From ab3782698fa94ca9ce130f238d0deb196a52b973 Mon Sep 17 00:00:00 2001 From: kavemang Date: Tue, 29 Sep 2026 18:13:44 -0500 Subject: [PATCH 1/3] diagnostics: show complete connection readouts (refs #2091) --- .../StrandAnalytics/ConnectionReadout.swift | 18 ++++++++++++ .../ConnectionReadoutTests.swift | 12 ++++++++ Strand/Screens/DevicesView.swift | 9 +++--- Strand/Screens/TestCentreView.swift | 7 +++-- .../com/noop/analytics/ConnectionReadout.kt | 21 ++++++++++++++ .../main/java/com/noop/ble/WhoopBleClient.kt | 16 ++++++++-- .../com/noop/testcentre/TestModeRegistry.kt | 3 +- .../main/java/com/noop/ui/DevicesScreen.kt | 17 +++++++++++ .../com/noop/ui/TestCentreLiveReadouts.kt | 29 ++++++++++++++++++- .../main/java/com/noop/ui/TestCentreScreen.kt | 10 +++++++ .../noop/analytics/ConnectionReadoutTest.kt | 8 +++++ .../com/noop/ui/TestCentreLiveReadoutsTest.kt | 23 ++++++++++++++- 12 files changed, 161 insertions(+), 12 deletions(-) diff --git a/Packages/StrandAnalytics/Sources/StrandAnalytics/ConnectionReadout.swift b/Packages/StrandAnalytics/Sources/StrandAnalytics/ConnectionReadout.swift index 5b229c8909..142956f065 100644 --- a/Packages/StrandAnalytics/Sources/StrandAnalytics/ConnectionReadout.swift +++ b/Packages/StrandAnalytics/Sources/StrandAnalytics/ConnectionReadout.swift @@ -238,6 +238,24 @@ public enum ConnectionReadout { return "no (waiting for the strap clock)" } + /// The current link's clock evidence, including future-dated and stale records. A post-1972 + /// timestamp alone is not proof that the clock is right (#2091). Twin of Kotlin clockStatusLabel. + public static func clockStatusLabel(deviceClockUnix: Int?, strapNewestUnix: Int?, nowUnix: Int) -> String { + guard deviceClockUnix != nil || strapNewestUnix != nil else { return "waiting for clock evidence" } + func verdict(_ unix: Int) -> String { + ConnectionTrace.clockVerdict(aheadSeconds: unix - nowUnix, newestUnix: unix, + futureToleranceSeconds: 120, + behindToleranceSeconds: ConnectionTrace.behindToleranceDefault) + } + let clock = deviceClockUnix.map(verdict) + let record = strapNewestUnix.map(verdict) + if clock?.contains("FUTURE-DATED") == true || record?.contains("FUTURE-DATED") == true { return "future-dated" } + if clock?.contains("RTC-EPOCH") == true { return "RTC reads 1970/71" } + if record?.contains("RTC-EPOCH") == true { return "records dated 1970/71" } + if record?.contains("CLOCK-WARNING") == true { return "record is over 48h old" } + return strapNewestUnix != nil ? "records dated normally" : "clock OK" + } + /// #1818: at or above this charge the "charge it" remedy is already satisfied, so repeating it is /// noise. Twin of the Kotlin constant - the two must move together or the platforms give /// different advice for the same strap. diff --git a/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/ConnectionReadoutTests.swift b/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/ConnectionReadoutTests.swift index 0f72937683..bd0b109bcc 100644 --- a/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/ConnectionReadoutTests.swift +++ b/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/ConnectionReadoutTests.swift @@ -226,6 +226,18 @@ final class ConnectionReadoutTests: XCTestCase { "no signal seen yet must not fabricate a fault") } + func testClockStatusRejectsFutureDatedRecords() { + XCTAssertEqual(ConnectionReadout.clockStatusLabel(deviceClockUnix: nil, + strapNewestUnix: 1_800_003_600, + nowUnix: 1_800_000_000), "future-dated") + XCTAssertEqual(ConnectionReadout.clockStatusLabel(deviceClockUnix: nil, + strapNewestUnix: 40_000_000, + nowUnix: 1_800_000_000), "records dated 1970/71") + XCTAssertEqual(ConnectionReadout.clockStatusLabel(deviceClockUnix: nil, + strapNewestUnix: 1_799_999_990, + nowUnix: 1_800_000_000), "records dated normally") + } + /// #1818: the remedy must track the battery. A charged strap told to "charge to 100%" is the bug /// the field report hit - the user had already done it, twice. func testRtcWarningRemedyTracksBattery() { diff --git a/Strand/Screens/DevicesView.swift b/Strand/Screens/DevicesView.swift index 151ae456d0..6909c70ab3 100644 --- a/Strand/Screens/DevicesView.swift +++ b/Strand/Screens/DevicesView.swift @@ -102,14 +102,15 @@ private struct DevicesContent: View { guard live.connected else { return nil } let deviceClock = ConnectionReadout.clockCorrelatedDevice(logLines: live.log) guard deviceClock != nil || live.strapRange != nil || live.lastFrameAtUnix != nil else { return nil } - let latched = ConnectionReadout.clockLatchedLabel(deviceClockUnix: deviceClock, - strapNewestUnix: live.strapRange?.newestUnix) + let latched = ConnectionReadout.clockStatusLabel(deviceClockUnix: deviceClock, + strapNewestUnix: live.strapRange?.newestUnix, + nowUnix: Int(Date().timeIntervalSince1970)) let frame = ConnectionReadout.lastFrameLabel(lastFrameUnix: live.lastFrameAtUnix, nowUnix: Int(Date().timeIntervalSince1970)) let warning = ConnectionReadout.rtcWarning(deviceClockUnix: deviceClock, strapNewestUnix: live.strapRange?.newestUnix, batteryPct: live.batterySamples.last?.soc) - return (String(localized: "Clock latched: \(latched) · last frame \(frame)"), warning) + return (String(localized: "Clock") + ": \(latched) · last frame \(frame)", warning) } /// #802: same shape and copy as `LiveView.reconnectGuideBanner`. Duplicated rather than hoisted @@ -753,7 +754,7 @@ private struct DeviceCard: View { var liveFirmware: String? = nil /// The active+connected strap's observed banked-history record layout (`hist_version`). var liveHistoryLayout: Int? = nil - /// #987: the active+connected strap's clock-state line ("Clock latched: yes · last frame 12s ago"), + /// #987/#2091: the active+connected strap's clock-state line ("Clock: records dated normally · last frame 12s ago"), /// nil for every other card. Built by the parent off the same pure ConnectionReadout parsers the /// Test Centre Connection panel binds, so the two readouts can never disagree. var liveClockLine: String? = nil diff --git a/Strand/Screens/TestCentreView.swift b/Strand/Screens/TestCentreView.swift index c5adbb48b6..591666584d 100644 --- a/Strand/Screens/TestCentreView.swift +++ b/Strand/Screens/TestCentreView.swift @@ -1088,9 +1088,10 @@ private struct ConnectionReadoutPanel: View { ReadoutRow(label: String(localized: "Rows drained (session)"), value: sessionRows.map(String.init) ?? String(localized: "no offload yet")) ReadoutRow(label: String(localized: "Rows drained (all time)"), value: String(allTimeRows)) - ReadoutRow(label: String(localized: "Clock latched"), - value: ConnectionReadout.clockLatchedLabel(deviceClockUnix: deviceClock, - strapNewestUnix: live.strapRange?.newestUnix)) + ReadoutRow(label: String(localized: "Clock"), + value: ConnectionReadout.clockStatusLabel(deviceClockUnix: deviceClock, + strapNewestUnix: live.strapRange?.newestUnix, + nowUnix: now)) ReadoutRow(label: String(localized: "Last frame"), value: ConnectionReadout.lastFrameLabel(lastFrameUnix: live.lastFrameAtUnix, nowUnix: now)) if let rtcWarning { diff --git a/android/app/src/main/java/com/noop/analytics/ConnectionReadout.kt b/android/app/src/main/java/com/noop/analytics/ConnectionReadout.kt index 0ec98c9d72..d7d4dfd7da 100644 --- a/android/app/src/main/java/com/noop/analytics/ConnectionReadout.kt +++ b/android/app/src/main/java/com/noop/analytics/ConnectionReadout.kt @@ -254,6 +254,27 @@ object ConnectionReadout { return "no (waiting for the strap clock)" } + /** The current link's clock evidence, including future-dated and stale records. A post-1972 + * timestamp alone is not proof that the clock is right (#2091). Twin of Swift clockStatusLabel. */ + fun clockStatusLabel(deviceClockUnix: Long?, strapNewestUnix: Long?, nowUnix: Long): String { + if (deviceClockUnix == null && strapNewestUnix == null) return "waiting for clock evidence" + fun verdict(unix: Long) = ConnectionTrace.clockVerdict( + aheadSeconds = unix - nowUnix, newestUnix = unix, + futureToleranceSeconds = 120L, + behindToleranceSeconds = ConnectionTrace.BEHIND_TOLERANCE_DEFAULT, + ) + val clock = deviceClockUnix?.let(::verdict) + val record = strapNewestUnix?.let(::verdict) + return when { + clock?.contains("FUTURE-DATED") == true || record?.contains("FUTURE-DATED") == true -> "future-dated" + clock?.contains("RTC-EPOCH") == true -> "RTC reads 1970/71" + record?.contains("RTC-EPOCH") == true -> "records dated 1970/71" + record?.contains("CLOCK-WARNING") == true -> "record is over 48h old" + strapNewestUnix != null -> "records dated normally" + else -> "clock OK" + } + } + /** #1818: at or above this charge the "charge it" remedy is already satisfied, so repeating it is * noise. Twin of the Swift constant - the two must move together or the platforms give different * advice for the same strap. */ diff --git a/android/app/src/main/java/com/noop/ble/WhoopBleClient.kt b/android/app/src/main/java/com/noop/ble/WhoopBleClient.kt index b96646be43..b90c73fa0b 100644 --- a/android/app/src/main/java/com/noop/ble/WhoopBleClient.kt +++ b/android/app/src/main/java/com/noop/ble/WhoopBleClient.kt @@ -153,6 +153,8 @@ data class LiveState( * [withRRIntervals]; emptied by [clearedBiometrics]. Twin of macOS LiveState.rrRecent (PR#191). */ val rrRecent: List = emptyList(), val batteryPct: Double? = null, + /** A battery event arrived on this link; the retained percentage alone may be from an older link. */ + val batterySeenThisLink: Boolean = false, /** Strap battery pack VOLTAGE (mV), decoded from the ~8-min BATTERY_LEVEL event (mv@21/@25) and the * GET_EXTENDED_BATTERY_INFO response (#592). Shown on the Devices card as a "x.xx V" readout beside * the percent. null until the first battery event lands. */ @@ -300,6 +302,8 @@ data class LiveState( * 5.0/MG. null before the first reply this session, or when the frame did not decode. Twin of * macOS LiveState.pagesBehindAtConnect. */ val pagesBehindAtConnect: Int? = null, + /** Newest banked record from this link's validated GET_DATA_RANGE reply, for clock diagnostics. */ + val strapNewestUnix: Long? = null, /** #612: TRUE when the WHOOP-4/generic empty-offload streak ([emptySyncTracker]) is currently * SUSTAINED (3+ consecutive completed-but-empty offloads). Not 5/MG-specific and not coupled to HR: * a connected strap that keeps handing over nothing has this true regardless of live-HR status. @@ -2315,7 +2319,7 @@ class WhoopBleClient( * ignored. Mirrors the Swift StandardHRSource→LiveState.setBattery wiring. */ fun publishExternalBattery(pct: Int) { - if (pct in 0..100) _state.update { it.copy(batteryPct = pct.toDouble()) } + if (pct in 0..100) _state.update { it.copy(batteryPct = pct.toDouble(), batterySeenThisLink = true) } } // MARK: Android Bluetooth handles. @@ -3654,6 +3658,10 @@ class WhoopBleClient( @Volatile private var realtimeArmed = false /** Wall-clock of the last inbound notification — drives the keep-alive liveness watchdog. */ @Volatile private var lastDataAtMs = 0L + @Volatile private var lastInboundFrameAtMs = 0L + + /** Read without publishing every inbound frame to all LiveState observers. */ + fun lastInboundFrameUnix(): Long? = lastInboundFrameAtMs.takeIf { it > 0L }?.div(1_000L) /** * #1865: when a LIVE HR sample last arrived — distinct from [lastDataAtMs], which any inbound frame @@ -7924,6 +7932,7 @@ class WhoopBleClient( inboundBytes += bytes.size if (uuid == CMD_NOTIFY_CHAR) cmdChannelFrames++ lastDataAtMs = System.currentTimeMillis() // feeds the keep-alive liveness watchdog + lastInboundFrameAtMs = lastDataAtMs resubscribedSinceData = false // data is flowing again — re-arm the one-shot resubscribe when { uuid == HEART_RATE_CHAR -> parseStandardHr(bytes) // 0x2A37 @@ -8216,6 +8225,7 @@ class WhoopBleClient( } (if (acceptsWindow) dataRangeNewestUnix(frame) else null)?.let { strapNewestTs = it + _state.update { state -> state.copy(strapNewestUnix = it) } // Capture the wall clock of THIS reading so the backfiller correlation pairs // the strap's device time with the wall time of the same instant (see field doc). strapNewestTsWall = System.currentTimeMillis() / 1000L @@ -9114,7 +9124,7 @@ class WhoopBleClient( /** Single funnel for battery readings (port of LiveState.setBattery). */ private fun setBattery(pct: Double) { - _state.update { it.copy(batteryPct = pct) } + _state.update { it.copy(batteryPct = pct, batterySeenThisLink = true) } // Battery test mode: one tagged (t, soc) line per reading, gated zero-cost when off (the gate is a // single SharedPreferences bool read; the formatter below only runs when the mode is on). Rides the // redacting log() sink; the Room battery series is the readout + trace source (#713, Test Centre). @@ -12094,6 +12104,8 @@ class WhoopBleClient( backfilling = false backfillDrain.reset() strapNewestTs = null + _state.update { it.copy(strapNewestUnix = null, batterySeenThisLink = false) } + lastInboundFrameAtMs = 0L strapNewestTsWall = null offloadFramesThisSession = 0 lastOffloadFrameAtMs = 0L // #174: don't carry a stale cooldown reference into the next session diff --git a/android/app/src/main/java/com/noop/testcentre/TestModeRegistry.kt b/android/app/src/main/java/com/noop/testcentre/TestModeRegistry.kt index 5e8c7b7c8f..a55a908d5f 100644 --- a/android/app/src/main/java/com/noop/testcentre/TestModeRegistry.kt +++ b/android/app/src/main/java/com/noop/testcentre/TestModeRegistry.kt @@ -86,7 +86,8 @@ object TestModeRegistry { questionnaire = listOf( Question("otherDevicePaired", "Is another phone or the WHOOP app paired to the strap right now?", Question.Kind.YES_NO), ), - liveReadout = listOf("connectionUptime", "reconnectCount", "lastOffloadResult"), + liveReadout = listOf("connectionUptime", "reconnectCount", "lastOffloadResult", + "sessionRows", "allTimeDrainedRows", "clockStatus", "lastFrame", "clockWarning"), capture = CaptureKind.Toggle, includesScreenshot = false, requires5MG = false, ) diff --git a/android/app/src/main/java/com/noop/ui/DevicesScreen.kt b/android/app/src/main/java/com/noop/ui/DevicesScreen.kt index 1019df3dbb..a3bdbfb7ce 100644 --- a/android/app/src/main/java/com/noop/ui/DevicesScreen.kt +++ b/android/app/src/main/java/com/noop/ui/DevicesScreen.kt @@ -1,6 +1,7 @@ package com.noop.ui import com.noop.R +import com.noop.analytics.ConnectionReadout import androidx.compose.ui.res.stringResource import android.widget.Toast import androidx.compose.foundation.background @@ -289,6 +290,14 @@ fun DevicesScreen( // Historical record layout from the current backfill, distinct from strap firmware. liveHistoryLayout = if (device.status == DeviceStatus.active.name && live.connected) live.historyLayoutVersion else null, + clockStatus = if (device.status == DeviceStatus.active.name && live.connected && + device.brand.equals("WHOOP", ignoreCase = true)) + ConnectionReadout.clockStatusLabel(null, live.strapNewestUnix, + System.currentTimeMillis() / 1_000L) else null, + clockWarning = if (device.status == DeviceStatus.active.name && live.connected && + device.brand.equals("WHOOP", ignoreCase = true)) + ConnectionReadout.rtcWarning(null, live.strapNewestUnix, + live.batteryPct.takeIf { live.batterySeenThisLink }) else null, onMakeActive = { switchTarget = device }, onRename = { renameTarget = device }, onRemove = { removeTarget = device }, @@ -748,6 +757,8 @@ private fun DeviceCard( liveFirmware: String? = null, /** The active+connected strap's observed banked-history record layout (`hist_version`). */ liveHistoryLayout: Int? = null, + clockStatus: String? = null, + clockWarning: String? = null, onMakeActive: () -> Unit, onRename: () -> Unit, onRemove: (() -> Unit)?, @@ -858,6 +869,12 @@ private fun DeviceCard( if (liveBatteryPct != null) { BatteryTube(pct = liveBatteryPct) } + if (clockStatus != null) { + Text("Clock: $clockStatus", style = NoopType.footnote, color = Palette.textSecondary) + } + if (clockWarning != null) { + Text(clockWarning, style = NoopType.footnote, color = Palette.textSecondary) + } // #592: strap pack voltage (mV → volts, 2dp) beside the percent, when the battery event has // reported it. Localized unit resource; purely additive, the percent tube is unchanged. diff --git a/android/app/src/main/java/com/noop/ui/TestCentreLiveReadouts.kt b/android/app/src/main/java/com/noop/ui/TestCentreLiveReadouts.kt index 9303d36c76..367b98669b 100644 --- a/android/app/src/main/java/com/noop/ui/TestCentreLiveReadouts.kt +++ b/android/app/src/main/java/com/noop/ui/TestCentreLiveReadouts.kt @@ -23,7 +23,11 @@ internal data class TestCentreLiveSnapshot( val logLines: List = emptyList(), val nowUnix: Long = System.currentTimeMillis() / 1_000, val connected: Boolean = false, + val strapNewestUnix: Long? = null, + val lastFrameUnix: Long? = null, + val allTimeDrainedRows: Long = 0, val batteryPct: Double? = null, + val currentLinkBatteryPct: Double? = null, val batteryEstimate: BatteryEstimator.Estimate? = null, val hrSamples: List = emptyList(), val gravitySamples: List = emptyList(), @@ -56,7 +60,8 @@ internal object TestCentreLiveRefreshPolicy { internal object TestCentreLiveReadouts { val mappedIds: Set = setOf( "hrDensityNow", "gravityCoverageNow", "lastNightGateFired", - "connectionUptime", "reconnectCount", "lastOffloadResult", + "connectionUptime", "reconnectCount", "lastOffloadResult", "sessionRows", + "allTimeDrainedRows", "clockStatus", "lastFrame", "clockWarning", "lastSessionSummary", "deviceMetricsNow", "lastImportSummary", "stepsToday", "calibrationState", "currentSoc", "estimateDaysLeft", "slopeSource", @@ -96,6 +101,28 @@ internal object TestCentreLiveReadouts { "lastOffloadResult" -> LiveReadoutRow( id, "Last offload result", ConnectionReadout.lastOffloadResult(tail) ?: "no offload yet", ) + "sessionRows" -> LiveReadoutRow( + id, "Rows drained (session)", ConnectionReadout.sessionRows(tail)?.toString() ?: "no offload yet", + ) + "allTimeDrainedRows" -> LiveReadoutRow( + id, "Rows drained (all time)", snapshot.allTimeDrainedRows.toString(), + ) + "clockStatus" -> LiveReadoutRow( + id, "Clock", ConnectionReadout.clockStatusLabel( + ConnectionReadout.clockCorrelatedDevice(snapshot.logLines), + snapshot.strapNewestUnix, snapshot.nowUnix, + ), + ) + "lastFrame" -> LiveReadoutRow( + id, "Last frame", ConnectionReadout.lastFrameLabel(snapshot.lastFrameUnix, snapshot.nowUnix), + ) + "clockWarning" -> LiveReadoutRow( + id, "Clock warning", ConnectionReadout.rtcWarning( + ConnectionReadout.clockCorrelatedDevice(snapshot.logLines), + snapshot.strapNewestUnix, + snapshot.currentLinkBatteryPct.takeIf { snapshot.connected }, + ) ?: "none", + ) "lastSessionSummary" -> LiveReadoutRow( id, "Last session", WorkoutsReadout.lastSessionSummary(tail) ?: "no session yet", ) diff --git a/android/app/src/main/java/com/noop/ui/TestCentreScreen.kt b/android/app/src/main/java/com/noop/ui/TestCentreScreen.kt index 796f3c7af0..9a907ec726 100644 --- a/android/app/src/main/java/com/noop/ui/TestCentreScreen.kt +++ b/android/app/src/main/java/com/noop/ui/TestCentreScreen.kt @@ -654,6 +654,8 @@ private fun TestCentreLiveReadoutPanel( activeStrapId: String, vm: AppViewModel, ) { + val context = LocalContext.current + val testCentre = remember { TestCentre.from(context) } var hrSamples by remember(mode.id) { mutableStateOf(emptyList()) } var gravitySamples by remember(mode.id) { mutableStateOf(emptyList()) } var batteryEstimate by remember(mode.id) { mutableStateOf(null) } @@ -714,7 +716,11 @@ private fun TestCentreLiveReadoutPanel( logLines = logLines, nowUnix = nowUnix, connected = live.connected, + strapNewestUnix = live.strapNewestUnix.takeIf { live.connected }, + lastFrameUnix = vm.ble.lastInboundFrameUnix().takeIf { live.connected }, + allTimeDrainedRows = testCentre.cumulativeDrainedRows(), batteryPct = live.batteryPct, + currentLinkBatteryPct = live.batteryPct.takeIf { live.connected && live.batterySeenThisLink }, batteryEstimate = batteryEstimate, hrSamples = hrSamples, gravitySamples = gravitySamples, @@ -722,11 +728,15 @@ private fun TestCentreLiveReadoutPanel( ) Column(verticalArrangement = Arrangement.spacedBy(4.dp), modifier = Modifier.padding(top = 2.dp)) { rows.forEach { row -> + if (row.id == "clockWarning" && row.value != "none") { + Text(row.value, style = NoopType.footnote, color = Palette.textSecondary) + } else if (row.id != "clockWarning") { Row(Modifier.fillMaxWidth()) { Text(row.label, style = NoopType.footnote, color = Palette.textTertiary) Spacer(Modifier.weight(1f)) Text(row.value, style = NoopType.mono, color = Palette.textSecondary) } + } } } } diff --git a/android/app/src/test/java/com/noop/analytics/ConnectionReadoutTest.kt b/android/app/src/test/java/com/noop/analytics/ConnectionReadoutTest.kt index 605d3f07de..4479aa44e9 100644 --- a/android/app/src/test/java/com/noop/analytics/ConnectionReadoutTest.kt +++ b/android/app/src/test/java/com/noop/analytics/ConnectionReadoutTest.kt @@ -229,6 +229,14 @@ class ConnectionReadoutTest { assertNull(ConnectionReadout.rtcWarning(null, null)) } + @Test fun clockStatusClassifiesBothCurrentLinkSignals() { + val now = 1_800_000_000L + assertEquals("future-dated", ConnectionReadout.clockStatusLabel(null, now + 3_600, now)) + assertEquals("records dated 1970/71", ConnectionReadout.clockStatusLabel(null, 40_000_000L, now)) + assertEquals("RTC reads 1970/71", ConnectionReadout.clockStatusLabel(40_000_000L, now - 10, now)) + assertEquals("records dated normally", ConnectionReadout.clockStatusLabel(null, now - 10, now)) + } + /** #1818: the remedy must track the battery. A charged strap told to "charge to 100%" is the bug * the field report hit - the user had already done it, twice. Twin of the Swift test. */ @Test fun rtcWarningRemedyTracksBattery() { diff --git a/android/app/src/test/java/com/noop/ui/TestCentreLiveReadoutsTest.kt b/android/app/src/test/java/com/noop/ui/TestCentreLiveReadoutsTest.kt index da6b481f2a..7452d2a2b2 100644 --- a/android/app/src/test/java/com/noop/ui/TestCentreLiveReadoutsTest.kt +++ b/android/app/src/test/java/com/noop/ui/TestCentreLiveReadoutsTest.kt @@ -43,7 +43,7 @@ class TestCentreLiveReadoutsTest { @Test fun everyRegistryDeclaredIdHasExactlyOnePresentationMapping() { val declared = TestModeRegistry.all.flatMap { it.liveReadout }.toSet() - assertEquals(16, declared.size) + assertEquals(21, declared.size) assertEquals(declared, TestCentreLiveReadouts.mappedIds) TestModeRegistry.all.forEach { mode -> @@ -58,6 +58,27 @@ class TestCentreLiveReadoutsTest { } } + @Test fun connectionShowsSessionRowsAndFutureDatedClockInsteadOfHealthyLatch() { + val mode = requireNotNull(TestModeRegistry.mode(TestDomain.CONNECTION)) + val rows = TestCentreLiveReadouts.rows( + mode = mode, + active = true, + snapshot = TestCentreLiveSnapshot( + logLines = listOf("[connection] offload progress sessionRows=12"), + nowUnix = 1_800_000_000L, + connected = true, + strapNewestUnix = 1_800_003_600L, + lastFrameUnix = 1_799_999_990L, + allTimeDrainedRows = 402L, + ), + ).associateBy { it.id } + + assertEquals("12", rows.getValue("sessionRows").value) + assertEquals("402", rows.getValue("allTimeDrainedRows").value) + assertEquals("future-dated", rows.getValue("clockStatus").value) + assertEquals("10s ago", rows.getValue("lastFrame").value) + } + @Test fun inactiveRowIsCompactAndDoesNotResolveEvenAnUnknownReadout() { val futureMode = requireNotNull(TestModeRegistry.mode(TestDomain.RECOVERY)) .copy(liveReadout = listOf("futureReadout")) From 93264fc6674256eac61480e80885160a3fb93ded Mon Sep 17 00:00:00 2001 From: kavemang Date: Tue, 29 Sep 2026 18:18:12 -0500 Subject: [PATCH 2/3] i18n: localize connection clock label --- android/app/src/main/java/com/noop/ui/DevicesScreen.kt | 3 ++- android/app/src/main/res/values-de/strings.xml | 1 + android/app/src/main/res/values-es/strings.xml | 1 + android/app/src/main/res/values-fr/strings.xml | 1 + android/app/src/main/res/values-it/strings.xml | 1 + android/app/src/main/res/values-pl/strings.xml | 1 + android/app/src/main/res/values-pt-rPT/strings.xml | 1 + android/app/src/main/res/values-ru/strings.xml | 1 + android/app/src/main/res/values-zh/strings.xml | 1 + android/app/src/main/res/values/strings.xml | 1 + 10 files changed, 11 insertions(+), 1 deletion(-) diff --git a/android/app/src/main/java/com/noop/ui/DevicesScreen.kt b/android/app/src/main/java/com/noop/ui/DevicesScreen.kt index a3bdbfb7ce..fead7b24a5 100644 --- a/android/app/src/main/java/com/noop/ui/DevicesScreen.kt +++ b/android/app/src/main/java/com/noop/ui/DevicesScreen.kt @@ -870,7 +870,8 @@ private fun DeviceCard( BatteryTube(pct = liveBatteryPct) } if (clockStatus != null) { - Text("Clock: $clockStatus", style = NoopType.footnote, color = Palette.textSecondary) + Text(stringResource(R.string.connection_clock_status, clockStatus), + style = NoopType.footnote, color = Palette.textSecondary) } if (clockWarning != null) { Text(clockWarning, style = NoopType.footnote, color = Palette.textSecondary) diff --git a/android/app/src/main/res/values-de/strings.xml b/android/app/src/main/res/values-de/strings.xml index 842cbe7c8f..c5af55a2d7 100644 --- a/android/app/src/main/res/values-de/strings.xml +++ b/android/app/src/main/res/values-de/strings.xml @@ -2864,4 +2864,5 @@ EKG-Aufzeichnung starten EKG-Aufzeichnung stoppen Auf diesem Strap läuft möglicherweise noch eine Aufzeichnung. Das Ergebnis steht im Strap-Log. + Uhr: %1$s diff --git a/android/app/src/main/res/values-es/strings.xml b/android/app/src/main/res/values-es/strings.xml index 60755b629a..7d64cef283 100644 --- a/android/app/src/main/res/values-es/strings.xml +++ b/android/app/src/main/res/values-es/strings.xml @@ -2851,4 +2851,5 @@ Iniciar captura de ECG Detener captura de ECG Puede que siga activa una captura en esta correa. El veredicto se registra en el strap log. + Reloj: %1$s diff --git a/android/app/src/main/res/values-fr/strings.xml b/android/app/src/main/res/values-fr/strings.xml index 6a25f6b4d2..1796460409 100644 --- a/android/app/src/main/res/values-fr/strings.xml +++ b/android/app/src/main/res/values-fr/strings.xml @@ -2850,4 +2850,5 @@ Démarrer la capture ECG Arrêter la capture ECG Une capture est peut-être encore active sur ce bracelet. Le verdict est écrit dans le strap log. + Horloge : %1$s diff --git a/android/app/src/main/res/values-it/strings.xml b/android/app/src/main/res/values-it/strings.xml index 1d9aa4b617..f0e3e8444c 100644 --- a/android/app/src/main/res/values-it/strings.xml +++ b/android/app/src/main/res/values-it/strings.xml @@ -2819,4 +2819,5 @@ Avvia acquisizione ECG Interrompi acquisizione ECG Una acquisizione potrebbe essere ancora attiva su questo strap. Il verdetto va nello strap log. + Orologio: %1$s diff --git a/android/app/src/main/res/values-pl/strings.xml b/android/app/src/main/res/values-pl/strings.xml index 496c3dd829..2b0aee9d7a 100644 --- a/android/app/src/main/res/values-pl/strings.xml +++ b/android/app/src/main/res/values-pl/strings.xml @@ -2867,4 +2867,5 @@ Rozpocznij zapis EKG Zatrzymaj zapis EKG Na tym pasku może nadal trwać zapis. Werdykt trafia do strap log. + Zegar: %1$s diff --git a/android/app/src/main/res/values-pt-rPT/strings.xml b/android/app/src/main/res/values-pt-rPT/strings.xml index 093bf2e7df..47d5929ae0 100644 --- a/android/app/src/main/res/values-pt-rPT/strings.xml +++ b/android/app/src/main/res/values-pt-rPT/strings.xml @@ -2843,4 +2843,5 @@ Iniciar captura de ECG Parar captura de ECG Pode ainda estar a decorrer uma captura nesta pulseira. O veredicto vai para o strap log. + Relógio: %1$s diff --git a/android/app/src/main/res/values-ru/strings.xml b/android/app/src/main/res/values-ru/strings.xml index 3bfb53d968..b6a6170669 100644 --- a/android/app/src/main/res/values-ru/strings.xml +++ b/android/app/src/main/res/values-ru/strings.xml @@ -2746,4 +2746,5 @@ Начать запись ЭКГ Остановить запись ЭКГ На этом ремешке запись может всё ещё идти. Результат попадёт в strap log. + Часы: %1$s diff --git a/android/app/src/main/res/values-zh/strings.xml b/android/app/src/main/res/values-zh/strings.xml index 34f2acd0e3..96b565a733 100644 --- a/android/app/src/main/res/values-zh/strings.xml +++ b/android/app/src/main/res/values-zh/strings.xml @@ -2821,4 +2821,5 @@ 开始心电图采集 停止心电图采集 此腕带上可能仍在采集。结论会写入 strap log。 + 时钟:%1$s diff --git a/android/app/src/main/res/values/strings.xml b/android/app/src/main/res/values/strings.xml index 0499f9d517..1c921f7b11 100644 --- a/android/app/src/main/res/values/strings.xml +++ b/android/app/src/main/res/values/strings.xml @@ -2880,4 +2880,5 @@ Start ECG capture Stop ECG capture A capture may still be running on this strap. The verdict goes to the strap log. + Clock: %1$s From 473089ec93003852f62533ede6834d84a15810ea Mon Sep 17 00:00:00 2001 From: kavemang Date: Tue, 29 Sep 2026 19:44:08 -0500 Subject: [PATCH 3/3] Fix connection readout parity and registry expectations --- .../StrandAnalytics/ConnectionReadout.swift | 40 ++++--------------- .../ConnectionReadoutTests.swift | 33 +++------------ Tools/parity_ledger_baseline.json | 2 +- Tools/parity_twin_map.json | 6 +-- .../com/noop/analytics/ConnectionReadout.kt | 40 ++++++++----------- .../noop/analytics/ConnectionReadoutTest.kt | 20 +--------- .../testcentre/TestModeRegistryParityTest.kt | 3 +- 7 files changed, 38 insertions(+), 106 deletions(-) diff --git a/Packages/StrandAnalytics/Sources/StrandAnalytics/ConnectionReadout.swift b/Packages/StrandAnalytics/Sources/StrandAnalytics/ConnectionReadout.swift index 142956f065..d2c3b079a2 100644 --- a/Packages/StrandAnalytics/Sources/StrandAnalytics/ConnectionReadout.swift +++ b/Packages/StrandAnalytics/Sources/StrandAnalytics/ConnectionReadout.swift @@ -210,45 +210,21 @@ public enum ConnectionReadout { return nil } - /// #987/#261: the "clock latched" readout value. "yes" once EITHER signal lands with a plausible - /// (post-1972) timestamp: a GET_CLOCK correlation (`deviceClockUnix`, the WHOOP4 path), or a - /// GET_DATA_RANGE reply's newest banked record (`strapNewestUnix`, the fallback). "no (RTC reads - /// 1970/71)" when whichever signal landed reads epoch-era; "no (waiting for the strap clock)" before - /// either replies. - /// - /// A WHOOP 5/MG's GET_CLOCK reply rides the puffin notify channel and never reaches the WHOOP4-only - /// correlation path that sets `deviceClockUnix` (see `BLEManager`'s connect-handshake comment) — its - /// records carry absolute timestamps, so it never NEEDS that correlation to decode history. Without - /// this fallback the row read "no (waiting for the strap clock)" forever on every 5/MG, even a fully - /// working one, because the one signal it checked structurally never populates for that family. The - /// data-range reply is an equal-weight proof the strap answered with a working clock, not a downgrade - /// — `rtcWarning` below already trusts it the same way. - public static func clockLatchedLabel(deviceClockUnix: Int?, strapNewestUnix: Int? = nil) -> String { - if let d = deviceClockUnix { - return d < ConnectionTrace.rtcEpochCeilingUnix ? "no (RTC reads 1970/71)" : "yes" - } - // #1823: this branch has NOT read a clock. It is reached when no clock correlation exists - which - // is every 5/MG, whose GET_CLOCK reply rides the puffin notify chars and never touches the WHOOP4 - // correlation path - so the only evidence is how the strap DATED its banked records. Saying "RTC - // reads 1970/71" there claimed a reading we never took, in the one readout a reporter quotes when - // asking why nothing syncs. Report the evidence we actually have. - if let n = strapNewestUnix { - return n < ConnectionTrace.rtcEpochCeilingUnix ? "no (records dated 1970/71)" : "yes" - } - return "no (waiting for the strap clock)" - } - /// The current link's clock evidence, including future-dated and stale records. A post-1972 - /// timestamp alone is not proof that the clock is right (#2091). Twin of Kotlin clockStatusLabel. + /// timestamp alone is not proof that the clock is right (#2091). + /// Kotlin twin: `ConnectionReadout.clockStatusLabel`. public static func clockStatusLabel(deviceClockUnix: Int?, strapNewestUnix: Int?, nowUnix: Int) -> String { guard deviceClockUnix != nil || strapNewestUnix != nil else { return "waiting for clock evidence" } - func verdict(_ unix: Int) -> String { + let clock = deviceClockUnix.map { unix in + ConnectionTrace.clockVerdict(aheadSeconds: unix - nowUnix, newestUnix: unix, + futureToleranceSeconds: 120, + behindToleranceSeconds: ConnectionTrace.behindToleranceDefault) + } + let record = strapNewestUnix.map { unix in ConnectionTrace.clockVerdict(aheadSeconds: unix - nowUnix, newestUnix: unix, futureToleranceSeconds: 120, behindToleranceSeconds: ConnectionTrace.behindToleranceDefault) } - let clock = deviceClockUnix.map(verdict) - let record = strapNewestUnix.map(verdict) if clock?.contains("FUTURE-DATED") == true || record?.contains("FUTURE-DATED") == true { return "future-dated" } if clock?.contains("RTC-EPOCH") == true { return "RTC reads 1970/71" } if record?.contains("RTC-EPOCH") == true { return "records dated 1970/71" } diff --git a/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/ConnectionReadoutTests.swift b/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/ConnectionReadoutTests.swift index bd0b109bcc..e10b104374 100644 --- a/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/ConnectionReadoutTests.swift +++ b/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/ConnectionReadoutTests.swift @@ -191,33 +191,6 @@ final class ConnectionReadoutTests: XCTestCase { XCTAssertNil(ConnectionReadout.clockCorrelatedDevice(logLines: ["connect up"])) } - func testClockLatchedLabel() { - XCTAssertEqual(ConnectionReadout.clockLatchedLabel(deviceClockUnix: 1_782_475_600), "yes") - XCTAssertEqual(ConnectionReadout.clockLatchedLabel(deviceClockUnix: 40_000_000), "no (RTC reads 1970/71)") - XCTAssertEqual(ConnectionReadout.clockLatchedLabel(deviceClockUnix: nil), "no (waiting for the strap clock)") - } - - // #261: a WHOOP 5/MG never populates deviceClockUnix (its GET_CLOCK reply rides the puffin channel, - // never the WHOOP4 correlation path) — the data-range fallback is what keeps the row from reading - // "waiting" forever on a strap that's actually fine. - func testClockLatchedLabelFallsBackToStrapNewestForFiveMG() { - XCTAssertEqual( - ConnectionReadout.clockLatchedLabel(deviceClockUnix: nil, strapNewestUnix: 1_782_475_600), - "yes") - // #1823: no clock was READ on this path - it is the 5/MG fallback, where the only evidence is - // how the strap dated its records. The wording must not claim a clock reading. - XCTAssertEqual( - ConnectionReadout.clockLatchedLabel(deviceClockUnix: nil, strapNewestUnix: 40_000_000), - "no (records dated 1970/71)") - XCTAssertEqual( - ConnectionReadout.clockLatchedLabel(deviceClockUnix: nil, strapNewestUnix: nil), - "no (waiting for the strap clock)") - // deviceClockUnix wins when BOTH signals are present (the WHOOP4 correlation is the more direct one). - XCTAssertEqual( - ConnectionReadout.clockLatchedLabel(deviceClockUnix: 1_782_475_600, strapNewestUnix: 40_000_000), - "yes") - } - func testRtcWarningFiresOnEpochEraClockOrNewest() { XCTAssertNotNil(ConnectionReadout.rtcWarning(deviceClockUnix: 40_000_000, strapNewestUnix: nil)) XCTAssertNotNil(ConnectionReadout.rtcWarning(deviceClockUnix: nil, strapNewestUnix: 30_000_000)) @@ -227,6 +200,12 @@ final class ConnectionReadoutTests: XCTestCase { } func testClockStatusRejectsFutureDatedRecords() { + XCTAssertEqual(ConnectionReadout.clockStatusLabel(deviceClockUnix: nil, + strapNewestUnix: nil, + nowUnix: 1_800_000_000), "waiting for clock evidence") + XCTAssertEqual(ConnectionReadout.clockStatusLabel(deviceClockUnix: 1_799_999_990, + strapNewestUnix: nil, + nowUnix: 1_800_000_000), "clock OK") XCTAssertEqual(ConnectionReadout.clockStatusLabel(deviceClockUnix: nil, strapNewestUnix: 1_800_003_600, nowUnix: 1_800_000_000), "future-dated") diff --git a/Tools/parity_ledger_baseline.json b/Tools/parity_ledger_baseline.json index 361e74c26a..4efeb063fc 100644 --- a/Tools/parity_ledger_baseline.json +++ b/Tools/parity_ledger_baseline.json @@ -19,7 +19,7 @@ {"rule": "test-only-callsite", "scope": "Packages/StrandImport", "reason": "Exact declaration is reached only from tests in the conservative lexical call graph; dynamic, callback and external entry points are intentionally not inferred.", "provenance": "Exact identities emitted by parity_ledger rule test-only-callsite for current upstream scope Packages/StrandImport; no wildcard matching.", "count": 14, "identities_sha256": "1acdc71178e0a555f000c42980345e8c38495f9e3806a866f31b260865d3ac98"}, {"rule": "test-only-callsite", "scope": "Packages/WhoopProtocol", "reason": "Exact declaration is reached only from tests in the conservative lexical call graph; dynamic, callback and external entry points are intentionally not inferred.", "provenance": "Exact identities emitted by parity_ledger rule test-only-callsite for current upstream scope Packages/WhoopProtocol; no wildcard matching.", "count": 30, "identities_sha256": "a9dc115534e8710217db9cd39bcceaa1419741737bfd3b733007dc0215cd5af2"}, {"rule": "test-only-callsite", "scope": "Packages/WhoopStore", "reason": "Exact declaration is reached only from tests in the conservative lexical call graph; dynamic, callback and external entry points are intentionally not inferred.", "provenance": "Exact identities emitted by parity_ledger rule test-only-callsite for current upstream scope Packages/WhoopStore; no wildcard matching.", "count": 37, "identities_sha256": "2a62c1ece1c541b662dd407f0668d9a02782bb4d21f9bb181518272ff12f0e40"}, - {"rule": "test-only-callsite", "scope": "android/analytics", "reason": "Exact declaration is reached only from tests in the conservative lexical call graph; dynamic, callback and external entry points are intentionally not inferred.", "provenance": "Exact identities emitted by parity_ledger rule test-only-callsite for current upstream scope android/analytics; no wildcard matching.", "count": 65, "identities_sha256": "fda58e5bb0fe07a9fa773d8e5f8961d483e8b177f1529b6e9dc3ac26df7c31cf"}, + {"rule": "test-only-callsite", "scope": "android/analytics", "reason": "Exact declaration is reached only from tests in the conservative lexical call graph; dynamic, callback and external entry points are intentionally not inferred.", "provenance": "Exact identities emitted by parity_ledger rule test-only-callsite for current upstream scope android/analytics; no wildcard matching.", "count": 60, "identities_sha256": "56788639820a818ea805a5fa58ead50867b1c269bf5d3974ba3724704c38dde6"}, {"rule": "test-only-callsite", "scope": "android/data", "reason": "Exact declaration is reached only from tests in the conservative lexical call graph; dynamic, callback and external entry points are intentionally not inferred.", "provenance": "Exact identities emitted by parity_ledger rule test-only-callsite for current upstream scope android/data; no wildcard matching.", "count": 13, "identities_sha256": "6abaae19303cadccbe259d7db320850e269433472b28c84a1bfce7d32d54ce5f"}, {"rule": "test-only-callsite", "scope": "android/ingest", "reason": "Exact declaration is reached only from tests in the conservative lexical call graph; dynamic, callback and external entry points are intentionally not inferred.", "provenance": "Exact identities emitted by parity_ledger rule test-only-callsite for current upstream scope android/ingest; no wildcard matching.", "count": 12, "identities_sha256": "d4f45d66087768eab133b918af6658b06ec1d91305e38fb045f6ae1629c94678"}, {"rule": "test-only-callsite", "scope": "android/oura", "reason": "Exact declaration is reached only from tests in the conservative lexical call graph; dynamic, callback and external entry points are intentionally not inferred.", "provenance": "Exact identities emitted by parity_ledger rule test-only-callsite for current upstream scope android/oura; no wildcard matching.", "count": 4, "identities_sha256": "9beaa9034af0a1021b9a9f43739971934f260147e0a9a5c462468548a7fd577d"}, diff --git a/Tools/parity_twin_map.json b/Tools/parity_twin_map.json index 4fe84922f8..429423a084 100644 --- a/Tools/parity_twin_map.json +++ b/Tools/parity_twin_map.json @@ -19,15 +19,15 @@ }, "authority": { "files": {"count": 507, "sha256": "efc6950759de76273adccde8b6457f5e8577bdaf6ed9c6538cde79b0eaf0c612"}, - "functions": {"count": 4517, "sha256": "05dc56778990d1a6313fc8429b783110ef534ebe49693d91ab7da9c736df040c"}, + "functions": {"count": 4517, "sha256": "fdb0cbf1a9fa7f7dd2a84d5956170958771bdb88902adad9559f49a4b29181ae"}, "properties": {"count": 491, "sha256": "d91dc8fbf47fdfeb7be4ee410281adfd522565f9abf109c6cd101022169f9037"}, "constants": {"count": 1986, "sha256": "cb08c3e7a3503cf5a0ec9cd95ff78a68d7215328aa532c98f9046be63d6a8ece"}, "file_pairs": {"count": 78, "sha256": "ec0d88b4e0535c3b1051b5c10205f21174944018669fbe3479a3c21801a6dc38"}, - "function_pairs": {"count": 215, "sha256": "f4ffaafbdf720d27594002cc2337a7b1be5635ec8d02720b7722f01656e8720b"}, + "function_pairs": {"count": 216, "sha256": "c2a3eaddf212d2c58d08c74d2e66f96bbc718c0fdcac4eca31ecb2b5479880a8"}, "property_pairs": {"count": 163, "sha256": "b7d4a29086beec661993370657c0bcdae86f79b221fdc0043206161df5e1117f"}, "constant_pairs": {"count": 694, "sha256": "ae11bdde04d49f9dfe83170f0aa362e6bf18d3fdc381c1bdf5bbc256caa400ab"}, "unpaired_files": {"count": 373, "sha256": "2b0c9bc717b301037f735ea7cc32f7a7cd9b4a7887d46550b9e727ffd66d3639"}, - "unpaired_functions": {"count": 4100, "sha256": "cc10b9887488dddae0c270071d3fde36a0c6c764df318e18a27e569e181051a9"}, + "unpaired_functions": {"count": 4098, "sha256": "318812b2972c8c5f55fa5a96dbdfc3e0302d893c9981484f992ec902981fbaa3"}, "unpaired_properties": {"count": 165, "sha256": "fe609dd25384cda6de1616d36753fed68431b1a1f9a73b6fd664c7ff35089955"}, "unpaired_constants": {"count": 598, "sha256": "59f90c169443ea5b3243b0138e125a6e995d115f64f9c4c4860eabbaababdc6d"} } diff --git a/android/app/src/main/java/com/noop/analytics/ConnectionReadout.kt b/android/app/src/main/java/com/noop/analytics/ConnectionReadout.kt index d7d4dfd7da..b7e0e7ef67 100644 --- a/android/app/src/main/java/com/noop/analytics/ConnectionReadout.kt +++ b/android/app/src/main/java/com/noop/analytics/ConnectionReadout.kt @@ -238,33 +238,25 @@ object ConnectionReadout { return null } - /** #987/#261: the "clock latched" readout value: "yes" once EITHER signal lands with a plausible - * (post-1972) timestamp — a GET_CLOCK correlation (deviceClockUnix, the WHOOP4 path) or a - * GET_DATA_RANGE reply's newest banked record (strapNewestUnix, the fallback a WHOOP 5/MG needs - * since its GET_CLOCK reply never populates deviceClockUnix — see the Swift twin's doc comment for - * why). "no (RTC reads 1970/71)" on an epoch-era signal; "no (waiting for the strap clock)" before - * either replies. Twin of the Swift labeller. */ - fun clockLatchedLabel(deviceClockUnix: Long?, strapNewestUnix: Long? = null): String { - val ceiling = ConnectionTrace.RTC_EPOCH_CEILING_UNIX - if (deviceClockUnix != null) return if (deviceClockUnix < ceiling) "no (RTC reads 1970/71)" else "yes" - // #1823: this branch has NOT read a clock - it is reached when no correlation exists, which is - // every 5/MG. The only evidence is how the strap DATED its records, so say that rather than - // claiming a clock reading we never took. Twin of the Swift wording. - if (strapNewestUnix != null) return if (strapNewestUnix < ceiling) "no (records dated 1970/71)" else "yes" - return "no (waiting for the strap clock)" - } - /** The current link's clock evidence, including future-dated and stale records. A post-1972 - * timestamp alone is not proof that the clock is right (#2091). Twin of Swift clockStatusLabel. */ + * timestamp alone is not proof that the clock is right (#2091). + * Swift twin: `ConnectionReadout.clockStatusLabel`. */ fun clockStatusLabel(deviceClockUnix: Long?, strapNewestUnix: Long?, nowUnix: Long): String { if (deviceClockUnix == null && strapNewestUnix == null) return "waiting for clock evidence" - fun verdict(unix: Long) = ConnectionTrace.clockVerdict( - aheadSeconds = unix - nowUnix, newestUnix = unix, - futureToleranceSeconds = 120L, - behindToleranceSeconds = ConnectionTrace.BEHIND_TOLERANCE_DEFAULT, - ) - val clock = deviceClockUnix?.let(::verdict) - val record = strapNewestUnix?.let(::verdict) + val clock = deviceClockUnix?.let { unix -> + ConnectionTrace.clockVerdict( + aheadSeconds = unix - nowUnix, newestUnix = unix, + futureToleranceSeconds = 120L, + behindToleranceSeconds = ConnectionTrace.BEHIND_TOLERANCE_DEFAULT, + ) + } + val record = strapNewestUnix?.let { unix -> + ConnectionTrace.clockVerdict( + aheadSeconds = unix - nowUnix, newestUnix = unix, + futureToleranceSeconds = 120L, + behindToleranceSeconds = ConnectionTrace.BEHIND_TOLERANCE_DEFAULT, + ) + } return when { clock?.contains("FUTURE-DATED") == true || record?.contains("FUTURE-DATED") == true -> "future-dated" clock?.contains("RTC-EPOCH") == true -> "RTC reads 1970/71" diff --git a/android/app/src/test/java/com/noop/analytics/ConnectionReadoutTest.kt b/android/app/src/test/java/com/noop/analytics/ConnectionReadoutTest.kt index 4479aa44e9..2f11adf0e9 100644 --- a/android/app/src/test/java/com/noop/analytics/ConnectionReadoutTest.kt +++ b/android/app/src/test/java/com/noop/analytics/ConnectionReadoutTest.kt @@ -203,24 +203,6 @@ class ConnectionReadoutTest { assertNull(ConnectionReadout.clockCorrelatedDevice(listOf("connect up"))) } - @Test fun clockLatchedLabel() { - assertEquals("yes", ConnectionReadout.clockLatchedLabel(1_782_475_600L)) - assertEquals("no (RTC reads 1970/71)", ConnectionReadout.clockLatchedLabel(40_000_000L)) - assertEquals("no (waiting for the strap clock)", ConnectionReadout.clockLatchedLabel(null)) - } - - // #261: a WHOOP 5/MG never populates deviceClockUnix (its GET_CLOCK reply rides the puffin channel, - // never the WHOOP4 correlation path) — the data-range fallback is what keeps the row from reading - // "waiting" forever on a strap that's actually fine. - @Test fun clockLatchedLabelFallsBackToStrapNewestForFiveMG() { - assertEquals("yes", ConnectionReadout.clockLatchedLabel(null, 1_782_475_600L)) - // #1823: no clock was READ on this path - the wording must not claim one. - assertEquals("no (records dated 1970/71)", ConnectionReadout.clockLatchedLabel(null, 40_000_000L)) - assertEquals("no (waiting for the strap clock)", ConnectionReadout.clockLatchedLabel(null, null)) - // deviceClockUnix wins when BOTH signals are present (the WHOOP4 correlation is the more direct one). - assertEquals("yes", ConnectionReadout.clockLatchedLabel(1_782_475_600L, 40_000_000L)) - } - @Test fun rtcWarningFiresOnEpochEraClockOrNewest() { assertTrue(ConnectionReadout.rtcWarning(40_000_000L, null) != null) assertTrue(ConnectionReadout.rtcWarning(null, 30_000_000L) != null) @@ -231,6 +213,8 @@ class ConnectionReadoutTest { @Test fun clockStatusClassifiesBothCurrentLinkSignals() { val now = 1_800_000_000L + assertEquals("waiting for clock evidence", ConnectionReadout.clockStatusLabel(null, null, now)) + assertEquals("clock OK", ConnectionReadout.clockStatusLabel(now - 10, null, now)) assertEquals("future-dated", ConnectionReadout.clockStatusLabel(null, now + 3_600, now)) assertEquals("records dated 1970/71", ConnectionReadout.clockStatusLabel(null, 40_000_000L, now)) assertEquals("RTC reads 1970/71", ConnectionReadout.clockStatusLabel(40_000_000L, now - 10, now)) diff --git a/android/app/src/test/java/com/noop/testcentre/TestModeRegistryParityTest.kt b/android/app/src/test/java/com/noop/testcentre/TestModeRegistryParityTest.kt index 2b32f9668c..d4bbde60e2 100644 --- a/android/app/src/test/java/com/noop/testcentre/TestModeRegistryParityTest.kt +++ b/android/app/src/test/java/com/noop/testcentre/TestModeRegistryParityTest.kt @@ -172,7 +172,8 @@ class TestModeRegistryParityTest { } @Test fun phase2LiveReadoutIds() { - assertEquals(listOf("connectionUptime", "reconnectCount", "lastOffloadResult"), + assertEquals(listOf("connectionUptime", "reconnectCount", "lastOffloadResult", + "sessionRows", "allTimeDrainedRows", "clockStatus", "lastFrame", "clockWarning"), TestModeRegistry.mode(TestDomain.CONNECTION)?.liveReadout) assertEquals(listOf("lastSessionSummary"), TestModeRegistry.mode(TestDomain.WORKOUTS)?.liveReadout)