diff --git a/css/style.css b/css/style.css index 2c25df2..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); @@ -1538,7 +1544,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/index.html b/index.html index b24ac2f..1c40ad1 100644 --- a/index.html +++ b/index.html @@ -292,6 +292,10 @@
+ +
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..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'; @@ -1468,6 +1488,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 +1516,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/app.js b/js/app.js index 0ddacd0..05dacad 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,11 +516,31 @@ 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 // 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 */ } } @@ -530,7 +550,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 +580,7 @@ class App { } undo() { + this.editor.flushPending(); if (!this._undoStack.length) return; this._redoStack.push(this._lastState); this._lastState = this._undoStack.pop(); @@ -569,6 +590,7 @@ class App { } redo() { + this.editor.flushPending(); if (!this._redoStack.length) return; this._undoStack.push(this._lastState); this._lastState = this._redoStack.pop(); @@ -685,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. @@ -772,7 +797,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); @@ -1509,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 f41df4a..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; @@ -1032,10 +1049,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/js/engine.js b/js/engine.js index bc3d976..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; }; @@ -1519,8 +1546,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..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 @@ -826,6 +911,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..bd42d8e 100644 --- a/test/smoke.js +++ b/test/smoke.js @@ -2588,6 +2588,288 @@ 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 + // 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 + // 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 + // 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 + // 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.