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)) + } +}