import APIClient import Foundation import HostRegistry import TestSupport import Testing import WireProtocol @testable import WebTerm /// T-iOS-13 · SessionListViewModel (merged chooser + dashboard, plan §7). /// /// Covers the task RED list end to end, deterministically (zero real waits): /// - poll cadence on `FakeClock` (`waitForSleepers` barrier + cancellation /// leak-check on leave), /// - status dot mapping for all 5 states + the `pending:true` ⚠ badge priority /// (overlay — `/live-sessions` carries NO pending field, src/types.ts:246-256), /// - telemetry staleness at the exact `Tunables.telemetryStaleTtlMs` boundary /// via an injected now-provider, /// - swipe-to-kill: optimistic removal proven BEFORE the server replies (gated /// transport), Origin stamped on the DELETE, rollback on failure, /// - server order kept with `exited:true` grouped at the bottom, /// - "+ New session" navigation signal carrying `sessionId: nil`. /// /// All HTTP goes through the REAL `APIClient` over `FakeHTTPTransport` /// (task brief: LiveSessionInfo decode exists — exercise it, don't stub rows). @MainActor @Suite("SessionListViewModel") struct SessionListViewModelTests { private nonisolated static let base = "http://192.168.1.5:3000" /// Fixed "now" injected into the VM — staleness must be deterministic. private nonisolated static let nowMs = 1_700_000_100_000 // MARK: - Fixtures private func makeHost( _ urlString: String = SessionListViewModelTests.base, name: String = "书房 Mac" ) throws -> HostRegistry.Host { let url = try #require(URL(string: urlString)) let endpoint = try #require(HostEndpoint(baseURL: url)) return HostRegistry.Host(id: UUID(), name: name, endpoint: endpoint) } private func listURL(_ base: String = SessionListViewModelTests.base) throws -> URL { try #require(URL(string: "\(base)/live-sessions")) } private func deleteURL( id: UUID, base: String = SessionListViewModelTests.base ) throws -> URL { try #require(URL(string: "\(base)/live-sessions/\(id.uuidString.lowercased())")) } /// One `/live-sessions` entry in the server's exact JSON shape. private func sessionJSON( id: UUID, createdAt: Int = 1_700_000_000_000, clientCount: Int = 1, status: String = "working", exited: Bool = false, cwd: String? = "/Users/dev/proj", cols: Int = 80, rows: Int = 24, telemetry: String? = nil ) -> String { let cwdJSON = cwd.map { "\"\($0)\"" } ?? "null" let telemetryJSON = telemetry.map { ",\"telemetry\":\($0)" } ?? "" return "{\"id\":\"\(id.uuidString.lowercased())\",\"createdAt\":\(createdAt)" + ",\"clientCount\":\(clientCount),\"status\":\"\(status)\",\"exited\":\(exited)" + ",\"cwd\":\(cwdJSON),\"cols\":\(cols),\"rows\":\(rows)\(telemetryJSON)}" } private func listBody(_ entries: [String]) -> Data { Data("[\(entries.joined(separator: ","))]".utf8) } private func makeViewModel( hosts: [HostRegistry.Host], http: any HTTPTransport, clock: any Clock = FakeClock(), nowMs: @escaping @Sendable () -> Int = { SessionListViewModelTests.nowMs } ) -> SessionListViewModel { SessionListViewModel( hostStore: InMemoryHostStore(hosts: hosts), http: http, clock: clock, nowMs: nowMs ) } /// Loads hosts + performs one manual fetch — the no-polling arrangement /// used by every test that does not exercise the poll loop itself. private func loadOnce(_ viewModel: SessionListViewModel) async { await viewModel.reloadHosts() await viewModel.refresh() } private struct StoreFailure: Error {} private actor ThrowingHostStore: HostStore { func loadAll() async throws -> [HostRegistry.Host] { throw StoreFailure() } func upsert(_ host: HostRegistry.Host) async throws -> [HostRegistry.Host] { [] } func remove(id: UUID) async throws -> [HostRegistry.Host] { [] } } /// HTTPTransport wrapper that parks requests of one method until the test /// opens the gate — the deterministic way to observe the OPTIMISTIC state /// while the DELETE is still in flight. private actor GatedHTTPTransport: HTTPTransport { private let inner: FakeHTTPTransport private let gatedMethod: String private var isOpen = false private var gatedArrivalCount = 0 private var gateOpeners: [CheckedContinuation] = [] private var arrivalWaiters: [CheckedContinuation] = [] init(inner: FakeHTTPTransport, gatedMethod: String) { self.inner = inner self.gatedMethod = gatedMethod.uppercased() } func send(_ request: URLRequest) async throws -> (Data, HTTPURLResponse) { if (request.httpMethod ?? "GET").uppercased() == gatedMethod { gatedArrivalCount += 1 for waiter in arrivalWaiters { waiter.resume() } arrivalWaiters = [] if !isOpen { await withCheckedContinuation { gateOpeners.append($0) } } } return try await inner.send(request) } /// Suspends until at least one gated request has arrived (and parked). func waitForGatedArrival() async { guard gatedArrivalCount == 0 else { return } await withCheckedContinuation { arrivalWaiters.append($0) } } func openGate() { isOpen = true for opener in gateOpeners { opener.resume() } gateOpeners = [] } } // MARK: - Poll cadence + leave stops cleanly (task RED item 1) @Test("visible: polls /live-sessions every listPollInterval; leaving cancels the parked sleeper — no leaked task, zero further traffic") func pollingCadenceAndLeaveStopsCleanly() async throws { // Arrange: 3 queued list responses — one per expected poll tick. let clock = FakeClock() let http = FakeHTTPTransport() let host = try makeHost() let url = try listURL() let sessionId = UUID() for _ in 0..<3 { await http.queueSuccess(url: url, body: listBody([sessionJSON(id: sessionId)])) } let viewModel = makeViewModel(hosts: [host], http: http, clock: clock) // Act: appear (twice — idempotence: no second poll loop may spawn). viewModel.appeared() viewModel.appeared() await viewModel.waitUntilFetches(count: 1) // Assert: first fetch landed and rows are decoded LiveSessionInfo. #expect(viewModel.rows.map(\.id) == [sessionId]) #expect(viewModel.emptyState == nil) // Act + Assert: each interval elapsing triggers exactly one more fetch. await clock.waitForSleepers(count: 1) clock.advance(by: Tunables.listPollInterval) await viewModel.waitUntilFetches(count: 2) await clock.waitForSleepers(count: 1) clock.advance(by: Tunables.listPollInterval) await viewModel.waitUntilFetches(count: 3) // Act: leave the screen while the poll task is parked in sleep. await clock.waitForSleepers(count: 1) viewModel.disappeared() // Assert: cancellation reclaimed the sleeper synchronously (no leaked // timer) and a long stretch of fake time produces zero new requests. #expect(clock.pendingSleeperCount == 0) clock.advance(by: Tunables.listPollInterval * 10) let requestCount = await http.recordedRequests.count #expect(requestCount == 3) } // MARK: - Empty states @Test("no paired hosts → .notPaired empty state and ZERO network traffic") func notPairedEmptyStateWithoutTraffic() async throws { // Arrange let http = FakeHTTPTransport() let viewModel = makeViewModel(hosts: [], http: http) // Act viewModel.appeared() await viewModel.waitUntilFetches(count: 1) // Assert #expect(viewModel.emptyState == .notPaired) #expect(await http.recordedRequests.isEmpty) viewModel.disappeared() } @Test("empty list from the host → .noSessions; a later non-empty fetch clears it") func noSessionsEmptyStateThenPopulated() async throws { // Arrange let http = FakeHTTPTransport() let host = try makeHost() let url = try listURL() await http.queueSuccess(url: url, body: listBody([])) let viewModel = makeViewModel(hosts: [host], http: http) // Act & Assert: loaded + empty → .noSessions (distinct from not-paired). await loadOnce(viewModel) #expect(viewModel.emptyState == .noSessions) // Act & Assert: pull-to-refresh path fetches on demand and clears it. await http.queueSuccess(url: url, body: listBody([sessionJSON(id: UUID())])) await viewModel.refresh() #expect(viewModel.rows.count == 1) #expect(viewModel.emptyState == nil) } @Test("host store failure surfaces explicit copy — never a silent empty list") func hostStoreFailureIsExplicit() async throws { // Arrange let viewModel = SessionListViewModel( hostStore: ThrowingHostStore(), http: FakeHTTPTransport(), clock: FakeClock(), nowMs: { Self.nowMs } ) // Act await viewModel.reloadHosts() // Assert #expect(viewModel.hosts.isEmpty) #expect(viewModel.hostsErrorMessage == SessionListCopy.hostsLoadFailed) } // MARK: - Status dots (5 states) + pending ⚠ badge priority @Test("status dot maps every ClaudeStatus state from the wire", arguments: [ ("working", ClaudeStatus.working), ("waiting", ClaudeStatus.waiting), ("idle", ClaudeStatus.idle), ("unknown", ClaudeStatus.unknown), ("stuck", ClaudeStatus.stuck), ]) func statusDotMapsFiveStates(raw: String, expected: ClaudeStatus) async throws { // Arrange let http = FakeHTTPTransport() let host = try makeHost() let sessionId = UUID() await http.queueSuccess( url: try listURL(), body: listBody([sessionJSON(id: sessionId, status: raw)]) ) let viewModel = makeViewModel(hosts: [host], http: http) // Act await loadOnce(viewModel) // Assert #expect(viewModel.rows.first?.indicator == .status(expected)) } @Test("pending overlay wins over the status dot with a ⚠ badge, and clears back") func pendingBadgeTakesPriority() async throws { // Arrange: a WORKING session — the strongest contrast to the badge. let http = FakeHTTPTransport() let host = try makeHost() let sessionId = UUID() await http.queueSuccess( url: try listURL(), body: listBody([sessionJSON(id: sessionId, status: "working")]) ) let viewModel = makeViewModel(hosts: [host], http: http) await loadOnce(viewModel) #expect(viewModel.rows.first?.indicator == .status(.working)) // Act: gate arrives (status frame pending:true → T-iOS-15 wiring calls this). viewModel.setPendingApproval(sessionId: sessionId, pending: true) // Assert: badge PRIORITY — status stays working underneath. #expect(viewModel.rows.first?.indicator == .pendingApproval) #expect(viewModel.rows.first?.info.status == .working) // Act & Assert: gate resolved → dot returns. viewModel.setPendingApproval(sessionId: sessionId, pending: false) #expect(viewModel.rows.first?.indicator == .status(.working)) } @Test("indicator glyphs mirror public/tabs.ts claudeIcon, ⚠ for pending") func indicatorGlyphsMirrorWeb() { #expect(SessionListViewModel.StatusIndicator.status(.working).glyph == "⚙") #expect(SessionListViewModel.StatusIndicator.status(.waiting).glyph == "⏳") #expect(SessionListViewModel.StatusIndicator.status(.idle).glyph == "✓") #expect(SessionListViewModel.StatusIndicator.status(.stuck).glyph == "⚠") #expect(SessionListViewModel.StatusIndicator.status(.unknown).glyph == "") #expect(SessionListViewModel.StatusIndicator.pendingApproval.glyph == "⚠") } // MARK: - Telemetry staleness (injected now-provider, exact boundary) @Test("telemetry chips grey strictly past telemetryStaleTtlMs; at the boundary they stay live; no telemetry → no chips") func telemetryStalenessBoundary() async throws { // Arrange: `at` exactly TTL old (NOT stale — web rule is strict `>`, // public/preview-grid.ts), one 1 ms older (stale), one without telemetry. let http = FakeHTTPTransport() let host = try makeHost() let boundaryId = UUID() let staleId = UUID() let bareId = UUID() let boundaryAt = Self.nowMs - Tunables.telemetryStaleTtlMs let staleAt = Self.nowMs - Tunables.telemetryStaleTtlMs - 1 await http.queueSuccess(url: try listURL(), body: listBody([ sessionJSON(id: boundaryId, telemetry: "{\"at\":\(boundaryAt),\"costUsd\":0.5}"), sessionJSON(id: staleId, telemetry: "{\"at\":\(staleAt),\"costUsd\":0.5}"), sessionJSON(id: bareId), ])) let viewModel = makeViewModel(hosts: [host], http: http) // Act await loadOnce(viewModel) // Assert let rowsById = Dictionary(uniqueKeysWithValues: viewModel.rows.map { ($0.id, $0) }) #expect(rowsById[boundaryId]?.telemetry?.isStale == false) #expect(rowsById[staleId]?.telemetry?.isStale == true) #expect(rowsById[bareId]?.telemetry == nil) } @Test("chip model mirrors the web gauge: $ cost to 4 places, ctx warn > 80, PR link https-only") func telemetryChipModelContent() throws { // Arrange: http:// PR URL — the SEC-L5 mirror must refuse to link it. let telemetry = StatusTelemetry( contextUsedPct: 91.4, costUsd: 0.1234, model: "opus", pr: PrInfo(number: 7, url: "http://github.com/x/pull/7", reviewState: "approved"), at: Self.nowMs ) // Act let model = try #require(TelemetryChips.Model(telemetry: telemetry, nowMs: Self.nowMs)) // Assert #expect(model.costText == "$0.1234") #expect(model.contextText == "ctx 91%") #expect(model.isContextWarning) #expect(model.modelName == "opus") #expect(model.prText == "PR #7") #expect(model.prReviewState == "approved") #expect(model.prURL == nil) // http never becomes a tappable link #expect(!model.isStale) // Act & Assert: https URL IS linkable; low ctx carries no warning. let httpsTelemetry = StatusTelemetry( contextUsedPct: 40, pr: PrInfo(number: 7, url: "https://github.com/x/pull/7"), at: Self.nowMs ) let httpsModel = try #require( TelemetryChips.Model(telemetry: httpsTelemetry, nowMs: Self.nowMs) ) #expect(httpsModel.prURL != nil) #expect(!httpsModel.isContextWarning) #expect(TelemetryChips.Model(telemetry: nil, nowMs: Self.nowMs) == nil) } // MARK: - Ordering: server order kept, exited grouped at the bottom @Test("rows keep the server's order with exited sessions grouped last, order preserved inside each group") func orderingGroupsExitedAtBottom() async throws { // Arrange: server (newest-first) order interleaves exited sessions. let http = FakeHTTPTransport() let host = try makeHost() let liveA = UUID() let exitedB = UUID() let liveC = UUID() let exitedD = UUID() await http.queueSuccess(url: try listURL(), body: listBody([ sessionJSON(id: liveA), sessionJSON(id: exitedB, exited: true), sessionJSON(id: liveC), sessionJSON(id: exitedD, exited: true), ])) let viewModel = makeViewModel(hosts: [host], http: http) // Act await loadOnce(viewModel) // Assert #expect(viewModel.rows.map(\.id) == [liveA, liveC, exitedB, exitedD]) } // MARK: - Swipe-to-kill: optimistic + Origin + rollback @Test("kill removes the row optimistically BEFORE the server replies, stamps Origin on the DELETE, stays removed on 204") func killOptimisticRemovalThenConfirmed() async throws { // Arrange: DELETE parks at a gate so the in-flight state is observable. let inner = FakeHTTPTransport() let gated = GatedHTTPTransport(inner: inner, gatedMethod: "DELETE") let host = try makeHost() let targetId = UUID() let otherId = UUID() await inner.queueSuccess( url: try listURL(), body: listBody([sessionJSON(id: targetId), sessionJSON(id: otherId)]) ) await inner.queueSuccess(method: "DELETE", url: try deleteURL(id: targetId), status: 204) let viewModel = makeViewModel(hosts: [host], http: gated) await loadOnce(viewModel) #expect(viewModel.rows.map(\.id) == [targetId, otherId]) // Act: swipe-to-kill; hold the DELETE at the gate. let killTask = Task { await viewModel.kill(sessionId: targetId) } await gated.waitForGatedArrival() // Assert: the row is ALREADY gone while the server has not answered. #expect(viewModel.rows.map(\.id) == [otherId]) #expect(viewModel.killErrorMessage == nil) // Act: let the 204 through. await gated.openGate() await killTask.value // Assert: still removed; the DELETE carried the exact Origin (G 铁律). #expect(viewModel.rows.map(\.id) == [otherId]) let deleteRequest = await inner.recordedRequests.first { $0.httpMethod == "DELETE" } #expect(deleteRequest?.url == (try deleteURL(id: targetId))) #expect(deleteRequest?.value(forHTTPHeaderField: "Origin") == Self.base) } @Test("kill failure rolls the row back at its original position with explicit copy") func killFailureRollsBack() async throws { // Arrange: the Origin guard rejects the DELETE (403). let http = FakeHTTPTransport() let host = try makeHost() let targetId = UUID() let otherId = UUID() await http.queueSuccess( url: try listURL(), body: listBody([sessionJSON(id: targetId), sessionJSON(id: otherId)]) ) await http.queueSuccess(method: "DELETE", url: try deleteURL(id: targetId), status: 403) let viewModel = makeViewModel(hosts: [host], http: http) await loadOnce(viewModel) // Act await viewModel.kill(sessionId: targetId) // Assert: rollback restored the ORIGINAL position, copy is actionable. #expect(viewModel.rows.map(\.id) == [targetId, otherId]) #expect(viewModel.killErrorMessage == SessionListCopy.killFailed(APIClientError.forbidden.message)) } @Test("kill on an already-gone session (404) keeps it removed without an error") func killAlreadyGoneStaysRemoved() async throws { // Arrange let http = FakeHTTPTransport() let host = try makeHost() let targetId = UUID() await http.queueSuccess(url: try listURL(), body: listBody([sessionJSON(id: targetId)])) await http.queueSuccess(method: "DELETE", url: try deleteURL(id: targetId), status: 404) let viewModel = makeViewModel(hosts: [host], http: http) await loadOnce(viewModel) // Act await viewModel.kill(sessionId: targetId) // Assert: 404 = session already dead — the optimistic removal was right. #expect(viewModel.rows.isEmpty) #expect(viewModel.killErrorMessage == nil) } // MARK: - Navigation signals @Test("+ New session emits an OpenRequest with sessionId nil; row taps carry the id; repeats re-fire") func navigationSignals() async throws { // Arrange let http = FakeHTTPTransport() let host = try makeHost() let sessionId = UUID() await http.queueSuccess(url: try listURL(), body: listBody([sessionJSON(id: sessionId)])) let viewModel = makeViewModel(hosts: [host], http: http) await loadOnce(viewModel) #expect(viewModel.openRequest == nil) // Act & Assert: "+ New session" → sessionId nil (attach(null) downstream). viewModel.requestNewSession() let newRequest = try #require(viewModel.openRequest) #expect(newRequest.sessionId == nil) #expect(newRequest.host == host) // Act & Assert: opening a listed session carries its id. viewModel.openSession(id: sessionId) let openRequest = try #require(viewModel.openRequest) #expect(openRequest.sessionId == sessionId) #expect(openRequest.host == host) // Act & Assert: the SAME tap again is a fresh signal (unique id). viewModel.openSession(id: sessionId) let repeated = try #require(viewModel.openRequest) #expect(repeated.sessionId == sessionId) #expect(repeated.id != openRequest.id) // Act & Assert: unknown ids are refused at the boundary. viewModel.openSession(id: UUID()) #expect(viewModel.openRequest?.id == repeated.id) } // MARK: - Host switching (multi-host header hook) @Test("switching hosts refetches from the new endpoint and resets the list") func hostSwitchRefetches() async throws { // Arrange: two paired hosts with distinct session lists. let baseB = "http://10.0.0.7:3000" let hostA = try makeHost() let hostB = try makeHost(baseB, name: "工作室 Mac") let http = FakeHTTPTransport() let sessionA = UUID() let sessionB = UUID() await http.queueSuccess(url: try listURL(), body: listBody([sessionJSON(id: sessionA)])) await http.queueSuccess( url: try listURL(baseB), body: listBody([sessionJSON(id: sessionB)]) ) let viewModel = makeViewModel(hosts: [hostA, hostB], http: http) await loadOnce(viewModel) #expect(viewModel.activeHost == hostA) #expect(viewModel.rows.map(\.id) == [sessionA]) // Act await viewModel.selectHost(id: hostB.id) // Assert: list now comes from host B; navigation carries host B. #expect(viewModel.activeHost == hostB) #expect(viewModel.rows.map(\.id) == [sessionB]) viewModel.requestNewSession() #expect(viewModel.openRequest?.host == hostB) let lastURL = await http.recordedRequests.last?.url #expect(lastURL == (try listURL(baseB))) } // MARK: - Fetch failure keeps the stale list (explicit error, no wipe) @Test("a failed poll keeps the last good rows and surfaces copy; the next success clears it") func fetchFailureKeepsStaleRows() async throws { // Arrange let http = FakeHTTPTransport() let host = try makeHost() let url = try listURL() let sessionId = UUID() await http.queueSuccess(url: url, body: listBody([sessionJSON(id: sessionId)])) await http.queueSuccess(url: url, status: 500) await http.queueSuccess(url: url, body: listBody([sessionJSON(id: sessionId)])) let viewModel = makeViewModel(hosts: [host], http: http) await loadOnce(viewModel) #expect(viewModel.rows.map(\.id) == [sessionId]) // Act: the 500 poll. await viewModel.refresh() // Assert: stale rows survive, error copy is explicit (includes status). #expect(viewModel.rows.map(\.id) == [sessionId]) let message = try #require(viewModel.fetchErrorMessage) #expect(message.contains("500")) #expect(viewModel.emptyState == nil) // Act & Assert: recovery clears the error. await viewModel.refresh() #expect(viewModel.fetchErrorMessage == nil) } }