agent: lumotia — Phase B.10 pin focusTimer expired-rehydrate startTick invariant

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) <noreply@anthropic.com>
This commit is contained in:
2026-05-14 20:25:32 +01:00
parent 401b6c3654
commit 1c4ac98504

View 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();
});
});