zitare-native/Tests/UnitTests/SnapshotSyncTests.swift
Till c89d48c6f6 ζ-2 native: SwiftData-Snapshot-Cache + DailyQuoteWidget
- SnapshotModels.swift: CachedQuote (slug-unique, themes/regions
  als CSV), SnapshotMeta (singleton mit lastSyncedAt + totalCount),
  SnapshotContainer.make() mit App-Group-Store-URL (Fallback auf
  App-Container für Dev ohne Apple-Dev-Portal-Setup)
- SnapshotSync (actor) mit injectable Loader für Tests: refresh /
  refreshIfStale / tryRefresh (fail-soft). Re-konsolidiert beim Pull
  (Update + Insert + Delete entzogene Slugs). 24h-Staleness-Default.
- DailyQuoteWidget: Hash-of-Day-Picker aus SwiftData, drei Sizes,
  Mitternacht-Refresh-Policy, Placeholder bei leerem Store. Widget-
  Target zieht SnapshotModels.swift mit (project.yml).
- ZitareNativeApp triggert SnapshotSync.tryRefresh() bei Launch +
  WidgetCenter.reloadAllTimelines() danach.
- AppConfig.snapshotURL = webBaseURL/index-min.json (Web-Endpoint
  noch nicht live, fail-soft).
- DeepLinkRouter Substring-Guard fix (`/t` statt `/t/` im
  Prefix-Array, sonst greift hasPrefix("/t//") nicht).
- 22 Tests grün (6 AppConfig + 11 DeepLinkRouter + 3 SnapshotSync +
  1 UI + 1 Widget-Compile-Smoke), swiftlint 0 violations in 22 Files

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 13:16:05 +02:00

104 lines
4 KiB
Swift

import SwiftData
import XCTest
@testable import ZitareNative
@MainActor
final class SnapshotSyncTests: XCTestCase {
/// Erster Pull: 2 Quotes, beide werden persistiert.
func test_persistsInitialPayload() async throws {
let container = try SnapshotContainer.make(inMemory: true)
let sync = SnapshotSync.forTesting(container: container) { Self.firstRun }
try await sync.refresh()
let context = ModelContext(container)
let quotes = try context.fetch(FetchDescriptor<CachedQuote>())
XCTAssertEqual(quotes.count, 2)
let bySlug = Dictionary(uniqueKeysWithValues: quotes.map { ($0.slug, $0) })
XCTAssertEqual(bySlug["spitteler-x"]?.authorName, "Carl Spitteler")
XCTAssertEqual(bySlug["keller-x"]?.regions, ["zuerich", "schweiz"])
let metas = try context.fetch(FetchDescriptor<SnapshotMeta>())
XCTAssertEqual(metas.count, 1)
XCTAssertEqual(metas.first?.totalCount, 2)
}
/// Zweiter Pull (gleicher Container): keller-x zurückgezogen,
/// spitteler-x text geändert, neuere-x dazu Update + Delete +
/// Insert wie erwartet.
func test_reconcilesOnSecondPull() async throws {
let container = try SnapshotContainer.make(inMemory: true)
let sync1 = SnapshotSync.forTesting(container: container) { Self.firstRun }
try await sync1.refresh()
let sync2 = SnapshotSync.forTesting(container: container) { Self.secondRun }
try await sync2.refresh()
let context = ModelContext(container)
let after = try context.fetch(FetchDescriptor<CachedQuote>())
let afterBySlug = Dictionary(uniqueKeysWithValues: after.map { ($0.slug, $0) })
XCTAssertEqual(after.count, 2)
XCTAssertEqual(afterBySlug["spitteler-x"]?.text, "A-updated")
XCTAssertNotNil(afterBySlug["neuere-x"])
XCTAssertNil(afterBySlug["keller-x"])
}
/// `refreshIfStale` macht kein Refresh, wenn lastSyncedAt frisch.
func test_freshSnapshotSkipsRefresh() async throws {
let container = try SnapshotContainer.make(inMemory: true)
let sync = SnapshotSync.forTesting(
container: container,
staleAfter: 3600
) { Self.smallPayload }
try await sync.refresh()
// Loader, der explosiv knallt refreshIfStale darf ihn nicht
// aufrufen, weil noch frisch.
let brokenSync = SnapshotSync(
container: container,
loader: { throw SnapshotSyncError.badResponse(-999) },
staleAfter: 3600
)
try await brokenSync.refreshIfStale() // soll *nicht* werfen
}
// MARK: - Fixtures
nonisolated(unsafe) static let firstRun: Data = .init(#"""
{
"generatedAt": "2026-05-08T20:48:48.795Z",
"count": 2,
"quotes": [
{ "slug": "spitteler-x", "text": "A",
"authorSlug": "spitteler", "authorName": "Carl Spitteler",
"language": "de", "themeSlugs": ["lebenskunst"],
"regionSlugs": ["schweiz"] },
{ "slug": "keller-x", "text": "B",
"authorSlug": "keller", "authorName": "Gottfried Keller",
"language": "de", "themeSlugs": [],
"regionSlugs": ["zuerich","schweiz"] }
]
}
"""#.utf8)
nonisolated(unsafe) static let secondRun: Data = .init(#"""
{
"generatedAt": "2026-05-09T00:00:00.000Z",
"count": 2,
"quotes": [
{ "slug": "spitteler-x", "text": "A-updated",
"authorSlug": "spitteler", "authorName": "Carl Spitteler",
"language": "de", "themeSlugs": ["lebenskunst"],
"regionSlugs": ["schweiz"] },
{ "slug": "neuere-x", "text": "C",
"authorSlug": "neuere", "authorName": "Anon",
"language": "de", "themeSlugs": ["philosophie"],
"regionSlugs": [] }
]
}
"""#.utf8)
nonisolated(unsafe) static let smallPayload: Data = .init(#"""
{"generatedAt":"2026-05-01T00:00:00.000Z","count":1,
"quotes":[{"slug":"a","text":"A","authorSlug":null,
"authorName":null,"language":"de","themeSlugs":[],"regionSlugs":[]}]}
"""#.utf8)
}