Files
workouts/WorkoutsTests/WCPayloadRoundTripTests.swift
T
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

156 lines
7.4 KiB
Swift

import Foundation
import Testing
import IndieSync
@testable import Workouts
/// Round-trips every `WCPayload` wire shape — the iPhone↔Watch bridge's only
/// serialization boundary. Routines/workouts ride as JSON `Data` blobs (via
/// `DocumentCoder`, ISO-8601), settings and edit locks as native plist scalars, and
/// the ephemeral live-run frames as native values (so their timers keep sub-second
/// precision). A field dropped or a `nil`-vs-empty confusion here silently desyncs
/// the two devices, so each direction is pinned end to end, including the
/// decode-failure contract (`nil` means "failed to decode", never "empty list").
struct WCPayloadRoundTripTests {
private static let ts = Date(timeIntervalSince1970: 1_700_000_000) // whole second: ISO-8601 safe
private func routine(id: String, name: String) -> RoutineDocument {
RoutineDocument(
schemaVersion: RoutineDocument.currentSchemaVersion, id: id, name: name, color: "blue",
systemImage: "dumbbell.fill", order: 0, createdAt: Self.ts, updatedAt: Self.ts,
exercises: [ExerciseDocument(id: "EX-\(id)", name: "Bench Press", order: 0, sets: 4,
reps: 10, weight: 135, loadType: 1, durationSeconds: 0,
machineSettings: [MachineSetting(name: "Seat", value: "4")])],
activityType: nil
)
}
private func workout(id: String) -> WorkoutDocument {
WorkoutDocument(
schemaVersion: WorkoutDocument.currentSchemaVersion, id: id, routineID: "S", routineName: "Push",
start: Self.ts, end: Self.ts, status: WorkoutStatus.completed.rawValue,
createdAt: Self.ts, updatedAt: Self.ts,
logs: [WorkoutLogDocument(id: "L-\(id)", exerciseName: "Bench Press", order: 0, sets: 4,
reps: 10, weight: 135, loadType: 1, durationSeconds: 0,
currentStateIndex: 4, status: WorkoutStatus.completed.rawValue,
notes: "solid", date: Self.ts)],
metrics: nil
)
}
// MARK: - Phone → Watch state + settings
@Test func stateRoundTripsRoutinesWorkoutsAndSettings() {
let routines = [routine(id: "SP1", name: "Upper Body"), routine(id: "SP2", name: "Lower Body")]
let workouts = [workout(id: "01WKA"), workout(id: "01WKB")]
let dict = WCPayload.encodeState(
routines: routines, workouts: workouts, restSeconds: 90, doneCountdownSeconds: 5,
weightUnit: "kg", editingWorkoutID: "01WKA", editingRoutineID: "SP1"
)
#expect(WCPayload.decodeRoutines(dict) == routines)
#expect(WCPayload.decodeWorkouts(dict) == workouts)
#expect(WCPayload.decodeRestSeconds(dict) == 90)
#expect(WCPayload.decodeDoneCountdownSeconds(dict) == 5)
#expect(WCPayload.decodeWeightUnit(dict) == "kg")
#expect(WCPayload.decodeEditingWorkoutID(dict) == "01WKA")
#expect(WCPayload.decodeEditingRoutineID(dict) == "SP1")
}
/// Nil edit locks mean "not editing" → the keys are absent, so the decoders return
/// nil (the receiver clears the lock). An empty routines/workouts list is legitimately
/// empty — it still encodes a key and decodes back to `[]`, never nil.
@Test func stateOmitsNilEditLocksAndKeepsEmptyListsDistinct() {
let dict = WCPayload.encodeState(
routines: [], workouts: [], restSeconds: 60, doneCountdownSeconds: 3,
weightUnit: "lb", editingWorkoutID: nil, editingRoutineID: nil
)
#expect(WCPayload.decodeEditingWorkoutID(dict) == nil)
#expect(WCPayload.decodeEditingRoutineID(dict) == nil)
// Authoritative empty push: an empty (present) list decodes to [], not nil.
#expect(WCPayload.decodeRoutines(dict) == [])
#expect(WCPayload.decodeWorkouts(dict) == [])
}
/// An absent routines/workouts key decodes to `[]` (nothing to apply), NOT nil — nil is
/// reserved for a genuine decode failure below.
@Test func absentRoutinesWorkoutsKeysDecodeToEmpty() {
let empty: [String: Any] = [:]
#expect(WCPayload.decodeRoutines(empty) == [])
#expect(WCPayload.decodeWorkouts(empty) == [])
}
/// A present-but-corrupt payload (a build-mismatch schema the receiver can't decode)
/// yields `nil` — the signal the watch bridge's nil-decode guard uses to skip
/// pruning against a bogus set. This must stay distinct from an empty list.
@Test func corruptStateDecodesToNilNotEmpty() {
let garbage = Data([0xFF, 0xFE, 0x00, 0x01])
let dict: [String: Any] = [WCPayload.routinesKey: garbage, WCPayload.workoutsKey: garbage]
#expect(WCPayload.decodeRoutines(dict) == nil)
#expect(WCPayload.decodeWorkouts(dict) == nil)
}
// MARK: - Watch → Phone workout update
@Test func workoutUpdateRoundTrips() {
let w = workout(id: "01WKUPDATE")
let dict = WCPayload.encodeWorkoutUpdate(w)
#expect(dict[WCPayload.typeKey] as? String == WCPayload.workoutUpdateType)
#expect(WCPayload.decodeWorkoutUpdate(dict) == w)
}
@Test func workoutUpdateWithMissingBlobDecodesToNil() {
let dict: [String: Any] = [WCPayload.typeKey: WCPayload.workoutUpdateType]
#expect(WCPayload.decodeWorkoutUpdate(dict) == nil)
}
// MARK: - Watch → Phone requestSync
@Test func requestSyncMessageCarriesType() {
let dict = WCPayload.requestSyncMessage()
#expect(dict[WCPayload.typeKey] as? String == WCPayload.requestSyncType)
}
// MARK: - Live-run mirror (ephemeral)
private func liveProgress(phaseEnd: Date?) -> LiveProgress {
LiveProgress(
workoutID: "01WKLIVE", logID: "LOG-LIVE", exerciseName: "Leg Press", phase: .rest,
setIndex: 2, setCount: 4, detail: "30 sec",
phaseStart: Date(timeIntervalSince1970: 1_700_000_123.75), // sub-second: proves native (not ISO-8601) transport
phaseEnd: phaseEnd, version: 7
)
}
@Test func liveProgressRoundTripsWithCountdownEnd() {
let frame = liveProgress(phaseEnd: Date(timeIntervalSince1970: 1_700_000_153.75))
let dict = WCPayload.encodeLiveProgress(frame)
#expect(dict[WCPayload.typeKey] as? String == WCPayload.liveProgressType)
#expect(WCPayload.decodeLiveProgress(dict) == frame)
}
/// A rep-based work set counts up and carries no `phaseEnd`; the key must be omitted
/// and decode back to nil, not a zero date.
@Test func liveProgressRoundTripsWithoutEnd() {
let frame = liveProgress(phaseEnd: nil)
let dict = WCPayload.encodeLiveProgress(frame)
#expect(dict[WCPayload.lpEndKey] == nil)
let decoded = WCPayload.decodeLiveProgress(dict)
#expect(decoded == frame)
#expect(decoded?.phaseEnd == nil)
}
@Test func liveProgressMissingRequiredKeyDecodesToNil() {
var dict = WCPayload.encodeLiveProgress(liveProgress(phaseEnd: nil))
dict[WCPayload.lpWorkoutIDKey] = nil // drop a required field
#expect(WCPayload.decodeLiveProgress(dict) == nil)
}
@Test func liveEndedCarriesTypeAndIDs() {
let dict = WCPayload.encodeLiveEnded(workoutID: "01WKLIVE", logID: "LOG-LIVE")
#expect(dict[WCPayload.typeKey] as? String == WCPayload.liveEndedType)
#expect(dict[WCPayload.lpWorkoutIDKey] as? String == "01WKLIVE")
#expect(dict[WCPayload.lpLogIDKey] as? String == "LOG-LIVE")
}
}