Consolidate duplicated editor code, trim comments, refresh README

Split GraphCanvas (1076 -> 831 lines) into EdgeLayer and graphMenus;
the port and marker menus were the same list and are now one function.
Value specs of the inspector and the list tab share valueSpecs.ts, and
the remaining copies (compound commands, type glyphs, typed asset
lookup, name rule, play/editor world setup, marquee) are merged.

Four copies had already drifted apart; merging them fixes:
- "Disconnect N links" in the port menu is one undo step, not N
- "+ Add collider" is hidden on a placed container instance, as in
  the hierarchy menu, instead of always failing
- a bool property coerced from the text "false" becomes false
- a port command on an unknown node type reports instead of crashing

Comment-only edits leave the bundle byte-identical. README corrected in
35 places (step limit, jump-run clock, suite duration, 409 body, ...).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
rootandClaude Opus 5 committed 2026-09-11 11:00:30 +02:00
1 parent 9212e61c4c
commit 8f92a06c84
96 files changed
+999 -1261

No files matched your search

-1
View File
@@ -121,7 +121,6 @@ export async function inspector(t) {
check('viewport drag moved the red crate', o1Now.x > -150, JSON.stringify(o1Now));
await shot('06-dragged');
// 5. Autosave → server.
await sleep(1800);
const saved = await api(`projects/${projectId}`);
const renamed = sceneOf(saved).objects.find((o) => o.id === 'o2');
+24 -3
View File
@@ -8,6 +8,7 @@ export async function graph(t) {
sleep,
evaluate,
json,
find,
shot,
click,
clickText,
@@ -70,9 +71,8 @@ export async function graph(t) {
`${axisUndone.x},${axisUndone.y}`,
);
// 13b. Regression: typing a query and then *clicking* a row must add exactly one node.
// (The search field's blur-commit once picked the first match and the row click picked
// it again → two nodes.)
// 13b. Typing a query and then *clicking* a row adds exactly one node: the search field's
// blur-commit must not pick the first match as well.
const gcenter = await json('__gm.graph.center()');
const beforeClickPick = await graphCounts();
await click(gcenter.x + 250, gcenter.y + 250, 'right');
@@ -742,6 +742,27 @@ export async function graph(t) {
await undo();
await sleep(200);
const forkedNow = async () =>
(await asset('mover')).graph.edges.filter(
(e) => e.from.node === edge.from.node && e.from.port === edge.from.port,
).length;
const portNow = await json(
`__gm.graph.portAt(${JSON.stringify(edge.from.node)}, ${JSON.stringify(edge.from.port)}, true)`,
);
await click(portNow.x, portNow.y, 'right');
await sleep(150);
const offered = await find('Disconnect 2 links');
if (offered) await click(offered.x, offered.y);
await sleep(250);
const cut = await forkedNow();
await undo();
await sleep(250);
check(
'disconnecting both links at a port is one undo step',
Boolean(offered) && cut === 0 && (await forkedNow()) === 2,
JSON.stringify({ offered: Boolean(offered), cut, after: await forkedNow() }),
);
// 13q. Collapsing a node: the title bar alone, every link meeting at one point per side. No new
// link lands on it until it is expanded, and the state is saved with the project.
await select({ kind: 'asset', assetId: 'mover' });
+22 -13
View File
@@ -2,13 +2,8 @@ import { instanceChild } from '../lib/builders.mjs';
import { same } from '../lib/harness.mjs';
/**
* Multi-selection and placed containers.
*
* A container placed in a scene is not copied into it: the scene stores the container object
* alone and its content is resolved from the template on every read, so a template edit reaches
* every instance at once (see `src/core/model/instance.ts`). That is what the checks below are
* about – what the scene *holds* against what the editor *lists*, deviations kept per instance,
* and the structure of resolved content being the template's business and nothing else's.
* Multi-selection and placed containers: what the scene *holds* (the container object alone) against
* what the editor *lists* (content resolved from the template), and deviations kept per instance.
*/
export async function containers(t) {
const {
@@ -16,6 +11,7 @@ export async function containers(t) {
sleep,
evaluate,
json,
has,
shot,
click,
clickText,
@@ -41,7 +37,6 @@ export async function containers(t) {
projectId,
} = t;
const center = await json('__gm.viewportCenter()');
const has = (text) => evaluate(`Boolean(__gm.locate(${JSON.stringify(text)}))`);
/** Types a value into an inspector field, which runs the real command behind that row. */
const setField = async (label, value) => {
const row = await locate(label);
@@ -129,10 +124,8 @@ export async function containers(t) {
await undo();
check('undo restores all of them', (await objects()).length === listedBeforeDelete);
// 16d. Create container: both crates, with their colliders and the Mover, become the content
// of a new container asset and *leave the scene*. The scene stores one object in their
// stead; the five members are only resolved from the template from now on, which is the
// whole difference to copying them in – see `src/core/model/instance.ts`.
// 16d. Create container: both crates, their colliders and the Mover *leave the scene*; one stored
// container object stands in their stead and the five are resolved from its template.
const assetsBefore = (await json('__gm.state.project.assets')).length;
const storedBefore = (await storedObjects()).length;
const listedBefore = (await objects()).length;
@@ -306,7 +299,23 @@ export async function containers(t) {
await selectObject(redA);
check('the inspector offers no collider for resolved content', !(await has('+ Add collider')));
await selectObject(instanceA.id);
check('but it does for the stored container object', await has('+ Add collider'));
check(
'nor for the placed container itself, which the command refuses as the hierarchy menu does',
!(await has('+ Add collider')),
);
const plainId = await json(`(() => {
const p = __gm.state.project;
const sprite = __gm.state.activeScene.objects.find(
(o) => p.assets.find((a) => a.id === o.assetId)?.type === 'sprite',
);
return sprite ? sprite.id : null;
})()`);
await selectObject(plainId);
check(
'while a plain sprite still gets it',
Boolean(plainId) && (await has('+ Add collider')),
JSON.stringify({ plainId }),
);
await selectObject(redA);
let noticeAfter = await notice();
await press('Delete');
+1 -1
View File
@@ -5,6 +5,7 @@ export async function textAndEnabled(t) {
sleep,
evaluate,
json,
has,
shot,
click,
clickText,
@@ -16,7 +17,6 @@ export async function textAndEnabled(t) {
stop,
typeText,
} = t;
const has = (text) => evaluate(`Boolean(__gm.locate(${JSON.stringify(text)}))`);
// 17. Text assets: created through the + menu, placed, and rendered as real text in the scene.
await clickText('+');
+2 -4
View File
@@ -43,10 +43,8 @@ export async function menus(t) {
await shot('31-long-parent-menu');
// 18a. The entries scroll under the wheel; the search box above them does not move.
// Anchored on a filler row: "None" is also what an empty dropdown reads, so locating it could
// find an inspector field behind the menu instead – one that never moves. Which filler is asked
// for is read off the open menu: the capped list only draws the rows it shows, and how many
// objects come before them depends on what the earlier steps left in the scene.
// Anchored on a filler row read off the open menu: "None" could match an inspector field behind it,
// and which fillers are drawn depends on what earlier steps left in the scene.
const visibleFillers = (await json('__gm.overlayTexts()')).filter((e) => e.startsWith('Filler '));
const anchor = visibleFillers[visibleFillers.length - 1];
check('the picker lists the filler objects', Boolean(anchor), JSON.stringify(visibleFillers));
+8 -20
View File
@@ -1,3 +1,5 @@
import { colourDistance } from '../lib/harness.mjs';
/**
* Uploading a picture and wearing it. Runs last because it adds an asset and repaints the
* scene; the checks that count assets live in earlier steps.
@@ -36,13 +38,6 @@ const PASTE_SPARE = `(async () => {
window.dispatchEvent(new ClipboardEvent('paste', { clipboardData: transfer, bubbles: true }));
})()`;
function colourDistance(a, b) {
const channels = (hex) => [1, 3, 5].map((i) => parseInt(hex.slice(i, i + 2), 16));
const [ar, ag, ab] = channels(a);
const [br, bg, bb] = channels(b);
return Math.abs(ar - br) + Math.abs(ag - bg) + Math.abs(ab - bb);
}
export async function textures(t) {
const { check, sleep, json, evaluate, shot, mutate, press } = t;
const { api, click, clickText, doubleClick, locate, undo } = t;
@@ -143,8 +138,7 @@ export async function textures(t) {
await shot('32-textured-sprite');
// 19b-i. Switching the Filter property takes effect at once, without a reload. Read off the
// screen rather than off `scaleMode`: the property was always assigned correctly, it was the
// GPU sampler behind it that used to survive the change.
// screen rather than off `scaleMode`, because the GPU sampler can miss a correctly assigned value.
const enlarge = (w, h) => `
const sprite = __gm.state.project.assets.find((a) => a.id === 'tex-sprite');
sprite.props.width = ${w};
@@ -162,10 +156,8 @@ export async function textures(t) {
}
return points;
};
// Read the pixels first, then ask the driver what it actually has: a failure then says by
// itself whether the model never switched, the switch missed the GPU sampler, or the pixels
// were measured wrong. This check has fallen over sporadically without any of the three ever
// being distinguishable from the message.
// Pixels plus what the driver has, so a failure says whether the model, the GPU sampler or the
// measurement is wrong.
const sample = async (value) => {
await mutate(
`__gm.state.project.assets.find((a) => a.id === ${JSON.stringify(texture.id)}).props.filter = ${JSON.stringify(value)}`,
@@ -184,9 +176,7 @@ export async function textures(t) {
);
const soft = await sample('linear');
// The drift — that the picture actually changed — is what makes this a test of the switch.
// Were the switch to hang, both readings would be the blurred one, and "some pixel is blended"
// alone would pass and hide the failure of the check above. Measured on the recorded readings:
// The drift proves the switch: with a hung one, "some pixel is blended" alone would pass. Measured:
// a working switch drifts 425, a hung one 0, and 30 even if every channel were off by two.
const drift = hard.pixels.reduce((sum, c, i) => sum + colourDistance(c, soft.pixels[i]), 0);
check(
@@ -219,10 +209,8 @@ export async function textures(t) {
);
}
// 19e. "+ → Texture" offers the pictures the server already holds instead of the machine's
// file system. Nothing collects unreferenced files, so a picture one project dropped is
// still there for the next – which is exactly what this drives: paste a second picture,
// undo the asset (the file stays), then pick that file out of the dialog.
// 19e. "+ → Texture" offers the pictures the server already holds, unreferenced ones included:
// paste a second picture, undo the asset (the file stays), then pick that file in the dialog.
await evaluate(PASTE_SPARE);
await sleep(2500);
const two = await json("__gm.state.project.assets.filter((a) => a.type === 'texture').map((a) => a.file)");
+2 -7
View File
@@ -1,3 +1,5 @@
import { colourDistance } from '../lib/harness.mjs';
/**
* The Texture tab: cutting frames out of a sheet, and a sprite wearing one.
*
@@ -5,13 +7,6 @@
* of its own – that is what makes "which part of the sheet is on screen" a `pixelAt` question.
*/
function colourDistance(a, b) {
const channels = (hex) => [1, 3, 5].map((i) => parseInt(hex.slice(i, i + 2), 16));
const [ar, ag, ab] = channels(a);
const [br, bg, bb] = channels(b);
return Math.abs(ar - br) + Math.abs(ag - bg) + Math.abs(ab - bb);
}
/** The four quadrants of the sheet the texture step pastes, plus the sprite's own white. */
const PALETTE = {
red: '#e63c3c',
+3 -7
View File
@@ -1,9 +1,6 @@
/**
* Labels must be shortened to fit their control, not written past it. Runs last because it
* renames assets to absurd lengths to provoke the case.
*
* `__gm.textOverflows()` walks every visible text and reports the ones drawn wider than their
* widget; against a build without the fix it reported the graph picker at 106 px over.
* Labels must be shortened to fit their control, not written past it. Runs last because it renames
* assets to absurd lengths to provoke the case.
*/
/** A name far wider than any 200 px picker, so a control that does not truncate must spill. */
@@ -31,8 +28,7 @@ export async function overflow(t) {
__gm.state.select(null)`);
await sleep(600);
// 21. The pickers keep the width they were given. `width` used to set only a minimum, so a
// picker grew with whatever name it happened to show and the header reflowed on load.
// 21. The pickers keep the width they were given instead of growing with the name they show.
const after = await pickerWidths();
for (const tab of ['graph', 'texture', 'container']) {
check(
+2 -7
View File
@@ -1,11 +1,6 @@
/**
* The ↔ / ↕ buttons beside a sprite's Width and Height: fit that side to the proportions of the
* picture the sprite wears, set both sides to its pixel size, and not exist at all while it wears
* no picture. With both on offer the button is the menu form "↔ ▾", which is what the checks
* below distinguish.
*
* Runs on the sheet the texture steps imported (64 × 48) and adds a frame with a deliberately
* different shape, which is what makes "sheet or frame?" a question the numbers can answer.
* The ↔ / ↕ buttons beside a sprite's Width and Height (absent without a picture). Runs on the imported
* 64 × 48 sheet plus a frame of a different shape, so "sheet or frame?" shows in the numbers.
*/
/** The rows are read as numbers, so a float from a division needs a tolerance. */
+2 -6
View File
@@ -1,12 +1,8 @@
import { instanceChild } from '../lib/builders.mjs';
/**
* The Container tab: editing a container's template with the scene view's own tools.
*
* Runs after the container step, which created a container asset from two crates and left two
* instances of it in the scene. Neither of them holds a copy: what they show is resolved from
* the template on every read, which is what makes "which instance follows a template edit"
* a question worth asking.
* The Container tab: editing a template with the scene view's tools. Relies on the container step
* having left two instances of a two-crate container in the scene.
*/
const NAME = 'Crate pair';
+9 -49
View File
@@ -1,4 +1,5 @@
import { edge, node } from '../lib/builders.mjs';
import { MAKE_OBJECT } from './lib/scaffold.mjs';
/**
* "Run in editor": a behavior that keeps running while the project is not playing, and whose
@@ -49,7 +50,6 @@ export async function editorRun(t) {
await mutate(`
const p = __gm.state.project;
const scene = __gm.state.activeScene;
const proto = scene.objects[0];
const asset = structuredClone(p.assets.find((a) => a.id === 'mover'));
Object.assign(asset, {
id: 'drifter',
@@ -59,20 +59,8 @@ export async function editorRun(t) {
graph: ${JSON.stringify(driftGraph)},
});
p.assets.push(asset);
const make = (id, assetId, parentId, y) => {
const o = structuredClone(proto);
Object.assign(o, {
id,
assetId,
name: id === 'er-box' ? 'Drift box' : 'Drifter',
position: { x: 0, y, z: 0 },
parentId,
overrides: {},
instanceEdits: {},
});
return o;
};
scene.objects.push(make('er-box', 'crate', '', 400), make('er-logic', 'drifter', 'er-box', 0));
${MAKE_OBJECT}
scene.objects.push(make('er-box', 'crate', '', 400, 'Drift box'), make('er-logic', 'drifter', 'er-box', 0, 'Drifter'));
`);
await sleep(400);
@@ -166,10 +154,8 @@ export async function editorRun(t) {
`${JSON.stringify(stopped)} x=${stoppedAt}`,
);
// 18f. Undo covers the two clicks on the switch – but not a millimetre of what the behavior did
// while it was on. That is the price of editing the model directly, better stated here than
// discovered later. Note the first undo takes back the *switching off*, so the behavior
// briefly runs again between the two: undo rewinds the switch, never the clock.
// 18f. Undo covers the two clicks on the switch but nothing the behavior did while on. The first
// undo re-enables it, so it briefly runs again: undo rewinds the switch, never the clock.
await undo();
await undo();
await sleep(300);
@@ -188,20 +174,7 @@ export async function editorRun(t) {
await mutate(`
const p = __gm.state.project;
const scene = __gm.state.activeScene;
const proto = scene.objects[0];
const make = (id, assetId, parentId, y) => {
const o = structuredClone(proto);
Object.assign(o, {
id,
assetId,
name: '',
position: { x: 0, y, z: 0 },
parentId,
overrides: {},
instanceEdits: {},
});
return o;
};
${MAKE_OBJECT}
p.assets.push({
id: 'er-level',
type: 'container',
@@ -246,26 +219,13 @@ export async function editorRun(t) {
await mutate(`
const p = __gm.state.project;
const scene = __gm.state.activeScene;
const proto = scene.objects[0];
const asset = structuredClone(p.assets.find((a) => a.id === 'drifter'));
Object.assign(asset, { id: 'walker', name: 'Walker', graph: ${JSON.stringify(walkGraph)} });
p.assets.push(asset);
const make = (id, assetId, parentId, y, overrides) => {
const o = structuredClone(proto);
Object.assign(o, {
id,
assetId,
name: '',
position: { x: 0, y, z: 0 },
parentId,
overrides,
instanceEdits: {},
});
return o;
};
${MAKE_OBJECT}
scene.objects.push(
make('er-walkbox', 'crate', '', 1200, {}),
make('er-walker', 'walker', 'er-walkbox', 0, { runInEditor: true }),
make('er-walkbox', 'crate', '', 1200),
Object.assign(make('er-walker', 'walker', 'er-walkbox', 0), { overrides: { runInEditor: true } }),
);
`);
await sleep(600);
+5 -10
View File
@@ -2,12 +2,8 @@ import { edge, node } from '../lib/builders.mjs';
import { LANES, scaffoldBehavior } from './lib/scaffold.mjs';
/**
* Pausing and resuming play: the world stops where it is, the clock stops with it, and input that
* arrives while it is held is dropped instead of firing all at once on resume.
*
* The step brings its own box and its own behavior. The fixture's Mover needs the arrow keys to
* move at all, so a hold would be indistinguishable from simply not pressing anything; this one
* drifts on its own. Everything it adds is removed again at the end.
* Pausing freezes world and clock and drops input instead of replaying it on resume. Brings its own
* self-drifting box, since the fixture's Mover only moves on arrow keys and a pause would look like idling.
*/
const DRIFT_PER_SECOND = 100;
@@ -35,7 +31,7 @@ const pauseGraph = {
};
export async function pause(t) {
const { check, sleep, json, shot, clickText, press, entity, world, stop } = t;
const { check, sleep, json, has, shot, clickText, press, entity, world, stop } = t;
const playInfo = () => json('__gm.playInfo()');
const boxX = async () => (await entity('p-box')).x;
const keyHits = async () => (await world()).log.filter((line) => line.includes(KEY_LOG)).length;
@@ -82,11 +78,10 @@ export async function pause(t) {
);
// `locate` throws on a missing label, so the button that must be *gone* is asked for through
// the hook, which answers with null.
const labelShown = (text) => json(`__gm.locate(${JSON.stringify(text)}) !== null`);
check(
'the button offers to resume while it is held, and no longer to pause',
(await labelShown('▶ Resume')) === true && (await labelShown('❚❚ Pause')) === false,
`resume=${await labelShown('▶ Resume')} pause=${await labelShown('❚❚ Pause')}`,
(await has('▶ Resume')) === true && (await has('❚❚ Pause')) === false,
`resume=${await has('▶ Resume')} pause=${await has('❚❚ Pause')}`,
);
await shot('36-paused');
+5 -14
View File
@@ -1,4 +1,5 @@
import { edge, node, port } from '../lib/builders.mjs';
import { MAKE_OBJECT } from './lib/scaffold.mjs';
/**
* A behavior used as a node inside another behavior's graph: the ports it grows from the other
@@ -72,7 +73,6 @@ export async function nestedBehavior(t) {
await mutate(`
const p = __gm.state.project;
const scene = __gm.state.activeScene;
const proto = scene.objects[0];
const mover = p.assets.find((a) => a.id === 'mover');
// At the front of the list, not the end: these get dragged out of the asset panel below, and
// by this point in the suite the list is long enough to scroll a row at the end out of reach.
@@ -84,16 +84,8 @@ export async function nestedBehavior(t) {
behavior('nb-double', 'Double', ${JSON.stringify(doubleGraph)});
behavior('nb-caller', 'Caller', ${JSON.stringify(callerGraph)});
behavior('nb-empty', 'Empty', { nodes: [], edges: [] });
const make = (id, assetId, parentId, name) => {
const o = structuredClone(proto);
Object.assign(o, {
id, assetId, name,
position: { x: 0, y: 700, z: 0 },
parentId, overrides: {}, instanceEdits: {},
});
return o;
};
scene.objects.push(make('nb-box', 'crate', '', 'Nested box'), make('nb-logic', 'nb-caller', 'nb-box', 'Caller'));
${MAKE_OBJECT}
scene.objects.push(make('nb-box', 'crate', '', 700, 'Nested box'), make('nb-logic', 'nb-caller', 'nb-box', 700, 'Caller'));
`);
await sleep(400);
@@ -235,8 +227,7 @@ export async function nestedBehavior(t) {
`dropped at ${JSON.stringify(droppedAt)}, let go at ${JSON.stringify(dropAt)}`,
);
// 19h. The question from the note: a behavior with no start node can still be added – and now it
// says why nothing happens instead of sitting there looking healthy.
// 19h. A behavior with no start node can still be added, but says why nothing happens.
const beforeEmpty = await nodeIds();
const emptyRow = await json(`__gm.assetRow('nb-empty')`);
await t.dragAsset('nb-empty', spot(0.6, 0.75).x, spot(0.6, 0.75).y);
@@ -275,7 +266,7 @@ export async function nestedBehavior(t) {
`nodes ${beforeSelf.length} → ${afterSelf.length}, notice=${JSON.stringify(notice)} row=${JSON.stringify(selfRow)} dragging=${JSON.stringify(dragging)}`,
);
// 19j. Double-clicking a call opens the behavior it runs – the other half of the note.
// 19j. Double-clicking a call opens the behavior it runs.
const callNode = await json(`__gm.graph.nodeAt('nb-call')`);
check('the graph shown is still the caller', (await evaluate('__gm.graph.current()')) === 'nb-caller');
await t.doubleClick(callNode.x, callNode.y);
+2 -6
View File
@@ -2,12 +2,8 @@ import { edge, node } from '../lib/builders.mjs';
import { LANES, scaffoldBehavior } from './lib/scaffold.mjs';
/**
* The auto-repeat of `On key down`: a held key fires again after a delay and then on its own
* interval, which is what lets a Tetris piece slide sideways on one press.
*
* The step brings its own box and behavior with *two* `On key down` nodes on the same key – one
* plain, one repeating. One hold then measures both at once, so "it repeated" cannot be confused
* with "the key arrived more often than expected". Everything it adds is removed again at the end.
* The auto-repeat of `On key down`. A plain and a repeating node on the same key are measured in one
* hold, so "it repeated" cannot be confused with "the key arrived more often than expected".
*/
const PLAIN = 'plain hit';
+3 -6
View File
@@ -3,10 +3,9 @@
* tab, and there a row per item edits, adds and removes them with the inspector's own widgets.
*/
export async function listTab(t) {
const { check, sleep, json, shot, click, clickText, press, typeText, mutate, undo } = t;
const { check, sleep, json, find, shot, click, clickText, press, typeText, mutate, undo } = t;
/** `locate` throws when the text is absent, which is the wrong shape for "is it there?". */
const find = (text) => json(`__gm.locate(${JSON.stringify(text)}, false)`);
// A throwaway behavior carrying one list variable; removed again at the end of the step. Its
// items are two digits so that no value can be confused with a row's index label.
@@ -30,7 +29,6 @@ export async function listTab(t) {
check('the editor is still on the scene tab', (await json('__gm.tab()')) === 'scene');
await shot('01-count-button');
// 2. Clicking it opens the List tab on that very list.
await click(button.x, button.y);
await sleep(200);
check('clicking it switches to the List tab', (await json('__gm.tab()')) === 'list');
@@ -75,7 +73,6 @@ export async function listTab(t) {
JSON.stringify(shortRows),
);
// 5. Adding and removing whole items.
await clickText('+ Item');
await sleep(200);
info = await json('__gm.list.info()');
@@ -249,8 +246,8 @@ export async function listTab(t) {
JSON.stringify(await cellsOf()),
);
// 9. A node's unconnected `list` input carries its value in the very same params bag a setting
// does, so it is a count button too – before this it offered a number field.
// 9. A node's unconnected `list` input lives in the same params bag as a setting, so it is a count
// button too.
await mutate(`
const a = __gm.state.project.assets.find((x) => x.id === 'celly');
a.graph.nodes.push({ id: 'counter', type: 'Count', x: 40, y: 40, params: {} });
+1 -2
View File
@@ -3,10 +3,9 @@
* same row, and writing one field keeps the others – the trap a shared snapshot would fall into.
*/
export async function structVariable(t) {
const { check, sleep, json, shot, click, clickText, press, typeText, mutate, select, undo } = t;
const { check, sleep, json, find, shot, click, clickText, press, typeText, mutate, select, undo } = t;
/** `locate` throws when the text is absent, which is the wrong shape for "is it there?". */
const find = (text) => json(`__gm.locate(${JSON.stringify(text)}, false)`);
const piece = () => json("__gm.state.project.assets.find((a) => a.id === 'structy').props.piece");
/** Left edge of each row's control – one column grid means one number, in the browser's own font. */
const leftsOf = (labels) =>
+3 -11
View File
@@ -1,4 +1,5 @@
import { edge, node, port, variable } from '../lib/builders.mjs';
import { MAKE_OBJECT } from './lib/scaffold.mjs';
/**
* Get variables / Set variables: one node for several of them. Their ports are the behavior's
@@ -56,7 +57,6 @@ export async function variableNodes(t) {
await mutate(`
const p = __gm.state.project;
const scene = __gm.state.activeScene;
const proto = scene.objects[0];
const mover = p.assets.find((a) => a.id === 'mover');
const a = structuredClone(mover);
Object.assign(a, {
@@ -67,16 +67,8 @@ export async function variableNodes(t) {
graph: ${JSON.stringify(logicGraph)},
});
p.assets.unshift(a);
const make = (id, assetId, parentId, name) => {
const o = structuredClone(proto);
Object.assign(o, {
id, assetId, name,
position: { x: 0, y: 900, z: 0 },
parentId, overrides: {}, instanceEdits: {},
});
return o;
};
scene.objects.push(make('vp-box', 'crate', '', 'Speedy box'), make('vp-brain', 'vp-logic', 'vp-box', 'Speeds'));
${MAKE_OBJECT}
scene.objects.push(make('vp-box', 'crate', '', 900, 'Speedy box'), make('vp-brain', 'vp-logic', 'vp-box', 900, 'Speeds'));
`);
await sleep(400);
+4 -9
View File
@@ -1,8 +1,6 @@
/**
* Steps 17–20 each need the same throwaway rig in the fixture scene: a behavior asset cloned off
* `mover` carrying the step's own graph, a crate to hold it, and a logic child that runs it.
* Spelled out per step it drifted four ways – `push` vs `unshift`, three different `make`
* signatures, and y-offsets nothing kept apart.
* The throwaway rig steps 17–20 share in the fixture scene: a behavior asset cloned off `mover` with the
* step's graph, a crate to hold it, and a logic child that runs it.
*/
/**
@@ -38,11 +36,8 @@ export const MAKE_OBJECT = `
};`;
/**
* Builds the rig and hands back the ids it created, plus `remove()` which takes them out again
* and returns what is left – so a step's cleanup cannot fall out of step with what it made.
*
* `front` puts the asset at the head of the project list: the asset panel is long by this point
* in the suite, and a row appended at the end scrolls out of reach of `dragAsset`.
* Returns the created ids plus `remove()`, so a step's cleanup matches what it made. `front` puts the
* asset at the head of the list: a row appended at the end scrolls out of `dragAsset`'s reach.
*/
export async function scaffoldBehavior(t, options) {
const {
+7 -18
View File
@@ -1,13 +1,6 @@
/**
* Where the headless browser for a check run lives.
*
* Local by default. Set `GM_REMOTE=user@host` to run it on another machine instead: an SSH
* session forwards the CDP port back to 127.0.0.1, so everything downstream – waitForChrome,
* /json/new, the WebSocket – stays unchanged. The checks load the public base URL, so the
* remote side needs nothing but a browser and internet.
*
* Why bother: even with the GPU in use an idle editor page keeps rendering frames that never
* change, and this machine has 8 GB with most of the swap already gone.
* Launches the check browser locally, or with `GM_REMOTE=user@host` on another machine with the CDP port
* forwarded over SSH, since this 8 GB host has little memory to spare.
*/
import { spawn, execFileSync } from 'node:child_process';
import { fileURLToPath } from 'node:url';
@@ -21,10 +14,8 @@ const FLAGS = [
'--headless',
'--hide-scrollbars',
/**
* Measured on this host (idle editor page, 20 s): software WebGL costs 373% of a core at 8 fps,
* the Intel HD 630 via ANGLE/Vulkan costs 69% at 56 fps. There is deliberately no software
* fallback — a host without a usable GPU should fail loudly rather than melt down. `--use-angle=gl`
* and `--use-gl=egl` look even cheaper but get no WebGL context at all — do not use them.
* Measured (idle editor, 20 s): software WebGL 373% of a core at 8 fps, HD 630 via ANGLE/Vulkan 69% at
* 56 fps; no software fallback on purpose. `--use-angle=gl` and `--use-gl=egl` get no WebGL context at all.
*/
'--use-angle=vulkan',
'--ignore-gpu-blocklist',
@@ -81,7 +72,7 @@ export function launchBrowser({ port, width, height }) {
}
const key = process.env.GM_REMOTE_KEY ?? join(homedir(), '.ssh', 'gm-win.key');
// Copied every run: a stale launcher on the remote is a debugging session nobody enjoys.
// Copied every run so the remote never runs a stale launcher.
execFileSync('scp', [...sshArgs(key), LAUNCHER, `${remote}:gm-browser.ps1`], { stdio: 'pipe' });
const proc = spawn(
@@ -101,10 +92,8 @@ export function launchBrowser({ port, width, height }) {
return {
proc,
/**
* The launcher notices a finished run on its own, but only after its idle window – a browser
* lingering a minute on someone else's desktop is rude, so end it now and let that window be
* the net for runs that die without reaching here. Only this run's pid: the host is somebody's
* desktop, and killing by name would close the browser they are reading this in.
* Ends the browser now rather than after the launcher's idle window. Kill only this pid: the host is
* someone's desktop, and killing by name would close their browser.
*/
stop: () => {
const pidFile = `$env:TEMP\\gm-browser-${port}.pid`;
-1
View File
@@ -26,7 +26,6 @@ export const sceneObject = (
*/
export const instanceChild = (instanceId, ...templatePath) => `${instanceId}~${templatePath.join('/')}`;
/** A graph edge from `from.fromPort` to `to.toPort`. */
export const edge = (id, from, fromPort, to, toPort) => ({
id,
from: { node: from, port: fromPort },
+23 -26
View File
@@ -1,18 +1,7 @@
/**
* Headless-Chromium harness for the check scripts (e2e.mjs, smoke-jumprun.mjs).
*
* Starts chromium with remote debugging, opens a CDP session and hands a set of helpers to
* the body function. Widgets are located through `window.__gm.locate(text)` (installed by
* the editor), so checks never depend on pixel coordinates.
*
* await runChecks({ projectId, baseUrl, port, shotPrefix }, async (t) => { ... });
*
* The harness always waits for chromium to actually exit (a leftover GPU process kept rendering at
* full CPU once when only SIGTERM was sent) and exits with code 1 on failures.
*
* A run that ends without printing its summary exits 1 as well. Every wait here can time out for
* that reason: chromium dies under memory pressure on this machine, and a run that stops halfway
* used to exit 0 – silently reporting success for checks it never ran.
* Headless-Chromium harness for the checks; widgets are found via `__gm.locate(text)`, not coordinates.
* It always waits for chromium to exit (a leftover GPU process burns CPU) and exits 1 on failures or when
* the summary line is missing, since chromium can die halfway under memory pressure.
*/
import { writeFileSync } from 'node:fs';
import { launchBrowser } from './browser.mjs';
@@ -26,6 +15,14 @@ const SERVER_TIMEOUT_MS = 20_000;
/** Structural equality for the small plain values the checks compare (arrays of ids, selections). */
export const same = (a, b) => JSON.stringify(a) === JSON.stringify(b);
/** Sum of the channel differences of two `#rrggbb` colours. */
export function colourDistance(a, b) {
const channels = (hex) => [1, 3, 5].map((i) => parseInt(hex.slice(i, i + 2), 16));
const [ar, ag, ab] = channels(a);
const [br, bg, bb] = channels(b);
return Math.abs(ar - br) + Math.abs(ag - bg) + Math.abs(ab - bb);
}
/** Keys the checks press, with the codes Chromium needs to route them like a real keyboard. */
const KEYS = {
Enter: { key: 'Enter', code: 'Enter', windowsVirtualKeyCode: 13 },
@@ -131,8 +128,12 @@ function makeTools({ cdp, projectId, baseUrl, consoleLog, check, shotPrefix }) {
};
/** Evaluates an expression in the page and returns it as plain JSON; `undefined` comes back as null. */
const json = async (expression) => JSON.parse(await evaluate(`JSON.stringify((${expression}) ?? null)`));
/** Like `locate`, but null instead of throwing – which a check for absence needs. */
const find = async (text, last = false) =>
(await evaluate(`window.__gm && window.__gm.locate(${JSON.stringify(text)}, ${last})`)) || null;
const has = async (text) => Boolean(await find(text));
const locate = async (text, last = false) => {
const p = await evaluate(`window.__gm && window.__gm.locate(${JSON.stringify(text)}, ${last})`);
const p = await find(text, last);
if (!p) throw new Error(`widget with text "${text}" not found`);
return p;
};
@@ -176,10 +177,7 @@ function makeTools({ cdp, projectId, baseUrl, consoleLog, check, shotPrefix }) {
};
/** CDP reports held buttons as a bitmask, which is not the button name it takes elsewhere. */
const BUTTON_MASK = { left: 1, right: 2, middle: 4 };
/**
* Drags with the left button unless told otherwise. The fifth argument is either a step count
* (its long-standing meaning) or `{ steps, button, modifiers }`.
*/
/** The fifth argument is a step count or `{ steps, button, modifiers }`. */
const drag = async (x1, y1, x2, y2, options = {}) => {
const {
steps = 12,
@@ -199,10 +197,8 @@ function makeTools({ cdp, projectId, baseUrl, consoleLog, check, shotPrefix }) {
};
/**
* Drags an asset out of the Assets panel onto a point in a view – the only way to place one
* since the context menu lost its entry. By id, not by text: the container picker shows the
* same name as the row. Unlike `drag()` it presses and moves in separate events on purpose,
* because the panel turns the press into a drag only once it has travelled.
* By id, not text: the container picker shows the same name as the row. Press and moves are separate
* events because the panel starts a drag only once the pointer has travelled.
*/
const dragAsset = async (assetId, x, y, steps = 8) => {
const from = await json(`__gm.assetRow(${JSON.stringify(assetId)})`);
@@ -242,9 +238,8 @@ function makeTools({ cdp, projectId, baseUrl, consoleLog, check, shotPrefix }) {
await keyEvent('keyUp', name);
};
/**
* Typed per character to exercise the per-keystroke paths (live preview, search filters), then
* verified: a partial type is otherwise indistinguishable from a wrong value – a run once
* committed x=3 for "300" and the check blamed the value, not the lost characters.
* Typed per character to exercise per-keystroke paths, then verified: a partial type is otherwise
* indistinguishable from a wrong value.
*/
const typeText = async (text) => {
for (const ch of text) await cdp.send('Input.insertText', { text: ch });
@@ -330,6 +325,8 @@ function makeTools({ cdp, projectId, baseUrl, consoleLog, check, shotPrefix }) {
sleep,
evaluate,
json,
find,
has,
locate,
shot,
mouse,
+5 -27
View File
@@ -1,30 +1,9 @@
#!/usr/bin/env node
/**
* Writes the second example game: Tetris, built only from assets and node graphs.
*
* The board is a list of 200 numbers, one per cell: -1 for empty, otherwise the shape that came to
* rest there. Reading a cell is `Item at(grid, row·10 + column)` and clearing a full line is an
* empty row joined in front of everything above and below it – with no text encoding in between,
* so the numbers in the inspector are the numbers the game means.
*
* What travels as a bundle rather than as loose numbers: the falling piece (`piece`) and the one
* move being tried on it (`next`) are structs of shape/rot/x/y, and `shapes` is a list of `{x, y}`
* cells. That is what lets `Next move` hand back a whole candidate for `Fits` to judge and the
* master to accept in a single write, and it halves the index arithmetic in `Piece cell`.
*
* Where a step needs several variables at once, one `Get variables` / `Set variables` stands for the
* lot – the call to `Next move` has one node at each side of it instead of five. How far such a node
* may reach is bounded by the pulse cache; `graphBuilder` below says how.
*
* The game is ten behaviors rather than one graph. `Tetris` owns the state and does none of the
* work: every step is a behavior of its own, called as a node – `Spawn piece`, `Next move`,
* `Fits`, `Draw piece`, `Lock piece`, `Clear lines`, `Show lines`, `Draw board`, with `Piece cell`
* used by three of them. None of them holds state between calls: everything they need arrives
* through their inputs and every answer leaves through their outputs, which is what lets `Fits`
* serve turning, moving and falling alike.
*
* What the player sees is a pool of objects the graphs address by name: `c0…c199` for the board
* and `p0…p3` for the piece that is falling. Nothing is spawned or destroyed while playing.
* Writes the Tetris example from assets and graphs only. The board is a list of 200 numbers (-1 empty,
* else the resting shape); `piece`/`next` are shape/rot/x/y structs. The `Tetris` master owns the state
* and calls stateless behaviors as nodes, which lets `Fits` serve turning, moving and falling alike.
* Board and piece are a fixed pool of named objects (`c0…c199`, `p0…p3`); nothing spawns while playing.
*
* Usage: node tools/seed-tetris.mjs [projectId] [baseUrl]
*/
@@ -254,8 +233,7 @@ function objectOfRound(g, prefix, loop, namePrefix, column, row) {
/**
* Where one of the four cells of a piece sits: reads the rotation out of `shapes` and adds the
* piece's own position. Used by the fit test and by the drawing, which is the whole point of it
* being a behavior – the two used to be the same twenty nodes twice.
* piece's own position. A behavior so the fit test and the drawing share it.
*/
function pieceCellGraph() {
const g = graphBuilder();
+6 -6
View File
@@ -47,12 +47,12 @@ await runChecks({ projectId, baseUrl, port: 9336, shotPrefix: 'gm-jr' }, async (
stop,
navigate,
exceptions,
has,
} = t;
/** Variables of the "Game" behavior instance, live from the running world. */
const gameVars = async () => (await entity('ogame')).props;
const coins = async () => (await world()).entities.filter((e) => e.name === 'Coin').length;
const visible = (text) => evaluate(`Boolean(__gm.locate(${JSON.stringify(text)}))`);
/** Clicks an object where the *player* sees it (through the camera), not in the scene view. */
const clickObject = async (objectId) => {
const p = await json(`__gm.gameScreen(${JSON.stringify(objectId)})`);
@@ -95,7 +95,7 @@ await runChecks({ projectId, baseUrl, port: 9336, shotPrefix: 'gm-jr' }, async (
await play();
await sleep(300);
check('play opens the Game tab', (await evaluate('__gm.tab()')) === 'game');
check('the title screen is up', await visible('JUMP & RUN'));
check('the title screen is up', await has('JUMP & RUN'));
const level = await entity('L');
const title = await entity('T');
check(
@@ -174,7 +174,7 @@ await runChecks({ projectId, baseUrl, port: 9336, shotPrefix: 'gm-jr' }, async (
// Against the count the run actually reached, not a fixed 1: how many coins the walk sweeps up
// depends on machine speed – a faster host collects two.
const hud = `Coins ${afterCoin.coins}/${afterCoin.needed}`;
check('the HUD shows the counter', await visible(hud), hud);
check('the HUD shows the counter', await has(hud), hud);
check('the clock is counting down', afterCoin.timeLeft < 28, `timeLeft ${afterCoin.timeLeft}`);
await shot('05-collected');
@@ -183,7 +183,7 @@ await runChecks({ projectId, baseUrl, port: 9336, shotPrefix: 'gm-jr' }, async (
await sleep(300);
check(
'collecting everything wins the game',
(await visible('YOU WIN!')) &&
(await has('YOU WIN!')) &&
(await entity('W')).props.enabled === true &&
(await entity('L')).props.enabled === false,
);
@@ -194,7 +194,7 @@ await runChecks({ projectId, baseUrl, port: 9336, shotPrefix: 'gm-jr' }, async (
await sleep(1200);
check(
'Play again restarts the scene at the title screen',
(await visible('JUMP & RUN')) && (await evaluate('__gm.state.mode')) === 'play',
(await has('JUMP & RUN')) && (await evaluate('__gm.state.mode')) === 'play',
);
check('the restarted run has all ten coins back', (await coins()) === 10, `${await coins()} coins`);
@@ -205,7 +205,7 @@ await runChecks({ projectId, baseUrl, port: 9336, shotPrefix: 'gm-jr' }, async (
await sleep(600);
check(
'the clock running out loses the game',
(await visible('TIME UP')) && (await entity('B')).props.enabled === true,
(await has('TIME UP')) && (await entity('B')).props.enabled === true,
);
await shot('07-lost');