Refactor round: ship web/index.html, drop the remote browser, fix stale comments and README
- `.gitignore` said `index.html` unanchored, which also caught the Vite source page; a fresh clone could neither `dev` nor `build`. Anchored to `/index.html`, page added. - Removed GM_REMOTE (tools/remote/gm-browser.ps1 and the ssh/scp branch in browser.mjs): the Windows test machine is gone and the only flaky check that lived on it is closed (#37). - Removed `npm run preview`, undocumented and unused. - Comments: wrong number in collider.test.ts (250 → 200), the "declarations used to…" history in listItems.ts and friends, the 24 px/28 contradiction, section banners in nodes/index.ts, node-catalog.test.ts and api/index.php, name paraphrases, and the stale step numbers in tools/e2e/11-…23- from the old global count. - README: asset types include `animation`, kind order Number/Text/Boolean, tagged colour and texture items, 6/5 kinds, the `Texture` node, `./build.sh`, PROJECT_FORMAT_VERSION, and the file tree (kinds.ts, listItems.ts, propertyPorts.ts, Breadcrumb.ts, beads.ts, tools/assets/). - tests/helpers.ts takes the duplicated `findObject` and `sceneWithCamera`. Proof: ./check.sh 797 tests, full e2e ALL PASSED 633 checks against this bundle. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
1 parent
d86a655a4d
commit
6471671f7d
44 files changed
+196
-445
No files matched your search
+10
-10
@@ -25,7 +25,7 @@ export async function inspector(t) {
|
||||
projectId,
|
||||
} = t;
|
||||
|
||||
// 1. Select the blue crate in the hierarchy.
|
||||
// 2a. Select the blue crate in the hierarchy.
|
||||
await clickText('Blue crate');
|
||||
let s = await state();
|
||||
check(
|
||||
@@ -35,7 +35,7 @@ export async function inspector(t) {
|
||||
);
|
||||
await shot('02-object-selected');
|
||||
|
||||
// 1b. Deviations are shown in gold – on the inspector label and on the hierarchy row. The blue
|
||||
// 2b. Deviations are shown in gold – on the inspector label and on the hierarchy row. The blue
|
||||
// crate overrides width and color, the camera and the red crate's subtree nothing.
|
||||
const GOLD = '#f2c84b';
|
||||
const PLAIN = '#e6e8ee';
|
||||
@@ -57,7 +57,7 @@ export async function inspector(t) {
|
||||
`__gm.state.activeScene.objects.find(o => o.id === 'o2').overrides = { width: 140, color: 0x4488ff }`,
|
||||
);
|
||||
|
||||
// 2. Rename via inspector: the name field sits right of the "Name" label.
|
||||
// 2c. Rename via inspector: the name field sits right of the "Name" label.
|
||||
const nameLabel = await locate('Name');
|
||||
await click(nameLabel.x + 160, nameLabel.y);
|
||||
await press('a', { ctrl: true });
|
||||
@@ -72,7 +72,7 @@ export async function inspector(t) {
|
||||
);
|
||||
await shot('04-renamed');
|
||||
|
||||
// 2b. A double-click into a text field selects its content, so the next keystroke replaces the
|
||||
// 2d. A double-click into a text field selects its content, so the next keystroke replaces the
|
||||
// whole name instead of inserting into it. Renamed back, since later checks go by the name.
|
||||
await doubleClick(nameLabel.x + 160, nameLabel.y);
|
||||
await typeText('Twice');
|
||||
@@ -83,7 +83,7 @@ export async function inspector(t) {
|
||||
(await object('o2')).name === 'Twice',
|
||||
JSON.stringify((await object('o2')).name),
|
||||
);
|
||||
// Undone rather than typed back: check 6 further down counts its undos, so this must not
|
||||
// Undone rather than typed back: check 2h further down counts its undos, so this must not
|
||||
// leave a step on the stack.
|
||||
await undo();
|
||||
await sleep(200);
|
||||
@@ -93,14 +93,14 @@ export async function inspector(t) {
|
||||
JSON.stringify((await object('o2')).name),
|
||||
);
|
||||
|
||||
// 3. Scrub the X field (right of the "X" label) by 100 px.
|
||||
// 2e. Scrub the X field (right of the "X" label) by 100 px.
|
||||
const xLabel = await locate('X');
|
||||
await drag(xLabel.x + 160, xLabel.y, xLabel.x + 260, xLabel.y);
|
||||
const xNow = (await object('o2')).position.x;
|
||||
check('scrubbing X moved the object', xNow > 120, `x=${xNow}`);
|
||||
await shot('05-scrubbed');
|
||||
|
||||
// 3b. Type into X, then click straight into the empty viewport: the typed value must be
|
||||
// 2f. Type into X, then click straight into the empty viewport: the typed value must be
|
||||
// committed (not reverted) even though the click deselects and rebuilds the inspector.
|
||||
const center = await json('__gm.viewportCenter()');
|
||||
await click(xLabel.x + 160, xLabel.y); // plain click → text editing with everything selected
|
||||
@@ -114,7 +114,7 @@ export async function inspector(t) {
|
||||
`x=${xTyped} selection=${JSON.stringify(s.selection)}`,
|
||||
);
|
||||
|
||||
// 4. Drag the red crate in the viewport. It is at world (-150,0); the view is centred
|
||||
// 2g. Drag the red crate in the viewport. It is at world (-150,0); the view is centred
|
||||
// on the origin, so compute its screen position from the viewport centre.
|
||||
await drag(center.x - 150, center.y, center.x - 50, center.y - 70);
|
||||
const o1Now = (await object('o1')).position;
|
||||
@@ -129,7 +129,7 @@ export async function inspector(t) {
|
||||
check('override kept', renamed.overrides.color === 4491519, JSON.stringify(renamed.overrides));
|
||||
check('save status is saved', (await state()).save === 'saved');
|
||||
|
||||
// 6. Undo four times (drag, typed X, scrub, rename) with Ctrl+Z, then redo once.
|
||||
// 2h. Undo four times (drag, typed X, scrub, rename) with Ctrl+Z, then redo once.
|
||||
await click(center.x, center.y + 200); // click empty viewport → deselect, drop focus
|
||||
for (let i = 0; i < 4; i++) await undo();
|
||||
const afterUndo = (await t.objects()).map((o) => ({ id: o.id, name: o.name, x: o.position.x }));
|
||||
@@ -142,7 +142,7 @@ export async function inspector(t) {
|
||||
check('redo restored the rename', (await object('o2')).name === 'Renamed crate');
|
||||
await shot('07-undo-redo');
|
||||
|
||||
// 7. A change to the *structure* of what is shown normally rebuilds the inspector, which
|
||||
// 2i. A change to the *structure* of what is shown normally rebuilds the inspector, which
|
||||
// destroys the widget being typed in. While a field in here has focus the rebuild has to
|
||||
// wait – otherwise a field whose value decides the structure cannot be typed into at all.
|
||||
await clickText('Renamed crate');
|
||||
|
||||
@@ -31,7 +31,7 @@ export async function play(t) {
|
||||
const gameInfo = () => json('__gm.gameInfo()');
|
||||
const play = t.play;
|
||||
|
||||
// 7. Play: both crates lie inside the 640×360 frame at the origin; the behavior symbol is
|
||||
// 3a. Play: both crates lie inside the 640×360 frame at the origin; the behavior symbol is
|
||||
// editor-only. The "Mover" behavior owns the red crate: holding ArrowRight moves it in the
|
||||
// world (Axis input × speed × dt), Esc stops without touching the model.
|
||||
await clickText('Renamed crate');
|
||||
@@ -72,7 +72,7 @@ export async function play(t) {
|
||||
`${o2AfterPlay} vs ${xBeforePlay}, o1=${o1AfterPlay}`,
|
||||
);
|
||||
|
||||
// 7a. Editing while playing: Play opens the Game tab, the panels stay open and edit a runtime
|
||||
// 3b. Editing while playing: Play opens the Game tab, the panels stay open and edit a runtime
|
||||
// copy. Moving the blue crate (model hook + preview, as a scrub does) and deleting it with
|
||||
// the Delete key change the world at once, undo brings it back, Esc restores the saved model.
|
||||
await play();
|
||||
@@ -151,7 +151,7 @@ export async function play(t) {
|
||||
);
|
||||
check('stop returns to the Scene tab', (await evaluate('__gm.tab()')) === 'scene');
|
||||
|
||||
// 7b. Culling: move the blue crate far outside the camera frame → only one item visible.
|
||||
// 3c. Culling: move the blue crate far outside the camera frame → only one item visible.
|
||||
await mutate(`__gm.state.activeScene.objects.find(o => o.id === 'o2').position.x = 5000`);
|
||||
await play();
|
||||
info = await gameInfo();
|
||||
@@ -159,7 +159,7 @@ export async function play(t) {
|
||||
await stop();
|
||||
await mutate(`__gm.state.activeScene.objects.find(o => o.id === 'o2').position.x = ${xBeforePlay}`);
|
||||
|
||||
// 7c. Without a camera nothing is rendered: delete the camera object, play, then undo.
|
||||
// 3d. Without a camera nothing is rendered: delete the camera object, play, then undo.
|
||||
await select({ kind: 'object', holderId: 'scene1', objectId: 'o3' });
|
||||
await press('Delete');
|
||||
await sleep(200);
|
||||
@@ -178,7 +178,7 @@ export async function play(t) {
|
||||
await undo();
|
||||
check('undo restored the camera object', (await objects()).length === 7);
|
||||
|
||||
// 7d. Which camera renders: a camera that is switched off is skipped like any other object, and
|
||||
// 3e. Which camera renders: a camera that is switched off is skipped like any other object, and
|
||||
// of those left the first in hierarchy order takes the view. The second camera is parked far
|
||||
// from the origin, so "seen through it" is provable – both crates fall outside its frame.
|
||||
await select({ kind: 'object', holderId: 'scene1', objectId: 'o3' });
|
||||
@@ -242,7 +242,7 @@ export async function play(t) {
|
||||
JSON.stringify((await object('o3')).overrides),
|
||||
);
|
||||
|
||||
// 7e. Not playing, the Game tab previews the scene through the same camera instead of waiting
|
||||
// 3f. Not playing, the Game tab previews the scene through the same camera instead of waiting
|
||||
// for Play. Proven by comparing it with what Play then draws – identical readout and an
|
||||
// identical pixel on the red crate – so "it renders something" cannot pass for a preview.
|
||||
const crateSpot = async () => {
|
||||
|
||||
@@ -19,7 +19,7 @@ export async function assets(t) {
|
||||
asset,
|
||||
} = t;
|
||||
|
||||
// 8. Context menu on the Crate asset. Placing left the menu when it became a drag.
|
||||
// 4a. Context menu on the Crate asset. Placing left the menu when it became a drag.
|
||||
await clickText('Crate', 'right');
|
||||
check('asset context menu opens', Boolean(await evaluate(`__gm.locate('Delete…')`)));
|
||||
check('placing is no longer a menu item', !(await evaluate(`__gm.locate('Place in scene')`)));
|
||||
@@ -27,7 +27,7 @@ export async function assets(t) {
|
||||
await press('Escape');
|
||||
await sleep(150);
|
||||
|
||||
// 8b. Drag the Crate asset from the panel into the scene view. Driven step by step rather than
|
||||
// 4b. Drag the Crate asset from the panel into the scene view. Driven step by step rather than
|
||||
// with `drag()`, because the preview only exists while the button is still down.
|
||||
const from = await json("__gm.assetRow('crate')");
|
||||
const centre = await json('__gm.viewportCenter()');
|
||||
@@ -73,7 +73,7 @@ export async function assets(t) {
|
||||
await sleep(150);
|
||||
check('redo puts it back', (await objects()).length === 8);
|
||||
|
||||
// 9. Create a new asset through the + menu.
|
||||
// 4c. Create a new asset through the + menu.
|
||||
await clickText('+');
|
||||
await clickText('Sprite');
|
||||
await sleep(100);
|
||||
@@ -81,7 +81,7 @@ export async function assets(t) {
|
||||
check('+ menu creates a sprite asset', names.includes('Sprite'), JSON.stringify(names));
|
||||
await shot('12-new-asset');
|
||||
|
||||
// 9b. Double-click on a scene row: the one thing activating an asset still does since placing
|
||||
// 4d. Double-click on a scene row: the one thing activating an asset still does since placing
|
||||
// became a drag. The second scene goes in through the model – the "+" menu's "Scene" entry
|
||||
// shares its label with the Scene tab, so a click by text could land on either.
|
||||
await mutate(
|
||||
@@ -103,7 +103,7 @@ export async function assets(t) {
|
||||
);
|
||||
await mutate(`__gm.state.project.assets = __gm.state.project.assets.filter((a) => a.id !== 'scene2')`);
|
||||
|
||||
// 12. Asset defaults: the Crate deviates in height (60 against the schema's 100) and colour,
|
||||
// 4e. Asset defaults: the Crate deviates in height (60 against the schema's 100) and colour,
|
||||
// so two rows carry a reset. Rows run Enabled, Width, Height, Color, Texture, so height's
|
||||
// reset is the first on screen and colour's the last.
|
||||
await select({ kind: 'asset', assetId: 'crate' });
|
||||
@@ -144,7 +144,7 @@ export async function assets(t) {
|
||||
JSON.stringify(await evaluate(`__gm.locate('↺')`)),
|
||||
);
|
||||
|
||||
// 12b. The same gold in the asset list: a row is marked while the asset carries anything the
|
||||
// 4f. The same gold in the asset list: a row is marked while the asset carries anything the
|
||||
// inspector marks. The Crate deviates twice, so it takes two resets to turn it plain.
|
||||
const GOLD = '#f2c84b';
|
||||
const PLAIN = '#e6e8ee';
|
||||
|
||||
@@ -3,7 +3,7 @@ export async function drawing(t) {
|
||||
const { check, sleep, json, shot, click, locate, typeText, press, undo, mutate, select, object } = t;
|
||||
const orderOfCrates = () => json(`__gm.drawOrder().filter(id => id === 'o1' || id === 'o2')`);
|
||||
|
||||
// 10. Symbols stay on top: put the camera first in the hierarchy (drawn first) and a crate
|
||||
// 5a. Symbols stay on top: put the camera first in the hierarchy (drawn first) and a crate
|
||||
// right on top of its icon → the icon must still be drawn above the sprite.
|
||||
await mutate(`
|
||||
const objs = __gm.state.activeScene.objects;
|
||||
@@ -20,7 +20,7 @@ export async function drawing(t) {
|
||||
);
|
||||
await shot('13-symbol-on-top');
|
||||
|
||||
// 11. Z-order: put both crates on the origin. With equal z the hierarchy order wins
|
||||
// 5b. Z-order: put both crates on the origin. With equal z the hierarchy order wins
|
||||
// (o1 before o2 → o2 on top); raising o1's z draws it above o2; undo restores it.
|
||||
await mutate(
|
||||
`const o2 = __gm.state.activeScene.objects.find(o => o.id === 'o2'); o2.position.x = 0; o2.position.y = 0`,
|
||||
|
||||
+18
-18
@@ -34,7 +34,7 @@ export async function graph(t) {
|
||||
};
|
||||
const variableKeys = async () => (await asset('mover')).variables.map((v) => v.key).join(',');
|
||||
|
||||
// 13. Selecting the Mover asset binds the graph; the Graph tab shows it.
|
||||
// 6a. Selecting the Mover asset binds the graph; the Graph tab shows it.
|
||||
await clickText('Mover');
|
||||
await evaluate(`__gm.showTab('graph')`);
|
||||
await sleep(250);
|
||||
@@ -44,7 +44,7 @@ export async function graph(t) {
|
||||
);
|
||||
await shot('16-graph');
|
||||
|
||||
// 13a. Drag a node: clicking its title selects it, dragging moves it (undoable).
|
||||
// 6b. Drag a node: clicking its title selects it, dragging moves it (undoable).
|
||||
const axisBefore = await nodeOf('n-axis');
|
||||
const axisPos = await json(`__gm.graph.nodeAt('n-axis')`);
|
||||
await drag(axisPos.x, axisPos.y, axisPos.x + 60, axisPos.y + 40);
|
||||
@@ -71,7 +71,7 @@ export async function graph(t) {
|
||||
`${axisUndone.x},${axisUndone.y}`,
|
||||
);
|
||||
|
||||
// 13b. Typing a query and then *clicking* a row adds exactly one node: the search field's
|
||||
// 6c. 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();
|
||||
@@ -93,7 +93,7 @@ export async function graph(t) {
|
||||
await undo(); // so the next step finds the same empty spot and the expected graph
|
||||
check('undo removes the clicked node again', (await graphCounts()).nodes === beforeClickPick.nodes);
|
||||
|
||||
// 13c. Right-click the empty canvas → palette → search "log" → Enter adds a Log node.
|
||||
// 6d. Right-click the empty canvas → palette → search "log" → Enter adds a Log node.
|
||||
const before13 = await graphCounts();
|
||||
await click(gcenter.x + 250, gcenter.y + 250, 'right');
|
||||
await sleep(100);
|
||||
@@ -112,7 +112,7 @@ export async function graph(t) {
|
||||
);
|
||||
await shot('17-node-added');
|
||||
|
||||
// 13d. Connect: drag from the Move node's exec output onto the Log node's exec input. An
|
||||
// 6e. Connect: drag from the Move node's exec output onto the Log node's exec input. An
|
||||
// exec output must not connect to a data input: dragging onto "message" does nothing.
|
||||
const fromPort = await json(`__gm.graph.portAt('n-move', 'exec', true)`);
|
||||
const toPort = await json(`__gm.graph.portAt(${JSON.stringify(newNode.id)}, 'exec', false)`);
|
||||
@@ -231,7 +231,7 @@ export async function graph(t) {
|
||||
JSON.stringify(cancelled),
|
||||
);
|
||||
|
||||
// 13e. Delete removes the selected node together with its edge; undo brings both back.
|
||||
// 6f. Delete removes the selected node together with its edge; undo brings both back.
|
||||
await select({ kind: 'node', assetId: 'mover', nodeId: newNode.id });
|
||||
await sleep(200);
|
||||
check('the node inspector has no delete button of its own', !(await t.has('Delete node')));
|
||||
@@ -251,7 +251,7 @@ export async function graph(t) {
|
||||
JSON.stringify(restored),
|
||||
);
|
||||
|
||||
// 13f. The fps readout runs in both modes. Play always starts on the Game tab; switching to
|
||||
// 6g. The fps readout runs in both modes. Play always starts on the Game tab; switching to
|
||||
// Graph while playing shows the live highlight: hold ArrowRight → the exec edge is hot.
|
||||
const fpsEdit = await evaluate('__gm.fps()');
|
||||
check('fps readout is measured in edit mode', typeof fpsEdit === 'number' && fpsEdit > 0, `fps=${fpsEdit}`);
|
||||
@@ -281,7 +281,7 @@ export async function graph(t) {
|
||||
await holding;
|
||||
await stop();
|
||||
|
||||
// 13g. Variables: the "+ Variable" dropdown → Number adds "var1" with default 0; undo removes it.
|
||||
// 6h. Variables: the "+ Variable" dropdown → Number adds "var1" with default 0; undo removes it.
|
||||
await select({ kind: 'asset', assetId: 'mover' });
|
||||
await sleep(150);
|
||||
await clickText('+ Variable');
|
||||
@@ -296,7 +296,7 @@ export async function graph(t) {
|
||||
await undo();
|
||||
check('undo removes the variable', (await variableKeys()) === 'speed', await variableKeys());
|
||||
|
||||
// 13h. The tab keeps its subject: selecting something else leaves the graph alone, and the
|
||||
// 6i. The tab keeps its subject: selecting something else leaves the graph alone, and the
|
||||
// header picker switches behaviors without any selection at all.
|
||||
const size = await graphCounts();
|
||||
const readout = `${size.nodes} nodes · ${size.edges} links`;
|
||||
@@ -337,7 +337,7 @@ export async function graph(t) {
|
||||
(await evaluate(`__gm.graph.current()`)) === 'mover',
|
||||
);
|
||||
|
||||
// 13i. Mouse behaviour, as in the scene view: the middle button pans from anywhere – a node
|
||||
// 6j. Mouse behaviour, as in the scene view: the middle button pans from anywhere – a node
|
||||
// under it included – and the left button no longer pans at all.
|
||||
await clickText('Fit');
|
||||
await sleep(250);
|
||||
@@ -374,7 +374,7 @@ export async function graph(t) {
|
||||
JSON.stringify({ axisBeforeBand, axisAfterBand }),
|
||||
);
|
||||
|
||||
// 13j. A rubber band across the whole canvas takes every node – the count comes from the model,
|
||||
// 6k. A rubber band across the whole canvas takes every node – the count comes from the model,
|
||||
// so this cannot agree with the canvas by sharing its rule.
|
||||
const nodeCount = (await asset('mover')).graph.nodes.length;
|
||||
await drag(corner.x, corner.y, opposite.x, opposite.y, { steps: 16 });
|
||||
@@ -386,7 +386,7 @@ export async function graph(t) {
|
||||
);
|
||||
await shot('21-graph-multiselect');
|
||||
|
||||
// 13k. The whole selection moves as one, and one undo takes all of it back.
|
||||
// 6l. The whole selection moves as one, and one undo takes all of it back.
|
||||
const beforeMove = (await asset('mover')).graph.nodes.map((n) => ({ id: n.id, x: n.x, y: n.y }));
|
||||
const grabAt = await json(`__gm.graph.nodeAt('n-axis')`);
|
||||
await drag(grabAt.x, grabAt.y, grabAt.x + 40, grabAt.y + 30);
|
||||
@@ -479,7 +479,7 @@ export async function graph(t) {
|
||||
await undo();
|
||||
await sleep(150);
|
||||
|
||||
// 13l. Delete acts on the selection, not just the primary node.
|
||||
// 6m. Delete acts on the selection, not just the primary node.
|
||||
await drag(corner.x, corner.y, opposite.x, opposite.y, { steps: 16 });
|
||||
const beforeDelete = await graphCounts();
|
||||
await press('Delete');
|
||||
@@ -498,7 +498,7 @@ export async function graph(t) {
|
||||
JSON.stringify(restoredAll),
|
||||
);
|
||||
|
||||
// 13m. Ctrl-click adds and removes single nodes. Fit first, so every node is really on screen
|
||||
// 6n. Ctrl-click adds and removes single nodes. Fit first, so every node is really on screen
|
||||
// and a click cannot miss one that the earlier pan pushed over the edge.
|
||||
await select({ kind: 'asset', assetId: 'mover' });
|
||||
await clickText('Fit');
|
||||
@@ -520,7 +520,7 @@ export async function graph(t) {
|
||||
JSON.stringify(await json('__gm.graph.selected()')),
|
||||
);
|
||||
|
||||
// 13n. Fit really fits: proven against a node parked far outside, and against the fact that the
|
||||
// 6o. Fit really fits: proven against a node parked far outside, and against the fact that the
|
||||
// graph is then wider than the canvas – otherwise centring alone would pass this check.
|
||||
await evaluate(
|
||||
`(() => { const n = __gm.state.project.assets.find(a => a.id === 'mover').graph.nodes.find(n => n.id === 'n-axis'); n.x += 2500; __gm.state.preview(); })()`,
|
||||
@@ -553,7 +553,7 @@ export async function graph(t) {
|
||||
await sleep(250);
|
||||
check('the parked node is back where it started', (await positions()) === before13i, await positions());
|
||||
|
||||
// 13o. Hiding a link: it stops being drawn across the graph but keeps carrying its value, and
|
||||
// 6p. Hiding a link: it stops being drawn across the graph but keeps carrying its value, and
|
||||
// comes back while either of its two nodes is selected.
|
||||
await select({ kind: 'asset', assetId: 'mover' });
|
||||
await clickText('Fit');
|
||||
@@ -628,7 +628,7 @@ export async function graph(t) {
|
||||
JSON.stringify(shown),
|
||||
);
|
||||
|
||||
// 13p. Two hidden links out of one port. Drawn per link they would stack stub, dot and name on
|
||||
// 6q. Two hidden links out of one port. Drawn per link they would stack stub, dot and name on
|
||||
// the very same pixels – the names smearing over each other. One port, one mark.
|
||||
const before13p = await graphCounts();
|
||||
const forkPort = await json(
|
||||
@@ -770,7 +770,7 @@ export async function graph(t) {
|
||||
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
|
||||
// 6r. 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' });
|
||||
await clickText('Fit');
|
||||
|
||||
@@ -25,7 +25,7 @@ export async function parenting(t) {
|
||||
stop,
|
||||
} = t;
|
||||
|
||||
// 14. Drag the blue crate's row onto the red crate's row: it becomes a child, keeps its world
|
||||
// 7a. Drag the blue crate's row onto the red crate's row: it becomes a child, keeps its world
|
||||
// position (local becomes the difference) and is listed right below its parent.
|
||||
await mutate(`
|
||||
const objs = __gm.state.activeScene.objects;
|
||||
@@ -53,7 +53,7 @@ export async function parenting(t) {
|
||||
);
|
||||
await shot('20-parented');
|
||||
|
||||
// 14a. Moving the parent (X field of the red crate) drags the child along in world space.
|
||||
// 7b. Moving the parent (X field of the red crate) drags the child along in world space.
|
||||
await clickText('Red crate');
|
||||
const xField = await locate('X');
|
||||
await click(xField.x + 160, xField.y);
|
||||
@@ -70,7 +70,7 @@ export async function parenting(t) {
|
||||
`${JSON.stringify(o2.position)} world=${JSON.stringify(worldOfO2)}`,
|
||||
);
|
||||
|
||||
// 14b. Play: entities mirror the hierarchy; undo restores parent and position.
|
||||
// 7c. Play: entities mirror the hierarchy; undo restores parent and position.
|
||||
await select(null);
|
||||
await press('Enter', { ctrl: true });
|
||||
await sleep(300);
|
||||
@@ -90,7 +90,7 @@ export async function parenting(t) {
|
||||
JSON.stringify(o2),
|
||||
);
|
||||
|
||||
// 14c. Parent via the inspector menu: the picker is searchable, Enter takes the single match.
|
||||
// 7d. Parent via the inspector menu: the picker is searchable, Enter takes the single match.
|
||||
await clickText('Blue crate');
|
||||
await clickText('None'); // the Parent dropdown, labelled with the current parent
|
||||
const entries = await json('__gm.overlayTexts()');
|
||||
@@ -128,7 +128,7 @@ export async function parenting(t) {
|
||||
JSON.stringify(o2),
|
||||
);
|
||||
|
||||
// 14d. The gap above a row is a different drop than the row itself: it orders the dragged
|
||||
// 7e. The gap above a row is a different drop than the row itself: it orders the dragged
|
||||
// object among that row's siblings instead of putting it inside, and moves nothing.
|
||||
const rowsBefore = await json('__gm.hierarchyOrder()');
|
||||
check(
|
||||
|
||||
@@ -26,7 +26,7 @@ export async function colliders(t) {
|
||||
stop,
|
||||
} = t;
|
||||
|
||||
// 15. Right-click the red crate → "Add collider": a collider asset sized like the crate
|
||||
// 8a. Right-click the red crate → "Add collider": a collider asset sized like the crate
|
||||
// (100×60) is placed as its child at the centre, selected and drawn in the overlay. The
|
||||
// seed already gives both crates a "Crate collider" child (oc1/oc2), so the added one is
|
||||
// undone again to keep exactly one collider per body for the checks below.
|
||||
@@ -59,7 +59,7 @@ export async function colliders(t) {
|
||||
(await objects()).length === objectsBefore && colliderAssetGone,
|
||||
);
|
||||
|
||||
// 15a. Wire "On collision enter → Log(other)" into the Mover (owner = red crate) and play with
|
||||
// 8b. Wire "On collision enter → Log(other)" into the Mover (owner = red crate) and play with
|
||||
// the crates overlapping: the log names the blue crate exactly once (enter, not stay),
|
||||
// and colliders are not rendered in play mode.
|
||||
await mutate(`
|
||||
@@ -89,7 +89,7 @@ export async function colliders(t) {
|
||||
);
|
||||
await stop();
|
||||
|
||||
// 15b. Solid vs trigger. Put the blue crate 150 to the right of the red one (red 100 wide, the
|
||||
// 8c. Solid vs trigger. Put the blue crate 150 to the right of the red one (red 100 wide, the
|
||||
// blue collider 140 wide → 30 of free travel) and hold ArrowRight: the Mover's Move
|
||||
// (Collide with solids) parks the red crate flush against the blue one at world x = −120.
|
||||
// Making the blue collider a trigger lets it pass through instead (drawn amber). A frame
|
||||
|
||||
+16
-16
@@ -55,7 +55,7 @@ export async function containers(t) {
|
||||
await sleep(250);
|
||||
};
|
||||
|
||||
// 16. Reset both crates to known spots, then Ctrl-click the second row in the hierarchy:
|
||||
// 9a. Reset both crates to known spots, then Ctrl-click the second row in the hierarchy:
|
||||
// both are selected, the inspector shows the multi view.
|
||||
await mutate(`
|
||||
const objs = __gm.state.activeScene.objects;
|
||||
@@ -85,7 +85,7 @@ export async function containers(t) {
|
||||
JSON.stringify(s.selection),
|
||||
);
|
||||
|
||||
// 16a. Rubber band on empty space around the red crate (world x −230…−90, y −50…50): picks
|
||||
// 9b. Rubber band on empty space around the red crate (world x −230…−90, y −50…50): picks
|
||||
// the objects whose position lies inside – the crate and its collider, not the Mover
|
||||
// (at y = 110), not the camera (at the origin).
|
||||
await drag(center.x - 230, center.y - 50, center.x - 90, center.y + 50);
|
||||
@@ -97,7 +97,7 @@ export async function containers(t) {
|
||||
);
|
||||
await shot('25-marquee');
|
||||
|
||||
// 16b. Dragging one of several selected objects moves all of them as one undo step.
|
||||
// 9c. Dragging one of several selected objects moves all of them as one undo step.
|
||||
const redScreen = await json(`__gm.objectScreen('o1')`);
|
||||
await select({ kind: 'object', holderId: 'scene1', objectId: 'o2', objectIds: ['o1', 'o2'] });
|
||||
await drag(redScreen.x, redScreen.y + 20, redScreen.x + 50, redScreen.y + 20);
|
||||
@@ -112,7 +112,7 @@ export async function containers(t) {
|
||||
(await object('o1')).position.x === -150 && (await object('o2')).position.x === 120,
|
||||
);
|
||||
|
||||
// 16c. Delete with a multi-selection removes all of them (with subtrees) as one step.
|
||||
// 9d. Delete with a multi-selection removes all of them (with subtrees) as one step.
|
||||
const listedBeforeDelete = (await objects()).length;
|
||||
await select({ kind: 'object', holderId: 'scene1', objectId: 'o2', objectIds: ['o1', 'o2'] });
|
||||
await press('Delete');
|
||||
@@ -124,7 +124,7 @@ export async function containers(t) {
|
||||
await undo();
|
||||
check('undo restores all of them', (await objects()).length === listedBeforeDelete);
|
||||
|
||||
// 16d. Create container: both crates, their colliders and the Mover *leave the scene*; one stored
|
||||
// 9e. 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;
|
||||
@@ -201,7 +201,7 @@ export async function containers(t) {
|
||||
);
|
||||
await shot('24-container-created');
|
||||
|
||||
// 16e. Dragging the container symbol in the viewport moves the whole group; undo restores it.
|
||||
// 9f. Dragging the container symbol in the viewport moves the whole group; undo restores it.
|
||||
const cScreen = await json(`__gm.objectScreen('${instanceA.id}')`);
|
||||
await drag(cScreen.x, cScreen.y, cScreen.x + 100, cScreen.y);
|
||||
check(
|
||||
@@ -212,7 +212,7 @@ export async function containers(t) {
|
||||
await undo();
|
||||
check('undo moves the container back', (await object(instanceA.id)).position.x === -15);
|
||||
|
||||
// 16f. Placing the container asset again adds exactly ONE stored object – and five more that
|
||||
// 9g. Placing the container asset again adds exactly ONE stored object – and five more that
|
||||
// the editor lists. Against the old copying model the stored count would jump by six.
|
||||
const spot = await json('__gm.viewportCenter()');
|
||||
await dragAsset(containerAsset.id, spot.x + 90, spot.y + 60);
|
||||
@@ -243,7 +243,7 @@ export async function containers(t) {
|
||||
);
|
||||
await shot('26-container-placed');
|
||||
|
||||
// 16g. A value edited on one instance is kept on that instance and nowhere else: the scene
|
||||
// 9h. A value edited on one instance is kept on that instance and nowhere else: the scene
|
||||
// grows by no object, the deviation lands in `instanceEdits` under the template path.
|
||||
await selectObject(redB);
|
||||
check('the inspector names the container the object comes from', await has(containerAsset.name));
|
||||
@@ -262,7 +262,7 @@ export async function containers(t) {
|
||||
JSON.stringify((await object(redA)).position),
|
||||
);
|
||||
|
||||
// 16h. Editing the template reaches every instance that has not been touched there – and only
|
||||
// 9i. Editing the template reaches every instance that has not been touched there – and only
|
||||
// those. The untouched axis of the edited object follows along with the rest.
|
||||
const setTemplate = (body) =>
|
||||
mutate(
|
||||
@@ -294,7 +294,7 @@ export async function containers(t) {
|
||||
(await object(redA)).position.x === -135 && (await object(redA)).name === 'Red crate',
|
||||
);
|
||||
|
||||
// 16i. Structure is the template's business: resolved content cannot be removed, re-parented,
|
||||
// 9j. Structure is the template's business: resolved content cannot be removed, re-parented,
|
||||
// given a collider or turned into a container of its own.
|
||||
// The hierarchy menu is the only way to a collider, and a disabled entry must not even reach
|
||||
// the command: the command refuses too, but with a notice.
|
||||
@@ -385,7 +385,7 @@ export async function containers(t) {
|
||||
);
|
||||
await shot('26b-structure-refused');
|
||||
|
||||
// 16j. Unpack: the content is written into the scene as ordinary objects under the very same
|
||||
// 9k. Unpack: the content is written into the scene as ordinary objects under the very same
|
||||
// ids, the instance stops following the template – and now its structure *can* be changed.
|
||||
await dragAsset(containerAsset.id, spot.x - 90, spot.y + 60);
|
||||
const instanceC = (await storedObjects()).find(
|
||||
@@ -465,7 +465,7 @@ export async function containers(t) {
|
||||
JSON.stringify({ now: (await storedObjects()).length, was: storedBeforeUnpack }),
|
||||
);
|
||||
|
||||
// 16k. Spawning a container at runtime spells it out the same way Unpack does, under the same
|
||||
// 9l. Spawning a container at runtime spells it out the same way Unpack does, under the same
|
||||
// derived ids – and Stop throws the whole run away with it.
|
||||
const spawnParam = (assetId) =>
|
||||
mutate(
|
||||
@@ -495,7 +495,7 @@ export async function containers(t) {
|
||||
check('stop discards the spawned instance', (await objects()).length === beforeSpawn.length);
|
||||
await spawnParam('crate');
|
||||
|
||||
// 16l. Apply / revert on resolved content. The red crate of instance B was moved on x, the one
|
||||
// 9m. Apply / revert on resolved content. The red crate of instance B was moved on x, the one
|
||||
// of instance A still matches the template; ↑ writes the value into the template and A
|
||||
// follows, ↺ drops the deviation so B follows again.
|
||||
const templateRed = async () => (await asset(containerAsset.id)).objects.find((o) => o.id === redAPath);
|
||||
@@ -528,7 +528,7 @@ export async function containers(t) {
|
||||
JSON.stringify(revertedB.instanceEdits),
|
||||
);
|
||||
|
||||
// 16m. An override on resolved content deviates from two bases at once – the template it
|
||||
// 9n. An override on resolved content deviates from two bases at once – the template it
|
||||
// follows and, one step further up, the asset – so apply offers a menu. Reset never goes
|
||||
// past the nearest base, so it stays a single button.
|
||||
await selectObject(redB);
|
||||
@@ -574,7 +574,7 @@ export async function containers(t) {
|
||||
JSON.stringify({ template: (await templateRed()).overrides, a: (await object(redA)).overrides }),
|
||||
);
|
||||
|
||||
// 16n. redB matches its template again (both 77) and has nothing of its own left to reset –
|
||||
// 9o. redB matches its template again (both 77) and has nothing of its own left to reset –
|
||||
// but the value still differs from the asset (100), which apply can still reach past it.
|
||||
await sleep(200);
|
||||
check(
|
||||
@@ -593,7 +593,7 @@ export async function containers(t) {
|
||||
.objects.find(o => o.id === '${redAPath}').overrides.width`,
|
||||
);
|
||||
|
||||
// 16o. Save and load: an instance is a container object plus its deviations, nothing else.
|
||||
// 9p. Save and load: an instance is a container object plus its deviations, nothing else.
|
||||
// The content must be nowhere in the saved scene, and reloading must resolve it back.
|
||||
await selectObject(redB);
|
||||
await setField('Y', 12);
|
||||
|
||||
@@ -18,7 +18,7 @@ export async function textAndEnabled(t) {
|
||||
typeText,
|
||||
} = t;
|
||||
|
||||
// 17. Text assets: created through the + menu, placed, and rendered as real text in the scene.
|
||||
// 10a. Text assets: created through the + menu, placed, and rendered as real text in the scene.
|
||||
await clickText('+');
|
||||
await clickText('Text');
|
||||
await sleep(100);
|
||||
@@ -35,7 +35,7 @@ export async function textAndEnabled(t) {
|
||||
check('a placed text object draws its content in the scene', await has('Hello world'));
|
||||
await shot('29-text-object');
|
||||
|
||||
// 17a. The enabled switch: every object has it, switching it off is a normal override, and a
|
||||
// 10b. The enabled switch: every object has it, switching it off is a normal override, and a
|
||||
// switched-off object disappears from the scene view too (it stays in the hierarchy).
|
||||
const enabledRow = await json("__gm.inspectorField('Enabled')");
|
||||
check('the inspector shows the Enabled switch for a scene object', Boolean(enabledRow));
|
||||
@@ -89,7 +89,7 @@ export async function textAndEnabled(t) {
|
||||
check('and it is still not drawn', !(await json('__gm.drawOrder()')).includes('otext'));
|
||||
await shot('30b-disabled-gizmo');
|
||||
|
||||
// 17b. In the running game a switched-off object does not exist: it is not rendered, and
|
||||
// 10c. In the running game a switched-off object does not exist: it is not rendered, and
|
||||
// switching it back on brings it back.
|
||||
await play();
|
||||
await sleep(200);
|
||||
@@ -105,7 +105,7 @@ export async function textAndEnabled(t) {
|
||||
await stop();
|
||||
check('stop leaves the play-mode switch behind', (await object('otext')).overrides.enabled === false);
|
||||
|
||||
// 17c. A field that shows no decimals must not store any either. Font size is such a field, so
|
||||
// 10d. A field that shows no decimals must not store any either. Font size is such a field, so
|
||||
// typing a fraction has to round: showing "33" while the project holds 32.6 would be a lie.
|
||||
const fontLabel = await locate('Font size');
|
||||
check('Font size reads as a whole number', Boolean(await has('32')) && !(await has('32.000')));
|
||||
|
||||
@@ -24,7 +24,7 @@ export async function menus(t) {
|
||||
__gm.state.select({ kind: 'object', holderId: 'scene1', objectIds: ['otext'], objectId: 'otext' })`);
|
||||
await sleep(150);
|
||||
|
||||
// 18. A picker with 40+ entries is capped in height and stays fully on screen instead of
|
||||
// 11a. A picker with 40+ entries is capped in height and stays fully on screen instead of
|
||||
// running off the bottom edge.
|
||||
const parentRow = await json("__gm.inspectorField('Parent')");
|
||||
await click(parentRow.x, parentRow.y);
|
||||
@@ -42,7 +42,7 @@ 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.
|
||||
// 11b. The entries scroll under the wheel; the search box above them does not move.
|
||||
// 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 '));
|
||||
|
||||
@@ -48,7 +48,7 @@ export async function textures(t) {
|
||||
await evaluate('document.activeElement instanceof HTMLElement && document.activeElement.blur()');
|
||||
await sleep(200);
|
||||
|
||||
// 19. Pasting an image imports it: uploaded, stored under its hash, and made an asset.
|
||||
// 12a. Pasting an image imports it: uploaded, stored under its hash, and made an asset.
|
||||
await evaluate(PASTE_IMAGE);
|
||||
await sleep(2500);
|
||||
|
||||
@@ -68,7 +68,7 @@ export async function textures(t) {
|
||||
JSON.stringify({ w: texture.w, h: texture.h }),
|
||||
);
|
||||
|
||||
// 19a. Importing the same picture again reuses the asset: the server hands back the file it
|
||||
// 12b. Importing the same picture again reuses the asset: the server hands back the file it
|
||||
// already has, since the name is the content hash.
|
||||
await evaluate(PASTE_IMAGE);
|
||||
await sleep(2500);
|
||||
@@ -86,7 +86,7 @@ export async function textures(t) {
|
||||
JSON.stringify(await json('__gm.state.notice')),
|
||||
);
|
||||
|
||||
// 19b. The picture is actually drawn – a sprite wearing it is no longer one flat colour.
|
||||
// 12c. The picture is actually drawn – a sprite wearing it is no longer one flat colour.
|
||||
// On a fresh white sprite of its own, so the sample does not depend on eleven steps of edits
|
||||
// to the fixture. Cloned rather than written as a literal: a literal missed `editorOffset`,
|
||||
// which sent the position to NaN and culled the object without an error.
|
||||
@@ -188,7 +188,7 @@ export async function textures(t) {
|
||||
await mutate(enlarge(160, 120));
|
||||
await sleep(400);
|
||||
|
||||
// 19c. The colour multiplies the picture, so a tinted white quadrant stops being white.
|
||||
// 12d. The colour multiplies the picture, so a tinted white quadrant stops being white.
|
||||
await mutate("__gm.state.project.assets.find((a) => a.id === 'tex-sprite').props.color = 0x3366ff");
|
||||
await sleep(600);
|
||||
const tinted = await at(0.3, 0.3);
|
||||
@@ -198,7 +198,7 @@ export async function textures(t) {
|
||||
JSON.stringify({ was: bottomRight, tinted }),
|
||||
);
|
||||
|
||||
// 19d. A reference that no longer resolves falls back to the plain colour instead of failing.
|
||||
// 12e. A reference that no longer resolves falls back to the plain colour instead of failing.
|
||||
await mutate("__gm.state.project.assets.find((a) => a.id === 'tex-sprite').props.texture = 'gone'");
|
||||
await sleep(600);
|
||||
const fallback = await at(0.3, 0.3);
|
||||
@@ -209,7 +209,7 @@ export async function textures(t) {
|
||||
);
|
||||
}
|
||||
|
||||
// 19e. "+ → Texture" offers the pictures the server already holds, unreferenced ones included:
|
||||
// 12f. "+ → 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);
|
||||
@@ -315,7 +315,7 @@ export async function textures(t) {
|
||||
(await json("__gm.state.project.assets.filter((a) => a.type === 'texture').length")) === 1,
|
||||
);
|
||||
|
||||
// 19f. Right-clicking a texture asset makes a sprite wearing it at the picture's pixel size.
|
||||
// 12g. Right-clicking a texture asset makes a sprite wearing it at the picture's pixel size.
|
||||
const entryText = 'Create sprite from texture';
|
||||
const spriteRow = await json("__gm.assetRow('crate')");
|
||||
await click(spriteRow.x, spriteRow.y, 'right');
|
||||
|
||||
@@ -36,7 +36,7 @@ function nearestColour(sample) {
|
||||
export async function frames(t) {
|
||||
const { check, sleep, json, evaluate, shot, clickText, drag, press, undo } = t;
|
||||
|
||||
// 20. The tab exists and shows the texture the selection points at.
|
||||
// 13a. The tab exists and shows the texture the selection points at.
|
||||
await press('Escape');
|
||||
await evaluate('__gm.state.select(null)');
|
||||
const texture = await json("(__gm.state.project.assets.find((a) => a.type === 'texture') ?? {}).id ?? ''");
|
||||
@@ -51,7 +51,7 @@ export async function frames(t) {
|
||||
);
|
||||
await shot('33-texture-tab');
|
||||
|
||||
// 21. Dragging on the picture cuts a frame. Aimed at the top-right quadrant (32,0)-(64,24),
|
||||
// 13b. Dragging on the picture cuts a frame. Aimed at the top-right quadrant (32,0)-(64,24),
|
||||
// inset by a pixel: a press on a frame edge is read as a resize, not a new rectangle.
|
||||
const from = await json('__gm.texture.pointAt(33, 1)');
|
||||
const to = await json('__gm.texture.pointAt(63, 23)');
|
||||
@@ -81,7 +81,7 @@ export async function frames(t) {
|
||||
);
|
||||
await shot('34-frame-cut');
|
||||
|
||||
// 21a. It is one undo step, not one per pointer move.
|
||||
// 13c. It is one undo step, not one per pointer move.
|
||||
await undo();
|
||||
await sleep(400);
|
||||
check(
|
||||
@@ -93,7 +93,7 @@ export async function frames(t) {
|
||||
await t.redo();
|
||||
await sleep(400);
|
||||
|
||||
// 22. A sprite wearing the frame shows that part of the sheet and nothing else.
|
||||
// 13d. A sprite wearing the frame shows that part of the sheet and nothing else.
|
||||
await evaluate(`(() => {
|
||||
const sprite = __gm.state.project.assets.find((a) => a.id === 'tex-sprite');
|
||||
sprite.props.color = 0xffffff;
|
||||
@@ -120,7 +120,7 @@ export async function frames(t) {
|
||||
await shot('35-sprite-wearing-a-frame');
|
||||
}
|
||||
|
||||
// 23. Slicing offers a live count before it commits.
|
||||
// 13e. Slicing offers a live count before it commits.
|
||||
// Repainted magenta first: magenta is nowhere in the sheet, so the fallback check below
|
||||
// cannot be confused with a part of the picture.
|
||||
await evaluate(`(() => {
|
||||
@@ -156,7 +156,7 @@ export async function frames(t) {
|
||||
check('and numbers them in reading order', sliced[0]?.startsWith('Frame 1 '), JSON.stringify(sliced[0]));
|
||||
await shot('36-sliced-sheet');
|
||||
|
||||
// 23a. A sprite pointed at a frame slicing removed falls back to its colour.
|
||||
// 13f. A sprite pointed at a frame slicing removed falls back to its colour.
|
||||
await evaluate("__gm.showTab('scene')");
|
||||
await sleep(700);
|
||||
if (box) {
|
||||
|
||||
@@ -28,7 +28,7 @@ export async function overflow(t) {
|
||||
__gm.state.select(null)`);
|
||||
await sleep(600);
|
||||
|
||||
// 21. The pickers keep the width they were given instead of growing with the name they show.
|
||||
// 14a. 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(
|
||||
@@ -38,7 +38,7 @@ export async function overflow(t) {
|
||||
);
|
||||
}
|
||||
|
||||
// 21a. Every tab, because each one owns a different set of controls.
|
||||
// 14b. Every tab, because each one owns a different set of controls.
|
||||
for (const tab of ['scene', 'graph', 'texture', 'game', 'container', 'list']) {
|
||||
await evaluate(`__gm.showTab(${JSON.stringify(tab)})`);
|
||||
await sleep(400);
|
||||
@@ -50,7 +50,7 @@ export async function overflow(t) {
|
||||
);
|
||||
}
|
||||
|
||||
// 21b. Shortened, not simply dropped: the value must still be readable and marked as cut.
|
||||
// 14c. Shortened, not simply dropped: the value must still be readable and marked as cut.
|
||||
await evaluate("__gm.showTab('graph')");
|
||||
await sleep(400);
|
||||
const shown = (await json('__gm.textOverflows(true)')).find((e) => e.text.startsWith('Player movement'));
|
||||
@@ -61,7 +61,7 @@ export async function overflow(t) {
|
||||
);
|
||||
await shot('36-long-labels');
|
||||
|
||||
// 21c. An inspector full of dropdowns – the parent row and the enum fields – with a selection
|
||||
// 14d. An inspector full of dropdowns – the parent row and the enum fields – with a selection
|
||||
// whose asset name is now overlong too.
|
||||
await evaluate("__gm.showTab('scene')");
|
||||
await sleep(300);
|
||||
@@ -70,7 +70,7 @@ export async function overflow(t) {
|
||||
const inspector = await json('__gm.textOverflows()');
|
||||
check('no label spills in the inspector', inspector.length === 0, JSON.stringify(inspector));
|
||||
|
||||
// 21d. The other direction: a control narrow enough to swallow its whole label draws nothing
|
||||
// 14e. The other direction: a control narrow enough to swallow its whole label draws nothing
|
||||
// at all, which the overflow walk cannot see – an empty text spills over no edge.
|
||||
for (const tab of ['scene', 'graph', 'texture', 'container', 'list']) {
|
||||
await evaluate(`__gm.showTab(${JSON.stringify(tab)})`);
|
||||
|
||||
@@ -24,7 +24,7 @@ export async function aspect(t) {
|
||||
const sheet = await json("(__gm.state.project.assets.find((a) => a.type === 'texture') ?? {}).id ?? ''");
|
||||
check('the imported sheet is still there', sheet !== '', sheet);
|
||||
|
||||
// 26. A sprite wearing nothing has nothing to be proportional to, so the buttons are absent.
|
||||
// 15a. A sprite wearing nothing has nothing to be proportional to, so the buttons are absent.
|
||||
// Edited as an asset: one base value per row instead of an override, so the numbers below
|
||||
// say what they mean.
|
||||
await mutate(`
|
||||
@@ -38,7 +38,7 @@ export async function aspect(t) {
|
||||
check('no texture, no aspect buttons', (await sizeButton('↔')) === '' && (await sizeButton('↕')) === '');
|
||||
await shot('40-aspect-without-a-texture');
|
||||
|
||||
// 27. Wearing the 64 x 48 sheet, a 100 x 100 sprite is neither proportional nor at pixel size,
|
||||
// 15b. Wearing the 64 x 48 sheet, a 100 x 100 sprite is neither proportional nor at pixel size,
|
||||
// so both rows offer two things and show the menu form.
|
||||
await mutate(`${spriteProp('texture')} = ${JSON.stringify(sheet)}`);
|
||||
await sleep(500);
|
||||
@@ -48,7 +48,7 @@ export async function aspect(t) {
|
||||
);
|
||||
await shot('41-aspect-buttons');
|
||||
|
||||
// 28. Fit moves the width only, to the sheet's ratio: 100 x 64/48.
|
||||
// 15c. Fit moves the width only, to the sheet's ratio: 100 x 64/48.
|
||||
await clickText('↔ ▾');
|
||||
await sleep(400);
|
||||
await clickText('Fit to 64 × 48');
|
||||
@@ -65,7 +65,7 @@ export async function aspect(t) {
|
||||
);
|
||||
await shot('42-aspect-fitted');
|
||||
|
||||
// 28a. One command, so it is one undo step – and the menu form comes back with the old shape.
|
||||
// 15d. One command, so it is one undo step – and the menu form comes back with the old shape.
|
||||
await undo();
|
||||
await sleep(500);
|
||||
check('undo takes the width back', (await json(spriteProp('width'))) === 100);
|
||||
@@ -73,7 +73,7 @@ export async function aspect(t) {
|
||||
await t.redo();
|
||||
await sleep(500);
|
||||
|
||||
// 28b. What is left over is the pixel size, now a direct button: it writes *both* sides, and
|
||||
// 15e. What is left over is the pixel size, now a direct button: it writes *both* sides, and
|
||||
// the whole button goes because there is nothing left to offer.
|
||||
await clickText('↔');
|
||||
await sleep(500);
|
||||
@@ -96,7 +96,7 @@ export async function aspect(t) {
|
||||
await t.redo();
|
||||
await sleep(500);
|
||||
|
||||
// 29. A frame is the picture the sprite wears, not the sheet it was cut from: 40 x 10 against
|
||||
// 15f. A frame is the picture the sprite wears, not the sheet it was cut from: 40 x 10 against
|
||||
// the sheet's 64 x 48, so the two ratios cannot be confused for one another.
|
||||
await mutate(`
|
||||
const asset = __gm.state.project.assets.find((a) => a.id === ${JSON.stringify(sheet)});
|
||||
@@ -118,7 +118,7 @@ export async function aspect(t) {
|
||||
);
|
||||
check('and only the pixel size is left to offer', (await sizeButton('↕')) === 'plain');
|
||||
|
||||
// 30. The same rows on a placed object write an override rather than the asset value.
|
||||
// 15g. The same rows on a placed object write an override rather than the asset value.
|
||||
await mutate(`${spriteProp('texture')} = ${JSON.stringify(sheet)}`);
|
||||
await select({ kind: 'object', holderId: 'scene1', objectIds: ['otex'], objectId: 'otex' });
|
||||
await sleep(600);
|
||||
@@ -140,7 +140,7 @@ export async function aspect(t) {
|
||||
);
|
||||
await shot('44-aspect-on-an-object');
|
||||
|
||||
// 30a. The pixel size on an object writes both overrides, and as one undo step.
|
||||
// 15h. The pixel size on an object writes both overrides, and as one undo step.
|
||||
await clickText('↔');
|
||||
await sleep(500);
|
||||
const sized = await json("__gm.state.activeScene.objects.find((o) => o.id === 'otex').overrides");
|
||||
|
||||
+11
-11
@@ -37,7 +37,7 @@ export async function containerTab(t) {
|
||||
);
|
||||
await sleep(200);
|
||||
|
||||
// 22. The tab opens on the container the project has, and its header says which one.
|
||||
// 16a. The tab opens on the container the project has, and its header says which one.
|
||||
await clickText('Container');
|
||||
await sleep(500);
|
||||
const template = await json(`__gm.state.project.assets.find(a => a.id === '${containerId}').objects`);
|
||||
@@ -60,7 +60,7 @@ export async function containerTab(t) {
|
||||
);
|
||||
await shot('30-container-tab');
|
||||
|
||||
// 22a. The hierarchy follows the visible tab: it lists the template, not the scene.
|
||||
// 16b. The hierarchy follows the visible tab: it lists the template, not the scene.
|
||||
const rows = await json('__gm.hierarchyOrder()');
|
||||
check(
|
||||
'the hierarchy lists the template objects while the tab is open',
|
||||
@@ -68,7 +68,7 @@ export async function containerTab(t) {
|
||||
JSON.stringify(rows),
|
||||
);
|
||||
|
||||
// 22b. Clicking a template object selects it against the container as its holder – the same
|
||||
// 16c. Clicking a template object selects it against the container as its holder – the same
|
||||
// id set as the scene would give, but a different holder.
|
||||
const root = template.find((o) => o.parentId === '');
|
||||
const at = await json(`__gm.container.objectScreen(${JSON.stringify(root.id)})`);
|
||||
@@ -82,7 +82,7 @@ export async function containerTab(t) {
|
||||
JSON.stringify(s.selection),
|
||||
);
|
||||
|
||||
// 22c. The point of the tab: a drag writes the template, and every instance follows it – except
|
||||
// 16d. The point of the tab: a drag writes the template, and every instance follows it – except
|
||||
// one that was moved there on its own, whose deviation the instance keeps.
|
||||
const templateX = () =>
|
||||
json(
|
||||
@@ -132,7 +132,7 @@ export async function containerTab(t) {
|
||||
);
|
||||
await setDeviation(`delete edits['${root.id}']`);
|
||||
|
||||
// 22d. An asset dragged from the panel lands in the view it is dropped on: dropping on this
|
||||
// 16e. An asset dragged from the panel lands in the view it is dropped on: dropping on this
|
||||
// tab adds one object to the template, not to the scene.
|
||||
// What the scene *stores* – that must not move. What it *lists* has to grow by one per placed
|
||||
// instance instead, since every one of them resolves the new template object too.
|
||||
@@ -158,7 +158,7 @@ export async function containerTab(t) {
|
||||
);
|
||||
await shot('31-container-object-added');
|
||||
|
||||
// 22e. Delete works on a template object like on any other, as one undo step.
|
||||
// 16f. Delete works on a template object like on any other, as one undo step.
|
||||
await press('Delete');
|
||||
await sleep(250);
|
||||
check(
|
||||
@@ -174,7 +174,7 @@ export async function containerTab(t) {
|
||||
await press('Delete');
|
||||
await sleep(250);
|
||||
|
||||
// 22f. Fit frames the content. Proven against an object parked far outside the view: without
|
||||
// 16g. Fit frames the content. Proven against an object parked far outside the view: without
|
||||
// fitting it is nowhere near the canvas.
|
||||
await evaluate(
|
||||
`(() => { const t = __gm.state.project.assets.find(a => a.id === '${containerId}').objects.find(o => o.id === '${root.id}'); t.position.x = 3000; __gm.state.preview(); })()`,
|
||||
@@ -198,7 +198,7 @@ export async function containerTab(t) {
|
||||
await sleep(300);
|
||||
await shot('32-container-fitted');
|
||||
|
||||
// 22g. A template object exists only in this tab, so selecting one from elsewhere switches to
|
||||
// 16h. A template object exists only in this tab, so selecting one from elsewhere switches to
|
||||
// it – the rule a graph node and a texture frame already follow. A scene object does not.
|
||||
await evaluate("__gm.showTab('scene')");
|
||||
await sleep(300);
|
||||
@@ -221,7 +221,7 @@ export async function containerTab(t) {
|
||||
JSON.stringify(await json('__gm.hierarchyOrder()')),
|
||||
);
|
||||
|
||||
// 22h. Switching to another container is a plain asset selection – the same thing the header's
|
||||
// 16i. Switching to another container is a plain asset selection – the same thing the header's
|
||||
// picker does. The hierarchy has to follow it, title and rows both.
|
||||
await evaluate("__gm.showTab('container')");
|
||||
await sleep(300);
|
||||
@@ -286,7 +286,7 @@ export async function containerTab(t) {
|
||||
JSON.stringify({ before: ownBefore.length, after: ownAfter.length }),
|
||||
);
|
||||
|
||||
// 22i. Nesting: another container, though, may go in – and the tab it lands in stays put.
|
||||
// 16j. Nesting: another container, though, may go in – and the tab it lands in stays put.
|
||||
const nested = await json("__gm.state.project.assets.find(a => a.id === 'c2').objects");
|
||||
await dragAsset(containerId, canvas2.x - 40, canvas2.y + 40);
|
||||
const nestedAfter = await json("__gm.state.project.assets.find(a => a.id === 'c2').objects");
|
||||
@@ -298,7 +298,7 @@ export async function containerTab(t) {
|
||||
JSON.stringify({ before: nested.length, after: nestedAfter.length }),
|
||||
);
|
||||
|
||||
// 22j. Nesting resolves all the way down: placing the outer container in the scene stores one
|
||||
// 16k. Nesting resolves all the way down: placing the outer container in the scene stores one
|
||||
// object, and the inner container's content appears under `<instance>~<outer>/<inner>`.
|
||||
const innerObject = nestedAfter.find((o) => o.assetId === containerId);
|
||||
const innerTemplate = await json(`__gm.state.project.assets.find(a => a.id === '${containerId}').objects`);
|
||||
|
||||
@@ -45,7 +45,7 @@ const driftGraph = {
|
||||
export async function editorRun(t) {
|
||||
const { check, sleep, evaluate, json, shot, click, holdKey, mutate, undo, object, api, projectId } = t;
|
||||
|
||||
// 18a. A box with a drifting behavior below it. Written into the model directly: what is under
|
||||
// 17a. A box with a drifting behavior below it. Written into the model directly: what is under
|
||||
// test is the switch, not another way of building a graph.
|
||||
await mutate(`
|
||||
const p = __gm.state.project;
|
||||
@@ -74,7 +74,7 @@ export async function editorRun(t) {
|
||||
`${JSON.stringify(idle)} x=${startX}`,
|
||||
);
|
||||
|
||||
// 18b. The switch itself, through the inspector. It belongs with Enabled and above the
|
||||
// 17b. The switch itself, through the inspector. It belongs with Enabled and above the
|
||||
// VARIABLES section: it is a switch of the behavior, not something the behavior declares.
|
||||
await evaluate(
|
||||
`__gm.state.select({ kind: 'object', holderId: __gm.state.project.activeSceneId, objectId: 'er-logic' })`,
|
||||
@@ -110,7 +110,7 @@ export async function editorRun(t) {
|
||||
`${JSON.stringify(running)} mode=${mode}`,
|
||||
);
|
||||
|
||||
// 18c. It really drives its owner, and it is the stored scene object that moves – no runtime
|
||||
// 17c. It really drives its owner, and it is the stored scene object that moves – no runtime
|
||||
// copy is involved here, which is the whole difference to playing.
|
||||
const before = await boxX();
|
||||
await sleep(600);
|
||||
@@ -122,7 +122,7 @@ export async function editorRun(t) {
|
||||
);
|
||||
await shot('36-editor-run');
|
||||
|
||||
// 18d. The point of this semantic: the change is a real edit, so it reaches the server.
|
||||
// 17d. The point of this semantic: the change is a real edit, so it reaches the server.
|
||||
const first = await api(`projects/${projectId}`);
|
||||
await sleep(2000);
|
||||
const second = await api(`projects/${projectId}`);
|
||||
@@ -142,7 +142,7 @@ export async function editorRun(t) {
|
||||
`${saves} saves in 2 s`,
|
||||
);
|
||||
|
||||
// 18e. Taking the switch back stops it again.
|
||||
// 17e. Taking the switch back stops it again.
|
||||
await click(runBox.x, runBox.y);
|
||||
await sleep(300);
|
||||
const stoppedAt = await boxX();
|
||||
@@ -154,7 +154,7 @@ export async function editorRun(t) {
|
||||
`${JSON.stringify(stopped)} x=${stoppedAt}`,
|
||||
);
|
||||
|
||||
// 18f. Undo covers the two clicks on the switch but nothing the behavior did while on. The first
|
||||
// 17f. 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();
|
||||
@@ -168,7 +168,7 @@ export async function editorRun(t) {
|
||||
`overrides=${JSON.stringify((await object('er-logic')).overrides)} x=${afterUndo.position.x}, started at ${startX}`,
|
||||
);
|
||||
|
||||
// 18g. The case a real project has: the behavior sits inside a *placed container* – a level
|
||||
// 17g. The case a real project has: the behavior sits inside a *placed container* – a level
|
||||
// packed up once and dropped into the scene. The scene stores the container object alone,
|
||||
// so this only works if the run resolves the template instead of expecting stored objects.
|
||||
await mutate(`
|
||||
@@ -213,7 +213,7 @@ export async function editorRun(t) {
|
||||
`stored=${JSON.stringify(stored.filter((id) => id.startsWith('er-')))} edit.x=${edits['t-box']?.position?.x} template.x=${template.x}`,
|
||||
);
|
||||
|
||||
// 18h. The keyboard. It is the editor's while the scene is on screen – a name being typed or the
|
||||
// 17h. The keyboard. It is the editor's while the scene is on screen – a name being typed or the
|
||||
// arrow keys walking the hierarchy must not steer a game at the same time. The game tab is
|
||||
// where no key means anything to the editor, so that is where a behavior may have them.
|
||||
await mutate(`
|
||||
|
||||
@@ -51,7 +51,7 @@ export async function pause(t) {
|
||||
const boxX = async () => (await entity('p-box'))?.x ?? NaN;
|
||||
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
|
||||
// 18a. 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.
|
||||
const rig = await scaffoldBehavior(t, {
|
||||
assetId: 'pauser',
|
||||
@@ -75,7 +75,7 @@ export async function pause(t) {
|
||||
`x ${beforeDrift} → ${drifted}, ${JSON.stringify(running)}`,
|
||||
);
|
||||
|
||||
// 19b. The toolbar button holds it. Both the clock and the box have to stop – a world that keeps
|
||||
// 18b. The toolbar button holds it. Both the clock and the box have to stop – a world that keeps
|
||||
// ticking while the picture happens to look the same would pass a position check alone.
|
||||
await clickText('❚❚ Pause');
|
||||
await sleep(200);
|
||||
@@ -100,7 +100,7 @@ export async function pause(t) {
|
||||
);
|
||||
await shot('36-paused');
|
||||
|
||||
// 19c. Keys pressed while it is held are dropped, not queued: resuming must not fire an edge for
|
||||
// 18c. Keys pressed while it is held are dropped, not queued: resuming must not fire an edge for
|
||||
// each of them. The count before proves the graph reacts at all.
|
||||
const hitsBefore = await keyHits();
|
||||
await press('ArrowRight');
|
||||
@@ -110,7 +110,7 @@ export async function pause(t) {
|
||||
await press('ArrowRight');
|
||||
await sleep(200);
|
||||
|
||||
// 19d. Ctrl+Enter resumes, the same shortcut that started play.
|
||||
// 18d. Ctrl+Enter resumes, the same shortcut that started play.
|
||||
await press('Enter', { ctrl: true });
|
||||
await sleep(400);
|
||||
const resumed = await playInfo();
|
||||
@@ -139,7 +139,7 @@ export async function pause(t) {
|
||||
`${hitsBefore} → ${await keyHits()}`,
|
||||
);
|
||||
|
||||
// 19e. A hold must never outlive its play: Esc stops from a paused game, and the next run starts
|
||||
// 18e. A hold must never outlive its play: Esc stops from a paused game, and the next run starts
|
||||
// moving. A pause left standing would leave the editor unable to run anything.
|
||||
await press('Enter', { ctrl: true });
|
||||
await sleep(200);
|
||||
@@ -159,7 +159,7 @@ export async function pause(t) {
|
||||
`after stop ${JSON.stringify(afterStop)}, x ${restartedX} → ${await boxX()}`,
|
||||
);
|
||||
|
||||
// 19f. The graph stays editable while the game runs, with the mouse as in edit mode: a click
|
||||
// 18f. The graph stays editable while the game runs, with the mouse as in edit mode: a click
|
||||
// selects the Move node, Delete removes it, and the running world stops drifting the box.
|
||||
await select({ kind: 'asset', assetId: 'pauser' });
|
||||
await evaluate(`__gm.showTab('graph')`);
|
||||
@@ -244,7 +244,7 @@ export async function pause(t) {
|
||||
JSON.stringify((await asset('pauser')).graph.nodes.map((n) => n.id)),
|
||||
);
|
||||
|
||||
// 19g. Play discarded its copy, so the box is back at the position the model holds.
|
||||
// 18g. Play discarded its copy, so the box is back at the position the model holds.
|
||||
const left = await rig.remove();
|
||||
check(
|
||||
'the step leaves nothing of its own behind',
|
||||
|
||||
+12
-12
@@ -33,7 +33,7 @@ export async function listTab(t) {
|
||||
`);
|
||||
await sleep(150);
|
||||
|
||||
// 1. The inspector shows the count, not a comma-separated line.
|
||||
// 21a. The inspector shows the count, not a comma-separated line.
|
||||
const button = await find('3 items');
|
||||
check('a list property reads as its item count', button !== null, JSON.stringify(button));
|
||||
check('the editor is still on the scene tab', (await json('__gm.tab()')) === 'scene');
|
||||
@@ -48,7 +48,7 @@ export async function listTab(t) {
|
||||
check('and the inspector is still beside it', (await find('VARIABLES')) !== null);
|
||||
await shot('02-list-tab');
|
||||
|
||||
// 3. A row edits its item, and it is one undo step.
|
||||
// 21b. A row edits its item, and it is one undo step.
|
||||
const second = await json('__gm.list.fieldAt(1)');
|
||||
await click(second.x, second.y);
|
||||
await typeText('42');
|
||||
@@ -60,7 +60,7 @@ export async function listTab(t) {
|
||||
info = await json('__gm.list.info()');
|
||||
check('undo takes just that item back', JSON.stringify(info.items) === '["30","11","44"]', info.items[1]);
|
||||
|
||||
// 4. A short list opened after a long one. Its rows leave the content height pinned to the
|
||||
// 21c. A short list opened after a long one. Its rows leave the content height pinned to the
|
||||
// viewport, so a layout skipped on "same size" would stack them all at the top.
|
||||
await mutate(`
|
||||
const a = __gm.state.project.assets.find((x) => x.id === 'listy');
|
||||
@@ -83,7 +83,7 @@ export async function listTab(t) {
|
||||
JSON.stringify(shortRows),
|
||||
);
|
||||
|
||||
// 5. `+ Item` asks for a kind, since the list does not hold one for it.
|
||||
// 21d. `+ Item` asks for a kind, since the list does not hold one for it.
|
||||
await clickText('+ Item');
|
||||
await sleep(150);
|
||||
check(
|
||||
@@ -104,7 +104,7 @@ export async function listTab(t) {
|
||||
check('undo removes it again', (await json('__gm.list.info()')).items.length === 3);
|
||||
await shot('03-rows');
|
||||
|
||||
// 6. Back on the scene tab the button carries the current count.
|
||||
// 21e. Back on the scene tab the button carries the current count.
|
||||
await mutate('__gm.state.openList(null)');
|
||||
await sleep(200);
|
||||
check('closing the list leaves the tab', (await json('__gm.tab()')) === 'scene');
|
||||
@@ -115,7 +115,7 @@ export async function listTab(t) {
|
||||
await sleep(200);
|
||||
check('and the count follows the value', (await find('2 items')) !== null);
|
||||
|
||||
// 6b. A list declares nothing about its items, so the kind sits on the row: the mark in front of
|
||||
// 21f. A list declares nothing about its items, so the kind sits on the row: the mark in front of
|
||||
// it is the picker, and picking changes that one item and no other.
|
||||
const numsOf = () => json(`__gm.state.project.assets.find((a) => a.id === 'listy')`);
|
||||
await mutate("__gm.state.openList({ kind: 'asset', assetId: 'listy', key: 'nums' })");
|
||||
@@ -146,7 +146,7 @@ export async function listTab(t) {
|
||||
JSON.stringify(listy.props.nums),
|
||||
);
|
||||
|
||||
// 6c. A colour is the one kind a stored number cannot say for itself, so it is tagged.
|
||||
// 21g. A colour is the one kind a stored number cannot say for itself, so it is tagged.
|
||||
mark = await json('__gm.list.markAt(1)');
|
||||
await click(mark.x, mark.y);
|
||||
await sleep(150);
|
||||
@@ -167,7 +167,7 @@ export async function listTab(t) {
|
||||
await undo();
|
||||
await sleep(200);
|
||||
|
||||
// 6d. The second tagged kind: a picture is an id, which stored on its own would be a plain text.
|
||||
// 21h. The second tagged kind: a picture is an id, which stored on its own would be a plain text.
|
||||
// The row carries the picker the `texture` property has, frames of a sheet included.
|
||||
// The pictures the earlier steps imported are set aside for this one: the menu lists every sheet
|
||||
// with all of its frames, and a long one would put the entry to click outside the drawn menu.
|
||||
@@ -241,7 +241,7 @@ export async function listTab(t) {
|
||||
JSON.stringify(lefts),
|
||||
);
|
||||
|
||||
// 7. A list whose target is gone empties the tab rather than showing a stale one.
|
||||
// 21i. A list whose target is gone empties the tab rather than showing a stale one.
|
||||
await mutate("__gm.state.openList({ kind: 'asset', assetId: 'listy', key: 'nums' })");
|
||||
await sleep(200);
|
||||
check('a list can be opened from outside the inspector too', (await json('__gm.tab()')) === 'list');
|
||||
@@ -256,7 +256,7 @@ export async function listTab(t) {
|
||||
check('a deleted target empties the tab', info.title === '' && info.items.length === 0, info.title);
|
||||
await shot('04-empty');
|
||||
|
||||
// 8. A list whose items are structs: each row is the bundle's fields under each other, built by
|
||||
// 21j. A list whose items are structs: each row is the bundle's fields under each other, built by
|
||||
// the inspector's own struct renderer. Values are two digits so no row's index can match one.
|
||||
await mutate(`
|
||||
const p = __gm.state.project;
|
||||
@@ -317,7 +317,7 @@ export async function listTab(t) {
|
||||
JSON.stringify(back),
|
||||
);
|
||||
|
||||
// 8b. A struct item carries its own shape, so it is named, typed and grown on its own row – and
|
||||
// 21k. A struct item carries its own shape, so it is named, typed and grown on its own row – and
|
||||
// primitives only, because a list inside a list item is the one shape the model does not store.
|
||||
const cellsOf = () => json(`__gm.state.project.assets.find((a) => a.id === 'celly').props.cells`);
|
||||
check('a struct item carries the add-a-field control', (await find('+ Field')) !== null);
|
||||
@@ -387,7 +387,7 @@ export async function listTab(t) {
|
||||
JSON.stringify(await cellsOf()),
|
||||
);
|
||||
|
||||
// 9. A node's unconnected `list` input lives in the same params bag as a setting, so it is a count
|
||||
// 21l. 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');
|
||||
|
||||
@@ -44,7 +44,7 @@ export async function structVariable(t) {
|
||||
`);
|
||||
await sleep(150);
|
||||
|
||||
// 1. The declaration is what the inspector draws: a head that can add fields, a row per field.
|
||||
// 22a. The declaration is what the inspector draws: a head that can add fields, a row per field.
|
||||
check('a struct variable carries the add-a-field control', (await find('+ Field')) !== null);
|
||||
check('and a row for each declared field', (await find('x')) !== null && (await find('tag')) !== null);
|
||||
check('whose value is shown, not a summary of the bundle', (await find('30.000')) !== null);
|
||||
@@ -56,7 +56,7 @@ export async function structVariable(t) {
|
||||
);
|
||||
await shot('01-struct-rows');
|
||||
|
||||
// 2. Writing one field keeps the others. Two edits in a row without a rebuild in between is
|
||||
// 22b. Writing one field keeps the others. Two edits in a row without a rebuild in between is
|
||||
// exactly where handlers sharing one snapshot of the bundle would revive the first value.
|
||||
const xField = await json("__gm.inspectorField('x')");
|
||||
await click(xField.x, xField.y);
|
||||
@@ -77,13 +77,13 @@ export async function structVariable(t) {
|
||||
JSON.stringify(await piece()),
|
||||
);
|
||||
|
||||
// 3. One undo step per field.
|
||||
// 22c. One undo step per field.
|
||||
await undo();
|
||||
check('undo takes back just the last field', (await piece()).tag === 'tee' && (await piece()).x === 42);
|
||||
await undo();
|
||||
check('and the one before it', (await piece()).x === 30, JSON.stringify(await piece()));
|
||||
|
||||
// 4. Adding a field reaches the declaration and the value at once.
|
||||
// 22d. Adding a field reaches the declaration and the value at once.
|
||||
await clickText('+ Field');
|
||||
await sleep(150);
|
||||
await pickKind('Boolean');
|
||||
@@ -96,7 +96,7 @@ export async function structVariable(t) {
|
||||
await sleep(200);
|
||||
check('undo takes it out of the value too', !('field1' in (await piece())), JSON.stringify(await piece()));
|
||||
|
||||
// 5. Renaming a field carries its value along.
|
||||
// 22e. Renaming a field carries its value along.
|
||||
const nameField = await json("__gm.locate('x')");
|
||||
await click(nameField.x, nameField.y);
|
||||
await press('a', { ctrl: true });
|
||||
@@ -109,7 +109,7 @@ export async function structVariable(t) {
|
||||
JSON.stringify(await piece()),
|
||||
);
|
||||
|
||||
// 6. A field may itself be a list. Its row is the same "n items" button a list property has,
|
||||
// 22f. A field may itself be a list. Its row is the same "n items" button a list property has,
|
||||
// and it opens the List tab one level down – on the field, not on the whole bundle.
|
||||
await clickText('+ Field');
|
||||
await sleep(150);
|
||||
@@ -160,7 +160,7 @@ export async function structVariable(t) {
|
||||
await mutate('__gm.state.openList(null)');
|
||||
await sleep(200);
|
||||
|
||||
// 7. The same bundle on a placed object. An override is sparse – it starts out unset – while a
|
||||
// 22g. The same bundle on a placed object. An override is sparse – it starts out unset – while a
|
||||
// field edit rebuilds the whole bundle, so the untouched fields have to come from what the row
|
||||
// shows (the asset's values) rather than from the empty form of the declaration.
|
||||
const placed = () => json("__gm.state.activeScene.objects.find((o) => o.id === 'ostruct').overrides.piece");
|
||||
@@ -180,7 +180,7 @@ export async function structVariable(t) {
|
||||
await sleep(200);
|
||||
check('a placed behavior shows its bundle, filled from the asset', (await find('tee')) !== null);
|
||||
|
||||
// 8. It reads exactly like the asset does: a head carrying the struct mark, then one row per
|
||||
// 22h. It reads exactly like the asset does: a head carrying the struct mark, then one row per
|
||||
// field with its kind mark, its name and its value. What is missing is the shape editing –
|
||||
// a placed object overrides values, the declaration stays with the asset that owns it.
|
||||
const bundleMark = await find('⊞');
|
||||
|
||||
+10
-90
@@ -1,14 +1,4 @@
|
||||
/**
|
||||
* 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';
|
||||
import { dirname, join } from 'node:path';
|
||||
import { homedir } from 'node:os';
|
||||
|
||||
const HERE = dirname(fileURLToPath(import.meta.url));
|
||||
const LAUNCHER = join(HERE, '..', 'remote', 'gm-browser.ps1');
|
||||
import { spawn } from 'node:child_process';
|
||||
|
||||
const FLAGS = [
|
||||
'--headless',
|
||||
@@ -29,89 +19,19 @@ const FLAGS = [
|
||||
'--mute-audio',
|
||||
];
|
||||
|
||||
function sshArgs(key) {
|
||||
return [
|
||||
'-i',
|
||||
key,
|
||||
'-o',
|
||||
'IdentitiesOnly=yes',
|
||||
'-o',
|
||||
'BatchMode=yes',
|
||||
'-o',
|
||||
'StrictHostKeyChecking=accept-new',
|
||||
'-o',
|
||||
'ConnectTimeout=15',
|
||||
// A dead peer must surface as a closed socket, not as a run that waits forever.
|
||||
'-o',
|
||||
'ServerAliveInterval=10',
|
||||
'-o',
|
||||
'ServerAliveCountMax=3',
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Starts the browser and returns its process plus a `stop()` that ends it the way that host
|
||||
* needs. `proc.stderr` carries the browser's own last words either way.
|
||||
*/
|
||||
/** Starts chromium with the CDP port open; `proc.stderr` carries its last words. */
|
||||
export function launchBrowser({ port, width, height }) {
|
||||
const remote = process.env.GM_REMOTE;
|
||||
if (!remote) {
|
||||
const proc = spawn(
|
||||
'chromium',
|
||||
// --no-sandbox: this container runs as root, where chromium refuses to start otherwise.
|
||||
[
|
||||
...FLAGS,
|
||||
'--no-sandbox',
|
||||
`--remote-debugging-port=${port}`,
|
||||
`--window-size=${width},${height}`,
|
||||
'about:blank',
|
||||
],
|
||||
{ stdio: ['ignore', 'ignore', 'pipe'] },
|
||||
);
|
||||
return { proc, stop: () => proc.kill('SIGTERM'), where: 'local chromium' };
|
||||
}
|
||||
|
||||
const key = process.env.GM_REMOTE_KEY ?? join(homedir(), '.ssh', 'gm-win.key');
|
||||
// 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(
|
||||
'ssh',
|
||||
'chromium',
|
||||
// --no-sandbox: this container runs as root, where chromium refuses to start otherwise.
|
||||
[
|
||||
...sshArgs(key),
|
||||
'-o',
|
||||
'ExitOnForwardFailure=yes',
|
||||
'-L',
|
||||
`${port}:127.0.0.1:${port}`,
|
||||
remote,
|
||||
`powershell -NoProfile -ExecutionPolicy Bypass -File gm-browser.ps1 -Port ${port} -Size ${width},${height}`,
|
||||
...FLAGS,
|
||||
'--no-sandbox',
|
||||
`--remote-debugging-port=${port}`,
|
||||
`--window-size=${width},${height}`,
|
||||
'about:blank',
|
||||
],
|
||||
{ stdio: ['ignore', 'ignore', 'pipe'] },
|
||||
);
|
||||
|
||||
return {
|
||||
proc,
|
||||
/**
|
||||
* 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`;
|
||||
try {
|
||||
execFileSync(
|
||||
'ssh',
|
||||
[
|
||||
...sshArgs(key),
|
||||
remote,
|
||||
`powershell -NoProfile -Command "if (Test-Path ${pidFile}) { Stop-Process -Id (Get-Content ${pidFile}) -Force -ErrorAction SilentlyContinue }"`,
|
||||
],
|
||||
{ stdio: 'pipe', timeout: 20_000 },
|
||||
);
|
||||
} catch {
|
||||
// Unreachable host, denied, already gone: the idle window still collects it.
|
||||
}
|
||||
proc.kill('SIGTERM');
|
||||
},
|
||||
where: `remote browser on ${remote}`,
|
||||
};
|
||||
return { proc, stop: () => proc.kill('SIGTERM'), where: 'local chromium' };
|
||||
}
|
||||
@@ -66,7 +66,6 @@ export const variable = (key, kind, value, fields) => ({
|
||||
...(fields ? { fields } : {}),
|
||||
});
|
||||
|
||||
/** A struct field declaration. */
|
||||
export const field = (key, kind = 'number') => ({ key, kind });
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,120 +0,0 @@
|
||||
# Launches a headless browser with the CDP port open and ties its life to whoever is using it.
|
||||
#
|
||||
# Two measured facts shape this. Windows OpenSSH does not kill its child when the connection
|
||||
# drops - waiting for the caller to die leaves the browser running forever (6 orphaned chrome.exe
|
||||
# after a SIGKILLed tunnel). And stdin is no signal either: only the first chunk a caller writes
|
||||
# ever arrives here, so a heartbeat over stdin looks identical to a dead caller.
|
||||
#
|
||||
# What is reliable is the CDP port itself: while a run is under way the forwarded connection is
|
||||
# established, and it closes the moment the tunnel or the run dies. So the browser stays for as
|
||||
# long as somebody is talking to it. The job object then makes the kill total - when this process
|
||||
# ends for any reason, KILL_ON_JOB_CLOSE takes the whole browser tree with it.
|
||||
param([int]$Port = 9333, [string]$Size = "1280,800", [int]$IdleSeconds = 45, [int]$MaxMinutes = 30)
|
||||
$ErrorActionPreference = 'Stop'
|
||||
|
||||
Add-Type -TypeDefinition @'
|
||||
using System;
|
||||
using System.Runtime.InteropServices;
|
||||
public static class GmJob {
|
||||
[DllImport("kernel32.dll", CharSet = CharSet.Unicode)]
|
||||
static extern IntPtr CreateJobObject(IntPtr attrs, string name);
|
||||
[DllImport("kernel32.dll")]
|
||||
static extern bool SetInformationJobObject(IntPtr job, int infoClass, IntPtr info, uint len);
|
||||
[DllImport("kernel32.dll")]
|
||||
static extern bool AssignProcessToJobObject(IntPtr job, IntPtr process);
|
||||
|
||||
const int ExtendedLimitInformation = 9;
|
||||
const int LimitKillOnJobClose = 0x2000;
|
||||
// Offset of LimitFlags inside JOBOBJECT_EXTENDED_LIMIT_INFORMATION on x64, and the struct's size.
|
||||
const int LimitFlagsOffset = 16;
|
||||
const int StructSize = 144;
|
||||
|
||||
public static IntPtr Create() {
|
||||
IntPtr job = CreateJobObject(IntPtr.Zero, null);
|
||||
if (job == IntPtr.Zero) throw new Exception("CreateJobObject failed");
|
||||
IntPtr info = Marshal.AllocHGlobal(StructSize);
|
||||
try {
|
||||
for (int i = 0; i < StructSize; i++) Marshal.WriteByte(info, i, 0);
|
||||
Marshal.WriteInt32(info, LimitFlagsOffset, LimitKillOnJobClose);
|
||||
if (!SetInformationJobObject(job, ExtendedLimitInformation, info, StructSize))
|
||||
throw new Exception("SetInformationJobObject failed: " + Marshal.GetLastWin32Error());
|
||||
} finally { Marshal.FreeHGlobal(info); }
|
||||
return job;
|
||||
}
|
||||
|
||||
public static void Assign(IntPtr job, IntPtr process) {
|
||||
if (!AssignProcessToJobObject(job, process))
|
||||
throw new Exception("AssignProcessToJobObject failed: " + Marshal.GetLastWin32Error());
|
||||
}
|
||||
}
|
||||
'@
|
||||
|
||||
$candidates = @(
|
||||
"C:\Program Files\Google\Chrome\Application\chrome.exe",
|
||||
"C:\Program Files (x86)\Google\Chrome\Application\chrome.exe",
|
||||
"C:\Program Files (x86)\Microsoft\Edge\Application\msedge.exe"
|
||||
)
|
||||
$exe = $candidates | Where-Object { Test-Path $_ } | Select-Object -First 1
|
||||
if (-not $exe) { [Console]::Error.WriteLine("no chrome or edge found"); exit 2 }
|
||||
|
||||
$udd = Join-Path $env:TEMP "gm-e2e-$Port"
|
||||
Remove-Item -Recurse -Force $udd -ErrorAction SilentlyContinue
|
||||
|
||||
# No --disable-gpu here: forcing software WebGL on a desktop with a real card rasterises every
|
||||
# frame on the CPU and made the host unusable. Let the machine use the card it has.
|
||||
$flags = @(
|
||||
'--headless', '--ignore-gpu-blocklist', '--renderer-process-limit=1',
|
||||
"--remote-debugging-port=$Port", "--user-data-dir=`"$udd`"", "--window-size=$Size",
|
||||
'--hide-scrollbars', '--disable-background-networking', '--disable-sync',
|
||||
'--disable-extensions', '--no-first-run', '--mute-audio', '--no-default-browser-check',
|
||||
'about:blank'
|
||||
)
|
||||
|
||||
$job = [GmJob]::Create()
|
||||
$proc = Start-Process -FilePath $exe -ArgumentList $flags -PassThru -NoNewWindow
|
||||
[GmJob]::Assign($job, $proc.Handle)
|
||||
# The pid file is how the caller ends this run's browser without touching the browser windows the
|
||||
# person at this desk has open.
|
||||
$pidFile = Join-Path $env:TEMP "gm-browser-$Port.pid"
|
||||
Set-Content -Path $pidFile -Value $proc.Id
|
||||
[Console]::Error.WriteLine("gm-browser: $exe pid=$($proc.Id) port=$Port job=on")
|
||||
|
||||
# Either the browser dies on its own, or nobody is driving it any more. Both must end this script.
|
||||
# The window has to survive the gap before the first client connects, hence starting the clock now.
|
||||
$reason = "the browser exited on its own with code"
|
||||
$started = Get-Date
|
||||
$lastClient = $started
|
||||
while (-not $proc.HasExited) {
|
||||
Start-Sleep -Seconds 3
|
||||
if (((Get-Date) - $started).TotalMinutes -ge $MaxMinutes) {
|
||||
$reason = "the $MaxMinutes-minute cap"
|
||||
break
|
||||
}
|
||||
# .NET rather than Get-NetTCPConnection: that one goes through WMI, which is denied to this
|
||||
# account - its CIM session broke mid-run and took the browser down with it.
|
||||
try {
|
||||
$tcp = [System.Net.NetworkInformation.IPGlobalProperties]::GetIPGlobalProperties().GetActiveTcpConnections()
|
||||
$clients = @($tcp | Where-Object { $_.LocalEndPoint.Port -eq $Port -and $_.State -eq 'Established' })
|
||||
} catch {
|
||||
# A probe that fails says nothing about the run; the cap above is what bounds an orphan.
|
||||
[Console]::Error.WriteLine("gm-browser: connection probe failed: $($_.Exception.Message)")
|
||||
continue
|
||||
}
|
||||
if ($clients.Count -gt 0) {
|
||||
$lastClient = Get-Date
|
||||
} elseif (((Get-Date) - $lastClient).TotalSeconds -ge $IdleSeconds) {
|
||||
$reason = "no CDP client for $IdleSeconds s"
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if ($proc.HasExited) {
|
||||
[Console]::Error.WriteLine("gm-browser: $reason $($proc.ExitCode)")
|
||||
} else {
|
||||
# By pid, never by name: this host is somebody's desktop and their own Chrome must survive a test
|
||||
# run. taskkill is denied to this account, Stop-Process is not, and the job object takes the
|
||||
# renderer children when this process ends - this just makes the kill immediate.
|
||||
Stop-Process -Id $proc.Id -Force -ErrorAction SilentlyContinue
|
||||
[Console]::Error.WriteLine("gm-browser: $reason, killed the browser")
|
||||
}
|
||||
Remove-Item $pidFile -Force -ErrorAction SilentlyContinue
|
||||
Reference in new issue
Block a user