Files
hapi/ios/Packages/HapiKit/Tests/HapiClientTests/Stores/MachineStoreTests.swift
T
weishu 67b7fe7304 feat(ios): session list store + UI (A-M2a)
HapiProtocol (pure, mirroring the Android reference port):
- SummaryPatching: sessionSummary.ts derivations (pending requests
  cap-5/oldest-first/id-tiebreak, kinds, todo progress), toSessionSummary
  projection with the per-flavor agentSessionId resolution (legacy chain
  omits piSessionId, replicated), applySessionSummaryPatch with the
  deliberate >= version gates (vs the detail path's strict >), the
  keep-alive render-irrelevance filter (pendingRequests compare ignores
  'since'; metadata compare ignores hapiMcpUrl), max-monotonic updatedAt,
  post-patch updatedAt as fallback 'since', and the deprecated legacy
  detail-required gate kept for rule pinning.
- SessionSorting: exact list comparator (globalPinned > pinned > active >
  pendingRequestsCount desc among active > updatedAt desc; stable).
- SessionMetadata: per-flavor session-id fields (claude/codex/gemini/
  opencode/grok/agy/cursor/kimi/copilot/pi).

HapiClient stores (@MainActor @Observable, per hub):
- DiskCache: 500 ms debounced atomic JSON snapshots + SnapshotLocations.
- SessionListStore: sorted summaries + detail cache; full-session upsert
  preserving hub-computed scheduled fields; strict-> detail vs >= summary
  patch paths; keep-alive identity preservation (listRevision); empty-{}
  and unparseable payloads take the REST fallback; 16 ms coalesced
  refresh; optimistic pin (roll-forward) and archive (restore).
- MachineStore: the exact machine-updated decision tree.
- LastSeenStore: monotonic unread watermarks + per-scope baseline.
- SyncEventRouter: SyncEvent fan-out to the stores, global-scope message
  events refresh the list, gap handshake triggers the full resync.

App:
- HubSession owns the stores + router (replaces the TODO(M2) routing);
  background flushes snapshots.
- Features/Sessions: SessionListView + SessionListModel (status dot with
  thinking pulse, title cascade, flavor·machine·worktree meta, relative
  age on a minute timeline, pending/todo badges, unread dots, pinned
  section, machine filter chips >= 2, pull-to-refresh, empty/loading/
  offline states, long-press pin/archive context menu) + an M2f chat
  placeholder pushed on row tap.
- HomePlaceholderView -> HomeView hosting the list (hub switcher +
  connection dot kept in the toolbar).

Tests transcribe the Android suites: SummaryPatchingTest (18),
SessionSortingTest (6), SessionStoreTest, MachineStoreTest,
LastSeenStoreTest, JsonSnapshotStoreTest, StoreSyncTargetsTest — using
the existing HTTPPerforming recording stub plus a path-routing performer
for concurrent refetches.
2026-08-17 20:17:02 +08:00

114 lines
4.5 KiB
Swift

import Foundation
import HapiClient
import HapiProtocol
import Testing
/// Transcribes the Android reference suite (`MachineStoreTest.kt`): the exact
/// `machine-updated` decision tree plus refresh and the snapshot round-trip.
@MainActor
@Suite("MachineStore")
struct MachineStoreTests {
private func makeStore(
snapshotDirectory: URL? = nil
) throws -> (performer: RecordingPerformer, store: MachineStore) {
let performer = RecordingPerformer()
let api = try makeStoreAPIClient(performer: performer)
let store = MachineStore(
api: api,
snapshotDirectory: snapshotDirectory,
refreshBatch: .milliseconds(1)
)
return (performer, store)
}
@Test func refreshReplacesTheListInAPIOrder() async throws {
let (performer, store) = try makeStore()
await performer.enqueue(json: try machinesResponseJSON(storeMachine("m2"), storeMachine("m1")))
try await store.refresh()
#expect(store.machines.map(\.id) == ["m2", "m1"])
}
@Test func fullMachinePayloadUpsertsInPlaceAndAppendsNewOnes() async throws {
let (performer, store) = try makeStore()
await performer.enqueue(json: try machinesResponseJSON(storeMachine("m1"), storeMachine("m2")))
try await store.refresh()
store.applyMachineEvent(
machineId: "m1",
data: try machineUpdatedData("m1", dataJSON: machineJSON(storeMachine("m1", host: "renamed")))
)
#expect(store.machines.first?.metadata?.host == "renamed")
#expect(store.machines.map(\.id) == ["m1", "m2"])
store.applyMachineEvent(
machineId: "m3",
data: try machineUpdatedData("m3", dataJSON: machineJSON(storeMachine("m3")))
)
#expect(store.machines.map(\.id) == ["m1", "m2", "m3"])
}
@Test func fullMachinePayloadWithActiveFalseRemovesTheRow() async throws {
let (performer, store) = try makeStore()
await performer.enqueue(json: try machinesResponseJSON(storeMachine("m1"), storeMachine("m2")))
try await store.refresh()
store.applyMachineEvent(
machineId: "m1",
data: try machineUpdatedData("m1", dataJSON: machineJSON(storeMachine("m1", active: false)))
)
#expect(store.machines.map(\.id) == ["m2"])
}
@Test func nullDataRemovesTheMachine() async throws {
let (performer, store) = try makeStore()
await performer.enqueue(json: try machinesResponseJSON(storeMachine("m1"), storeMachine("m2")))
try await store.refresh()
store.applyMachineEvent(
machineId: "m1",
data: try machineUpdatedData("m1", dataJSON: "null")
)
#expect(store.machines.map(\.id) == ["m2"])
}
@Test func patchWithActiveFalseRemovesOtherPatchesRefetch() async throws {
let (performer, store) = try makeStore()
await performer.enqueue(json: try machinesResponseJSON(storeMachine("m1"), storeMachine("m2")))
try await store.refresh()
store.applyMachineEvent(
machineId: "m1",
data: try machineUpdatedData("m1", dataJSON: "{\"active\":false}")
)
#expect(store.machines.map(\.id) == ["m2"])
// activeAt-only patch carries too little to upsert → refetch.
await performer.enqueue(json: try machinesResponseJSON(storeMachine("m2"), storeMachine("m3")))
store.applyMachineEvent(
machineId: "m2",
data: try machineUpdatedData("m2", dataJSON: "{\"activeAt\":123}")
)
try await expectEventually { store.machines.map(\.id) == ["m2", "m3"] }
}
@Test func absentDataRefetchesMachines() async throws {
let (performer, store) = try makeStore()
await performer.enqueue(json: try machinesResponseJSON(storeMachine("m1")))
store.applyMachineEvent(
machineId: "m1",
data: try machineUpdatedData("m1", dataJSON: nil)
)
try await expectEventually { store.machines.map(\.id) == ["m1"] }
}
@Test func machinesRoundTripThroughTheSnapshot() async throws {
let directory = makeTempDirectory()
let (performer, store) = try makeStore(snapshotDirectory: directory)
await performer.enqueue(json: try machinesResponseJSON(storeMachine("m1")))
try await store.refresh()
await store.flushPersistence()
let (_, cold) = try makeStore(snapshotDirectory: directory)
#expect(cold.machines.map(\.id) == ["m1"])
}
}