pageta-native/Tests/UnitTests/EventSyncReducerTests.swift
Till JS 53420b1c42 refactor(event-sync): auf geteilte ManaEventSync-Lib migrieren
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>
2026-05-25 12:40:11 +02:00

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")
}
}