From 0d32abbfdf008bd5674e6d6b3b303cb546a67e23 Mon Sep 17 00:00:00 2001 From: Bernd Helm Date: Wed, 19 Aug 2026 22:25:34 +0200 Subject: [PATCH] fix(connection): make duration rounding deterministic --- .../Sources/StrandAnalytics/ConnectionReadout.swift | 9 +++++---- .../SessionHeldSuffixTests.swift | 8 ++++++++ .../java/com/noop/analytics/ConnectionReadout.kt | 12 ++++++++---- .../java/com/noop/ble/ConnectionDownReasonTest.kt | 12 ++++++++++++ 4 files changed, 33 insertions(+), 8 deletions(-) diff --git a/Packages/StrandAnalytics/Sources/StrandAnalytics/ConnectionReadout.swift b/Packages/StrandAnalytics/Sources/StrandAnalytics/ConnectionReadout.swift index ab13b16bc6..7657cca138 100644 --- a/Packages/StrandAnalytics/Sources/StrandAnalytics/ConnectionReadout.swift +++ b/Packages/StrandAnalytics/Sources/StrandAnalytics/ConnectionReadout.swift @@ -22,12 +22,13 @@ public enum ConnectionTrace { /// before anyone could start on it. /// /// An unknown start yields NO suffix rather than `after 0.0s` — "instant drop" and "we do not know" - /// are different diagnoses. Locale-fixed so a decimal comma cannot follow the phone language into a - /// log people paste into issues. Twin of the Kotlin `ConnectionTrace.sessionHeldSuffix`. + /// are different diagnoses. Integer half-up quantization makes exact 50 ms ties deterministic, and + /// rendering the whole and fractional digits directly keeps locale out of pasted logs. Twin of the + /// Kotlin `ConnectionTrace.sessionHeldSuffix`. public static func sessionHeldSuffix(millis: Int) -> String { guard millis >= 0 else { return "" } - return " after " + String(format: "%.1f", locale: Locale(identifier: "en_US_POSIX"), - Double(millis) / 1000.0) + "s" + let tenths = millis / 100 + (millis % 100 >= 50 ? 1 : 0) + return " after \(tenths / 10).\(tenths % 10)s" } diff --git a/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/SessionHeldSuffixTests.swift b/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/SessionHeldSuffixTests.swift index 5bc4da1f87..1ba72c0281 100644 --- a/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/SessionHeldSuffixTests.swift +++ b/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/SessionHeldSuffixTests.swift @@ -18,6 +18,14 @@ final class SessionHeldSuffixTests: XCTestCase { XCTAssertEqual(ConnectionTrace.sessionHeldSuffix(millis: 432), " after 0.4s") } + /// Exact half-ties round up to the next tenth on both platforms. This is an integer-millisecond + /// trace contract, so its result must not depend on the host runtime's floating-point formatter. + func testHalfTiesRoundUpToTheNextTenth() { + XCTAssertEqual(ConnectionTrace.sessionHeldSuffix(millis: 50), " after 0.1s") + XCTAssertEqual(ConnectionTrace.sessionHeldSuffix(millis: 150), " after 0.2s") + XCTAssertEqual(ConnectionTrace.sessionHeldSuffix(millis: 250), " after 0.3s") + } + /// An unknown start yields NO suffix rather than `after 0.0s` — "dropped instantly" and "we do not /// know when it came up" are different diagnoses and must not render alike. func testAnUnknownStartAddsNothing() { 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 2fdfd976de..c9b413dd2a 100644 --- a/android/app/src/main/java/com/noop/analytics/ConnectionReadout.kt +++ b/android/app/src/main/java/com/noop/analytics/ConnectionReadout.kt @@ -22,11 +22,15 @@ object ConnectionTrace { * before anyone could start on it. * * An unknown start yields NO suffix rather than `after 0.0s` — "instant drop" and "we do not know" - * are different diagnoses. Locale-fixed so a decimal comma cannot follow the phone language into a - * log people paste into issues. Twin of the Swift `ConnectionTrace.sessionHeldSuffix`. + * are different diagnoses. Integer half-up quantization makes exact 50 ms ties deterministic, and + * rendering the whole and fractional digits directly keeps locale out of pasted logs. Twin of the + * Swift `ConnectionTrace.sessionHeldSuffix`. */ - fun sessionHeldSuffix(millis: Long): String = - if (millis < 0L) "" else " after ${"%.1f".format(java.util.Locale.US, millis / 1000.0)}s" + fun sessionHeldSuffix(millis: Long): String { + if (millis < 0L) return "" + val tenths = millis / 100L + if (millis % 100L >= 50L) 1L else 0L + return " after ${tenths / 10L}.${tenths % 10L}s" + } /** diff --git a/android/app/src/test/java/com/noop/ble/ConnectionDownReasonTest.kt b/android/app/src/test/java/com/noop/ble/ConnectionDownReasonTest.kt index b10d540eed..d7a8d87eca 100644 --- a/android/app/src/test/java/com/noop/ble/ConnectionDownReasonTest.kt +++ b/android/app/src/test/java/com/noop/ble/ConnectionDownReasonTest.kt @@ -64,6 +64,13 @@ class ConnectionDownReasonTest { assertEquals(" after 0.4s", ConnectionTrace.sessionHeldSuffix(432)) } + /** Exact half-ties round up on both platforms, independently of the host runtime's formatter. */ + @Test fun sessionLengthHalfTiesRoundUpToTheNextTenth() { + assertEquals(" after 0.1s", ConnectionTrace.sessionHeldSuffix(50)) + assertEquals(" after 0.2s", ConnectionTrace.sessionHeldSuffix(150)) + assertEquals(" after 0.3s", ConnectionTrace.sessionHeldSuffix(250)) + } + /** * Unknown session start yields no suffix rather than a misleading zero — "after 0.0s" would read as * an instant drop, which is a different diagnosis from "we do not know". @@ -71,4 +78,9 @@ class ConnectionDownReasonTest { @Test fun anUnknownSessionStartYieldsNoSuffix() { assertEquals("", ConnectionTrace.sessionHeldSuffix(-1)) } + + /** Zero is a measured instant drop, not the negative unknown-start sentinel. */ + @Test fun anInstantDropIsStillReported() { + assertEquals(" after 0.0s", ConnectionTrace.sessionHeldSuffix(0)) + } }