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
79 lines
3.6 KiB
Swift
79 lines
3.6 KiB
Swift
import Foundation
|
|
import Testing
|
|
@testable import Workouts_Watch_App
|
|
|
|
/// Locks the small pure helpers the watch UI leans on: the run-flow duration label, the
|
|
/// weight-unit formatter, the workout-volume roll-up the watch attaches to finished sessions,
|
|
/// and the heart-rate zone bucketing the live session folds sensor time into. All pure — no
|
|
/// SwiftUI host, no `HKWorkoutSession`.
|
|
struct WatchHelpersTests {
|
|
|
|
// MARK: - ExerciseProgressView.durationLabel (run-flow "m/s" footer)
|
|
|
|
@Test func durationLabelFormatsMinutesAndSeconds() {
|
|
#expect(ExerciseProgressView.durationLabel(90) == "1m 30s")
|
|
#expect(ExerciseProgressView.durationLabel(605) == "10m 5s")
|
|
}
|
|
|
|
@Test func durationLabelDropsWholeSecondsAndWholeMinutes() {
|
|
#expect(ExerciseProgressView.durationLabel(60) == "1 min")
|
|
#expect(ExerciseProgressView.durationLabel(120) == "2 min")
|
|
#expect(ExerciseProgressView.durationLabel(45) == "45 sec")
|
|
#expect(ExerciseProgressView.durationLabel(0) == "0 sec")
|
|
}
|
|
|
|
// MARK: - WeightUnit.format (watch row / picker subtitle)
|
|
|
|
@Test func weightUnitFormatsWithAbbreviation() {
|
|
#expect(WeightUnit.lb.format(135) == "135 lb")
|
|
#expect(WeightUnit.kg.format(60) == "60 kg")
|
|
#expect(WeightUnit.lb.format(0) == "0 lb")
|
|
}
|
|
|
|
// MARK: - WorkoutVolume.total (metric the watch fills on finish)
|
|
|
|
private static let ts = Date(timeIntervalSince1970: 1_700_000_000)
|
|
|
|
private func log(id: String, sets: Int, reps: Int, weight: Double, loadType: LoadType) -> WorkoutLogDocument {
|
|
WorkoutLogDocument(
|
|
id: id, exerciseName: "Ex-\(id)", order: 0, sets: sets, reps: reps, weight: weight,
|
|
loadType: loadType.rawValue, durationSeconds: 0, currentStateIndex: 0,
|
|
status: WorkoutStatus.completed.rawValue, notes: nil, date: Self.ts
|
|
)
|
|
}
|
|
|
|
@Test func workoutVolumeSumsSetsRepsWeightAcrossWeightedLogs() {
|
|
let logs = [
|
|
log(id: "A", sets: 4, reps: 10, weight: 135, loadType: .weight), // 5400
|
|
log(id: "B", sets: 3, reps: 8, weight: 100, loadType: .weight), // 2400
|
|
]
|
|
#expect(WorkoutVolume.total(logs) == 7800)
|
|
}
|
|
|
|
@Test func workoutVolumeExcludesNonWeightedLogs() {
|
|
let logs = [
|
|
log(id: "A", sets: 4, reps: 10, weight: 135, loadType: .weight), // 5400
|
|
log(id: "B", sets: 3, reps: 30, weight: 999, loadType: .duration), // excluded (timed)
|
|
log(id: "C", sets: 2, reps: 5, weight: 50, loadType: .none), // excluded (no load)
|
|
]
|
|
#expect(WorkoutVolume.total(logs) == 5400)
|
|
}
|
|
|
|
@Test func workoutVolumeOfNoLogsIsZero() {
|
|
#expect(WorkoutVolume.total([]) == 0)
|
|
}
|
|
|
|
// MARK: - WorkoutSessionManager.zoneIndex (HR zone bucketing)
|
|
|
|
@Test func zoneIndexBucketsByFractionOfMaxHeartRate() {
|
|
let maxHR = 200.0 // thresholds land on 120 / 140 / 160 / 180 bpm
|
|
#expect(WorkoutSessionManager.zoneIndex(for: 100, maxHR: maxHR) == 0) // 50% → zone 0
|
|
#expect(WorkoutSessionManager.zoneIndex(for: 119, maxHR: maxHR) == 0) // <60% → zone 0
|
|
#expect(WorkoutSessionManager.zoneIndex(for: 120, maxHR: maxHR) == 1) // 60% → zone 1
|
|
#expect(WorkoutSessionManager.zoneIndex(for: 140, maxHR: maxHR) == 2) // 70% → zone 2
|
|
#expect(WorkoutSessionManager.zoneIndex(for: 160, maxHR: maxHR) == 3) // 80% → zone 3
|
|
#expect(WorkoutSessionManager.zoneIndex(for: 180, maxHR: maxHR) == 4) // 90% → zone 4
|
|
#expect(WorkoutSessionManager.zoneIndex(for: 220, maxHR: maxHR) == 4) // over max → still 4
|
|
}
|
|
}
|