Seed starter splits deterministically with immutable clone-on-edit seeds
Starter splits ship as byte-canonical SplitDocument JSON with fixed ULIDs (Workouts/Resources/StarterSplits, regenerated by Scripts/generate_starter_splits.swift) and auto-seed after connect into a verifiably empty container, re-checked after a settle delay — wrong guesses are harmless because identical bytes make same-path conflicts empty and tombstones reap resurrected seeds. Seeds are immutable: SyncEngine.save(split:) forks an edited seed to a fresh ULID and soft-deletes the original, whose stub is exempt from pruning (IndieSync 0.3.0 prune(exempting:)) and vetoes resurrection forever; split views resolve by id through a redirect map to follow the swap. Add Starter Splits in Settings restores deleted seeds by lifting the veto stub and rewriting the bundle bytes. Also fixes ingestFromWatch bypassing the tombstone veto (a phone-deleted workout resurrected when a stale watch resent it) and reaps a live file immediately when its tombstone arrives. SplitDetailView also picks up the category-grouped exercise sections from the exercise-category work.
This commit is contained in:
@@ -0,0 +1,111 @@
|
||||
import Foundation
|
||||
import SwiftData
|
||||
import Testing
|
||||
import IndieSync
|
||||
@testable import Workouts
|
||||
|
||||
/// Locks the bundled starter-split library end to end: the catalog decodes and
|
||||
/// orders correctly, the pinned seed ids never drift, a pristine save re-emits
|
||||
/// byte-identical JSON (no phantom iCloud churn), every seed passes the
|
||||
/// forward-compatibility gate, and `isPristine` tracks edits correctly through
|
||||
/// both the raw document and a SwiftData cache round-trip.
|
||||
struct SeedLibraryTests {
|
||||
|
||||
@Test func catalogLoadsFourSeedsInOrder() {
|
||||
let seeds = SeedLibrary.seeds
|
||||
#expect(seeds.count == 4)
|
||||
#expect(seeds.map(\.doc.order) == [0, 1, 2, 3])
|
||||
#expect(seeds.map(\.doc.name) == ["Upper Body", "Core", "Lower Body", "Bodyweight Core"])
|
||||
}
|
||||
|
||||
/// These ids are minted from a frozen 2020 timestamp and are already shipped
|
||||
/// inside installed app bundles. Changing any of them breaks cross-device
|
||||
/// convergence (two devices would mint different ids for "the same" starter
|
||||
/// split) and voids the permanent-tombstone exemption that lets an
|
||||
/// edited-then-deleted seed's stub veto resurrection forever — regenerate
|
||||
/// only with extreme care, and never for a seed that has already shipped.
|
||||
@Test func seedIDsArePinned() {
|
||||
#expect(SeedLibrary.seedIDs == [
|
||||
"01DXF6DT0038BDC2WC3EVX8ZJ5", // Upper Body
|
||||
"01DXF6DT001MA0TM7FHJZT098Z", // Core
|
||||
"01DXF6DT006QRF1PMGK17FV505", // Lower Body
|
||||
"01DXF6DT00RV99WG172YFW4NKA", // Bodyweight Core
|
||||
])
|
||||
}
|
||||
|
||||
/// A pristine save must re-emit the exact bundle bytes, or iCloud would see a
|
||||
/// modification on every install/upgrade that touches a never-edited seed.
|
||||
@Test func encodingReproducesBundleBytesExactly() throws {
|
||||
for seed in SeedLibrary.seeds {
|
||||
#expect(try DocumentCoder.encode(seed.doc) == seed.data)
|
||||
}
|
||||
}
|
||||
|
||||
@Test func allSeedsAreReadableAtCurrentSchemaVersion() {
|
||||
for seed in SeedLibrary.seeds {
|
||||
#expect(seed.doc.isReadable)
|
||||
#expect(seed.doc.schemaVersion == SplitDocument.currentSchemaVersion)
|
||||
}
|
||||
}
|
||||
|
||||
@Test func pristineSeedsAreThemselvesPristine() {
|
||||
for seed in SeedLibrary.seeds {
|
||||
#expect(SeedLibrary.isPristine(seed.doc))
|
||||
}
|
||||
}
|
||||
|
||||
/// Edit sheets stamp `updatedAt` on every Save, so a no-op save must not read
|
||||
/// as an edit — otherwise saving without changing anything would fork the seed.
|
||||
@Test func pristineIgnoresUpdatedAtOnlyChange() throws {
|
||||
let seed = try #require(SeedLibrary.seeds.first)
|
||||
var edited = seed.doc
|
||||
edited.updatedAt = Date()
|
||||
#expect(SeedLibrary.isPristine(edited))
|
||||
}
|
||||
|
||||
@Test func pristineDetectsExerciseWeightChange() throws {
|
||||
let seed = try #require(SeedLibrary.seeds.first)
|
||||
var edited = seed.doc
|
||||
edited.exercises[0].weight += 5
|
||||
#expect(!SeedLibrary.isPristine(edited))
|
||||
}
|
||||
|
||||
@Test func pristineDetectsNameChange() throws {
|
||||
let seed = try #require(SeedLibrary.seeds.first)
|
||||
var edited = seed.doc
|
||||
edited.name = "Renamed"
|
||||
#expect(!SeedLibrary.isPristine(edited))
|
||||
}
|
||||
|
||||
/// A non-seed id has no seed to compare against; `isPristine` defaults to
|
||||
/// true since callers are expected to gate on `isSeed` before asking.
|
||||
@Test func pristineDefaultsTrueForNonSeedID() throws {
|
||||
let seed = try #require(SeedLibrary.seeds.first)
|
||||
var notASeed = seed.doc
|
||||
notASeed.id = ULID.make()
|
||||
#expect(SeedLibrary.isPristine(notASeed))
|
||||
}
|
||||
|
||||
/// Round-trips every seed through the SwiftData cache the way the metadata
|
||||
/// observer would (document → `CacheMapper.upsertSplit` → entity), then
|
||||
/// rebuilds a document from that entity the way the write path would. The
|
||||
/// result must still read as pristine, or every user's very first cache
|
||||
/// rebuild would silently fork their starter splits.
|
||||
@MainActor
|
||||
@Test func mapperRoundTripPreservesPristineness() throws {
|
||||
let schema = Schema([Split.self, Exercise.self, Workout.self, WorkoutLog.self])
|
||||
// cloudKitDatabase: .none matches WorkoutsModelContainer — without it SwiftData
|
||||
// applies CloudKit schema validation (all relationships optional), which the
|
||||
// cache entities intentionally don't satisfy.
|
||||
let config = ModelConfiguration(schema: schema, isStoredInMemoryOnly: true, cloudKitDatabase: .none)
|
||||
let container = try ModelContainer(for: schema, configurations: [config])
|
||||
let context = ModelContext(container)
|
||||
|
||||
for seed in SeedLibrary.seeds {
|
||||
CacheMapper.upsertSplit(seed.doc, relativePath: seed.doc.relativePath, into: context)
|
||||
let entity = try #require(CacheMapper.fetchSplit(id: seed.id, in: context))
|
||||
let rebuilt = SplitDocument(from: entity)
|
||||
#expect(SeedLibrary.isPristine(rebuilt))
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user