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
194 lines
10 KiB
Swift
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)
|
|
}
|
|
}
|