- `.gitignore` said `index.html` unanchored, which also caught the Vite source page; a fresh clone could neither `dev` nor `build`. Anchored to `/index.html`, page added. - Removed GM_REMOTE (tools/remote/gm-browser.ps1 and the ssh/scp branch in browser.mjs): the Windows test machine is gone and the only flaky check that lived on it is closed (#37). - Removed `npm run preview`, undocumented and unused. - Comments: wrong number in collider.test.ts (250 → 200), the "declarations used to…" history in listItems.ts and friends, the 24 px/28 contradiction, section banners in nodes/index.ts, node-catalog.test.ts and api/index.php, name paraphrases, and the stale step numbers in tools/e2e/11-…23- from the old global count. - README: asset types include `animation`, kind order Number/Text/Boolean, tagged colour and texture items, 6/5 kinds, the `Texture` node, `./build.sh`, PROJECT_FORMAT_VERSION, and the file tree (kinds.ts, listItems.ts, propertyPorts.ts, Breadcrumb.ts, beads.ts, tools/assets/). - tests/helpers.ts takes the duplicated `findObject` and `sceneWithCamera`. Proof: ./check.sh 797 tests, full e2e ALL PASSED 633 checks against this bundle. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
335 lines
14 KiB
JavaScript
335 lines
14 KiB
JavaScript
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);
|
||
|
||
// 16a. 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');
|
||
|
||
// 16b. 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),
|
||
);
|
||
|
||
// 16c. 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),
|
||
);
|
||
|
||
// 16d. 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}']`);
|
||
|
||
// 16e. 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');
|
||
|
||
// 16f. 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);
|
||
|
||
// 16g. 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');
|
||
|
||
// 16h. 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()')),
|
||
);
|
||
|
||
// 16i. 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 }),
|
||
);
|
||
|
||
// 16j. 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 }),
|
||
);
|
||
|
||
// 16k. 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);
|
||
}
|