agent: lumotia — Phase B.10 pin focusTimer expired-rehydrate startTick invariant
Phase B.10 audit of commit5ba761a(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. The5ba761acommit could not add a test at the time it landed because vitest wasn't wired in the workspace — vitest scaffold landed in commit206ac62the 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) <noreply@anthropic.com>
This commit is contained in:
72
src/lib/stores/focusTimer.test.ts
Normal file
72
src/lib/stores/focusTimer.test.ts
Normal file
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user