Files
feuerwerk/tools/e2e/09-containers.mjs
T
rootandClaude Opus 5 9d93020bd8 Add a Container tab and propagate template edits
The Container tab reuses SceneViewport against any object holder, so a
container template is edited exactly like a scene. Right-click no longer
changes the selection, which previously made nesting impossible: opening
the menu on a container loaded it, and "Place in container" then targeted
itself.

Template edits (move, rename, override) now reach already-placed instances
per slot, through a shared templateSync used by both the apply path and the
plain object commands. Copies the user has touched keep their own value.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-08 20:19:54 +02:00

329 lines
15 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';
/**
* Multi-selection and containers: Ctrl-click, Create container, dragging the group, rubber
* band, group move/delete, placing and spawning the container asset, and apply/revert of
* instance values against the template and the asset.
*/
export async function containers(t) {
const {
check,
sleep,
evaluate,
json,
shot,
click,
clickText,
drag,
locate,
press,
undo,
mutate,
select,
state,
object,
objects,
asset,
world,
play,
stop,
} = t;
const center = await json('__gm.viewportCenter()');
const has = (text) => evaluate(`Boolean(__gm.locate(${JSON.stringify(text)}))`);
// 16. 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;
const o1 = objs.find(o => o.id === 'o1'); const o2 = objs.find(o => o.id === 'o2');
o1.position = { x: -150, y: 0, z: 0 }; o1.parentId = '';
o2.position = { x: 120, y: -80, z: 0 }; o2.parentId = '';
objs.find(o => o.id === 'oc2').overrides = { width: 140 };
__gm.state.select(null)`);
await clickText('Red crate');
const blueRow = await locate('Blue crate');
await click(blueRow.x, blueRow.y, 'left', { ctrl: true });
let s = await state();
check(
'Ctrl-click in the hierarchy adds to the selection',
s.selection?.kind === 'object' &&
same(s.selection.objectIds, ['o1', 'o2']) &&
s.selection.objectId === 'o2',
JSON.stringify(s.selection),
);
check('inspector shows the multi-selection', await has('2 OBJECTS SELECTED'));
await shot('23-multi-selected');
await click(blueRow.x, blueRow.y, 'left', { ctrl: true });
s = await state();
check(
'Ctrl-click again removes the object from the selection',
s.selection?.kind === 'object' && same(s.selection.objectIds, ['o1']),
JSON.stringify(s.selection),
);
await click(blueRow.x, blueRow.y, 'left', { ctrl: true });
// 16a. Create container: both crates (with their colliders and the Mover) become the content
// of a new container asset; the scene gets one container object at the selection's
// centre (-15,-40), the crates hang below it with recomputed local positions, world
// positions are unchanged, and the container is selected.
const assetsBefore = (await json('__gm.state.project.assets')).length;
const objectsBefore = (await objects()).length;
await clickText('Create container');
await sleep(100);
s = await state();
const containerObj = s.selection?.kind === 'object' ? await object(s.selection.objectId) : null;
const containerAsset = containerObj ? await asset(containerObj.assetId) : null;
check(
'Create container adds one container asset and one placed container, selected',
containerAsset?.type === 'container' &&
s.selection.objectIds.length === 1 &&
(await json('__gm.state.project.assets')).length === assetsBefore + 1 &&
(await objects()).length === objectsBefore + 1,
JSON.stringify({ containerObj, type: containerAsset?.type }),
);
check(
'the container sits at the centre of the selection',
containerObj && containerObj.parentId === '' && same(containerObj.position, { x: -15, y: -40, z: 0 }),
JSON.stringify(containerObj?.position),
);
let o1c = await object('o1');
const o2c = await object('o2');
check(
'the crates are re-parented below the container with local positions relative to its centre',
o1c.parentId === containerObj?.id &&
o1c.position.x === -135 &&
o1c.position.y === 40 &&
o2c.parentId === containerObj?.id &&
o2c.position.x === 135 &&
o2c.position.y === -40,
JSON.stringify({ o1: o1c.position, o2: o2c.position }),
);
check(
'the template holds the whole subtree (2 crates, 2 colliders, the Mover)',
containerAsset?.objects.length === 5 &&
containerAsset.objects.filter((o) => o.parentId === '').length === 2 &&
o1c.templateId &&
containerAsset.objects.some((o) => o.id === o1c.templateId),
JSON.stringify(containerAsset?.objects.map((o) => [o.assetId, o.parentId === '' ? 'root' : 'child'])),
);
const orderC = await json('(__gm.hierarchyExpandAll(), __gm.hierarchyOrder())');
const cAt = orderC.indexOf(containerObj?.id);
check(
'hierarchy lists the container with its content indented below',
cAt >= 0 && same(orderC.slice(cAt), [containerObj.id, 'o1', 'o4', 'oc1', 'o2', 'oc2']),
JSON.stringify(orderC),
);
check(
'container symbol is drawn in the overlay layer',
containerObj && (await evaluate(`__gm.layerOf('${containerObj.id}')`)) === 'overlay',
);
await shot('24-container-created');
// 16b. Dragging the container symbol in the viewport moves the whole group; undo restores it.
const cScreen = await json(`__gm.objectScreen('${containerObj.id}')`);
await drag(cScreen.x, cScreen.y, cScreen.x + 100, cScreen.y);
const containerMoved = await object(containerObj.id);
o1c = await object('o1');
check(
'dragging the container moves the group (children keep their local position)',
containerMoved.position.x === 85 && o1c.position.x === -135,
JSON.stringify({ container: containerMoved.position, o1: o1c.position }),
);
await undo();
check('undo moves the container back', (await object(containerObj.id)).position.x === -15);
// 16c. 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);
s = await state();
check(
'rubber-band selection picks the objects inside it',
s.selection?.kind === 'object' && same([...s.selection.objectIds].sort(), ['o1', 'oc1']),
JSON.stringify(s.selection),
);
await shot('25-marquee');
// 16d. 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);
o1c = await object('o1');
const o2d = await object('o2');
check(
'dragging a multi-selection moves every selected object',
o1c.position.x === -85 && o2d.position.x === 185,
JSON.stringify({ o1: o1c.position, o2: o2d.position }),
);
await undo();
o1c = await object('o1');
check('one undo restores both', o1c.position.x === -135 && (await object('o2')).position.x === 135);
// 16e. Delete with a multi-selection removes all of them (with subtrees) as one step.
const objectsBeforeDelete = (await objects()).length;
await select({ kind: 'object', holderId: 'scene1', objectId: 'o2', objectIds: ['o1', 'o2'] });
await press('Delete');
await sleep(150);
check(
'Delete removes the whole multi-selection with its subtrees',
(await objects()).length === objectsBeforeDelete - 5,
);
await undo();
check('undo restores all of them', (await objects()).length === objectsBeforeDelete);
// 16f. Placing the container asset again from the asset panel creates a second instance:
// the root plus 5 content objects with fresh ids, the crate copy at the same offset.
await clickText(containerAsset.name, 'right');
await clickText('Place in scene');
await sleep(100);
const afterPlace = await objects();
const secondRoot = afterPlace.find((o) => o.assetId === containerAsset.id && o.id !== containerObj.id);
const secondCrate =
secondRoot && afterPlace.find((o) => o.parentId === secondRoot.id && o.templateId === o1c.templateId);
check(
'placing the container asset instantiates its content below a new container object',
afterPlace.length === objectsBeforeDelete + 6 &&
secondCrate &&
secondCrate.position.x === -135 &&
secondCrate.position.y === 40,
JSON.stringify({ count: afterPlace.length, secondCrate }),
);
await shot('26-container-placed');
// 16f-ii. Spawning the container at runtime instantiates its content the same way: point the
// Spawner at the container asset, press Space while playing – a whole instance appears
// in the runtime copy and in the world, and Stop throws it away with the rest.
const spawnParam = (assetId) =>
mutate(
`__gm.state.project.assets.find(a => a.id === 'spawner').graph.nodes.find(n => n.id === 's-spawn').params.asset = ${JSON.stringify(assetId)}`,
);
await spawnParam(containerAsset.id);
const beforeSpawn = await objects();
await play();
await press('Space');
await sleep(250);
const fresh = (await objects()).filter((o) => !beforeSpawn.some((b) => b.id === o.id));
const spawnedRoot = fresh.find((o) => o.parentId === '');
check(
'Spawn of a container asset creates the container object plus its content',
fresh.length === 6 &&
spawnedRoot?.assetId === containerAsset.id &&
fresh.filter((o) => o.templateId).length === 5,
JSON.stringify(fresh.map((o) => ({ id: o.id, assetId: o.assetId, templateId: o.templateId }))),
);
const spawnWorld = await world();
check(
'the spawned instance lives in the world and its name reached the Log node',
spawnWorld.entities.length === beforeSpawn.length + 6 && spawnWorld.log.includes(containerAsset.name),
JSON.stringify({ n: spawnWorld.entities.length, log: spawnWorld.log.slice(-1) }),
);
await stop();
check('stop discards the spawned instance', (await objects()).length === beforeSpawn.length);
await spawnParam('crate');
// 16g. Apply / revert on a container instance. The red crate (o1) is part of the first
// container; the second instance has an unmodified copy of it. Moving o1 on x shows
// ↑ and ↺ on the X row; ↑ writes x into the template and the copy follows; undo takes
// both back; ↺ puts o1 back to the template value.
const templateA = async () => (await asset(containerAsset.id)).objects.find((o) => o.id === o1c.templateId);
await select({ kind: 'object', holderId: 'scene1', objectId: 'o1' });
await sleep(200);
check('inspector names the container the object is part of', await has(containerAsset.name));
check('no apply/revert while the instance matches its template', !(await has('↑')) && !(await has('↺')));
await mutate(`__gm.state.activeScene.objects.find(o => o.id === 'o1').position.x = -100`);
const applyButton = await evaluate(`__gm.locate('↑')`);
check('a moved instance shows apply and revert on its X row', Boolean(applyButton) && (await has('↺')));
await shot('27-apply-buttons');
await click(applyButton.x, applyButton.y);
await sleep(100);
check(
'apply writes x into the template and the unmodified copy follows',
(await templateA()).position.x === -100 && (await object(secondCrate.id)).position.x === -100,
JSON.stringify({ template: (await templateA()).position, copy: (await object(secondCrate.id)).position }),
);
check('apply hides the buttons again', !(await has('↑')));
await undo();
check(
'undo restores template and copy',
(await templateA()).position.x === -135 && (await object(secondCrate.id)).position.x === -135,
);
const revertButton = await evaluate(`__gm.locate('↺')`);
await click(revertButton.x, revertButton.y);
await sleep(100);
check('revert puts the instance back to the template position', (await object('o1')).position.x === -135);
// 16h. Overrides on an instance offer a menu: "Apply to container" or "Apply to asset".
await mutate(`__gm.state.activeScene.objects.find(o => o.id === 'o1').overrides.width = 77`);
const applyMenu = await evaluate(`__gm.locate('↑ ▾')`);
check('an overridden property on an instance shows the apply menu button', Boolean(applyMenu));
await click(applyMenu.x, applyMenu.y);
check(
'the menu offers container and asset',
(await has('Apply to container')) && (await has('Apply to asset')),
);
await shot('28-apply-menu');
await clickText('Apply to asset');
await sleep(100);
const crateAfterApply = (await asset('crate')).props;
check(
'Apply to asset writes the value into the asset and drops the override',
crateAfterApply.width === 77 && (await object('o1')).overrides.width === undefined,
JSON.stringify(crateAfterApply),
);
await undo();
check(
'undo restores asset and override',
(await asset('crate')).props.width === 100 && (await object('o1')).overrides.width === 77,
);
await click(applyMenu.x, applyMenu.y);
await clickText('Apply to container');
await sleep(100);
check(
'Apply to container writes the override into the template and the copy',
(await templateA()).overrides.width === 77 && (await object(secondCrate.id)).overrides.width === 77,
);
// 16i. o1 now matches its template (both 77) but still overrides the asset (100). Only bases
// the value really differs from are offered, so "Reset to container" is left out and the
// menu collapses to a direct ↺. Deviating from both brings the menu back.
await sleep(200);
check(
'a reset that would change nothing is left out – a direct button instead of a menu',
(await has('↺')) && !(await has('↺ ▾')) && !(await has('↑ ▾')),
);
await mutate(`__gm.state.activeScene.objects.find(o => o.id === 'o1').overrides.width = 55`);
const resetMenu = await evaluate(`__gm.locate('↺ ▾')`);
check('deviating from template and asset brings the reset menu back', Boolean(resetMenu));
await click(resetMenu.x, resetMenu.y);
check(
'the reset menu offers container and asset',
(await has('Reset to container')) && (await has('Reset to asset')),
);
await shot('28b-reset-menu');
await clickText('Reset to container');
await sleep(100);
check(
'Reset to container puts the override back on the template value',
(await object('o1')).overrides.width === 77,
);
const singleReset = await evaluate(`__gm.locate('↺')`);
check(
'with only the asset left to differ from, the menu is a single button again',
Boolean(singleReset) && !(await has('↺ ▾')),
);
await click(singleReset.x, singleReset.y);
await sleep(100);
check(
'↺ drops the override so the object follows the asset again',
(await object('o1')).overrides.width === undefined && (await templateA()).overrides.width === 77,
);
// Dropping the override left the object on the asset value (100) while the template still
// says 77 – that is a real deviation, so exactly one entry remains: "Reset to container".
check(
'the remaining deviation from the template keeps a single button, not a menu',
(await has('↺')) && !(await has('↺ ▾')),
);
await undo();
check('undo restores the override', (await object('o1')).overrides.width === 77);
}