Files
workouts/WorkoutsTests/SetEntryTests.swift
T
rzen 394ec0989e Record per-set actuals and drive the chart and volume from them
Every completed set now writes a SetEntry (reps/weight or seconds),
pre-filled from the plan by transition(to:) so the list checkbox, both
run flows, and One More all capture for free; reset clears, skip keeps
partials. The rest and finish pages show the just-done set as a pill
that opens a stepper sheet for correcting reps and weight (2.5 lb /
1.25 kg steps). The Weight Progression chart plots the top-set actual
weight and workout volume sums recorded sets, both falling back to the
plan for legacy logs via effectiveSetEntries.

Storage side of UX #3 rides along: plan weights are Double now.
Schema bumps: SplitDocument 2→3, WorkoutDocument 3→4 (a fractional
weight fails an older Int decode, and a rewrite would strip the
irreplaceable actuals), SwiftData cache 4→5. A per-log updatedAt is
reserved for the future cross-device log merge.

Claude-Session: https://claude.ai/code/session_01HJDQQDA9QdP8zByg43H5v3
2026-07-08 12:48:37 -04:00

216 lines
9.3 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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)
}
}