Give the e2e steps one place to build their fixtures

Steps 18 and 20 both hand-rolled the same object-plus-behavior scaffold, down
to lane offsets that nothing kept distinct. Step 19 claimed in a comment that it
cleaned up after itself but never checked it – the only one of the four that
did not.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
rootandClaude Opus 5 committed 2026-09-10 07:14:17 +02:00
1 parent 6ac6b69388
commit 787a19d525
7 files changed
+140 -125

No files matched your search

+14 -38
View File
@@ -1,4 +1,5 @@
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
@@ -34,42 +35,22 @@ const pauseGraph = {
};
export async function pause(t) {
const { check, sleep, json, shot, clickText, press, entity, world, mutate, stop } = t;
const { check, sleep, json, 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;
// 19a. A box with a drifting behavior below it, written into the model directly: what is under
// test is the pause, not another way of building a graph.
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: 'pauser',
name: 'Pauser',
props: { enabled: true, runInEditor: false },
variables: [],
graph: ${JSON.stringify(pauseGraph)},
});
p.assets.push(asset);
const make = (id, assetId, parentId, y) => {
const o = structuredClone(proto);
Object.assign(o, {
id,
assetId,
name: id === 'p-box' ? 'Pause box' : 'Pauser',
position: { x: 0, y, z: 0 },
parentId,
overrides: {},
instanceEdits: {},
});
return o;
};
scene.objects.push(make('p-box', 'crate', '', 300), make('p-logic', 'pauser', 'p-box', 0));
`);
await sleep(400);
const rig = await scaffoldBehavior(t, {
assetId: 'pauser',
name: 'Pauser',
graph: pauseGraph,
boxId: 'p-box',
boxName: 'Pause box',
logicId: 'p-logic',
y: LANES.pause,
});
await t.play();
const running = await playInfo();
@@ -171,15 +152,10 @@ export async function pause(t) {
await sleep(200);
// 19f. Play discarded its copy, so the box is back at the position the model holds.
await mutate(`
const p = __gm.state.project;
const scene = __gm.state.activeScene;
scene.objects = scene.objects.filter((o) => o.id !== 'p-box' && o.id !== 'p-logic');
p.assets = p.assets.filter((a) => a.id !== 'pauser');
`);
await sleep(300);
const left = await rig.remove();
check(
'the step leaves nothing of its own behind',
(await json(`__gm.sceneObjects().filter((o) => o.id.startsWith('p-')).length`)) === 0,
left.objects === 0 && left.assets === 0,
JSON.stringify(left),
);
}
+9
View File
@@ -297,4 +297,13 @@ export async function nestedBehavior(t) {
scene.objects = scene.objects.filter((o) => o.id !== 'nb-box' && o.id !== 'nb-logic');
`);
await sleep(300);
const left = await json(`{
objects: __gm.sceneObjects().filter((o) => o.id.startsWith('nb-')).length,
assets: __gm.state.project.assets.filter((a) => a.id.startsWith('nb-')).length,
}`);
check(
'the step leaves nothing of its own behind',
left.objects === 0 && left.assets === 0,
JSON.stringify(left),
);
}
+14 -38
View File
@@ -1,4 +1,5 @@
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
@@ -32,7 +33,7 @@ const repeatGraph = {
};
export async function keyRepeat(t) {
const { check, sleep, json, shot, click, holdKey, world, mutate, stop, select, typeText } = t;
const { check, sleep, json, shot, click, holdKey, world, stop, select, typeText } = t;
/** Both counts out of one read of the world, so the two are always of the same moment. */
const hits = async () => {
const log = (await world()).log;
@@ -42,35 +43,15 @@ export async function keyRepeat(t) {
// 20a. A box with the two-node behavior below it, written into the model directly: what is under
// test is the repeat, not another way of building a graph.
await mutate(`
const p = __gm.state.project;
const scene = __gm.state.activeScene;
const proto = scene.objects[0];
const a = structuredClone(p.assets.find((x) => x.id === 'mover'));
Object.assign(a, {
id: 'repeater',
name: 'Repeater',
props: { enabled: true, runInEditor: false },
variables: [],
graph: ${JSON.stringify(repeatGraph)},
});
p.assets.push(a);
const make = (id, assetId, parentId, y) => {
const o = structuredClone(proto);
Object.assign(o, {
id,
assetId,
name: id === 'r-box' ? 'Repeat box' : 'Repeater',
position: { x: 0, y, z: 0 },
parentId,
overrides: {},
instanceEdits: {},
});
return o;
};
scene.objects.push(make('r-box', 'crate', '', -300), make('r-logic', 'repeater', 'r-box', 0));
`);
await sleep(400);
const rig = await scaffoldBehavior(t, {
assetId: 'repeater',
name: 'Repeater',
graph: repeatGraph,
boxId: 'r-box',
boxName: 'Repeat box',
logicId: 'r-logic',
y: LANES.keyRepeat,
});
await t.play();
await sleep(300);
@@ -154,15 +135,10 @@ export async function keyRepeat(t) {
await stop();
await sleep(300);
await mutate(`
const p = __gm.state.project;
const scene = __gm.state.activeScene;
scene.objects = scene.objects.filter((o) => o.id !== 'r-box' && o.id !== 'r-logic');
p.assets = p.assets.filter((a) => a.id !== 'repeater');
`);
await sleep(300);
const left = await rig.remove();
check(
'the step leaves nothing of its own behind',
(await json(`__gm.sceneObjects().filter((o) => o.id.startsWith('r-')).length`)) === 0,
left.objects === 0 && left.assets === 0,
JSON.stringify(left),
);
}
+102
View File
@@ -0,0 +1,102 @@
/**
* 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.
*/
/**
* Where each step parks its box. The fixture scene is shared, so two steps at the same y would
* overlap and each would fail for reasons belonging to the other.
*/
export const LANES = {
editorRun: 400,
editorRunContainer: 800,
editorRunEdits: 1200,
pause: 300,
nested: 700,
keyRepeat: -300,
};
/**
* Source for a `make(id, assetId, parentId, y, name)` factory, to paste inside a `mutate()`
* string. It clones `scene.objects[0]`, so a new object carries whatever shape the fixture has.
*/
export const MAKE_OBJECT = `
const make = (id, assetId, parentId, y, name = '') => {
const o = structuredClone(scene.objects[0]);
Object.assign(o, {
id,
assetId,
name,
position: { x: 0, y, z: 0 },
parentId,
overrides: {},
instanceEdits: {},
});
return o;
};`;
/**
* 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`.
*/
export async function scaffoldBehavior(t, options) {
const {
assetId,
name,
graph,
boxId,
boxName = 'Box',
logicId,
y,
front = false,
props = { enabled: true, runInEditor: false },
} = options;
await t.mutate(`
const p = __gm.state.project;
const scene = __gm.state.activeScene;
const asset = structuredClone(p.assets.find((a) => a.id === 'mover'));
Object.assign(asset, {
id: ${JSON.stringify(assetId)},
name: ${JSON.stringify(name)},
props: ${JSON.stringify(props)},
variables: [],
graph: ${JSON.stringify(graph)},
});
p.assets.${front ? 'unshift' : 'push'}(asset);
${MAKE_OBJECT}
scene.objects.push(
make(${JSON.stringify(boxId)}, 'crate', '', ${y}, ${JSON.stringify(boxName)}),
make(${JSON.stringify(logicId)}, ${JSON.stringify(assetId)}, ${JSON.stringify(boxId)}, 0, ${JSON.stringify(name)}),
);
`);
await t.sleep(400);
return {
assetId,
boxId,
logicId,
/** Removes everything this rig added and reports what survived (should be all zeroes). */
async remove() {
await t.evaluate('__gm.state.select(null)');
await t.mutate(`
const p = __gm.state.project;
const scene = __gm.state.activeScene;
const objects = ${JSON.stringify([boxId, logicId])};
const assets = ${JSON.stringify([assetId])};
scene.objects = scene.objects.filter((o) => !objects.includes(o.id));
p.assets = p.assets.filter((a) => !assets.includes(a.id));
`);
await t.sleep(300);
return t.json(`{
objects: __gm.sceneObjects().filter((o) => ${JSON.stringify([boxId, logicId])}.includes(o.id)).length,
assets: __gm.state.project.assets.filter((a) => a.id === ${JSON.stringify(assetId)}).length,
}`);
},
};
}
-8
View File
@@ -141,8 +141,6 @@ function makeTools({ cdp, projectId, baseUrl, consoleLog, check, shotPrefix }) {
writeFileSync(`/tmp/${shotPrefix}-${name}.png`, Buffer.from(data, 'base64'));
};
// --- mouse ---------------------------------------------------------------------------------
const mouse = (type, x, y, button = 'left', extra = {}) =>
cdp.send('Input.dispatchMouseEvent', {
type,
@@ -227,8 +225,6 @@ function makeTools({ cdp, projectId, baseUrl, consoleLog, check, shotPrefix }) {
await sleep(200);
};
// --- keyboard ------------------------------------------------------------------------------
const keyEvent = (type, name, modifiers) => {
const key = KEYS[name];
if (!key) throw new Error(`unknown key "${name}"`);
@@ -269,8 +265,6 @@ function makeTools({ cdp, projectId, baseUrl, consoleLog, check, shotPrefix }) {
await sleep(200);
};
// --- editor state --------------------------------------------------------------------------
/** Selection, mode, save status and notice – the facts most checks look at. */
const state = () =>
json(
@@ -306,8 +300,6 @@ function makeTools({ cdp, projectId, baseUrl, consoleLog, check, shotPrefix }) {
await sleep(300);
};
// --- server ----------------------------------------------------------------------------------
// Timeout, not convenience: a stalled response keeps its socket open, so node stays alive and
// the whole run hangs on this line instead of failing.
const api = async (route) => {
+1 -9
View File
@@ -19,8 +19,6 @@ import { PROJECTS, cliArgs, writeProject } from './lib/projects.mjs';
const { projectId, baseUrl } = cliArgs('seed-jumprun.mjs', PROJECTS.jumpRun);
// --- colours ---------------------------------------------------------------------------------
const SKY = 0x151821;
const STONE = 0x3c4454;
const GRASS = 0x4a7d3a;
@@ -32,7 +30,7 @@ const WHITE = 0xf2f5f7;
const HINT = 0x9aa3b2;
const BUTTON_TEXT = 0x10240a;
// --- level geometry (the camera shows x −320…320, y −180…180; +y is down) ---------------------
// The camera shows x −320…320, y −180…180; +y is down.
/** A solid box: the sprite plus a collider child of the same size. */
const solid = (id, x, y, w, h, color) => [
@@ -66,7 +64,6 @@ const coinTemplate = [
];
const levelTemplate = [
// Ground, side walls and six platforms.
...solid('ground', 0, 165, 660, 30, GRASS),
...solid('wall-l', -315, 0, 10, 360, STONE),
...solid('wall-r', 315, 0, 10, 360, STONE),
@@ -87,7 +84,6 @@ const levelTemplate = [
coin('c8', -230, -60),
coin('c9', 255, -90),
coin('c10', 290, 128),
// The player: sprite, solid collider and the behavior that owns it.
sceneObject('player', 'player-sprite', { name: 'Player', x: -270, y: 125, z: 2 }),
sceneObject('player-c', 'solid', { parentId: 'player', overrides: { width: 30, height: 40 } }),
sceneObject('player-logic', 'player', { parentId: 'player', y: -60 }),
@@ -122,8 +118,6 @@ const templates = {
lose: screenTemplate('lose', 'TIME UP', 'The time is over - try again.', 'PLAY AGAIN', 'restart-button'),
};
// --- graphs ----------------------------------------------------------------------------------
/**
* "Player": gravity, walking, jumping and picking coins up – three strands.
*
@@ -353,8 +347,6 @@ const restartButtonGraph = {
edges: [edge('re1', 'r-click', 'exec', 'r-restart', 'exec')],
};
// --- project ---------------------------------------------------------------------------------
const project = {
formatVersion: 2,
id: projectId,
-32
View File
@@ -24,8 +24,6 @@ import { PROJECTS, cliArgs, writeProject } from './lib/projects.mjs';
const { projectId, baseUrl } = cliArgs('seed-tetris.mjs', PROJECTS.tetris);
// --- board geometry --------------------------------------------------------------------------
const COLS = 10;
const ROWS = 20;
const CELL = 16;
@@ -37,8 +35,6 @@ const CELL_Y = (row) => row * CELL - (ROWS * CELL) / 2 + CELL / 2;
const EMPTY = -1;
const EMPTY_GRID = Array.from({ length: COLS * ROWS }, () => EMPTY);
// --- colours ---------------------------------------------------------------------------------
const BACKGROUND = 0x151821;
const WELL = 0x0d1017;
const WHITE = 0xf2f5f7;
@@ -47,8 +43,6 @@ const HINT = 0x9aa3b2;
/** One per shape, in the order of `SHAPES` below – and the `colors` variable as it stands. */
const SHAPE_COLORS = [0x2ec9d6, 0xf5c542, 0xa35fd6, 0x63c74d, 0xe0553f, 0x4a7de0, 0xe08a3f];
// --- the seven shapes ------------------------------------------------------------------------
/**
* Each shape as its four cells in a box of `size` × `size`, the box being what turning rotates
* inside: (x, y) → (size − 1 − y, x) is a quarter turn clockwise. I needs a 4-box, O a 2-box.
@@ -147,8 +141,6 @@ function shapeTable() {
/** The column a shape starts in, so every piece appears centred over the well. */
const spawnColumns = () => SHAPES.map((s) => Math.floor((COLS - s.size) / 2));
// --- the board container ---------------------------------------------------------------------
/** The well, the 200 board cells (switched off = empty) and the four cells of the falling piece. */
const boardTemplate = [
sceneObject('well', 'well-sprite', { name: 'Well' }),
@@ -166,8 +158,6 @@ const boardTemplate = [
),
];
// --- graph helpers -----------------------------------------------------------------------------
/**
* One graph under construction. Nodes are laid out in bands, one per strand, so the editor shows a
* behavior as a stack of readable blocks rather than one cloud.
@@ -225,8 +215,6 @@ function objectOfRound(g, prefix, loop, namePrefix, column, row) {
return object;
}
// --- behavior: Piece cell ------------------------------------------------------------------------
/**
* 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
@@ -277,8 +265,6 @@ function pieceCellGraph() {
return g.done();
}
// --- behavior: Fits -------------------------------------------------------------------------------
/**
* Whether the four cells of a piece may stand where it is asked to: inside the well, and on empty
* board cells. Every attempt to turn, move or drop goes through this one answer.
@@ -344,8 +330,6 @@ function fitsGraph() {
return g.done();
}
// --- behavior: Clear lines -------------------------------------------------------------------------
/**
* Removes every full row and counts it. Walks the board from the bottom up; a cleared row does not
* move the walk on, because everything above has dropped into it – so the same row is looked at
@@ -422,8 +406,6 @@ function clearLinesGraph() {
return g.done();
}
// --- behavior: Draw board ---------------------------------------------------------------------------
/**
* The board as it stands, cell by cell: switched off where the list says empty, in the colour of
* the shape that came to rest there otherwise. No end node – it answers nothing, it only draws.
@@ -457,8 +439,6 @@ function drawBoardGraph() {
return g.done();
}
// --- behavior: Spawn piece -----------------------------------------------------------------------
/** Which piece appears next and in which column: one of the seven, centred over the well. */
function spawnPieceGraph() {
const g = graphBuilder();
@@ -476,8 +456,6 @@ function spawnPieceGraph() {
return g.done();
}
// --- behavior: Next move -------------------------------------------------------------------------
/**
* The one move a frame attempts, and nothing else: turning beats moving beats falling. It only
* proposes – whether the piece may go there is `Fits`, one step later. A wish that was granted
@@ -615,8 +593,6 @@ function nextMoveGraph() {
return g.done();
}
// --- behavior: Draw piece ------------------------------------------------------------------------
/**
* Moves the four cell objects of the falling piece to where the piece is. This is the only place
* that touches them, so where they stand *is* where the piece is – which is what `Lock piece`
@@ -668,8 +644,6 @@ function drawPieceGraph() {
return g.done();
}
// --- behavior: Lock piece ------------------------------------------------------------------------
/** Writes the four cells of the landed piece into the board, read back from where they stand. */
function lockPieceGraph() {
const g = graphBuilder();
@@ -718,8 +692,6 @@ function lockPieceGraph() {
return g.done();
}
// --- behavior: Show lines ------------------------------------------------------------------------
/** The one place that writes the counter on screen. */
function showLinesGraph() {
const g = graphBuilder();
@@ -736,8 +708,6 @@ function showLinesGraph() {
return g.done();
}
// --- behavior: Tetris ---------------------------------------------------------------------------
/**
* The master graph. It owns the state and does none of the work: every step below is a behavior of
* its own, called as a node. What is left here is the order the steps run in and which variable
@@ -928,8 +898,6 @@ function tetrisGraph() {
return g.done();
}
// --- project -----------------------------------------------------------------------------------
const label = (id, name, x, y, text, size, color, align = 'center') =>
sceneObject(id, 'label', { name, x, y, overrides: { text, fontSize: size, color, align } });