import Foundation import Testing @testable import Workouts /// Pins the per-log merge (`WorkoutMergePlanner.merge`) that replaced `ingestFromWatch`'s /// whole-document `updatedAt` gate — the durable fix for H1. The decisive properties: edits to /// *different* logs commute (neither side is clobbered), the same log resolves by newest per-log /// stamp, a phone-authored deletion tombstone stops a stale watch push from resurrecting a /// removed exercise (unless a strictly-newer edit supersedes the deletion), and a stale/duplicate /// push merges back to exactly the cached document (so ingest re-pushes rather than writes). struct WorkoutMergePlannerTests { private static let t0 = Date(timeIntervalSince1970: 1_700_000_000) private static func at(_ seconds: TimeInterval) -> Date { t0.addingTimeInterval(seconds) } private func log(_ id: String, order: Int = 0, status: WorkoutStatus = .inProgress, reps: Int = 10, updatedAt: Date? = nil, startedAt: Date? = nil, completedAt: Date? = nil) -> WorkoutLogDocument { WorkoutLogDocument( id: id, exerciseName: "Ex-\(id)", order: order, sets: 3, reps: reps, weight: 100, loadType: LoadType.weight.rawValue, durationSeconds: 0, currentStateIndex: 0, status: status.rawValue, notes: nil, date: Self.t0, machineSettings: nil, startedAt: startedAt, completedAt: completedAt, setEntries: nil, updatedAt: updatedAt) } private func workout(updatedAt: Date, status: WorkoutStatus = .inProgress, logs: [WorkoutLogDocument], deletedLogIDs: [String: Date]? = nil, metrics: WorkoutMetrics? = nil, end: Date? = nil) -> WorkoutDocument { WorkoutDocument( schemaVersion: WorkoutDocument.currentSchemaVersion, id: "W", splitID: nil, splitName: nil, start: Self.t0, end: end, status: status.rawValue, createdAt: Self.t0, updatedAt: updatedAt, logs: logs, metrics: metrics, deletedLogIDs: deletedLogIDs) } private func merged(_ incoming: WorkoutDocument, _ cached: WorkoutDocument) -> WorkoutDocument { WorkoutMergePlanner.merge(incoming: incoming, cached: cached, now: Self.at(1000)) } private func reps(_ doc: WorkoutDocument, _ id: String) -> Int? { doc.logs.first { $0.id == id }?.reps } // MARK: - Commuting edits to different logs (the H1 case) @Test func concurrentEditsToDifferentLogsBothSurvive() { // Phone advanced A; watch advanced B — each from the shared base, unaware of the other. let phone = workout(updatedAt: Self.at(10), logs: [log("A", order: 0, reps: 12, updatedAt: Self.at(10)), log("B", order: 1, reps: 10, updatedAt: Self.at(5))]) let watch = workout(updatedAt: Self.at(11), logs: [log("A", order: 0, reps: 10, updatedAt: Self.at(5)), log("B", order: 1, reps: 15, updatedAt: Self.at(11))]) let result = merged(watch, phone) #expect(reps(result, "A") == 12) // phone's A edit kept #expect(reps(result, "B") == 15) // watch's B edit kept #expect(result.logs.count == 2) } @Test func mergeIsOrderIndependentForLogContent() { let phone = workout(updatedAt: Self.at(10), logs: [log("A", order: 0, reps: 12, updatedAt: Self.at(10)), log("B", order: 1, reps: 10, updatedAt: Self.at(5))]) let watch = workout(updatedAt: Self.at(11), logs: [log("A", order: 0, reps: 10, updatedAt: Self.at(5)), log("B", order: 1, reps: 15, updatedAt: Self.at(11))]) let a = merged(watch, phone) let b = merged(phone, watch) // The winning log versions are the same regardless of which document is "incoming". #expect(reps(a, "A") == reps(b, "A")) #expect(reps(a, "B") == reps(b, "B")) } // MARK: - Same log: newest per-log stamp wins @Test func sameLogNewerStampWins() { let cached = workout(updatedAt: Self.at(10), logs: [log("A", reps: 12, updatedAt: Self.at(10))]) let incoming = workout(updatedAt: Self.at(20), logs: [log("A", reps: 20, updatedAt: Self.at(20))]) #expect(reps(merged(incoming, cached), "A") == 20) } @Test func sameLogOlderStampLoses() { let cached = workout(updatedAt: Self.at(20), logs: [log("A", reps: 20, updatedAt: Self.at(20))]) let incoming = workout(updatedAt: Self.at(10), logs: [log("A", reps: 12, updatedAt: Self.at(10))]) #expect(reps(merged(incoming, cached), "A") == 20) } @Test func legacyNilStampIsOldest() { // A legacy log with no per-log stamp must lose to any stamped edit. let cached = workout(updatedAt: Self.at(1), logs: [log("A", reps: 99, updatedAt: nil)]) let incoming = workout(updatedAt: Self.at(5), logs: [log("A", reps: 20, updatedAt: Self.at(5))]) #expect(reps(merged(incoming, cached), "A") == 20) } // MARK: - Additions @Test func watchAddedLogIsKept() { let cached = workout(updatedAt: Self.at(10), logs: [log("A", order: 0, updatedAt: Self.at(10))]) let incoming = workout(updatedAt: Self.at(11), logs: [log("A", order: 0, updatedAt: Self.at(10)), log("C", order: 1, updatedAt: Self.at(11))]) let result = merged(incoming, cached) #expect(Set(result.logs.map(\.id)) == ["A", "C"]) } // MARK: - Deletion tombstones @Test func tombstonedLogDoesNotResurrect() { // Phone deleted B (tombstone at t=10); the watch, unaware, still pushes B (edited earlier). let cached = workout(updatedAt: Self.at(10), logs: [log("A", updatedAt: Self.at(10))], deletedLogIDs: ["B": Self.at(10)]) let incoming = workout(updatedAt: Self.at(11), logs: [log("A", updatedAt: Self.at(6)), log("B", updatedAt: Self.at(5))]) let result = merged(incoming, cached) #expect(Set(result.logs.map(\.id)) == ["A"]) // B stays deleted #expect(result.deletedLogIDs?["B"] == Self.at(10)) // tombstone retained (within grace) } @Test func newerEditResurrectsAndClearsTombstone() { // The watch edited B *after* the phone's deletion — the newer action wins (LWW per log), // so B comes back and its now-stale tombstone is dropped. let cached = workout(updatedAt: Self.at(10), logs: [log("A", updatedAt: Self.at(10))], deletedLogIDs: ["B": Self.at(10)]) let incoming = workout(updatedAt: Self.at(21), logs: [log("A", updatedAt: Self.at(6)), log("B", updatedAt: Self.at(20))]) let result = merged(incoming, cached) #expect(Set(result.logs.map(\.id)) == ["A", "B"]) #expect(result.deletedLogIDs?["B"] == nil) } @Test func tombstonesUnionAcrossSides() { let cached = workout(updatedAt: Self.at(10), logs: [log("A", updatedAt: Self.at(10))], deletedLogIDs: ["B": Self.at(10)]) let incoming = workout(updatedAt: Self.at(11), logs: [log("A", updatedAt: Self.at(6))], deletedLogIDs: ["C": Self.at(11)]) let result = merged(incoming, cached) #expect(result.deletedLogIDs?["B"] == Self.at(10)) #expect(result.deletedLogIDs?["C"] == Self.at(11)) } @Test func staleTombstonesArePruned() { // A tombstone older than the grace period (now = t0+1000; grace = 30d) is dropped. let old = Self.at(1000 - WorkoutMergePlanner.tombstoneGrace - 1) let cached = workout(updatedAt: Self.at(10), logs: [log("A", updatedAt: Self.at(10))], deletedLogIDs: ["B": old]) let result = merged(cached, cached) #expect(result.deletedLogIDs == nil) } // MARK: - Workout-level resolution @Test func statusRecomputedFromMergedLogs() { // Phone: A done, B in progress → inProgress. Watch completes B (newer) → merged is all // resolved, so status recomputes to completed with the injected end time. let cached = workout(updatedAt: Self.at(10), status: .inProgress, logs: [log("A", status: .completed, updatedAt: Self.at(8)), log("B", status: .inProgress, updatedAt: Self.at(9))]) let incoming = workout(updatedAt: Self.at(20), status: .inProgress, logs: [log("A", status: .completed, updatedAt: Self.at(8)), log("B", status: .completed, updatedAt: Self.at(20))]) let result = merged(incoming, cached) #expect(result.status == WorkoutStatus.completed.rawValue) #expect(result.end == Self.at(1000)) // recompute used the injected `now` } @Test func newerDocumentSuppliesWorkoutScalars() { // The just-finished watch push carries the metrics summary; the merge must keep it. let m = WorkoutMetrics(activeEnergyKcal: 200, avgHeartRate: 130, maxHeartRate: 160, minHeartRate: 70, totalVolume: 3000, hrZoneSeconds: nil, healthKitWorkoutUUID: "HK-1", source: .watch, recordedAt: Self.at(20)) let cached = workout(updatedAt: Self.at(10), logs: [log("A", updatedAt: Self.at(10))]) let incoming = workout(updatedAt: Self.at(20), logs: [log("A", updatedAt: Self.at(10))], metrics: m) #expect(merged(incoming, cached).metrics == m) } // MARK: - The re-push (no-op) case ingest relies on @Test func stalePushMergesBackToCached() { // A behind/duplicate watch push adds nothing newer → merge == cached, so ingest re-pushes // authoritative state instead of writing. let cached = workout(updatedAt: Self.at(20), logs: [log("A", order: 0, reps: 20, updatedAt: Self.at(20)), log("B", order: 1, reps: 15, updatedAt: Self.at(18))]) let stale = workout(updatedAt: Self.at(9), logs: [log("A", order: 0, reps: 10, updatedAt: Self.at(5)), log("B", order: 1, reps: 10, updatedAt: Self.at(5))]) #expect(merged(stale, cached) == cached) } }