- 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>
104 lines
4 KiB
Swift
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)
|
|
}
|