Files
feuerwerk/tools/e2e/16-container.mjs
T
rootandClaude Opus 5 8f92a06c84 Consolidate duplicated editor code, trim comments, refresh README
Split GraphCanvas (1076 -> 831 lines) into EdgeLayer and graphMenus;
the port and marker menus were the same list and are now one function.
Value specs of the inspector and the list tab share valueSpecs.ts, and
the remaining copies (compound commands, type glyphs, typed asset
lookup, name rule, play/editor world setup, marquee) are merged.

Four copies had already drifted apart; merging them fixes:
- "Disconnect N links" in the port menu is one undo step, not N
- "+ Add collider" is hidden on a placed container instance, as in
  the hierarchy menu, instead of always failing
- a bool property coerced from the text "false" becomes false
- a port command on an unknown node type reports instead of crashing

Comment-only edits leave the bundle byte-identical. README corrected in
35 places (step limit, jump-run clock, suite duration, 409 body, ...).

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

335 lines
14 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 { instanceChild } from '../lib/builders.mjs';
/**
* The Container tab: editing a template with the scene view's tools. Relies on the container step
* having left two instances of a two-crate container in the scene.
*/
const NAME = 'Crate pair';
export async function containerTab(t) {
const {
check,
sleep,
json,
evaluate,
mutate,
select,
shot,
click,
clickText,
drag,
dragAsset,
mouse,
press,
undo,
state,
object,
objects,
storedObjects,
} = 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 every instance follows it – except
// one that was moved there on its own, whose deviation the instance keeps.
const templateX = () =>
json(
`__gm.state.project.assets.find(a => a.id === '${containerId}').objects.find(o => o.id === '${root.id}').position.x`,
);
// The instances resolve this template object under `<instance>~${root.id}`; nothing of it is
// stored, so the resolved list is the only place to read it.
const instanceXs = () =>
json(
`Object.fromEntries(__gm.sceneObjects().filter(o => o.id.endsWith('~${root.id}')).map(o => [o.id, o.position.x]))`,
);
const shown = await instanceXs();
const [dirtyId, ...cleanIds] = Object.keys(shown);
const dirtyInstance = dirtyId.slice(0, dirtyId.indexOf('~'));
const setDeviation = (body) =>
mutate(
`const edits = __gm.state.activeScene.objects.find(o => o.id === '${dirtyInstance}').instanceEdits;
${body}`,
);
await setDeviation(`edits['${root.id}'] = { position: { x: ${shown[dirtyId] + 7} }, overrides: {} }`);
const before = await templateX();
await drag(at.x, at.y, at.x + 90, at.y);
const after = await templateX();
const afterDrag = await instanceXs();
check(
'dragging in the Container tab moves the template',
after === before + 90,
JSON.stringify({ before, after }),
);
check(
'the instances that still matched the template follow it',
cleanIds.length > 0 && cleanIds.every((id) => afterDrag[id] === shown[id] + 90),
JSON.stringify({ shown, afterDrag, cleanIds }),
);
check(
'the instance that was moved on its own keeps its own position',
afterDrag[dirtyId] === shown[dirtyId] + 7,
JSON.stringify({ was: shown[dirtyId], now: afterDrag[dirtyId] }),
);
await undo();
const undone = await instanceXs();
check('undo takes the template move back', (await templateX()) === before);
check(
'and takes the instances back with it',
cleanIds.every((id) => undone[id] === shown[id]),
JSON.stringify({ shown, undone }),
);
await setDeviation(`delete edits['${root.id}']`);
// 22d. 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.
const sceneStored = (await storedObjects()).length;
const sceneListed = (await objects()).length;
const instanceCount = Object.keys(await instanceXs()).length;
const canvas = await json('__gm.container.center()');
await dragAsset('crate', canvas.x + 40, canvas.y + 40);
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 stores nothing more for it', (await storedObjects()).length === sceneStored);
check(
'but every placed instance shows the new template object at once',
(await objects()).length === sceneListed + instanceCount,
JSON.stringify({ listed: (await objects()).length, was: sceneListed, instances: instanceCount }),
);
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: 'otex' })");
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('otex'),
JSON.stringify(await json('__gm.hierarchyOrder()')),
);
// 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');
// 22i2. Dragging the open container onto its own template would nest it in itself. The drag
// has to say so while it hovers – an outline that only turns out to be a lie on release
// is worse than none – and the drop has to change nothing.
const canvas2 = await json('__gm.container.center()');
const own = await json("__gm.assetRow('c2')");
await mouse('mouseMoved', own.x, own.y);
await mouse('mousePressed', own.x, own.y);
for (let i = 1; i <= 8; i++) {
await mouse(
'mouseMoved',
own.x + ((canvas2.x - own.x) * i) / 8,
own.y + ((canvas2.y - own.y) * i) / 8,
'left',
{
buttons: 1,
},
);
await sleep(16);
}
const refused = await json('__gm.container.dropPreview()');
check(
'a container dragged over its own template is refused while it hovers',
Boolean(refused) && /nests forever/.test(refused.rejection ?? ''),
JSON.stringify(refused),
);
// Picking it up selects it; the tab must not follow that, or the target would swap mid-drag.
check(
'and the tab keeps showing the container being dropped into',
(await json('__gm.container.current()')) === 'c2',
JSON.stringify({ shown: await json('__gm.container.current()') }),
);
await shot('33b-container-cycle-refused');
const ownBefore = await json("__gm.state.project.assets.find(a => a.id === 'c2').objects");
await mouse('mouseReleased', canvas2.x, canvas2.y);
await sleep(200);
const ownAfter = await json("__gm.state.project.assets.find(a => a.id === 'c2').objects");
check(
'releasing it changes nothing',
ownAfter.length === ownBefore.length,
JSON.stringify({ before: ownBefore.length, after: ownAfter.length }),
);
// 22i. 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");
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 }),
);
// 22j. 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`);
await evaluate("__gm.showTab('scene')");
await sleep(300);
const storedBefore = (await storedObjects()).length;
const listedBefore = (await objects()).length;
const spot = await json('__gm.viewportCenter()');
await dragAsset('c2', spot.x + 80, spot.y + 70);
const outer = (await storedObjects()).find((o) => o.assetId === 'c2');
const deep = innerTemplate.map((o) => instanceChild(outer.id, innerObject.id, o.id));
const listedIds = (await objects()).map((o) => o.id);
check(
'a container nested in a container template resolves through both levels',
(await storedObjects()).length === storedBefore + 1 &&
(await objects()).length === listedBefore + 1 + nestedAfter.length + innerTemplate.length &&
deep.every((id) => listedIds.includes(id)),
JSON.stringify({
stored: (await storedObjects()).length - storedBefore,
listed: (await objects()).length - listedBefore,
deep: deep.filter((id) => !listedIds.includes(id)),
}),
);
check(
'and the nested content hangs below the nested container object',
(await object(deep[0])).parentId === instanceChild(outer.id, innerObject.id),
JSON.stringify(await object(deep[0])),
);
await shot('34-container-nested');
await undo(); // the placement
await undo(); // the nesting
await sleep(200);
}