diff --git a/src/lib/stores/focusTimer.test.ts b/src/lib/stores/focusTimer.test.ts new file mode 100644 index 0000000..9a99356 --- /dev/null +++ b/src/lib/stores/focusTimer.test.ts @@ -0,0 +1,72 @@ +// Phase B.10 audit regression (2026-05-14). The Race-10 fix in commit +// 5ba761a was documentation-only — it added a load-bearing comment +// saying "startTick() is REQUIRED here" on the already-expired branch +// of `rehydrate()`. Without that startTick call, the tick loop never +// runs after rehydrating an expired timer, so `now >= completionFlashUntil` +// never trips and the completion flash sticks until the user +// interacts. The comment is the only thing standing between a future +// edit and the regression — vitest wasn't wired in the workspace when +// 5ba761a landed (vitest scaffold arrived in commit 206ac62 a day +// later as Phase A.5). Now that vitest is wired, pin the invariant +// with a test so a future drop of the startTick call surfaces as a +// red gate rather than a stuck UI flash. + +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import { focusTimer } from "./focusTimer.svelte"; + +const STORAGE_KEY = "lumotia.focusTimer.v1"; + +beforeEach(() => { + // Drain the module-singleton's in-closure state from any prior test. + focusTimer.cancel(); + localStorage.clear(); + vi.useFakeTimers(); +}); + +afterEach(() => { + vi.useRealTimers(); + focusTimer.cancel(); + localStorage.clear(); +}); + +describe("focusTimer rehydrate of an already-expired timer", () => { + it("auto-clears the completion flash after the 3s window via the tick loop", () => { + // Seed localStorage with a timer that started a minute ago and was + // only 30 s long — it expired 30 s before "now" by wall-clock. + const now = Date.now(); + const startedAt = now - 60_000; + const durationMs = 30_000; + localStorage.setItem( + STORAGE_KEY, + JSON.stringify({ + startedAt, + durationMs, + taskId: null, + label: null, + }), + ); + + focusTimer.rehydrate(); + + // Immediately after rehydrate the completion flash must be visible + // (fireCompletion was called) and the timer must be reported as + // active (startedAt is still set until the flash clears). + expect(focusTimer.showingCompletionFlash).toBe(true); + expect(focusTimer.active).toBe(true); + + // Advance past the 3-second flash window. If startTick() was dropped + // from the already-expired branch of rehydrate(), the tick loop + // would never run and the flash would stay visible forever — + // because tick() is the only path that observes + // `now >= completionFlashUntil` and calls clear(). + vi.advanceTimersByTime(3_500); + + expect(focusTimer.showingCompletionFlash).toBe(false); + expect(focusTimer.active).toBe(false); + expect(focusTimer.remainingMs).toBe(0); + + // localStorage must also be wiped — clear() persists null. + expect(localStorage.getItem(STORAGE_KEY)).toBeNull(); + }); +});