- Implement MailRootView with navigation and sidebar for mail management. - Create MailSidebarView, ThreadListView, and ThreadDetailView for displaying mail content. - Introduce ComposeView for composing new messages. - Add MailTheme for consistent styling across mail components. - Implement adaptive layouts for iOS and macOS. - Create unit tests for AppNavigationCommand and AppViewModel to ensure correct functionality.
250 lines
8.1 KiB
Swift
250 lines
8.1 KiB
Swift
import XCTest
|
|
@testable import SocialIO
|
|
|
|
final class AppViewModelTests: XCTestCase {
|
|
@MainActor
|
|
func testFilteredThreadsRespectMailboxUnreadAndSearch() async throws {
|
|
let inboxUnread = makeThread(
|
|
routeID: "inbox-unread",
|
|
mailbox: .inbox,
|
|
subject: "Roadmap review",
|
|
body: "Please review the roadmap before launch.",
|
|
isUnread: true,
|
|
isStarred: false,
|
|
sentAt: .now
|
|
)
|
|
let inboxRead = makeThread(
|
|
routeID: "inbox-read",
|
|
mailbox: .inbox,
|
|
subject: "Budget sync",
|
|
body: "Closing the budget sync loop.",
|
|
isUnread: false,
|
|
isStarred: false,
|
|
sentAt: .now.addingTimeInterval(-60)
|
|
)
|
|
let archivedStarred = makeThread(
|
|
routeID: "archived-starred",
|
|
mailbox: .archive,
|
|
subject: "Archived roadmap notes",
|
|
body: "Keeping the roadmap context around.",
|
|
isUnread: true,
|
|
isStarred: true,
|
|
sentAt: .now.addingTimeInterval(-120)
|
|
)
|
|
let model = AppViewModel(
|
|
service: StubMailService(threadsToLoad: [inboxRead, archivedStarred, inboxUnread]),
|
|
controlService: StubControlService()
|
|
)
|
|
|
|
await model.load()
|
|
XCTAssertEqual(model.filteredThreads.map(\.routeID), ["inbox-unread", "inbox-read"])
|
|
|
|
model.setUnreadOnly(true)
|
|
XCTAssertEqual(model.filteredThreads.map(\.routeID), ["inbox-unread"])
|
|
|
|
model.selectMailbox(.starred)
|
|
XCTAssertEqual(model.filteredThreads.map(\.routeID), ["archived-starred"])
|
|
|
|
model.setSearchText("context")
|
|
XCTAssertEqual(model.filteredThreads.map(\.routeID), ["archived-starred"])
|
|
|
|
model.setSearchText("launch")
|
|
XCTAssertTrue(model.filteredThreads.isEmpty)
|
|
}
|
|
|
|
@MainActor
|
|
func testPendingThreadCommandAppliesAfterLoad() async throws {
|
|
let thread = makeThread(
|
|
routeID: "launch-copy",
|
|
mailbox: .inbox,
|
|
subject: "Launch copy",
|
|
body: "The second pass is ready.",
|
|
isUnread: true,
|
|
isStarred: false,
|
|
sentAt: .now,
|
|
messageRouteID: "launch-copy-2"
|
|
)
|
|
let model = AppViewModel(
|
|
service: StubMailService(threadsToLoad: [thread]),
|
|
controlService: StubControlService()
|
|
)
|
|
|
|
model.apply(
|
|
command: .thread(
|
|
threadRouteID: "launch-copy",
|
|
mailbox: .inbox,
|
|
messageRouteID: "launch-copy-2",
|
|
search: nil,
|
|
unreadOnly: nil
|
|
)
|
|
)
|
|
|
|
XCTAssertNil(model.selectedThread)
|
|
|
|
await model.load()
|
|
|
|
XCTAssertEqual(model.selectedThread?.routeID, "launch-copy")
|
|
XCTAssertEqual(model.focusedMessageRouteID, "launch-copy-2")
|
|
XCTAssertEqual(model.selectedMailbox, .inbox)
|
|
}
|
|
|
|
@MainActor
|
|
func testSendCurrentDraftSuccessClosesComposeAndSelectsSentThread() async throws {
|
|
let sentThread = makeThread(
|
|
routeID: "sent-1",
|
|
mailbox: .sent,
|
|
subject: "Status",
|
|
body: "Sent body",
|
|
isUnread: false,
|
|
isStarred: false,
|
|
sentAt: .now
|
|
)
|
|
let service = StubMailService(threadsToLoad: [], sendResult: .success(sentThread))
|
|
let model = AppViewModel(service: service, controlService: StubControlService())
|
|
let draft = ComposeDraft(to: "team@social.io", subject: "Status", body: "Sent body")
|
|
model.composeDraft = draft
|
|
model.isComposing = true
|
|
|
|
let didSend = await model.sendCurrentDraft()
|
|
|
|
XCTAssertTrue(didSend)
|
|
XCTAssertEqual(service.sentDrafts, [draft])
|
|
XCTAssertFalse(model.isComposing)
|
|
XCTAssertFalse(model.isSending)
|
|
XCTAssertEqual(model.selectedMailbox, .sent)
|
|
XCTAssertEqual(model.selectedThread?.routeID, "sent-1")
|
|
XCTAssertEqual(model.threads.first?.routeID, "sent-1")
|
|
}
|
|
|
|
@MainActor
|
|
func testSendCurrentDraftFailureKeepsComposeOpenAndPreservesDraft() async throws {
|
|
let service = StubMailService(threadsToLoad: [], sendResult: .failure(TestError.sendFailed))
|
|
let model = AppViewModel(service: service, controlService: StubControlService())
|
|
let draft = ComposeDraft(to: "team@social.io", subject: "Status", body: "Body")
|
|
model.composeDraft = draft
|
|
model.isComposing = true
|
|
|
|
let didSend = await model.sendCurrentDraft()
|
|
|
|
XCTAssertFalse(didSend)
|
|
XCTAssertEqual(service.sentDrafts, [draft])
|
|
XCTAssertTrue(model.isComposing)
|
|
XCTAssertFalse(model.isSending)
|
|
XCTAssertEqual(model.composeDraft, draft)
|
|
XCTAssertEqual(model.errorMessage, "Unable to send message.")
|
|
XCTAssertTrue(model.threads.isEmpty)
|
|
}
|
|
|
|
@MainActor
|
|
func testBeginBackendControlCanRestartAfterPreviousStreamFinishes() async throws {
|
|
let model = AppViewModel(
|
|
service: StubMailService(threadsToLoad: [
|
|
makeThread(
|
|
routeID: "launch-copy",
|
|
mailbox: .inbox,
|
|
subject: "Launch copy",
|
|
body: "Mail body",
|
|
isUnread: true,
|
|
isStarred: false,
|
|
sentAt: .now
|
|
)
|
|
]),
|
|
controlService: StubControlService(commandsPerCall: [
|
|
[.mailbox(mailbox: .archive, search: nil, unreadOnly: true)],
|
|
[.mailbox(mailbox: .starred, search: "roadmap", unreadOnly: false)]
|
|
])
|
|
)
|
|
|
|
await model.load()
|
|
await model.beginBackendControl()
|
|
|
|
XCTAssertEqual(model.selectedMailbox, .archive)
|
|
XCTAssertTrue(model.showUnreadOnly)
|
|
|
|
await model.beginBackendControl()
|
|
|
|
XCTAssertEqual(model.selectedMailbox, .starred)
|
|
XCTAssertEqual(model.searchText, "roadmap")
|
|
XCTAssertFalse(model.showUnreadOnly)
|
|
}
|
|
}
|
|
|
|
private enum TestError: Error {
|
|
case sendFailed
|
|
}
|
|
|
|
private final class StubMailService: MailServicing {
|
|
private let threadsToLoad: [MailThread]
|
|
private let sendResult: Result<MailThread, Error>
|
|
private(set) var sentDrafts: [ComposeDraft] = []
|
|
|
|
init(threadsToLoad: [MailThread], sendResult: Result<MailThread, Error> = .failure(TestError.sendFailed)) {
|
|
self.threadsToLoad = threadsToLoad
|
|
self.sendResult = sendResult
|
|
}
|
|
|
|
func loadThreads() async throws -> [MailThread] {
|
|
threadsToLoad
|
|
}
|
|
|
|
func send(draft: ComposeDraft) async throws -> MailThread {
|
|
sentDrafts.append(draft)
|
|
return try sendResult.get()
|
|
}
|
|
}
|
|
|
|
private final class StubControlService: AppControlServicing {
|
|
private let commandsPerCall: [[AppNavigationCommand]]
|
|
private var callCount = 0
|
|
|
|
init(commandsPerCall: [[AppNavigationCommand]] = []) {
|
|
self.commandsPerCall = commandsPerCall
|
|
}
|
|
|
|
func commands() -> AsyncStream<AppNavigationCommand> {
|
|
let commands = callCount < commandsPerCall.count ? commandsPerCall[callCount] : []
|
|
callCount += 1
|
|
|
|
return AsyncStream { continuation in
|
|
for command in commands {
|
|
continuation.yield(command)
|
|
}
|
|
|
|
continuation.finish()
|
|
}
|
|
}
|
|
}
|
|
|
|
private func makeThread(
|
|
routeID: String,
|
|
mailbox: Mailbox,
|
|
subject: String,
|
|
body: String,
|
|
isUnread: Bool,
|
|
isStarred: Bool,
|
|
sentAt: Date,
|
|
messageRouteID: String? = nil
|
|
) -> MailThread {
|
|
let sender = MailPerson(name: "Sender", email: "sender@social.io")
|
|
let recipient = MailPerson(name: "Recipient", email: "recipient@social.io")
|
|
|
|
return MailThread(
|
|
routeID: routeID,
|
|
mailbox: mailbox,
|
|
subject: subject,
|
|
participants: [sender, recipient],
|
|
messages: [
|
|
MailMessage(
|
|
routeID: messageRouteID ?? "\(routeID)-message",
|
|
sender: sender,
|
|
recipients: [recipient],
|
|
sentAt: sentAt,
|
|
body: body
|
|
)
|
|
],
|
|
isUnread: isUnread,
|
|
isStarred: isStarred,
|
|
tags: []
|
|
)
|
|
}
|