From 8f577caffc979a0b02bc9d4106634162e7294c95 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 28 Aug 2026 19:41:19 +0000 Subject: [PATCH] Fix eleven defects from the second review Each reproduced before the change and verified after, with a test that fails without its fix. Two are regressions in last week's own work. A mid-run save dropped the authored colour mix whenever the total happened to be unchanged. b868dcc gated both the amount and the colour map behind one amount comparison, so a balanced loop, income and spend at matching rates, recorded neither: the total still read 20 at the moment of the write and the reload rebased {orange:20} to the {orange:14, green:6} that was in transit. The sameMap check written to catch exactly that drift sat inside the guard and never ran. The two drift independently, so they are tested independently now; at-rest files still carry neither field. A sparkline built while the timeline was scrubbed started live. 642ee0d taught _updateSparklines to push the scrub index into sparklines it already knew about, but a card built during a scrub, which is what selecting another node to see its earlier value does, created one at null. The hero read the replayed value over a trace of the whole run, the same card contradicting itself. The smoke test added with that fix selects before scrubbing, so it could not have caught this. A trader paying out of a delay or a queue created resources from nothing. Their contents are mirrored by an internal queue that releases on its own schedule; takeResources drew the count down and left it intact, so the same units were handed over and released again. Measured: a delay partner grew the system total from 25 to 36 over twelve steps while its queue owed 2 against a count of 1. Nothing in transit is available to trade, so it no longer pays. Receiving is untouched. Shift+Tab was a dead end on the canvas. Forward Tab past the last node released, backward from the first computed -1, clamped to 0 and reselected forever after preventDefault had eaten the key. Both ends release now. Adding or deleting a row in the Params or Checks rail rebuilt the panel and destroyed the focused button, dropping focus out of the panel. Add lands on the new row's name box, delete on the panel's add button. The mobile timeline drawer was 150px against 173 to 195px of content, so the entire scrub row sat below the fold with between zero and three usable pixels, and neither html, body nor the drawer scrolls. It fits its content now: measured 0px visible before and 39px after at three phone sizes, with the slider hit-testable. The concept guide had no responsive rule at all, so a 230px nav that will not shrink left a 69px article column on a 320px screen, roughly one word per line with the long words running past the modal edge. Stacked below 768px: 69px becomes 299px and the overflow goes to zero. Note text wrapped only on spaces and was never clipped, so a pasted URL was one 57-character word painted 193px past the note in near-black, over whatever sat to the right. Long words break, and the text is clipped to the note as a backstop, since counting characters cannot predict wide or CJK glyphs. Exported SVG and PNG dropped the class-only styles: no rule for .n-caption at all, so a converter's recipe rendered black on the dark background at 1.09 to 1 contrast and nearly twice its intended size, and font-family reached only elements that already carried the attribute, so node labels, badges and connection pills fell back to the browser serif. The gate guide described two of the three routing modes and claimed a gate stores nothing, both untrue since All mode: there the weights are not shares, each output takes its own weight in units. The panel's percentage rows and its weight help said the same thing and are corrected with it. The Library article pointed at a File menu entry that does not exist, and on touch the whole file-controls container is hidden anyway. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01SH7ouoNymubArm7VEgALHg --- css/style.css | 29 ++++++++++++- js/app-analysis.js | 16 ++++++- js/app-export.js | 15 +++++-- js/app-fields.js | 26 ++++++++++++ js/app-props.js | 24 +++++++++-- js/editor.js | 6 +++ js/engine.js | 12 ++++++ js/kb.js | 29 ++++++++----- js/model.js | 11 ++++- js/renderer.js | 30 ++++++++++++- test/run.js | 70 ++++++++++++++++++++++++++++++ test/smoke.js | 104 +++++++++++++++++++++++++++++++++++++++++++++ 12 files changed, 347 insertions(+), 25 deletions(-) diff --git a/css/style.css b/css/style.css index 3091d51..982f11c 100644 --- a/css/style.css +++ b/css/style.css @@ -1477,8 +1477,33 @@ input[type="range"] { #props-panel .prop-row input, #props-panel .prop-row select, #props-panel .wide-input, #props-panel .var-wide-input { min-height: 42px; } - /* The timeline gives the canvas room back on small screens. */ - #timeline { height: 150px; } + /* The timeline gives the canvas room back on small screens, but not so much + that its own controls fall off the bottom. At 150px the chart, the legend + and the scrub row wanted 173px with a single-row legend and 195px with two, + so the whole footer (Replay, the slider, the step label, Back to live) sat + below the fold with between zero and three usable pixels. html and body do + not scroll and #tl-resize is mousedown-only, so there was no way to reach + it. Fit the content instead of cropping it, and let the drawer scroll if a + long legend still overflows. */ + #timeline { height: auto; min-height: 150px; max-height: 46vh; overflow-y: auto; } + /* The scrub row is the reason the drawer exists on a phone; never let it + shrink away under a tall legend. */ + #tl-scrub { flex-shrink: 0; } + .tl-legend { max-height: 34px; } +} + +/* The concept guide is the whole in-app documentation, and on a phone it had no + responsive rule at all: .kb-body is a flex row and .kb-nav is a hard 230px + that will not shrink, so inside a 94vw modal the article column collapsed to + 109px on a 390px screen and 21px on a 320px one, roughly one word per line + with the longer words running past the modal edge. Stack the two instead, and + cap the nav so the article always gets the bulk of the height. */ +@media (max-width: 768px) { + .kb-body { flex-direction: column; } + .kb-nav { + width: auto; max-height: 30vh; + border-right: none; border-bottom: 1px solid var(--border); + } } /* Small phones (iPhone SE class). The touch topbar does not wrap, so at 320px diff --git a/js/app-analysis.js b/js/app-analysis.js index ee78f74..c891c7a 100644 --- a/js/app-analysis.js +++ b/js/app-analysis.js @@ -494,7 +494,11 @@ class AppAnalysis { del.style.cssText = 'padding:2px 8px;flex-shrink:0'; del.setAttribute('aria-label', 'Delete check'); del.appendChild(this._faIcon('xmark')); - del.addEventListener('click', () => { list.splice(i, 1); this._renderProps(); this._commit(); }); + del.addEventListener('click', () => { + list.splice(i, 1); + this._renderPropsFocused(() => this._panelAddButton()); + this._commit(); + }); row.appendChild(inp); row.appendChild(del); panel.appendChild(row); }); @@ -509,7 +513,15 @@ class AppAnalysis { const addBtn = document.createElement('button'); addBtn.textContent = '+ Add check'; addBtn.className = 'btn var-add-btn'; - addBtn.addEventListener('click', () => { list.push(''); this._renderProps(); this._commit(); }); + addBtn.addEventListener('click', () => { + list.push(''); + // The new check is the last row, and it is empty and waiting for input. + this._renderPropsFocused(() => { + const rows = document.querySelectorAll('#props-content .prop-row input[type="text"]'); + return rows[rows.length - 1]; + }); + this._commit(); + }); panel.appendChild(addBtn); this._section(panel, 'Check against a run'); diff --git a/js/app-export.js b/js/app-export.js index 0a3b65f..9ef1ac0 100644 --- a/js/app-export.js +++ b/js/app-export.js @@ -77,13 +77,22 @@ class AppExport { const font = tok('--font'); for (const el of out.querySelectorAll('[font-family]')) if (el.getAttribute('font-family').startsWith('var(')) el.setAttribute('font-family', font); + const mono = tok('--mono'); const style = svgEl('style'); style.textContent = [ '.n-count { fill: #fff; font-size: 13px; font-weight: 700; font-family: monospace; }', `.n-label, .n-badge, .grp-label { paint-order: stroke; stroke: ${tok('--bg')}; stroke-width: 3px; stroke-linejoin: round; }`, - `.n-label { fill: ${tok('--text')}; font-size: 11px; }`, - `.n-badge { fill: ${tok('--text-dim')}; font-size: 11px; }`, - `.conn-label { fill: ${tok('--text')}; }`, + // font-family belongs here, not only in the var() rewrite above: that loop + // only reaches elements that already carry the attribute, so node labels, + // badges and connection pills fell back to the browser serif in the file. + `.n-label { fill: ${tok('--text')}; font-size: 11px; font-family: ${font}; }`, + `.n-badge { fill: ${tok('--text-dim')}; font-size: 11px; font-family: ${font}; }`, + `.conn-label { fill: ${tok('--text')}; font-family: ${font}; }`, + // A converter's recipe caption carries no inline fill or size, so without + // a rule it took the SVG defaults: black at 16px on the exported #0d0e11 + // background, a contrast ratio of about 1.09 to 1, and overrunning the + // node's label zone at nearly twice the intended size. + `.n-caption { fill: ${tok('--text-dim')}; font-size: 9px; font-family: ${mono}; }`, ].join('\n'); out.insertBefore(style, defs); return { svg: out, w, h, bg }; diff --git a/js/app-fields.js b/js/app-fields.js index af557f2..f05fb5b 100644 --- a/js/app-fields.js +++ b/js/app-fields.js @@ -103,6 +103,13 @@ class AppFields { panel.appendChild(card); const sl = new Sparkline(card, node.id, this.engine); + // Inherit the scrub immediately. _updateSparklines pushes _scrubIndex into + // the sparklines it already knows about, but a card built DURING a scrub + // (selecting another node to see what it was doing at that step) created + // one at scrubIndex null, so the hero read the replayed value while the + // trace right below it plotted the whole run and labelled the end of it. + // The same card contradicting itself, which is what this was meant to end. + sl.scrubIndex = this._scrubIndex; this._sparklines.set(node.id, sl); sl.update(); return card; @@ -407,6 +414,25 @@ class AppFields { // ── Live update helpers ─────────────────────────────────────────────────── + // Rebuild the properties panel, then put focus somewhere deliberate. + // _renderProps() tears down #props-content, so a handler triggered from a + // button inside the panel destroys the very element that had focus and it + // falls back to : no focus ring, nothing announced, and getting back to + // the row costs a Tab press for every control that survived. pick() runs + // after the rebuild and returns the element to land on. + _renderPropsFocused(pick) { + this._renderProps(); + let el = null; + try { el = pick ? pick() : null; } catch { el = null; } + if (el && typeof el.focus === 'function') el.focus(); + } + + // The panel's own "+ Add" button, the sensible landing spot after a row is + // removed: it is where the user was working and it always exists. + _panelAddButton() { + return document.querySelector('#props-content .var-add-btn'); + } + // What the properties panel should display for a node right now. While the // timeline is scrubbed that is the recorded value at the previewed step, not // the live model. The canvas and the timeline both had a scrub-aware read diff --git a/js/app-props.js b/js/app-props.js index 694e2c1..ebf18e8 100644 --- a/js/app-props.js +++ b/js/app-props.js @@ -692,7 +692,11 @@ class AppProps { delBtn.setAttribute('aria-label', 'Delete parameter'); delBtn.className = 'btn'; delBtn.style.cssText = 'padding:2px 8px;flex-shrink:0'; - delBtn.addEventListener('click', () => { delete params[key]; this._renderProps(); this._commit(); }); + delBtn.addEventListener('click', () => { + delete params[key]; + this._renderPropsFocused(() => this._panelAddButton()); + this._commit(); + }); row.appendChild(ki); row.appendChild(vi); row.appendChild(delBtn); panel.appendChild(row); } @@ -704,7 +708,10 @@ class AppProps { let k = 'param' + (Object.keys(params).length + 1); while (params[k] !== undefined) k += '_'; params[k] = 0; - this._renderProps(); + // Land on the name box of the row that was just created, which is what + // the user is about to type into. + this._renderPropsFocused(() => + document.querySelector(`#props-content input[aria-label="Name of parameter ${k}"]`)); this._commit(); }); panel.appendChild(addBtn); @@ -1737,7 +1744,12 @@ class AppProps { name.textContent = `→ ${tgtNode ? (tgtNode.label || tgtNode.type) : '?'}`; const pct = document.createElement('span'); pct.className = 'gate-out-pct'; - pct.textContent = totalW > 0 ? `${Math.round(getW(c) / totalW * 100)}%` : '0%'; + // All mode is not a split: every output takes its full weight in units + // each step (engine _fireGate), so a share of the total is simply the + // wrong number and read as a promise the gate never made. + pct.textContent = node.gateMode === 'all' + ? `${Math.round(getW(c))}/step` + : (totalW > 0 ? `${Math.round(getW(c) / totalW * 100)}%` : '0%'); head.appendChild(name); head.appendChild(pct); card.appendChild(head); @@ -1939,7 +1951,11 @@ class AppProps { const n = parseFloat(v); conn.weight = isFinite(n) ? Math.max(0, n) : 0; this.renderer.render(); }, 'output share (0 = off)'); } - this._info(panel, 'Share of the gate\'s resources routed down this output (deterministic split or weighted chance). A formula is re-evaluated each step, so the split can track variables (e.g. difficulty, gold).'); + // The same overclaim the guide carried: in All mode the weight is not a + // share of anything, it is the number of units this output takes. + this._info(panel, src.gateMode === 'all' + ? 'Units routed down this output each step. In All mode every output takes its own weight independently, so these do not add up to a share. A formula is re-evaluated each step, so it can track variables (e.g. difficulty, gold).' + : 'Share of the gate\'s resources routed down this output (Split proportion or Random weighted chance). A formula is re-evaluated each step, so the split can track variables (e.g. difficulty, gold).'); } else if (fromDelay) { // Delays release matured resources, split across outputs by rate. rateField(); diff --git a/js/editor.js b/js/editor.js index e29f36a..f41df4a 100644 --- a/js/editor.js +++ b/js/editor.js @@ -944,7 +944,13 @@ class Editor { const order = this._nodesInReadingOrder(); const at = order.findIndex(n => n.id === this.renderer.selectedId); if (at === -1 && e.shiftKey) return; // nothing selected: Shift+Tab leaves + // Both ends release. Forward past the last node was handled; backward from + // the first was not, so `next` went to -1, clamped back to 0 and reselected + // the same node forever after preventDefault had already eaten the key. + // Backward focus order through the page was simply a dead end, and a user + // who merely still had a node selected got pulled into it. if (at === order.length - 1 && !e.shiftKey) { this._select(null, null); this.renderer.render(); return; } + if (at === 0 && e.shiftKey) { this._select(null, null); this.renderer.render(); return; } e.preventDefault(); const next = at === -1 ? 0 : at + (e.shiftKey ? -1 : 1); this._keyboardGoTo(order[Math.max(0, Math.min(order.length - 1, next))]); diff --git a/js/engine.js b/js/engine.js index 192199a..c1706f0 100644 --- a/js/engine.js +++ b/js/engine.js @@ -966,6 +966,18 @@ class SimEngine { const color = n.resourceColor || DEFAULT_COLOR; return (conn.colorFilter && color !== conn.colorFilter) ? 0 : Infinity; } + // A delay and a queue do not hold their contents freely: what they show is + // mirrored by an internal queue (_queue / _fifo) that releases on its own + // schedule. pay() below reaches for takeResources, which would draw the + // count down while leaving that queue untouched, so the same units are + // handed to the partner AND released again when their time comes. That + // creates resources out of nothing every step, silently, and the count and + // the queue drift apart (measured: a delay partner grew the system total + // from 25 to 36 over twelve steps while its queue owed 2 against a count + // of 1). Nothing in transit is available to trade, so it cannot pay. + // Receiving is unaffected: _give routes an arrival through the queue + // properly, so a delay or queue on the trader's far end still works. + if (n.type === NodeType.DELAY || n.type === NodeType.QUEUE) return 0; n.reconcile(); return conn.colorFilter ? (n.colorMap[conn.colorFilter] || 0) : n.resources; }; diff --git a/js/kb.js b/js/kb.js index 4143623..9788288 100644 --- a/js/kb.js +++ b/js/kb.js @@ -53,16 +53,22 @@ const KB_ARTICLES = [ { id: 'node-gate', category: 'Nodes', title: 'Gate', keywords: 'split route distribute weight deterministic probabilistic random branch', - body: 'A gate routes incoming resources to its outputs without storing ' - + 'anything itself. Each outgoing connection carries a weight, and the gate ' - + 'splits the flow by those weights normalized against their sum, so weights ' - + 'of three and one send three-quarters of the flow one way and one-quarter ' - + 'the other, and weights of 70, 22, 7 and 1 split it into 70%, 22%, 7% and ' - + '1%. In deterministic mode that proportional split happens every step. In ' - + 'probabilistic mode the gate instead sends each whole unit to a single ' - + 'output chosen at random, higher weights more likely to win and a weight of ' - + 'zero never chosen; over many units the shares converge on the same ' - + 'percentages. A weight can be a fixed number or a formula over diagram ' + body: 'A gate passes incoming resources straight out to its outputs rather ' + + 'than accumulating them, holding at most one step of inflow in transit. ' + + 'Each outgoing connection carries a weight, and what the gate does with ' + + 'those weights depends on its routing mode. There are three. In split ' + + 'mode the weights are normalized against their sum, so weights of three ' + + 'and one send three-quarters of the flow one way and one-quarter the ' + + 'other, and weights of 70, 22, 7 and 1 split it into 70%, 22%, 7% and 1%. ' + + 'In random mode the gate sends each whole unit to a single output chosen ' + + 'at random, higher weights more likely to win and a weight of zero never ' + + 'chosen; over many units the shares converge on the same percentages. In ' + + 'all mode the weights are not shares at all: every output takes its own ' + + 'weight in units each step, so weights of three and one send three units ' + + 'down one branch and one down the other, independently. If the gate does ' + + 'not hold enough to satisfy every output, the ones it reaches first are ' + + 'served and the remainder stays put until the next step. A weight can be ' + + 'a fixed number or a formula over diagram ' + 'variables, just like a connection rate. A formula weight is re-evaluated ' + 'each step, so the split can shift as the run unfolds. Route more flow down ' + 'the hard branch as difficulty climbs, for example. Switch a gate output ' @@ -679,7 +685,8 @@ const KB_ARTICLES = [ + 'way to learn how a loop is wired before building your own. Your saved ' + 'diagrams are entries you manage yourself: give the current canvas a name ' + 'and click Save diagram to keep it, then Load to restore it later. The ' - + 'Library is reachable from the toolbar or the File menu.', + + 'Library is reachable from the Library button in the toolbar, or from ' + + 'the menu button when the screen is narrow.', }, { id: 'saving', category: 'Saving and sharing', title: 'Saving and loading', diff --git a/js/model.js b/js/model.js index c1b704e..9ad5eee 100644 --- a/js/model.js +++ b/js/model.js @@ -490,8 +490,15 @@ class MNode { // ticking over — silently became the model's new starting amount. // Infinite sources are excluded: JSON cannot carry Infinity, and loadJSON // already restores their baseline from the node type. - if (isFinite(this._initialResources) && this._initialResources !== d.resources) { - d.initialResources = this._initialResources; + // The amount and the colour mix drift independently, so they are tested + // independently. Gating the map on the amount meant a balanced loop, income + // and spend at matching rates, never recorded either: the total still read + // 20 at the moment of the write, the guard was false, and the reload rebased + // the authored mix to whatever was in transit ({orange:20} coming back as + // {orange:14, green:6}). Any colour filter or converter recipe keyed on the + // authored colour then drew from a smaller pool than the model says it has. + if (isFinite(this._initialResources)) { + if (this._initialResources !== d.resources) d.initialResources = this._initialResources; const base = this._initialColorMap || {}; const bk = Object.keys(base); const sameMap = bk.length === Object.keys(this.colorMap).length diff --git a/js/renderer.js b/js/renderer.js index 68f68ab..ae5990c 100644 --- a/js/renderer.js +++ b/js/renderer.js @@ -840,7 +840,19 @@ class Renderer { _makeNoteEl(note) { const g = svgEl('g', { 'data-id': note.id, cursor: 'pointer' }); g.appendChild(svgEl('rect', { class: 'note-bg', rx: '4', 'stroke-width': '1.5' })); - g.appendChild(svgEl('text', { class: 'note-text', 'font-size': '11', 'font-family': 'var(--font)', 'pointer-events': 'none' })); + // Clip the text to the note. Wrapping alone cannot guarantee a fit: it + // counts characters against a width, so wide glyphs and CJK overrun a line + // the wrapper believes fits. Unclipped, that text painted outside the note + // in near-black on the dark canvas, and noteLayer sits above nodeLayer so it + // covered whatever was to the right. Belt and braces with the hard break in + // _wrapNoteText. + const clip = svgEl('clipPath', { id: `noteclip-${note.id}` }); + clip.appendChild(svgEl('rect', { class: 'note-clip-rect' })); + g.appendChild(clip); + g.appendChild(svgEl('text', { + class: 'note-text', 'font-size': '11', 'font-family': 'var(--font)', + 'pointer-events': 'none', 'clip-path': `url(#noteclip-${note.id})`, + })); return g; } @@ -872,6 +884,13 @@ class Renderer { textEl.appendChild(ts); }); textEl.setAttribute('fill', '#1a1a1a'); + const clipRect = el.querySelector('.note-clip-rect'); + if (clipRect) { + clipRect.setAttribute('x', String(note.x)); + clipRect.setAttribute('y', String(note.y)); + clipRect.setAttribute('width', String(Math.max(0, note.w))); + clipRect.setAttribute('height', String(Math.max(0, note.h))); + } this._updateResizeHandles(el, note, isSel); } @@ -884,6 +903,15 @@ class Renderer { let line = ''; for (const word of words) { if (!word) continue; + // A word longer than the line can never fit by moving it down, so break + // it. Without this a pasted URL was one 57-character "word" emitted as a + // single line that ran far past the note's edge. + if (word.length > maxChars) { + if (line) { result.push(line); line = ''; } + for (let i = 0; i < word.length; i += maxChars) result.push(word.slice(i, i + maxChars)); + line = result.pop(); + continue; + } if (line && line.length + 1 + word.length > maxChars) { result.push(line); line = word; } else { diff --git a/test/run.js b/test/run.js index 7c85780..f656386 100644 --- a/test/run.js +++ b/test/run.js @@ -734,6 +734,76 @@ test('a limited source emits its stock then runs dry', () => { eq(s.produced, 10, 'produced equals the emitted stock'); }); +test('a mid-run save keeps the authored colour mix when the total is unchanged', () => { + // The amount and the colour mix drift independently. Gating the map write on + // the amount meant a balanced loop (income and spend at matching rates) never + // recorded either, so the reload rebased the authored mix to whatever was in + // transit and any colour filter keyed on the authored colour drew from less + // than the model says it holds. + const ORANGE = '#ffa726', GREEN = '#4caf50'; + const d = new Diagram(); + const gold = new MNode(NodeType.POOL, 200, 100); gold.label = 'Gold'; gold.setCount(20, ORANGE); + const mine = new MNode(NodeType.SOURCE, 0, 100); mine.label = 'Mine'; mine.resourceColor = GREEN; + const spend = new MNode(NodeType.DRAIN, 400, 100); spend.label = 'Spend'; + d.addNode(gold); d.addNode(mine); d.addNode(spend); + d.addConnection(new MConnection(mine.id, gold.id, ConnectionType.RESOURCE)); + d.addConnection(new MConnection(gold.id, spend.id, ConnectionType.RESOURCE)); + + const e = new SimEngine(d); e.reset(); + for (let i = 0; i < 6; i++) e.doStep(); + eq(gold.resources, 20, 'the balanced loop leaves the total untouched'); + assert(gold.colorMap[GREEN] > 0, 'but the mix has drifted'); + + const back = new Diagram(); back.loadJSON(d.toJSON()); + const g2 = [...back.nodes.values()].find(n => n.label === 'Gold'); + const e2 = new SimEngine(back); e2.reset(); + eq(g2.colorMap[ORANGE], 20, 'reset after reload restores the authored colour'); + eq(g2.colorMap[GREEN], undefined, 'and holds none of what was in transit'); +}); + +test('an at-rest diagram still writes no baseline fields', () => { + // The point of the guard: files that never ran stay byte-identical. + const d = new Diagram(); + const p = new MNode(NodeType.POOL, 0, 0); p.label = 'Gold'; p.setCount(20, '#ffa726'); + const s = new MNode(NodeType.SOURCE, 100, 0); s.label = 'Mine'; + d.addNode(p); d.addNode(s); + d.addConnection(new MConnection(s.id, p.id, ConnectionType.RESOURCE)); + const before = JSON.stringify(d.toJSON()); + assert(!/initial(Resources|ColorMap)/.test(before), 'no baseline fields at rest'); + const e = new SimEngine(d); e.reset(); + eq(JSON.stringify(d.toJSON()), before, 'reset() alone changes nothing on disk'); +}); + +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 + // intact, handing the same units to the partner and releasing them again: + // resources created from nothing, every step. + const qlen = (n) => (n._queue ? n._queue.reduce((s, b) => s + b.amount, 0) : 0) + + (n._fifo ? n._fifo.reduce((s, b) => s + b.amount, 0) : 0); + + for (const partnerType of [NodeType.DELAY, NodeType.QUEUE, NodeType.POOL]) { + const d = new Diagram(); + const gold = new MNode(NodeType.POOL, 0, 0); gold.label = 'Gold'; gold.setCount(20); + const mkt = new MNode(NodeType.TRADER, 200, 0); mkt.label = 'Market'; + const ship = new MNode(partnerType, 400, 0); ship.label = 'Shipping'; ship.setCount(5); + const spent = new MNode(NodeType.DRAIN, 600, 0); spent.label = 'Spent'; + d.addNode(gold); d.addNode(mkt); d.addNode(ship); d.addNode(spent); + d.addConnection(new MConnection(gold.id, mkt.id, ConnectionType.RESOURCE)); + d.addConnection(new MConnection(mkt.id, ship.id, ConnectionType.RESOURCE)); + d.addConnection(new MConnection(ship.id, spent.id, ConnectionType.RESOURCE)); + + const e = new SimEngine(d); e.reset(); + const conserved = () => gold.resources + ship.resources + (spent.drained || 0); + const start = conserved(); + for (let i = 0; i < 12; i++) e.doStep(); + eq(conserved(), start, `${partnerType} partner: nothing is created or destroyed`); + if (partnerType !== NodeType.POOL) { + eq(qlen(ship), ship.resources, `${partnerType} partner: queue agrees with the count`); + } + } +}); + test('.econ round-trip survives a node labelled with a DSL head keyword', () => { // dslParse dispatches on tokens[0] before scanning for an arrow, so a node // labelled `pool` that is the SOURCE of a connection emitted `pool -> Gold` diff --git a/test/smoke.js b/test/smoke.js index 9b9e582..c7df6d1 100644 --- a/test/smoke.js +++ b/test/smoke.js @@ -495,6 +495,110 @@ const URL = process.env.SMOKE_URL || 'http://localhost:8080/'; ok('a colliding rename is rejected across the shared namespace, a real one still works'); else fail('rename guard: ' + JSON.stringify(renameGuard)); + // Round 2 findings. + + // Backward Tab had no counterpart to the forward release: at the first node it + // computed -1, clamped back to 0 and reselected forever after preventDefault. + const backTab = await page.evaluate(() => { + window.app._clearAll(); + window.app._closeFeature(); + const d = window.app.diagram; + d.addNode(new MNode(NodeType.POOL, 200, 200)); + d.addNode(new MNode(NodeType.POOL, 400, 300)); + window.app.editor.setTool('select'); + window.app.renderer.render(); + const canvas = document.getElementById('canvas'); + canvas.focus(); + const tab = (shift) => window.dispatchEvent( + new KeyboardEvent('keydown', { key: 'Tab', shiftKey: shift, bubbles: true })); + tab(false); // select the first node + const selected = window.app.renderer.selectedId; + tab(true); // must release, not reselect + return { selected: !!selected, afterBack: window.app.renderer.selectedId }; + }); + if (backTab.selected && backTab.afterBack === null) + ok('Shift+Tab releases backward off the first node instead of reselecting it'); + else fail('backward tab: ' + JSON.stringify(backTab)); + + // Adding or deleting a rail row rebuilds #props-content, destroying the button + // that had focus and dropping it out of the panel entirely. + const rowFocus = await page.evaluate(() => { + window.app._clearAll(); + window.app._closeFeature(); + window.app.diagram.params = { alpha: 1, beta: 2 }; + window.app.renderer.render(); + window.app._commit(); + document.querySelector('#diagram-rail .rail-btn[data-feature="params"]').click(); + const label = () => { + const a = document.activeElement; + return a.tagName + (a.getAttribute && a.getAttribute('aria-label') ? `[${a.getAttribute('aria-label')}]` : ''); + }; + document.querySelector('#props-content .var-add-btn').click(); + const afterAdd = label(); + document.querySelector('#props-content button[aria-label="Delete parameter"]').click(); + const afterDelete = label(); + return { afterAdd, afterDelete }; + }); + if (/Name of parameter/.test(rowFocus.afterAdd) && rowFocus.afterDelete.startsWith('BUTTON')) + ok('adding or deleting a rail row keeps focus inside the panel'); + else fail('row focus: ' + JSON.stringify(rowFocus)); + + // Note text wrapped only on spaces and was never clipped, so a pasted URL ran + // far outside the note in near-black, over whatever sat to the right. + const noteFit = await page.evaluate(() => { + window.app._clearAll(); + window.app._closeFeature(); + const n = window.app.diagram.addNote(new MNote(200, 200)); + n.w = 160; n.h = 80; + n.text = 'Rebalance per https://example.com/economy/balance-notes/2026-08-28-draft'; + window.app.renderer.render(); + const el = document.querySelector('.sticky-note .note-text'); + const bb = el.getBBox(); + return { + spill: Math.round((bb.x + bb.width) - (n.x + n.w)), + clipped: !!el.getAttribute('clip-path'), + lines: el.querySelectorAll('tspan').length, + }; + }); + if (noteFit.spill <= 0 && noteFit.clipped && noteFit.lines > 1) + ok('a long unbroken word in a note wraps and stays inside its bounds'); + else fail('note fit: ' + JSON.stringify(noteFit)); + + // The export style block omitted .n-caption entirely (black on the exported + // dark background) and only set font-family on elements already carrying it. + const expStyles = await page.evaluate(() => { + window.app._clearAll(); + window.app._closeFeature(); + const c = window.app.diagram.addNode(new MNode(NodeType.CONVERTER, 300, 300)); + c.inputAmount = 2; + window.app.renderer.render(); + const txt = new XMLSerializer().serializeToString(window.app._buildExportSVG().svg); + return { + caption: /\.n-caption\s*\{/.test(txt), + captionBlack: /\.n-caption[^}]*fill:\s*(#000|black)/i.test(txt), + labelFont: /\.n-label[^}]*font-family/.test(txt), + }; + }); + if (expStyles.caption && !expStyles.captionBlack && expStyles.labelFont) + ok('exported SVG carries the caption rule and a real font for every label'); + else fail('export styles: ' + JSON.stringify(expStyles)); + + // The gate guide described only two of the three routing modes and claimed a + // gate stores nothing, both false once All mode exists. + const gateDocs = await page.evaluate(() => { + const gate = KB_ARTICLES.find(a => a.id === 'node-gate'); + const lib = KB_ARTICLES.find(a => /library/i.test(a.id) || /Library/i.test(a.title)); + return { + mentionsAll: /all mode/i.test(gate.body), + claimsNoStore: /without storing/i.test(gate.body), + libFileMenu: lib ? /File menu/i.test(lib.body) : true, + emDash: KB_ARTICLES.some(a => a.body.includes('\u2014')), + }; + }); + if (gateDocs.mentionsAll && !gateDocs.claimsNoStore && !gateDocs.libFileMenu && !gateDocs.emDash) + ok('guide describes all three gate modes and points at controls that exist'); + else fail('kb copy: ' + JSON.stringify(gateDocs)); + // Navigation: zoom controls step the scale and update the readout; fit-to-content // re-frames without error. const nav = await page.evaluate(() => {