test(engine): guard offline-loop safety and harden purity test
- 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.
This commit is contained in:
@@ -3,7 +3,40 @@ import { join } from 'node:path';
|
|||||||
import { describe, expect, it } from 'vitest';
|
import { describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
const ENGINE_DIR = join(import.meta.dirname, '..');
|
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<string[]> {
|
async function engineSourceFiles(): Promise<string[]> {
|
||||||
const entries = await readdir(ENGINE_DIR, { withFileTypes: true });
|
const entries = await readdir(ENGINE_DIR, { withFileTypes: true });
|
||||||
@@ -18,8 +51,8 @@ describe('engine purity', () => {
|
|||||||
expect(files.length).toBeGreaterThan(0);
|
expect(files.length).toBeGreaterThan(0);
|
||||||
for (const file of files) {
|
for (const file of files) {
|
||||||
const source = await readFile(file, 'utf8');
|
const source = await readFile(file, 'utf8');
|
||||||
for (const pattern of FORBIDDEN) {
|
for (const { pattern, label } of FORBIDDEN) {
|
||||||
expect(source, `${file} must stay free of ${pattern}`).not.toMatch(pattern);
|
expect(source, `${file} must not use ${label}`).not.toMatch(pattern);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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()', () => {
|
describe('applyOfflineProgress()', () => {
|
||||||
it('credits whole ticks of elapsed time to the active action', () => {
|
it('credits whole ticks of elapsed time to the active action', () => {
|
||||||
const content = testContent();
|
const content = testContent();
|
||||||
|
|||||||
+7
-4
@@ -122,10 +122,13 @@ export function NavRail() {
|
|||||||
{item.icon}
|
{item.icon}
|
||||||
<span className="hidden md:inline">{item.label}</span>
|
<span className="hidden md:inline">{item.label}</span>
|
||||||
{item.id === 'story' && storyHasUnread ? (
|
{item.id === 'story' && storyHasUnread ? (
|
||||||
<span
|
<>
|
||||||
className="absolute top-1.5 right-1.5 md:top-1/2 md:right-3 h-2 w-2 md:-translate-y-1/2 animate-pulse rounded-full bg-amber-500 shadow-[0_0_8px_rgba(245,158,11,0.6)]"
|
<span
|
||||||
aria-hidden="true"
|
className="absolute top-1.5 right-1.5 md:top-1/2 md:right-3 h-2 w-2 md:-translate-y-1/2 animate-pulse rounded-full bg-amber-500 shadow-[0_0_8px_rgba(245,158,11,0.6)]"
|
||||||
/>
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
<span className="sr-only">New story</span>
|
||||||
|
</>
|
||||||
) : null}
|
) : null}
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
|
|||||||
Reference in New Issue
Block a user