Files
workouts/WorkoutsTests/WorkoutStatusMachineTests.swift
rzen 6e440317c4 Restructure into a three-tab app with Progress, goals, and Meditation
The UX redesign's first landing (spec in UX-REDESIGN.md): ContentView
becomes a Today / Progress / Settings TabView, "Routine" replaces
"Split" in every user-facing string and view name (code-level types
keep their names), and workout starting moves to shared
WorkoutStarter / StartedWorkoutNavigator plumbing.

- New Progress tab: weekly goal streaks, workout trends, per-exercise
  weight progression, achievements, and the full history list
  (WorkoutLogsView -> WorkoutHistoryView).
- Goals: stable categories workouts roll up to, managed from Settings.
- New Meditation exercise + starter routine; timed sits record to
  Apple Health as Mind & Body sessions.

Claude-Session: https://claude.ai/code/session_012qw2itfzKyEJ1HpsFt8Ex4
2026-07-11 07:53:01 -04:00

180 lines
7.7 KiB
Swift

import Foundation
import Testing
import IndieSync
@testable import Workouts
/// Locks the workout status state machine on `WorkoutDocument` — the single
/// source of the status-from-logs rule every screen funnels through
/// (`recomputeStatusFromLogs`, `endKeepingProgress`) plus the per-log
/// `WorkoutLogDocument.transition(to:)` timestamp bookkeeping. Includes the
/// regression for the "end re-stamped on every edit" bug: recomputing an
/// already-completed workout must keep its original `end`, not jump it to now.
struct WorkoutStatusMachineTests {
private static let start = Date(timeIntervalSince1970: 1_700_000_000)
private static let originalEnd = Date(timeIntervalSince1970: 1_700_003_600)
private func log(id: String, order: Int, status: WorkoutStatus) -> WorkoutLogDocument {
WorkoutLogDocument(
id: id, exerciseName: "Ex-\(id)", order: order, sets: 3, reps: 10, weight: 100,
loadType: LoadType.weight.rawValue, durationSeconds: 0, currentStateIndex: 0,
status: status.rawValue, notes: nil, date: Self.start
)
}
private func workout(
status: WorkoutStatus, end: Date?, logs: [WorkoutLogDocument]
) -> WorkoutDocument {
WorkoutDocument(
schemaVersion: WorkoutDocument.currentSchemaVersion, id: "01WK", routineID: "S",
routineName: "S", start: Self.start, end: end, status: status.rawValue,
createdAt: Self.start, updatedAt: Self.start, logs: logs, metrics: nil
)
}
// MARK: - recomputeStatusFromLogs
/// All logs resolved (a mix of completed and skipped) → the workout is completed and
/// gets an `end` stamped on the transition.
@Test func allResolvedCompletesAndStampsEnd() {
var w = workout(status: .inProgress, end: nil, logs: [
log(id: "a", order: 0, status: .completed),
log(id: "b", order: 1, status: .skipped),
log(id: "c", order: 2, status: .completed),
])
w.recomputeStatusFromLogs()
#expect(w.status == WorkoutStatus.completed.rawValue)
#expect(w.end != nil) // stamped on the transition into completed
}
/// Any log started but not all resolved → in progress, `end` cleared.
@Test func anyStartedIsInProgressWithNoEnd() {
var w = workout(status: .completed, end: Self.originalEnd, logs: [
log(id: "a", order: 0, status: .completed),
log(id: "b", order: 1, status: .inProgress),
log(id: "c", order: 2, status: .notStarted),
])
w.recomputeStatusFromLogs()
#expect(w.status == WorkoutStatus.inProgress.rawValue)
#expect(w.end == nil) // reopened — a stale end must clear
}
/// No log started → not started, `end` cleared.
@Test func noneStartedIsNotStarted() {
var w = workout(status: .inProgress, end: Self.originalEnd, logs: [
log(id: "a", order: 0, status: .notStarted),
log(id: "b", order: 1, status: .notStarted),
])
w.recomputeStatusFromLogs()
#expect(w.status == WorkoutStatus.notStarted.rawValue)
#expect(w.end == nil)
}
/// A workout with no logs is not "all resolved" (the guard excludes the empty set) →
/// not started.
@Test func emptyLogsIsNotStarted() {
var w = workout(status: .inProgress, end: Self.originalEnd, logs: [])
w.recomputeStatusFromLogs()
#expect(w.status == WorkoutStatus.notStarted.rawValue)
#expect(w.end == nil)
}
/// REGRESSION (FIX-3): an already-completed workout that still has all logs resolved
/// keeps its ORIGINAL `end` when recompute runs again (e.g. the user re-opens the
/// finished workout to fix a typo). The old code re-stamped `end = Date()` every
/// time, jerking a months-old workout's end forward to now.
@Test func alreadyCompletedKeepsOriginalEnd() {
var w = workout(status: .completed, end: Self.originalEnd, logs: [
log(id: "a", order: 0, status: .completed),
log(id: "b", order: 1, status: .skipped),
])
w.recomputeStatusFromLogs()
#expect(w.status == WorkoutStatus.completed.rawValue)
#expect(w.end == Self.originalEnd) // preserved, not re-stamped to now
}
/// A workout marked completed but somehow missing an `end` (defensive: a legacy or
/// partially-written file) gets one stamped — the preservation only applies when a
/// real end already exists.
@Test func completedWithNilEndGetsStamped() {
var w = workout(status: .completed, end: nil, logs: [
log(id: "a", order: 0, status: .completed),
])
w.recomputeStatusFromLogs()
#expect(w.status == WorkoutStatus.completed.rawValue)
#expect(w.end != nil)
}
// MARK: - endKeepingProgress
/// Ending a run in progress skips every not-completed log, keeps completed ones, and
/// resolves the workout to completed with an `end` and a bumped `updatedAt`.
@Test func endKeepingProgressSkipsRemainderAndCompletes() {
var w = workout(status: .inProgress, end: nil, logs: [
log(id: "a", order: 0, status: .completed),
log(id: "b", order: 1, status: .inProgress),
log(id: "c", order: 2, status: .notStarted),
])
w.endKeepingProgress()
#expect(w.status == WorkoutStatus.completed.rawValue)
#expect(w.end != nil)
#expect(w.logs[0].status == WorkoutStatus.completed.rawValue) // kept
#expect(w.logs[1].status == WorkoutStatus.skipped.rawValue) // was in progress
#expect(w.logs[2].status == WorkoutStatus.skipped.rawValue) // was not started
#expect(w.updatedAt > Self.start)
}
// MARK: - WorkoutLogDocument.transition
@Test func transitionToInProgressStampsStartOnce() {
var l = log(id: "a", order: 0, status: .notStarted)
l.transition(to: .inProgress)
let firstStart = l.startedAt
#expect(firstStart != nil)
#expect(l.completedAt == nil)
// Re-entering inProgress must not overwrite the original startedAt.
l.transition(to: .inProgress)
#expect(l.startedAt == firstStart)
}
@Test func transitionToCompletedStampsCompletionOnceAndKeepsStart() {
var l = log(id: "a", order: 0, status: .notStarted)
l.transition(to: .inProgress)
let started = l.startedAt
l.transition(to: .completed)
let firstCompleted = l.completedAt
#expect(l.status == WorkoutStatus.completed.rawValue)
#expect(firstCompleted != nil)
#expect(l.startedAt == started) // start preserved through completion
// A second completed transition must not move completedAt.
l.transition(to: .completed)
#expect(l.completedAt == firstCompleted)
}
@Test func transitionToNotStartedClearsBothTimestamps() {
var l = log(id: "a", order: 0, status: .completed)
l.transition(to: .inProgress)
l.transition(to: .completed)
l.transition(to: .notStarted)
#expect(l.status == WorkoutStatus.notStarted.rawValue)
#expect(l.startedAt == nil)
#expect(l.completedAt == nil)
}
/// Skipping clears any completion stamp but leaves `startedAt` (the exercise may
/// have been partway through before the user skipped the rest).
@Test func transitionToSkippedClearsCompletionKeepsStart() {
var l = log(id: "a", order: 0, status: .notStarted)
l.transition(to: .inProgress)
l.transition(to: .completed)
let started = l.startedAt
l.transition(to: .skipped)
#expect(l.status == WorkoutStatus.skipped.rawValue)
#expect(l.completedAt == nil)
#expect(l.startedAt == started)
}
}