import Foundation import Testing import IndieSync @testable import Workouts /// Round-trips every `WCPayload` wire shape — the iPhone↔Watch bridge's only /// serialization boundary. Splits/workouts ride as JSON `Data` blobs (via /// `DocumentCoder`, ISO-8601), settings and edit locks as native plist scalars, and /// the ephemeral live-run frames as native values (so their timers keep sub-second /// precision). A field dropped or a `nil`-vs-empty confusion here silently desyncs /// the two devices, so each direction is pinned end to end, including the /// decode-failure contract (`nil` means "failed to decode", never "empty list"). struct WCPayloadRoundTripTests { private static let ts = Date(timeIntervalSince1970: 1_700_000_000) // whole second: ISO-8601 safe private func split(id: String, name: String) -> SplitDocument { SplitDocument( schemaVersion: SplitDocument.currentSchemaVersion, id: id, name: name, color: "blue", systemImage: "dumbbell.fill", order: 0, createdAt: Self.ts, updatedAt: Self.ts, exercises: [ExerciseDocument(id: "EX-\(id)", name: "Bench Press", order: 0, sets: 4, reps: 10, weight: 135, loadType: 1, durationSeconds: 0, machineSettings: [MachineSetting(name: "Seat", value: "4")])], activityType: nil ) } private func workout(id: String) -> WorkoutDocument { WorkoutDocument( schemaVersion: WorkoutDocument.currentSchemaVersion, id: id, splitID: "S", splitName: "Push", start: Self.ts, end: Self.ts, status: WorkoutStatus.completed.rawValue, createdAt: Self.ts, updatedAt: Self.ts, logs: [WorkoutLogDocument(id: "L-\(id)", exerciseName: "Bench Press", order: 0, sets: 4, reps: 10, weight: 135, loadType: 1, durationSeconds: 0, currentStateIndex: 4, status: WorkoutStatus.completed.rawValue, notes: "solid", date: Self.ts)], metrics: nil ) } // MARK: - Phone → Watch state + settings @Test func stateRoundTripsSplitsWorkoutsAndSettings() { let splits = [split(id: "SP1", name: "Upper Body"), split(id: "SP2", name: "Lower Body")] let workouts = [workout(id: "01WKA"), workout(id: "01WKB")] let dict = WCPayload.encodeState( splits: splits, workouts: workouts, restSeconds: 90, doneCountdownSeconds: 5, weightUnit: "kg", editingWorkoutID: "01WKA", editingSplitID: "SP1" ) #expect(WCPayload.decodeSplits(dict) == splits) #expect(WCPayload.decodeWorkouts(dict) == workouts) #expect(WCPayload.decodeRestSeconds(dict) == 90) #expect(WCPayload.decodeDoneCountdownSeconds(dict) == 5) #expect(WCPayload.decodeWeightUnit(dict) == "kg") #expect(WCPayload.decodeEditingWorkoutID(dict) == "01WKA") #expect(WCPayload.decodeEditingSplitID(dict) == "SP1") } /// Nil edit locks mean "not editing" → the keys are absent, so the decoders return /// nil (the receiver clears the lock). An empty splits/workouts list is legitimately /// empty — it still encodes a key and decodes back to `[]`, never nil. @Test func stateOmitsNilEditLocksAndKeepsEmptyListsDistinct() { let dict = WCPayload.encodeState( splits: [], workouts: [], restSeconds: 60, doneCountdownSeconds: 3, weightUnit: "lb", editingWorkoutID: nil, editingSplitID: nil ) #expect(WCPayload.decodeEditingWorkoutID(dict) == nil) #expect(WCPayload.decodeEditingSplitID(dict) == nil) // Authoritative empty push: an empty (present) list decodes to [], not nil. #expect(WCPayload.decodeSplits(dict) == []) #expect(WCPayload.decodeWorkouts(dict) == []) } /// An absent splits/workouts key decodes to `[]` (nothing to apply), NOT nil — nil is /// reserved for a genuine decode failure below. @Test func absentSplitsWorkoutsKeysDecodeToEmpty() { let empty: [String: Any] = [:] #expect(WCPayload.decodeSplits(empty) == []) #expect(WCPayload.decodeWorkouts(empty) == []) } /// A present-but-corrupt payload (a build-mismatch schema the receiver can't decode) /// yields `nil` — the signal the watch bridge's nil-decode guard uses to skip /// pruning against a bogus set. This must stay distinct from an empty list. @Test func corruptStateDecodesToNilNotEmpty() { let garbage = Data([0xFF, 0xFE, 0x00, 0x01]) let dict: [String: Any] = [WCPayload.splitsKey: garbage, WCPayload.workoutsKey: garbage] #expect(WCPayload.decodeSplits(dict) == nil) #expect(WCPayload.decodeWorkouts(dict) == nil) } // MARK: - Watch → Phone workout update @Test func workoutUpdateRoundTrips() { let w = workout(id: "01WKUPDATE") let dict = WCPayload.encodeWorkoutUpdate(w) #expect(dict[WCPayload.typeKey] as? String == WCPayload.workoutUpdateType) #expect(WCPayload.decodeWorkoutUpdate(dict) == w) } @Test func workoutUpdateWithMissingBlobDecodesToNil() { let dict: [String: Any] = [WCPayload.typeKey: WCPayload.workoutUpdateType] #expect(WCPayload.decodeWorkoutUpdate(dict) == nil) } // MARK: - Watch → Phone requestSync @Test func requestSyncMessageCarriesType() { let dict = WCPayload.requestSyncMessage() #expect(dict[WCPayload.typeKey] as? String == WCPayload.requestSyncType) } // MARK: - Live-run mirror (ephemeral) private func liveProgress(phaseEnd: Date?) -> LiveProgress { LiveProgress( workoutID: "01WKLIVE", logID: "LOG-LIVE", exerciseName: "Leg Press", phase: .rest, setIndex: 2, setCount: 4, detail: "30 sec", phaseStart: Date(timeIntervalSince1970: 1_700_000_123.75), // sub-second: proves native (not ISO-8601) transport phaseEnd: phaseEnd, version: 7 ) } @Test func liveProgressRoundTripsWithCountdownEnd() { let frame = liveProgress(phaseEnd: Date(timeIntervalSince1970: 1_700_000_153.75)) let dict = WCPayload.encodeLiveProgress(frame) #expect(dict[WCPayload.typeKey] as? String == WCPayload.liveProgressType) #expect(WCPayload.decodeLiveProgress(dict) == frame) } /// A rep-based work set counts up and carries no `phaseEnd`; the key must be omitted /// and decode back to nil, not a zero date. @Test func liveProgressRoundTripsWithoutEnd() { let frame = liveProgress(phaseEnd: nil) let dict = WCPayload.encodeLiveProgress(frame) #expect(dict[WCPayload.lpEndKey] == nil) let decoded = WCPayload.decodeLiveProgress(dict) #expect(decoded == frame) #expect(decoded?.phaseEnd == nil) } @Test func liveProgressMissingRequiredKeyDecodesToNil() { var dict = WCPayload.encodeLiveProgress(liveProgress(phaseEnd: nil)) dict[WCPayload.lpWorkoutIDKey] = nil // drop a required field #expect(WCPayload.decodeLiveProgress(dict) == nil) } @Test func liveEndedCarriesTypeAndIDs() { let dict = WCPayload.encodeLiveEnded(workoutID: "01WKLIVE", logID: "LOG-LIVE") #expect(dict[WCPayload.typeKey] as? String == WCPayload.liveEndedType) #expect(dict[WCPayload.lpWorkoutIDKey] as? String == "01WKLIVE") #expect(dict[WCPayload.lpLogIDKey] as? String == "LOG-LIVE") } }