Files
workouts/WorkoutsTests/WorkoutMergePlannerTests.swift
T
rzen 6e440317c4 Restructure into a three-tab app with Progress, goals, and Meditation
The UX redesign's first landing (spec in UX-REDESIGN.md): ContentView
becomes a Today / Progress / Settings TabView, "Routine" replaces
"Split" in every user-facing string and view name (code-level types
keep their names), and workout starting moves to shared
WorkoutStarter / StartedWorkoutNavigator plumbing.

- New Progress tab: weekly goal streaks, workout trends, per-exercise
  weight progression, achievements, and the full history list
  (WorkoutLogsView -> WorkoutHistoryView).
- Goals: stable categories workouts roll up to, managed from Settings.
- New Meditation exercise + starter routine; timed sits record to
  Apple Health as Mind & Body sessions.

Claude-Session: https://claude.ai/code/session_012qw2itfzKyEJ1HpsFt8Ex4
2026-07-11 07:53:01 -04:00

194 lines
10 KiB
Swift

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", routineID: nil,
routineName: 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)
}
}