Files
hapi/ios/Packages/HapiKit/Tests/HapiClientTests/PaginationFixtureTests.swift
T
weishu 7712a92f51 feat(ios): message window store port + pagination fixture harness (A-M2d)
HapiProtocol/Window/ — pure port of web/src/lib/message-window-store.ts +
messages.ts, mirroring the Android reference port 1:1:
- MessageWindowState: constants (400/600/800/800/200), MessagePosition,
  OlderLoadOutcome, full InternalState fields, persisted v2 snapshot shape
- MessageWindowLogic: every transition (merge/trim preserving queued rows,
  latest replace with request-baseline identity preservation, tail sync
  begin/apply/finish, older pages + epoch-mismatch reset, enterTailMode
  that deliberately keeps requiresLatestReset, activate, SSE ingest with
  hidden-row cursor advance, markConsumed stamping server rows to 'sent',
  optimistic lifecycle, queued reconcile, hydrate/persist, seededState)
- MessageMerge: position comparator (ASCII tie-break), localId echo
  replacement preserving status/invokedAt, 10s sent-dedup fallback
- MessageRetention: calls the fixtures-green chat pipeline's
  normalizeDecryptedMessage directly (no hand-mirrored tree to drift)
- WindowMessage: identity-carrying final class (web's !== baseline
  classification), tri-state invokedAt, buildOptimisticMessage

HapiClient/Stores/ — async half:
- MessagesProviding seam (three-variant MessagesPageQuery; APIClient
  conforms via its existing endpoints)
- MessageWindowController actor: single-flight tail controller with
  trailing drain and a synchronous generation bump before the first await
  (the web runs to its first suspension; Android used UNDISPATCHED),
  fetchOlder with onBeforeApply veto, SSE hooks, optimistic append/status/
  cancel-invoked, queued-state reconciliation in 1000-id batches, seedFrom
- WindowSnapshotStore: per-session JSON snapshots, LRU 10 (dedup TODO with
  the session-list package's cache) + MessageWindowControllers registry

Tests (swift-testing):
- PaginationFixtureTests: parameterized replay of shared/fixtures/
  pagination/*.json against the real controller via a scripted provider;
  asserts expectedRequests (canonical JSON incl. explicit-null untils),
  expectedOutcome, expectedCandidates, and the final expectedState
  projection with per-op labels and first-differing-line diffs
- MessageWindowControllerTests: tail-sync coalescing + trailing drain,
  concurrent-SSE preservation across a reset replace, cursor-no-advance
  guard, snapshot round-trip/LRU, seedFrom

All 11 fixtures verified green against a line-by-line Python mirror of
these exact algorithms (no local Swift toolchain; CI runs the real suite).
2026-08-17 20:18:05 +08:00

483 lines
20 KiB
Swift

import Foundation
import HapiClient
import HapiProtocol
import Testing
/// Pagination conformance suite: replays every op script in
/// `shared/fixtures/pagination/` against the REAL `MessageWindowController`
/// (driven by a scripted `MessagesProviding`) and requires
///
/// - the exact same `GET /messages` requests, in order (`expectedRequests`,
/// canonical-JSON compare incl. the explicit-null `untilAt`/`untilSeq` on
/// the first catch-up request),
/// - the same older-load outcomes (`expectedOutcome`) and queued-state
/// reconcile candidates (`expectedCandidates`),
/// - the exact final window projection (`expectedState`).
///
/// The twin of `web/src/lib/message-window-store.fixtures.test.ts` and the
/// Android `PaginationFixtureTest`. A failure here means this port drifted
/// from the web reference (or the fixtures were regenerated after a web
/// behavior change and the port must catch up). Failures are per-fixture,
/// labeled `ops[i]`, and canonical mismatches print a first-differing-line
/// diff with context.
///
/// Determinism: ops run strictly sequentially and every controller call is
/// awaited to completion (the scripted provider answers immediately), so the
/// replay is exact — mirroring the web harness awaiting every op.
@Suite("Pagination golden fixtures")
struct PaginationFixtureTests {
/// Highest fixture document schema this suite understands. Mirrors the
/// README rule: fail loudly when the on-disk version is newer.
private static let supportedFixtureVersion = 1
// MARK: - Fixture discovery (same #filePath scheme as ChatFixtureTests)
/// Repo-root `shared/fixtures`, resolved from this file's own location:
/// `ios/Packages/HapiKit/Tests/HapiClientTests/…` → package root →
/// `../../../shared/fixtures`.
private static func fixturesDirectory() -> URL {
URL(fileURLWithPath: #filePath)
.deletingLastPathComponent() // Tests/HapiClientTests
.deletingLastPathComponent() // Tests
.deletingLastPathComponent() // package root: ios/Packages/HapiKit
.appendingPathComponent("../../../shared/fixtures")
.standardizedFileURL
}
/// Sorted fixture file names — the parameterized-test argument list.
static let paginationFixtureNames: [String] = {
let directory = fixturesDirectory().appendingPathComponent("pagination")
let contents = (try? FileManager.default.contentsOfDirectory(
at: directory,
includingPropertiesForKeys: nil
)) ?? []
return contents
.filter { $0.pathExtension == "json" }
.map { $0.lastPathComponent }
.sorted()
}()
private static func fixtureURL(_ fileName: String) -> URL {
fixturesDirectory().appendingPathComponent("pagination").appendingPathComponent(fileName)
}
@Test func hasPaginationFixturesOnDisk() {
#expect(
!Self.paginationFixtureNames.isEmpty,
"no pagination fixtures found under \(Self.fixturesDirectory().path)"
)
}
@Test func fixturesVersionIsSupported() throws {
let versionFile = Self.fixturesDirectory().appendingPathComponent("VERSION")
let text = try String(contentsOf: versionFile, encoding: .utf8)
.trimmingCharacters(in: .whitespacesAndNewlines)
let version = try #require(Int(text), "VERSION must be a single integer, got: \(text)")
#expect(
version <= Self.supportedFixtureVersion,
"fixtures were regenerated with a newer schema (\(version)); update the window port"
)
}
// MARK: - Replay
@Test("fixture", arguments: PaginationFixtureTests.paginationFixtureNames)
func fixtureReplayMatchesStore(_ fileName: String) async throws {
let data = try Data(contentsOf: Self.fixtureURL(fileName))
let document = try JSONDecoder().decode(JSONValue.self, from: data)
let fixtureVersion = try #require(document["fixtureVersion"]?.intValue, "\(fileName): fixtureVersion")
#expect(fixtureVersion <= Self.supportedFixtureVersion,
"\(fileName) uses fixtureVersion \(fixtureVersion)")
let name = try #require(document["name"]?.stringValue, "\(fileName): name")
#expect("\(name).json" == fileName, "\(fileName) name field mismatch")
let scripted = ScriptedMessagesProvider()
let controller = MessageWindowController(
sessionId: "fixture-pagination-\(name)",
provider: scripted
)
let ops = try #require(document["ops"]?.arrayValue, "\(fileName): ops")
for (opIndex, opValue) in ops.enumerated() {
try await executeOp(
opValue,
controller: controller,
scripted: scripted,
label: "\(fileName) ops[\(opIndex)]"
)
}
let expectedState = try #require(document["expectedState"], "\(fileName): expectedState")
let projected = Self.projectWindowState(await controller.state)
compareCanonical(expected: expectedState, actual: projected, label: "\(fileName) expectedState")
}
// MARK: - Op replay
private func executeOp(
_ opValue: JSONValue,
controller: MessageWindowController,
scripted: ScriptedMessagesProvider,
label: String
) async throws {
let op = try #require(opValue.objectValue, "\(label): op must be an object")
let kind = try #require(op["op"]?.stringValue, "\(label): missing op kind")
switch kind {
case "sync-tail":
await scripted.begin(try decodeResponses(op, label: label))
await controller.syncTail()
await assertSettled(controller: controller, scripted: scripted, label: label)
await assertRequests(op, scripted: scripted, label: label)
case "fetch-older":
await scripted.begin(try decodeResponses(op, label: label))
let outcome = await controller.fetchOlder()
await assertSettled(controller: controller, scripted: scripted, label: label)
await assertRequests(op, scripted: scripted, label: label)
if let expected = op["expectedOutcome"] {
compareCanonical(
expected: expected,
actual: Self.projectOutcome(outcome, label: label),
label: "\(label) outcome"
)
}
case "sse-messages":
let rows = try #require(op["messages"]?.arrayValue, "\(label): messages")
await controller.ingestSSEMessages(try rows.map { try decodeWindowMessage($0, label: label) })
case "append-optimistic":
let message = try #require(op["message"], "\(label): message")
await controller.appendOptimistic(try decodeWindowMessage(message, label: label))
case "update-status":
let localId = try #require(op["localId"]?.stringValue, "\(label): localId")
let statusWire = try #require(op["status"]?.stringValue, "\(label): status")
let status = try #require(MessageStatus(rawValue: statusWire), "\(label): status '\(statusWire)'")
await controller.updateStatus(localId: localId, status: status)
case "messages-consumed":
let localIds = try #require(op["localIds"]?.arrayValue, "\(label): localIds")
.compactMap(\.stringValue)
let invokedAt = try #require(op["invokedAt"]?.intValue, "\(label): invokedAt")
await controller.markConsumed(localIds: localIds, invokedAt: invokedAt)
case "message-cancelled":
let localId = try #require(op["localId"]?.stringValue, "\(label): localId")
await controller.removeMessage(localIdOrId: localId)
case "cancel-invoked":
let localId = try #require(op["localId"]?.stringValue, "\(label): localId")
let message = try #require(op["message"], "\(label): message")
await controller.applyCancelInvoked(
localId: localId,
message: try decodeWindowMessage(message, label: label)
)
case "set-view-mode":
let mode = op["mode"]?.stringValue == "history" ? MessageViewMode.history : .tail
await controller.setViewMode(mode)
case "queued-state":
// Mirrors the web runner (reconcileQueuedStateAfterConnect's post
// tail-sync half): collect candidates, apply invoked verdicts
// grouped by timestamp, drop deleted candidates.
let candidates = await controller.queuedReconcileCandidateLocalIds()
if let expected = op["expectedCandidates"] {
compareCanonical(
expected: expected,
actual: .array(candidates.map(JSONValue.string)),
label: "\(label) candidates"
)
}
let invokedEntries = try #require(op["invoked"]?.arrayValue, "\(label): invoked")
var timestamps: [Int] = []
var localIdsByTimestamp: [Int: [String]] = [:]
for entry in invokedEntries {
let invokedAt = try #require(entry["invokedAt"]?.intValue, "\(label): invoked.invokedAt")
let localId = try #require(entry["localId"]?.stringValue, "\(label): invoked.localId")
if localIdsByTimestamp[invokedAt] == nil { timestamps.append(invokedAt) }
localIdsByTimestamp[invokedAt, default: []].append(localId)
}
for invokedAt in timestamps {
await controller.markConsumed(
localIds: localIdsByTimestamp[invokedAt]!,
invokedAt: invokedAt
)
}
let queuedLocalIds = try #require(op["queuedLocalIds"]?.arrayValue, "\(label): queuedLocalIds")
.compactMap(\.stringValue)
await controller.reconcileQueuedLocalIds(
candidateLocalIds: candidates,
queuedLocalIds: queuedLocalIds
)
default:
Issue.record("\(label): unknown op '\(kind)'")
}
}
// MARK: - Decoding
private func decodeResponses(_ op: [String: JSONValue], label: String) throws -> [MessagesResponse] {
let responses = try #require(op["responses"], "\(label): responses")
return try Self.decodeValue([MessagesResponse].self, responses, label: "\(label) responses")
}
private func decodeWindowMessage(_ value: JSONValue, label: String) throws -> WindowMessage {
try Self.decodeValue(WindowMessage.self, value, label: "\(label) message")
}
/// Typed decode of a JSONValue subtree (round-trip through canonical
/// text — `WindowMessage`'s own Codable keeps the invokedAt tri-state).
private static func decodeValue<T: Decodable>(
_ type: T.Type,
_ value: JSONValue,
label: String
) throws -> T {
do {
return try JSONDecoder().decode(T.self, from: Data(toCanonicalJSON(value).utf8))
} catch {
Issue.record("\(label): decode failed — \(error)")
throw error
}
}
// MARK: - Assertions
private func assertSettled(
controller: MessageWindowController,
scripted: ScriptedMessagesProvider,
label: String
) async {
let remaining = await scripted.remaining
#expect(remaining == 0, "\(label): \(remaining) scripted response(s) left unconsumed")
let state = await controller.state
#expect(state.warning == nil, "\(label): store reported a warning: \(state.warning ?? "")")
#expect(
!state.isSyncingTail && !state.isLoadingMore,
"\(label): store still busy after the op settled"
)
}
private func assertRequests(
_ op: [String: JSONValue],
scripted: ScriptedMessagesProvider,
label: String
) async {
guard let expected = op["expectedRequests"] else { return }
let actual = JSONValue.array(await scripted.requests)
compareCanonical(expected: expected, actual: actual, label: "\(label) requests")
}
/// Byte-for-byte canonical comparison; on mismatch records the first
/// differing line with context from both documents.
private func compareCanonical(expected: JSONValue, actual: JSONValue, label: String) {
let expectedText = toCanonicalJSON(expected)
let actualText = toCanonicalJSON(actual)
if expectedText != actualText {
Issue.record(Comment(rawValue: Self.diff(
expected: expectedText,
actual: actualText,
label: label
)))
}
}
// MARK: - Projection (normative, shared/fixtures/README.md "expectedState")
private static func projectOutcome(_ outcome: OlderLoadOutcome, label: String) -> JSONValue {
switch outcome {
case .applied(_, let hasMore, let addedRenderableCount):
return .object([
"kind": .string("applied"),
"hasMore": .bool(hasMore),
"addedRenderableCount": .number(Double(addedRenderableCount)),
])
case .stopped(let reason):
return .object([
"kind": .string("stopped"),
"reason": .string(reason.rawValue),
])
case .failed(let error):
Issue.record("\(label): older-page load failed: \(error)")
return .object(["kind": .string("failed")])
}
}
private static func projectWindowState(_ state: MessageWindowState) -> JSONValue {
.object([
"messages": .array(state.messages.map(projectMessage)),
"hasMore": .bool(state.hasMore),
"epoch": state.epoch.map { JSONValue.number(Double($0)) } ?? .null,
"viewMode": .string(state.viewMode.rawValue),
"olderCursor": projectCursor(state.oldestPosition),
"newestCursor": projectCursor(state.newestPosition),
])
}
private static func projectMessage(_ message: WindowMessage) -> JSONValue {
var projected: [String: JSONValue] = [
"id": .string(message.id),
"localId": message.localId.map(JSONValue.string) ?? .null,
"seq": message.seq.map { JSONValue.number(Double($0)) } ?? .null,
"createdAt": .number(Double(message.createdAt)),
"queued": .bool(message.isQueuedForInvocation),
"optimistic": .bool(message.isOptimistic),
]
// Wire tri-state: the key appears only when the wire carried it.
switch message.invokedAt {
case .absent:
break
case .null:
projected["invokedAt"] = .null
case .number(let value):
projected["invokedAt"] = .number(Double(value))
}
if let scheduledAt = message.scheduledAt {
projected["scheduledAt"] = .number(Double(scheduledAt))
}
if let status = message.status {
projected["status"] = .string(status.rawValue)
}
return .object(projected)
}
private static func projectCursor(_ position: MessagePosition?) -> JSONValue {
guard let position else { return .null }
return .object([
"at": .number(Double(position.at)),
"seq": .number(Double(position.seq)),
])
}
// MARK: - Diff (same shape as ChatFixtureTests)
/// Readable line-level diff: the first differing line with surrounding
/// context from both documents, so the CI log pinpoints the divergence
/// without downloading artifacts.
private static func diff(expected: String, actual: String, label: String) -> String {
let expectedLines = expected.components(separatedBy: "\n")
let actualLines = actual.components(separatedBy: "\n")
let commonCount = min(expectedLines.count, actualLines.count)
var firstDiff = commonCount
for index in 0..<commonCount where expectedLines[index] != actualLines[index] {
firstDiff = index
break
}
if firstDiff == commonCount && expectedLines.count == actualLines.count {
return "\(label): documents differ but no differing line found (line-ending issue?)"
}
let contextStart = max(0, firstDiff - 3)
func window(_ lines: [String]) -> String {
let end = min(lines.count, firstDiff + 4)
guard contextStart < end else { return " <past end of document>" }
return lines[contextStart..<end].enumerated().map { offset, line in
let lineNumber = contextStart + offset + 1
let marker = (contextStart + offset) == firstDiff ? ">" : " "
return String(format: "%@ %4d | %@", marker, lineNumber, line)
}.joined(separator: "\n")
}
return """
\(label): mismatch at line \(firstDiff + 1) \
(expected \(expectedLines.count) lines, actual \(actualLines.count))
--- expected (fixture) ---
\(window(expectedLines))
--- actual (port) ---
\(window(actualLines))
"""
}
}
// MARK: - Scripted transport
/// The scripted transport: serves queued `MessagesResponse`s FIFO and records
/// each request in the canonical shape the web harness pinned — `limit` only
/// for a latest page; `beforeAt`+`beforeSeq`+`limit` for older pages;
/// `afterAt`+`afterSeq`+`untilAt`+`untilSeq`+`epoch`+`limit` (untils
/// explicitly null on the first loop request) for tail catch-up.
actor ScriptedMessagesProvider: MessagesProviding {
struct ScriptError: Error, LocalizedError {
let message: String
var errorDescription: String? { message }
}
private var queue: [MessagesResponse] = []
private(set) var requests: [JSONValue] = []
var remaining: Int { queue.count }
func begin(_ responses: [MessagesResponse]) {
queue = responses
requests = []
}
func messages(sessionId: String, query: MessagesPageQuery) async throws -> MessagesResponse {
requests.append(Self.requestJSON(query))
guard !queue.isEmpty else {
throw ScriptError(message: "scripted MessagesProviding exhausted: unexpected getMessages request")
}
return queue.removeFirst()
}
func queuedState(sessionId: String, localIds: [String]) async throws -> QueuedStateResponse {
// queued-state ops are replayed via store primitives, like the web runner.
throw ScriptError(message: "scripted MessagesProviding does not serve queued-state")
}
private static func requestJSON(_ query: MessagesPageQuery) -> JSONValue {
switch query {
case .latest(let limit):
return .object(["limit": .number(Double(limit))])
case .before(let beforeAt, let beforeSeq, let limit):
return .object([
"beforeAt": .number(Double(beforeAt)),
"beforeSeq": .number(Double(beforeSeq)),
"limit": .number(Double(limit)),
])
case .after(let afterAt, let afterSeq, let untilAt, let untilSeq, let epoch, let limit):
return .object([
"afterAt": .number(Double(afterAt)),
"afterSeq": .number(Double(afterSeq)),
"epoch": .number(Double(epoch)),
"limit": .number(Double(limit)),
"untilAt": untilAt.map { JSONValue.number(Double($0)) } ?? .null,
"untilSeq": untilSeq.map { JSONValue.number(Double($0)) } ?? .null,
])
}
}
}
// MARK: - JSONValue accessors (test-local; the module's are internal)
extension JSONValue {
fileprivate var objectValue: [String: JSONValue]? {
if case .object(let value) = self { return value }
return nil
}
fileprivate var arrayValue: [JSONValue]? {
if case .array(let value) = self { return value }
return nil
}
fileprivate var stringValue: String? {
if case .string(let value) = self { return value }
return nil
}
fileprivate var intValue: Int? {
if case .number(let value) = self { return Int(value) }
return nil
}
fileprivate subscript(key: String) -> JSONValue? {
objectValue?[key]
}
}