Fix rebucketing, seed upgrades, watch pruning, end re-stamping; add model tests
Editing a workout's start date now removes the file at its old month bucket so the record no longer duplicates on the next reconcile. Seed reconcile re-checks the tombstone veto before overwriting an upgraded seed. The watch applies authoritative-empty pushes so remote deletes prune, and a re-saved finished workout keeps its original end time. Adds unit tests for the mappers, path bucketing, and status machine. Claude-Session: https://claude.ai/code/session_01HJDQQDA9QdP8zByg43H5v3
This commit is contained in:
@@ -0,0 +1,195 @@
|
||||
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([Split.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,
|
||||
machineSettings: [MachineSetting]? = nil, startedAt: Date? = nil, completedAt: Date? = nil
|
||||
) -> WorkoutLogDocument {
|
||||
WorkoutLogDocument(
|
||||
id: id, exerciseName: name, order: order, sets: 4, reps: 10, weight: 135,
|
||||
loadType: LoadType.weight.rawValue, durationSeconds: 0, currentStateIndex: 2,
|
||||
status: status.rawValue, notes: "note-\(name)", date: Self.logDate,
|
||||
machineSettings: machineSettings, startedAt: startedAt, completedAt: completedAt
|
||||
)
|
||||
}
|
||||
|
||||
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", splitID: "SPLIT-42", splitName: "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.
|
||||
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.
|
||||
log(id: "LOG-B", name: "Leg Press", order: 1, status: .completed, machineSettings: [],
|
||||
startedAt: Self.startedAt, completedAt: Self.completedAt),
|
||||
// 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()
|
||||
)
|
||||
|
||||
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.splitID == "SPLIT-42")
|
||||
#expect(rebuilt.splitName == "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[1].machineSettings == []) // machine, nothing recorded
|
||||
#expect(rebuilt.logs[1].startedAt == Self.startedAt)
|
||||
#expect(rebuilt.logs[1].completedAt == Self.completedAt)
|
||||
#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")
|
||||
}
|
||||
|
||||
/// 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", splitID: nil, splitName: 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.splitID == 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", splitID: "S", splitName: "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", splitID: "S", splitName: "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" })
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user