Add a watchOS test target

New Workouts Watch AppTests bundle wired into the watch scheme. Extracts
the phone-to-watch cache apply/prune into a pure, session-free
WatchCacheApplier seam and makes the HR-zone bucketing a nonisolated
static, so both can be unit-tested off the main actor without a live
WatchConnectivity session.

Claude-Session: https://claude.ai/code/session_01HJDQQDA9QdP8zByg43H5v3
This commit is contained in:
2026-07-08 07:57:24 -04:00
parent a4ed4df756
commit 1b399ee7ba
6 changed files with 387 additions and 14 deletions
@@ -97,13 +97,16 @@ final class WatchConnectivityBridge: NSObject {
/// Apply a decoded state push. `nil` (decode failure the phone runs a build with
/// a different document schema) is logged and skipped so we neither prune the cache
/// against a bogus empty set nor silently show stale data forever.
/// against a bogus empty set nor silently show stale data forever. The upsert/prune
/// itself is delegated to the pure, session-free `WatchCacheApplier` seam so the
/// apply/prune contract is unit-testable.
private func applyState(_ splits: [SplitDocument]?, workouts: [WorkoutDocument]?) {
guard let splits, let workouts else {
guard WatchCacheApplier.apply(splits: splits, workouts: workouts, into: context) else {
Self.log.error("applyState: payload failed to decode (splits=\(splits == nil ? "failed" : "ok", privacy: .public), workouts=\(workouts == nil ? "failed" : "ok", privacy: .public)) — phone/watch build mismatch?")
return
}
applyState(splits, workouts: workouts)
Self.log.info("applyState: applied \(splits?.count ?? 0) splits, \(workouts?.count ?? 0) workouts")
lastSyncDate = Date()
}
func requestSync() {
@@ -210,8 +213,35 @@ final class WatchConnectivityBridge: NSObject {
}
}
private func applyState(_ splits: [SplitDocument], workouts: [WorkoutDocument]) {
Self.log.info("applyState: \(splits.count) splits, \(workouts.count) workouts")
}
// MARK: - Cache apply/prune seam
/// The pure, session-free core of the phonewatch state apply: it upserts the authoritative
/// splits/workouts into the watch's SwiftData cache and prunes anything the phone no longer
/// sends. Split out of `WatchConnectivityBridge` (which wraps `WCSession`) so the apply/prune
/// contract including the authoritative-empty prune and the nil-decode skip is unit-testable
/// against an in-memory `ModelContext` without a live WatchConnectivity session.
enum WatchCacheApplier {
/// Entry point mirroring the wire decode: `nil` for *either* set means the phone's payload
/// failed to decode (a build/schema mismatch). We skip entirely no upsert, no prune so a
/// bogus empty set can never wipe real rows. Returns `true` when an authoritative push was
/// applied, `false` when skipped, so the caller can log / stamp `lastSyncDate` accordingly.
@MainActor
@discardableResult
static func apply(splits: [SplitDocument]?, workouts: [WorkoutDocument]?, into context: ModelContext) -> Bool {
guard let splits, let workouts else { return false }
apply(splits: splits, workouts: workouts, into: context)
return true
}
/// Upsert every split/workout the phone sent, then prune anything it *didn't*. Both sets are
/// authoritative, so an authoritative empty push clears rows the phone no longer has (a deleted
/// split; a run discarded/deleted on the phone, completed-and-aged-out, or otherwise dropped
/// from the ~24h window). On first launch the cache is empty, so the prune is a harmless no-op.
/// The watch never originates a split/workout, so pruning can't lose local-only data.
@MainActor
static func apply(splits: [SplitDocument], workouts: [WorkoutDocument], into context: ModelContext) {
var liveSplitIDs = Set<String>()
for s in splits {
CacheMapper.upsertSplit(s, relativePath: s.relativePath, into: context)
@@ -222,11 +252,6 @@ final class WatchConnectivityBridge: NSObject {
CacheMapper.upsertWorkout(w, relativePath: w.relativePath, into: context)
liveWorkoutIDs.insert(w.id)
}
// Both are authoritative sets prune anything the phone no longer sends. For
// workouts that set is every active run plus recently-completed ones (~24h), so a
// run that was discarded/deleted on the phone (or aged out of the window) drops out
// of the push and is pruned here which empties the active list and ends the
// session. The watch never originates a workout, so pruning can't lose local data.
if let allSplits = try? context.fetch(FetchDescriptor<Split>()) {
for s in allSplits where !liveSplitIDs.contains(s.id) { context.delete(s) }
}
@@ -234,7 +259,6 @@ final class WatchConnectivityBridge: NSObject {
for w in allWorkouts where !liveWorkoutIDs.contains(w.id) { context.delete(w) }
}
try? context.save()
lastSyncDate = Date()
}
}