mana-swift-event-sync/Tests/ManaEventSyncTests/GoldenReplayParityTests.swift
Till JS 34e263ab1c test(golden): Replay-Parität gegen geteilte event-kit-Fixtures (E-4.3)
Swift-Replayer für die 5 geteilten Golden-Cases in
mana/packages/event-kit/test-fixtures/golden/ — dieselben events.jsonl +
expected-state.json, die der TS-Runner (golden-runner.test.ts) fährt. Erfüllt
die README-Pflicht „TS/Swift/Kotlin byte-für-byte identisch".

Spiegelt shared-app.ts (Counter+Note, 5 Event-Typen) als test-lokale Reducer
— die Lib hält keine Domänen-Reducer (Invariante #1). Deckt Plumbing-Parität:
Envelope-Decode (inkl. String-sequenceNumber), Event-Reihenfolge, Multi-
Aggregate-Isolation, Sequence-/Count-Tracking. Skippt sauber, wenn das
mana-Schwester-Repo fehlt. 25 Tests grün, swiftlint --strict clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 17:49:22 +02:00

126 lines
5.1 KiB
Swift

import Foundation
import XCTest
@testable import ManaEventSync
/// Golden-Replay-Parität gegen die **geteilten** Fixtures in
/// `mana/packages/event-kit/test-fixtures/golden/` — dieselben Files, die der
/// TS-Replayer (`golden-runner.test.ts`) fährt. Deren README macht das zur
/// Pflicht: TS-, Swift- und Kotlin-Replayer müssen byte-für-byte denselben
/// State produzieren, sonst driftet die Event-Projektion zwischen Plattformen.
///
/// Diese Suite spiegelt `shared-app.ts` (Counter + Note, 5 Event-Typen) als
/// test-lokale Reducer — die Lib hält bewusst **keine** Domänen-Reducer
/// (Invariante #1). Getestet wird damit die *Plumbing*-Parität: Envelope-
/// Decode (inkl. String-`sequenceNumber`), Event-Reihenfolge, Multi-
/// Aggregate-Isolation, Sequence- + Count-Tracking.
final class GoldenReplayParityTests: XCTestCase {
/// Schwester-Repo `mana` (wie `../mana-swift-core` als Package-Dep).
private static let goldenDir: URL = .init(fileURLWithPath: #filePath)
.deletingLastPathComponent() // ManaEventSyncTests/
.deletingLastPathComponent() // Tests/
.deletingLastPathComponent() // <repo root>/
.appendingPathComponent("../mana/packages/event-kit/test-fixtures/golden")
.standardizedFileURL
// MARK: - Replay (minimaler Spiegel von replay(goldenApp, events))
private struct ReplayResult {
var states: [String: [String: JSONValue]] = [:]
var eventsApplied = 0
var lastSequenceNumber: Int64 = 0
}
/// Reducer-Registry, 1:1 zu `shared-app.ts`.
private func reduce(_ state: [String: JSONValue], _ event: EventEnvelope) -> [String: JSONValue] {
var next = state
switch event.eventType {
case "CounterCreated":
next["id"] = event.payload.get("counterId")
next["count"] = .int(0)
next["name"] = event.payload.get("name")
case "CounterIncremented":
next["count"] = .int(currentCount(state) + 1)
case "CounterRenamed":
next["name"] = event.payload.get("newName")
case "NoteCreated":
next["id"] = event.payload.get("noteId")
next["text"] = event.payload.get("text")
case "NoteEdited":
next["text"] = event.payload.get("newText")
default:
XCTFail("Unbekannter eventType in Fixture: \(event.eventType)")
}
return next
}
private func currentCount(_ state: [String: JSONValue]) -> Int64 {
if case let .int(value) = state["count"] { return value }
return 0
}
private func replay(_ events: [EventEnvelope]) -> ReplayResult {
var result = ReplayResult()
for event in events {
let current = result.states[event.aggregateId] ?? [:]
result.states[event.aggregateId] = reduce(current, event)
result.eventsApplied += 1
if let seq = event.sequenceNumber {
result.lastSequenceNumber = max(result.lastSequenceNumber, seq)
}
}
return result
}
// MARK: - Fixture-Loading
private struct Expected: Decodable {
let states: [String: [String: JSONValue]]
let eventsApplied: Int
let lastSequenceNumber: String
}
private func loadEvents(_ caseDir: URL) throws -> [EventEnvelope] {
let content = try String(contentsOf: caseDir.appendingPathComponent("events.jsonl"), encoding: .utf8)
let decoder = JSONDecoder()
return try content
.split(separator: "\n")
.map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
.filter { !$0.isEmpty }
.map { try decoder.decode(EventEnvelope.self, from: Data($0.utf8)) }
}
private func loadExpected(_ caseDir: URL) throws -> Expected {
let url = caseDir.appendingPathComponent("expected-state.json")
return try JSONDecoder().decode(Expected.self, from: Data(contentsOf: url))
}
private func caseDirs() throws -> [URL] {
try FileManager.default
.contentsOfDirectory(at: Self.goldenDir, includingPropertiesForKeys: nil)
.filter { $0.lastPathComponent.range(of: #"^\d{2}-"#, options: .regularExpression) != nil }
.sorted { $0.lastPathComponent < $1.lastPathComponent }
}
// MARK: - Test
func testGoldenFixturesReplayToExpectedState() throws {
guard FileManager.default.fileExists(atPath: Self.goldenDir.path) else {
throw XCTSkip("Geteilte Fixtures fehlen (\(Self.goldenDir.path)) — mana-Repo nicht ausgecheckt.")
}
let dirs = try caseDirs()
XCTAssertFalse(dirs.isEmpty, "Keine Golden-Cases in \(Self.goldenDir.path)")
for dir in dirs {
let name = dir.lastPathComponent
let result = try replay(loadEvents(dir))
let expected = try loadExpected(dir)
XCTAssertEqual(result.eventsApplied, expected.eventsApplied, "eventsApplied [\(name)]")
XCTAssertEqual(
String(result.lastSequenceNumber), expected.lastSequenceNumber,
"lastSequenceNumber [\(name)]"
)
XCTAssertEqual(result.states, expected.states, "states [\(name)]")
}
}
}