From f46c6499a22a84a017798d3cd2a7ab8dbcbe36e6 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 31 Aug 2026 02:24:23 +0000 Subject: [PATCH 1/6] Fix two regressions this session shipped in PR #64 Both were introduced by round-4 fixes and neither existing test caught them. Monte Carlo progress stopped updating entirely. The async driver drains yields for a 14ms chunk and then inspects the last one, and it reported progress only when that last yield ended a trial. Once the generator began yielding after every step (the round-4 responsiveness fix), well under 1% of yields ended a trial, so a chunk almost never stopped on one: the dialog sat at 'Running...' with a 0% bar for the whole batch, sweeps and sensitivity runs included. Measured against the pre-PR engine on the same model: 100x200 gave 8 progress callbacks before and 0 after, 1000x300 gave 76 before and 0 after. Every yield already carries the completed-trial count, so the chunk's last yield is always current: report it whether or not it ended a trial. Restored to 8 and 85. Typing into a Delay's or Queue's live Amount field scrambled its schedule. _field commits on the input event, once per keystroke, which was harmless when the handler was a plain setCount but is destructive now that it routes to SimEngine.setLiveCount: typing '12' committed 1 first, physically discarding the units and timers the other 11 stood for, then re-added 11 on a fresh full delay. Measured through the real UI: a batch of 12 due at step 5 arrived split as 1 at step 5 and 11 at step 8. _field takes a commitOn event now, and the amount row passes 'change' for a Delay or Queue so the edit applies once the user has finished typing. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01SH7ouoNymubArm7VEgALHg --- js/app-fields.js | 10 ++++++++-- js/app-props.js | 5 ++++- js/engine.js | 9 +++++++-- test/run.js | 28 ++++++++++++++++++++++++++++ test/smoke.js | 48 ++++++++++++++++++++++++++++++++++++++++++++++++ 5 files changed, 95 insertions(+), 5 deletions(-) diff --git a/js/app-fields.js b/js/app-fields.js index f05fb5b..f3dd315 100644 --- a/js/app-fields.js +++ b/js/app-fields.js @@ -224,7 +224,13 @@ class AppFields { // Unique id generator for programmatic label↔control association. _uid() { return 'fld-' + (App._fieldSeq = (App._fieldSeq || 0) + 1); } - _field(panel, label, type, value, onChange, placeholder = '') { + // `commitOn` is the DOM event that applies the edit. 'input' (the default) + // fires on every keystroke, which is what makes a field feel live. Pass + // 'change' for a field whose write is destructive, so it applies once the + // user has finished typing: typing "12" through an 'input' field commits the + // intermediate value 1 first, and for a Delay or Queue that physically + // discards the units and timers the difference stands for. + _field(panel, label, type, value, onChange, placeholder = '', commitOn = 'input') { const row = document.createElement('div'); row.className = 'prop-row'; const lbl = document.createElement('label'); @@ -235,7 +241,7 @@ class AppFields { if (placeholder) inp.placeholder = placeholder; if (label) { inp.id = this._uid(); lbl.htmlFor = inp.id; } else inp.setAttribute('aria-label', placeholder || 'value'); - inp.addEventListener('input', () => onChange(inp.value)); + inp.addEventListener(commitOn, () => onChange(inp.value)); row.appendChild(lbl); row.appendChild(inp); panel.appendChild(row); diff --git a/js/app-props.js b/js/app-props.js index 7d84f6c..7d9e33f 100644 --- a/js/app-props.js +++ b/js/app-props.js @@ -1468,6 +1468,9 @@ class AppProps { // node.resources is the live count once a run has stepped; only at rest // does this field set the baseline that Reset returns to. Label it // honestly here, and keep it honest per step in _refreshResourceCount. + // A Delay's or Queue's amount edit moves the in-flight pipeline, so it + // must not run per keystroke: see the commitOn note on _field. + const pipelineNode = node.type === NodeType.DELAY || node.type === NodeType.QUEUE; this._field(panel, this.engine.step > 0 ? AMOUNT_LABEL_LIVE : AMOUNT_LABEL_AT_REST, 'number', node.resources, v => { // Keep the node's own colour. setCount defaults to DEFAULT_COLOR, so // retyping a pool's amount used to convert every typed resource it held @@ -1493,7 +1496,7 @@ class AppProps { // number directly above the new one. this._refreshResourceCount(); this._refreshTypeReadouts(); - }); + }, '', pipelineNode ? 'change' : 'input'); // Quick +/- steppers for adjusting the current amount during play // (these nudge the live value without changing the starting baseline). const stepRow = document.createElement('div'); diff --git a/js/engine.js b/js/engine.js index bc3d976..88a0eb5 100644 --- a/js/engine.js +++ b/js/engine.js @@ -1519,8 +1519,13 @@ class SimEngine { r = job.next(); } if (r.done) { resolve(r.value); return; } - // Mid-trial breaths carry no new progress; only completed trials do. - if (opts.onProgress && !r.value.partial) opts.onProgress(r.value.done, r.value.total); + // Every yield carries the number of trials finished so far, mid-trial + // breaths included, so the chunk's last yield is always the current + // count. Reporting only on a non-partial yield reported almost never: + // with hundreds of steps per trial, well under 1% of yields end a + // trial, so a chunk hardly ever stopped on one and the dialog sat at + // "Running…" and a 0% bar for the whole batch. + if (opts.onProgress) opts.onProgress(r.value.done, r.value.total); setTimeout(tick, 0); }; tick(); diff --git a/test/run.js b/test/run.js index 87c45fd..999e6b5 100644 --- a/test/run.js +++ b/test/run.js @@ -826,6 +826,34 @@ test('a Monte Carlo batch yields inside a trial, not only between trials', () => assert(yields >= 2 * 25, `yields inside each trial too (got ${yields})`); }); +testAsync('a Monte Carlo batch reports progress while it runs', async () => { + // The async driver drains yields for a 14ms chunk and then looks at the last + // one. Once the generator started yielding after every step, well under 1% of + // yields ended a trial, so a chunk hardly ever stopped on one and the dialog + // sat at "Running..." with a 0% bar for the whole batch. Every yield carries + // the completed-trial count, so the chunk's last yield is always current. + const d = new Diagram(); + const src = new MNode(NodeType.SOURCE, 0, 0); src.label = 'Mine'; + const pool = new MNode(NodeType.POOL, 200, 0); pool.label = 'Gold'; + d.addNode(src); d.addNode(pool); + const c = new MConnection(src.id, pool.id, ConnectionType.RESOURCE); + c.rateMode = RateMode.FORMULA; c.formula = 'randomInt(1,5)'; + d.addConnection(c); + + const e = new SimEngine(d); + const seen = []; + const res = await e.runMonteCarloAsync(300, 400, { + seed: 'progress', + onProgress: (done, total) => seen.push({ done, total }), + }); + assert(res && res.runs === 300, 'the batch still completes'); + assert(seen.length >= 3, `progress is reported repeatedly (got ${seen.length} reports)`); + assert(seen.every((v, i) => i === 0 || v.done >= seen[i - 1].done), 'the count never goes backwards'); + assert(seen.every(v => v.total === 300), 'the total is the run count'); + const last = seen[seen.length - 1].done; + assert(last > 300 * 0.5, `progress gets most of the way to the end before finishing (reached ${last}/300)`); +}); + testAsync('a cancelled Monte Carlo batch still restores the RNG it borrowed', async () => { // The driver dropped the generator on cancel instead of closing it, so the // finally block never ran and the shared RNG stayed parked on the last diff --git a/test/smoke.js b/test/smoke.js index 8e05c77..0cef29a 100644 --- a/test/smoke.js +++ b/test/smoke.js @@ -2588,6 +2588,54 @@ const URL = process.env.SMOKE_URL || 'http://localhost:8080/'; ok(`components: save selection (${comp.compNodes} nodes, ${comp.compConns} conn), insert adds 2 nodes, undo reverts`); else fail('components: ' + JSON.stringify(comp)); + // Properties: typing into a Delay's live Amount field must not scramble the + // in-flight schedule. The field commits per keystroke, so typing "12" used to + // commit 1 first, and for a Delay that physically discards the units and + // timers the difference stands for. + const delayType = await (async () => { + const build = () => page.evaluate(() => { + const app = window.app; + app._clearAll(); + const del = new MNode(NodeType.DELAY, 300, 300); del.label = 'Belt'; + del.delay = 5; del.setCount(12, '#8d6e63'); + const out = new MNode(NodeType.POOL, 600, 300); out.label = 'Out'; + app.diagram.addNode(del); app.diagram.addNode(out); + const c = new MConnection(del.id, out.id, ConnectionType.RESOURCE); c.rate = 99; + app.diagram.addConnection(c); + app.renderer.render(); app._commit(); + app.engine.reset(); + app.engine.doStep(); app.engine.doStep(); + app.editor._select(del.id, 'node'); + return { delayId: del.id, outId: out.id }; + }); + const drain = (ids) => page.evaluate(({ outId }) => { + const app = window.app; const seq = []; + for (let i = 0; i < 10; i++) { app.engine.doStep(); seq.push(app.diagram.nodes.get(outId).resources); } + return seq; + }, ids); + + const ids = await build(); + const baseline = await drain(ids); + + const ids2 = await build(); + const inp = await page.$('#props-content input[type="number"]'); + if (!inp) return { error: 'no amount field on a delay' }; + await inp.click({ clickCount: 3 }); + await page.keyboard.press('Control+a'); + await page.keyboard.type('12', { delay: 60 }); + const midTyping = await page.evaluate(({ delayId }) => { + const n = window.app.diagram.nodes.get(delayId); + return { res: n.resources, batches: (n._queue || []).length }; + }, ids2); + await page.keyboard.press('Tab'); + const after = await drain(ids2); + return { baseline, midTyping, after }; + })(); + if (!delayType.error && delayType.midTyping.batches === 1 && delayType.midTyping.res === 12 + && JSON.stringify(delayType.after) === JSON.stringify(delayType.baseline)) + ok('properties: retyping a delay\'s live amount leaves its release schedule alone'); + else fail('delay amount typing: ' + JSON.stringify(delayType)); + // Export: a big diagram must not silently download a 0-byte PNG. Browsers cap // both a canvas's longest side and its total area; past either, drawImage // no-ops and toDataURL returns a stub. From f89195ab6f3905ee20b3eb3d49b61f0f7dccde62 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 31 Aug 2026 02:27:20 +0000 Subject: [PATCH 2/6] Stop undo from eating the wrong edit and destroying redo Round-5 findings 3 and 4, which compound into one unrecoverable loss. Snapshots carry the module-level id counter, and loadJSON only ever raises it so that ids handed out since a snapshot cannot collide with it. Restoring a snapshot therefore never reproduces its own text, so _commit's 'did anything actually change?' test always reported a change once an undo had happened, and the next commit, even one that changed nothing at all, pushed an undo entry and cleared the redo stack. Snapshots are now compared with the counter excluded. Arrow-key nudges are coalesced for 400ms so a held key is a single undo step, but the commit was only ever armed on a timer. A Ctrl+Z inside that window stepped straight past the nudge and undid the edit before it: place two pools, nudge, Ctrl+Z, and the second pool disappears. The pending commit then landed and, through the bug above, wiped the redo stack, so it could not be brought back at all. Undo and redo now flush a pending nudge first. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01SH7ouoNymubArm7VEgALHg --- js/app.js | 21 +++++++++++++++++++-- js/editor.js | 14 +++++++++++++- test/smoke.js | 52 +++++++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 84 insertions(+), 3 deletions(-) diff --git a/js/app.js b/js/app.js index 0ddacd0..1574042 100644 --- a/js/app.js +++ b/js/app.js @@ -505,7 +505,7 @@ class App { _commitReplace(prevSnap) { this._dropScenarioState(); const snap = this._snapshot(); - if (snap === prevSnap) { this._lastState = snap; this._updateUndoButtons(); return; } + if (this._sameSnapshot(snap, prevSnap)) { this._lastState = snap; this._updateUndoButtons(); return; } if (prevSnap != null) { this._undoStack.push(prevSnap); if (this._undoStack.length > 100) this._undoStack.shift(); @@ -516,6 +516,21 @@ class App { this._persistAutosave(); } + // Two snapshots describe the same diagram. The module-level id counter rides + // along in the JSON and loadJSON only ever raises it, so that ids handed out + // since cannot collide, which means restoring a snapshot never reproduces its + // own text. Comparing the raw strings therefore reported a change after every + // undo, and the next commit, even one that changed nothing at all, pushed an + // undo entry and wiped the redo stack. + _sameSnapshot(a, b) { + if (a === b) return true; + if (a == null || b == null) return false; + try { + const strip = (j) => { const o = JSON.parse(j); delete o._idSeq; return JSON.stringify(o); }; + return strip(a) === strip(b); + } catch { return false; } + } + // Mirror the current state into the autosave slot. Every path that changes // what is on the canvas has to call this: undo and redo moved _lastState // without it, so an undo looked repaired on screen and was thrown away on the @@ -530,7 +545,7 @@ class App { // delegated commit listener) don't create empty undo steps. _commit() { let snap = this._snapshot(); - if (snap === this._lastState) return; + if (this._sameSnapshot(snap, this._lastState)) return; // A real change happened: bump the file's modified timestamp (it is part // of the snapshot, so re-take it after stamping). this.diagram.meta.modified = Date.now(); @@ -560,6 +575,7 @@ class App { } undo() { + this.editor.flushPending(); if (!this._undoStack.length) return; this._redoStack.push(this._lastState); this._lastState = this._undoStack.pop(); @@ -569,6 +585,7 @@ class App { } redo() { + this.editor.flushPending(); if (!this._redoStack.length) return; this._undoStack.push(this._lastState); this._lastState = this._redoStack.pop(); diff --git a/js/editor.js b/js/editor.js index f41df4a..992cdec 100644 --- a/js/editor.js +++ b/js/editor.js @@ -1032,10 +1032,22 @@ class Editor { for (const t of targets) { t.x += dx; t.y += dy; } this.renderer.render(); clearTimeout(this._nudgeTimer); - this._nudgeTimer = setTimeout(() => this._changed(), 400); + this._nudgeTimer = setTimeout(() => { this._nudgeTimer = null; this._changed(); }, 400); } } + // Commit a nudge that is still inside its coalescing window. Arrow-key moves + // are batched for 400ms so a held key is one undo step, but anything that + // reads the undo history has to see the moves the user has already made: + // with the commit still pending, Ctrl+Z stepped straight past the nudge and + // undid whatever edit came before it. + flushPending() { + if (!this._nudgeTimer) return; + clearTimeout(this._nudgeTimer); + this._nudgeTimer = null; + this._changed(); + } + _onKeyUp(e) { if (e.code === 'Space') { // Always clear, even behind a dialog: _onKey may have swallowed the diff --git a/test/smoke.js b/test/smoke.js index 0cef29a..a37f51c 100644 --- a/test/smoke.js +++ b/test/smoke.js @@ -2588,6 +2588,58 @@ const URL = process.env.SMOKE_URL || 'http://localhost:8080/'; ok(`components: save selection (${comp.compNodes} nodes, ${comp.compConns} conn), insert adds 2 nodes, undo reverts`); else fail('components: ' + JSON.stringify(comp)); + // Undo: an arrow-key nudge is coalesced for 400ms, so a Ctrl+Z inside that + // window used to step straight past it and undo the edit before it (a node + // placement), and the pending commit then landed and wiped the redo stack, so + // the node could not be brought back. + const nudgeUndo = await (async () => { + await page.evaluate(() => { window.app._clearAll(); window.app._commit(); }); + for (const [x, y] of [[300, 300], [500, 300]]) { + await page.click('[data-tool="place-pool"]'); + await page.mouse.click(x, y); + } + const placed = await page.evaluate(() => window.app.diagram.nodes.size); + await page.keyboard.press('ArrowRight'); + await page.keyboard.press('ArrowRight'); + const nudgedXs = await page.evaluate(() => [...window.app.diagram.nodes.values()].map(n => n.x)); + await page.keyboard.press('Control+z'); + const afterUndo = await page.evaluate(() => ({ + nodes: window.app.diagram.nodes.size, + xs: [...window.app.diagram.nodes.values()].map(n => n.x), + })); + await page.waitForTimeout(700); // any pending coalesced commit lands here + const settled = await page.evaluate(() => ({ + nodes: window.app.diagram.nodes.size, + redoDisabled: document.getElementById('btn-redo').disabled, + })); + await page.keyboard.press('Control+y'); + const afterRedo = await page.evaluate(() => [...window.app.diagram.nodes.values()].map(n => n.x)); + return { placed, nudgedXs, afterUndo, settled, afterRedo }; + })(); + if (nudgeUndo.placed === 2 && nudgeUndo.afterUndo.nodes === 2 && nudgeUndo.settled.nodes === 2 + && JSON.stringify(nudgeUndo.afterUndo.xs) !== JSON.stringify(nudgeUndo.nudgedXs) + && !nudgeUndo.settled.redoDisabled + && JSON.stringify(nudgeUndo.afterRedo) === JSON.stringify(nudgeUndo.nudgedXs)) + ok('undo: Ctrl+Z right after an arrow nudge undoes the nudge, and redo survives'); + else fail('nudge undo: ' + JSON.stringify(nudgeUndo)); + + // Undo: after any undo the id counter has moved on, so a raw snapshot compare + // always saw a change. A commit that changes nothing must not clear redo. + const redoKeep = await page.evaluate(() => { + const app = window.app; + app._clearAll(); app._commit(); + const n = new MNode(NodeType.POOL, 200, 200); n.label = 'One'; + app.diagram.addNode(n); app.renderer.render(); app._commit(); + app.undo(); + const beforeNoop = app._redoStack.length; + app._commit(); // nothing changed since the undo + app._commit(); + return { beforeNoop, afterNoop: app._redoStack.length, undoDepth: app._undoStack.length }; + }); + if (redoKeep.beforeNoop === 1 && redoKeep.afterNoop === 1) + ok('undo: a commit that changes nothing keeps the redo stack'); + else fail('redo preservation: ' + JSON.stringify(redoKeep)); + // Properties: typing into a Delay's live Amount field must not scramble the // in-flight schedule. The field commits per keystroke, so typing "12" used to // commit 1 first, and for a Delay that physically discards the units and From 341972a3b4f6876bc5cc26d380cd234c2a19655b Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 31 Aug 2026 02:29:26 +0000 Subject: [PATCH 3/6] Stop an embed writing over the visitor's own work, and close its two other leaks Round-5 findings 5, 6 and 7. Embed mode strips the editing chrome but leaves the canvas fully editable, and every edit reached _persistAutosave, which writes the same-origin sim_autosave key. A visitor who so much as dragged a node in someone's embedded diagram had their own saved diagram silently replaced by it, and found the embed's content waiting for them on their next visit with no undo stack left. _persistAutosave is now a no-op in embed mode. The body.embed rules hide the palette, properties panel, Setup rail and the file and analysis controls, but not the overflow menu button, which a max-width: 768px media query shows. Most iframes are narrower than that, so the menu handed back every control those rules had just removed, New diagram and Open file included. It is hidden in embed mode now. The 'open in Simulations' link stripped the embed marker only from the query string, so for the #embed hash form the knowledge base documents, the link pointed straight back at the embed. It now strips the marker from the hash too, including the #d=...&embed shape a shared embed actually has. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01SH7ouoNymubArm7VEgALHg --- css/style.css | 7 ++++++- js/app.js | 25 ++++++++++++++++++++++++- test/smoke.js | 50 ++++++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 80 insertions(+), 2 deletions(-) diff --git a/css/style.css b/css/style.css index 2c25df2..05bbb80 100644 --- a/css/style.css +++ b/css/style.css @@ -1538,7 +1538,12 @@ body.embed #palette, body.embed #props-panel, body.embed #diagram-rail, body.embed .file-controls, -body.embed .analysis-controls { display: none; } +body.embed .analysis-controls, +/* The overflow menu carries everything the collapsed topbar hides, so at any + width under 768px, which is most iframes, it handed every control the rules + above just removed straight back: the palette, the file controls, New + diagram, Open file, Save as JSON and the rest. */ +body.embed #btn-mobile-menu { display: none; } body.embed #topbar { min-height: 44px; } .embed-open { margin-left: auto; font-size: 11px; color: var(--text-faint); white-space: nowrap; diff --git a/js/app.js b/js/app.js index 1574042..3269820 100644 --- a/js/app.js +++ b/js/app.js @@ -536,6 +536,11 @@ class App { // without it, so an undo looked repaired on screen and was thrown away on the // next reload, taking the mistake it had just undone with it. _persistAutosave() { + // Never in embed mode. The chrome is hidden there but the canvas is still + // editable, and sim_autosave is same-origin: a visitor who nudged a node in + // someone's embedded diagram had their own saved work silently replaced by + // it, and found the embed's diagram waiting for them on their next visit. + if (document.body.classList.contains('embed')) return; try { localStorage.setItem('sim_autosave', this._lastState); } catch { /* blocked storage */ } } @@ -789,7 +794,25 @@ class App { name.id = 'embed-title'; tail.appendChild(name); const link = document.createElement('a'); - link.href = location.href.replace(/([?&])embed(=[^&]*)?/, '$1').replace(/[?&]$/, ''); + // Rebuild the URL without the embed marker, wherever it came from. The + // marker is accepted in the query (?embed) and in the hash (#embed, or + // #d=...&embed, which is what the knowledge base documents), but only the + // query form was ever stripped: for a hash embed the link pointed back at + // the embed itself, so the one escape hatch an embed offers did nothing. + link.href = (() => { + try { + const u = new URL(location.href); + u.searchParams.delete('embed'); + u.hash = u.hash + .replace(/(^#|&)embed\b(=[^&]*)?/g, '$1') + .replace(/^#&/, '#') + .replace(/&&+/g, '&') + .replace(/[#&]$/, ''); + return u.toString(); + } catch { + return location.href.replace(/([?&])embed(=[^&]*)?/, '$1').replace(/[?&]$/, ''); + } + })(); link.target = '_blank'; link.rel = 'noopener'; link.textContent = 'open in Simulations ↗'; tail.appendChild(link); diff --git a/test/smoke.js b/test/smoke.js index a37f51c..22858c9 100644 --- a/test/smoke.js +++ b/test/smoke.js @@ -2588,6 +2588,56 @@ const URL = process.env.SMOKE_URL || 'http://localhost:8080/'; ok(`components: save selection (${comp.compNodes} nodes, ${comp.compConns} conn), insert adds 2 nodes, undo reverts`); else fail('components: ' + JSON.stringify(comp)); + // Embed mode: the chrome is stripped but the canvas stays editable, so an + // embed must not write the host's sim_autosave, must not hand the hidden + // controls back through the overflow menu at iframe widths, and its one + // escape hatch must actually leave the embed. + const embed = await (async () => { + const ctx = await browser.newContext({ viewport: { width: 700, height: 600 } }); + const ep = await ctx.newPage(); + await ep.route('https://fonts.googleapis.com/**', r => r.fulfill({ contentType: 'text/css', body: '' })); + await ep.addInitScript(() => { + try { + localStorage.setItem('sim_seen_welcome', '1'); + localStorage.setItem('sim_autosave', JSON.stringify({ + version: 1, meta: { name: 'HostWork' }, connections: [], + nodes: [1, 2, 3, 4].map(i => ({ id: 'h' + i, type: 'pool', x: i * 100, y: 100, label: 'Host' + i })), + })); + } catch (e) {} + }); + // The hash form is what the knowledge base documents. + await ep.goto(URL + '#embed', { waitUntil: 'networkidle' }); + const onLoad = await ep.evaluate(() => { + const m = document.getElementById('btn-mobile-menu'); + return { + isEmbed: document.body.classList.contains('embed'), + menuVisible: !!m && getComputedStyle(m).display !== 'none' && m.getBoundingClientRect().width > 0, + link: (document.querySelector('.embed-open a') || {}).href || '', + }; + }); + // Edit inside the embed with a real drag. + await ep.evaluate(() => { + const app = window.app; app._clearAll(); + const n = new MNode(NodeType.POOL, 300, 300); n.label = 'EmbedDoc'; + app.diagram.addNode(n); app.renderer.render(); app._commit(); + }); + await ep.mouse.move(400, 300); await ep.mouse.down(); + await ep.mouse.move(460, 340, { steps: 6 }); await ep.mouse.up(); + await ep.waitForTimeout(150); + const host = await ep.evaluate(() => { + try { + const a = JSON.parse(localStorage.getItem('sim_autosave') || '{}'); + return { name: a.meta && a.meta.name, nodes: (a.nodes || []).length }; + } catch { return { name: null, nodes: -1 }; } + }); + await ctx.close(); + return { onLoad, host }; + })(); + if (embed.onLoad.isEmbed && !embed.onLoad.menuVisible && !/embed/.test(embed.onLoad.link) + && embed.host.name === 'HostWork' && embed.host.nodes === 4) + ok('embed: leaves the host autosave alone, hides the overflow menu, and links back out'); + else fail('embed: ' + JSON.stringify(embed)); + // Undo: an arrow-key nudge is coalesced for 400ms, so a Ctrl+Z inside that // window used to step straight past it and undo the edit before it (a node // placement), and the pending commit then landed and wiped the redo stack, so From c101383d7f207ffdeb14d1e60928eb4e5156547e Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 31 Aug 2026 02:33:48 +0000 Subject: [PATCH 4/6] Three engine defects: a swallowing trader, an all-off gate that routes everything, and a lost baseline Round-5 findings 8, 11 and 12. _acceptable already refuses a trader as a flow target, on the grounds that a trader never holds resources, but the trader's own canAccept rejected only sources and registers. A trader used as another trader's partner therefore took the payment and kept it forever, invisibly: its canvas number, chart value and history entry all report the trade count rather than what it holds, so the units simply left the economy. Measured on the reported model, Gold fell from 100 to 80 over ten steps with the 20 sitting on the partner as a hidden holding that a mid-run save wrote into the file. A gate output weight of 0 means off, which the panel says in as many words and shows as 0%. Deterministic Split routed through _proportionalShares, whose zero-total fallback spreads the amount evenly. That is right for a delay handing a matured batch to its outputs, and the opposite of what a gate's weights mean: a gate with every weight at 0 emptied itself into every output at once, while Random and All correctly held everything. Split now holds too. run() captures the reset baseline at step 0 and the first doStep() captured it again, so anything that changed state between them was recorded as the diagram's authored starting amount. Clicking an interactive node in the gap before the first tick therefore rewrote its starting amount permanently: Reset returned to the post-click value and a save carried it. The baseline is now captured once per run, and an interactive fire at step 0 takes it first. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01SH7ouoNymubArm7VEgALHg --- js/engine.js | 35 +++++++++++++++++++--- test/run.js | 85 ++++++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 116 insertions(+), 4 deletions(-) diff --git a/js/engine.js b/js/engine.js index 88a0eb5..66fedb1 100644 --- a/js/engine.js +++ b/js/engine.js @@ -24,6 +24,12 @@ class SimEngine { } saveInitial() { + // One capture per run. run() takes the baseline at step 0 and then the + // first doStep() took it again, so anything that changed state in between, + // in particular clicking an interactive node while waiting for the first + // tick, was captured as the diagram's authored starting amount and Reset + // could never get the real one back. + this._baselineSaved = true; for (const n of this.diagram.nodes.values()) { const infiniteSource = n.type === NodeType.SOURCE && !n.limited; n._initialResources = infiniteSource ? Infinity : n.resources; @@ -105,6 +111,7 @@ class SimEngine { // a prior Monte Carlo batch may have left on the shared RNG. SimRandom.seed(this.diagram.seed || null); this.step = 0; + this._baselineSaved = false; this.history = []; this._histStride = 1; // Flow accumulator for spike attribution: resource amounts per connection @@ -201,7 +208,7 @@ class SimEngine { doStep() { if (this.step === 0) { - this.saveInitial(); + if (!this._baselineSaved) this.saveInitial(); this._seedPipelines(); this._updateVariables(); this._evalRegisters(); @@ -237,7 +244,7 @@ class SimEngine { // Pressing Play resamples 'play'-updated random variables once. this._sampleCustomVars('play'); if (this.step === 0) { - this.saveInitial(); + if (!this._baselineSaved) this.saveInitial(); this._seedPipelines(); this._updateVariables(); this._evalRegisters(); @@ -254,6 +261,11 @@ class SimEngine { fireInteractive(nodeId) { const node = this.diagram.nodes.get(nodeId); if (!node || node.activation !== ActivationMode.INTERACTIVE) return; + // Firing is a play action, never an authoring one, so the baseline Reset + // returns to has to be whatever was on the canvas before the click. At step + // 0 it may not have been captured yet (Run takes it, but the click can land + // in the gap before the first tick, and Step has not run at all). + if (this.step === 0 && !this._baselineSaved) this.saveInitial(); const ctx = this._makeCtx(); const fired = []; this._runFireQueue([{ node, forced: true }], ctx, fired); @@ -978,7 +990,14 @@ class SimEngine { return movedAny; } - // Deterministic: split proportionally to output weights. + // Deterministic: split proportionally to output weights. A weight of 0 is + // off, which is what the panel says ("0 = off") and what it shows (0%), so + // a gate whose outputs are all 0 routes nothing. _proportionalShares falls + // back to an even split when no weight is positive, which is right for a + // delay handing a matured batch to its outputs but is the opposite of what + // a gate's weights mean: it emptied the gate into every output at once, + // while the Random and All modes correctly held everything. + if (weights.reduce((a, b) => a + b, 0) <= 0) return movedAny; const shares = this._proportionalShares(node.resources, weights); outs.forEach((conn, i) => { @@ -1069,7 +1088,15 @@ class SimEngine { // pays away (a full pool can still swap like-for-like). const canAccept = (n, recv, pays) => { if (recv <= 0) return true; - if (n.type === NodeType.SOURCE || n.type === NodeType.REGISTER) return false; + // Mirrors the general intake rule in _acceptable: a trader never holds + // resources, its connections are trade routes. This list was missing + // TRADER, so a trader used as another trader's partner accepted the + // payment and kept it forever, invisibly: its canvas number, chart + // value and history entry all report the trade count, not what it + // holds, so the units simply left the economy and a mid-run save + // carried them into the file as a hidden holding. + if (n.type === NodeType.SOURCE || n.type === NodeType.REGISTER + || n.type === NodeType.TRADER) return false; if (n.capacity === Infinity || n.type === NodeType.DRAIN) return true; return n.capacity - n.resources - (ctx.reserved.get(n.id) || 0) + pays >= recv; }; diff --git a/test/run.js b/test/run.js index 999e6b5..c96416a 100644 --- a/test/run.js +++ b/test/run.js @@ -774,6 +774,91 @@ test('an at-rest diagram still writes no baseline fields', () => { eq(JSON.stringify(d.toJSON()), before, 'reset() alone changes nothing on disk'); }); +test('a trader cannot hand resources to another trader either', () => { + // _acceptable already refuses a trader as a flow target ("a trader never + // holds resources"), but the trader's own canAccept rejected only sources and + // registers. A trader used as another trader's partner therefore took the + // payment and kept it forever, invisibly: its canvas number, chart value and + // history entry all report the trade count, so the units simply left the + // economy and a mid-run save wrote them into the file as a hidden holding. + const d = new Diagram(); + const gold = new MNode(NodeType.POOL, 0, 0); gold.label = 'Gold'; gold.setCount(100); + const mkt = new MNode(NodeType.TRADER, 200, 0); mkt.label = 'Market'; + const broker = new MNode(NodeType.TRADER, 400, 0); broker.label = 'Broker'; + d.addNode(gold); d.addNode(mkt); d.addNode(broker); + const cin = new MConnection(gold.id, mkt.id, ConnectionType.RESOURCE); cin.rate = 2; + const cout = new MConnection(mkt.id, broker.id, ConnectionType.RESOURCE); cout.rate = 0; + d.addConnection(cin); d.addConnection(cout); + + const e = new SimEngine(d); e.reset(); + for (let i = 0; i < 10; i++) e.doStep(); + eq(gold.resources, 100, 'nothing leaves the pool for a trader partner'); + eq(broker.resources, 0, 'and nothing is stashed on the partner trader'); + eq(mkt.trades || 0, 0, 'the trade never happens'); +}); + +test('a gate whose output weights are all zero routes nothing, in every mode', () => { + // A weight of 0 is off: the panel says so and shows 0%. Deterministic split + // ran through _proportionalShares, whose zero-total fallback spreads evenly + // (right for a delay releasing a matured batch, the opposite of what a gate's + // weights mean), so Split emptied the gate while Random and All held it. + for (const mode of ['split', 'random', 'all']) { + const d = new Diagram(); + const g = new MNode(NodeType.GATE, 0, 0); g.label = 'G'; g.setCount(30); g.gateMode = mode; + const p1 = new MNode(NodeType.POOL, 200, -50); p1.label = 'P1'; + const p2 = new MNode(NodeType.POOL, 200, 50); p2.label = 'P2'; + d.addNode(g); d.addNode(p1); d.addNode(p2); + for (const p of [p1, p2]) { + const c = new MConnection(g.id, p.id, ConnectionType.RESOURCE); c.weight = 0; + d.addConnection(c); + } + const e = new SimEngine(d); e.reset(); + for (let i = 0; i < 5; i++) e.doStep(); + eq(g.resources, 30, `${mode}: the gate holds everything`); + eq(p1.resources + p2.resources, 0, `${mode}: no output receives anything`); + } + // A positive weight still routes, so the guard has not turned gates off. + const d = new Diagram(); + const g = new MNode(NodeType.GATE, 0, 0); g.setCount(30); g.gateMode = 'split'; + const p1 = new MNode(NodeType.POOL, 200, -50); + const p2 = new MNode(NodeType.POOL, 200, 50); + d.addNode(g); d.addNode(p1); d.addNode(p2); + const a = new MConnection(g.id, p1.id, ConnectionType.RESOURCE); a.weight = 2; + const b = new MConnection(g.id, p2.id, ConnectionType.RESOURCE); b.weight = 0; + d.addConnection(a); d.addConnection(b); + const e = new SimEngine(d); e.reset(); e.doStep(); + eq(p1.resources, 30, 'the weighted output still takes everything'); + eq(p2.resources, 0, 'and the zero-weight output stays off'); +}); + +test('firing an interactive node before the first tick does not rewrite the baseline', () => { + // run() captures the baseline at step 0 and the first doStep() captured it + // again, so a click landing in the gap between them was recorded as the + // diagram's authored starting amount and Reset could never undo it. + const d = new Diagram(); + const gold = new MNode(NodeType.POOL, 0, 0); gold.label = 'Gold'; gold.setCount(100); + gold.activation = ActivationMode.INTERACTIVE; + const buy = new MNode(NodeType.DRAIN, 200, 0); buy.label = 'Buy'; + d.addNode(gold); d.addNode(buy); + const c = new MConnection(gold.id, buy.id, ConnectionType.RESOURCE); c.rate = 10; + d.addConnection(c); + + const e = new SimEngine(d); e.reset(); + e.saveInitial(); // what run() does at step 0 + e.fireInteractive(gold.id); + eq(gold.resources, 90, 'the click spends from the pool'); + e.doStep(); + e.reset(); + eq(gold.resources, 100, 'Reset returns to the authored starting amount'); + + // Same through the Step button, which bootstraps inside doStep itself. + const e2 = new SimEngine(d); e2.reset(); + e2.fireInteractive(gold.id); + e2.doStep(); + e2.reset(); + eq(gold.resources, 100, 'and when the click precedes the very first Step'); +}); + test('a trader cannot pay out of a delay or a queue', () => { // Their contents are mirrored by an internal queue that releases on its own // schedule. takeResources would draw the count down and leave that queue From 95f99d1f3b14c0667081ab3b035a8ad6d116b847 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 31 Aug 2026 02:38:43 +0000 Subject: [PATCH 5/6] Stop the player panel naming the wrong node, and leave replay when forking Round-5 findings 9 and 10. An artificial-player rule keeps its target node id, but the node can be deleted or have its activation switched away from interactive. The dropdown then fell back to displaying the first interactive node in the diagram, so the panel claimed the rule was wired to a node it has nothing to do with while the rule silently never fired. It now shows '(node deleted)' and says the rule will not fire until another node is picked. Forking from a checkpoint did not leave history-scrub mode. Scrub paints a past step's values over the live model, so the canvas kept showing the replayed step's numbers under the checkpoint's step label, with the slider still pointing into a history the fork had just discarded. Measured: replaying step 2 of a run and forking a step-5 checkpoint left the canvas reading 10 under a 'Step 5' label. _forkFrom now exits scrub first and refreshes the scrubber. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01SH7ouoNymubArm7VEgALHg --- js/app-props.js | 22 +++++++++++++- test/smoke.js | 76 +++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 97 insertions(+), 1 deletion(-) diff --git a/js/app-props.js b/js/app-props.js index 7d9e33f..d8cc092 100644 --- a/js/app-props.js +++ b/js/app-props.js @@ -600,6 +600,12 @@ class AppProps { // the new timeline plots over the ghosts. _forkFrom(cp) { this.engine.stop(); + // Leave replay first. Scrub mode makes the renderer paint a past step's + // values over the live model, so forking while scrubbing left the canvas + // showing the replayed step's numbers under the checkpoint's step label: + // two different moments in one view, with the slider still pointing at a + // history the fork has just thrown away. + this._exitScrub(); this._syncRunButton(); let kept = null; if (this.engine.history.length >= 2 @@ -613,6 +619,7 @@ class AppProps { this.renderer.flowFx.clear(); this._clearSparklines(); this.renderer.render(); + this._refreshScrubber(); this._commit(); // The timeline is where the comparison lives — make sure it's on screen. if (kept && !this._timelineVisible) document.getElementById('btn-timeline').click(); @@ -799,14 +806,27 @@ class AppProps { // *displays* the first interactive node; the model is assigned on // change, which commits (mutating here would drift undo snapshots). const shownId = rule.nodeId || (interactives[0] && interactives[0].id); + // The rule's target can go away: the node is deleted, or its activation is + // switched off interactive. The dropdown then fell back to displaying the + // first interactive node, so the panel claimed the rule was wired to a + // node it has nothing to do with while the rule silently never fired. + const targetGone = !!rule.nodeId && !interactives.some(n => n.id === rule.nodeId); + if (targetGone) { + const o = document.createElement('option'); + o.value = ''; o.textContent = '(node deleted)'; o.selected = true; + ns.appendChild(o); + } for (const n of interactives) { const o = document.createElement('option'); o.value = n.id; o.textContent = n.label || n.type; - if (n.id === shownId) o.selected = true; + if (!targetGone && n.id === shownId) o.selected = true; ns.appendChild(o); } ns.addEventListener('change', () => { rule.nodeId = ns.value; this._commit(); }); nodeRow.appendChild(nl); nodeRow.appendChild(ns); box.appendChild(nodeRow); + if (targetGone) { + this._info(box, 'This rule points at a node that is gone or is no longer interactive, so it never fires. Pick another node.'); + } // Firing mode. const modeRow = document.createElement('div'); modeRow.className = 'prop-row'; diff --git a/test/smoke.js b/test/smoke.js index 22858c9..76b905a 100644 --- a/test/smoke.js +++ b/test/smoke.js @@ -2588,6 +2588,82 @@ const URL = process.env.SMOKE_URL || 'http://localhost:8080/'; ok(`components: save selection (${comp.compNodes} nodes, ${comp.compConns} conn), insert adds 2 nodes, undo reverts`); else fail('components: ' + JSON.stringify(comp)); + // Checkpoints: forking while the timeline is being replayed must leave replay + // first. Scrub mode paints a past step's values over the live model, so the + // canvas kept showing the replayed step's numbers under the checkpoint's step + // label: two different moments in one view. + const forkScrub = await page.evaluate(() => { + const app = window.app; + app._clearAll(); app._closeFeature(); + const src = new MNode(NodeType.SOURCE, 100, 200); src.label = 'Mine'; + const pool = new MNode(NodeType.POOL, 400, 200); pool.label = 'Gold'; + app.diagram.addNode(src); app.diagram.addNode(pool); + const c = new MConnection(src.id, pool.id, ConnectionType.RESOURCE); c.rate = 5; + app.diagram.addConnection(c); + app.renderer.render(); app._commit(); + app.engine.reset(); + for (let i = 0; i < 5; i++) app.engine.doStep(); + app._addCheckpoint(); // step 5, Gold = 25 + const cp = app._checkpoints[app._checkpoints.length - 1]; + for (let i = 0; i < 10; i++) app.engine.doStep(); // step 15, Gold = 75 + app._scrubTo(2); // replay step 2, Gold = 10 + const scrubbing = { idx: app._scrubIndex, shown: app._panelValueOf(pool) }; + app._forkFrom(cp); + // restoreState rebuilds the diagram through loadJSON, so the node objects + // are new: look the pool up by id rather than holding the old reference. + const poolAfter = app.diagram.nodes.get(pool.id); + return { + scrubbing, + afterIdx: app._scrubIndex, + engineStep: app.engine.step, + poolNow: poolAfter ? poolAfter.resources : null, + painted: app._panelValueOf(poolAfter), + labelStep: (document.getElementById('step-counter').textContent || '').trim(), + }; + }); + if (forkScrub.scrubbing.idx != null && forkScrub.afterIdx == null + && forkScrub.engineStep === 5 && forkScrub.poolNow === 25 && forkScrub.painted === 25 + && /Step 5\b/.test(forkScrub.labelStep)) + ok('checkpoints: forking while replaying leaves replay, so canvas and step label agree'); + else fail('fork while scrubbing: ' + JSON.stringify(forkScrub)); + + // Artificial player: a rule whose target node is gone must say so, not display + // a different node while silently never firing. + const playerRule = await page.evaluate(() => { + const app = window.app; + app._clearAll(); + const a = new MNode(NodeType.POOL, 200, 200); a.label = 'Chest'; + a.activation = ActivationMode.INTERACTIVE; + const b = new MNode(NodeType.POOL, 400, 200); b.label = 'Shop'; + b.activation = ActivationMode.INTERACTIVE; + app.diagram.addNode(a); app.diagram.addNode(b); + app.renderer.render(); app._commit(); + app._closeFeature(); + document.querySelector('#diagram-rail .rail-btn[data-feature="player"]').click(); + const ai = app.diagram.aiPlayer; + ai.rules.push({ nodeId: b.id, mode: 'interval', every: 2 }); + ai.enabled = true; + app._renderProps(); + const sel = () => document.querySelector('#props-content .ai-rule select'); + const before = { value: sel().value, text: sel().selectedOptions[0].textContent, isShop: sel().value === b.id }; + // Delete the rule's target through the model, then re-render the panel. + app.diagram.removeNode(b.id); + app.renderer.render(); app._commit(); + app._renderProps(); + const s2 = sel(); + return { + before, + afterText: s2.selectedOptions[0].textContent, + afterValue: s2.value, + ruleStillPointsAtGone: ai.rules[0].nodeId !== a.id, + warned: /never fires/i.test(document.getElementById('props-content').textContent), + }; + }); + if (playerRule.before.isShop && /deleted/i.test(playerRule.afterText) + && playerRule.afterValue === '' && playerRule.warned) + ok('player: a rule whose target node is gone says so instead of naming another node'); + else fail('player rule: ' + JSON.stringify(playerRule)); + // Embed mode: the chrome is stripped but the canvas stays editable, so an // embed must not write the host's sim_autosave, must not hand the hidden // controls back through the overflow menu at iframe widths, and its one From f63a432e47f6bfa4a99bc12ab4fe0b3ebd427230 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 31 Aug 2026 02:41:58 +0000 Subject: [PATCH 6/6] Give the canvas a live region and stop two controls announcing stale names Round-5 findings 13, 14 and 15. Keyboard selection moves inside the SVG, so DOM focus never leaves the canvas and assistive technology was told nothing at all: tabbing through a diagram was completely silent, and the properties panel that repaints is neither focused nor a live region. Tab and Shift+Tab now write what they landed on to a visually hidden polite live region: node type, label, current value, and position in the reading order. The zoom readout's visible text is updated live but its accessible name was static markup, so it announced 'Current zoom. Click to reset to 100%' at every zoom level. It now carries the live percentage. The timeline's replay button swaps its icon and its tooltip when replay starts but its aria-label was static markup too, so it announced 'Replay the run' while it was the Pause control. The name now follows the tooltip. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01SH7ouoNymubArm7VEgALHg --- css/style.css | 6 ++++++ index.html | 4 ++++ js/app.js | 9 ++++++++- js/editor.js | 17 ++++++++++++++++ test/smoke.js | 56 +++++++++++++++++++++++++++++++++++++++++++++++++++ 5 files changed, 91 insertions(+), 1 deletion(-) diff --git a/css/style.css b/css/style.css index 05bbb80..0f4a1df 100644 --- a/css/style.css +++ b/css/style.css @@ -753,6 +753,12 @@ input[type="range"] { background: transparent; border-color: transparent; } +/* Reachable by assistive technology, painted nowhere. */ +.sr-only { + position: absolute; width: 1px; height: 1px; margin: -1px; padding: 0; + overflow: hidden; clip: rect(0 0 0 0); clip-path: inset(50%); white-space: nowrap; border: 0; +} + /* ── Timeline panel ── */ #timeline { height: 200px; flex-shrink: 0; background: var(--panel); diff --git a/index.html b/index.html index b24ac2f..1c40ad1 100644 --- a/index.html +++ b/index.html @@ -292,6 +292,10 @@
+ +
diff --git a/js/app.js b/js/app.js index 3269820..05dacad 100644 --- a/js/app.js +++ b/js/app.js @@ -707,7 +707,10 @@ class App { if (!play) return; const on = !!this._scrubPlayTimer; play.replaceChildren(this._faIcon(on ? 'pause' : 'play')); + // The icon and the tooltip swapped but the accessible name was markup, so + // the control announced "Replay the run" while it was the Pause button. play.title = on ? 'Pause replay' : 'Replay the run'; + play.setAttribute('aria-label', play.title); } // Auto-advance through history at the current sim speed; stops at the end. @@ -1549,7 +1552,11 @@ class App { const zoomLabel = document.getElementById('btn-zoom-level'); zoomLabel.addEventListener('click', () => this.renderer.zoomTo(1)); this.renderer.onViewChange = (scale) => { - zoomLabel.textContent = `${Math.round(scale * 100)}%`; + const pct = Math.round(scale * 100); + zoomLabel.textContent = `${pct}%`; + // The visible readout is live but the accessible name was markup, so it + // announced "100%" at every zoom level. + zoomLabel.setAttribute('aria-label', `Current zoom ${pct}%. Click to reset to 100%`); this._minimap.update(); }; this.renderer.onViewChange(this.renderer._scale); diff --git a/js/editor.js b/js/editor.js index 992cdec..15c8f80 100644 --- a/js/editor.js +++ b/js/editor.js @@ -857,8 +857,25 @@ class Editor { // Select a node from the keyboard and make sure it can actually be seen. // Only pans when the node is outside the viewport: recentring on every step // would make the whole diagram lurch while tabbing between visible nodes. + // Say what the keyboard just landed on. Selection moves inside the SVG, so + // DOM focus never moves and assistive technology is told nothing at all: + // tabbing through a diagram was completely silent. The properties panel does + // repaint, but it is not focused and not a live region. + _announceSelection(node, index, total) { + const live = document.getElementById('canvas-live'); + if (!live || !node) return; + const name = node.label || node.type; + const value = node.chartValue; + const parts = [`${node.type} ${name}`]; + if (isFinite(value)) parts.push(String(value)); + parts.push(`${index + 1} of ${total}`); + live.textContent = parts.join(', '); + } + _keyboardGoTo(node) { if (!node) return; + const order = this._nodesInReadingOrder(); + this._announceSelection(node, order.findIndex(n => n.id === node.id), order.length); this._select(node.id, 'node'); const r = this.svg.getBoundingClientRect(); const sx = node.x * this.renderer._scale + this.renderer._panX; diff --git a/test/smoke.js b/test/smoke.js index 76b905a..bd42d8e 100644 --- a/test/smoke.js +++ b/test/smoke.js @@ -2588,6 +2588,62 @@ const URL = process.env.SMOKE_URL || 'http://localhost:8080/'; ok(`components: save selection (${comp.compNodes} nodes, ${comp.compConns} conn), insert adds 2 nodes, undo reverts`); else fail('components: ' + JSON.stringify(comp)); + // Accessibility: three controls whose accessible name has to track state. + const a11yNames = await (async () => { + await page.evaluate(() => { + const app = window.app; + app._clearAll(); app._closeFeature(); + ['Gold', 'Wood', 'Ore'].forEach((label, i) => { + const n = new MNode(NodeType.POOL, 200 + i * 150, 200); + n.label = label; n.setCount((i + 1) * 5); + app.diagram.addNode(n); + }); + app.renderer.render(); app._commit(); + app.editor._select(null, null); + document.getElementById('canvas').focus(); + }); + // Keyboard selection must be announced: DOM focus never leaves the . + await page.keyboard.press('Tab'); + const first = await page.evaluate(() => (document.getElementById('canvas-live') || {}).textContent || ''); + await page.keyboard.press('Tab'); + const second = await page.evaluate(() => (document.getElementById('canvas-live') || {}).textContent || ''); + const liveRegion = await page.evaluate(() => { + const el = document.getElementById('canvas-live'); + if (!el) return null; + const cs = getComputedStyle(el); + return { role: el.getAttribute('role'), live: el.getAttribute('aria-live'), w: Math.round(el.getBoundingClientRect().width) }; + }); + // Zoom readout: visible text is live, the accessible name was markup. + const zoom = await page.evaluate(() => { + const b = document.getElementById('btn-zoom-level'); + window.app.renderer.zoomStep(1.2); + const after = { text: b.textContent, label: b.getAttribute('aria-label') }; + window.app.renderer.zoomTo(1); + return after; + }); + // Replay button: icon and title swap, the accessible name did not. + const replay = await page.evaluate(() => { + const app = window.app; + app.engine.reset(); + for (let i = 0; i < 4; i++) app.engine.doStep(); + app._refreshScrubber(); + const b = document.getElementById('tl-play'); + const idle = b.getAttribute('aria-label'); + app._toggleScrubPlay(); + const playing = b.getAttribute('aria-label'); + app._toggleScrubPlay(); + return { idle, playing }; + }); + return { first, second, liveRegion, zoom, replay }; + })(); + if (a11yNames.liveRegion && a11yNames.liveRegion.live === 'polite' && a11yNames.liveRegion.w <= 1 + && /Gold/.test(a11yNames.first) && /1 of 3/.test(a11yNames.first) + && /Wood/.test(a11yNames.second) && /2 of 3/.test(a11yNames.second) + && a11yNames.zoom.text === '120%' && a11yNames.zoom.label.includes('120%') + && /replay/i.test(a11yNames.replay.idle) && /pause/i.test(a11yNames.replay.playing)) + ok('a11yNames: canvas keyboard selection is announced, zoom and replay names track state'); + else fail('a11yNames: ' + JSON.stringify(a11yNames)); + // Checkpoints: forking while the timeline is being replayed must leave replay // first. Scrub mode paints a past step's values over the live model, so the // canvas kept showing the replayed step's numbers under the checkpoint's step