Files
feuerwerk/tools/e2e/07-parenting.mjs
T
rootandClaude Opus 5 15ffcd5739 Left-align menu entries, add a Dropdown field, scrollable and searchable menus
Menu entries are left-aligned and live in a ScrollContainer capped at 320 px, so a
long list (the parent picker holds every object of the scene) scrolls instead of
running off the screen. `Menu(items, { searchable: true })` puts a filter box above
the scroll area; Enter takes the first match. Filtering forces a relayout because
two queries with the same hit count leave the height unchanged, which would keep
the stale row positions.

Fields that open a menu are a new `Dropdown` widget: the value sits flush left and
the chevron is drawn flush right instead of being written into the label. A menu
opened below a field is never narrower than that field.

New debug hooks `overlayTexts()` / `overlayRect()` make the popup contents
assertable; e2e step 11 covers the cap, the scrolling and the search box.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-08 11:31:14 +02:00

131 lines
4.5 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.
import { same } from '../lib/harness.mjs';
/**
* Hierarchy / parenting. Gives both crates unique names (later steps locate them by these),
* then parents by drag, by the inspector menu and unparents from the context menu – the world
* position is kept every time.
*/
export async function parenting(t) {
const {
check,
sleep,
json,
shot,
click,
clickText,
drag,
locate,
typeText,
press,
undo,
mutate,
select,
object,
entity,
stop,
} = t;
// 14. 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;
const o1 = objs.find(o => o.id === 'o1'); const o2 = objs.find(o => o.id === 'o2');
o1.name = 'Red crate'; o1.position = { x: -150, y: 0, z: 0 };
o2.name = 'Blue crate'; o2.position = { x: 120, y: -80, z: 0 }; o2.parentId = '';
__gm.state.select(null)`);
const redRow = await locate('Red crate');
const blueRow = await locate('Blue crate');
await drag(blueRow.x, blueRow.y, redRow.x, redRow.y);
await sleep(100);
let o2 = await object('o2');
check(
'dropping a row onto another parents it, keeping the world position',
o2.parentId === 'o1' && o2.position.x === 270 && o2.position.y === -80,
JSON.stringify(o2),
);
const order = await json('(__gm.hierarchyExpandAll(), __gm.hierarchyOrder())');
// o1's subtree: o2 (list order before o4) with its collider child, then o4, then o1's collider.
const o1At = order.indexOf('o1');
check(
'children are listed directly below their parent',
same(order.slice(o1At + 1, o1At + 5), ['o2', 'oc2', 'o4', 'oc1']),
JSON.stringify(order),
);
await shot('20-parented');
// 14a. 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);
await press('a', { ctrl: true });
await typeText('-50');
await press('Enter');
await sleep(200);
o2 = await object('o2');
const parent = await object('o2').then((o) => object(o.parentId));
const worldOfO2 = { x: o2.position.x + parent.position.x, y: o2.position.y + parent.position.y };
check(
'moving the parent moves the child in world space (local unchanged)',
o2.position.x === 270 && same(worldOfO2, { x: 220, y: -80 }),
`${JSON.stringify(o2.position)} world=${JSON.stringify(worldOfO2)}`,
);
// 14b. Play: entities mirror the hierarchy; undo restores parent and position.
await select(null);
await press('Enter', { ctrl: true });
await sleep(300);
const e2 = await entity('o2');
check(
'play instantiates the parent link and world positions',
e2 && e2.parent === 'o1' && e2.worldX === 220 && e2.x === 270,
JSON.stringify(e2),
);
await stop();
await undo(); // move
await undo(); // parent
o2 = await object('o2');
check(
'undo restores top level and the original local position',
o2.parentId === '' && o2.position.x === 120 && o2.position.y === -80,
JSON.stringify(o2),
);
// 14c. 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()');
check(
'the parent picker offers a search box and lists the candidates',
entries.includes('Search…') && entries.includes('None') && entries.includes('Red crate'),
JSON.stringify(entries),
);
await typeText('red');
await sleep(150);
const filtered = await json('__gm.overlayTexts()');
check(
'typing filters the picker down to the matching entries',
same(filtered, ['red', 'Red crate']),
JSON.stringify(filtered),
);
await shot('30-parent-search');
await press('Enter'); // takes the first match
await sleep(150);
o2 = await object('o2');
check(
'inspector Parent menu parents the object',
o2.parentId === 'o1' && o2.position.x === 270,
JSON.stringify(o2),
);
check('the picker closed after Enter', same(await json('__gm.overlayTexts()'), []));
const blueRow2 = await locate('Blue crate');
await click(blueRow2.x, blueRow2.y, 'right');
await clickText('Unparent (move to top level)');
await sleep(100);
o2 = await object('o2');
check(
'Unparent moves the object to the top level, keeping its world position',
o2.parentId === '' && o2.position.x === 120 && o2.position.y === -80,
JSON.stringify(o2),
);
}