From 1c4ac9850417d6ef7498a2862b9c41ec1f8ed271 Mon Sep 17 00:00:00 2001 From: Jake Date: Thu, 14 May 2026 20:25:32 +0100 Subject: [PATCH] =?UTF-8?q?agent:=20lumotia=20=E2=80=94=20Phase=20B.10=20p?= =?UTF-8?q?in=20focusTimer=20expired-rehydrate=20startTick=20invariant?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase B.10 audit of commit 5ba761a (focusTimer rehydrate startTick invariant; Race-10). The commit landed a comment-only change at src/lib/stores/focusTimer.svelte.ts lines 204-208: // startTick() is REQUIRED here: tick() is the only thing that // observes `now >= completionFlashUntil` and calls clear(). Without // it, the completion flash would stay visible until the user // interacts with the app. stopTick() runs via clear() once the // flash window elapses. A future edit could silently drop the startTick() call (the commit acknowledged this with the comment) and the regression would only surface on a real session: the user reopens Lumotia after a closed expired timer, sees the completion flash, and waits for it to clear. It never does. They click somewhere → clear() fires from the click handler. Visible bug, but no automated gate. The 5ba761a commit could not add a test at the time it landed because vitest wasn't wired in the workspace — vitest scaffold landed in commit 206ac62 the next day as Phase A.5. Now that vitest exists (jsdom environment, .svelte.ts rune transformer, fake timers via vi.useFakeTimers — see vite.config.js test block), the invariant is straightforwardly testable. Fix: new src/lib/stores/focusTimer.test.ts. The single test `auto-clears the completion flash after the 3s window via the tick loop`: 1. Seeds localStorage with a timer started 60 s ago that lasted only 30 s — already-expired by 30 s when rehydrate runs. 2. Calls focusTimer.rehydrate(). Asserts the flash is now visible and the timer is reported active. 3. vi.advanceTimersByTime(3_500) — pushes wall-clock past the completionFlashUntil mark, drives the setInterval ticks. 4. Asserts showingCompletionFlash is false, active is false, remainingMs is 0, and localStorage has been wiped. If a future edit removes the startTick() call on the already-expired branch of rehydrate(), step (3) won't run any ticks, the flash won't clear, and step (4) fires the regression assertion. The test pins the invariant the comment alone could not. Verification: * npm run test → 13/13 (was 12, +1 from this commit). Both test files pass: localStorageMigration.test.ts (unchanged, 12 tests) and the new focusTimer.test.ts (1 test). * npm run check → 0 errors / 0 warnings across 4015 files. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/lib/stores/focusTimer.test.ts | 72 +++++++++++++++++++++++++++++++ 1 file changed, 72 insertions(+) create mode 100644 src/lib/stores/focusTimer.test.ts 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(); + }); +});