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
This commit is contained in:
@@ -18,7 +18,7 @@ struct DuplicateCleanupPlannerTests {
|
||||
|
||||
private func exercise(
|
||||
name: String = "Bench Press", order: Int = 0, sets: Int = 3, reps: Int = 10,
|
||||
weight: Int = 100, loadType: Int = LoadType.weight.rawValue, duration: Int = 0
|
||||
weight: Double = 100, loadType: Int = LoadType.weight.rawValue, duration: Int = 0
|
||||
) -> ExerciseDocument {
|
||||
ExerciseDocument(
|
||||
id: "EX-\(name)-\(order)", name: name, order: order, sets: sets, reps: reps, weight: weight,
|
||||
|
||||
@@ -13,7 +13,7 @@ struct SeedReconcilePlannerTests {
|
||||
// MARK: - Fixtures
|
||||
|
||||
private func exercise(
|
||||
name: String, order: Int = 0, sets: Int = 3, reps: Int = 10, weight: Int = 100
|
||||
name: String, order: Int = 0, sets: Int = 3, reps: Int = 10, weight: Double = 100
|
||||
) -> ExerciseDocument {
|
||||
ExerciseDocument(
|
||||
id: "EX-\(name)-\(order)", name: name, order: order, sets: sets, reps: reps,
|
||||
|
||||
@@ -0,0 +1,215 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -45,7 +45,7 @@ struct SplitDocumentCodableTests {
|
||||
let decoded = try DocumentCoder.decode(SplitDocument.self, from: data)
|
||||
|
||||
#expect(decoded == original)
|
||||
#expect(decoded.schemaVersion == 2)
|
||||
#expect(decoded.schemaVersion == 3)
|
||||
#expect(decoded.relativePath == "Splits/\(original.id).json")
|
||||
#expect(decoded.exercises.first?.machineSettings?.count == 2)
|
||||
}
|
||||
@@ -100,10 +100,11 @@ struct SplitDocumentCodableTests {
|
||||
|
||||
/// A v1 split file carrying the since-removed keys (`weightLastUpdated`,
|
||||
/// `weightReminderWeeks`, `category`) must still decode — Codable ignores the
|
||||
/// extra keys, and every remaining field is present in old files. Re-stamping
|
||||
/// `schemaVersion` to the current value (what the app does on any rewrite via the
|
||||
/// cache→document mapper) round-trips cleanly at v2.
|
||||
@Test func decodesLegacyV1SplitAndRoundTripsAtV2() throws {
|
||||
/// extra keys, every remaining field is present in old files, and the integer
|
||||
/// `weight` decodes natively into today's `Double`. Re-stamping `schemaVersion`
|
||||
/// to the current value (what the app does on any rewrite via the
|
||||
/// cache→document mapper) round-trips cleanly at the current version.
|
||||
@Test func decodesLegacyV1SplitAndRoundTripsAtCurrent() throws {
|
||||
let json = """
|
||||
{
|
||||
"schemaVersion": 1,
|
||||
@@ -143,7 +144,7 @@ struct SplitDocumentCodableTests {
|
||||
let reData = try DocumentCoder.encode(decoded)
|
||||
let reDecoded = try DocumentCoder.decode(SplitDocument.self, from: reData)
|
||||
#expect(reDecoded == decoded)
|
||||
#expect(reDecoded.schemaVersion == 2)
|
||||
#expect(reDecoded.schemaVersion == 3)
|
||||
}
|
||||
|
||||
/// A workout-log file written before `completed` was removed still carries the
|
||||
|
||||
@@ -34,13 +34,16 @@ struct WorkoutDocumentMapperTests {
|
||||
|
||||
private func log(
|
||||
id: String, name: String, order: Int, status: WorkoutStatus,
|
||||
machineSettings: [MachineSetting]? = nil, startedAt: Date? = nil, completedAt: Date? = nil
|
||||
weight: Double = 135, machineSettings: [MachineSetting]? = nil,
|
||||
startedAt: Date? = nil, completedAt: Date? = nil,
|
||||
setEntries: [SetEntry]? = nil, updatedAt: Date? = nil
|
||||
) -> WorkoutLogDocument {
|
||||
WorkoutLogDocument(
|
||||
id: id, exerciseName: name, order: order, sets: 4, reps: 10, weight: 135,
|
||||
id: id, exerciseName: name, order: order, sets: 4, reps: 10, weight: weight,
|
||||
loadType: LoadType.weight.rawValue, durationSeconds: 0, currentStateIndex: 2,
|
||||
status: status.rawValue, notes: "note-\(name)", date: Self.logDate,
|
||||
machineSettings: machineSettings, startedAt: startedAt, completedAt: completedAt
|
||||
machineSettings: machineSettings, startedAt: startedAt, completedAt: completedAt,
|
||||
setEntries: setEntries, updatedAt: updatedAt
|
||||
)
|
||||
}
|
||||
|
||||
@@ -64,11 +67,18 @@ struct WorkoutDocumentMapperTests {
|
||||
start: Self.start, end: Self.end, status: WorkoutStatus.completed.rawValue,
|
||||
createdAt: Self.created, updatedAt: Self.updated,
|
||||
logs: [
|
||||
// order 0: not a machine exercise (nil), never started.
|
||||
// order 0: not a machine exercise (nil), never started, no entries.
|
||||
log(id: "LOG-A", name: "Bench Press", order: 0, status: .notStarted, machineSettings: nil),
|
||||
// order 1: machine exercise, nothing recorded yet (empty ≠ nil), completed with timestamps.
|
||||
log(id: "LOG-B", name: "Leg Press", order: 1, status: .completed, machineSettings: [],
|
||||
startedAt: Self.startedAt, completedAt: Self.completedAt),
|
||||
// order 1: machine exercise, nothing recorded yet (empty ≠ nil), completed with
|
||||
// timestamps, a fractional weight, recorded per-set actuals, and a per-log updatedAt.
|
||||
log(id: "LOG-B", name: "Leg Press", order: 1, status: .completed,
|
||||
weight: 42.5, machineSettings: [],
|
||||
startedAt: Self.startedAt, completedAt: Self.completedAt,
|
||||
setEntries: [
|
||||
SetEntry(reps: 10, weight: 42.5, completedAt: Self.startedAt),
|
||||
SetEntry(reps: 8, weight: 45, completedAt: Self.completedAt),
|
||||
],
|
||||
updatedAt: Self.updated),
|
||||
// order 2: machine exercise with recorded settings, skipped.
|
||||
log(id: "LOG-C", name: "Chest Press", order: 2, status: .skipped,
|
||||
machineSettings: [MachineSetting(name: "Seat Height", value: "4"),
|
||||
@@ -95,9 +105,17 @@ struct WorkoutDocumentMapperTests {
|
||||
#expect(rebuilt.status == WorkoutStatus.completed.rawValue)
|
||||
#expect(rebuilt.logs.count == 3)
|
||||
#expect(rebuilt.logs[0].machineSettings == nil) // not a machine exercise
|
||||
#expect(rebuilt.logs[0].setEntries == nil) // nothing recorded stays nil
|
||||
#expect(rebuilt.logs[0].updatedAt == nil)
|
||||
#expect(rebuilt.logs[1].machineSettings == []) // machine, nothing recorded
|
||||
#expect(rebuilt.logs[1].startedAt == Self.startedAt)
|
||||
#expect(rebuilt.logs[1].completedAt == Self.completedAt)
|
||||
#expect(rebuilt.logs[1].weight == 42.5) // fractional weight survives
|
||||
#expect(rebuilt.logs[1].setEntries == [
|
||||
SetEntry(reps: 10, weight: 42.5, completedAt: Self.startedAt),
|
||||
SetEntry(reps: 8, weight: 45, completedAt: Self.completedAt),
|
||||
])
|
||||
#expect(rebuilt.logs[1].updatedAt == Self.updated) // per-log merge stamp survives
|
||||
#expect(rebuilt.logs[2].machineSettings?.count == 2)
|
||||
#expect(rebuilt.metrics == fullMetrics())
|
||||
#expect(rebuilt.metrics?.hrZoneSeconds == [10, 20, 30, 40, 50])
|
||||
|
||||
Reference in New Issue
Block a user