Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 15 additions & 2 deletions Strand/System/DebugDataDiagnostics.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)")
Expand Down
85 changes: 85 additions & 0 deletions StrandTests/AlarmReadbackFrameLineTests.swift
Original file line number Diff line number Diff line change
@@ -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)"))
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Original file line number Diff line number Diff line change
@@ -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<String> =
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)" })
}
}
Loading