9 hand-gebaute Plumbing-Dateien (~900 LOC) gelöscht; pageta konsumiert jetzt ManaEventSync (../mana-swift-event-sync). Coordinator baut Events via engine.makeEnvelope/emit + liest via engine.events(forAggregate:) — kein currentUserId() mehr, die Engine besitzt die Identität (Vorbereitung Local-First ohne Login). Domäne (Payloads/States/Reducers) bleibt. Store-Name pageta-event-sync + App-Group unverändert → lokale Daten intakt. iOS-Sim-Build + 38/38 Tests grün. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
190 lines
6 KiB
Swift
190 lines
6 KiB
Swift
import ManaEventSync
|
|
@testable import PagetaNative
|
|
import XCTest
|
|
|
|
/// Reducer-Tests für die π-11 Event-Sync-Migration. Sicherstellt, dass
|
|
/// Replay über Events den erwarteten State produziert. Pendant-Tests
|
|
/// für die Web-Seite leben in `apps/web/src/lib/event-sync-api.test.ts`
|
|
/// (sofern dort vorhanden — sonst hier die führende Doku).
|
|
final class EventSyncReducerTests: XCTestCase {
|
|
// MARK: - Helpers
|
|
|
|
private func envelope(
|
|
_ type: String,
|
|
aggregateId: String = "article:01ABC",
|
|
userId: String = "user-1",
|
|
occurredAt: String = "2026-05-20T10:00:00Z",
|
|
payload: some Encodable
|
|
) throws -> EventEnvelope {
|
|
try EventEnvelope(
|
|
eventId: "evt-\(type)",
|
|
aggregateId: aggregateId,
|
|
appId: "pageta",
|
|
eventType: type,
|
|
eventVersion: 1,
|
|
occurredAt: occurredAt,
|
|
receivedAt: nil,
|
|
sequenceNumber: nil,
|
|
causationId: nil,
|
|
correlationId: nil,
|
|
actor: .user(principalId: userId),
|
|
attributedToUserId: userId,
|
|
origin: "user",
|
|
idempotencyKey: "idem-\(type)-\(aggregateId)",
|
|
payload: JSONValue.from(payload)
|
|
)
|
|
}
|
|
|
|
private func sampleSavedPayload(id: String = "01ABC") -> ArticleSavedPayload {
|
|
ArticleSavedPayload(
|
|
articleId: id,
|
|
originalUrl: "https://example.com/foo",
|
|
urlHash: "abc123",
|
|
title: "Foo",
|
|
excerpt: "kurz",
|
|
content: "Inhalt",
|
|
author: "Autor",
|
|
siteName: "Example",
|
|
imageUrl: nil,
|
|
wordCount: 100,
|
|
readingTimeMinutes: 1,
|
|
publishedAt: nil,
|
|
userNote: nil
|
|
)
|
|
}
|
|
|
|
// MARK: - Article reducer
|
|
|
|
func testReplayArticle_emptyEvents_returnsNil() {
|
|
XCTAssertNil(Reducers.replayArticle([]))
|
|
}
|
|
|
|
func testReplayArticle_savedOnly_projectsInitialState() throws {
|
|
let e = try envelope(EventType.articleSaved, payload: sampleSavedPayload())
|
|
let state = Reducers.replayArticle([e])
|
|
XCTAssertEqual(state?.id, "01ABC")
|
|
XCTAssertEqual(state?.title, "Foo")
|
|
XCTAssertEqual(state?.status, .unread)
|
|
XCTAssertEqual(state?.readingProgress, 0)
|
|
XCTAssertFalse(state?.isFavorite ?? true)
|
|
XCTAssertNil(state?.deletedAt)
|
|
XCTAssertEqual(state?.savedAt, "2026-05-20T10:00:00Z")
|
|
}
|
|
|
|
func testReplayArticle_statusChanged_setsStatusAndReadAt() throws {
|
|
let saved = try envelope(EventType.articleSaved, payload: sampleSavedPayload())
|
|
let changed = try envelope(
|
|
EventType.articleStatusChanged,
|
|
occurredAt: "2026-05-20T11:00:00Z",
|
|
payload: ArticleStatusChangedPayload(articleId: "01ABC", newStatus: .finished)
|
|
)
|
|
let state = Reducers.replayArticle([saved, changed])
|
|
XCTAssertEqual(state?.status, .finished)
|
|
XCTAssertEqual(state?.readAt, "2026-05-20T11:00:00Z")
|
|
}
|
|
|
|
func testReplayArticle_favoriteAndProgress() throws {
|
|
let saved = try envelope(EventType.articleSaved, payload: sampleSavedPayload())
|
|
let fav = try envelope(
|
|
EventType.articleFavoriteToggled,
|
|
payload: ArticleFavoriteToggledPayload(articleId: "01ABC", isFavorite: true)
|
|
)
|
|
let progress = try envelope(
|
|
EventType.articleProgressUpdated,
|
|
payload: ArticleProgressUpdatedPayload(articleId: "01ABC", newProgress: 0.42)
|
|
)
|
|
let state = Reducers.replayArticle([saved, fav, progress])
|
|
XCTAssertTrue(state?.isFavorite ?? false)
|
|
XCTAssertEqual(state?.readingProgress, 0.42)
|
|
}
|
|
|
|
func testReplayArticle_deletedSetsTombstone() throws {
|
|
let saved = try envelope(EventType.articleSaved, payload: sampleSavedPayload())
|
|
let deleted = try envelope(
|
|
EventType.articleDeleted,
|
|
occurredAt: "2026-05-20T12:00:00Z",
|
|
payload: ArticleDeletedPayload(articleId: "01ABC")
|
|
)
|
|
let state = Reducers.replayArticle([saved, deleted])
|
|
XCTAssertEqual(state?.deletedAt, "2026-05-20T12:00:00Z")
|
|
}
|
|
|
|
func testReplayArticle_unknownEventTypeIsSkipped() throws {
|
|
let saved = try envelope(EventType.articleSaved, payload: sampleSavedPayload())
|
|
struct Dummy: Codable { let foo: String }
|
|
let unknown = try envelope("SomeFutureEvent", payload: Dummy(foo: "bar"))
|
|
let state = Reducers.replayArticle([saved, unknown])
|
|
// Saved-State unverändert.
|
|
XCTAssertEqual(state?.title, "Foo")
|
|
XCTAssertEqual(state?.status, .unread)
|
|
}
|
|
|
|
func testReplayArticle_tagsSetReplacesArray() throws {
|
|
let saved = try envelope(EventType.articleSaved, payload: sampleSavedPayload())
|
|
let tags1 = try envelope(
|
|
EventType.articleTagsSet,
|
|
payload: ArticleTagsSetPayload(articleId: "01ABC", tags: ["a", "b"])
|
|
)
|
|
let tags2 = try envelope(
|
|
EventType.articleTagsSet,
|
|
payload: ArticleTagsSetPayload(articleId: "01ABC", tags: ["a", "b", "c"])
|
|
)
|
|
let state = Reducers.replayArticle([saved, tags1, tags2])
|
|
XCTAssertEqual(state?.tags, ["a", "b", "c"])
|
|
}
|
|
|
|
// MARK: - Highlight reducer
|
|
|
|
func testReplayHighlight_createdAndDeleted() throws {
|
|
let created = try envelope(
|
|
EventType.highlightCreated,
|
|
aggregateId: "highlight:H1",
|
|
payload: HighlightCreatedPayload(
|
|
highlightId: "H1",
|
|
articleId: "01ABC",
|
|
text: "wichtige Stelle",
|
|
note: nil,
|
|
color: .yellow,
|
|
startOffset: 0,
|
|
endOffset: 0,
|
|
contextBefore: nil,
|
|
contextAfter: nil
|
|
)
|
|
)
|
|
let s1 = Reducers.replayHighlight([created])
|
|
XCTAssertEqual(s1?.id, "H1")
|
|
XCTAssertEqual(s1?.text, "wichtige Stelle")
|
|
XCTAssertNil(s1?.deletedAt)
|
|
|
|
let deleted = try envelope(
|
|
EventType.highlightDeleted,
|
|
aggregateId: "highlight:H1",
|
|
occurredAt: "2026-05-20T13:00:00Z",
|
|
payload: HighlightDeletedPayload(highlightId: "H1")
|
|
)
|
|
let s2 = Reducers.replayHighlight([created, deleted])
|
|
XCTAssertEqual(s2?.deletedAt, "2026-05-20T13:00:00Z")
|
|
}
|
|
|
|
// MARK: - JSONValue roundtrip
|
|
|
|
func testJSONValue_roundtripStruct() throws {
|
|
let payload = ArticleStatusChangedPayload(articleId: "X", newStatus: .reading)
|
|
let value = try JSONValue.from(payload)
|
|
let decoded: ArticleStatusChangedPayload = try value.decode()
|
|
XCTAssertEqual(decoded.articleId, "X")
|
|
XCTAssertEqual(decoded.newStatus, .reading)
|
|
}
|
|
|
|
// MARK: - ULID
|
|
|
|
func testULID_isSortableByCreationOrder() {
|
|
let a = ULID.generate()
|
|
// minimaler Schlaf damit der Millis-Anteil sich ändert
|
|
Thread.sleep(forTimeInterval: 0.002)
|
|
let b = ULID.generate()
|
|
XCTAssertEqual(a.count, 26)
|
|
XCTAssertEqual(b.count, 26)
|
|
XCTAssertLessThan(a, b, "ULIDs sollen lexikografisch nach Zeit sortieren")
|
|
}
|
|
}
|