Files
workouts/WorkoutsTests/ScheduleRepairPlannerTests.swift
rzen e509d0e7c5 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
2026-07-11 13:02:55 -04:00

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)
}
}