import Foundation import Testing import IndieSync @testable import Workouts /// Locks the per-set actuals capture invariant on `WorkoutLogDocument` — the /// `transition(to:)` fill/clear rules every status flip funnels through (list /// checkbox, run-flow completeExercise/resetExercise, endKeepingProgress on both /// platforms), the `fillSetEntries` plan-default shapes per `LoadType`, the /// `effectiveSetEntries` legacy fallback every read path shares, and the /// actuals-aware `WorkoutVolume`. Also pins that a v3 workout-log file (integer /// weight, no `setEntries`) still decodes at today's Double/v4 shape. struct SetEntryTests { private static let date = Date(timeIntervalSince1970: 1_700_000_000) private func log( sets: Int = 3, reps: Int = 10, weight: Double = 100, loadType: LoadType = .weight, durationSeconds: Int = 0, status: WorkoutStatus = .notStarted, setEntries: [SetEntry]? = nil ) -> WorkoutLogDocument { WorkoutLogDocument( id: "LOG", exerciseName: "Ex", order: 0, sets: sets, reps: reps, weight: weight, loadType: loadType.rawValue, durationSeconds: durationSeconds, currentStateIndex: 0, status: status.rawValue, notes: nil, date: Self.date, setEntries: setEntries ) } // MARK: - transition(to:) fill/clear rules /// Completing a log with nothing recorded (the list checkbox path) synthesizes /// a full plan-default entry set. @Test func completeFillsAllMissingEntries() { var l = log(sets: 3, reps: 10, weight: 100) l.transition(to: .completed) #expect(l.setEntries?.count == 3) #expect(l.setEntries?.allSatisfy { $0.reps == 10 && $0.weight == 100 && $0.seconds == nil } == true) // The fill stamps the completion time on the synthesized entries. #expect(l.setEntries?.allSatisfy { $0.completedAt == l.completedAt } == true) } /// Completing a log that already recorded (and adjusted) entries fills only the /// missing tail — the recorded ones are never overwritten. @Test func completeFillsOnlyMissingTail() { let recorded = SetEntry(reps: 8, weight: 90, completedAt: Self.date) var l = log(sets: 3, reps: 10, weight: 100, setEntries: [recorded]) l.transition(to: .completed) #expect(l.setEntries?.count == 3) #expect(l.setEntries?.first == recorded) // preserved, not re-filled #expect(l.setEntries?.last?.reps == 10) // tail comes from the plan #expect(l.setEntries?.last?.weight == 100) } /// Re-completing an already-full log changes nothing (idempotent, like the /// completedAt stamp). @Test func completeIsIdempotentOnFullEntries() { var l = log(sets: 2) l.transition(to: .completed) let first = l.setEntries l.transition(to: .completed) #expect(l.setEntries == first) } /// Reset to notStarted (swiping back to Ready, checkbox un-complete) wipes the /// recorded entries with the timestamps — the run never happened. @Test func notStartedClearsEntries() { var l = log(sets: 2) l.transition(to: .completed) l.transition(to: .notStarted) #expect(l.setEntries == nil) } /// Skipping keeps partial entries — "End Workout, keeping progress" must not /// discard the sets that were done. @Test func skippedPreservesPartialEntries() { let partial = [SetEntry(reps: 10, weight: 100, completedAt: Self.date)] var l = log(sets: 3, status: .inProgress, setEntries: partial) l.transition(to: .skipped) #expect(l.setEntries == partial) } /// Moving to inProgress leaves entries alone (the run flow appends explicitly). @Test func inProgressLeavesEntriesAlone() { let partial = [SetEntry(reps: 10, weight: 100, completedAt: Self.date)] var l = log(sets: 3, status: .inProgress, setEntries: partial) l.transition(to: .inProgress) #expect(l.setEntries == partial) } // MARK: - fillSetEntries per LoadType @Test func fillShapesWeightedEntries() { var l = log(reps: 12, weight: 42.5, loadType: .weight) l.fillSetEntries(upTo: 1, at: Self.date) #expect(l.setEntries == [SetEntry(reps: 12, weight: 42.5, completedAt: Self.date)]) } @Test func fillShapesBodyweightEntries() { var l = log(reps: 15, loadType: .none) l.fillSetEntries(upTo: 1, at: Self.date) #expect(l.setEntries == [SetEntry(reps: 15, completedAt: Self.date)]) } @Test func fillShapesDurationEntries() { var l = log(reps: 0, loadType: .duration, durationSeconds: 45) l.fillSetEntries(upTo: 1, at: Self.date) #expect(l.setEntries == [SetEntry(seconds: 45, completedAt: Self.date)]) } /// A fill to zero (or below the recorded count) never turns nil into [] and /// never truncates — entries are append-only. @Test func fillNeverShrinksOrMaterializesEmpty() { var untouched = log() untouched.fillSetEntries(upTo: 0, at: Self.date) #expect(untouched.setEntries == nil) let recorded = [SetEntry(reps: 8, weight: 90, completedAt: Self.date), SetEntry(reps: 6, weight: 90, completedAt: Self.date)] var full = log(setEntries: recorded) full.fillSetEntries(upTo: 1, at: Self.date) #expect(full.setEntries == recorded) } // MARK: - effectiveSetEntries fallback /// Recorded entries win verbatim. @Test func effectiveEntriesPreferRecorded() { let recorded = [SetEntry(reps: 8, weight: 90, completedAt: Self.date)] let l = log(status: .completed, setEntries: recorded) #expect(l.effectiveSetEntries == recorded) } /// A completed legacy log (no entries on disk) synthesizes plan-default entries. @Test func effectiveEntriesSynthesizeForCompletedLegacy() { var l = log(sets: 3, reps: 10, weight: 100, status: .completed) l.completedAt = Self.date let entries = l.effectiveSetEntries #expect(entries.count == 3) #expect(entries.allSatisfy { $0.reps == 10 && $0.weight == 100 && $0.completedAt == Self.date }) } /// An unfinished log with nothing recorded performed nothing. @Test func effectiveEntriesEmptyForUnfinishedLegacy() { #expect(log(status: .notStarted).effectiveSetEntries.isEmpty) #expect(log(status: .inProgress).effectiveSetEntries.isEmpty) #expect(log(status: .skipped).effectiveSetEntries.isEmpty) } // MARK: - WorkoutVolume from actuals /// Recorded actuals drive the sum: reps×weight per entry, not the plan. @Test func volumeSumsActualEntries() { let l = log(sets: 3, reps: 10, weight: 100, status: .completed, setEntries: [ SetEntry(reps: 10, weight: 100, completedAt: Self.date), // 1000 SetEntry(reps: 8, weight: 102.5, completedAt: Self.date), // 820 ]) #expect(WorkoutVolume.total([l]) == 1820) } /// A completed legacy log (no entries) matches the old sets×reps×weight formula, /// and mixed actual/legacy logs sum both correctly. @Test func volumeLegacyEqualsOldFormulaAndMixes() { let legacyCompleted = log(sets: 4, reps: 10, weight: 135, status: .completed) // 5400 #expect(WorkoutVolume.total([legacyCompleted]) == 5400) // Legacy logs that never completed also keep the old full-plan behavior. let legacySkipped = log(sets: 3, reps: 8, weight: 100, status: .skipped) // 2400 #expect(WorkoutVolume.total([legacySkipped]) == 2400) let actual = log(sets: 2, reps: 10, weight: 50, status: .completed, setEntries: [ SetEntry(reps: 10, weight: 50, completedAt: Self.date), // 500 ]) #expect(WorkoutVolume.total([legacyCompleted, legacySkipped, actual]) == 8300) } /// Non-weight logs contribute nothing, with or without entries. @Test func volumeIgnoresUnweightedLogs() { var timed = log(loadType: .duration, durationSeconds: 45, status: .completed) timed.transition(to: .completed) let bodyweight = log(reps: 12, loadType: .none, status: .completed) #expect(WorkoutVolume.total([timed, bodyweight]) == 0) } // MARK: - Legacy v3 decode /// A workout-log shape from a v3 file — integer `weight`, no `setEntries`, no /// per-log `updatedAt` — decodes at today's shape: the Int lands in the Double /// natively and both new optionals stay nil. @Test func decodesLegacyV3WorkoutLog() throws { let json = """ { "id": "01HZZZZZZZZZZZZZZZZZZZZZZW", "exerciseName": "Bench Press", "order": 0, "sets": 4, "reps": 10, "weight": 135, "loadType": 1, "durationSeconds": 0, "currentStateIndex": 4, "status": "completed", "date": "2023-11-14T22:13:20Z" } """ let decoded = try DocumentCoder.decode(WorkoutLogDocument.self, from: Data(json.utf8)) #expect(decoded.weight == 135) #expect(decoded.setEntries == nil) #expect(decoded.updatedAt == nil) // And a fractional weight round-trips through the coder. var fractional = decoded fractional.weight = 42.5 let re = try DocumentCoder.decode(WorkoutLogDocument.self, from: DocumentCoder.encode(fractional)) #expect(re.weight == 42.5) } }