Files
workouts/WorkoutsTests/SeedLibraryTests.swift
T
rzen 16b61946d5 Treat order as presentation in seed logic; add per-seed restore and routine duplication
Drag-to-reorder writes Routine.order through the document, which used to
read as a content edit: isPristine would fork a starter for a mere reorder,
and reconcile's semantic compare would clobber a reordered seed file back
to bundle order. Both now normalize order (and updatedAt) away — a fixed-
ULID file still never holds user content; ordering is bookkeeping.

SyncEngine gains restoreSeed(id:) — the per-seed analog of the bulk
restore, sharing one restoreSeedIfEligible core — and duplicate(routine:),
which copies any routine (starter or not) to a fresh ULID with fresh
exercise ids, a unique "… Copy" name, and last position in the list.

Claude-Session: https://claude.ai/code/session_01H8VxUX4ckjU3vRF5M4L5FV
2026-07-15 10:45:02 -04:00

158 lines
7.0 KiB
Swift

import Foundation
import SwiftData
import Testing
import IndieSync
@testable import Workouts
/// Locks the bundled starter-routine 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 catalogLoadsFourteenSeedsInOrder() {
let seeds = SeedLibrary.seeds
#expect(seeds.count == 14)
#expect(seeds.map(\.doc.order) == Array(0...13))
#expect(seeds.map(\.doc.name) == [
"Upper Body", "Core", "Lower Body", "Bodyweight Core",
"Full Body Machines", "Free Weight Basics",
"Upper Body Warm-Up", "Lower Body Warm-Up",
"Morning Wake-Up", "Morning Mobility",
"Full Body Stretch", "Evening Stretch", "Cardio", "Meditation",
])
}
/// 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
/// routine) 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
"01DXF6DT00SZ0NPXPN36415NKM", // Full Body Machines
"01DXF6DT00WY3HMCH6E841YQE7", // Free Weight Basics
"01DXF6DT00G56WC0HS6RJWMFMD", // Upper Body Warm-Up
"01DXF6DT003QB4ASSR8JB1JT6A", // Lower Body Warm-Up
"01DXF6DT00FJ5F1HRENQPC1DTG", // Morning Wake-Up
"01DXF6DT00QHJ9G4SDMEX64NZD", // Morning Mobility
"01DXF6DT003PRJB3FGYSSGTQ5X", // Full Body Stretch
"01DXF6DT00M5YRFPHJQ2FS2B9P", // Evening Stretch
"01DXF6DT00BXJ6N63ZBX9M9VNH", // Cardio
"01DXF6DT00ANFJR7GX9G808G4M", // Meditation
])
}
/// 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 == RoutineDocument.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))
}
/// Dragging a starter to reorder the Library list writes a new `order` to it. Order
/// is presentation, not content, so a reorder must NOT fork the seed — an order-only
/// change reads as pristine.
@Test func pristineIgnoresOrderOnlyChange() throws {
let seed = try #require(SeedLibrary.seeds.first)
var edited = seed.doc
edited.order += 5
#expect(SeedLibrary.isPristine(edited))
}
/// A reorder save also stamps `updatedAt`; order + updatedAt together must still read
/// as pristine so a drag never forks a starter.
@Test func pristineIgnoresOrderAndUpdatedAtChange() throws {
let seed = try #require(SeedLibrary.seeds.first)
var edited = seed.doc
edited.order += 3
edited.updatedAt = Date()
#expect(SeedLibrary.isPristine(edited))
}
/// Order tolerance must not mask a genuine content edit: a real change (here, a
/// different rest length) is still detected even when `order` also differs.
@Test func pristineDetectsContentChangeDespiteOrderChange() throws {
let seed = try #require(SeedLibrary.seeds.first)
var edited = seed.doc
edited.order += 2
edited.restSeconds = (seed.doc.restSeconds ?? 60) + 30
#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.upsertRoutine` → 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 routines.
@MainActor
@Test func mapperRoundTripPreservesPristineness() throws {
let schema = Schema([Routine.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.upsertRoutine(seed.doc, relativePath: seed.doc.relativePath, into: context)
let entity = try #require(CacheMapper.fetchRoutine(id: seed.id, in: context))
let rebuilt = RoutineDocument(from: entity)
#expect(SeedLibrary.isPristine(rebuilt))
}
}
}