Files
openclaw/apps/shared/OpenClawKit/Tests/OpenClawKitTests/ChatViewModelUnreadTests.swift
2026-07-17 21:55:27 +01:00

556 lines
21 KiB
Swift

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 final class UnreadTestTransport: @unchecked Sendable, OpenClawChatTransport {
private let state: UnreadTestTransportState
private let sessions: [OpenClawChatSessionEntry]
private let respectsListLimit: Bool
private let patchDelay: Duration?
init(
sessions: [OpenClawChatSessionEntry],
historyFailures: Int = 0,
patchFailures: Int = 0,
respectsListLimit: Bool = false,
patchDelay: Duration? = nil)
{
self.sessions = sessions
self.respectsListLimit = respectsListLimit
self.patchDelay = patchDelay
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,
label _: String??,
category _: String??,
pinned _: Bool?,
archived _: Bool?,
unread: Bool?) async throws
{
guard let unread else { return }
await self.state.recordUnreadPatchStart()
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() 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
}
}
@MainActor
struct ChatViewModelUnreadTests {
@Test 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.waitUntil { await transport.unreadPatchAttempts().count == 1 }
viewModel.refresh()
try await self.waitUntil { await transport.listCallCount() >= 2 && !viewModel.isLoading }
let attempts = await transport.unreadPatchAttempts()
#expect(attempts.map(\.0) == ["a"])
#expect(attempts.map(\.1) == [false])
}
@Test 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.waitUntil { await transport.unreadPatchAttempts().count == 1 }
viewModel.refresh()
try await self.waitUntil { await transport.historyCallCount() >= 2 && !viewModel.isLoading }
#expect(await transport.unreadPatchAttempts().count == 1)
}
@Test 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.waitUntil { await transport.unreadPatchAttempts().count == 1 && !viewModel.isLoading }
let attempts = await transport.unreadPatchAttempts()
#expect(attempts.map(\.0) == ["main"])
#expect(attempts.map(\.1) == [true])
}
@Test 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.waitUntil { !viewModel.isLoading && viewModel.errorText != nil }
let attempts = await transport.unreadPatchAttempts()
#expect(attempts.isEmpty)
}
@Test 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.waitUntil { await transport.unreadPatchAttempts().count == 1 }
viewModel.switchSession(to: "b")
try await self.waitUntil { viewModel.sessionId == "session-b" }
viewModel.switchSession(to: "a")
try await self.waitUntil { await transport.unreadPatchAttempts().count == 2 }
let attempts = await transport.unreadPatchAttempts()
#expect(attempts.map(\.0) == ["a", "a"])
#expect(attempts.allSatisfy { !$0.1 })
}
@Test 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.waitUntil { viewModel.sessions.count == 2 }
viewModel.setSessionUnread(key: "a", unread: true)
try await self.waitUntil { 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.waitUntil { viewModel.errorText != nil }
viewModel.switchSession(to: "a")
try await self.waitUntil { await transport.unreadPatchAttempts().count == 2 }
let attempts = await transport.unreadPatchAttempts()
#expect(attempts.map(\.0) == ["a", "a"])
#expect(attempts.map(\.1) == [true, false])
}
@Test 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.waitUntil { await transport.unreadPatchAttempts().count == 1 }
viewModel.setSessionUnread(key: "a", unread: true)
try await self.waitUntil { await transport.unreadPatchAttempts().count == 2 }
viewModel.switchSession(to: "b")
try await self.waitUntil { viewModel.sessionId == "session-b" }
viewModel.switchSession(to: "a")
try await self.waitUntil { await transport.unreadPatchAttempts().count == 3 }
let attempts = await transport.unreadPatchAttempts()
#expect(attempts.map(\.1) == [false, true, false])
}
@Test 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.waitUntil { viewModel.sessions.count == 2 }
viewModel.setSessionUnread(key: "b", unread: true)
try await self.waitUntil { 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.waitUntil { await transport.unreadPatchAttempts().count == 2 }
let attempts = await transport.unreadPatchAttempts()
#expect(attempts.map(\.0) == ["b", "b"])
#expect(attempts.map(\.1) == [true, false])
}
@Test 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.waitUntil {
viewModel.unreadPatchGuard.confirmedUnread(key: "hidden") == false
}
#expect(viewModel.unreadPatchGuard.confirmedUnread(key: "hidden") == false)
}
@Test 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"])
}
@Test 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.waitUntil { viewModel.sessions.contains { $0.key == "old" } }
viewModel.load()
try await self.waitUntil { await transport.unreadPatchAttempts().count == 1 }
let attempts = await transport.unreadPatchAttempts()
#expect(attempts.map(\.0) == ["old"])
#expect(attempts.map(\.1) == [false])
}
@Test 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.waitUntil { 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.waitUntil { viewModel.errorText != nil }
try await self.waitUntil { await transport.listCallCount() >= 2 }
#expect(viewModel.sessions.first(where: { $0.key == "a" })?.unread == true)
#expect(viewModel.sessions.first(where: { $0.key == "b" })?.pinned == true)
}
@Test 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.waitUntil { await transport.unreadPatchStartCount() == 1 }
viewModel.setSessionUnread(key: "a", unread: true)
try await self.waitUntil { 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)
}
@Test 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.waitUntil { viewModel.sessions.count == 1 }
viewModel.setSessionUnread(key: "a", unread: true)
viewModel.refreshSessions()
try await self.waitUntil { await transport.unreadPatchAttempts().count == 1 }
await transport.setSessions([self.entry(key: "a", unread: true)])
viewModel.load()
try await self.waitUntil { !viewModel.isLoading && viewModel.sessionId == "session-a" }
let attempts = await transport.unreadPatchAttempts()
#expect(attempts.map(\.1) == [true])
}
@Test func `pending explicit unread overlays stale list until fresh observation`() async throws {
let transport = UnreadTestTransport(
sessions: [
self.entry(key: "a", unread: false),
self.entry(key: "b", unread: false),
],
patchDelay: .milliseconds(200))
let viewModel = self.viewModel(sessionKey: "b", transport: transport)
viewModel.refreshSessions()
try await self.waitUntil { viewModel.sessions.count == 2 }
viewModel.setSessionUnread(key: "a", unread: true)
try await self.waitUntil { await transport.unreadPatchStartCount() == 1 }
viewModel.refreshSessions()
try await self.waitUntil { await transport.listCallCount() >= 2 }
#expect(viewModel.sessions.first(where: { $0.key == "a" })?.unread == true)
#expect(viewModel.unreadPatchGuard.localUnreadOverride(key: "a") == true)
await transport.setSessions([
self.entry(key: "a", unread: true),
self.entry(key: "b", unread: false),
])
let listCallCount = await transport.listCallCount()
try await self.waitUntil { await transport.unreadPatchAttempts().count == 1 }
try await self.waitUntil {
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,
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,
lastInteractionAt: lastInteractionAt,
lastActivityAt: lastActivityAt)
}
private func waitUntil(
timeout: Duration = .seconds(5),
condition: @escaping @MainActor () async -> Bool) async throws
{
let clock = ContinuousClock()
let deadline = clock.now.advanced(by: timeout)
while clock.now < deadline {
if await condition() { return }
try await Task.sleep(for: .milliseconds(10))
}
Issue.record("timed out waiting for unread test condition")
}
}