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 private(set) var sentDrafts: [ComposeDraft] = [] init(threadsToLoad: [MailThread], sendResult: Result = .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 { 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: [] ) }