Editing a starter-seed routine forks it to a fresh ULID, but only workouts were durably repointed — schedules kept the dead seed id, and after a relaunch (empty in-memory redirect map) the Today board and edit form saw the routine as gone. - cloneSeedOnEdit now repoints schedules too (routineID + routineName track the clone; a schedule's name is a live-pointer fallback, unlike a workout's frozen run-as name) - A launch-time repair pass (pure ScheduleRepairPlanner + tests) heals already-broken references by re-attaching a dead-pointer schedule to the unique live routine matching its remembered name
89 lines
3.6 KiB
Swift
89 lines
3.6 KiB
Swift
import Foundation
|
|
import Testing
|
|
@testable import Workouts
|
|
|
|
/// Locks `ScheduleRepairPlanner`'s pure decision rules — schedule/routine cache
|
|
/// snapshots in, [(scheduleID, newRoutineID)] out. The engine's apply pass (file
|
|
/// rewrite + cache upsert + single notify) is deliberately untested here; the
|
|
/// planner carries all the judgment.
|
|
struct ScheduleRepairPlannerTests {
|
|
|
|
private func schedule(id: String = "S1", routineID: String, routineName: String) -> ScheduleRepairRef {
|
|
ScheduleRepairRef(id: id, routineID: routineID, routineName: routineName)
|
|
}
|
|
|
|
private func routine(id: String, name: String) -> RoutineRepairRef {
|
|
RoutineRepairRef(id: id, name: name)
|
|
}
|
|
|
|
// MARK: - Repairs
|
|
|
|
@Test func brokenReferenceWithUniqueNameMatchIsRepaired() {
|
|
let repairs = ScheduleRepairPlanner.plans(
|
|
schedules: [schedule(routineID: "DEAD-SEED", routineName: "Cardio")],
|
|
liveRoutines: [routine(id: "CLONE-1", name: "Cardio"),
|
|
routine(id: "R-2", name: "Upper Body")])
|
|
|
|
#expect(repairs == [ScheduleRepair(scheduleID: "S1", newRoutineID: "CLONE-1")])
|
|
}
|
|
|
|
@Test func multipleBrokenSchedulesEachRepairToTheirOwnNameMatch() {
|
|
let repairs = ScheduleRepairPlanner.plans(
|
|
schedules: [
|
|
schedule(id: "S1", routineID: "DEAD-A", routineName: "Cardio"),
|
|
schedule(id: "S2", routineID: "DEAD-B", routineName: "Upper Body"),
|
|
],
|
|
liveRoutines: [routine(id: "R-1", name: "Cardio"),
|
|
routine(id: "R-2", name: "Upper Body")])
|
|
|
|
#expect(repairs == [
|
|
ScheduleRepair(scheduleID: "S1", newRoutineID: "R-1"),
|
|
ScheduleRepair(scheduleID: "S2", newRoutineID: "R-2"),
|
|
])
|
|
}
|
|
|
|
// MARK: - Left alone
|
|
|
|
@Test func brokenReferenceWithNoNameMatchIsUntouched() {
|
|
let repairs = ScheduleRepairPlanner.plans(
|
|
schedules: [schedule(routineID: "DEAD-SEED", routineName: "Cardio")],
|
|
liveRoutines: [routine(id: "R-2", name: "Upper Body")])
|
|
|
|
#expect(repairs.isEmpty)
|
|
}
|
|
|
|
@Test func brokenReferenceWithTwoSameNameLiveRoutinesIsUntouched() {
|
|
// Two live routines both named "Cardio" — the match would be a guess, so the
|
|
// schedule is left for the edit form's "no longer available" flow.
|
|
let repairs = ScheduleRepairPlanner.plans(
|
|
schedules: [schedule(routineID: "DEAD-SEED", routineName: "Cardio")],
|
|
liveRoutines: [routine(id: "R-1", name: "Cardio"),
|
|
routine(id: "R-2", name: "Cardio")])
|
|
|
|
#expect(repairs.isEmpty)
|
|
}
|
|
|
|
@Test func healthyReferenceIsUntouchedEvenWhenAnotherRoutineSharesTheName() {
|
|
// The schedule's routineID resolves fine — a same-name routine elsewhere must
|
|
// not lure it away.
|
|
let repairs = ScheduleRepairPlanner.plans(
|
|
schedules: [schedule(routineID: "R-1", routineName: "Cardio")],
|
|
liveRoutines: [routine(id: "R-1", name: "Cardio"),
|
|
routine(id: "R-9", name: "Cardio")])
|
|
|
|
#expect(repairs.isEmpty)
|
|
}
|
|
|
|
// MARK: - Degenerate inputs
|
|
|
|
@Test func emptyInputsProduceNoRepairs() {
|
|
#expect(ScheduleRepairPlanner.plans(schedules: [], liveRoutines: []).isEmpty)
|
|
#expect(ScheduleRepairPlanner.plans(
|
|
schedules: [], liveRoutines: [routine(id: "R-1", name: "Cardio")]).isEmpty)
|
|
// Broken schedule, no live routines at all → nothing to repair to.
|
|
#expect(ScheduleRepairPlanner.plans(
|
|
schedules: [schedule(routineID: "DEAD", routineName: "Cardio")],
|
|
liveRoutines: []).isEmpty)
|
|
}
|
|
}
|