Files
rzen e12fe31152 Add Warm-Up and Stretching activity types
Adds WorkoutActivityType.warmUp (HealthKit .preparationAndRecovery) and
.stretching (.flexibility), and retags the six starter splits that were all
mislabeled as Functional Strength:

- Warm-Up:    Upper Body Warm-Up, Lower Body Warm-Up, Morning Wake-Up
- Stretching: Morning Mobility, Full Body Stretch, Evening Stretch

The split editor's activity picker surfaces them automatically (CaseIterable).
Older app versions decode the new raw values as the default type — additive and
not schema-gated, so no quarantine.
2026-07-09 15:45:31 -04:00

260 lines
11 KiB
Swift
Raw Permalink 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)
}
}
/// The Completed screen's one-line actuals summary (`SetEntryFormat.actualsLine`):
/// a uniform per-set list collapses to "N × v" for brevity, anything varying stays
/// a "· "-separated list.
struct SetEntryFormatTests {
private static let date = Date(timeIntervalSince1970: 1_700_000_000)
private func entry(_ reps: Int? = nil, w: Double? = nil, s: Int? = nil) -> SetEntry {
if let s { return SetEntry(seconds: s, completedAt: Self.date) }
if let w { return SetEntry(reps: reps, weight: w, completedAt: Self.date) }
return SetEntry(reps: reps, completedAt: Self.date)
}
private func line(_ entries: [SetEntry]) -> String {
SetEntryFormat.actualsLine(entries, weightUnit: .lb)
}
/// Four identical weighted sets read as "4 × 10", not "10 · 10 · 10 · 10".
@Test func uniformWeightedCollapses() {
let e = entry(10, w: 130)
#expect(line([e, e, e, e]) == "4 × 10 reps @ 130 lb")
}
/// Varying reps at one weight stay a list.
@Test func varyingRepsStayAList() {
#expect(line([entry(10, w: 100), entry(8, w: 100), entry(6, w: 100)]) == "10 · 8 · 6 reps @ 100 lb")
}
/// Varying weight falls back to the per-set "reps × weight" list.
@Test func varyingWeightIsPerSet() {
#expect(line([entry(10, w: 100), entry(8, w: 90)]) == "10 × 100 lb · 8 × 90 lb")
}
/// A single set is just its value — never "1 × 10".
@Test func singleSetIsNotCollapsed() {
#expect(line([entry(10, w: 130)]) == "10 reps @ 130 lb")
}
/// Bodyweight (no weight) and duration lists collapse the same way.
@Test func bodyweightAndDurationCollapse() {
#expect(line([entry(12), entry(12), entry(12)]) == "3 × 12 reps")
#expect(line([entry(s: 45), entry(s: 45), entry(s: 45)]) == "3 × 45 sec")
#expect(line([entry(s: 45), entry(s: 45), entry(s: 30)]) == "45 · 45 · 30 sec")
}
}