From 52332f80cd6dc172f5c0a71b9031326af58b4761 Mon Sep 17 00:00:00 2001 From: ryanbr Date: Sun, 12 Jul 2026 18:49:46 +1200 Subject: [PATCH] fix(sleep): displaySmoothed(minDuration<=0) must coalesce like Swift, not return raw fragments (#240 follow-up) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Android port of Swift Hypnogram.displaySmoothed added an early return `if (intervals.size <= 2 || minDurationSec <= 0.0) return intervals`. The `|| minDurationSec <= 0.0` short-circuit isn't in Swift, which guards ONLY on count and lets the "raw" (minDuration 0) case fall through to coalesce() — so Swift returns the coalesced timeline, while Android returned the 60-100 un-merged epoch fragments (the "comb" the smoothing exists to remove). Everything else in the port is faithful. Production is unaffected (the only caller passes STAGE_ROW_SMOOTH_SEC = 90.0), so this is a latent port-fidelity fix, not a shipping bug — but it makes the "straight port" claim true and protects a future 0-caller. Also strengthens `zeroFloorReturnsCoalescedInputUnsmoothed`: its old [light, deep, rem] fixture had no adjacent same-stage runs, so coalesce was a no-op and it passed even with the short-circuit. New fixture has a light/light seam and asserts the coalesced result — verified it FAILS against the old short-circuit and passes with the fix. Android-only (Health Connect / SleepScreen); testFullDebugUnitTest green. --- android/app/src/main/java/com/noop/ui/SleepScreen.kt | 7 ++++++- .../test/java/com/noop/ui/StageDisplaySmoothingTest.kt | 9 +++++++-- 2 files changed, 13 insertions(+), 3 deletions(-) diff --git a/android/app/src/main/java/com/noop/ui/SleepScreen.kt b/android/app/src/main/java/com/noop/ui/SleepScreen.kt index a55cdba7c1..d69853a7c1 100644 --- a/android/app/src/main/java/com/noop/ui/SleepScreen.kt +++ b/android/app/src/main/java/com/noop/ui/SleepScreen.kt @@ -3482,7 +3482,12 @@ internal fun displaySmoothed( intervals: List, minDurationSec: Double, ): List { - if (intervals.size <= 2 || minDurationSec <= 0.0) return intervals // Swift: guard count > 2 + // Match Swift `Hypnogram.displaySmoothed`: guard ONLY on count. A minDurationSec <= 0 ("raw") must + // still fall through to coalesce() — Swift returns the coalesced timeline, not the un-merged epoch + // fragments. Short-circuiting on <= 0 here returned 60-100 raw fragments (the "comb"), diverging from + // the port. With minDurationSec = 0 the absorb loop's `duration < 0` filter is empty, so it breaks + // right after the first coalesce — same result as Swift. + if (intervals.size <= 2) return intervals // Swift: guard count > 2 // Coalesce adjacent same-stage runs (also bridges the zero-length seams between epochs). fun coalesce(ivs: List): MutableList { diff --git a/android/app/src/test/java/com/noop/ui/StageDisplaySmoothingTest.kt b/android/app/src/test/java/com/noop/ui/StageDisplaySmoothingTest.kt index df233f01a0..96cc67b978 100644 --- a/android/app/src/test/java/com/noop/ui/StageDisplaySmoothingTest.kt +++ b/android/app/src/test/java/com/noop/ui/StageDisplaySmoothingTest.kt @@ -103,8 +103,13 @@ class StageDisplaySmoothingTest { @Test fun zeroFloorReturnsCoalescedInputUnsmoothed() { - val raw = listOf(iv("light", 0.0, 10.0), iv("deep", 10.0, 20.0), iv("rem", 20.0, 30.0)) - assertEquals(raw, displaySmoothed(raw, 0.0)) // smoothingSeconds: 0 → raw timeline + // minDurationSec = 0 ("raw") must still COALESCE adjacent same-stage runs (Swift parity) — NOT + // return the un-merged epoch fragments. Fixture has an adjacent light/light seam so it actually + // exercises the coalesce (the previous [light, deep, rem] fixture had none, so it passed even + // when the impl short-circuited to raw). + val raw = listOf(iv("light", 0.0, 10.0), iv("light", 10.0, 20.0), iv("deep", 20.0, 30.0)) + val coalesced = listOf(iv("light", 0.0, 20.0), iv("deep", 20.0, 30.0)) + assertEquals(coalesced, displaySmoothed(raw, 0.0)) } @Test