From 7f4bb238fcaf409a34edef8ad0f162a3fbce6e85 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Sat, 1 Aug 2026 07:38:06 -0700 Subject: [PATCH] test(dictation): pin the churn threshold as a rate, not a lifetime total Both existing churn tests stopped at exactly 250 refreshes in one window, so two mutations survived: deleting the per-window reset (the counter degrades into a session total, and three healthy downloads at 91 refreshes each cry wolf), and === to >= (fires on every refresh past the threshold, flooding the ring). Pins Date.now rather than using fake timers so the window rule is measured, not the machine. --- ...ictation-model-state-stabilisation.test.ts | 61 +++++++++++++++++-- 1 file changed, 57 insertions(+), 4 deletions(-) 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 be99f1bc913..3f0bd908e1e 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 @@ -1,5 +1,5 @@ /** @vitest-environment happy-dom */ -import { beforeEach, describe, expect, it, vi } from 'vitest' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { create, type StateCreator, type StoreApi, type UseBoundStore } from 'zustand' import type { SpeechModelState } from '../../../../shared/speech-types' import type { AppState } from '../types' @@ -11,11 +11,32 @@ const dictationSlice = createDictationSlice as unknown as StateCreator +// Mirrors CHURN_WINDOW_MS in ./dictation; enough to roll the window. +const PAST_CHURN_WINDOW_MS = 5_001 + // Why a fresh store per test: the churn window is per-slice closure state. function newStore(): UseBoundStore> { return create(dictationSlice) } +// Why pin Date.now: the churn rule is "per window", so a test that lets real time +// pass measures the machine, not the rule. +function pinClock(): { advance: (ms: number) => void } { + let now = 1_000_000 + vi.spyOn(Date, 'now').mockImplementation(() => now) + return { + advance: (ms) => { + now += ms + } + } +} + +function churnBreadcrumbs(): { name: string; data?: Record }[] { + return recordBreadcrumb.mock.calls + .map((call) => call[0] as { name: string; data?: Record }) + .filter((entry) => entry.name === SPEECH_MODEL_STATE_CHURN_BREADCRUMB) +} + beforeEach(() => { recordBreadcrumb = vi.fn() Object.assign(window, { @@ -28,6 +49,10 @@ beforeEach(() => { }) }) +afterEach(() => { + vi.restoreAllMocks() +}) + describe('dictation model-state stabilisation', () => { it('keeps the previous array when every field matches', async () => { reply = [{ id: 'whisper-tiny', status: 'downloading', progress: 0.42 }] @@ -105,10 +130,38 @@ describe('dictation model-state stabilisation', () => { await store.getState().refreshModelStates() } - const churn = recordBreadcrumb.mock.calls - .map((call) => call[0] as { name: string; data?: Record }) - .filter((entry) => entry.name === SPEECH_MODEL_STATE_CHURN_BREADCRUMB) + const churn = churnBreadcrumbs() expect(churn).toHaveLength(1) expect(churn[0].data).toMatchObject({ refreshes: 250, noOpRefreshes: 249 }) }) + + // 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. + it('records the churn breadcrumb at most once per window', async () => { + pinClock() + const store = newStore() + reply = [{ id: 'whisper-tiny', status: 'downloading', progress: 0.42 }] + for (let refresh = 0; refresh < 400; refresh += 1) { + await store.getState().refreshModelStates() + } + + expect(churnBreadcrumbs()).toHaveLength(1) + }) + + // The threshold is a rate, not a lifetime total. Without a per-window reset three + // healthy downloads (3 x 91) cross 250 and the breadcrumb cries wolf again. + it('counts per window, so healthy downloads never accumulate into a false alarm', async () => { + const clock = pinClock() + const store = newStore() + reply = [{ id: 'whisper-tiny', status: 'downloading', progress: 0.42 }] + for (let download = 0; download < 4; download += 1) { + for (let refresh = 0; refresh <= 90; refresh += 1) { + await store.getState().refreshModelStates() + } + clock.advance(PAST_CHURN_WINDOW_MS) + } + + expect(churnBreadcrumbs()).toEqual([]) + }) })