import Foundation import Testing import IndieSync @testable import Workouts /// Round-trips `SplitDocument` (and the embedded exercise / log shapes) through /// `DocumentCoder` — the exact JSON codec (ISO-8601 dates, sorted keys, /// pretty-printed) `DocumentFileStore` uses to write and read every document in /// this app's iCloud container. Also pins the schema-v2 migration: an old v1 /// split file (with the removed weight-reminder / category keys) must still decode /// and round-trip at the current version. struct SplitDocumentCodableTests { /// A whole-second epoch so ISO-8601 round-trips (no fractional-second loss). private static let stableTimestamp = Date(timeIntervalSince1970: 1_700_000_000) @Test func encodeDecodeRoundTrip() throws { let exercise = ExerciseDocument( id: ULID.make(), name: "Leg Press", order: 0, sets: 4, reps: 10, weight: 135, loadType: 0, durationSeconds: 0, machineSettings: [ MachineSetting(name: "Seat Height", value: "4"), MachineSetting(name: "Back Rest Incline", value: "45°") ] ) let original = SplitDocument( schemaVersion: SplitDocument.currentSchemaVersion, id: ULID.make(), name: "Push Day", color: "blue", systemImage: "dumbbell.fill", order: 0, createdAt: Self.stableTimestamp, updatedAt: Self.stableTimestamp, exercises: [exercise], activityType: nil ) let data = try DocumentCoder.encode(original) let decoded = try DocumentCoder.decode(SplitDocument.self, from: data) #expect(decoded == original) #expect(decoded.schemaVersion == 2) #expect(decoded.relativePath == "Splits/\(original.id).json") #expect(decoded.exercises.first?.machineSettings?.count == 2) } /// An `ExerciseDocument` with no machine settings encodes without a /// `machineSettings` key (nil optionals are omitted) and decodes back to nil — /// the "not a machine exercise" state. @Test func nonMachineExerciseOmitsMachineSettings() throws { let exercise = ExerciseDocument( id: ULID.make(), name: "Plank", order: 0, sets: 3, reps: 0, weight: 0, loadType: 2, durationSeconds: 45 ) let data = try DocumentCoder.encode(exercise) let json = String(decoding: data, as: UTF8.self) #expect(!json.contains("machineSettings")) let decoded = try DocumentCoder.decode(ExerciseDocument.self, from: data) #expect(decoded.machineSettings == nil) } /// `machineSettings` round-trips on `ExerciseDocument`, including the empty case /// ("machine-based, nothing recorded yet") which must stay distinct from nil. @Test func machineSettingsRoundTripOnExercise() throws { for settings: [MachineSetting] in [ [MachineSetting(name: "Pin", value: "3rd hole")], [] // empty ≠ nil: a machine exercise with nothing recorded ] { let exercise = ExerciseDocument( id: ULID.make(), name: "Chest Press", order: 0, sets: 4, reps: 10, weight: 40, loadType: 1, durationSeconds: 0, machineSettings: settings ) let data = try DocumentCoder.encode(exercise) let decoded = try DocumentCoder.decode(ExerciseDocument.self, from: data) #expect(decoded.machineSettings == settings) } } /// `machineSettings` round-trips on `WorkoutLogDocument` — the plan-time snapshot. @Test func machineSettingsRoundTripOnWorkoutLog() throws { let log = WorkoutLogDocument( id: ULID.make(), exerciseName: "Leg Press", order: 0, sets: 4, reps: 10, weight: 140, loadType: 1, durationSeconds: 0, currentStateIndex: 0, status: WorkoutStatus.notStarted.rawValue, notes: nil, date: Self.stableTimestamp, machineSettings: [MachineSetting(name: "Seat Height", value: "4")] ) let data = try DocumentCoder.encode(log) let decoded = try DocumentCoder.decode(WorkoutLogDocument.self, from: data) #expect(decoded == log) #expect(decoded.machineSettings == [MachineSetting(name: "Seat Height", value: "4")]) } /// A v1 split file carrying the since-removed keys (`weightLastUpdated`, /// `weightReminderWeeks`, `category`) must still decode — Codable ignores the /// extra keys, and every remaining field is present in old files. Re-stamping /// `schemaVersion` to the current value (what the app does on any rewrite via the /// cache→document mapper) round-trips cleanly at v2. @Test func decodesLegacyV1SplitAndRoundTripsAtV2() throws { let json = """ { "schemaVersion": 1, "id": "01HZZZZZZZZZZZZZZZZZZZZZZZ", "name": "Push Day", "color": "blue", "systemImage": "dumbbell.fill", "order": 0, "createdAt": "2023-11-14T22:13:20Z", "updatedAt": "2023-11-14T22:13:20Z", "exercises": [{ "id": "01HZZZZZZZZZZZZZZZZZZZZZZY", "name": "Bench Press", "order": 0, "sets": 4, "reps": 10, "weight": 135, "loadType": 1, "durationSeconds": 0, "weightLastUpdated": "2023-11-14T22:13:20Z", "weightReminderWeeks": 4, "category": 1 }] } """ var decoded = try DocumentCoder.decode(SplitDocument.self, from: Data(json.utf8)) // Removed keys are ignored; the surviving fields decode and machineSettings // defaults to nil (the file predates it). #expect(decoded.schemaVersion == 1) #expect(decoded.exercises.count == 1) #expect(decoded.exercises.first?.name == "Bench Press") #expect(decoded.exercises.first?.weight == 135) #expect(decoded.exercises.first?.machineSettings == nil) // The app re-stamps to the current version on rewrite; that must round-trip. decoded.schemaVersion = SplitDocument.currentSchemaVersion let reData = try DocumentCoder.encode(decoded) let reDecoded = try DocumentCoder.decode(SplitDocument.self, from: reData) #expect(reDecoded == decoded) #expect(reDecoded.schemaVersion == 2) } /// A workout-log file written before `completed` was removed still carries the /// key. It must decode fine (Codable ignores it) — completion is now derived /// from `status`. @Test func decodesLegacyWorkoutLogWithCompletedKey() throws { let json = """ { "id": "01HZZZZZZZZZZZZZZZZZZZZZZW", "exerciseName": "Bench Press", "order": 0, "sets": 4, "reps": 10, "weight": 135, "loadType": 1, "durationSeconds": 0, "currentStateIndex": 4, "completed": true, "status": "completed", "date": "2023-11-14T22:13:20Z" } """ let decoded = try DocumentCoder.decode(WorkoutLogDocument.self, from: Data(json.utf8)) #expect(decoded.status == WorkoutStatus.completed.rawValue) #expect(decoded.exerciseName == "Bench Press") #expect(decoded.machineSettings == nil) } }