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" }) } }