import Foundation import Testing import IndieSync @testable import Workouts /// Locks the workout status state machine on `WorkoutDocument` — the single /// source of the status-from-logs rule every screen funnels through /// (`recomputeStatusFromLogs`, `endKeepingProgress`) plus the per-log /// `WorkoutLogDocument.transition(to:)` timestamp bookkeeping. Includes the /// regression for the "end re-stamped on every edit" bug: recomputing an /// already-completed workout must keep its original `end`, not jump it to now. struct WorkoutStatusMachineTests { private static let start = Date(timeIntervalSince1970: 1_700_000_000) private static let originalEnd = Date(timeIntervalSince1970: 1_700_003_600) private func log(id: String, order: Int, status: WorkoutStatus) -> WorkoutLogDocument { WorkoutLogDocument( id: id, exerciseName: "Ex-\(id)", order: order, sets: 3, reps: 10, weight: 100, loadType: LoadType.weight.rawValue, durationSeconds: 0, currentStateIndex: 0, status: status.rawValue, notes: nil, date: Self.start ) } private func workout( status: WorkoutStatus, end: Date?, logs: [WorkoutLogDocument] ) -> WorkoutDocument { WorkoutDocument( schemaVersion: WorkoutDocument.currentSchemaVersion, id: "01WK", splitID: "S", splitName: "S", start: Self.start, end: end, status: status.rawValue, createdAt: Self.start, updatedAt: Self.start, logs: logs, metrics: nil ) } // MARK: - recomputeStatusFromLogs /// All logs resolved (a mix of completed and skipped) → the workout is completed and /// gets an `end` stamped on the transition. @Test func allResolvedCompletesAndStampsEnd() { var w = workout(status: .inProgress, end: nil, logs: [ log(id: "a", order: 0, status: .completed), log(id: "b", order: 1, status: .skipped), log(id: "c", order: 2, status: .completed), ]) w.recomputeStatusFromLogs() #expect(w.status == WorkoutStatus.completed.rawValue) #expect(w.end != nil) // stamped on the transition into completed } /// Any log started but not all resolved → in progress, `end` cleared. @Test func anyStartedIsInProgressWithNoEnd() { var w = workout(status: .completed, end: Self.originalEnd, logs: [ log(id: "a", order: 0, status: .completed), log(id: "b", order: 1, status: .inProgress), log(id: "c", order: 2, status: .notStarted), ]) w.recomputeStatusFromLogs() #expect(w.status == WorkoutStatus.inProgress.rawValue) #expect(w.end == nil) // reopened — a stale end must clear } /// No log started → not started, `end` cleared. @Test func noneStartedIsNotStarted() { var w = workout(status: .inProgress, end: Self.originalEnd, logs: [ log(id: "a", order: 0, status: .notStarted), log(id: "b", order: 1, status: .notStarted), ]) w.recomputeStatusFromLogs() #expect(w.status == WorkoutStatus.notStarted.rawValue) #expect(w.end == nil) } /// A workout with no logs is not "all resolved" (the guard excludes the empty set) → /// not started. @Test func emptyLogsIsNotStarted() { var w = workout(status: .inProgress, end: Self.originalEnd, logs: []) w.recomputeStatusFromLogs() #expect(w.status == WorkoutStatus.notStarted.rawValue) #expect(w.end == nil) } /// REGRESSION (FIX-3): an already-completed workout that still has all logs resolved /// keeps its ORIGINAL `end` when recompute runs again (e.g. the user re-opens the /// finished workout to fix a typo). The old code re-stamped `end = Date()` every /// time, jerking a months-old workout's end forward to now. @Test func alreadyCompletedKeepsOriginalEnd() { var w = workout(status: .completed, end: Self.originalEnd, logs: [ log(id: "a", order: 0, status: .completed), log(id: "b", order: 1, status: .skipped), ]) w.recomputeStatusFromLogs() #expect(w.status == WorkoutStatus.completed.rawValue) #expect(w.end == Self.originalEnd) // preserved, not re-stamped to now } /// A workout marked completed but somehow missing an `end` (defensive: a legacy or /// partially-written file) gets one stamped — the preservation only applies when a /// real end already exists. @Test func completedWithNilEndGetsStamped() { var w = workout(status: .completed, end: nil, logs: [ log(id: "a", order: 0, status: .completed), ]) w.recomputeStatusFromLogs() #expect(w.status == WorkoutStatus.completed.rawValue) #expect(w.end != nil) } // MARK: - endKeepingProgress /// Ending a run in progress skips every not-completed log, keeps completed ones, and /// resolves the workout to completed with an `end` and a bumped `updatedAt`. @Test func endKeepingProgressSkipsRemainderAndCompletes() { var w = workout(status: .inProgress, end: nil, logs: [ log(id: "a", order: 0, status: .completed), log(id: "b", order: 1, status: .inProgress), log(id: "c", order: 2, status: .notStarted), ]) w.endKeepingProgress() #expect(w.status == WorkoutStatus.completed.rawValue) #expect(w.end != nil) #expect(w.logs[0].status == WorkoutStatus.completed.rawValue) // kept #expect(w.logs[1].status == WorkoutStatus.skipped.rawValue) // was in progress #expect(w.logs[2].status == WorkoutStatus.skipped.rawValue) // was not started #expect(w.updatedAt > Self.start) } // MARK: - WorkoutLogDocument.transition @Test func transitionToInProgressStampsStartOnce() { var l = log(id: "a", order: 0, status: .notStarted) l.transition(to: .inProgress) let firstStart = l.startedAt #expect(firstStart != nil) #expect(l.completedAt == nil) // Re-entering inProgress must not overwrite the original startedAt. l.transition(to: .inProgress) #expect(l.startedAt == firstStart) } @Test func transitionToCompletedStampsCompletionOnceAndKeepsStart() { var l = log(id: "a", order: 0, status: .notStarted) l.transition(to: .inProgress) let started = l.startedAt l.transition(to: .completed) let firstCompleted = l.completedAt #expect(l.status == WorkoutStatus.completed.rawValue) #expect(firstCompleted != nil) #expect(l.startedAt == started) // start preserved through completion // A second completed transition must not move completedAt. l.transition(to: .completed) #expect(l.completedAt == firstCompleted) } @Test func transitionToNotStartedClearsBothTimestamps() { var l = log(id: "a", order: 0, status: .completed) l.transition(to: .inProgress) l.transition(to: .completed) l.transition(to: .notStarted) #expect(l.status == WorkoutStatus.notStarted.rawValue) #expect(l.startedAt == nil) #expect(l.completedAt == nil) } /// Skipping clears any completion stamp but leaves `startedAt` (the exercise may /// have been partway through before the user skipped the rest). @Test func transitionToSkippedClearsCompletionKeepsStart() { var l = log(id: "a", order: 0, status: .notStarted) l.transition(to: .inProgress) l.transition(to: .completed) let started = l.startedAt l.transition(to: .skipped) #expect(l.status == WorkoutStatus.skipped.rawValue) #expect(l.completedAt == nil) #expect(l.startedAt == started) } }