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>
126 lines
5.1 KiB
Swift
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)]")
|
|
}
|
|
}
|
|
}
|