From f7ad9e7ecd8f3e13734ad4e5ef9ff78002855ac6 Mon Sep 17 00:00:00 2001 From: ginnoir Date: Thu, 11 Jun 2026 22:40:38 -0500 Subject: [PATCH] test(engine): guard offline-loop safety and harden purity test MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - save.test.ts: add regression test proving loop actions do NOT start or yield during offline catch-up (applyOfflineProgress replays tickGame directly; maybeStartLoopAction is never called, so an enabled loop with no active action stays idle and yields 0). - purity.test.ts: extend FORBIDDEN list to catch environment APIs (Date.now(), localStorage., indexedDB., idb-keyval import, document., window., requestAnimationFrame()) in addition to the existing React/react-dom/zustand import guards. Patterns are scoped to call sites and member-access forms so prose comments (e.g. save.ts's "Date scheduling" doc comment) do not false-positive. lz-string is intentionally omitted — it is a pure compression library. - NavRail.tsx: add sr-only "New story" span alongside the aria-hidden pulse dot so screen readers can perceive the unread-story badge. --- src/engine/__tests__/purity.test.ts | 39 ++++++++++++++++++++++++++--- src/engine/__tests__/save.test.ts | 29 +++++++++++++++++++++ src/ui/NavRail.tsx | 11 +++++--- 3 files changed, 72 insertions(+), 7 deletions(-) diff --git a/src/engine/__tests__/purity.test.ts b/src/engine/__tests__/purity.test.ts index 5881642..2d08e93 100644 --- a/src/engine/__tests__/purity.test.ts +++ b/src/engine/__tests__/purity.test.ts @@ -3,7 +3,40 @@ import { join } from 'node:path'; import { describe, expect, it } from 'vitest'; const ENGINE_DIR = join(import.meta.dirname, '..'); -const FORBIDDEN = [/from\s+['"]react/, /from\s+['"]react-dom/, /from\s+['"]zustand/]; + +/** + * Patterns that must NOT appear in engine source files. + * + * React / state-manager imports — match the import statement so plain + * string occurrences in comments are not flagged. + * + * Environment / browser / wall-clock APIs — matched as usage tokens + * (property-access or call-site forms) to avoid false-positives on + * prose comments that name these APIs without using them. In + * particular: + * - `Date\.now\(` catches the call site; a comment saying "Date + * scheduling" does not contain "Date.now(" so it passes. + * - `localStorage\.` / `indexedDB\.` catch member-access, not the + * bare words that appear in save.ts's module-doc comment. + * - `from\s+['"]idb-keyval` catches the package import. + * - `\bdocument\.` / `\bwindow\.` catch DOM member-access. + * - `requestAnimationFrame\(` catches the call site. + * + * lz-string is a pure compression library used by save.ts — it is + * intentionally NOT in this list. + */ +const FORBIDDEN: { pattern: RegExp; label: string }[] = [ + { pattern: /from\s+['"]react['"]/, label: 'react import' }, + { pattern: /from\s+['"]react-dom['"]/, label: 'react-dom import' }, + { pattern: /from\s+['"]zustand['"]/, label: 'zustand import' }, + { pattern: /Date\.now\(/, label: 'Date.now() call (wall-clock)' }, + { pattern: /localStorage\./, label: 'localStorage access (storage API)' }, + { pattern: /indexedDB\./, label: 'indexedDB access (storage API)' }, + { pattern: /from\s+['"]idb-keyval['"]/, label: 'idb-keyval import (storage API)' }, + { pattern: /\bdocument\./, label: 'document access (DOM API)' }, + { pattern: /\bwindow\./, label: 'window access (browser global)' }, + { pattern: /requestAnimationFrame\(/, label: 'requestAnimationFrame call (scheduling API)' }, +]; async function engineSourceFiles(): Promise { const entries = await readdir(ENGINE_DIR, { withFileTypes: true }); @@ -18,8 +51,8 @@ describe('engine purity', () => { expect(files.length).toBeGreaterThan(0); for (const file of files) { const source = await readFile(file, 'utf8'); - for (const pattern of FORBIDDEN) { - expect(source, `${file} must stay free of ${pattern}`).not.toMatch(pattern); + for (const { pattern, label } of FORBIDDEN) { + expect(source, `${file} must not use ${label}`).not.toMatch(pattern); } } }); diff --git a/src/engine/__tests__/save.test.ts b/src/engine/__tests__/save.test.ts index 0685b75..25e9401 100644 --- a/src/engine/__tests__/save.test.ts +++ b/src/engine/__tests__/save.test.ts @@ -135,6 +135,35 @@ describe('invalid / tampered saves', () => { }); }); +describe('applyOfflineProgress() — loop invariant', () => { + it('does NOT start a loop action during offline catch-up even when it is enabled', () => { + // Hardest invariant: maybeStartLoopAction is called by the runtime AFTER each live + // tick, never from tickGame itself. Offline catch-up replays tickGame directly, so + // loop actions must never start (and therefore never yield) during catch-up. + const content = buildContent({ + resources: [{ id: 'wood', name: 'Wood', startAmount: 0 }], + actions: [ + { + id: 'chop', + name: 'Chop Wood', + kind: 'loop', + group: { id: 'test', label: 'Test' }, + durationMs: 1000, + yields: [{ resourceId: 'wood', amount: 1 }], + }, + ], + }); + const state = createGameState(content); + // Enable the loop — player has toggled it on — but do NOT make it active. + state.enabledLoopActionIds.chop = true; + // Simulate coming back online after 10 seconds (10 full loop durations). + applyOfflineProgress(state, content, 0, 10_000); + // The loop must NOT have started or yielded during offline catch-up. + expect(state.activeActionId).toBeNull(); + expect(state.resources.wood).toBe(0); + }); +}); + describe('applyOfflineProgress()', () => { it('credits whole ticks of elapsed time to the active action', () => { const content = testContent(); diff --git a/src/ui/NavRail.tsx b/src/ui/NavRail.tsx index 35289f2..7f316b2 100644 --- a/src/ui/NavRail.tsx +++ b/src/ui/NavRail.tsx @@ -122,10 +122,13 @@ export function NavRail() { {item.icon} {item.label} {item.id === 'story' && storyHasUnread ? ( -