Files
feuerwerk/tools/e2e/16-container.mjs
T
rootandClaude Opus 5 ead649eec3 Resolve container content from the template instead of copying it
A scene now stores only the container object; its content is resolved from
the template on every read, with deviations kept as sparse instanceEdits on
the container object. This drops the propagation bookkeeping entirely --
templateSync.ts is gone and the three structural gaps (add, remove, reparent)
cannot diverge any more.

Values stay editable on an instance, structure does not; Unpack writes the
content into the scene, keeping ids. Play mode bakes containers into the
throwaway copy before entities are built. No migration: formatVersion is 2
and the seeders write the new format.

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

319 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 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
* instances of it in the scene. Neither of them holds a copy: what they show is resolved from
* the template on every read, which is what makes "which instance follows 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,
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. 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;
// 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;
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 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()')),
);
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 }),
);
// 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;
await clickText('Spare box', 'right');
await clickText('Place in scene');
await sleep(300);
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);
}