From 98805913d1ca3dfb0b76c822d9e8360e3bea4149 Mon Sep 17 00:00:00 2001 From: Utku Deniz Altiok <93100191+UtkuDenizAltiok@users.noreply.github.com> Date: Thu, 5 Nov 2026 19:26:24 +0300 Subject: [PATCH] fix(apple): the Charge breakdown reports skin temperature once `ChargeBreakdownSection` drew the scorer's driver rows and then, whenever the night carried a skin-temperature deviation, a separate `SkinTempDeviationRow`. `RecoveryScorer.chargeDrivers` already lists a "Skin temperature" driver for every night with a deviation, read from the same `skinTempDevC` every caller passes as `skinTempRel`, so the classic Today Charge sheet, Coupled and Intelligence printed the one reading twice ("+0.3 C vs baseline" and "+0.3 C vs your normal" a few lines apart): the "two readouts of one fact" shape AGENTS.md rules out. Android's sheet draws the driver row only. The separate row is now drawn only when no driver row reports skin temperature, which keeps it for any caller that passes a relative marker without a matching driver. The driver label is pinned against the real scorer's output, so renaming it fails a test instead of bringing the second row back. Verification: ChargeBreakdownSkinTempOnceTests (2 new) plus the existing ChargeBreakdownFormatTests and ChargeBreakdownWiringTests pass on the Strand macOS host; NOOPiOS simulator build; doc_comment_lint clean; i18n_audit --ci passes; parity-governance acceptance passes. Co-Authored-By: Claude Opus 5.5 --- Strand/Screens/ChargeBreakdownFormat.swift | 17 +++++++-- Strand/Screens/IntelligenceView.swift | 2 +- .../ChargeBreakdownSkinTempOnceTests.swift | 36 +++++++++++++++++++ 3 files changed, 52 insertions(+), 3 deletions(-) create mode 100644 StrandTests/ChargeBreakdownSkinTempOnceTests.swift diff --git a/Strand/Screens/ChargeBreakdownFormat.swift b/Strand/Screens/ChargeBreakdownFormat.swift index bfe4ac4d33..eccf71afb6 100644 --- a/Strand/Screens/ChargeBreakdownFormat.swift +++ b/Strand/Screens/ChargeBreakdownFormat.swift @@ -299,6 +299,18 @@ struct ChargeBreakdownSection: View { let confidence: ScoreConfidence var skinTempRel: SkinTempRelative? = nil + /// The label `RecoveryScorer.chargeDrivers` gives its skin-temperature row, pinned against the scorer's + /// own output by `ChargeBreakdownSkinTempOnceTests`. + static let skinTempDriverLabel = "Skin temperature" + + /// Whether the separate relative skin-temperature row adds anything. `RecoveryScorer.chargeDrivers` + /// lists a skin-temperature driver for every night that carries a deviation, read from the same + /// `skinTempDevC` the callers pass as `skinTempRel`, so drawing `SkinTempDeviationRow` as well printed one + /// reading twice ("two readouts of one fact", AGENTS.md). Android's sheet draws the driver row only. + static func showsSeparateSkinTempRow(drivers: [ChargeDriver]) -> Bool { + !drivers.contains { $0.label == skinTempDriverLabel } + } + var body: some View { VStack(alignment: .leading, spacing: NoopMetrics.cardInnerSpacing) { Divider().overlay(StrandPalette.hairline) @@ -314,8 +326,9 @@ struct ChargeBreakdownSection: View { } } // A5 , the night's relative skin-temp marker, tagged REL., shown as a deviation from the - // personal normal rather than a fake clinical absolute. Only when the night carries one. - if let rel = skinTempRel { + // personal normal rather than a fake clinical absolute. Only when the night carries one AND no + // driver row above already reports it. + if let rel = skinTempRel, Self.showsSeparateSkinTempRow(drivers: drivers) { SkinTempDeviationRow(rel: rel) .padding(.top, NoopMetrics.space1) } diff --git a/Strand/Screens/IntelligenceView.swift b/Strand/Screens/IntelligenceView.swift index 7c89dc7a2f..d853546508 100644 --- a/Strand/Screens/IntelligenceView.swift +++ b/Strand/Screens/IntelligenceView.swift @@ -296,7 +296,7 @@ struct IntelligenceView: View { // A1: "What shaped it" , one row per engine-supplied Charge driver. Gated on a non-empty // list so a cold-start / calibrating night (no real contributions to attribute) shows // nothing here rather than a fake breakdown. A5's relative skin-temp marker rides at the - // foot when the night carries one. + // foot only when no driver row already reports the night's skin temperature. if !d.drivers.isEmpty { ChargeBreakdownSection(drivers: d.drivers, confidence: d.confidence, diff --git a/StrandTests/ChargeBreakdownSkinTempOnceTests.swift b/StrandTests/ChargeBreakdownSkinTempOnceTests.swift new file mode 100644 index 0000000000..b16d0d7862 --- /dev/null +++ b/StrandTests/ChargeBreakdownSkinTempOnceTests.swift @@ -0,0 +1,36 @@ +import XCTest +import StrandAnalytics +@testable import Strand + +/// The Charge breakdown must report a night's skin temperature once. The scorer's driver list already +/// carries a skin-temperature row whenever the night has a deviation, so the separate relative row is drawn +/// only when that driver is absent. These run the real `RecoveryScorer.chargeDrivers`, so a renamed driver +/// label fails here instead of silently bringing the second row back. +final class ChargeBreakdownSkinTempOnceTests: XCTestCase { + + private var trustedHrvFixture: BaselineState { + BaselineState(baseline: 55, spread: 12 / 1.253, nValid: 20, nightsSinceUpdate: 0, status: .trusted) + } + + private func driverRows(skinTempDev: Double?) -> [ChargeDriver] { + RecoveryScorer.chargeDrivers(hrv: 58, rhr: 54, resp: nil, + hrvBaseline: trustedHrvFixture, rhrBaseline: nil, + respBaseline: nil, sleepPerf: 0.85, skinTempDev: skinTempDev) + } + + func testANightWithADeviationReportsSkinTemperatureOnlyAsADriverRow() { + for deviation in [0.4, -0.6, 0.0] { + let drivers = driverRows(skinTempDev: deviation) + XCTAssertTrue(drivers.contains { $0.label == ChargeBreakdownSection.skinTempDriverLabel }, + "The scorer must list skin temperature for a deviation of \(deviation).") + XCTAssertFalse(ChargeBreakdownSection.showsSeparateSkinTempRow(drivers: drivers), + "A deviation of \(deviation) would be printed twice.") + } + } + + func testANightWithoutADeviationHasNoSkinTemperatureDriver() { + let drivers = driverRows(skinTempDev: nil) + XCTAssertFalse(drivers.contains { $0.label == ChargeBreakdownSection.skinTempDriverLabel }) + XCTAssertTrue(ChargeBreakdownSection.showsSeparateSkinTempRow(drivers: drivers)) + } +}