Files
workouts/WorkoutsTests/WorkoutDocumentMapperTests.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

216 lines
11 KiB
Swift

import Foundation
import SwiftData
import Testing
import IndieSync
@testable import Workouts
/// Mirrors `SeedLibraryTests.mapperRoundTripPreservesPristineness` for the *workout*
/// aggregate: a `WorkoutDocument` pushed through `CacheMapper.upsertWorkout` (the
/// document → cache direction the metadata observer / watch bridge use) and rebuilt
/// via `WorkoutDocument(from: entity)` (the cache → document write path) must
/// preserve every field in both directions — the aggregate's own columns, the full
/// embedded log list (order, machine settings, per-log timestamps), and the health
/// metrics. A field that survives one direction but not the other silently forks or
/// drops user data on the very first cache rebuild.
struct WorkoutDocumentMapperTests {
// Whole-second epochs kept distinct per field so a swapped assignment can't hide.
private static let start = Date(timeIntervalSince1970: 1_700_000_000)
private static let end = Date(timeIntervalSince1970: 1_700_003_600)
private static let created = Date(timeIntervalSince1970: 1_699_999_000)
private static let updated = Date(timeIntervalSince1970: 1_700_004_000)
private static let logDate = Date(timeIntervalSince1970: 1_700_000_500)
private static let startedAt = Date(timeIntervalSince1970: 1_700_000_600)
private static let completedAt = Date(timeIntervalSince1970: 1_700_001_200)
private static let recordedAt = Date(timeIntervalSince1970: 1_700_003_700)
@MainActor
private func makeContext() throws -> ModelContext {
let schema = Schema([Routine.self, Exercise.self, Workout.self, WorkoutLog.self])
// cloudKitDatabase: .none matches WorkoutsModelContainer (see SeedLibraryTests).
let config = ModelConfiguration(schema: schema, isStoredInMemoryOnly: true, cloudKitDatabase: .none)
return ModelContext(try ModelContainer(for: schema, configurations: [config]))
}
private func log(
id: String, name: String, order: Int, status: WorkoutStatus,
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: weight,
loadType: LoadType.weight.rawValue, durationSeconds: 0, currentStateIndex: 2,
status: status.rawValue, notes: "note-\(name)", date: Self.logDate,
machineSettings: machineSettings, startedAt: startedAt, completedAt: completedAt,
setEntries: setEntries, updatedAt: updatedAt
)
}
private func fullMetrics() -> WorkoutMetrics {
WorkoutMetrics(
activeEnergyKcal: 321.5, avgHeartRate: 128, maxHeartRate: 165, minHeartRate: 71,
totalVolume: 5400, hrZoneSeconds: [10, 20, 30, 40, 50],
healthKitWorkoutUUID: "HK-UUID-123", source: .watch, recordedAt: Self.recordedAt
)
}
/// A fully-populated workout (three logs exercising the nil / empty / populated
/// machineSettings distinction, distinct per-log timestamps, full metrics) must
/// round-trip byte-for-byte equal in both directions.
@MainActor
@Test func roundTripPreservesEveryField() throws {
let context = try makeContext()
let original = WorkoutDocument(
schemaVersion: WorkoutDocument.currentSchemaVersion,
id: "01WORKOUTROUNDTRIP00000001", routineID: "RT-42", routineName: "Push Day",
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, 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, 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"),
MachineSetting(name: "Back Rest", value: "45°")],
startedAt: Self.startedAt),
],
metrics: fullMetrics(),
deletedLogIDs: ["LOG-DELETED": Self.completedAt]
)
CacheMapper.upsertWorkout(original, relativePath: original.relativePath, into: context)
let entity = try #require(CacheMapper.fetchWorkout(id: original.id, in: context))
let rebuilt = WorkoutDocument(from: entity)
#expect(rebuilt == original)
// Spot-pin the load-bearing fields the whole-document `==` rolls up, so a future
// regression names itself.
#expect(rebuilt.routineID == "RT-42")
#expect(rebuilt.routineName == "Push Day")
#expect(rebuilt.start == Self.start)
#expect(rebuilt.end == Self.end)
#expect(rebuilt.createdAt == Self.created)
#expect(rebuilt.updatedAt == Self.updated)
#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])
#expect(rebuilt.metrics?.healthKitWorkoutUUID == "HK-UUID-123")
#expect(rebuilt.deletedLogIDs == ["LOG-DELETED": Self.completedAt]) // merge tombstones survive
}
/// A workout with no metrics yet (the common in-progress / just-started case) must
/// round-trip to `nil` metrics, not a phantom zero-filled `WorkoutMetrics`.
@MainActor
@Test func roundTripPreservesNilMetrics() throws {
let context = try makeContext()
let original = WorkoutDocument(
schemaVersion: WorkoutDocument.currentSchemaVersion,
id: "01WORKOUTNOMETRICS0000001", routineID: nil, routineName: nil,
start: Self.start, end: nil, status: WorkoutStatus.inProgress.rawValue,
createdAt: Self.created, updatedAt: Self.updated,
logs: [log(id: "LOG-X", name: "Plank", order: 0, status: .inProgress, startedAt: Self.startedAt)],
metrics: nil
)
CacheMapper.upsertWorkout(original, relativePath: original.relativePath, into: context)
let entity = try #require(CacheMapper.fetchWorkout(id: original.id, in: context))
let rebuilt = WorkoutDocument(from: entity)
#expect(rebuilt == original)
#expect(rebuilt.metrics == nil)
#expect(rebuilt.routineID == nil)
#expect(rebuilt.end == nil)
}
/// The embedded logs are reconciled by id and rebuilt via `logsArray` (sorted by
/// `order`), so a document whose logs arrive out of order comes back canonically
/// ordered — the round-trip normalizes, never scrambles.
@MainActor
@Test func roundTripNormalizesLogOrder() throws {
let context = try makeContext()
let scrambled = WorkoutDocument(
schemaVersion: WorkoutDocument.currentSchemaVersion,
id: "01WORKOUTLOGORDER00000001", routineID: "S", routineName: "S",
start: Self.start, end: Self.end, status: WorkoutStatus.completed.rawValue,
createdAt: Self.created, updatedAt: Self.updated,
logs: [
log(id: "LOG-2", name: "Third", order: 2, status: .completed),
log(id: "LOG-0", name: "First", order: 0, status: .completed),
log(id: "LOG-1", name: "Second", order: 1, status: .completed),
],
metrics: nil
)
CacheMapper.upsertWorkout(scrambled, relativePath: scrambled.relativePath, into: context)
let entity = try #require(CacheMapper.fetchWorkout(id: scrambled.id, in: context))
let rebuilt = WorkoutDocument(from: entity)
#expect(rebuilt.logs.map(\.order) == [0, 1, 2])
#expect(rebuilt.logs.map(\.id) == ["LOG-0", "LOG-1", "LOG-2"])
#expect(rebuilt.logs.map(\.exerciseName) == ["First", "Second", "Third"])
}
/// Re-upserting a changed document over an existing cache entity reconciles the log
/// set by id: a removed log is dropped, a new one inserted, survivors updated in
/// place — matching the observer's frequent in-workout re-imports.
@MainActor
@Test func reUpsertReconcilesLogsByID() throws {
let context = try makeContext()
let first = WorkoutDocument(
schemaVersion: WorkoutDocument.currentSchemaVersion,
id: "01WORKOUTREUPSERT00000001", routineID: "S", routineName: "S",
start: Self.start, end: nil, status: WorkoutStatus.inProgress.rawValue,
createdAt: Self.created, updatedAt: Self.updated,
logs: [
log(id: "LOG-keep", name: "Kept", order: 0, status: .completed),
log(id: "LOG-drop", name: "Dropped", order: 1, status: .notStarted),
],
metrics: nil
)
CacheMapper.upsertWorkout(first, relativePath: first.relativePath, into: context)
try context.save()
var second = first
second.logs = [
log(id: "LOG-keep", name: "Kept Renamed", order: 0, status: .completed), // updated in place
log(id: "LOG-add", name: "Added", order: 1, status: .notStarted), // inserted
]
CacheMapper.upsertWorkout(second, relativePath: second.relativePath, into: context)
// The observer saves after applying each delta batch; the relationship array only
// reflects a child deletion once saved, so mirror that here.
try context.save()
let entity = try #require(CacheMapper.fetchWorkout(id: second.id, in: context))
let rebuilt = WorkoutDocument(from: entity)
#expect(rebuilt.logs.map(\.id) == ["LOG-keep", "LOG-add"])
#expect(rebuilt.logs.first?.exerciseName == "Kept Renamed")
#expect(!rebuilt.logs.contains { $0.id == "LOG-drop" })
}
}