From 069f71bd58862eb8b549d52d8631811c48df34db Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Sat, 1 Aug 2026 07:57:43 -0700 Subject: [PATCH] test(dictation): pin the churn clock and the window's lower bound The two 250-refresh churn tests measured real elapsed time against a 5s window, so a loaded runner that took longer than one window to run the loop would roll the window and go red. Pin Date.now in both. Pinning alone leaves CHURN_WINDOW_MS unpinned below: shrinking it 5_000 -> 50 kept all 13 tests green, yet a 50ms window can never accumulate 250 refreshes and the detector would be dead. Add a storm spread across most of one window so the constant has to be wide enough to hold a sustained storm, not just an instantaneous burst. Co-authored-by: Orca --- ...oice-model-download-progress-storm.test.tsx | 4 ++++ ...dictation-model-state-stabilisation.test.ts | 18 ++++++++++++++++++ 2 files changed, 22 insertions(+) diff --git a/src/renderer/src/components/settings/voice-model-download-progress-storm.test.tsx b/src/renderer/src/components/settings/voice-model-download-progress-storm.test.tsx index 4744c57988d..a482ca54e2b 100644 --- a/src/renderer/src/components/settings/voice-model-download-progress-storm.test.tsx +++ b/src/renderer/src/components/settings/voice-model-download-progress-storm.test.tsx @@ -112,6 +112,7 @@ afterEach(() => { act(() => root?.unmount()) document.body.replaceChildren() vi.unstubAllGlobals() + vi.restoreAllMocks() }) /** @@ -191,6 +192,9 @@ describe('speech model download progress storm (render pressure)', () => { }) it('records the churn breadcrumb when refreshes outrun coalescing', async () => { + // Why pin Date.now: the threshold is per 5s window, so a loaded machine that + // takes longer than one window to run the loop would measure itself, not the rule. + vi.spyOn(Date, 'now').mockReturnValue(1_000_000) // Own store instance: the churn window is per-slice state the other tests advance. const churnStore = create(dictationSlice) for (let refresh = 0; refresh < 250; refresh += 1) { diff --git a/src/renderer/src/store/slices/dictation-model-state-stabilisation.test.ts b/src/renderer/src/store/slices/dictation-model-state-stabilisation.test.ts index 3f0bd908e1e..c0a8d07bd52 100644 --- a/src/renderer/src/store/slices/dictation-model-state-stabilisation.test.ts +++ b/src/renderer/src/store/slices/dictation-model-state-stabilisation.test.ts @@ -124,6 +124,7 @@ describe('dictation model-state stabilisation', () => { }) it('reports churn once the renderer is driven far past whole-percent pacing', async () => { + pinClock() const store = newStore() reply = [{ id: 'whisper-tiny', status: 'downloading', progress: 0.42 }] for (let refresh = 0; refresh < 250; refresh += 1) { @@ -135,6 +136,23 @@ describe('dictation model-state stabilisation', () => { expect(churn[0].data).toMatchObject({ refreshes: 250, noOpRefreshes: 249 }) }) + // Why spread across the window: a burst-only test passes for any window length, + // including one too short for a real storm to ever accumulate the threshold. The + // window has to be wide enough to hold a sustained storm, not just an instant one. + it('still reports a storm that is spread across most of one window', async () => { + const clock = pinClock() + const store = newStore() + reply = [{ id: 'whisper-tiny', status: 'downloading', progress: 0.42 }] + for (let refresh = 0; refresh < 250; refresh += 1) { + await store.getState().refreshModelStates() + clock.advance(19) + } + + const churn = churnBreadcrumbs() + expect(churn).toHaveLength(1) + expect(churn[0].data).toMatchObject({ refreshes: 250, windowMs: 249 * 19 }) + }) + // Stopping at exactly the threshold cannot tell "fires once" from "fires on every // refresh after", and the second floods the 30-entry ring this breadcrumb is // coalesced into to protect.