Merge watch workout pushes per log instead of whole-document
ingestFromWatch arbitrated by whole-document updatedAt, so concurrent edits to the same workout on both devices (phone edits exercise A while the watch completes exercise B) lost one side wholesale — the newer snapshot replaced the other (H1). Reconcile per log instead. WorkoutMergePlanner (pure, deterministic) unions logs by id, resolves each by newest per-log updatedAt, and applies phone-authored deletion tombstones so an absent log is never ambiguous between "deleted on the phone" and "just added on the watch". Edits to different exercises now commute — delivery order and offline gaps stop mattering. A stale/duplicate push merges back to exactly the cached doc, so ingest re-pushes authoritative state rather than writing. The per-log updatedAt scaffolding shipped (unused) in schema v4; it's now stamped by transition(to:) on status flips and a new touch() at the content-only edit sites (order, notes, machine settings, adjusted entries, new logs) on both phone and watch. deletedLogIDs is new: additive on the wire and cache, phone-authored (deleteLog), pruned after a 30-day grace. Because an older build rewriting a file would strip the tombstones and resurrect a deleted exercise, WorkoutDocument schema bumps 4->5 (forward gate quarantines old builds) and the cache bumps 5->6. recomputeStatusFromLogs takes an injectable now: so the merge recomputes status/end deterministically. WorkoutMergePlannerTests pins the decision table (commute, per-log newer-wins, legacy-nil, watch-add, tombstone honored/resurrect/union/prune, status recompute, no-op re-push); WorkoutDocumentMapperTests gains the deletedLogIDs round-trip.
This commit is contained in:
@@ -0,0 +1,193 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user