feat(ios): W3 UI layer + integration CI + ntfy docs
T-iOS-11: TerminalScreen/KeyBar/TerminalViewModel + KeyByteMap (byte-for-byte keybar.ts, arrows excluded from UIKeyCommand to preserve DECCKM) T-iOS-12: PairingScreen/VM — confirm-before-network (zero-call assertions), §5.4 four-tier warnings, Host construction per contract ruling T-iOS-13: SessionListScreen/VM — Tunables-paced polling with leak-free teardown, optimistic kill+rollback, pending via overlay (LiveSessionInfo has no pending field) T-iOS-14: GateBanner/PlanGateSheet/AwayDigestView/GateViewModel — three-way mapping from SessionCore Affordance single source, tap-epoch guard, per-epoch haptics T-iOS-16: IntegrationTests vs real Node server (10 tests: origin guards, mirror, kill-close vs exit-frame differential, 16MiB+ESC/C0 replay) + ios.yml own-sources coverage gate (red-once demoed) T-iOS-17: ios/README.md ntfy chapter (read-only verification, file:line cites) Verified: 224 unit + 10 integration tests green; 5/5 semantic spot-checks; zero Owns violations
This commit is contained in:
581
ios/App/WebTermTests/SessionListViewModelTests.swift
Normal file
581
ios/App/WebTermTests/SessionListViewModelTests.swift
Normal file
@@ -0,0 +1,581 @@
|
||||
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<Duration> = 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<Void, Never>] = []
|
||||
private var arrivalWaiters: [CheckedContinuation<Void, Never>] = []
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user