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
216 lines
11 KiB
Swift
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" })
|
|
}
|
|
}
|