Files
swiftapp/swift/Tests/AppViewModelTests.swift
Jürgen Kunz ad059e9b8d Add MailRootView and related components for mail functionality
- 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.
2026-04-19 01:00:32 +02:00

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: []
)
}