Skip to content

test(charge): pin the gap-longer-than-the-window display as a decision - #2670

Merged
ryanbr merged 1 commit into
mainfrom
test/charge-window-gap-pin
Oct 3, 2026
Merged

ryanbr merged 1 commit into
mainfrom
test/charge-window-gap-pin

Conversation

@ryanbr

@ryanbr ryanbr commented Oct 3, 2026 •

Copy link
Copy Markdown
Owner

Follow-up to #2666, test only. No production code changes.

#2525 made a Charge baseline read only the nights inside ChargeBaselines.windowDays, so after a gap longer than that the folded history is empty, the calibration count is non-nil (0, which #335 deliberately includes) and calibrating takes precedence over the carry. The Today Charge tile therefore reads "Calibrating" on such a gap, where #779 had it read "Latest sleep".

I think that is the right call: the baseline genuinely is empty after a gap that long, the next score needs four nights either way, and the old count read "not calibrating" while the engine's baseline was already empty. The problem is that it arrived as a side effect rather than a decision. The state mappers were not touched, only their input, so the existing mapper tests kept passing: they drive the count directly with synthetic values, and nothing asserted that a gap this long now yields a non-nil count. The display was free to flip back, in either direction, with nothing going red.

Each test pins BOTH displays from the SAME input, so the contrast is the test rather than a claim about it:

  • Fourteen own nights dated 2026-01-01 to 2026-01-14, which are 41 to 28 days before the 2026-02-11 anchor and so all outside the 21-day window that opens 2026-01-22.
  • Folding them WITHOUT the window is what the display did before Charge baselines stay permanently anchored to imported WHOOP history, however old it is #2525: nValid is 14, over minNightsSeed, the count is nil, and the carry wins as .carriedLastNight(stale: true), which is "Latest sleep".
  • Folding them THROUGH ChargeBaselines.history empties the fold, so ownValidNights is 0, the count is 0, and the mapper resolves to calibrating with four nights remaining.

The night count is load-bearing. An earlier draft of this test used a single night, which calibrates under BOTH rules because one banked night is still under minNightsSeed, so the carry branch was never reachable and the test pinned nothing despite its name. Fourteen is past minNightsTrust, so the old fold would have been trusted and the carry genuinely used to win. The Swift side converts the count with $0.map { max(1, Baselines.minNightsSeed - $0) }, lifted from the TodayView call site, because it is the nil propagating through that map which lets the carry through at all.

They sit directly below the #779 stale-carry test they collide with, and say in the comment that #2525 wins over #779 here, so whoever reads #779's test sees the newer rule beside it. Each names the other as its twin.

  • Kotlin: TodayExplainabilityTest.scoreState_gapLongerThanTheChargeWindow_calibratesRatherThanCarrying
  • Swift: TodayExplainabilityTests.testScoreState_gapLongerThanTheChargeWindow_calibratesRatherThanCarrying

Also worth recording from reviewing #2666: its parity oracle covers 40 spreads, 17 of them seeding, but contains no case where the window yields zero nights. The empty window is exactly the path this display change rides, so the oracle does not cover it on either platform. These tests close that for the display; the baseline side of it is still uncovered by the oracle.

How it was tested

  • Android, clean: testFullDebugUnitTest --no-build-cache --rerun-tasks over TodayExplainabilityTest (53 tests, 0 failures), with the result XML timestamp checked against the clock and the new test's name grepped out of the XML to confirm it ran rather than being filtered out by the --tests pattern.
  • Android, mutation-checked: neutralising ChargeBaselines.windowDays from 21 to 100000 turns the pin red (expected:<0> but was:<14>), so it does detect a reverted window rule. It fails at the ownValidNights assertion rather than at the display assertion, because the fourteen nights return to the fold before the display is resolved; the chain catches it either way. The production file was reverted under a shell trap and the revert verified.
  • Earlier runs also covered ChargeBaselinesTest (20), RecoveryCalibrationTest (9) and RecoveryDriversUiTest (8), all green.
  • Swift: not run locally. StrandTests needs macOS with Xcode, and StrandAnalytics does not build in my WSL environment (CSQLite finds no sqlite3.h), so the build (Strand, macOS) leg here is the first execution of the Swift twin. It was written against the signatures in the source and follows the import pattern in ChargeBreakdownWiringTests.

Refs #2666, #2525, #779, #335

#2525 made a Charge baseline read only nights inside `ChargeBaselines.windowDays`,
so after a gap longer than that the folded history is empty, the calibration count
is non-nil (0, which #335 deliberately includes) and calibrating takes precedence
over the carry. The Today Charge tile therefore reads "Calibrating" on such a gap
where #779 had it read "Latest sleep".

That is a reasonable call, but it arrived as a side effect: the state mappers were
not touched, only their input, so the existing mapper tests kept passing because
they drive the count directly with synthetic values. Nothing asserted that a gap
this long now yields a non-nil count, which left the display free to be flipped
back silently in either direction.

Each test pins BOTH displays from the SAME fourteen nights, so the contrast is the
test rather than a claim about it. Fourteen matters: fewer than `minNightsSeed`
banked nights calibrate under either rule and would pin nothing, so these would
have been trusted by the old whole-history fold and the carry genuinely used to
win. The Swift side converts the count with the same expression TodayView uses, so
the nil that lets the carry through is the production one.

Mutation-checked rather than assumed: neutralising `windowDays` turns the pin red
(expected 0 but was 14). An earlier draft of this test used a single night, which
calibrated under BOTH rules and so pinned nothing; that is why the night count is
spelled out above.

No production code changes.
@ryanbr
ryanbr force-pushed the test/charge-window-gap-pin branch from 74ba4ac to 28a1f57 Compare October 3, 2026 23:22
@ryanbr
ryanbr merged commit 22ce207 into main Oct 3, 2026
6 checks passed
@ryanbr
ryanbr deleted the test/charge-window-gap-pin branch October 3, 2026 23:35
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant