SOURCE / PINNED RELEASE
Made of little things.
Conway's Game of Life
- Release
- 230955df1900…
- Author-recorded commit
- c08196c2b050…
- License
- LICENSE
- Author’s source reference
- nostr://npub16nzc4ql748r2kv9202rgzrt0fmtk4n3tzsjl86yf6ty9agqe9u0qjcc7hc/wss%3A%2F%2Fgit.napplet.soy%2F/n-644c45e22c7
Archive hash verified: 2627ce678935a213…. The source-to-build association is the author’s claim; it has not been independently rebuilt.
// Browser suite (build plan §Phase 3/§Phase 4/§Phase 6 conventions): Playwright
// on the conformance-bundled Chromium loads the self-contained dist/ over
// file://, drives pointer gestures AND the Phase 4 control bar/keyboard, and
// asserts engine truth through the window.game_of_life QA hook. Gesture pins
// run with the sim PAUSED via qa.setRunning(false) so asserts are race-free
// against the live loop; the last gesture test resumes the loop to prove
// running-simulation paint consumption. Control tests keep the static-screen
// rule: controls + painting must never corrupt engine invariants.
import { test, before, after } from 'node:test';
import assert from 'node:assert/strict';
import { createRequire } from 'node:module';
import { readdirSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import { join } from 'node:path';
const ROOT = fileURLToPath(new URL('../', import.meta.url));
function loadPlaywright() {
try {
return createRequire(join(ROOT, 'noop.cjs'))('playwright');
} catch {
// pnpm layout: playwright lives in the virtual store (conformance dep).
const store = join(ROOT, 'node_modules/.pnpm');
const dir = readdirSync(store).find((d) => /^playwright@[^@]+$/.test(d));
if (!dir) throw new Error('playwright not resolvable (expected conformance dep)');
return createRequire(join(store, dir, 'noop.cjs'))('playwright');
}
}
const { chromium } = loadPlaywright();
let browser;
let page;
/** Any window-level throw in the suite (Phase 7 degradation law) is a failure. */
const pageErrors = [];
before(async () => {
browser = await chromium.launch();
page = await browser.newPage({ viewport: { width: 900, height: 600 } });
page.on('pageerror', (e) => pageErrors.push(String(e)));
await page.goto(new URL('dist/index.html', `file://${ROOT}`).href);
await page.waitForFunction('window.game_of_life && window.game_of_life.engine.aliveCount() > 0');
// Cell -> client-pixel mapping through the CSS box (fit/dpr agnostic).
await page.evaluate(() => {
window.__cellXY = (x, y) => {
const r = document.querySelector('#cells').getBoundingClientRect();
const g = window.game_of_life.engine;
return { x: r.left + (x + 0.5) * (r.width / g.w), y: r.top + (y + 0.5) * (r.height / g.h) };
};
});
});
after(async () => {
await browser?.close();
});
async function cell(x, y) {
return page.evaluate(({ x, y }) => window.game_of_life.engine.get(x, y), { x, y });
}
async function aliveCount() {
return page.evaluate(() => window.game_of_life.engine.aliveCount());
}
/** Dispatch a synthetic touch-pointer gesture step at a logical cell. */
async function touch(pointerId, type, x, y, button = 0) {
await page.evaluate(
({ pointerId, type, x, y, button }) => {
const p = window.__cellXY(x, y);
document.querySelector('#cells').dispatchEvent(
new PointerEvent(type, {
bubbles: true,
cancelable: true,
composed: true,
pointerId,
pointerType: 'touch',
isPrimary: true,
button,
buttons: type === 'pointerup' || type === 'pointercancel' ? 0 : 1,
clientX: p.x,
clientY: p.y,
}),
);
},
{ pointerId, type, x, y, button },
);
}
test('boots behind the welcome gate; Start enters the sim (spec §5 welcome rule)', async () => {
assert.equal(await aliveCount(), 36, 'gun population');
assert.equal(await page.$eval('#welcome', (el) => el.hidden), false, 'primer shown at boot');
assert.equal(await page.evaluate(() => window.game_of_life.welcomeVisible()), true);
assert.equal(await page.evaluate(() => window.game_of_life.isRunning()), false,
'sim waits behind the gate');
// Copy pins (spec §8): primer title, CTA label, basic-controls lines.
assert.equal(await page.$eval('#welcome-start', (el) => el.textContent), 'Start');
const primer = await page.$eval('#welcome', (el) => el.textContent.replace(/\s+/g, ' '));
assert.match(primer, /How to play/, 'primer title');
assert.match(primer, /Left click\/drag to paint cells/);
assert.match(primer, /Right click\/drag to erase/);
assert.match(primer, /Space play \/ pause/);
assert.match(primer, /Patterns: pick one, then tap the board/);
assert.match(primer, /Slider sets speed/);
// Gate law: with body focused the primer keys stay inert until Start.
await page.evaluate(() => document.activeElement?.blur()); // body owns keys
await page.keyboard.press('Space');
await page.keyboard.press('ArrowRight');
await page.keyboard.press('c');
assert.equal(await page.evaluate(() => window.game_of_life.isRunning()), false,
'Space obeys the welcome gate');
const gen0 = await page.evaluate(() => window.game_of_life.engine.generation);
assert.equal(gen0, 0, 'ArrowRight/C never fired behind the gate');
assert.equal(await aliveCount(), 36, 'gate inert: board untouched by C');
await page.waitForTimeout(600);
assert.equal(await page.evaluate(() => window.game_of_life.engine.generation), gen0,
'generation frozen behind the gate');
// Start enters the sim through the exact play/pause setRunning path.
await page.click('#welcome-start');
assert.equal(await page.$eval('#welcome', (el) => el.hidden), true, 'primer dismissed');
assert.equal(await page.evaluate(() => window.game_of_life.welcomeVisible()), false);
assert.equal(await page.evaluate(() => window.game_of_life.isRunning()), true,
'Start runs the sim');
assert.equal(await page.$eval('#play-pause', (el) => el.textContent), '\u23F8',
'play/pause label in step with the gate handoff');
await page.waitForTimeout(400);
const gen1 = await page.evaluate(() => window.game_of_life.engine.generation);
assert.ok(gen1 > gen0, `gen advanced after Start: ${gen0} -> ${gen1}`);
// Pause the live loop: gesture asserts below must be race-free.
await page.evaluate(() => window.game_of_life.setRunning(false));
});
test('touch tap paints one cell (alive)', async () => {
await touch(1, 'pointerdown', 60, 50);
await touch(1, 'pointerup', 60, 50);
assert.equal(await cell(60, 50), 1);
});
test('drag paints every crossed cell, not just endpoints (interpolation)', async () => {
await touch(2, 'pointerdown', 10, 70);
await touch(2, 'pointermove', 25, 70); // ONE move event: Bresenham must fill
await touch(2, 'pointerup', 25, 70);
for (let x = 10; x <= 25; x += 1) {
assert.equal(await cell(x, 70), 1, `row 70 cell ${x} painted`);
}
});
test('right-button drag erases exactly the crossed run (pair assert)', async () => {
const r = await page.evaluate(() => window.__cellXY(20, 70));
await page.mouse.move(r.x, r.y);
await page.mouse.down({ button: 'right' });
const mid = await page.evaluate(() => window.__cellXY(16, 70));
await page.mouse.move(mid.x, mid.y);
const end = await page.evaluate(() => window.__cellXY(12, 70));
await page.mouse.move(end.x, end.y);
await page.mouse.up({ button: 'right' });
for (let x = 12; x <= 20; x += 1) {
assert.equal(await cell(x, 70), 0, `row 70 cell ${x} erased`);
}
// Neighbours outside the gesture survive (erase did not bleed).
assert.equal(await cell(10, 70), 1);
assert.equal(await cell(11, 70), 1);
assert.equal(await cell(21, 70), 1);
assert.equal(await cell(25, 70), 1);
});
test('Android long-press order replays to exactly one paint', async () => {
// Chromium/Android fires contextmenu mid-hold then a synthesized click.
await touch(3, 'pointerdown', 75, 25);
await page.waitForTimeout(450);
await page.evaluate(() => {
document.querySelector('#cells').dispatchEvent(
new MouseEvent('contextmenu', { bubbles: true, cancelable: true, composed: true }),
);
});
await touch(3, 'pointerup', 75, 25);
await page.evaluate(() => {
const p = window.__cellXY(75, 25);
document.querySelector('#cells').dispatchEvent(
new MouseEvent('click', { bubbles: true, composed: true, clientX: p.x, clientY: p.y }),
);
});
assert.equal(await cell(75, 25), 1, 'painted once, click stayed inert');
});
test('two concurrent touch gestures paint independently', async () => {
await touch(4, 'pointerdown', 50, 20);
await touch(5, 'pointerdown', 55, 24);
await touch(4, 'pointerup', 50, 20);
await touch(5, 'pointerup', 55, 24);
assert.equal(await cell(50, 20), 1);
assert.equal(await cell(55, 24), 1);
});
test('painted lonely cell dies on the next generations (live sim consumes paint)', async () => {
const genPaint = await page.evaluate(() => {
const g = window.game_of_life.engine;
g.set(100, 50, 1);
window.game_of_life.setRunning(true); // resume: paint must enter live sim
return g.generation;
});
await page.waitForTimeout(400);
const genLater = await page.evaluate(() => window.game_of_life.engine.generation);
assert.ok(genLater >= genPaint + 2, 'clock kept running through paint');
assert.equal(await cell(100, 50), 0, 'lonely cell died from the live loop');
});
// --- Phase 4: simulation controls (spec §5) ---------------------------------
async function rendered(sel) {
return page.$eval(sel, (el) => el.textContent);
}
async function invariantHolds() {
// Engine's incremental population must equal a fresh recount: the
// controls+painting storm must never corrupt §2.3 bookkeeping.
return page.evaluate(() => {
const g = window.game_of_life.engine;
let s = 0;
for (const v of g.grid) s += v;
return s === g.aliveCount();
});
}
test('control labels render as pinned text (spec §5/§8)', async () => {
assert.equal(await rendered('#step'), '\u23ED'); // step ⏭
assert.equal(await rendered('#clear'), 'Clear');
assert.equal(await rendered('#random'), 'Random');
// The suite resumed the loop in the previous test: running shows pause.
assert.equal(await rendered('#play-pause'), '\u23F8'); // pause ⏸
assert.equal(await rendered('#speed-value'), '10/s');
});
test('play/pause button toggles label, loop stops and resumes', async () => {
await page.click('#play-pause'); // -> paused
assert.equal(await page.evaluate(() => window.game_of_life.isRunning()), false);
assert.equal(await rendered('#play-pause'), '\u23F5'); // play ⏵
const g0 = await page.evaluate(() => window.game_of_life.engine.generation);
await page.waitForTimeout(500);
assert.equal(await page.evaluate(() => window.game_of_life.engine.generation), g0,
'generation frozen while paused');
await page.click('#play-pause'); // -> running
assert.equal(await page.evaluate(() => window.game_of_life.isRunning()), true);
assert.equal(await rendered('#play-pause'), '\u23F8');
await page.waitForTimeout(400);
assert.ok(await page.evaluate((g0) => window.game_of_life.engine.generation > g0, g0),
'clock resumed');
await page.evaluate(() => window.game_of_life.setRunning(false)); // determinism
});
test('step advances exactly one generation while paused', async () => {
const g0 = await page.evaluate(() => window.game_of_life.engine.generation);
await page.click('#step');
assert.equal(await page.evaluate(() => window.game_of_life.engine.generation), g0 + 1);
await page.waitForTimeout(400);
assert.equal(await page.evaluate(() => window.game_of_life.engine.generation), g0 + 1,
'no extra steps leaked from the paused loop');
await page.click('#step');
assert.equal(await page.evaluate(() => window.game_of_life.engine.generation), g0 + 2);
});
test('speed slider extremes drive gen/s and the readout (D3)', async () => {
await page.$eval('#speed', (el) => {
el.value = '30';
el.dispatchEvent(new Event('input', { bubbles: true }));
});
assert.equal(await page.evaluate(() => window.game_of_life.genPerSec()), 30);
assert.equal(await rendered('#speed-value'), '30/s');
await page.$eval('#speed', (el) => {
el.value = '1';
el.dispatchEvent(new Event('input', { bubbles: true }));
});
assert.equal(await page.evaluate(() => window.game_of_life.genPerSec()), 1);
assert.equal(await rendered('#speed-value'), '1/s');
await page.$eval('#speed', (el) => {
el.value = '10';
el.dispatchEvent(new Event('input', { bubbles: true }));
});
assert.equal(await page.evaluate(() => window.game_of_life.genPerSec()), 10);
});
test('keyboard: Space toggles, ArrowRight steps, C clears', async () => {
await page.evaluate(() => document.activeElement?.blur()); // body owns keys
assert.equal(await page.evaluate(() => window.game_of_life.isRunning()), false);
await page.keyboard.press('Space');
assert.equal(await page.evaluate(() => window.game_of_life.isRunning()), true);
assert.equal(await rendered('#play-pause'), '\u23F8');
await page.keyboard.press('Space'); // back to paused for the step pin
assert.equal(await page.evaluate(() => window.game_of_life.isRunning()), false);
const g0 = await page.evaluate(() => window.game_of_life.engine.generation);
await page.keyboard.press('ArrowRight');
assert.equal(await page.evaluate(() => window.game_of_life.engine.generation), g0 + 1);
await page.keyboard.press('c');
assert.equal(await aliveCount(), 0, 'C cleared the board');
assert.equal(await page.evaluate(() => window.game_of_life.engine.generation), 0,
'clear resets the generation counter');
});
test('clear/random + running paint keep engine invariants (static-screen rule)', async () => {
await page.evaluate(() => window.game_of_life.setRunning(true));
await touch(6, 'pointerdown', 40, 5);
await touch(6, 'pointermove', 44, 5); // Bresenham run mid-run
await touch(6, 'pointerup', 44, 5);
for (let x = 40; x <= 44; x += 1) {
assert.equal(await cell(x, 5), 1, `painted while running: cell ${x}`);
}
await page.click('#random');
assert.ok(await aliveCount() > 0, 'random replaced the cleared/painted board');
assert.ok(await invariantHolds(), 'population bookkeeping intact after random');
await page.click('#step'); // legal mid-run
assert.ok(await invariantHolds(), 'bookkeeping intact after mid-run step');
await page.click('#clear');
assert.equal(await aliveCount(), 0);
assert.ok(await invariantHolds());
await page.click('#random'); // leave a live board for later checks/handoff
assert.ok(await aliveCount() > 0);
});
// --- Phase 5: pattern library menu (spec §6) ---------------------------------
test('Patterns button opens the menu without touching simulation phase', async () => {
await page.evaluate(() => {
window.game_of_life.setRunning(true);
window.game_of_life.engine.clear();
});
const gen0 = await page.evaluate(() => window.game_of_life.engine.generation);
assert.equal(await rendered('#patterns'), 'Patterns');
await page.click('#patterns');
assert.equal(await page.$eval('#pattern-menu', (el) => el.hidden), false, 'menu shown');
assert.equal(await page.$eval('#patterns', (el) => el.getAttribute('aria-expanded')), 'true');
// Static chrome rule: running stays running while the menu is open.
assert.equal(await page.evaluate(() => window.game_of_life.isRunning()), true);
await page.waitForTimeout(300);
const genOpen = await page.evaluate(() => window.game_of_life.engine.generation);
assert.ok(genOpen > gen0, 'clock kept advancing with menu open');
assert.equal(await aliveCount(), 0, 'open alone painted nothing');
await page.click('#menu-close'); // Back re-hides
assert.equal(await page.$eval('#pattern-menu', (el) => el.hidden), true, 'menu hidden again');
assert.equal(await page.$eval('#patterns', (el) => el.getAttribute('aria-expanded')), 'false');
assert.equal(await page.evaluate(() => window.game_of_life.isRunning()), true, 'close did not pause');
assert.equal(await page.evaluate(() => window.game_of_life.engine.generation) >= genOpen, true,
'generation never rewound by menu open/close');
await page.evaluate(() => window.game_of_life.setRunning(false)); // determinism
});
test('menu item arms placement; board tap stamps centered on the tap point', async () => {
await page.evaluate(() => window.game_of_life.engine.clear());
// Placement rework ruling 2026-10-30 (player): select FIRST, then place.
// A menu item ARMS the pattern and must stamp NOTHING itself.
await page.click('#patterns');
await page.click('#pattern-list button[data-pattern="pulsar"]');
assert.equal(await page.$eval('#pattern-menu', (el) => el.hidden), true, 'menu auto-closes');
assert.equal(await aliveCount(), 0, 'select alone painted nothing');
assert.equal(await page.evaluate(() => window.game_of_life.pendingPattern()), 'pulsar', 'pattern armed');
assert.equal(await page.evaluate(() => window.game_of_life.placeHintVisible()), true, 'armed hint shows');
const hint = await rendered('#place-status');
assert.ok(hint.includes('Pulsar') && hint.includes('tap the board'), `armed hint: ${hint}`);
// Hover ghost tracking must never write the engine.
await touch(7, 'pointermove', 60, 50);
assert.equal(await aliveCount(), 0, 'ghost hover painted nothing');
// The placing tap: pulsar bbox (13x13) centered on tapped cell (60,50)
// -> top-left (54,44); first row alive at x=56..58, dead at x=55.
await touch(7, 'pointerdown', 60, 50);
await touch(7, 'pointerup', 60, 50);
assert.equal(await aliveCount(), 48, 'pulsar stamped centered on the placing tap');
// Gesture-consumption proof: tapped center (60,50) is DEAD inside the
// pulsar body, so a single-cell paint riding along would show 49.
assert.equal(await cell(60, 50), 0, 'no stray paint cell at the tap center');
assert.equal(await cell(56, 44), 1);
assert.equal(await cell(57, 44), 1);
assert.equal(await cell(58, 44), 1);
assert.equal(await cell(55, 44), 0);
assert.equal(await page.evaluate(() => window.game_of_life.pendingPattern()), null, 'disarmed after placing');
assert.equal(await page.evaluate(() => window.game_of_life.placeHintVisible()), false, 'hint cleared');
});
test('armed placement cancels on Escape and right-click; paint law resumes', async () => {
await page.evaluate(() => {
window.game_of_life.engine.clear();
window.game_of_life.setRunning(false);
});
await page.click('#patterns');
await page.click('#pattern-list button[data-pattern="glider"]');
assert.equal(await page.evaluate(() => window.game_of_life.pendingPattern()), 'glider');
await page.keyboard.press('Escape');
assert.equal(await page.evaluate(() => window.game_of_life.pendingPattern()), null, 'Escape cancels arming');
assert.equal(await page.evaluate(() => window.game_of_life.placeHintVisible()), false, 'hint hidden');
assert.equal(await aliveCount(), 0, 'cancel stamped nothing');
// Re-arm through QA (same code path as the menu) and cancel with right-click:
// cancel must neither stamp nor erase mid-arming.
await page.evaluate(() => window.game_of_life.setPendingPattern('glider'));
assert.equal(await page.evaluate(() => window.game_of_life.placeHintVisible()), true, 'QA arm shows the hint');
await touch(8, 'pointerdown', 40, 40, 2);
await touch(8, 'pointerup', 40, 40, 2);
assert.equal(await page.evaluate(() => window.game_of_life.pendingPattern()), null, 'right-click cancels');
assert.equal(await aliveCount(), 0, 'right-click cancel painted/erased nothing');
// Disarmed: the ordinary paint law (spec §10) resumes untouched.
await touch(9, 'pointerdown', 40, 40);
await touch(9, 'pointerup', 40, 40);
assert.equal(await aliveCount(), 1, 'single-cell paint resumes after cancel');
assert.equal(await cell(40, 40), 1);
await page.evaluate(() => {
window.game_of_life.engine.clear();
window.game_of_life.setRunning(true);
});
});
test('stamps are additive over existing cells and legal while running', async () => {
await page.evaluate(() => window.game_of_life.engine.clear());
const before = await page.evaluate(() => {
window.game_of_life.setRunning(true);
return window.game_of_life.engine.aliveCount();
});
assert.equal(before, 0);
const written = await page.evaluate(() => window.game_of_life.stampPattern('block', 10, 10));
assert.equal(written, 4);
const again = await page.evaluate(() => window.game_of_life.stampPattern('block', 10, 10));
// Engine contract (engine.ts stampRle): pure OR, returns pattern population,
// NOT a delta — stable under overlap and wrap. Dedup shows in aliveCount.
assert.equal(again, 4, 'stamp returns declared population, overlap or not');
assert.equal(await aliveCount(), 4, 'additive OR: overlap did not double cells');
assert.equal(await page.evaluate(() => window.game_of_life.isRunning()), true,
'stamp legal while running');
});
test('library glider stamped at the far corner survives torus wrap and travels', async () => {
await page.evaluate(() => {
window.game_of_life.engine.clear();
window.game_of_life.stampPattern('glider', 119, 89); // corner: body wraps
});
assert.equal(await aliveCount(), 5);
assert.equal(await cell(0, 0), 1, 'wrapped corner cell alive (119+1 -> 0)');
await page.waitForFunction(
() => window.game_of_life.engine.generation >= 10,
undefined,
{ timeout: 5000 },
);
assert.equal(await aliveCount(), 5, 'glider still intact after >=10 live generations');
});
test('Escape closes the menu; patternIds exposes the ratified library', async () => {
await page.evaluate(() => document.activeElement?.blur());
await page.click('#patterns');
assert.equal(await page.evaluate(() => window.game_of_life.menuIsOpen()), true);
await page.keyboard.press('Escape');
assert.equal(await page.evaluate(() => window.game_of_life.menuIsOpen()), false);
assert.equal(await page.$eval('#pattern-menu', (el) => el.hidden), true);
const ids = await page.evaluate(() => window.game_of_life.patternIds());
assert.deepEqual(ids, [
'gosper-glider-gun', 'glider', 'lwss', 'pulsar', 'pentadecathlon',
'block', 'beacon', 'beehive', 'r-pentomino',
]);
const items = await page.$$eval('#pattern-list button[data-pattern]', (els) => els.map((e) => e.textContent));
assert.equal(items.length, 9, 'menu rendered from PATTERNS');
});
test('gun stamps 36 through the browser QA path (plan pin)', async () => {
await page.evaluate(() => window.game_of_life.engine.clear());
const written = await page.evaluate(() => window.game_of_life.stampPattern('gosper-glider-gun', 20, 20));
assert.equal(written, 36, 'gun pop 36 via menu code path');
assert.equal(await aliveCount(), 36);
});
// --- Phase 7: save wire (spec §7 / D6) ---------------------------------------
// Tested pair per napplet-dev convention: the SAME app code must run green on
// (a) the bare file:// host (no window.napplet at all: honest degradation,
// zero throws) and (b) a fake host storage surface (full save/restore).
// The fake backs its map with window.name, which survives same-tab reloads —
// exactly what the boot-load path needs. (App src/ still never touches
// browser storage: pinned by the guidance suite.)
function fakeHostStorage() {
const readMap = () => {
try {
return JSON.parse(window.name || '{}');
} catch {
return {};
}
};
const writeMap = (m) => {
window.name = JSON.stringify(m);
};
window.napplet = {
storage: {
async getItem(key) {
const m = readMap();
return Object.prototype.hasOwnProperty.call(m, key) ? m[key] : null;
},
async setItem(key, value) {
const m = readMap();
m[key] = String(value);
writeMap(m);
},
async removeItem(key) {
const m = readMap();
delete m[key];
writeMap(m);
},
async keys() {
return Object.keys(readMap());
},
},
};
}
test('bare host (no window.napplet): honest notice, saveNow declines, zero throws', async () => {
assert.equal(await page.evaluate(() => window.game_of_life.domains.storage), false);
assert.equal(await page.$eval('#save-status', (el) => el.hidden), false,
'storage absence is stated, not hidden');
const notice = await page.$eval('#save-status', (el) => el.textContent.trim());
assert.equal(notice, 'Saving unavailable \u2014 play stays in this session.',
'degradation copy is honest: session-only, no fake success');
const declined = await page.evaluate(async () => await window.game_of_life.saveNow());
assert.equal(declined, false, 'saveNow reports the outage instead of pretending');
});
let page2; // fake-host page (survives the reload inside the round-trip test)
test('fake host: fresh boot keeps the gun default, notice hidden, domain true', async () => {
page2 = await browser.newPage({ viewport: { width: 900, height: 600 } });
page2.on('pageerror', (e) => pageErrors.push(String(e)));
await page2.addInitScript(fakeHostStorage);
await page2.goto(new URL('dist/index.html', `file://${ROOT}`).href);
await page2.waitForFunction('window.game_of_life && window.game_of_life.engine.aliveCount() === 36');
assert.equal(await page2.evaluate(() => window.game_of_life.domains.storage), true);
assert.equal(await page2.$eval('#save-status', (el) => el.hidden), true,
'a working storage host gets no outage notice');
assert.equal(await page2.evaluate(() => window.game_of_life.genPerSec()), 10, 'D3 default speed');
});
test('board + speed + generation save, reload, and boot-load restores them all', async () => {
await page2.evaluate(() => {
const qa = window.game_of_life;
qa.setRunning(false);
qa.engine.clear();
qa.engine.generation = 42; // deterministic non-zero saved counter
qa.stampPattern('block', 30, 30); // -> block at 29..30 (QA direct-stamp path centers on the point)
});
await page2.$eval('#speed', (el) => {
el.value = '7';
el.dispatchEvent(new Event('input', { bubbles: true }));
});
assert.equal(await page2.$eval('#speed-value', (el) => el.textContent), '7/s');
const ok = await page2.evaluate(async () => await window.game_of_life.saveNow());
assert.equal(ok, true, 'save through the fake host wire succeeds');
const keys = await page2.evaluate(async () => window.napplet.storage.keys());
assert.deepEqual([...keys].sort(),
['game-of-life:board', 'game-of-life:generation', 'game-of-life:speed'],
'keys carry the plan Phase 7 prefix');
await page2.reload(); // window.name carries the map across
// Boot-load rule (plan): restored state renders immediately, not after a
// first step. pop===4 AND speed===7 both hold only after the load applies.
await page2.waitForFunction(
() => window.game_of_life.genPerSec() === 7 && window.game_of_life.engine.aliveCount() === 4,
undefined,
{ timeout: 4000 },
);
for (const [x, y] of [[29, 29], [30, 29], [29, 30], [30, 30]]) {
assert.equal(await page2.evaluate(({ x, y }) => window.game_of_life.engine.get(x, y), { x, y }),
1, `block cell (${x},${y}) restored from the save`);
}
assert.equal(await page2.$eval('#speed-value', (el) => el.textContent), '7/s',
'speed control reflects the restored setting');
const gen = await page2.evaluate(() => window.game_of_life.engine.generation);
assert.ok(gen >= 42, `generation counter resumes from the saved value (got ${gen})`);
});
test('corrupt save values keep the defaults playing; nothing throws', async () => {
const page3 = await browser.newPage({ viewport: { width: 900, height: 600 } });
page3.on('pageerror', (e) => pageErrors.push(String(e)));
await page3.addInitScript(fakeHostStorage);
await page3.addInitScript(() => {
window.name = JSON.stringify({
'game-of-life:board': 'x=120, y=90, junk!!',
'game-of-life:speed': 'elephant',
'game-of-life:generation': '-5',
});
});
await page3.goto(new URL('dist/index.html', `file://${ROOT}`).href);
await page3.waitForFunction('window.game_of_life && window.game_of_life.engine.aliveCount() === 36');
assert.equal(await page3.evaluate(() => window.game_of_life.genPerSec()), 10,
'junk speed never reaches the loop');
assert.equal(await page3.evaluate(() => window.game_of_life.engine.generation >= 0), true,
'junk generation never reaches the engine');
assert.equal(await page3.$eval('#save-status', (el) => el.hidden), true,
'storage itself works here: corrupt payload is not an outage');
});
// --- Phase 8: HUD counter cues (spec §12) ------------------------------------
// CSS-transient classes on #generation/#population: fired only on value
// change, self-removed on animationend. Pins run paused; the reduced-motion
// pair uses a MutationObserver because the flattened 1ms animation can END
// before an evaluate round-trip reads the class — the observer proves the cue
// fired at all, the later assert proves it self-removed (guards the
// animation:none pitfall where animationend never fires).
test('step cues gen but not pop; pop change cues both; cues self-remove', async () => {
// Change-gating law: syncHud cues compare against the LAST RENDERED value.
// Direct engine.set() writes never sync, so a change-gated pop pulse needs
// the population to actually differ between two syncs: a still block keeps
// pop 4 across steps (gen cue only), while a painted adjacent pair dies
// together and drops observed pop (both cues).
await page.evaluate(() => {
const qa = window.game_of_life;
qa.setRunning(false);
qa.engine.clear();
qa.stampPattern('block', 30, 30); // sync: pop 4 is the last rendered value
});
// Drain any cues from the setup syncs, then pause a beat for animations.
await page.evaluate(() => {
document.querySelector('#generation').classList.remove('cue-gen');
document.querySelector('#population').classList.remove('cue-pop');
});
await page.waitForTimeout(700);
await page.click('#step'); // stable block: generation moves, pop stays 4
const afterStep = await page.evaluate(() => ({
gen: document.querySelector('#generation').classList.contains('cue-gen'),
pop: document.querySelector('#population').classList.contains('cue-pop'),
}));
assert.equal(afterStep.gen, true, 'generation tick fires on every stepped gen');
assert.equal(afterStep.pop, false, 'population pulse is change-gated: stable block must not pulse');
// Paint an adjacent pair (touches sync via onPaint; each cell has exactly
// one neighbor, so BOTH die on the next step: observed pop drops).
await touch(8, 'pointerdown', 70, 70);
await touch(8, 'pointerup', 70, 70);
await touch(9, 'pointerdown', 71, 70);
await touch(9, 'pointerup', 71, 70);
await page.evaluate(() => {
document.querySelector('#generation').classList.remove('cue-gen');
document.querySelector('#population').classList.remove('cue-pop');
});
await page.waitForTimeout(700);
await page.click('#step'); // pair dies: observed pop 6 -> 4, a real change
const afterDeath = await page.evaluate(() => ({
gen: document.querySelector('#generation').classList.contains('cue-gen'),
pop: document.querySelector('#population').classList.contains('cue-pop'),
}));
assert.equal(afterDeath.pop, true, 'population pulse fires when the rendered value changes');
assert.equal(afterDeath.gen, true, 'generation tick still rides the same step');
await page.waitForTimeout(900); // both animations (0.3s/0.6s) must have self-removed
const drained = await page.evaluate(() => ({
gen: document.querySelector('#generation').classList.contains('cue-gen'),
pop: document.querySelector('#population').classList.contains('cue-pop'),
}));
assert.deepEqual(drained, { gen: false, pop: false }, 'cues never stick to the HUD spans');
});
test('reduced-motion flattens the cues but they still fire and self-remove', async () => {
// Leave a fresh dying pair for the reduced-motion step (pop drops, both
// cues must fire even though the animation is flattened to 1ms).
await touch(10, 'pointerdown', 80, 20);
await touch(10, 'pointerup', 80, 20);
await touch(11, 'pointerdown', 81, 20);
await touch(11, 'pointerup', 81, 20);
const popBefore = await aliveCount();
await page.emulateMedia({ reducedMotion: 'reduce' });
await page.evaluate(() => {
window.__cueSeen = { 'cue-gen': false, 'cue-pop': false };
const watch = (sel, cls) =>
new MutationObserver(() => {
if (document.querySelector(sel).classList.contains(cls)) window.__cueSeen[cls] = true;
}).observe(document.querySelector(sel), { attributes: true, attributeFilter: ['class'] });
watch('#generation', 'cue-gen');
watch('#population', 'cue-pop');
});
await page.click('#step');
await page.waitForTimeout(250); // 1ms animations have long ended by now
const seen = await page.evaluate(() => window.__cueSeen);
assert.deepEqual(seen, { 'cue-gen': true, 'cue-pop': true },
'under reduced motion both cues still fire (animationend stays alive)');
const drained = await page.evaluate(() => ({
gen: document.querySelector('#generation').classList.contains('cue-gen'),
pop: document.querySelector('#population').classList.contains('cue-pop'),
}));
assert.deepEqual(drained, { gen: false, pop: false },
'flattened cues self-remove: animationend is flattened, not killed');
assert.equal(await aliveCount(), popBefore - 2, 'sanity: the painted pair died on the step');
await page.emulateMedia({ reducedMotion: 'no-preference' });
});
test('no page-level exception fired anywhere in the suite (degradation law)', async () => {
assert.deepEqual(pageErrors, [], 'every storage absence/breakage stayed a gated state');
});
