import Foundation import Testing import IndieSync @testable import Workouts /// Locks the decision table of `SeedReconcilePlanner` — the safety-critical logic that /// upgrades stale starter-seed revisions and adds newly-shipped seeds on an existing /// install without ever duplicating a user's split, resurrecting a deleted seed, or /// downgrade-rewriting a newer app version's file. All fixtures are plain structs so /// every assertion is a pure, deterministic function of the inputs. struct SeedReconcilePlannerTests { // MARK: - Fixtures private func exercise( name: String, order: Int = 0, sets: Int = 3, reps: Int = 10, weight: Double = 100 ) -> ExerciseDocument { ExerciseDocument( id: "EX-\(name)-\(order)", name: name, order: order, sets: sets, reps: reps, weight: weight, loadType: LoadType.weight.rawValue, durationSeconds: 0 ) } /// A seed-shaped split at the current schema version, with a fixed frozen date so /// two builds of "the same" content compare equal. private func seed( id: String = "01DXF6DT0038BDC2WC3EVX8ZJ5", name: String = "Upper Body", schemaVersion: Int = SplitDocument.currentSchemaVersion, exercises: [ExerciseDocument]? = nil ) -> SplitDocument { SplitDocument( schemaVersion: schemaVersion, id: id, name: name, color: "indigo", systemImage: "dumbbell.fill", order: 0, createdAt: Date(timeIntervalSince1970: 0), updatedAt: Date(timeIntervalSince1970: 0), exercises: exercises ?? [exercise(name: "Bench Press")], activityType: nil ) } // MARK: - Live file present /// An older app version wrote this seed at schemaVersion 1 (and, on disk, carried /// extra now-removed keys — which decode into the current struct as simply absent). /// The content is otherwise identical, so normalizing the version stamp must make it /// read as up to date: no churn, no rewrite. @Test func semanticEqualitySkipsAcrossSchemaVersions() { let bundle = seed() // current schemaVersion var live = bundle live.schemaVersion = 1 // older wrapper, same content let input = SeedReconcileInput(seedID: bundle.id, seedDoc: bundle, liveDoc: live, hasStub: false) #expect(SeedReconcilePlanner.decision(for: input, liveSplitNames: []) == .skip(.upToDate)) } /// The bundle seed gained an exercise since this install wrote its file — a real /// content change must upgrade (overwrite with current bundle bytes), even though /// the live file is a lower schema version. @Test func contentChangeUpgrades() { let bundle = seed(exercises: [exercise(name: "Bench Press"), exercise(name: "Overhead Press", order: 1)]) var live = seed(exercises: [exercise(name: "Bench Press")]) // one fewer exercise live.schemaVersion = 1 let input = SeedReconcileInput(seedID: bundle.id, seedDoc: bundle, liveDoc: live, hasStub: false) #expect(SeedReconcilePlanner.decision(for: input, liveSplitNames: []) == .upgrade) } /// A weight tweak alone is enough of a content change to upgrade. @Test func exerciseWeightChangeUpgrades() { let bundle = seed(exercises: [exercise(name: "Bench Press", weight: 135)]) let live = seed(exercises: [exercise(name: "Bench Press", weight: 115)]) let input = SeedReconcileInput(seedID: bundle.id, seedDoc: bundle, liveDoc: live, hasStub: false) #expect(SeedReconcilePlanner.decision(for: input, liveSplitNames: []) == .upgrade) } /// A live file written by a NEWER app version (schemaVersion above current) is /// quarantined — never downgrade-rewrite it, even though it differs. @Test func newerSchemaFileIsQuarantined() { let bundle = seed() var live = seed(exercises: [exercise(name: "Something New")]) live.schemaVersion = SplitDocument.currentSchemaVersion + 1 #expect(!live.isReadable) // guards the premise let input = SeedReconcileInput(seedID: bundle.id, seedDoc: bundle, liveDoc: live, hasStub: false) #expect(SeedReconcilePlanner.decision(for: input, liveSplitNames: []) == .skip(.quarantined)) } // MARK: - No live file /// No live file but a stub exists → the user deleted this seed. The veto stands. @Test func stubVetoesResurrection() { let bundle = seed() let input = SeedReconcileInput(seedID: bundle.id, seedDoc: bundle, liveDoc: nil, hasStub: true) #expect(SeedReconcilePlanner.decision(for: input, liveSplitNames: []) == .skip(.vetoed)) } /// No live file, no stub, but a live split (a legacy runtime-built or user-cloned /// "Upper Body") already uses the seed's name → writing it would duplicate by name. @Test func nameCollisionSkips() { let bundle = seed(name: "Upper Body") let input = SeedReconcileInput(seedID: bundle.id, seedDoc: bundle, liveDoc: nil, hasStub: false) #expect(SeedReconcilePlanner.decision(for: input, liveSplitNames: ["Upper Body", "Core"]) == .skip(.nameCollision)) } /// No live file, no stub, no same-name split → a genuinely missing seed (newly /// shipped, or a pre-fixed-ULID install) gets written. @Test func missingSeedIsWritten() { let bundle = seed(name: "Bodyweight Core") let input = SeedReconcileInput(seedID: bundle.id, seedDoc: bundle, liveDoc: nil, hasStub: false) #expect(SeedReconcilePlanner.decision(for: input, liveSplitNames: ["Upper Body", "Core"]) == .write) } /// A stub present but the same name also live: the veto is checked first, so it /// still skips (as vetoed) rather than as a name collision — either way, no write. @Test func stubTakesPrecedenceOverNameCollision() { let bundle = seed(name: "Upper Body") let input = SeedReconcileInput(seedID: bundle.id, seedDoc: bundle, liveDoc: nil, hasStub: true) #expect(SeedReconcilePlanner.decision(for: input, liveSplitNames: ["Upper Body"]) == .skip(.vetoed)) } // MARK: - Restore /// Restore rewrites only a seed that is both absent and free of a name collision; /// the presence of a veto stub is irrelevant (restore lifts it). @Test func shouldRestoreOnlyWhenAbsentAndNameFree() { // Absent, name free → restore (whether or not a stub exists). #expect(SeedReconcilePlanner.shouldRestore(hasLiveFile: false, seedName: "Upper Body", liveSplitNames: [])) // Live file already present → reconcile handles any upgrade; restore leaves it. #expect(!SeedReconcilePlanner.shouldRestore(hasLiveFile: true, seedName: "Upper Body", liveSplitNames: [])) // A live same-name split exists → don't duplicate it. #expect(!SeedReconcilePlanner.shouldRestore(hasLiveFile: false, seedName: "Upper Body", liveSplitNames: ["Upper Body"])) } }