Files
hapi/ios/Packages/HapiKit/Tests/HapiProtocolTests/SessionSortingTests.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

98 lines
3.7 KiB
Swift

import Foundation
import HapiProtocol
import Testing
/// Pins the exact `sortSessionSummaries` order from `web/src/hooks/useSSE.ts`
/// (transcribed from the Android reference suite, `SessionSortingTest.kt`).
@Suite("Session sorting")
struct SessionSortingTests {
private func summary(
_ id: String,
active: Bool = false,
updatedAt: Int = 0,
pinned: Bool? = nil,
globalPinned: Bool? = nil,
pendingRequestsCount: Int = 0
) -> SessionSummary {
SessionSummary(
id: id,
active: active,
thinking: false,
activeAt: 0,
updatedAt: updatedAt,
pinned: pinned,
globalPinned: globalPinned,
metadata: nil,
metadataVersion: 0,
agentStateVersion: 0,
todosUpdatedAt: 0,
todoProgress: nil,
pendingRequestsCount: pendingRequestsCount,
pendingRequestKinds: [],
pendingRequests: [],
backgroundTaskCount: 0,
futureScheduledMessageCount: 0,
nextScheduledAt: nil,
model: nil,
modelReasoningEffort: nil,
effort: nil
)
}
@Test func globalPinnedBeatsPinnedBeatsActiveBeatsRecency() {
let sorted = sortSessionSummaries([
summary("recent-inactive", updatedAt: 9_000),
summary("active", active: true, updatedAt: 1_000),
summary("pinned", updatedAt: 500, pinned: true),
summary("global", updatedAt: 100, globalPinned: true),
])
#expect(sorted.map(\.id) == ["global", "pinned", "active", "recent-inactive"])
}
@Test func amongActiveSessionsPendingRequestsCountDescendsBeforeUpdatedAt() {
let sorted = sortSessionSummaries([
summary("a", active: true, updatedAt: 9_000, pendingRequestsCount: 0),
summary("b", active: true, updatedAt: 1_000, pendingRequestsCount: 2),
summary("c", active: true, updatedAt: 5_000, pendingRequestsCount: 1),
])
#expect(sorted.map(\.id) == ["b", "c", "a"])
}
@Test func inactiveSessionsIgnorePendingRequestsCountWebLeftActiveGuard() {
let sorted = sortSessionSummaries([
summary("low-pending-recent", updatedAt: 9_000, pendingRequestsCount: 0),
summary("high-pending-old", updatedAt: 1_000, pendingRequestsCount: 5),
])
#expect(sorted.map(\.id) == ["low-pending-recent", "high-pending-old"])
}
@Test func pinnedSectionsSortByRecencyWithinThemselves() {
let sorted = sortSessionSummaries([
summary("g-old", updatedAt: 100, globalPinned: true),
summary("p-new", updatedAt: 9_000, pinned: true),
summary("g-new", updatedAt: 5_000, globalPinned: true),
summary("p-old", updatedAt: 200, pinned: true),
])
#expect(sorted.map(\.id) == ["g-new", "g-old", "p-new", "p-old"])
}
@Test func nilPinFlagsCountAsFalseAndTiesKeepPriorOrderStable() {
let a = summary("a", updatedAt: 1_000, pinned: nil)
let b = summary("b", updatedAt: 1_000, pinned: false)
let c = summary("c", updatedAt: 1_000)
#expect(sortSessionSummaries([a, b, c]).map(\.id) == ["a", "b", "c"])
#expect(sortSessionSummaries([c, b, a]).map(\.id) == ["c", "b", "a"])
}
@Test func updatedAtComparisonSurvivesLongDeltas() {
// Long-range timestamps must not overflow a narrower subtraction
// (the comparator compares, never subtracts).
let sorted = sortSessionSummaries([
summary("old", updatedAt: 1_000),
summary("new", updatedAt: 1_000 + Int(Int32.max) * 3),
])
#expect(sorted.map(\.id) == ["new", "old"])
}
}