diff --git a/Strand/Screens/StressView.swift b/Strand/Screens/StressView.swift index d999b96c34..1dff8b2899 100644 --- a/Strand/Screens/StressView.swift +++ b/Strand/Screens/StressView.swift @@ -484,7 +484,7 @@ struct StressView: View { if let ratio = f.lfhf { StatTile( label: "Autonomic balance (LF/HF)", - value: String(format: "%.1f", ratio), + value: StressTrace.formatRatio(ratio), caption: String(localized: "Sympathetic vs parasympathetic tone from frequency-domain HRV. Higher leans sympathetic (stress-ward)."), accent: StressRamp.steady ) diff --git a/StrandTests/StressTraceTests.swift b/StrandTests/StressTraceTests.swift index c3f7b75442..c08db83072 100644 --- a/StrandTests/StressTraceTests.swift +++ b/StrandTests/StressTraceTests.swift @@ -35,6 +35,16 @@ final class StressTraceTests: XCTestCase { XCTAssertEqual(StressTrace.formatLevel(2.25), "2.3") } + func testTheLfHfRatioSharesTheLevelArithmeticWithoutItsCeiling() { + // 9/4 is an exact binary 2.25: Java's %.1f said "2.3" while C's said "2.2" (#2167). + XCTAssertEqual(StressTrace.formatRatio(9.0 / 4.0), "2.3") + XCTAssertEqual(StressTrace.formatRatio(0.25), "0.3") + XCTAssertEqual(StressTrace.formatRatio(1.75), "1.8") + // No stress-domain ceiling: a sympathetic-leaning ratio prints as itself, not as 3.0. + XCTAssertEqual(StressTrace.formatRatio(4.25), "4.3") + XCTAssertEqual(StressTrace.formatRatio(-0.3), "0.0") + } + func testAStressLevelRoundsToOneDecimal() { XCTAssertEqual(StressTrace.formatLevel(1.85), "1.9") XCTAssertEqual(StressTrace.formatLevel(1.96), "2.0") diff --git a/StrandiOSShared/StressTrace.swift b/StrandiOSShared/StressTrace.swift index 85f2319bdc..4f9245e481 100644 --- a/StrandiOSShared/StressTrace.swift +++ b/StrandiOSShared/StressTrace.swift @@ -105,6 +105,18 @@ public enum StressTrace { return "\(tenths / 10).\(tenths % 10)" } + /// Prints the LF/HF ratio to one decimal with the same tenths arithmetic as `formatLevel`. + /// + /// The two platforms disagreed through their printf-family formatters in OPPOSITE directions — + /// Java's `%.1f` rounds half UP where C's rounds half to EVEN — so an exact quotient such as + /// 9/4 spelled "2.3" on Android and "2.2" here. A ratio has no domain ceiling, so only the + /// floor is braced; band powers cannot go negative, but the brace keeps the two surfaces + /// identical rather than trusting that. + public static func formatRatio(_ value: Double) -> String { + let tenths = max(Int((value * 10).rounded(.toNearestOrAwayFromZero)), 0) + return "\(tenths / 10).\(tenths % 10)" + } + /// The one placement rule, shared so a dot and its vertex cannot land apart. private static func place(ts: Int64, level: Double, t0: Int64, span: CGFloat, width: CGFloat, height: CGFloat) -> Pt { diff --git a/android/app/src/main/java/com/noop/ui/StressScreen.kt b/android/app/src/main/java/com/noop/ui/StressScreen.kt index ad3d12961d..56235571b1 100644 --- a/android/app/src/main/java/com/noop/ui/StressScreen.kt +++ b/android/app/src/main/java/com/noop/ui/StressScreen.kt @@ -551,7 +551,7 @@ private fun StressAdvancedCard( StatTile( modifier = m, label = uiString(R.string.l10n_stress_screen_autonomic_balance_lf_hf_776cb6f7), - value = String.format(Locale.US, "%.1f", ratio), + value = StressTrace.formatRatio(ratio), caption = "Sympathetic vs parasympathetic tone from frequency-domain HRV. Higher leans sympathetic (stress-ward).", accent = StressRamp.STEADY, ) diff --git a/android/app/src/main/java/com/noop/widget/StressTrace.kt b/android/app/src/main/java/com/noop/widget/StressTrace.kt index d875e94414..7ffd324345 100644 --- a/android/app/src/main/java/com/noop/widget/StressTrace.kt +++ b/android/app/src/main/java/com/noop/widget/StressTrace.kt @@ -265,6 +265,20 @@ object StressTrace { return "${tenths / 10}.${tenths % 10}" } + /** + * Prints the LF/HF ratio to one decimal with the same tenths arithmetic as [formatLevel]. + * + * The two platforms disagreed through their printf-family formatters in OPPOSITE directions — + * Java's `%.1f` rounds half UP where C's rounds half to EVEN — so an exact quotient such as + * 9/4 spelled "2.3" here and "2.2" on iOS. A ratio has no domain ceiling, so only the floor is + * braced; band powers cannot go negative, but the brace keeps the two surfaces identical rather + * than trusting that. + */ + fun formatRatio(value: Double): String { + val tenths = (value * 10).roundToInt().coerceAtLeast(0) + return "${tenths / 10}.${tenths % 10}" + } + /** * The labels up the left edge, top-down: the top of the domain down to zero. * diff --git a/android/app/src/test/java/com/noop/widget/StressTraceTest.kt b/android/app/src/test/java/com/noop/widget/StressTraceTest.kt index e48eddbf99..4ebb495b98 100644 --- a/android/app/src/test/java/com/noop/widget/StressTraceTest.kt +++ b/android/app/src/test/java/com/noop/widget/StressTraceTest.kt @@ -280,6 +280,24 @@ class StressTraceTest { assertEquals("0.0", StressTrace.formatLevel(0.04)) } + /** The exact binary quarters, matching the Swift pin — each side guards its own direction (#2167). */ + @Test + fun `a level rounds half away from zero like iOS`() { + assertEquals("0.3", StressTrace.formatLevel(0.25)) + assertEquals("1.3", StressTrace.formatLevel(1.25)) + assertEquals("2.3", StressTrace.formatLevel(2.25)) + } + + /** The LF/HF ratio shares the level's tenths arithmetic without its ceiling. */ + @Test + fun `the lf hf ratio shares the level arithmetic without its ceiling`() { + assertEquals("2.3", StressTrace.formatRatio(9.0 / 4.0)) + assertEquals("0.3", StressTrace.formatRatio(0.25)) + assertEquals("1.8", StressTrace.formatRatio(1.75)) + assertEquals("4.3", StressTrace.formatRatio(4.25)) + assertEquals("0.0", StressTrace.formatRatio(-0.3)) + } + // #2166: the snapshot used to round to two decimals, so the widget rounded twice where the card // rounded once and the two printed different tenths on 5% of levels.