Files
workouts/WorkoutsTests/LiveProgressOrderingTests.swift
rzen 8cbe078ffd Make the live-run mirror survive lost phone-to-watch frames
Live frames ride sendMessage, which is reachable-only — and phone→watch
reachability drops exactly when the user is swiping on the phone (wrist
down). A frame that failed to send was staged but never retried until a
reachability edge, and a frame lost outright desynced the run until the
next human transition — sometimes forever, since a reconnect could even
re-send the stale staged frame and yank the peer backwards.

Four fixes, symmetric on both bridges and both run screens:

- A send that fails while nominally reachable now retries with a short
  backoff (a few times per staged message) instead of being swallowed.
- Receiving a frame that outranks the staged outbound one drops the
  staged frame, so a reconnect re-send can't move the run backwards;
  a delivery the staged frame outranks is ignored as stale.
- Every staleness comparison now tie-breaks the shared version sequence
  on the frame's wall-clock anchor (LiveProgress.isNewer) — after a
  lost frame both devices can mint the same version, and the later
  human action must win.
- Durable repair: when the absorbed workout doc shows sets completed
  beyond anything the open run screen recorded or followed, it jumps
  forward to the first unfinished set's work page — a lost frame now
  degrades to a briefly-stale page instead of a stuck one.
2026-07-08 18:52:24 -04:00

48 lines
2.2 KiB
Swift

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))
}
}