Files
feuerwerk/tools/e2e/16-container.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

263 lines
11 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 Container tab: editing a container's template with the scene view's own tools.
*
* Runs after the container step, which created a container asset from two crates and left two
* placed copies of it in the scene – which is what makes "which copies follow a template edit"
* a question worth asking.
*/
const NAME = 'Crate pair';
export async function containerTab(t) {
const {
check,
sleep,
json,
evaluate,
mutate,
select,
shot,
click,
clickText,
drag,
press,
undo,
state,
asset,
} = t;
await press('Escape');
const containerId = await json("(__gm.state.project.assets.find(a => a.type === 'container') ?? {}).id");
// The overflow step renames assets to absurd lengths; a readable name makes the header checkable.
await evaluate(
`(() => { __gm.state.project.assets.find(a => a.id === ${JSON.stringify(containerId)}).name = ${JSON.stringify(NAME)}; __gm.state.preview(); })()`,
);
await sleep(200);
// 22. 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`);
const count = template.length;
check(
'the Container tab opens showing a container',
(await json('__gm.tab()')) === 'container' && (await json('__gm.container.current()')) === containerId,
JSON.stringify({ tab: await json('__gm.tab()'), shown: await json('__gm.container.current()') }),
);
check(
'the header names the container and counts its template objects',
Boolean(await evaluate(`__gm.locate(${JSON.stringify(NAME)})`)) &&
Boolean(await evaluate(`__gm.locate(${JSON.stringify(`${count} objects`)})`)),
JSON.stringify({ name: NAME, count }),
);
check(
'the hierarchy panel retitles itself for the container it lists',
// A panel draws its title in capitals, so that is what there is to find.
Boolean(await evaluate(`__gm.locate(${JSON.stringify(`Container: ${NAME}`.toUpperCase())})`)),
);
await shot('30-container-tab');
// 22a. 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',
rows.length > 0 && rows.every((id) => template.some((o) => o.id === id)),
JSON.stringify(rows),
);
// 22b. 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)})`);
await click(at.x, at.y);
let s = await state();
check(
'clicking a template object selects it against the container, not the scene',
s.selection?.kind === 'object' &&
s.selection.holderId === containerId &&
s.selection.objectId === root.id,
JSON.stringify(s.selection),
);
// 22c. The point of the tab: a drag writes the template, and the placed copies follow it –
// except one that was moved on its own beforehand, which keeps what it has.
const templateX = () =>
json(
`__gm.state.project.assets.find(a => a.id === '${containerId}').objects.find(o => o.id === '${root.id}').position.x`,
);
const copyXs = () =>
json(
`Object.fromEntries(__gm.state.activeScene.objects.filter(o => o.templateId === '${root.id}').map(o => [o.id, o.position.x]))`,
);
const copies = await copyXs();
const [dirtyId, ...cleanIds] = Object.keys(copies);
await mutate(`__gm.state.activeScene.objects.find(o => o.id === '${dirtyId}').position.x += 7`);
const before = await templateX();
await drag(at.x, at.y, at.x + 90, at.y);
const after = await templateX();
const copiesAfter = await copyXs();
check(
'dragging in the Container tab moves the template',
after === before + 90,
JSON.stringify({ before, after }),
);
check(
'the placed copies that still matched the template follow it',
cleanIds.length > 0 && cleanIds.every((id) => copiesAfter[id] === copies[id] + 90),
JSON.stringify({ copies, copiesAfter, cleanIds }),
);
check(
'a copy that was moved on its own keeps its own position',
copiesAfter[dirtyId] === copies[dirtyId] + 7,
JSON.stringify({ was: copies[dirtyId], now: copiesAfter[dirtyId] }),
);
await undo();
const copiesUndone = await copyXs();
check('undo takes the template move back', (await templateX()) === before);
check(
'and takes the copies back with it',
cleanIds.every((id) => copiesUndone[id] === copies[id]),
JSON.stringify({ copies, copiesUndone }),
);
await mutate(`__gm.state.activeScene.objects.find(o => o.id === '${dirtyId}').position.x -= 7`);
// 22d. Placing goes through the Assets panel, whose entry follows the visible tab: it adds one
// object to the template, not to the scene.
const crateName = (await asset('crate')).name;
const sceneCount = (await json('__gm.state.activeScene.objects')).length;
await clickText(crateName, 'right');
check(
'the asset menu offers Place in container while the tab is open',
Boolean(await evaluate("__gm.locate('Place in container')")) &&
!(await evaluate("__gm.locate('Place in scene')")),
);
await clickText('Place in container');
await sleep(300);
const grown = await json(`__gm.state.project.assets.find(a => a.id === '${containerId}').objects`);
s = await state();
check(
'Place in container puts one object into the template and selects it there',
grown.length === count + 1 &&
s.selection?.holderId === containerId &&
grown.some((o) => o.id === s.selection.objectId),
JSON.stringify({ before: count, after: grown.length, selection: s.selection }),
);
check('the scene is untouched by it', (await json('__gm.state.activeScene.objects')).length === sceneCount);
await shot('31-container-object-added');
// 22e. Delete works on a template object like on any other, as one undo step.
await press('Delete');
await sleep(250);
check(
'Delete removes a template object',
(await json(`__gm.state.project.assets.find(a => a.id === '${containerId}').objects`)).length === count,
);
await undo();
check(
'undo puts it back',
(await json(`__gm.state.project.assets.find(a => a.id === '${containerId}').objects`)).length ===
count + 1,
);
await press('Delete');
await sleep(250);
// 22f. 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(); })()`,
);
await sleep(300);
const area = await json('__gm.container.rect()');
const inside = (p) => p.x >= area.x && p.x <= area.x + area.w && p.y >= area.y && p.y <= area.y + area.h;
const far = await json(`__gm.container.objectScreen(${JSON.stringify(root.id)})`);
await clickText('Fit');
await sleep(300);
const fitted = await json(`__gm.container.objectScreen(${JSON.stringify(root.id)})`);
check(
'Fit brings an object parked far outside back into view',
!inside(far) && inside(fitted),
JSON.stringify({ area, far, fitted }),
);
await evaluate(
`(() => { const t = __gm.state.project.assets.find(a => a.id === '${containerId}').objects.find(o => o.id === '${root.id}'); t.position.x = ${before}; __gm.state.preview(); })()`,
);
await clickText('Fit');
await sleep(300);
await shot('32-container-fitted');
// 22g. 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);
await evaluate(
`__gm.state.select({ kind: 'object', holderId: ${JSON.stringify(containerId)}, objectId: ${JSON.stringify(root.id)} })`,
);
await sleep(300);
check(
'selecting a template object switches to the Container tab',
(await json('__gm.tab()')) === 'container',
);
await evaluate("__gm.showTab('scene')");
await sleep(300);
await evaluate("__gm.state.select({ kind: 'object', holderId: 'scene1', objectId: 'o1' })");
await sleep(300);
check('selecting a scene object leaves the Scene tab alone', (await json('__gm.tab()')) === 'scene');
check(
'the hierarchy is back on the scene with it',
(await json('__gm.hierarchyOrder()')).includes('o1'),
JSON.stringify(await json('__gm.hierarchyOrder()')),
);
await clickText(crateName, 'right');
check(
'and back on the Scene tab the same entry reads Place in scene',
Boolean(await evaluate("__gm.locate('Place in scene')")) &&
!(await evaluate("__gm.locate('Place in container')")),
);
await press('Escape');
// 22h. 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);
await mutate(`const src = __gm.state.project.assets.find(a => a.id === '${containerId}');
const copy = JSON.parse(JSON.stringify(src));
copy.id = 'c2'; copy.name = 'Spare box';
copy.objects = copy.objects.slice(0, 1).map((o) => ({ ...o, id: 'c2o1', parentId: '' }));
__gm.state.project.assets.push(copy)`);
await select({ kind: 'asset', assetId: 'c2' });
await sleep(400);
check(
'picking another container moves the tab and the hierarchy to it',
(await json('__gm.container.current()')) === 'c2' &&
JSON.stringify(await json('__gm.hierarchyOrder()')) === JSON.stringify(['c2o1']) &&
Boolean(await evaluate("__gm.locate('CONTAINER: SPARE BOX')")),
JSON.stringify({
shown: await json('__gm.container.current()'),
rows: await json('__gm.hierarchyOrder()'),
}),
);
await shot('33-container-switched');
// 22i. Nesting: a right-click must not select, or picking the container to place would load it
// into the tab first – you would then be placing it into itself.
const nested = await json("__gm.state.project.assets.find(a => a.id === 'c2').objects");
await clickText(NAME, 'right');
check(
'right-clicking another container leaves the open one alone',
(await json('__gm.container.current()')) === 'c2',
JSON.stringify({ shown: await json('__gm.container.current()') }),
);
await clickText('Place in container');
await sleep(300);
const nestedAfter = await json("__gm.state.project.assets.find(a => a.id === 'c2').objects");
check(
'and placing it nests it inside that one',
nestedAfter.length === nested.length + 1 &&
nestedAfter.some((o) => o.assetId === containerId) &&
(await json('__gm.container.current()')) === 'c2',
JSON.stringify({ before: nested.length, after: nestedAfter.length }),
);
await undo();
await sleep(200);
}