Download apps/shared/OpenClawKit/Tests/OpenClawKitTests/ChatViewModelUnreadTests.swift from SaylorTwift/openclaw: direct link, hf CLI and curl.
- Browser
- Download file 26.8 kB
-
https://huggingface.co/SaylorTwift/openclaw/resolve/main/apps/shared/OpenClawKit/Tests/OpenClawKitTests/ChatViewModelUnreadTests.swift
- Command line
-
hf download hf://SaylorTwift/openclaw/apps/shared/OpenClawKit/Tests/OpenClawKitTests/ChatViewModelUnreadTests.swift
-
curl -L -o ChatViewModelUnreadTests.swift https://huggingface.co/SaylorTwift/openclaw/resolve/main/apps/shared/OpenClawKit/Tests/OpenClawKitTests/ChatViewModelUnreadTests.swift
26.8 kB
| import Foundation | |
| import OpenClawKit | |
| import Testing | |
| @testable import OpenClawChatUI | |
| private actor UnreadTestTransportState { | |
| var historyCalls = 0 | |
| var listCalls = 0 | |
| var unreadPatchAttempts: [(String, Bool)] = [] | |
| var unreadPatchStarts = 0 | |
| var sessionOverride: [OpenClawChatSessionEntry]? | |
| var historyFailuresRemaining: Int | |
| var patchFailuresRemaining: Int | |
| init(historyFailures: Int, patchFailures: Int) { | |
| self.historyFailuresRemaining = historyFailures | |
| self.patchFailuresRemaining = patchFailures | |
| } | |
| } | |
| private actor UnreadMutationRecorder { | |
| private(set) var events: [String] = [] | |
| func append(_ event: String) { | |
| self.events.append(event) | |
| } | |
| } | |
| private actor UnreadPatchGate { | |
| private var continuation: CheckedContinuation<Void, Never>? | |
| private var released = false | |
| func wait() async { | |
| guard !self.released else { return } | |
| await withCheckedContinuation { continuation in | |
| if self.released { | |
| continuation.resume() | |
| } else { | |
| self.continuation = continuation | |
| } | |
| } | |
| } | |
| func release() { | |
| self.released = true | |
| self.continuation?.resume() | |
| self.continuation = nil | |
| } | |
| } | |
| private final class UnreadTestTransport: @unchecked Sendable, OpenClawChatTransport { | |
| private let state: UnreadTestTransportState | |
| private let sessions: [OpenClawChatSessionEntry] | |
| private let respectsListLimit: Bool | |
| private let patchDelay: Duration? | |
| private let patchGate: UnreadPatchGate? | |
| init( | |
| sessions: [OpenClawChatSessionEntry], | |
| historyFailures: Int = 0, | |
| patchFailures: Int = 0, | |
| respectsListLimit: Bool = false, | |
| patchDelay: Duration? = nil, | |
| patchGate: UnreadPatchGate? = nil) | |
| { | |
| self.sessions = sessions | |
| self.respectsListLimit = respectsListLimit | |
| self.patchDelay = patchDelay | |
| self.patchGate = patchGate | |
| self.state = UnreadTestTransportState( | |
| historyFailures: historyFailures, | |
| patchFailures: patchFailures) | |
| } | |
| func requestHistory(sessionKey: String) async throws -> OpenClawChatHistoryPayload { | |
| await self.state.recordHistoryCall() | |
| if await self.state.consumeHistoryFailure() { | |
| throw NSError(domain: "UnreadTestTransport", code: 1) | |
| } | |
| return OpenClawChatHistoryPayload( | |
| sessionKey: sessionKey, | |
| sessionId: "session-\(sessionKey)", | |
| messages: [], | |
| thinkingLevel: "off") | |
| } | |
| func sendMessage( | |
| sessionKey _: String, | |
| message _: String, | |
| thinking _: String, | |
| idempotencyKey _: String, | |
| attachments _: [OpenClawChatAttachmentPayload]) async throws -> OpenClawChatSendResponse | |
| { | |
| throw NSError(domain: "UnreadTestTransport", code: 3) | |
| } | |
| func listSessions( | |
| limit: Int?, | |
| search _: String?, | |
| archived _: Bool) async throws -> OpenClawChatSessionsListResponse | |
| { | |
| await self.state.recordListCall() | |
| let sessions = await self.state.sessionOverride ?? self.sessions | |
| let listed = if self.respectsListLimit, let limit { | |
| Array(sessions.prefix(limit)) | |
| } else { | |
| sessions | |
| } | |
| return OpenClawChatSessionsListResponse( | |
| ts: nil, | |
| path: nil, | |
| count: listed.count, | |
| defaults: nil, | |
| sessions: listed) | |
| } | |
| func patchSession( | |
| key: String, | |
| expectedSessionID _: String?, | |
| label _: String??, | |
| category _: String??, | |
| color _: String?? = nil, | |
| pinned _: Bool?, | |
| archived _: Bool?, | |
| unread: Bool?) async throws | |
| { | |
| guard let unread else { return } | |
| await self.state.recordUnreadPatchStart() | |
| if let patchGate { | |
| await patchGate.wait() | |
| } else if let patchDelay { | |
| try await Task.sleep(for: patchDelay) | |
| } | |
| await self.state.recordUnreadPatch(key: key, unread: unread) | |
| if await self.state.consumePatchFailure() { | |
| throw NSError(domain: "UnreadTestTransport", code: 2) | |
| } | |
| } | |
| func requestHealth(timeoutMs _: Int) async throws -> Bool { | |
| true | |
| } | |
| func listModels(agentID _: String?) async throws -> [OpenClawChatModelChoice] { | |
| [] | |
| } | |
| func events() -> AsyncStream<OpenClawChatTransportEvent> { | |
| AsyncStream { $0.finish() } | |
| } | |
| func unreadPatchAttempts() async -> [(String, Bool)] { | |
| await self.state.unreadPatchAttempts | |
| } | |
| func historyCallCount() async -> Int { | |
| await self.state.historyCalls | |
| } | |
| func unreadPatchStartCount() async -> Int { | |
| await self.state.unreadPatchStarts | |
| } | |
| func listCallCount() async -> Int { | |
| await self.state.listCalls | |
| } | |
| func setSessions(_ sessions: [OpenClawChatSessionEntry]) async { | |
| await self.state.setSessions(sessions) | |
| } | |
| } | |
| extension UnreadTestTransportState { | |
| fileprivate func recordHistoryCall() { | |
| self.historyCalls += 1 | |
| } | |
| fileprivate func recordListCall() { | |
| self.listCalls += 1 | |
| } | |
| fileprivate func recordUnreadPatch(key: String, unread: Bool) { | |
| self.unreadPatchAttempts.append((key, unread)) | |
| } | |
| fileprivate func recordUnreadPatchStart() { | |
| self.unreadPatchStarts += 1 | |
| } | |
| fileprivate func consumeHistoryFailure() -> Bool { | |
| guard self.historyFailuresRemaining > 0 else { return false } | |
| self.historyFailuresRemaining -= 1 | |
| return true | |
| } | |
| fileprivate func consumePatchFailure() -> Bool { | |
| guard self.patchFailuresRemaining > 0 else { return false } | |
| self.patchFailuresRemaining -= 1 | |
| return true | |
| } | |
| fileprivate func setSessions(_ sessions: [OpenClawChatSessionEntry]) { | |
| self.sessionOverride = sessions | |
| } | |
| } | |
| (.serialized) | |
| struct ChatViewModelUnreadTests { | |
| func `successful activation clears unread once`() async throws { | |
| let transport = UnreadTestTransport(sessions: [self.entry(key: "a", unread: true)]) | |
| let viewModel = self.viewModel(sessionKey: "a", transport: transport) | |
| viewModel.load() | |
| try await self.waitForUnreadState("initial activation unread patch") { | |
| await transport.unreadPatchAttempts().count == 1 | |
| } | |
| viewModel.refresh() | |
| try await self.waitForUnreadState("refresh bootstrap settled") { | |
| await transport.listCallCount() >= 2 && !viewModel.isLoading | |
| } | |
| let attempts = await transport.unreadPatchAttempts() | |
| #expect(attempts.map(\.0) == ["a"]) | |
| #expect(attempts.map(\.1) == [false]) | |
| } | |
| func `main alias refresh does not rearm unread clearing`() async throws { | |
| let transport = UnreadTestTransport( | |
| sessions: [self.entry(key: "agent:alpha:main", unread: true)]) | |
| let viewModel = self.viewModel( | |
| sessionKey: "main", | |
| activeAgentID: "alpha", | |
| transport: transport) | |
| viewModel.load() | |
| try await self.waitForUnreadState("main alias activation unread patch") { | |
| await transport.unreadPatchAttempts().count == 1 | |
| } | |
| viewModel.refresh() | |
| try await self.waitForUnreadState("main alias refresh settled") { | |
| await transport.historyCallCount() >= 2 && !viewModel.isLoading | |
| } | |
| #expect(await transport.unreadPatchAttempts().count == 1) | |
| } | |
| func `cold main alias mark unread shares canonical activation identity`() async throws { | |
| let transport = UnreadTestTransport( | |
| sessions: [self.entry(key: "agent:alpha:main", unread: true)], | |
| patchDelay: .milliseconds(50)) | |
| let viewModel = self.viewModel( | |
| sessionKey: "main", | |
| activeAgentID: "alpha", | |
| transport: transport) | |
| viewModel.setSessionUnread(key: "main", unread: true) | |
| viewModel.load() | |
| try await self.waitForUnreadState("cold main alias unread mutation settled") { | |
| await transport.unreadPatchAttempts().count == 1 && !viewModel.isLoading | |
| } | |
| let attempts = await transport.unreadPatchAttempts() | |
| #expect(attempts.map(\.0) == ["main"]) | |
| #expect(attempts.map(\.1) == [true]) | |
| } | |
| func `failed history does not clear unread`() async throws { | |
| let transport = UnreadTestTransport( | |
| sessions: [self.entry(key: "a", unread: true)], | |
| historyFailures: 1) | |
| let viewModel = self.viewModel(sessionKey: "a", transport: transport) | |
| viewModel.load() | |
| try await self.waitForUnreadState("failed history surfaced") { | |
| !viewModel.isLoading && viewModel.errorText != nil | |
| } | |
| let attempts = await transport.unreadPatchAttempts() | |
| #expect(attempts.isEmpty) | |
| } | |
| func `failed unread patch retries on next activation`() async throws { | |
| let transport = UnreadTestTransport( | |
| sessions: [ | |
| self.entry(key: "a", unread: true), | |
| self.entry(key: "b", unread: false), | |
| ], | |
| patchFailures: 1) | |
| let viewModel = self.viewModel(sessionKey: "a", transport: transport) | |
| viewModel.load() | |
| try await self.waitForUnreadState("first failed unread patch recorded") { | |
| await transport.unreadPatchAttempts().count == 1 | |
| } | |
| viewModel.switchSession(to: "b") | |
| try await self.waitForUnreadState("switched to session b") { | |
| viewModel.sessionId == "session-b" | |
| } | |
| viewModel.switchSession(to: "a") | |
| try await self.waitForUnreadState("retry unread patch recorded") { | |
| await transport.unreadPatchAttempts().count == 2 | |
| } | |
| let attempts = await transport.unreadPatchAttempts() | |
| #expect(attempts.map(\.0) == ["a", "a"]) | |
| #expect(attempts.allSatisfy { !$0.1 }) | |
| } | |
| func `failed intermediate activation still rearms unread clearing`() async throws { | |
| let transport = UnreadTestTransport( | |
| sessions: [ | |
| self.entry(key: "a", unread: false), | |
| self.entry(key: "b", unread: false), | |
| ], | |
| historyFailures: 1) | |
| let viewModel = self.viewModel(sessionKey: "a", transport: transport) | |
| viewModel.refreshSessions() | |
| try await self.waitForUnreadState("initial session list loaded") { | |
| viewModel.sessions.count == 2 | |
| } | |
| viewModel.setSessionUnread(key: "a", unread: true) | |
| try await self.waitForUnreadState("explicit unread mutation recorded") { | |
| await transport.unreadPatchAttempts().count == 1 | |
| } | |
| await transport.setSessions([ | |
| self.entry(key: "a", unread: true), | |
| self.entry(key: "b", unread: false), | |
| ]) | |
| viewModel.switchSession(to: "b") | |
| try await self.waitForUnreadState("failed intermediate activation surfaced") { | |
| viewModel.errorText != nil | |
| } | |
| viewModel.switchSession(to: "a") | |
| try await self.waitForUnreadState("reactivated unread patch recorded") { | |
| await transport.unreadPatchAttempts().count == 2 | |
| } | |
| let attempts = await transport.unreadPatchAttempts() | |
| #expect(attempts.map(\.0) == ["a", "a"]) | |
| #expect(attempts.map(\.1) == [true, false]) | |
| } | |
| func `explicit mark unread rearms automatic clearing`() async throws { | |
| let transport = UnreadTestTransport(sessions: [ | |
| self.entry(key: "a", unread: true), | |
| self.entry(key: "b", unread: false), | |
| ]) | |
| let viewModel = self.viewModel(sessionKey: "a", transport: transport) | |
| viewModel.load() | |
| try await self.waitForUnreadState("initial activation read recorded") { | |
| await transport.unreadPatchAttempts().count == 1 | |
| } | |
| viewModel.setSessionUnread(key: "a", unread: true) | |
| try await self.waitForUnreadState("explicit unread mutation recorded") { | |
| await transport.unreadPatchAttempts().count == 2 | |
| } | |
| viewModel.switchSession(to: "b") | |
| try await self.waitForUnreadState("switched to session b") { | |
| viewModel.sessionId == "session-b" | |
| } | |
| viewModel.switchSession(to: "a") | |
| try await self.waitForUnreadState("reactivated unread patch recorded") { | |
| await transport.unreadPatchAttempts().count == 3 | |
| } | |
| let attempts = await transport.unreadPatchAttempts() | |
| #expect(attempts.map(\.1) == [false, true, false]) | |
| } | |
| func `marking background session unread does not consume its activation`() async throws { | |
| let transport = UnreadTestTransport(sessions: [ | |
| self.entry(key: "a", unread: false), | |
| self.entry(key: "b", unread: false), | |
| ]) | |
| let viewModel = self.viewModel(sessionKey: "a", transport: transport) | |
| viewModel.refreshSessions() | |
| try await self.waitForUnreadState("background session list loaded") { | |
| viewModel.sessions.count == 2 | |
| } | |
| viewModel.setSessionUnread(key: "b", unread: true) | |
| try await self.waitForUnreadState("background unread mutation recorded") { | |
| await transport.unreadPatchAttempts().count == 1 | |
| } | |
| await transport.setSessions([ | |
| self.entry(key: "a", unread: false), | |
| self.entry(key: "b", unread: true), | |
| ]) | |
| viewModel.switchSession(to: "b") | |
| try await self.waitForUnreadState("background activation read recorded") { | |
| await transport.unreadPatchAttempts().count == 2 | |
| } | |
| let attempts = await transport.unreadPatchAttempts() | |
| #expect(attempts.map(\.0) == ["b", "b"]) | |
| #expect(attempts.map(\.1) == [true, false]) | |
| } | |
| func `manual unread from another client survives refresh until reactivation`() async throws { | |
| let transport = UnreadTestTransport(sessions: [ | |
| self.entry(key: "a", unread: false), | |
| self.entry(key: "b", unread: false), | |
| ]) | |
| let viewModel = self.viewModel(sessionKey: "a", transport: transport) | |
| viewModel.load() | |
| try await self.waitForUnreadState("initial read session loaded") { | |
| !viewModel.isLoading && viewModel.sessionId == "session-a" | |
| } | |
| await transport.setSessions([ | |
| self.entry(key: "a", unread: true, markedUnreadAt: 100), | |
| self.entry(key: "b", unread: false), | |
| ]) | |
| viewModel.refresh() | |
| try await self.waitForUnreadState("manual unread refresh settled") { | |
| await transport.historyCallCount() >= 2 && !viewModel.isLoading | |
| } | |
| #expect(await transport.unreadPatchAttempts().isEmpty) | |
| viewModel.switchSession(to: "b") | |
| try await self.waitForUnreadState("other session activated") { | |
| viewModel.sessionId == "session-b" | |
| } | |
| viewModel.switchSession(to: "a") | |
| try await self.waitForUnreadState("manual unread acknowledged on reactivation") { | |
| await transport.unreadPatchAttempts().count == 1 | |
| } | |
| let attempts = await transport.unreadPatchAttempts() | |
| #expect(attempts.map(\.0) == ["a"]) | |
| #expect(attempts.map(\.1) == [false]) | |
| } | |
| func `newer manual unread remains visible when activation acknowledgement loses race`() async throws { | |
| let patchGate = UnreadPatchGate() | |
| let transport = UnreadTestTransport( | |
| sessions: [self.entry(key: "a", unread: true, markedUnreadAt: 100)], | |
| patchGate: patchGate) | |
| let viewModel = self.viewModel(sessionKey: "a", transport: transport) | |
| viewModel.load() | |
| try await self.waitForUnreadState("activation acknowledgement started") { | |
| await transport.unreadPatchStartCount() == 1 | |
| } | |
| await transport.setSessions([ | |
| self.entry(key: "a", unread: true, markedUnreadAt: 101), | |
| ]) | |
| await patchGate.release() | |
| try await self.waitForUnreadState("authoritative unread refresh applied") { | |
| await transport.listCallCount() >= 2 && | |
| viewModel.sessions.first(where: { $0.key == "a" })?.markedUnreadAt == 101 | |
| } | |
| let session = try #require(viewModel.sessions.first(where: { $0.key == "a" })) | |
| #expect(session.unread == true) | |
| #expect(session.markedUnreadAt == 101) | |
| } | |
| func `successful off-list mark read records read confirmation`() async throws { | |
| let transport = UnreadTestTransport(sessions: []) | |
| let viewModel = self.viewModel(sessionKey: "a", transport: transport) | |
| viewModel.setSessionUnread(key: "hidden", unread: false) | |
| try await self.waitForUnreadState("off-list read confirmation recorded") { | |
| viewModel.unreadPatchGuard.confirmedUnread(key: "hidden") == false | |
| } | |
| #expect(viewModel.unreadPatchGuard.confirmedUnread(key: "hidden") == false) | |
| } | |
| func `failed route lease preserves mutation queue ordering`() async throws { | |
| let recorder = UnreadMutationRecorder() | |
| let queue = ChatSessionUnreadMutationQueue() | |
| let firstLease = OpenClawChatSessionMutationRouteLease { _, _, _, _, _, _, _, _, _ in | |
| await recorder.append("first-start") | |
| try await Task.sleep(for: .milliseconds(100)) | |
| await recorder.append("first-end") | |
| } | |
| let thirdLease = OpenClawChatSessionMutationRouteLease { _, _, _, _, _, _, _, _, _ in | |
| await recorder.append("third") | |
| } | |
| let first = queue.reserve( | |
| routeLease: Task<OpenClawChatSessionMutationRouteLease?, Never> { firstLease }, | |
| queueKey: "a", | |
| routeKey: "a", | |
| unread: false) | |
| let second = queue.reserve( | |
| routeLease: Task<OpenClawChatSessionMutationRouteLease?, Never> { nil }, | |
| queueKey: "a", | |
| routeKey: "a", | |
| unread: true) | |
| let third = queue.reserve( | |
| routeLease: Task<OpenClawChatSessionMutationRouteLease?, Never> { thirdLease }, | |
| queueKey: "a", | |
| routeKey: "a", | |
| unread: false) | |
| try await first.value | |
| await #expect(throws: OpenClawChatTransportSendError.self) { | |
| try await second.value | |
| } | |
| try await third.value | |
| #expect(await recorder.events == ["first-start", "first-end", "third"]) | |
| } | |
| func `activation clears selected session outside refresh page`() async throws { | |
| let recent = (0..<50).map { index in | |
| self.entry(key: "recent-\(index)", unread: false, updatedAt: Double(100 - index)) | |
| } | |
| let selected = self.entry(key: "old", unread: true, updatedAt: 1) | |
| let transport = UnreadTestTransport( | |
| sessions: recent + [selected], | |
| respectsListLimit: true) | |
| let viewModel = self.viewModel(sessionKey: "old", transport: transport) | |
| viewModel.refreshSessions(limit: 200) | |
| try await self.waitForUnreadState("selected off-page session loaded") { | |
| viewModel.sessions.contains { $0.key == "old" } | |
| } | |
| viewModel.load() | |
| try await self.waitForUnreadState("off-page activation read recorded") { | |
| await transport.unreadPatchAttempts().count == 1 | |
| } | |
| let attempts = await transport.unreadPatchAttempts() | |
| #expect(attempts.map(\.0) == ["old"]) | |
| #expect(attempts.map(\.1) == [false]) | |
| } | |
| func `failed unread patch refreshes authoritative session state`() async throws { | |
| let transport = UnreadTestTransport( | |
| sessions: [ | |
| self.entry(key: "a", unread: true), | |
| self.entry(key: "b", unread: false), | |
| ], | |
| patchFailures: 1, | |
| patchDelay: .milliseconds(50)) | |
| let viewModel = self.viewModel(sessionKey: "b", transport: transport) | |
| viewModel.refreshSessions() | |
| try await self.waitForUnreadState("authoritative session list loaded") { | |
| viewModel.sessions.count == 2 | |
| } | |
| await transport.setSessions([ | |
| self.entry(key: "a", unread: true), | |
| self.entry(key: "b", unread: false, pinned: true), | |
| ]) | |
| viewModel.setSessionUnread(key: "a", unread: false) | |
| let otherIndex = try #require(viewModel.sessions.firstIndex(where: { $0.key == "b" })) | |
| viewModel.sessions[otherIndex].pinned = true | |
| try await self.waitForUnreadState("unread mutation failure surfaced") { | |
| viewModel.errorText != nil | |
| } | |
| try await self.waitForUnreadState("failure refresh completed") { | |
| await transport.listCallCount() >= 2 | |
| } | |
| #expect(viewModel.sessions.first(where: { $0.key == "a" })?.unread == true) | |
| #expect(viewModel.sessions.first(where: { $0.key == "b" })?.pinned == true) | |
| } | |
| func `mark unread wins over an older activation read`() async throws { | |
| let transport = UnreadTestTransport( | |
| sessions: [self.entry(key: "a", unread: true)], | |
| patchDelay: .milliseconds(50)) | |
| let viewModel = self.viewModel(sessionKey: "a", transport: transport) | |
| viewModel.load() | |
| try await self.waitForUnreadState("activation read started") { | |
| await transport.unreadPatchStartCount() == 1 | |
| } | |
| viewModel.setSessionUnread(key: "a", unread: true) | |
| try await self.waitForUnreadState("explicit unread mutation completed") { | |
| await transport.unreadPatchAttempts().count == 2 | |
| } | |
| let attempts = await transport.unreadPatchAttempts() | |
| #expect(attempts.map(\.1) == [false, true]) | |
| #expect(viewModel.sessions.first(where: { $0.key == "a" })?.unread == true) | |
| } | |
| func `stale list does not undo pending explicit unread`() async throws { | |
| let transport = UnreadTestTransport( | |
| sessions: [self.entry(key: "a", unread: false)], | |
| patchDelay: .milliseconds(50)) | |
| let viewModel = self.viewModel(sessionKey: "a", transport: transport) | |
| viewModel.refreshSessions() | |
| try await self.waitForUnreadState("initial session list loaded") { | |
| viewModel.sessions.count == 1 | |
| } | |
| viewModel.setSessionUnread(key: "a", unread: true) | |
| viewModel.refreshSessions() | |
| try await self.waitForUnreadState("explicit unread mutation completed") { | |
| await transport.unreadPatchAttempts().count == 1 | |
| } | |
| await transport.setSessions([self.entry(key: "a", unread: true)]) | |
| viewModel.load() | |
| try await self.waitForUnreadState("reactivated session loaded") { | |
| !viewModel.isLoading && viewModel.sessionId == "session-a" | |
| } | |
| let attempts = await transport.unreadPatchAttempts() | |
| #expect(attempts.map(\.1) == [true]) | |
| } | |
| func `pending explicit unread overlays stale list until fresh observation`() async throws { | |
| let patchGate = UnreadPatchGate() | |
| let transport = UnreadTestTransport( | |
| sessions: [ | |
| self.entry(key: "a", unread: false), | |
| self.entry(key: "b", unread: false), | |
| ], | |
| patchGate: patchGate) | |
| let viewModel = self.viewModel(sessionKey: "b", transport: transport) | |
| viewModel.refreshSessions() | |
| try await self.waitForUnreadState("pending unread session list loaded") { | |
| viewModel.sessions.count == 2 | |
| } | |
| viewModel.setSessionUnread(key: "a", unread: true) | |
| try await self.waitForUnreadState("unread patch started") { | |
| await transport.unreadPatchStartCount() == 1 | |
| } | |
| viewModel.refreshSessions() | |
| try await self.waitForUnreadState("stale list refresh completed") { | |
| await transport.listCallCount() >= 2 | |
| } | |
| #expect(viewModel.sessions.first(where: { $0.key == "a" })?.unread == true) | |
| #expect(viewModel.unreadPatchGuard.localUnreadOverride(key: "a") == true) | |
| await patchGate.release() | |
| await transport.setSessions([ | |
| self.entry(key: "a", unread: true), | |
| self.entry(key: "b", unread: false), | |
| ]) | |
| let listCallCount = await transport.listCallCount() | |
| try await self.waitForUnreadState("unread patch completed") { | |
| await transport.unreadPatchAttempts().count == 1 | |
| } | |
| try await self.waitForUnreadState("fresh unread observation applied") { | |
| await transport.listCallCount() > listCallCount && | |
| viewModel.unreadPatchGuard.localUnreadOverride(key: "a") == nil | |
| } | |
| #expect(viewModel.unreadPatchGuard.localUnreadOverride(key: "a") == nil) | |
| #expect(viewModel.unreadPatchGuard.confirmedUnread(key: "a") == true) | |
| #expect(viewModel.sessions.first(where: { $0.key == "a" })?.unread == true) | |
| } | |
| private func viewModel( | |
| sessionKey: String, | |
| activeAgentID: String? = nil, | |
| transport: UnreadTestTransport) -> OpenClawChatViewModel | |
| { | |
| let defaults = UserDefaults(suiteName: "ChatViewModelUnreadTests.\(UUID().uuidString)") ?? .standard | |
| return OpenClawChatViewModel( | |
| sessionKey: sessionKey, | |
| transport: transport, | |
| activeAgentId: activeAgentID, | |
| modelPickerStore: ChatModelPickerStore(defaults: defaults)) | |
| } | |
| private func entry( | |
| key: String, | |
| unread: Bool, | |
| updatedAt: Double = 1, | |
| markedUnreadAt: Double? = nil, | |
| lastInteractionAt: Double? = nil, | |
| lastActivityAt: Double? = nil, | |
| pinned: Bool? = nil) -> OpenClawChatSessionEntry | |
| { | |
| OpenClawChatSessionEntry( | |
| key: key, | |
| kind: nil, | |
| displayName: nil, | |
| surface: nil, | |
| subject: nil, | |
| room: nil, | |
| space: nil, | |
| updatedAt: updatedAt, | |
| sessionId: nil, | |
| systemSent: nil, | |
| abortedLastRun: nil, | |
| thinkingLevel: nil, | |
| verboseLevel: nil, | |
| inputTokens: nil, | |
| outputTokens: nil, | |
| totalTokens: nil, | |
| modelProvider: nil, | |
| model: nil, | |
| contextTokens: nil, | |
| pinned: pinned, | |
| unread: unread, | |
| markedUnreadAt: markedUnreadAt, | |
| lastInteractionAt: lastInteractionAt, | |
| lastActivityAt: lastActivityAt) | |
| } | |
| private func waitForUnreadState( | |
| _ label: String, | |
| condition: @escaping () async -> Bool) async throws | |
| { | |
| try await waitUntil(label) { | |
| await condition() | |
| } | |
| } | |
| } | |