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:
@@ -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 phone→watch 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()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user