import Foundation import SwiftData import Testing @testable import Workouts_Watch_App /// Locks the phone→watch state apply/prune contract — the pure `WatchCacheApplier` seam the /// `WatchConnectivityBridge` delegates its cache mutation to. The bridge wraps `WCSession`, so /// this session-free seam is the testable surface for the three behaviors that matter: /// 1. An authoritative push upserts the routines/workouts the phone sent. /// 2. An authoritative **empty** push prunes stale rows the phone no longer has (the /// recently-fixed case — otherwise a deleted routine / aged-out run is orphaned forever). /// 3. A `nil` set (the phone's payload failed to decode — a build/schema mismatch) is a /// decode failure, not an empty set: it must skip entirely and NOT prune real rows. /// /// These are all membership/presence assertions (which ids survive) — deliberately orthogonal to /// any updatedAt/version ordering, which this suite does not exercise. @MainActor struct WatchCacheApplierTests { private static let ts = Date(timeIntervalSince1970: 1_700_000_000) private func makeContext() throws -> ModelContext { let schema = Schema([Routine.self, Exercise.self, Workout.self, WorkoutLog.self]) // cloudKitDatabase: .none matches WorkoutsModelContainer (the watch's read-through cache). let config = ModelConfiguration(schema: schema, isStoredInMemoryOnly: true, cloudKitDatabase: .none) return ModelContext(try ModelContainer(for: schema, configurations: [config])) } private func routine(id: String, name: String) -> RoutineDocument { RoutineDocument( schemaVersion: RoutineDocument.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: LoadType.weight.rawValue, durationSeconds: 0, machineSettings: nil)], activityType: nil ) } private func workout(id: String, name: String = "Push") -> WorkoutDocument { WorkoutDocument( schemaVersion: WorkoutDocument.currentSchemaVersion, id: id, routineID: "S", routineName: name, start: Self.ts, end: nil, status: WorkoutStatus.inProgress.rawValue, createdAt: Self.ts, updatedAt: Self.ts, logs: [WorkoutLogDocument(id: "L-\(id)", exerciseName: "Bench Press", order: 0, sets: 4, reps: 10, weight: 135, loadType: LoadType.weight.rawValue, durationSeconds: 0, currentStateIndex: 0, status: WorkoutStatus.notStarted.rawValue, notes: nil, date: Self.ts)], metrics: nil ) } private func routineIDs(in ctx: ModelContext) -> Set { Set((try? ctx.fetch(FetchDescriptor()))?.map(\.id) ?? []) } private func workoutIDs(in ctx: ModelContext) -> Set { Set((try? ctx.fetch(FetchDescriptor()))?.map(\.id) ?? []) } // MARK: - Authoritative upsert @Test func authoritativePushUpsertsRoutinesAndWorkouts() throws { let ctx = try makeContext() WatchCacheApplier.apply( routines: [routine(id: "SP1", name: "Upper"), routine(id: "SP2", name: "Lower")], workouts: [workout(id: "01WKA"), workout(id: "01WKB")], into: ctx ) #expect(routineIDs(in: ctx) == ["SP1", "SP2"]) #expect(workoutIDs(in: ctx) == ["01WKA", "01WKB"]) } @Test func reUpsertSameIDUpdatesInPlaceWithoutDuplicating() throws { let ctx = try makeContext() WatchCacheApplier.apply(routines: [routine(id: "SP1", name: "Original")], workouts: [], into: ctx) WatchCacheApplier.apply(routines: [routine(id: "SP1", name: "Renamed")], workouts: [], into: ctx) let routines = try ctx.fetch(FetchDescriptor()) #expect(routines.count == 1) #expect(routines.first?.name == "Renamed") } // MARK: - Authoritative empty prunes stale rows (the recently-fixed case) @Test func authoritativeEmptyPushPrunesAllStaleRows() throws { let ctx = try makeContext() // Seed the cache as if the phone had previously pushed real state. WatchCacheApplier.apply( routines: [routine(id: "SP1", name: "Upper")], workouts: [workout(id: "01WKA")], into: ctx ) #expect(routineIDs(in: ctx) == ["SP1"]) #expect(workoutIDs(in: ctx) == ["01WKA"]) // The user deleted everything on the phone (or all runs aged out): an authoritative // EMPTY push must clear the now-stale rows rather than orphan them forever. WatchCacheApplier.apply(routines: [], workouts: [], into: ctx) #expect(routineIDs(in: ctx).isEmpty) #expect(workoutIDs(in: ctx).isEmpty) } @Test func partialPushPrunesOnlyRowsThePhoneNoLongerSends() throws { let ctx = try makeContext() WatchCacheApplier.apply( routines: [routine(id: "SP1", name: "Keep"), routine(id: "SP2", name: "Drop")], workouts: [workout(id: "01WKKEEP"), workout(id: "01WKDROP")], into: ctx ) // The phone now sends only the survivors — the omitted ones are pruned. WatchCacheApplier.apply( routines: [routine(id: "SP1", name: "Keep")], workouts: [workout(id: "01WKKEEP")], into: ctx ) #expect(routineIDs(in: ctx) == ["SP1"]) #expect(workoutIDs(in: ctx) == ["01WKKEEP"]) } @Test func authoritativeEmptyPushOnEmptyCacheIsANoOp() throws { let ctx = try makeContext() WatchCacheApplier.apply(routines: [], workouts: [], into: ctx) #expect(routineIDs(in: ctx).isEmpty) #expect(workoutIDs(in: ctx).isEmpty) } // MARK: - nil-decode guard: a decode failure must NOT prune @Test func nilRoutinesSkipsEntirelyAndDoesNotPrune() throws { let ctx = try makeContext() WatchCacheApplier.apply(routines: [routine(id: "SP1", name: "Upper")], workouts: [workout(id: "01WKA")], into: ctx) // A corrupt/absent routines payload decodes to nil (build mismatch). Skip — the existing // rows must survive, never pruned against a bogus set. let applied = WatchCacheApplier.apply(routines: nil, workouts: [], into: ctx) #expect(applied == false) #expect(routineIDs(in: ctx) == ["SP1"]) #expect(workoutIDs(in: ctx) == ["01WKA"]) } @Test func nilWorkoutsSkipsEntirelyAndDoesNotPrune() throws { let ctx = try makeContext() WatchCacheApplier.apply(routines: [routine(id: "SP1", name: "Upper")], workouts: [workout(id: "01WKA")], into: ctx) let applied = WatchCacheApplier.apply(routines: [], workouts: nil, into: ctx) #expect(applied == false) #expect(routineIDs(in: ctx) == ["SP1"]) #expect(workoutIDs(in: ctx) == ["01WKA"]) } // MARK: - New-field fidelity through the watch cache /// A pushed workout carrying the v4 log fields — fractional weight, recorded /// `setEntries`, per-log `updatedAt` — survives apply → cache entity → document /// intact, so the watch's echo back to the phone can't strip actuals. @Test func applyRoundTripsSetEntriesAndPerLogUpdatedAt() throws { let ctx = try makeContext() var doc = workout(id: "01WKENTRIES") doc.logs[0].weight = 42.5 doc.logs[0].status = WorkoutStatus.completed.rawValue doc.logs[0].setEntries = [ SetEntry(reps: 10, weight: 42.5, completedAt: Self.ts), SetEntry(reps: 8, weight: 45, completedAt: Self.ts), ] doc.logs[0].updatedAt = Self.ts WatchCacheApplier.apply(routines: [], workouts: [doc], into: ctx) let entity = try #require(CacheMapper.fetchWorkout(id: "01WKENTRIES", in: ctx)) let rebuilt = WorkoutDocument(from: entity) #expect(rebuilt.logs[0].weight == 42.5) #expect(rebuilt.logs[0].setEntries == doc.logs[0].setEntries) #expect(rebuilt.logs[0].updatedAt == Self.ts) } @Test func optionalOverloadReturnsTrueWhenBothSetsDecode() throws { let ctx = try makeContext() // Typed optionals to disambiguate onto the nil-aware overload (an authoritative, // successfully-decoded — if empty — push), whose Bool return is the applied/skip signal. let routines: [RoutineDocument]? = [] let workouts: [WorkoutDocument]? = [] let applied = WatchCacheApplier.apply(routines: routines, workouts: workouts, into: ctx) #expect(applied == true) } }