Files
feuerwerk/tools/e2e/04-assets.mjs
T
adminandClaude Opus 5 3b039384c4
game-maker / suite (push) Failing after 10m23s
A search that ignores what a thing is called is no search, so the name wins (#43)
Typing "variable" into the node palette landed on `Run behavior`, because the
filter weighed label, type and description alike and then sorted by category.
`searchRank` in `src/ui/search.ts` now ranks a match: exact label or type, then
prefix, then label, then type, and only last a hit that lives in the
description alone. Category and alphabet order within a rank. The searchable
`Menu` uses the same function, so searching means the same thing everywhere.

The `+` menu grew to fifteen entries in declaration order. It is built in two
alphabetical groups now -- the types that go into a scene, then the ones that
stay in the library -- with a separator between them, the split `isPlaceableType`
already draws. `ASSET_TYPES` itself stays untouched: it is also the
serialisation and check order. A search flattens the groups and drops the lines.

Popups were capped at fixed heights (320 menu, 260 palette, 340 pickers) while
the canvas had room to spare. `Popup.roomFor()` measures its own chrome and
takes the window height minus a tenth at each end. Placement clamps
asymmetrically: the bottom margin is enforced, the top is only a budget, so a
menu anchored near the top stays at its button instead of being pushed off it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-15 10:21:20 +02:00

307 lines
13 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/** The asset panel: placing by dragging into the view, creating through "+", and resetting an asset value. */
export async function assets(t) {
const {
check,
sleep,
evaluate,
json,
shot,
click,
doubleClick,
clickText,
mouse,
mutate,
press,
redo,
undo,
select,
objects,
asset,
} = t;
// 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')`)));
await shot('11-context-menu');
await press('Escape');
await sleep(150);
// 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()');
const to = { x: centre.x + 70, y: centre.y - 50 };
await mouse('mouseMoved', from.x, from.y);
await mouse('mousePressed', from.x, from.y);
for (let i = 1; i <= 8; i++) {
const x = from.x + ((to.x - from.x) * i) / 8;
const y = from.y + ((to.y - from.y) * i) / 8;
await mouse('mouseMoved', x, y, 'left', { buttons: 1 });
await sleep(16);
}
const preview = await json('__gm.dropPreview()');
check(
'dragging an asset over the view previews where it would land',
Boolean(preview) && preview.assetId === 'crate' && preview.rejection === '',
JSON.stringify(preview),
);
await shot('11b-drop-preview');
await mouse('mouseReleased', to.x, to.y);
await sleep(200);
const count = (await objects()).length;
check('dropping the asset adds an object', count === 8, `objects=${count}`);
const placed = await json(
'(() => { const id = __gm.state.selectedObjectIds[0]; return __gm.sceneObjects().find(o => o.id === id) ?? null; })()',
);
// The preview is a promise: whatever it drew has to be where the object then sits, or the
// outline was decoration rather than a prediction.
check(
'the object lands where the preview promised, and is selected',
Boolean(placed) &&
placed.assetId === 'crate' &&
placed.position.x === preview.x &&
placed.position.y === preview.y,
JSON.stringify({ placed: placed && placed.position, preview }),
);
check('the preview is gone once the asset is placed', (await json('__gm.dropPreview()')) === null);
await undo();
const afterUndo = (await objects()).length;
check('one undo takes the dropped object back out', afterUndo === 7, `objects=${afterUndo}`);
await redo();
await sleep(150);
check('redo puts it back', (await objects()).length === 8);
// 4c. Create a new asset through the + menu. Since #43 the menu is two alphabetical groups –
// what can be placed in a scene, a line, then what only lives in the library – and it is
// tall enough to show all fifteen types without scrolling.
await clickText('+');
await sleep(150);
const rows = await json('__gm.menuRows()');
const cut = rows.indexOf(null);
const placeable = rows.slice(0, cut);
const library = rows.slice(cut + 1);
check(
'the + menu is split by exactly one line',
rows.filter((r) => r === null).length === 1 && cut > 0,
JSON.stringify(rows),
);
check(
'every asset type is offered once, each group sorted by label',
placeable.length + library.length === 15 &&
String(placeable) === String([...placeable].sort()) &&
String(library) === String([...library].sort()),
JSON.stringify({ placeable, library }),
);
check(
'placeable types come first, the library ones after the line',
placeable.includes('Sprite') &&
!placeable.includes('Scene') &&
library.includes('Scene') &&
library.includes('Texture'),
JSON.stringify({ placeable, library }),
);
// The height cap is the canvas, not a constant: fifteen entries used to run under the mask edge,
// where `locate` still finds them but a click lands on nothing (that is what took step 30 down).
await shot('tmp-plus-menu');
const menuBox = await json('__gm.overlayRect()');
const lastRow = await evaluate(`__gm.locate(${JSON.stringify(library.at(-1))}, true)`);
const screenH = await json('__gm.app.screen.height');
check(
'the whole menu stands on screen, last entry clickable',
Boolean(lastRow) &&
lastRow.y < menuBox.y + menuBox.h &&
menuBox.y + menuBox.h <= screenH &&
menuBox.y >= 0,
JSON.stringify({ menuBox, lastRow, screenH }),
);
// A tenth of the canvas stays free below every popup. The margin used to live only in the height
// cap, never in the placement, so a popup that had to be shifted up ended flush on the bottom
// edge with the whole gap collected above it. Above it is a budget, not a distance: this menu
// hangs off a button near the top and stays there.
const above = menuBox.y;
const below = screenH - (menuBox.y + menuBox.h);
const margin = Math.round(screenH * 0.1);
check(
'the menu keeps a tenth of the canvas free below, and stays under its button',
below >= margin - 1 && above >= 0 && above < margin,
JSON.stringify({ above, below, margin, screenH }),
);
await clickText('Sprite');
await sleep(100);
const names = await json('__gm.state.project.assets.map(a => a.name)');
check('+ menu creates a sprite asset', names.includes('Sprite'), JSON.stringify(names));
await shot('12-new-asset');
// 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(
`__gm.state.project.assets.push({ id: 'scene2', type: 'scene', name: 'Level 2', props: { backgroundColor: 0x22252c }, objects: [] })`,
);
const secondScene = await json("__gm.assetRow('scene2')");
await evaluate("__gm.showTab('graph')");
await sleep(150);
await doubleClick(secondScene.x, secondScene.y);
check(
'double-clicking a scene asset makes it the active scene',
(await json('__gm.state.project.activeSceneId')) === 'scene2',
await json('__gm.state.project.activeSceneId'),
);
check('and opens the Scene tab', (await json('__gm.tab()')) === 'scene', await json('__gm.tab()'));
const firstScene = await json("__gm.assetRow('scene1')");
await doubleClick(firstScene.x, firstScene.y);
check(
'double-clicking the first one switches back',
(await json('__gm.state.project.activeSceneId')) === 'scene1',
await json('__gm.state.project.activeSceneId'),
);
await mutate(`__gm.state.project.assets = __gm.state.project.assets.filter((a) => a.id !== 'scene2')`);
// 4d′. Opening an asset: a click on its row only selects, a double-click opens the tab it
// lives in – every type that has one. The fixture lacks four of them; they go in through
// the model, empty.
await mutate(`__gm.state.project.assets.push(
{ id: 'sm-open', type: 'statemachine', name: 'Machine', props: {}, graph: { nodes: [], edges: [] } },
{ id: 'tex-open', type: 'texture', name: 'Sheet', props: {}, file: '', width: 0, height: 0, frames: [] },
{ id: 'anim-open', type: 'animation', name: 'Walk', props: {}, frames: [] },
{ id: 'box-open', type: 'container', name: 'Box', props: {}, objects: [] },
)`);
const openable = [
['mover', 'graph', '__gm.graph.current()'],
['sm-open', 'statemachine', '__gm.stateMachine.current()'],
['adjust-colors', 'shader', '__gm.shader.current()'],
['tex-open', 'texture', '__gm.texture.current()'],
['anim-open', 'animation', '__gm.animation.current().assetId'],
['box-open', 'container', '__gm.container.current()'],
];
for (const [id, tab, shown] of openable) {
await evaluate("__gm.showTab('scene')");
await sleep(150);
const row = await json(`__gm.assetRow(${JSON.stringify(id)})`);
await click(row.x, row.y);
await sleep(500); // past the double-click window, so the next press starts a fresh pair
const clicked = {
tab: await json('__gm.tab()'),
selected: (await json('__gm.state.selection'))?.assetId,
};
check(
`a click on the ${tab} asset selects it and leaves the Scene tab alone`,
clicked.tab === 'scene' && clicked.selected === id,
JSON.stringify(clicked),
);
await doubleClick(row.x, row.y);
await sleep(300);
const opened = { tab: await json('__gm.tab()'), shown: await json(shown) };
check(
`a double-click opens the ${tab} tab on it`,
opened.tab === tab && opened.shown === id,
JSON.stringify(opened),
);
}
// Enter goes the same way (`onActivate`), but only once the list holds keyboard focus, which a
// click does not give it – so it is not driven here.
await evaluate("__gm.showTab('scene')");
await sleep(150);
await clickText('+');
await sleep(200);
const entry = await evaluate(`__gm.locate('Behavior', true)`);
await click(entry.x, entry.y);
await sleep(400);
const created = await json(
"(__gm.state.project.assets.find((a) => a.type === 'behavior' && !['mover', 'spawner'].includes(a.id)) ?? {}).id ?? ''",
);
const fresh = { created, tab: await json('__gm.tab()'), shown: await json('__gm.graph.current()') };
check(
'creating an asset through + opens its tab on the new one',
Boolean(created) && fresh.tab === 'graph' && fresh.shown === created,
JSON.stringify(fresh),
);
await undo();
await mutate(
`__gm.state.project.assets = __gm.state.project.assets.filter((a) => !['sm-open', 'tex-open', 'anim-open', 'box-open'].includes(a.id))`,
);
await evaluate("__gm.showTab('scene')");
await sleep(150);
// 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' });
await sleep(200);
const heightReset = await evaluate(`__gm.locate('↺')`);
const colorReset = await evaluate(`__gm.locate('↺', true)`);
check('asset inspector shows a reset per deviating property', Boolean(heightReset));
check(
'the two deviations have two separate resets',
Boolean(colorReset) && colorReset.y > heightReset.y,
JSON.stringify({ heightReset, colorReset }),
);
await click(heightReset.x, heightReset.y);
await sleep(100);
check(
'reset puts the asset property back to its default',
(await asset('crate')).props.height === 100,
`height=${(await asset('crate')).props.height}`,
);
const remaining = await evaluate(`__gm.locate('↺')`);
check(
'the reset of the property now at its default is gone, the other one stays',
Boolean(remaining) &&
remaining.y === colorReset.y &&
remaining.y === (await evaluate(`__gm.locate('↺', true)`)).y,
JSON.stringify({ remaining, colorReset }),
);
await shot('15-asset-reset');
await undo();
check(
'undo restores the asset value',
(await asset('crate')).props.height === 60,
`height=${(await asset('crate')).props.height}`,
);
check(
'the height reset is back after undo',
(await evaluate(`__gm.locate('↺')`))?.y === heightReset.y,
JSON.stringify(await evaluate(`__gm.locate('↺')`)),
);
// 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';
const assetColor = (id) => json(`__gm.assetRowColor('${id}')`);
check('a deviating asset is marked gold in the list', (await assetColor('crate')) === GOLD);
check('an asset at its defaults is not', (await assetColor('cam')) === PLAIN, await assetColor('cam'));
// The Mover's `speed` is 200 against a declared variable default of 0. The inspector marks no
// variable, so neither may the list – the two must say the same thing.
check(
'a behavior is not marked for a variable value, which the inspector does not mark either',
(await assetColor('mover')) === PLAIN,
await assetColor('mover'),
);
await click(heightReset.x, heightReset.y);
await sleep(150);
check(
'the mark stays while the second deviation is left',
(await assetColor('crate')) === GOLD,
await assetColor('crate'),
);
const colorOnly = await evaluate(`__gm.locate('↺')`);
await click(colorOnly.x, colorOnly.y);
await sleep(150);
check(
'resetting the last deviation clears the mark, without a reload',
(await assetColor('crate')) === PLAIN,
`${await assetColor('crate')} props=${JSON.stringify((await asset('crate')).props)}`,
);
await shot('15b-asset-marks');
await undo();
await undo();
check(
'undo brings the mark back',
(await assetColor('crate')) === GOLD,
`${await assetColor('crate')} props=${JSON.stringify((await asset('crate')).props)}`,
);
}