Back to Conway's Game of Life
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.

tests/browser.test.mjs
// 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');
});