test(home): drive the veil tests' clock explicitly, not advanceUntilIdle
android / Build + lint + test (push) Successful in 3m54s

All seven new UpdateVeilController tests failed in CI run 3163, and the
one test that passed is the tell: it was the only one that never called
advanceUntilIdle().

advanceUntilIdle() advances only while *foreground* work remains. Every
coroutine this controller owns lives in backgroundScope — it has to, because
its consumer loop runs forever and would otherwise stop runTest from
completing — so advanceUntilIdle() returned having run nothing at all, and
the assertions landed on a session that never started. Hence "exhausts its
attempts. Expected <3>, actual <0>" and, where an earlier advanceTimeBy had
got a session partway, "retries until the pull succeeds. Expected <3>,
actual <2>".

Each wait is now an explicit advanceTimeBy sized for what that test still
has pending, and the class KDoc says why so nobody folds them back.

The drains stay deliberately under maxHoldMs. If a drain overshot the
ceiling, "the veil lowered" would stop distinguishing "it settled" from "it
gave up" — which is exactly what these tests exist to tell apart.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-31 20:47:20 -04:00
co-authored by Claude Opus 5
parent 4f99b42844
commit d3b40342b4
@@ -7,7 +7,6 @@ import kotlinx.coroutines.flow.map
import kotlinx.coroutines.launch
import kotlinx.coroutines.test.TestScope
import kotlinx.coroutines.test.advanceTimeBy
import kotlinx.coroutines.test.advanceUntilIdle
import kotlinx.coroutines.test.runCurrent
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.Test
@@ -22,6 +21,13 @@ private const val ART_IN_FLIGHT = 3
private const val SUCCEED_ON_ATTEMPT = 3
private const val QUIET_WINDOWS_TO_OUTLAST = 3
// Time to let a session finish once the screen has stopped changing: the
// retry backoffs, the quiet window and the minimum hold all fit inside it,
// while staying well under maxHoldMs. That gap matters — if a drain ran
// past the ceiling, "the veil lowered" would no longer distinguish
// "it settled" from "it gave up", which is the whole point of these tests.
private const val DRAIN_MS = 3_000L
/**
* The veil's job is to stay up until the screen has stopped moving. Each
* test pins one of the ways the previous fixed-delay implementation
@@ -30,6 +36,14 @@ private const val QUIET_WINDOWS_TO_OUTLAST = 3
* The controller is built on `backgroundScope` throughout: its consumer
* loop runs forever, so hanging it off the test's own scope would stop
* `runTest` from ever completing.
*
* Consequence, and the reason every wait below is an explicit
* `advanceTimeBy`: **`advanceUntilIdle()` is useless here.** It advances
* only while *foreground* work remains, and everything this controller
* does lives in `backgroundScope` — so it returns having run nothing, and
* assertions land on a session that never started (CI run 3163 failed all
* seven of these with "expected 3, actual 0" and friends). Drive the clock
* deliberately instead; don't "simplify" these back to advanceUntilIdle.
*/
@OptIn(ExperimentalCoroutinesApi::class)
class UpdateVeilControllerTest {
@@ -101,7 +115,7 @@ class UpdateVeilControllerTest {
assertTrue(controller.visible.value, "veil must hold across staged churn")
}
advanceUntilIdle()
advanceTimeBy(DRAIN_MS)
assertFalse(controller.visible.value, "veil lowers once the screen goes quiet")
}
@@ -118,7 +132,7 @@ class UpdateVeilControllerTest {
assertTrue(controller.visible.value, "veil must wait on in-flight art")
screen.artLoading(0)
advanceUntilIdle()
advanceTimeBy(DRAIN_MS)
assertFalse(controller.visible.value, "veil lowers once art has landed")
}
@@ -138,7 +152,7 @@ class UpdateVeilControllerTest {
advanceTimeBy(timings.retryBackoffMs + 1)
assertTrue(controller.visible.value, "veil stays up across the backoff")
advanceUntilIdle()
advanceTimeBy(DRAIN_MS)
assertEquals(SUCCEED_ON_ATTEMPT, attempts, "retries until the pull succeeds")
assertEquals(listOf(false, true, false), seen, "raised once, lowered once")
}
@@ -154,7 +168,7 @@ class UpdateVeilControllerTest {
}
controller.request()
advanceUntilIdle()
advanceTimeBy(DRAIN_MS)
assertEquals(timings.attempts, attempts, "exhausts its attempts")
assertFalse(controller.visible.value, "gives up silently")
@@ -162,7 +176,7 @@ class UpdateVeilControllerTest {
// — the reconnect-driven recovery still gets to try again later.
succeed = true
controller.request()
advanceUntilIdle()
advanceTimeBy(DRAIN_MS)
assertEquals(timings.attempts + 1, attempts, "a later request still runs")
}
@@ -186,7 +200,7 @@ class UpdateVeilControllerTest {
advanceTimeBy(WORK_MS + 1)
assertTrue(controller.visible.value, "second trigger extends the same veil")
advanceUntilIdle()
advanceTimeBy(DRAIN_MS)
assertEquals(2, started, "the mid-session trigger still did its pull")
assertEquals(listOf(false, true, false), seen, "one veil session, not two")
}
@@ -215,7 +229,7 @@ class UpdateVeilControllerTest {
}
controller.request()
advanceUntilIdle()
advanceTimeBy(DRAIN_MS)
assertTrue(ran, "the refresh still happens")
assertFalse(controller.visible.value, "but no veil over a skeleton")
}
@@ -236,7 +250,7 @@ class UpdateVeilControllerTest {
runCurrent()
assertTrue(controller.visible.value, "raises the moment cached content paints")
advanceUntilIdle()
advanceTimeBy(SLOW_WORK_MS + DRAIN_MS)
assertFalse(controller.visible.value)
}
}