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
475 lines
24 KiB
Swift
475 lines
24 KiB
Swift
import Foundation
|
|
import SwiftData
|
|
import Testing
|
|
@testable import Workouts
|
|
|
|
/// Pins the pure decision table of `WriteBacklog` — the depth-1 per-document write
|
|
/// queue `SyncEngine` drains to disk. Every operation here is a `struct`-level
|
|
/// mutation with no I/O and no clock of its own, so replacement, retry bookkeeping,
|
|
/// backoff, and TTL pruning are all deterministic and directly testable.
|
|
/// `SyncEngineWriteQueueTests` below covers how the engine wires this into the cache
|
|
/// and the `save`/`delete`/`ingestFromWatch` call sites.
|
|
struct WriteBacklogTests {
|
|
|
|
private static let t0 = Date(timeIntervalSince1970: 1_700_000_000)
|
|
|
|
/// Mid-month, noon dates in the current time zone — matches
|
|
/// `WorkoutPathBucketingTests`'s pattern so a derived `relativePath` month bucket
|
|
/// can never be nudged across a boundary by a real-world UTC offset.
|
|
private func date(year: Int, month: Int, day: Int = 15, hour: Int = 12) -> Date {
|
|
var comps = DateComponents()
|
|
comps.year = year; comps.month = month; comps.day = day; comps.hour = hour
|
|
return Calendar(identifier: .gregorian).date(from: comps)!
|
|
}
|
|
|
|
private func workoutDoc(id: String, start: Date, updatedAt: Date, routineName: String? = nil) -> WorkoutDocument {
|
|
WorkoutDocument(
|
|
schemaVersion: WorkoutDocument.currentSchemaVersion, id: id, routineID: nil,
|
|
routineName: routineName, start: start, end: nil,
|
|
status: WorkoutStatus.notStarted.rawValue, createdAt: updatedAt, updatedAt: updatedAt,
|
|
logs: [], metrics: nil
|
|
)
|
|
}
|
|
|
|
/// Builds a `PendingWrite` with a workout payload and full control over every
|
|
/// bookkeeping field, so replacement/retry/backoff tests can set up exact
|
|
/// preconditions directly instead of driving them indirectly through `SyncEngine`.
|
|
private func pendingWorkout(
|
|
id: String, start: Date, timestamp: Date, enqueuedAt: Date, routineName: String? = nil,
|
|
attempts: Int = 0, lastAttemptAt: Date? = nil, faultMessage: String? = nil,
|
|
stalePaths: Set<String> = []
|
|
) -> PendingWrite {
|
|
PendingWrite(
|
|
payload: .workout(workoutDoc(id: id, start: start, updatedAt: timestamp, routineName: routineName)),
|
|
timestamp: timestamp, stalePaths: stalePaths, enqueuedAt: enqueuedAt,
|
|
attempts: attempts, lastAttemptAt: lastAttemptAt, faultMessage: faultMessage
|
|
)
|
|
}
|
|
|
|
// MARK: - enqueue
|
|
|
|
/// Enqueuing into an empty slot stores the write verbatim, and `count`/`isEmpty`
|
|
/// reflect the newly-occupied slot — the base case every replacement test below
|
|
/// builds on.
|
|
@Test func enqueueIntoEmptySlotStoresWrite() {
|
|
var backlog = WriteBacklog()
|
|
#expect(backlog.isEmpty)
|
|
#expect(backlog.count == 0)
|
|
|
|
let write = pendingWorkout(id: "01ENQUEUEEMPTY0000000001", start: Self.t0, timestamp: Self.t0, enqueuedAt: Self.t0)
|
|
backlog.enqueue(write)
|
|
|
|
#expect(!backlog.isEmpty)
|
|
#expect(backlog.count == 1)
|
|
#expect(backlog.pendingWrite(for: write.documentID)?.timestamp == Self.t0)
|
|
}
|
|
|
|
/// Newer-wins replacement: the incoming document's content takes the slot, but it
|
|
/// inherits the FIRST entry's retry bookkeeping (`enqueuedAt`, `attempts`,
|
|
/// `lastAttemptAt`, `faultMessage`) — resetting those on every edit would flap the
|
|
/// retry banner even though the backlog is failing for environmental reasons, not
|
|
/// because of what's in the slot. Stale paths union both sides' preexisting sets,
|
|
/// plus the occupant's now-abandoned target path (a month-rebucketing edit moves a
|
|
/// workout's file; an earlier attempt may already have landed at the old path).
|
|
@Test func newerWinsReplacementInheritsBookkeepingAndUnionsStalePaths() throws {
|
|
var backlog = WriteBacklog()
|
|
let id = "01NEWERWINS0000000000001"
|
|
let marchStart = date(year: 2024, month: 3)
|
|
let aprilStart = date(year: 2024, month: 4)
|
|
let oldTargetPath = "Workouts/2024/03/\(id).json"
|
|
|
|
let existing = pendingWorkout(
|
|
id: id, start: marchStart, timestamp: Self.t0,
|
|
enqueuedAt: Self.t0.addingTimeInterval(-100),
|
|
attempts: 2, lastAttemptAt: Self.t0.addingTimeInterval(-10), faultMessage: "disk full",
|
|
stalePaths: ["Workouts/2024/02/\(id).json"]
|
|
)
|
|
#expect(existing.targetPath == oldTargetPath)
|
|
backlog.enqueue(existing)
|
|
|
|
let incoming = pendingWorkout(
|
|
id: id, start: aprilStart, timestamp: Self.t0.addingTimeInterval(10),
|
|
enqueuedAt: Self.t0, // would be its own enqueuedAt if it were the first — must be overridden
|
|
stalePaths: ["Workouts/2024/05/\(id).json"]
|
|
)
|
|
backlog.enqueue(incoming)
|
|
|
|
let slot = try #require(backlog.pendingWrite(for: id))
|
|
#expect(slot.timestamp == incoming.timestamp)
|
|
guard case .workout(let doc) = slot.payload else {
|
|
Issue.record("expected a workout payload"); return
|
|
}
|
|
#expect(doc.start == aprilStart) // the newer document content wins the slot
|
|
|
|
#expect(slot.enqueuedAt == existing.enqueuedAt)
|
|
#expect(slot.attempts == existing.attempts)
|
|
#expect(slot.lastAttemptAt == existing.lastAttemptAt)
|
|
#expect(slot.faultMessage == existing.faultMessage)
|
|
|
|
#expect(slot.stalePaths.isSuperset(of: existing.stalePaths))
|
|
#expect(slot.stalePaths.isSuperset(of: incoming.stalePaths))
|
|
#expect(slot.stalePaths.contains(oldTargetPath))
|
|
}
|
|
|
|
/// An incoming write at exactly the occupant's timestamp still wins the slot —
|
|
/// equal timestamps are treated as the latest local re-save of the same logical
|
|
/// state, so last-in is correct.
|
|
@Test func equalTimestampIncomingWinsSlot() throws {
|
|
var backlog = WriteBacklog()
|
|
let id = "01EQUALTIMESTAMP000000001"
|
|
backlog.enqueue(pendingWorkout(id: id, start: Self.t0, timestamp: Self.t0, enqueuedAt: Self.t0, routineName: "Original"))
|
|
backlog.enqueue(pendingWorkout(id: id, start: Self.t0, timestamp: Self.t0, enqueuedAt: Self.t0, routineName: "Incoming"))
|
|
|
|
let slot = try #require(backlog.pendingWrite(for: id))
|
|
guard case .workout(let doc) = slot.payload else {
|
|
Issue.record("expected a workout payload"); return
|
|
}
|
|
#expect(doc.routineName == "Incoming")
|
|
}
|
|
|
|
/// An incoming write strictly older than the occupant is dropped outright — the
|
|
/// slot (and its bookkeeping) is left completely unchanged.
|
|
@Test func strictlyOlderIncomingIsDropped() throws {
|
|
var backlog = WriteBacklog()
|
|
let id = "01OLDERDROPPED0000000001"
|
|
backlog.enqueue(pendingWorkout(id: id, start: Self.t0, timestamp: Self.t0, enqueuedAt: Self.t0, routineName: "Kept"))
|
|
backlog.enqueue(pendingWorkout(id: id, start: Self.t0, timestamp: Self.t0.addingTimeInterval(-5), enqueuedAt: Self.t0, routineName: "Ignored"))
|
|
|
|
let slot = try #require(backlog.pendingWrite(for: id))
|
|
guard case .workout(let doc) = slot.payload else {
|
|
Issue.record("expected a workout payload"); return
|
|
}
|
|
#expect(doc.routineName == "Kept")
|
|
#expect(slot.timestamp == Self.t0)
|
|
}
|
|
|
|
// MARK: - resolve
|
|
|
|
/// `resolve(id:ifTimestampAtMost:)` is the replacement-safety property the drain
|
|
/// relies on: it removes the slot when the just-written version is still current
|
|
/// (`entry.timestamp <= given`), but a write that slotted in WHILE the attempt was
|
|
/// in flight (`entry.timestamp > given`) survives — the drain only just wrote the
|
|
/// OLD content, so the newer queued edit must not be discarded as if it had landed.
|
|
@Test func resolveRemovesOnlyWhenNoNewerWriteSlottedIn() throws {
|
|
var backlog = WriteBacklog()
|
|
let id = "01RESOLVESAFETY0000000001"
|
|
let firstAttemptTimestamp = Self.t0
|
|
backlog.enqueue(pendingWorkout(id: id, start: Self.t0, timestamp: firstAttemptTimestamp, enqueuedAt: Self.t0))
|
|
|
|
let newerTimestamp = Self.t0.addingTimeInterval(30)
|
|
backlog.enqueue(pendingWorkout(id: id, start: Self.t0, timestamp: newerTimestamp, enqueuedAt: Self.t0))
|
|
|
|
backlog.resolve(id: id, ifTimestampAtMost: firstAttemptTimestamp)
|
|
let survivor = try #require(backlog.pendingWrite(for: id))
|
|
#expect(survivor.timestamp == newerTimestamp)
|
|
|
|
backlog.resolve(id: id, ifTimestampAtMost: newerTimestamp)
|
|
#expect(backlog.pendingWrite(for: id) == nil)
|
|
}
|
|
|
|
// MARK: - markFailed
|
|
|
|
/// `markFailed` increments attempts and stamps `lastAttemptAt` on every call, only
|
|
/// overwrites `faultMessage` when a new one is supplied (an unrecoverable error
|
|
/// shouldn't be forgotten by a subsequent plain retry failure), `maxAttempts` is
|
|
/// the max across every slot, and `firstFaultMessage` walks slots oldest-`enqueuedAt`
|
|
/// first — so once the OLDEST slot also faults, its message takes priority even
|
|
/// though a newer slot faulted first in wall-clock time.
|
|
@Test func markFailedTracksAttemptsFaultPriorityAndMax() throws {
|
|
var backlog = WriteBacklog()
|
|
let older = "01MARKFAILEDOLDER00000001" // enqueued first
|
|
let newer = "01MARKFAILEDNEWER00000001" // enqueued second
|
|
backlog.enqueue(pendingWorkout(id: older, start: Self.t0, timestamp: Self.t0, enqueuedAt: Self.t0))
|
|
backlog.enqueue(pendingWorkout(id: newer, start: Self.t0, timestamp: Self.t0, enqueuedAt: Self.t0.addingTimeInterval(5)))
|
|
#expect(backlog.maxAttempts == 0)
|
|
#expect(backlog.firstFaultMessage == nil)
|
|
|
|
let failAt1 = Self.t0.addingTimeInterval(1)
|
|
backlog.markFailed(id: older, at: failAt1)
|
|
var entry = try #require(backlog.pendingWrite(for: older))
|
|
#expect(entry.attempts == 1)
|
|
#expect(entry.lastAttemptAt == failAt1)
|
|
#expect(entry.faultMessage == nil)
|
|
|
|
// `newer` is the only faulting slot so far — it surfaces even though `older`
|
|
// was enqueued earlier and hasn't faulted.
|
|
backlog.markFailed(id: newer, at: Self.t0.addingTimeInterval(2), fault: "disk full")
|
|
#expect(backlog.firstFaultMessage == "disk full")
|
|
|
|
// Once `older` (the earlier-enqueued slot) also faults, its message takes
|
|
// priority — `firstFaultMessage` walks entries oldest-first.
|
|
backlog.markFailed(id: older, at: Self.t0.addingTimeInterval(3), fault: "older fault")
|
|
#expect(backlog.firstFaultMessage == "older fault")
|
|
|
|
// A later failure with no new fault keeps the existing message rather than
|
|
// clearing it.
|
|
backlog.markFailed(id: older, at: Self.t0.addingTimeInterval(4))
|
|
entry = try #require(backlog.pendingWrite(for: older))
|
|
#expect(entry.attempts == 3)
|
|
#expect(entry.faultMessage == "older fault")
|
|
|
|
#expect(backlog.maxAttempts == 3) // older: 3 attempts, newer: 1
|
|
}
|
|
|
|
// MARK: - Backoff scheduling
|
|
|
|
/// `nextDue`/`earliestNextAttempt` are the drain loop's scheduling surface: a
|
|
/// fresh entry (never attempted) is due immediately at its `enqueuedAt`, and each
|
|
/// failure re-anchors the backoff at `retryDelay(afterAttempts:)` from the FAILURE
|
|
/// time — not cumulative, not from `enqueuedAt`.
|
|
@Test func nextDueAndEarliestNextAttemptHonorBackoff() throws {
|
|
var backlog = WriteBacklog()
|
|
let id = "01BACKOFFDUE0000000000001"
|
|
let enqueuedAt = Self.t0
|
|
backlog.enqueue(pendingWorkout(id: id, start: Self.t0, timestamp: Self.t0, enqueuedAt: enqueuedAt))
|
|
// A second, never-failing slot enqueued much later — present throughout so
|
|
// `earliestNextAttempt` is genuinely picking a minimum across slots, not just
|
|
// echoing the only one that exists.
|
|
let freshID = "01BACKOFFFRESH00000000001"
|
|
backlog.enqueue(pendingWorkout(id: freshID, start: Self.t0, timestamp: Self.t0, enqueuedAt: Self.t0.addingTimeInterval(10_000)))
|
|
|
|
var entry = try #require(backlog.pendingWrite(for: id))
|
|
#expect(entry.nextAttemptAt == enqueuedAt)
|
|
#expect(backlog.nextDue(at: enqueuedAt.addingTimeInterval(-1)) == nil)
|
|
#expect(backlog.nextDue(at: enqueuedAt)?.documentID == id)
|
|
#expect(backlog.earliestNextAttempt() == enqueuedAt)
|
|
|
|
let firstFailure = Self.t0.addingTimeInterval(100)
|
|
backlog.markFailed(id: id, at: firstFailure)
|
|
entry = try #require(backlog.pendingWrite(for: id))
|
|
let expectedFirstRetry = firstFailure.addingTimeInterval(WriteBacklog.retryDelay(afterAttempts: 1))
|
|
#expect(entry.nextAttemptAt == expectedFirstRetry)
|
|
#expect(backlog.nextDue(at: expectedFirstRetry.addingTimeInterval(-0.5)) == nil)
|
|
#expect(backlog.nextDue(at: expectedFirstRetry)?.documentID == id)
|
|
|
|
let secondFailure = expectedFirstRetry.addingTimeInterval(50)
|
|
backlog.markFailed(id: id, at: secondFailure)
|
|
entry = try #require(backlog.pendingWrite(for: id))
|
|
let expectedSecondRetry = secondFailure.addingTimeInterval(WriteBacklog.retryDelay(afterAttempts: 2))
|
|
#expect(entry.nextAttemptAt == expectedSecondRetry)
|
|
#expect(backlog.earliestNextAttempt() == expectedSecondRetry)
|
|
}
|
|
|
|
/// Locks the exact backoff table: three quick silent retries (2s/4s/8s), then calm
|
|
/// long retries, capped at 5 minutes from attempt 7 onward.
|
|
@Test func retryDelayScheduleMatchesBackoffTable() {
|
|
#expect(WriteBacklog.retryDelay(afterAttempts: 0) == 0)
|
|
#expect(WriteBacklog.retryDelay(afterAttempts: 1) == 2)
|
|
#expect(WriteBacklog.retryDelay(afterAttempts: 2) == 4)
|
|
#expect(WriteBacklog.retryDelay(afterAttempts: 3) == 8)
|
|
#expect(WriteBacklog.retryDelay(afterAttempts: 4) == 30)
|
|
#expect(WriteBacklog.retryDelay(afterAttempts: 5) == 60)
|
|
#expect(WriteBacklog.retryDelay(afterAttempts: 6) == 120)
|
|
#expect(WriteBacklog.retryDelay(afterAttempts: 7) == 300)
|
|
#expect(WriteBacklog.retryDelay(afterAttempts: 50) == 300) // capped, not just "7"
|
|
}
|
|
|
|
// MARK: - pruneExpired
|
|
|
|
/// A persisted backlog loaded on relaunch drops any slot stuck longer than the
|
|
/// TTL — a run that died with a queued write shouldn't drain stale state into a
|
|
/// container that's long since moved on — while a younger slot survives untouched.
|
|
@Test func pruneExpiredDropsOnlyEntriesPastTheTTL() throws {
|
|
var backlog = WriteBacklog()
|
|
let now = Self.t0
|
|
let ttl: TimeInterval = 60
|
|
let youngID = "01PRUNEYOUNG0000000000001"
|
|
let oldID = "01PRUNEOLD0000000000000001"
|
|
backlog.enqueue(pendingWorkout(id: youngID, start: Self.t0, timestamp: Self.t0, enqueuedAt: now.addingTimeInterval(-30)))
|
|
backlog.enqueue(pendingWorkout(id: oldID, start: Self.t0, timestamp: Self.t0, enqueuedAt: now.addingTimeInterval(-90)))
|
|
|
|
backlog.pruneExpired(now: now, ttl: ttl)
|
|
|
|
#expect(backlog.pendingWrite(for: youngID) != nil)
|
|
#expect(backlog.pendingWrite(for: oldID) == nil)
|
|
#expect(backlog.count == 1)
|
|
}
|
|
|
|
// MARK: - WriteBacklogFile
|
|
|
|
/// The sidecar file round-trips a non-empty backlog's slots (ids and timestamps
|
|
/// intact), and saving an EMPTY backlog removes any existing file instead of
|
|
/// writing `{}` — the sidecar's absence IS "no pending writes," matching what a
|
|
/// fresh install/relaunch with nothing queued looks like on disk.
|
|
@Test func fileRoundTripPreservesSlotsAndEmptySaveRemovesFile() throws {
|
|
let url = FileManager.default.temporaryDirectory.appending(path: UUID().uuidString + ".json")
|
|
defer { try? FileManager.default.removeItem(at: url) }
|
|
|
|
var backlog = WriteBacklog()
|
|
let idA = "01FILEROUNDTRIPA00000001"
|
|
let idB = "01FILEROUNDTRIPB00000001"
|
|
backlog.enqueue(pendingWorkout(id: idA, start: Self.t0, timestamp: Self.t0, enqueuedAt: Self.t0))
|
|
backlog.enqueue(pendingWorkout(id: idB, start: Self.t0, timestamp: Self.t0.addingTimeInterval(5), enqueuedAt: Self.t0.addingTimeInterval(5)))
|
|
|
|
WriteBacklogFile.save(backlog, to: url)
|
|
#expect(FileManager.default.fileExists(atPath: url.path))
|
|
|
|
let loaded = WriteBacklogFile.load(from: url)
|
|
#expect(loaded.count == 2)
|
|
#expect(loaded.pendingWrite(for: idA)?.timestamp == Self.t0)
|
|
#expect(loaded.pendingWrite(for: idA)?.documentID == idA)
|
|
#expect(loaded.pendingWrite(for: idB)?.timestamp == Self.t0.addingTimeInterval(5))
|
|
|
|
// Saving an EMPTY backlog over an existing file removes it rather than
|
|
// writing `{}`.
|
|
WriteBacklogFile.save(WriteBacklog(), to: url)
|
|
#expect(!FileManager.default.fileExists(atPath: url.path))
|
|
}
|
|
}
|
|
|
|
// MARK: - SyncEngine integration
|
|
|
|
/// Coverage for `SyncEngine`'s write-queue integration: every `save`/`delete` mirrors
|
|
/// into the SwiftData cache immediately and enqueues the file write (see
|
|
/// `WriteBacklogTests` above for the queue's own pure mechanics). None of these tests
|
|
/// call `connect()` — matching `SyncEngineTests`'s scope note, the engine's `store`
|
|
/// stays nil, so `kickDrain()` never launches a background drain that could race these
|
|
/// assertions, and no real iCloud container is ever touched. Each test builds its own
|
|
/// `SyncEngine` against a fresh in-memory `ModelContainer` and a unique temp
|
|
/// `backlogURL`, so tests can't contaminate each other or the developer's real
|
|
/// Application Support directory.
|
|
struct SyncEngineWriteQueueTests {
|
|
|
|
private static let t0 = Date(timeIntervalSince1970: 1_700_000_000)
|
|
|
|
private func tempBacklogURL() -> URL {
|
|
FileManager.default.temporaryDirectory.appending(path: "WriteBacklogTests-\(UUID().uuidString).json")
|
|
}
|
|
|
|
@MainActor
|
|
private func makeEngine(backlogURL: URL) throws -> (engine: SyncEngine, container: ModelContainer) {
|
|
let schema = Schema([Routine.self, Exercise.self, Workout.self, WorkoutLog.self])
|
|
let config = ModelConfiguration(schema: schema, isStoredInMemoryOnly: true, cloudKitDatabase: .none)
|
|
let container = try ModelContainer(for: schema, configurations: [config])
|
|
return (SyncEngine(container: container, backlogURL: backlogURL), container)
|
|
}
|
|
|
|
private func makeWorkoutDoc(id: String, updatedAt: Date, routineName: String? = "Push Day") -> WorkoutDocument {
|
|
WorkoutDocument(
|
|
schemaVersion: WorkoutDocument.currentSchemaVersion, id: id, routineID: "RT-1",
|
|
routineName: routineName, start: Self.t0, end: nil,
|
|
status: WorkoutStatus.notStarted.rawValue, createdAt: Self.t0, updatedAt: updatedAt,
|
|
logs: [], metrics: nil
|
|
)
|
|
}
|
|
|
|
// MARK: - save(workout:)
|
|
|
|
/// A local save mirrors into the cache immediately (a same-process write doesn't
|
|
/// reliably wake the metadata observer, and never does in the simulator) and
|
|
/// queues exactly one write.
|
|
@MainActor
|
|
@Test func saveWorkoutMirrorsToCacheAndQueuesWrite() async throws {
|
|
let (engine, container) = try makeEngine(backlogURL: tempBacklogURL())
|
|
let id = "01SAVEQUEUE0000000000001"
|
|
await engine.save(workout: makeWorkoutDoc(id: id, updatedAt: Self.t0))
|
|
|
|
let cached = try #require(CacheMapper.fetchWorkout(id: id, in: container.mainContext))
|
|
#expect(cached.updatedAt == Self.t0)
|
|
#expect(engine.pendingWriteCount == 1)
|
|
#expect(engine.writeQueueState == .pending)
|
|
}
|
|
|
|
/// Depth-1 per-document slot: saving the same id twice keeps exactly one queued
|
|
/// write, and the cache — which every save mirrors into unconditionally — shows
|
|
/// the LATEST version, matching what the eventually-drained file will contain.
|
|
@MainActor
|
|
@Test func repeatedSaveSameIDStaysDepthOneWithNewestCached() async throws {
|
|
let (engine, container) = try makeEngine(backlogURL: tempBacklogURL())
|
|
let id = "01DEPTHONE00000000000001"
|
|
await engine.save(workout: makeWorkoutDoc(id: id, updatedAt: Self.t0))
|
|
await engine.save(workout: makeWorkoutDoc(id: id, updatedAt: Self.t0.addingTimeInterval(60)))
|
|
|
|
#expect(engine.pendingWriteCount == 1)
|
|
let cached = try #require(CacheMapper.fetchWorkout(id: id, in: container.mainContext))
|
|
#expect(cached.updatedAt == Self.t0.addingTimeInterval(60))
|
|
}
|
|
|
|
// MARK: - ingestFromWatch
|
|
|
|
/// The watch bridge's `updatedAt` intake gate: a strictly-older push is dropped
|
|
/// (the cache is already ahead of the watch), an equal timestamp is the
|
|
/// echo/duplicate case and is ignored wholesale even when some other field
|
|
/// differs, and a strictly-newer push is accepted. `sendMessage` plus a queued
|
|
/// `transferUserInfo` fallback are unordered, so this gate is what keeps a stale
|
|
/// racing push from clobbering a newer local save.
|
|
@MainActor
|
|
@Test func ingestFromWatchAppliesUpdatedAtGate() async throws {
|
|
let (engine, container) = try makeEngine(backlogURL: tempBacklogURL())
|
|
let id = "01INGESTGATE000000000001"
|
|
await engine.save(workout: makeWorkoutDoc(id: id, updatedAt: Self.t0, routineName: "Push Day"))
|
|
|
|
// (a) strictly older — dropped, cache unchanged.
|
|
await engine.ingestFromWatch(makeWorkoutDoc(id: id, updatedAt: Self.t0.addingTimeInterval(-60), routineName: "Push Day"))
|
|
var cached = try #require(CacheMapper.fetchWorkout(id: id, in: container.mainContext))
|
|
#expect(cached.updatedAt == Self.t0)
|
|
|
|
// (b) same updatedAt but a changed field — the echo case, ignored entirely
|
|
// (equal timestamp means duplicate, not a genuine edit).
|
|
await engine.ingestFromWatch(makeWorkoutDoc(id: id, updatedAt: Self.t0, routineName: "Changed Name"))
|
|
cached = try #require(CacheMapper.fetchWorkout(id: id, in: container.mainContext))
|
|
#expect(cached.updatedAt == Self.t0)
|
|
#expect(cached.routineName == "Push Day")
|
|
|
|
// (c) strictly newer — accepted.
|
|
await engine.ingestFromWatch(makeWorkoutDoc(id: id, updatedAt: Self.t0.addingTimeInterval(60), routineName: "Changed Name"))
|
|
cached = try #require(CacheMapper.fetchWorkout(id: id, in: container.mainContext))
|
|
#expect(cached.updatedAt == Self.t0.addingTimeInterval(60))
|
|
#expect(cached.routineName == "Changed Name")
|
|
}
|
|
|
|
/// A queued-but-unwritten delete vetoes resurrection: a watch push racing the
|
|
/// delete — even carrying an `updatedAt` newer than the deleted version — must
|
|
/// not bring the entity back, because the delete's stub simply hasn't landed on
|
|
/// disk yet (the same veto an already-written tombstone would apply).
|
|
@MainActor
|
|
@Test func pendingDeleteVetoesWatchIngestResurrection() async throws {
|
|
let (engine, container) = try makeEngine(backlogURL: tempBacklogURL())
|
|
let id = "01DELETEVETO0000000000001"
|
|
await engine.save(workout: makeWorkoutDoc(id: id, updatedAt: Self.t0))
|
|
let entity = try #require(CacheMapper.fetchWorkout(id: id, in: container.mainContext))
|
|
await engine.delete(workout: entity)
|
|
|
|
await engine.ingestFromWatch(makeWorkoutDoc(id: id, updatedAt: Self.t0.addingTimeInterval(120)))
|
|
|
|
#expect(CacheMapper.fetchWorkout(id: id, in: container.mainContext) == nil)
|
|
}
|
|
|
|
// MARK: - Restore lifecycle
|
|
|
|
/// `beginRestore()` clears the backlog outright — queued writes reference
|
|
/// pre-restore state, and draining them into the just-restored tree would
|
|
/// reintroduce edits the user chose to roll back by restoring a backup.
|
|
@MainActor
|
|
@Test func beginRestoreClearsBacklogAndResetsQueueState() async throws {
|
|
let (engine, _) = try makeEngine(backlogURL: tempBacklogURL())
|
|
await engine.save(workout: makeWorkoutDoc(id: "01RESTORECLEAR00000001", updatedAt: Self.t0))
|
|
#expect(engine.pendingWriteCount == 1)
|
|
|
|
engine.beginRestore()
|
|
#expect(engine.pendingWriteCount == 0)
|
|
#expect(engine.writeQueueState == .idle)
|
|
|
|
engine.endRestore() // balance the begin/end pair, matching every production call site
|
|
}
|
|
|
|
// MARK: - Persistence
|
|
|
|
/// The backlog sidecar survives across process launches: a second engine pointed
|
|
/// at the SAME `backlogURL` picks up the first engine's still-queued write on
|
|
/// `init`, before ever connecting — this is what lets a killed app resume
|
|
/// draining on next launch instead of silently losing the edit.
|
|
@MainActor
|
|
@Test func backlogPersistsAcrossEngineInstancesSharingBacklogURL() async throws {
|
|
let url = tempBacklogURL()
|
|
defer { try? FileManager.default.removeItem(at: url) }
|
|
|
|
let (engineA, _) = try makeEngine(backlogURL: url)
|
|
await engineA.save(workout: makeWorkoutDoc(id: "01PERSISTBACKLOG000001", updatedAt: Self.t0))
|
|
#expect(engineA.pendingWriteCount == 1)
|
|
|
|
let (engineB, _) = try makeEngine(backlogURL: url)
|
|
#expect(engineB.pendingWriteCount == 1)
|
|
}
|
|
}
|