Keep schedules attached to routines across seed clone-on-edit forks
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
This commit is contained in:
@@ -0,0 +1,88 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user