From 45dc320d65fbc7c23e792eb6ac2075b96ea39f3c Mon Sep 17 00:00:00 2001 From: Fanboynz Date: Mon, 28 Sep 2026 16:09:16 +1300 Subject: [PATCH 1/2] diag: say when an alarm readback has no stored frame The readback frame line was emitted only when the frame existed, so a readback carrying an epoch and no frame printed nothing at all, which reads as "the frame was checked and was fine". The two causes are indistinguishable from outside: #1707 began banking the frame on 2026-08-28, so any readback taken before that has an epoch and no frame, and a write that failed looks identical. A 2045 readback in a 2026-09-28 capture cost a trip through git history to establish that the record simply predated the capture. The line now always appears and names both possibilities, the way the strap-clock verdict above it already says why it abstains rather than printing no line. Since the writer stores at least "empty" for any frame it decodes, this branch is reachable in practice only for records banked before that capture existed, which is the intent. Also aligns the Apple twin's blank handling with Kotlin's isNotBlank. A whitespace-only value printed a frame line carrying no bytes there; it counts as not stored on both platforms now. Four Robolectric tests. Mutation-checked: removing the branch turns exactly the two new-behaviour tests red and leaves both controls green. --- Strand/System/DebugDataDiagnostics.swift | 17 +++- .../com/noop/testcentre/AndroidDiagnostics.kt | 18 +++- .../testcentre/AlarmReadbackFrameLineTest.kt | 90 +++++++++++++++++++ 3 files changed, 120 insertions(+), 5 deletions(-) create mode 100644 android/app/src/test/java/com/noop/testcentre/AlarmReadbackFrameLineTest.kt diff --git a/Strand/System/DebugDataDiagnostics.swift b/Strand/System/DebugDataDiagnostics.swift index 3371507d2e..c453e44905 100644 --- a/Strand/System/DebugDataDiagnostics.swift +++ b/Strand/System/DebugDataDiagnostics.swift @@ -588,8 +588,21 @@ enum DebugDataDiagnostics { if streak >= 2 { rline += " · \(streak) in a row (register likely needs a reset, #34)" } lines.append(rline) // The bytes the epoch was decoded from: what tells a stored stale alarm from a misdecode. - if let raw = d.string(forKey: "alarm.lastReportedRaw"), !raw.isEmpty { - lines.append("Readback frame: \(raw)") + // #1707 started banking the frame on 2026-08-28, so a readback taken before that has an + // epoch and no frame. Printing nothing there read as "the frame was checked and was fine", + // and a 2045 readback in a 2026-09-28 capture cost a trip through git history to explain. + // Say which of the two it is, the way the strap-clock verdict says why it abstains rather + // than printing no line at all. + // Blank counts as not stored, matching Kotlin's isNotBlank: a whitespace-only value is as + // uninformative as an absent one, and printing it emitted a frame line carrying no bytes. + let reportedRaw = d.string(forKey: "alarm.lastReportedRaw")? + .trimmingCharacters(in: .whitespacesAndNewlines) + if let reportedRaw, !reportedRaw.isEmpty { + lines.append("Readback frame: \(reportedRaw)") + } else { + lines.append("Readback frame: not stored (this readback predates the frame capture, or " + + "the write failed), so a genuinely-stored stale alarm cannot be told from a " + + "misdecode of a fixed response field here") } } else { lines.append("Strap reports: (no readback)") diff --git a/android/app/src/main/java/com/noop/testcentre/AndroidDiagnostics.kt b/android/app/src/main/java/com/noop/testcentre/AndroidDiagnostics.kt index 97d34612e5..0f1e2bc7f4 100644 --- a/android/app/src/main/java/com/noop/testcentre/AndroidDiagnostics.kt +++ b/android/app/src/main/java/com/noop/testcentre/AndroidDiagnostics.kt @@ -682,9 +682,21 @@ object AndroidDiagnostics { if (reportedAt > 0L) rl += " · read ${relTime(System.currentTimeMillis() - reportedAt)}" add(rl) // The bytes the epoch was decoded from: what tells a stored stale alarm from a misdecode. - p.getString("alarm.lastReportedRaw", null) - ?.takeIf { it.isNotBlank() } - ?.let { add("Readback frame: $it") } + // #1707 started banking the frame on 2026-08-28, so a readback taken before that has an + // epoch and no frame. Printing nothing there read as "the frame was checked and was + // fine", and a 2045 readback in a 2026-09-28 capture cost a trip through git history to + // explain. Say which of the two it is, the way the strap-clock verdict above says why it + // abstains rather than printing no line at all. + val reportedRaw = p.getString("alarm.lastReportedRaw", null)?.takeIf { it.isNotBlank() } + add( + if (reportedRaw != null) { + "Readback frame: $reportedRaw" + } else { + "Readback frame: not stored (this readback predates the frame capture, or the " + + "write failed), so a genuinely-stored stale alarm cannot be told from a " + + "misdecode of a fixed response field here" + }, + ) } else add("Strap reports: (no readback)") } else add("Last arm: never") // #1: did the strap actually fire? (STRAP_DRIVEN_ALARM_EXECUTED) diff --git a/android/app/src/test/java/com/noop/testcentre/AlarmReadbackFrameLineTest.kt b/android/app/src/test/java/com/noop/testcentre/AlarmReadbackFrameLineTest.kt new file mode 100644 index 0000000000..3371eba281 --- /dev/null +++ b/android/app/src/test/java/com/noop/testcentre/AlarmReadbackFrameLineTest.kt @@ -0,0 +1,90 @@ +package com.noop.testcentre + +import android.content.Context +import com.noop.ui.NoopPrefs +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.RuntimeEnvironment + +/** + * A readback with no stored frame says so, instead of printing no line at all. + * + * `alarm.lastReportedRaw` only started being banked with #1707 on 2026-08-28, so any readback taken + * before that has an epoch and no frame. The export used to emit the frame line only when the frame + * existed, which made the two causes indistinguishable from the outside: a reader could not tell a + * readback that predates the capture from one whose write failed, and an absent line reads as "checked, + * nothing wrong". A 2026-09-28 capture carrying a 2045 readback cost a trip through git history for + * exactly that reason, which is the cost this pins shut. + * + * The epoch is what gates the block, so these cases are reached by writing `lastReportedEpoch` and + * varying only whether the frame is present. Twin of the Swift branch in `DebugDataDiagnostics`. + */ +@RunWith(RobolectricTestRunner::class) +class AlarmReadbackFrameLineTest { + + private fun ctx(): Context = RuntimeEnvironment.getApplication() + + /** An arm plus a readback, with the frame set to [raw] or cleared when null. */ + private fun seedReadback(c: Context, raw: String?) { + val now = System.currentTimeMillis() + NoopPrefs.of(c).edit() + .putLong("alarm.lastArmSentEpoch", now / 1000L + 3600L) + .putLong("alarm.lastArmAt", now) + .putLong("alarm.lastReportedEpoch", now / 1000L + 3600L) + .putLong("alarm.lastReportedAt", now) + .apply() + NoopPrefs.of(c).edit().apply { + if (raw == null) remove("alarm.lastReportedRaw") else putString("alarm.lastReportedRaw", raw) + }.apply() + } + + private fun frameLines(c: Context): List = + AndroidDiagnostics.alarmLines(c).filter { it.startsWith("Readback frame:") } + + @Test + fun `a stored frame is printed verbatim`() { + val c = ctx() + seedReadback(c, "0a1b2c3d") + assertEquals(listOf("Readback frame: 0a1b2c3d"), frameLines(c)) + } + + @Test + fun `a readback with no stored frame still emits a line saying why`() { + val c = ctx() + seedReadback(c, null) + val lines = frameLines(c) + assertEquals("the absent frame must still produce exactly one line", 1, lines.size) + val line = lines.single() + assertTrue("it must name the pre-capture cause: $line", line.contains("predates the frame capture")) + assertTrue("it must name the failed-write cause: $line", line.contains("write failed")) + } + + /** A blank frame is as uninformative as an absent one, so it takes the same branch. */ + @Test + fun `a blank stored frame is treated as not stored`() { + val c = ctx() + seedReadback(c, " ") + val line = frameLines(c).single() + assertTrue("a blank frame must not be printed as if it were bytes: $line", + line.contains("not stored")) + } + + /** With no readback at all the block reports that instead, and emits no frame line. */ + @Test + fun `no readback emits no frame line`() { + val c = ctx() + val now = System.currentTimeMillis() + NoopPrefs.of(c).edit() + .putLong("alarm.lastArmSentEpoch", now / 1000L + 3600L) + .putLong("alarm.lastArmAt", now) + .remove("alarm.lastReportedEpoch") + .remove("alarm.lastReportedRaw") + .apply() + assertTrue(frameLines(c).isEmpty()) + assertTrue("the no-readback case has its own line", + AndroidDiagnostics.alarmLines(c).any { it == "Strap reports: (no readback)" }) + } +} From 49aac9c7f0da003ba538020cff223506ea8253eb Mon Sep 17 00:00:00 2001 From: Fanboynz Date: Mon, 28 Sep 2026 16:17:46 +1300 Subject: [PATCH 2/2] test: the Swift twin for the readback frame line The branch landed on both platforms but only Android got tests, and the Apple half is where the behaviour actually changed twice: the new else branch, and blank handling moving from isEmpty to a trimmed check so a whitespace-only value stops printing a frame line carrying no bytes. `alarmLines` is a static func reading UserDefaults.standard, so it is seedable directly, the way StrapProvidesLineTests and OrphanedSamplesLineTests already cover sibling diagnostic lines. Keys are cleared in both setUp and tearDown so no case can inherit another's readback. The absent-frame assertion pins the Kotlin text verbatim, not merely a substring. These lines exist so an Android report and an Apple one compare directly, and the two strings are byte-identical today, so asserting each side against only itself would let them drift apart silently. --- StrandTests/AlarmReadbackFrameLineTests.swift | 85 +++++++++++++++++++ 1 file changed, 85 insertions(+) create mode 100644 StrandTests/AlarmReadbackFrameLineTests.swift diff --git a/StrandTests/AlarmReadbackFrameLineTests.swift b/StrandTests/AlarmReadbackFrameLineTests.swift new file mode 100644 index 0000000000..df50488b41 --- /dev/null +++ b/StrandTests/AlarmReadbackFrameLineTests.swift @@ -0,0 +1,85 @@ +import XCTest +@testable import Strand + +/// A readback with no stored frame says so, instead of printing no line at all. +/// +/// `alarm.lastReportedRaw` only started being banked with #1707 on 2026-08-28, so any readback taken +/// before that has an epoch and no frame. Emitting the frame line only when the frame existed made the +/// two causes indistinguishable from outside: a reader could not tell a readback that predates the +/// capture from one whose write failed, and an absent line reads as "checked, nothing wrong". A +/// 2026-09-28 capture carrying a 2045 readback cost a trip through git history for that reason. +/// +/// Twin of the Kotlin `AlarmReadbackFrameLineTest`, and the expected strings are the Kotlin ones: these +/// lines exist so an Android and an Apple report compare directly, so asserting each side against only +/// itself would prove nothing about the pair. +final class AlarmReadbackFrameLineTests: XCTestCase { + + private static let keys = [ + "alarm.lastArmSentEpoch", "alarm.lastArmAt", + "alarm.lastReportedEpoch", "alarm.lastReportedAt", "alarm.lastReportedRaw", + ] + + override func setUp() { + super.setUp() + for k in Self.keys { UserDefaults.standard.removeObject(forKey: k) } + } + + override func tearDown() { + for k in Self.keys { UserDefaults.standard.removeObject(forKey: k) } + super.tearDown() + } + + /// An arm plus a readback, with the frame set to `raw` or left absent when nil. + private func seedReadback(raw: String?) { + let d = UserDefaults.standard + let now = Date().timeIntervalSince1970 + d.set(Int(now) + 3600, forKey: "alarm.lastArmSentEpoch") + d.set(now, forKey: "alarm.lastArmAt") + d.set(Int(now) + 3600, forKey: "alarm.lastReportedEpoch") + d.set(now, forKey: "alarm.lastReportedAt") + if let raw { d.set(raw, forKey: "alarm.lastReportedRaw") } + } + + private func frameLines() -> [String] { + DebugDataDiagnostics.alarmLines().filter { $0.hasPrefix("Readback frame:") } + } + + func testAStoredFrameIsPrintedVerbatim() { + seedReadback(raw: "0a1b2c3d") + XCTAssertEqual(frameLines(), ["Readback frame: 0a1b2c3d"]) + } + + func testAReadbackWithNoStoredFrameStillEmitsALineSayingWhy() { + seedReadback(raw: nil) + let lines = frameLines() + XCTAssertEqual(lines.count, 1, "the absent frame must still produce exactly one line") + let line = lines[0] + XCTAssertTrue(line.contains("predates the frame capture"), line) + XCTAssertTrue(line.contains("write failed"), line) + // The Kotlin text, verbatim, so the two reports stay comparable line for line. + XCTAssertEqual(line, "Readback frame: not stored (this readback predates the frame capture, or " + + "the write failed), so a genuinely-stored stale alarm cannot be told from a " + + "misdecode of a fixed response field here") + } + + /// A blank frame is as uninformative as an absent one, so it takes the same branch. + /// + /// This is the half that diverged: Kotlin used `isNotBlank` while this side used `!isEmpty`, so a + /// whitespace-only value printed a frame line carrying no bytes here and counted as absent there. + func testABlankStoredFrameIsTreatedAsNotStored() { + seedReadback(raw: " ") + let line = frameLines()[0] + XCTAssertTrue(line.contains("not stored"), + "a blank frame must not be printed as if it were bytes: \(line)") + } + + /// With no readback at all the block reports that instead, and emits no frame line. + func testNoReadbackEmitsNoFrameLine() { + let d = UserDefaults.standard + let now = Date().timeIntervalSince1970 + d.set(Int(now) + 3600, forKey: "alarm.lastArmSentEpoch") + d.set(now, forKey: "alarm.lastArmAt") + XCTAssertTrue(frameLines().isEmpty) + XCTAssertTrue(DebugDataDiagnostics.alarmLines().contains("Strap reports: (no readback)")) + } +}