diff --git a/CHANGELOG.md b/CHANGELOG.md index 0ae7156..9b73d10 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,7 @@ **July 2026** +Swiping through sets on the iPhone now reliably moves the watch along too, instead of the two drifting apart mid-workout. + On Apple Watch, the workout screen now gives its whole display to the set and rest timer, without the form-guide figure shown on iPhone. Barbells and pull-up bars in the animated form guide now turn with the figure as the view circles, instead of hovering fixed on screen. diff --git a/Shared/Connectivity/LiveProgress.swift b/Shared/Connectivity/LiveProgress.swift index 1a42628..ccae2fd 100644 --- a/Shared/Connectivity/LiveProgress.swift +++ b/Shared/Connectivity/LiveProgress.swift @@ -48,3 +48,19 @@ struct LiveProgress: Sendable, Equatable { /// Monotonic per-run sequence, so the mirror can drop a stale / out-of-order delivery. var version: Int } + +extension LiveProgress { + /// Strict "newer than" over the shared per-run sequence, tie-broken by the phase anchor. + /// The tie-break matters because the sequence isn't collision-free: each side catches its + /// counter up only from frames it *receives*, so after a lost frame both devices can mint + /// the same version independently. On a collision the later wall-clock action wins — + /// paired-device clock skew is sub-second, so the anchor orders genuine collisions + /// correctly. Equal version *and* equal anchor is the same frame redelivered: not newer. + func isNewer(thanVersion version: Int, start: Date) -> Bool { + self.version != version ? self.version > version : phaseStart > start + } + + func isNewer(than other: LiveProgress) -> Bool { + isNewer(thanVersion: other.version, start: other.phaseStart) + } +} diff --git a/Workouts Watch App/Connectivity/WatchConnectivityBridge.swift b/Workouts Watch App/Connectivity/WatchConnectivityBridge.swift index 143efe4..ac33b55 100644 --- a/Workouts Watch App/Connectivity/WatchConnectivityBridge.swift +++ b/Workouts Watch App/Connectivity/WatchConnectivityBridge.swift @@ -38,6 +38,12 @@ final class WatchConnectivityBridge: NSObject { /// wall-clock anchor, so a late re-send self-corrects on arrival rather than reading as stale. private var pendingLive: PendingLive? + /// In-flight backoff retry for a staged live message whose send *failed* (as opposed to + /// never being attempted because the peer was unreachable — that case is re-flushed by the + /// reachability/activation delegates). See `scheduleLiveRetry`. + private var liveRetryTask: Task? + private var liveRetryAttempt = 0 + /// The latest live-run frame the *phone* sent, for the run we currently have open to apply /// (ephemeral; nil when the phone isn't driving). The watch's `ExerciseProgressView` reads /// this to follow a phone-driven transition; it's never persisted. @@ -138,6 +144,7 @@ final class WatchConnectivityBridge: NSObject { var stamped = frame stamped.version = liveVersion pendingLive = .progress(stamped) + liveRetryAttempt = 0 flushLive() } @@ -147,6 +154,7 @@ final class WatchConnectivityBridge: NSObject { func sendLiveEnded(workoutID: String, logID: String) { guard let session, session.activationState == .activated else { return } pendingLive = .ended(workoutID: workoutID, logID: logID) + liveRetryAttempt = 0 flushLive() } @@ -156,6 +164,8 @@ final class WatchConnectivityBridge: NSObject { /// WatchConnectivity's background queue, so it must be nonisolated (@Sendable) — an /// inherited-@MainActor closure would trap (swift_task_checkIsolated) there. private func flushLive() { + liveRetryTask?.cancel() + liveRetryTask = nil guard let session, let pending = pendingLive, session.activationState == .activated, session.isReachable else { return } let payload: [String: Any] @@ -165,14 +175,43 @@ final class WatchConnectivityBridge: NSObject { case .ended(let workoutID, let logID): payload = WCPayload.encodeLiveEnded(workoutID: workoutID, logID: logID) } - session.sendMessage(payload, replyHandler: nil, errorHandler: { @Sendable _ in }) + session.sendMessage(payload, replyHandler: nil, errorHandler: { @Sendable [weak self] _ in + Task { @MainActor in self?.scheduleLiveRetry() } + }) } - /// Apply a live-run frame the phone sent. Drops a stale one for the same run, and catches - /// our send counter up so the next frame we send outranks it (shared per-run sequence). + /// A send failed while the phone was nominally reachable. Without this the staged message + /// would sit until the next reachability/activation edge — which may never come mid-workout — + /// so back off briefly and re-flush, a handful of times per staged message (the edges still + /// re-flush after the retries are spent). Whatever is staged *when the retry fires* is sent, + /// so a newer frame staged meanwhile supersedes the failed one here too. + private func scheduleLiveRetry() { + guard pendingLive != nil, liveRetryTask == nil, liveRetryAttempt < 5 else { return } + liveRetryAttempt += 1 + let delay = min(8.0, 0.5 * pow(2.0, Double(liveRetryAttempt - 1))) + liveRetryTask = Task { [weak self] in + try? await Task.sleep(for: .seconds(delay)) + guard let self, !Task.isCancelled else { return } + self.liveRetryTask = nil + self.flushLive() + } + } + + /// Apply a live-run frame the phone sent. Catches our send counter up first (shared per-run + /// sequence), then arbitrates against our own staged outbound frame for the same run: if the + /// incoming frame outranks it, drop the staged one (so a later reconnect can't re-send stale + /// state and yank the run backwards); if the *staged* frame outranks the delivery — a late or + /// version-collided arrival predating our own latest action — ignore the incoming frame. + /// Finally drops anything not newer than what we're already showing (redeliveries included). private func applyIncomingLive(_ frame: LiveProgress) { liveVersion = max(liveVersion, frame.version) - if let current = liveIncoming, current.logID == frame.logID, frame.version < current.version { return } + if case .progress(let staged) = pendingLive, staged.logID == frame.logID { + guard !staged.isNewer(than: frame) else { return } + pendingLive = nil + liveRetryTask?.cancel() + liveRetryTask = nil + } + if let current = liveIncoming, current.logID == frame.logID, !frame.isNewer(than: current) { return } liveIncoming = frame } diff --git a/Workouts Watch App/Views/ExerciseProgressView.swift b/Workouts Watch App/Views/ExerciseProgressView.swift index 2250756..a15e438 100644 --- a/Workouts Watch App/Views/ExerciseProgressView.swift +++ b/Workouts Watch App/Views/ExerciseProgressView.swift @@ -61,8 +61,15 @@ struct ExerciseProgressView: View { /// A human swipe leaves this `nil` (→ treated as human); programmatic moves set it. @State private var pageChangeCause: PageChangeCause? - /// Highest remote frame version we've applied, so a redelivery doesn't re-jump. + /// Highest remote frame we've applied — version plus its anchor tie-break (see + /// `LiveProgress.isNewer`) — so a redelivery or collided stale frame doesn't re-jump. @State private var lastAppliedVersion = 0 + @State private var lastAppliedStart = Date.distantPast + + /// Highest completed-set count this screen has itself recorded or followed via a live + /// frame — the baseline `repairFromDurable` compares an absorbed doc against, so a + /// durable echo of our own progress (or of a frame we followed) never triggers a jump. + @State private var knownStateIndex: Int /// When a remote frame drove us to a page, that page's timer anchors to the *sender's* /// wall clock (`remoteAnchorStart`/`End`) instead of local `now` — so delivery latency @@ -100,6 +107,7 @@ struct ExerciseProgressView: View { let log = doc.wrappedValue.logs.first { $0.id == logID } let sets = max(1, log?.sets ?? 1) _setCount = State(initialValue: sets) + _knownStateIndex = State(initialValue: max(0, log?.currentStateIndex ?? 0)) let notStarted = (log?.status ?? WorkoutStatus.notStarted.rawValue) == WorkoutStatus.notStarted.rawValue // The Ready page always leads the flow (except in the screenshot host). A @@ -141,6 +149,12 @@ struct ExerciseProgressView: View { return base + completed * 2 } + /// Completed-set count a given page implies — the same math `recordProgress` records + /// (capped at `setCount − 1`; only an explicit Done marks the final set). + private func completedSets(for pageIndex: Int) -> Int { + min(max(0, (pageIndex - base + 1) / 2), setCount - 1) + } + /// Position within the work/rest cycle for the current page — `nil` on the Ready /// and Finish pages (which show no dots). private var currentCycleIndex: Int? { @@ -257,6 +271,9 @@ struct ExerciseProgressView: View { .onChange(of: incomingFrame) { _, frame in if let frame { applyIncoming(frame) } } + .onChange(of: log?.currentStateIndex) { _, _ in + repairFromDurable() + } .onAppear { guard !didRestorePage else { return } if startsResumed { @@ -305,10 +322,15 @@ struct ExerciseProgressView: View { /// for a remote One More) without re-broadcasting or re-recording it — the phone owns the /// durable write, which arrives separately over the document sync. private func applyIncoming(_ frame: LiveProgress) { - guard didRestorePage, frame.logID == logID, frame.version > lastAppliedVersion else { return } + guard didRestorePage, frame.logID == logID, + frame.isNewer(thanVersion: lastAppliedVersion, start: lastAppliedStart) else { return } lastAppliedVersion = frame.version + lastAppliedStart = frame.phaseStart if frame.setCount != setCount { setCount = frame.setCount } let target = page(forPhase: frame.phase, setIndex: frame.setIndex) + // A followed frame is progress this screen now *knows*, so a durable echo of the + // same transition can't re-trigger the repair jump (a remote reset re-baselines). + knownStateIndex = frame.phase == .ready ? 0 : max(knownStateIndex, completedSets(for: target)) // Anchor the target page's timer to the sender's wall clock, not local now, so the // displayed countdown matches regardless of how long the frame took to arrive. remoteAnchorPage = target @@ -319,6 +341,27 @@ struct ExerciseProgressView: View { withAnimation { currentPage = target } } + /// Durable-state safety net for the ephemeral mirror. A peer's swipe frame can be lost + /// (`sendMessage` is reachable-only and the retry can run dry), but its durable write still + /// arrives via the application context and is absorbed into `doc` by the parent list. When + /// the absorbed doc shows sets completed beyond anything this screen has recorded or + /// followed, jump forward to the first unfinished set's work page so the flow can't stay + /// stuck on a stale phase. Forward-only — progress is monotonic, so a same-or-behind doc + /// is our own write echoing back — and only while the run is still in progress. + private func repairFromDurable() { + guard didRestorePage, let log, + log.status == WorkoutStatus.inProgress.rawValue else { return } + let completed = log.currentStateIndex + guard completed > knownStateIndex else { return } + knownStateIndex = completed + if log.sets > setCount { setCount = log.sets } + let target = base + min(max(0, completed), setCount - 1) * 2 + guard target > currentPage else { return } + clearRemoteAnchor() + pageChangeCause = .remote + withAnimation { currentPage = target } + } + /// Drop the remote anchor when a local transition moves us off the remote-driven page, /// so the next phase counts off local `now` (and a swipe back doesn't reuse a stale anchor). private func clearRemoteAnchor() { @@ -478,6 +521,7 @@ struct ExerciseProgressView: View { /// plan, and slide forward into the bonus set's work phase. private func addSet() { let newCount = setCount + 1 + knownStateIndex = max(knownStateIndex, newCount - 1) if let i = doc.logs.firstIndex(where: { $0.id == logID }) { doc.logs[i].sets = newCount @@ -505,8 +549,8 @@ struct ExerciseProgressView: View { /// swiping back — or a transient TabView snap to page 0 — never un-counts a set. private func recordProgress(for pageIndex: Int) { if showsReady && pageIndex == 0 { return } // Ready page records nothing - let cycleIndex = pageIndex - base - let reached = min(max(0, (cycleIndex + 1) / 2), setCount - 1) + let reached = completedSets(for: pageIndex) + knownStateIndex = max(knownStateIndex, reached) guard let i = doc.logs.firstIndex(where: { $0.id == logID }) else { return } guard reached > doc.logs[i].currentStateIndex else { return } @@ -527,6 +571,7 @@ struct ExerciseProgressView: View { guard let i = doc.logs.firstIndex(where: { $0.id == logID }) else { return } let log = doc.logs[i] // Skip the write if it's already pristine (e.g. landing on Ready before any set). + knownStateIndex = 0 guard log.currentStateIndex != 0 || log.status != WorkoutStatus.notStarted.rawValue else { return } doc.logs[i].currentStateIndex = 0 @@ -537,6 +582,7 @@ struct ExerciseProgressView: View { } private func completeExercise() { + knownStateIndex = max(knownStateIndex, setCount) guard let i = doc.logs.firstIndex(where: { $0.id == logID }) else { return } doc.logs[i].currentStateIndex = setCount doc.logs[i].transition(to: .completed) diff --git a/Workouts/Connectivity/LiveRunState.swift b/Workouts/Connectivity/LiveRunState.swift index 33c5851..54a8ba5 100644 --- a/Workouts/Connectivity/LiveRunState.swift +++ b/Workouts/Connectivity/LiveRunState.swift @@ -34,9 +34,9 @@ final class LiveRunState { return c } - /// Apply an incoming frame, dropping a stale one for the same run. + /// Apply an incoming frame, dropping a stale (or redelivered) one for the same run. func apply(_ frame: LiveProgress) { - if let c = current, c.logID == frame.logID, frame.version < c.version { return } + if let c = current, c.logID == frame.logID, !frame.isNewer(than: c) { return } current = frame } diff --git a/Workouts/Connectivity/PhoneConnectivityBridge.swift b/Workouts/Connectivity/PhoneConnectivityBridge.swift index e659d13..c343345 100644 --- a/Workouts/Connectivity/PhoneConnectivityBridge.swift +++ b/Workouts/Connectivity/PhoneConnectivityBridge.swift @@ -38,6 +38,12 @@ final class PhoneConnectivityBridge: NSObject { /// wall-clock anchor, so a late re-send self-corrects on arrival rather than reading as stale. private var pendingLive: PendingLive? + /// In-flight backoff retry for a staged live message whose send *failed* (as opposed to + /// never being attempted because the peer was unreachable — that case is re-flushed by the + /// reachability/activation delegates). See `scheduleLiveRetry`. + private var liveRetryTask: Task? + private var liveRetryAttempt = 0 + private var context: ModelContext { container.mainContext } init(container: ModelContainer, syncEngine: SyncEngine, liveRunState: LiveRunState) { @@ -146,6 +152,7 @@ final class PhoneConnectivityBridge: NSObject { var stamped = frame stamped.version = liveVersion pendingLive = .progress(stamped) + liveRetryAttempt = 0 flushLive() } @@ -155,6 +162,7 @@ final class PhoneConnectivityBridge: NSObject { func sendLiveEnded(workoutID: String, logID: String) { guard let session, session.activationState == .activated else { return } pendingLive = .ended(workoutID: workoutID, logID: logID) + liveRetryAttempt = 0 flushLive() } @@ -164,6 +172,8 @@ final class PhoneConnectivityBridge: NSObject { /// WatchConnectivity's background queue, so it must be nonisolated (@Sendable) — an /// inherited-@MainActor closure would trap (swift_task_checkIsolated) there. private func flushLive() { + liveRetryTask?.cancel() + liveRetryTask = nil guard let session, let pending = pendingLive, session.activationState == .activated, session.isReachable else { return } let payload: [String: Any] @@ -173,13 +183,42 @@ final class PhoneConnectivityBridge: NSObject { case .ended(let workoutID, let logID): payload = WCPayload.encodeLiveEnded(workoutID: workoutID, logID: logID) } - session.sendMessage(payload, replyHandler: nil, errorHandler: { @Sendable _ in }) + session.sendMessage(payload, replyHandler: nil, errorHandler: { @Sendable [weak self] _ in + Task { @MainActor in self?.scheduleLiveRetry() } + }) + } + + /// A send failed while the watch was nominally reachable. Without this the staged message + /// would sit until the next reachability/activation edge — which may never come mid-workout — + /// so back off briefly and re-flush, a handful of times per staged message (the edges still + /// re-flush after the retries are spent). Whatever is staged *when the retry fires* is sent, + /// so a newer frame staged meanwhile supersedes the failed one here too. + private func scheduleLiveRetry() { + guard pendingLive != nil, liveRetryTask == nil, liveRetryAttempt < 5 else { return } + liveRetryAttempt += 1 + let delay = min(8.0, 0.5 * pow(2.0, Double(liveRetryAttempt - 1))) + liveRetryTask = Task { [weak self] in + try? await Task.sleep(for: .seconds(delay)) + guard let self, !Task.isCancelled else { return } + self.liveRetryTask = nil + self.flushLive() + } } /// Apply a frame the watch sent. Catch our send counter up to it first, so the next frame /// we send outranks it and the shared per-run sequence keeps increasing across devices. + /// Then arbitrate against our own staged outbound frame for the same run: if the incoming + /// frame outranks it, drop the staged one (so a later reconnect can't re-send stale state + /// and yank the run backwards); if the *staged* frame outranks the delivery — a late or + /// version-collided arrival predating our own latest action — ignore the incoming frame. private func applyIncomingLive(_ frame: LiveProgress) { liveVersion = max(liveVersion, frame.version) + if case .progress(let staged) = pendingLive, staged.logID == frame.logID { + guard !staged.isNewer(than: frame) else { return } + pendingLive = nil + liveRetryTask?.cancel() + liveRetryTask = nil + } liveRunState.apply(frame) } diff --git a/Workouts/Views/WorkoutLogs/ExerciseProgressView.swift b/Workouts/Views/WorkoutLogs/ExerciseProgressView.swift index 967fc59..a22df3a 100644 --- a/Workouts/Views/WorkoutLogs/ExerciseProgressView.swift +++ b/Workouts/Views/WorkoutLogs/ExerciseProgressView.swift @@ -82,8 +82,15 @@ struct ExerciseProgressView: View { /// A human swipe leaves this `nil` (→ treated as human); programmatic moves set it. @State private var pageChangeCause: PageChangeCause? - /// Highest remote frame version we've applied, so a redelivery doesn't re-jump. + /// Highest remote frame we've applied — version plus its anchor tie-break (see + /// `LiveProgress.isNewer`) — so a redelivery or collided stale frame doesn't re-jump. @State private var lastAppliedVersion = 0 + @State private var lastAppliedStart = Date.distantPast + + /// Highest completed-set count this screen has itself recorded or followed via a live + /// frame — the baseline `repairFromDurable` compares an absorbed doc against, so a + /// durable echo of our own progress (or of a frame we followed) never triggers a jump. + @State private var knownStateIndex: Int /// When a remote frame drove us to a page, that page's timer anchors to the *sender's* /// wall clock (`remoteAnchorStart`/`End`) instead of local `now` — so delivery latency @@ -125,6 +132,7 @@ struct ExerciseProgressView: View { let log = doc.wrappedValue.logs.first { $0.id == logID } let sets = max(1, log?.sets ?? 1) _setCount = State(initialValue: sets) + _knownStateIndex = State(initialValue: max(0, log?.currentStateIndex ?? 0)) // The Ready page always leads the flow. A not-started run opens on it; an // in-progress run opens on its first unfinished set and re-asserts that page past @@ -165,6 +173,12 @@ struct ExerciseProgressView: View { return base + completed * 2 } + /// Completed-set count a given page implies — the same math `recordProgress` records + /// (capped at `setCount − 1`; only an explicit Done marks the final set). + private func completedSets(for pageIndex: Int) -> Int { + min(max(0, (pageIndex - base + 1) / 2), setCount - 1) + } + /// Position within the work/rest cycle for the current page — `nil` on the Ready /// and Finish pages (which show no dots). private var currentCycleIndex: Int? { @@ -301,6 +315,9 @@ struct ExerciseProgressView: View { .onChange(of: incomingFrame) { _, frame in if let frame { applyIncoming(frame) } } + .onChange(of: log?.currentStateIndex) { _, _ in + repairFromDurable() + } .onAppear { guard !didRestorePage else { return } if startsResumed { @@ -375,10 +392,15 @@ struct ExerciseProgressView: View { /// for a remote One More) without re-broadcasting or re-recording it — the watch owns the /// durable write, which arrives separately over the document sync. private func applyIncoming(_ frame: LiveProgress) { - guard didRestorePage, frame.logID == logID, frame.version > lastAppliedVersion else { return } + guard didRestorePage, frame.logID == logID, + frame.isNewer(thanVersion: lastAppliedVersion, start: lastAppliedStart) else { return } lastAppliedVersion = frame.version + lastAppliedStart = frame.phaseStart if frame.setCount != setCount { setCount = frame.setCount } let target = page(forPhase: frame.phase, setIndex: frame.setIndex) + // A followed frame is progress this screen now *knows*, so a durable echo of the + // same transition can't re-trigger the repair jump (a remote reset re-baselines). + knownStateIndex = frame.phase == .ready ? 0 : max(knownStateIndex, completedSets(for: target)) // Anchor the target page's timer to the sender's wall clock, not local now, so the // displayed countdown matches regardless of how long the frame took to arrive. remoteAnchorPage = target @@ -389,6 +411,27 @@ struct ExerciseProgressView: View { withAnimation { currentPage = target } } + /// Durable-state safety net for the ephemeral mirror. A peer's swipe frame can be lost + /// (`sendMessage` is reachable-only and the retry can run dry), but its durable write still + /// arrives via the document sync and is absorbed into `doc` by the parent. When the + /// absorbed doc shows sets completed beyond anything this screen has recorded or + /// followed, jump forward to the first unfinished set's work page so the flow can't stay + /// stuck on a stale phase. Forward-only — progress is monotonic, so a same-or-behind doc + /// is our own write echoing back — and only while the run is still in progress. + private func repairFromDurable() { + guard didRestorePage, let log, + log.status == WorkoutStatus.inProgress.rawValue else { return } + let completed = log.currentStateIndex + guard completed > knownStateIndex else { return } + knownStateIndex = completed + if log.sets > setCount { setCount = log.sets } + let target = base + min(max(0, completed), setCount - 1) * 2 + guard target > currentPage else { return } + clearRemoteAnchor() + pageChangeCause = .remote + withAnimation { currentPage = target } + } + /// Drop the remote anchor when a local transition moves us off the remote-driven page, /// so the next phase counts off local `now` (and a swipe back doesn't reuse a stale anchor). private func clearRemoteAnchor() { @@ -596,6 +639,7 @@ struct ExerciseProgressView: View { /// plan, and slide forward into the bonus set's work phase. private func addSet() { let newCount = setCount + 1 + knownStateIndex = max(knownStateIndex, newCount - 1) if let i = doc.logs.firstIndex(where: { $0.id == logID }) { doc.logs[i].sets = newCount @@ -623,8 +667,8 @@ struct ExerciseProgressView: View { /// TabView snap to page 0 — never un-counts a set. private func recordProgress(for pageIndex: Int) { if showsReady && pageIndex == 0 { return } // Ready page records nothing - let cycleIndex = pageIndex - base - let reached = min(max(0, (cycleIndex + 1) / 2), setCount - 1) + let reached = completedSets(for: pageIndex) + knownStateIndex = max(knownStateIndex, reached) guard let i = doc.logs.firstIndex(where: { $0.id == logID }) else { return } guard reached > doc.logs[i].currentStateIndex else { return } @@ -644,6 +688,7 @@ struct ExerciseProgressView: View { private func resetExercise() { guard let i = doc.logs.firstIndex(where: { $0.id == logID }) else { return } let log = doc.logs[i] + knownStateIndex = 0 // Skip the write if it's already pristine (e.g. landing on Ready before any set). guard log.currentStateIndex != 0 || log.status != WorkoutStatus.notStarted.rawValue else { return } @@ -655,6 +700,7 @@ struct ExerciseProgressView: View { } private func completeExercise() { + knownStateIndex = max(knownStateIndex, setCount) guard let i = doc.logs.firstIndex(where: { $0.id == logID }) else { return } doc.logs[i].currentStateIndex = setCount doc.logs[i].transition(to: .completed) diff --git a/WorkoutsTests/LiveProgressOrderingTests.swift b/WorkoutsTests/LiveProgressOrderingTests.swift new file mode 100644 index 0000000..bc6c1a6 --- /dev/null +++ b/WorkoutsTests/LiveProgressOrderingTests.swift @@ -0,0 +1,47 @@ +import Foundation +import Testing +@testable import Workouts + +/// Pins the live-mirror staleness ordering (`LiveProgress.isNewer`): the shared per-run +/// version sequence decides, and the phase anchor breaks ties. The tie-break exists +/// because the sequence isn't collision-free — each side catches its counter up only from +/// frames it *receives*, so after a lost frame both devices can mint the same version +/// independently, and the later wall-clock action must win. Equal version *and* equal +/// anchor is the same frame redelivered, which must NOT rank as newer (a redelivery would +/// otherwise re-jump the follower's page). +struct LiveProgressOrderingTests { + private static let t0 = Date(timeIntervalSince1970: 1_700_000_000) + + private func frame(version: Int, start: Date) -> LiveProgress { + LiveProgress(workoutID: "W", logID: "L", exerciseName: "Bench", phase: .work, + setIndex: 0, setCount: 3, detail: "8 reps", + phaseStart: start, phaseEnd: nil, version: version) + } + + @Test func higherVersionWinsRegardlessOfAnchor() { + let older = frame(version: 3, start: Self.t0.addingTimeInterval(10)) + let newer = frame(version: 4, start: Self.t0) // earlier anchor, higher version + #expect(newer.isNewer(than: older)) + #expect(!older.isNewer(than: newer)) + } + + @Test func collidedVersionsTieBreakOnAnchor() { + let phoneFrame = frame(version: 4, start: Self.t0) + let watchFrame = frame(version: 4, start: Self.t0.addingTimeInterval(2)) + #expect(watchFrame.isNewer(than: phoneFrame)) + #expect(!phoneFrame.isNewer(than: watchFrame)) + } + + @Test func redeliveryIsNotNewer() { + let sent = frame(version: 4, start: Self.t0) + #expect(!sent.isNewer(than: sent)) + } + + @Test func versionAndStartOverloadMatchesFrameOverload() { + let f = frame(version: 4, start: Self.t0) + #expect(f.isNewer(thanVersion: 3, start: Self.t0.addingTimeInterval(99))) + #expect(f.isNewer(thanVersion: 4, start: Self.t0.addingTimeInterval(-1))) + #expect(!f.isNewer(thanVersion: 4, start: Self.t0)) + #expect(!f.isNewer(thanVersion: 5, start: .distantPast)) + } +}