The library grows to 64 exercises: arm circles, torso twist, leg swings, hip and neck mobility, marching, calf raises, and a full stretching set (forward fold, quad, calf, chest, triceps, hip flexor, butterfly, cobra, child's pose), each with an authored motion rig and reference page. Eight new starter splits join the catalog — Upper and Lower Body Warm-Up, Morning Wake-Up, Morning Mobility, Full Body Stretch, Evening Stretch, Free Weight Basics, and Full Body Machines — regenerated deterministically at split schema v3 with fixed ULIDs. Claude-Session: https://claude.ai/code/session_01HJDQQDA9QdP8zByg43H5v3
126 lines
5.6 KiB
Swift
126 lines
5.6 KiB
Swift
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 catalogLoadsTwelveSeedsInOrder() {
|
|
let seeds = SeedLibrary.seeds
|
|
#expect(seeds.count == 12)
|
|
#expect(seeds.map(\.doc.order) == Array(0...11))
|
|
#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",
|
|
])
|
|
}
|
|
|
|
/// 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
|
|
"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
|
|
])
|
|
}
|
|
|
|
/// 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))
|
|
}
|
|
}
|
|
}
|