- `.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>
625 lines
26 KiB
JavaScript
625 lines
26 KiB
JavaScript
import { instanceChild } from '../lib/builders.mjs';
|
||
import { same } from '../lib/harness.mjs';
|
||
|
||
/**
|
||
* Multi-selection and placed containers: what the scene *holds* (the container object alone) against
|
||
* what the editor *lists* (content resolved from the template), and deviations kept per instance.
|
||
*/
|
||
export async function containers(t) {
|
||
const {
|
||
check,
|
||
sleep,
|
||
evaluate,
|
||
json,
|
||
has,
|
||
shot,
|
||
click,
|
||
clickText,
|
||
drag,
|
||
dragAsset,
|
||
locate,
|
||
press,
|
||
typeText,
|
||
undo,
|
||
mutate,
|
||
select,
|
||
state,
|
||
object,
|
||
objects,
|
||
storedObjects,
|
||
asset,
|
||
world,
|
||
play,
|
||
stop,
|
||
api,
|
||
sceneOf,
|
||
navigate,
|
||
projectId,
|
||
} = t;
|
||
const center = await json('__gm.viewportCenter()');
|
||
/** Types a value into an inspector field, which runs the real command behind that row. */
|
||
const setField = async (label, value) => {
|
||
const row = await locate(label);
|
||
await click(row.x + 160, row.y);
|
||
await press('a', { ctrl: true });
|
||
await typeText(String(value));
|
||
await press('Enter');
|
||
await sleep(200);
|
||
};
|
||
const notice = async () => {
|
||
await evaluate("__gm.state.notice = ''");
|
||
return async () => (await state()).notice;
|
||
};
|
||
const selectObject = async (id) => {
|
||
await select({ kind: 'object', holderId: 'scene1', objectId: id, objectIds: [id] });
|
||
await sleep(250);
|
||
};
|
||
|
||
// 9a. 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),
|
||
);
|
||
|
||
// 9b. 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');
|
||
|
||
// 9c. 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);
|
||
check(
|
||
'dragging a multi-selection moves every selected object',
|
||
(await object('o1')).position.x === -100 && (await object('o2')).position.x === 170,
|
||
JSON.stringify({ o1: (await object('o1')).position, o2: (await object('o2')).position }),
|
||
);
|
||
await undo();
|
||
check(
|
||
'one undo restores both',
|
||
(await object('o1')).position.x === -150 && (await object('o2')).position.x === 120,
|
||
);
|
||
|
||
// 9d. Delete with a multi-selection removes all of them (with subtrees) as one step.
|
||
const listedBeforeDelete = (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 === listedBeforeDelete - 5,
|
||
);
|
||
await undo();
|
||
check('undo restores all of them', (await objects()).length === listedBeforeDelete);
|
||
|
||
// 9e. Create container: both crates, their colliders and the Mover *leave the scene*; one stored
|
||
// container object stands in their stead and the five are resolved from its template.
|
||
const assetsBefore = (await json('__gm.state.project.assets')).length;
|
||
const storedBefore = (await storedObjects()).length;
|
||
const listedBefore = (await objects()).length;
|
||
await select({ kind: 'object', holderId: 'scene1', objectId: 'o2', objectIds: ['o1', 'o2'] });
|
||
await clickText('Create container');
|
||
await sleep(150);
|
||
s = await state();
|
||
const instanceA = s.selection?.kind === 'object' ? await object(s.selection.objectId) : null;
|
||
const containerAsset = instanceA ? await asset(instanceA.assetId) : null;
|
||
const storedAfter = (await storedObjects()).length;
|
||
const listedAfter = (await objects()).length;
|
||
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,
|
||
JSON.stringify({ instanceA, type: containerAsset?.type }),
|
||
);
|
||
check(
|
||
'the container sits at the centre of the selection',
|
||
instanceA && instanceA.parentId === '' && same(instanceA.position, { x: -15, y: -40, z: 0 }),
|
||
JSON.stringify(instanceA?.position),
|
||
);
|
||
const swallowed = ['o1', 'o2', 'o4', 'oc1', 'oc2'];
|
||
const storedIds = (await storedObjects()).map((o) => o.id);
|
||
check(
|
||
'the members left the scene: five objects out, one container object in',
|
||
storedAfter === storedBefore - 4 && !swallowed.some((id) => storedIds.includes(id)),
|
||
JSON.stringify({ storedBefore, storedAfter, storedIds }),
|
||
);
|
||
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,
|
||
JSON.stringify(containerAsset?.objects.map((o) => [o.assetId, o.parentId === '' ? 'root' : 'child'])),
|
||
);
|
||
|
||
// The two crate templates, told apart by where the centring put them.
|
||
const roots = containerAsset.objects.filter((o) => o.parentId === '');
|
||
const redTemplate = roots.find((o) => o.position.x === -135);
|
||
const contentOf = (instanceId) => containerAsset.objects.map((o) => instanceChild(instanceId, o.id));
|
||
const redA = instanceChild(instanceA.id, redTemplate.id);
|
||
const redAPath = redTemplate.id;
|
||
check(
|
||
'the editor lists the content again, under ids derived from the template',
|
||
listedAfter === listedBefore + 1 &&
|
||
same(
|
||
(await objects()).filter((o) => contentOf(instanceA.id).includes(o.id)).map((o) => o.id),
|
||
contentOf(instanceA.id),
|
||
),
|
||
JSON.stringify({ listedBefore, listedAfter, derived: contentOf(instanceA.id) }),
|
||
);
|
||
const redResolved = await object(redA);
|
||
check(
|
||
'a resolved object carries the template values and hangs below the container',
|
||
redResolved &&
|
||
redResolved.parentId === instanceA.id &&
|
||
redResolved.name === 'Red crate' &&
|
||
redResolved.position.x === -135 &&
|
||
redResolved.position.y === 40,
|
||
JSON.stringify(redResolved),
|
||
);
|
||
const orderC = await json('(__gm.hierarchyExpandAll(), __gm.hierarchyOrder())');
|
||
const cAt = orderC.indexOf(instanceA.id);
|
||
check(
|
||
'hierarchy lists the container with its resolved content indented below',
|
||
cAt >= 0 && same(orderC.slice(cAt), [instanceA.id, ...contentOf(instanceA.id)]),
|
||
JSON.stringify(orderC),
|
||
);
|
||
check(
|
||
'container symbol is drawn in the overlay layer',
|
||
(await evaluate(`__gm.layerOf('${instanceA.id}')`)) === 'overlay',
|
||
);
|
||
await shot('24-container-created');
|
||
|
||
// 9f. Dragging the container symbol in the viewport moves the whole group; undo restores it.
|
||
const cScreen = await json(`__gm.objectScreen('${instanceA.id}')`);
|
||
await drag(cScreen.x, cScreen.y, cScreen.x + 100, cScreen.y);
|
||
check(
|
||
'dragging the container moves the group (its content keeps the template positions)',
|
||
(await object(instanceA.id)).position.x === 85 && (await object(redA)).position.x === -135,
|
||
JSON.stringify({ container: (await object(instanceA.id)).position }),
|
||
);
|
||
await undo();
|
||
check('undo moves the container back', (await object(instanceA.id)).position.x === -15);
|
||
|
||
// 9g. Placing the container asset again adds exactly ONE stored object – and five more that
|
||
// the editor lists. Against the old copying model the stored count would jump by six.
|
||
const spot = await json('__gm.viewportCenter()');
|
||
await dragAsset(containerAsset.id, spot.x + 90, spot.y + 60);
|
||
const instanceB = (await storedObjects()).find(
|
||
(o) => o.assetId === containerAsset.id && o.id !== instanceA.id,
|
||
);
|
||
const redB = instanceB && instanceChild(instanceB.id, redTemplate.id);
|
||
check(
|
||
'placing a container stores one object and lists its whole content',
|
||
Boolean(instanceB) &&
|
||
(await storedObjects()).length === storedAfter + 1 &&
|
||
(await objects()).length === listedAfter + 1 + containerAsset.objects.length,
|
||
JSON.stringify({
|
||
stored: (await storedObjects()).length,
|
||
listed: (await objects()).length,
|
||
expected: listedAfter + 6,
|
||
}),
|
||
);
|
||
check(
|
||
'the second instance shows the same content at the same template positions',
|
||
(await object(redB)).position.x === -135 && (await object(redB)).position.y === 40,
|
||
JSON.stringify((await object(redB))?.position),
|
||
);
|
||
check(
|
||
'a fresh instance carries no deviations at all',
|
||
same(instanceB.instanceEdits, {}),
|
||
JSON.stringify(instanceB.instanceEdits),
|
||
);
|
||
await shot('26-container-placed');
|
||
|
||
// 9h. A value edited on one instance is kept on that instance and nowhere else: the scene
|
||
// grows by no object, the deviation lands in `instanceEdits` under the template path.
|
||
await selectObject(redB);
|
||
check('the inspector names the container the object comes from', await has(containerAsset.name));
|
||
await setField('X', -100);
|
||
const editedB = (await storedObjects()).find((o) => o.id === instanceB.id);
|
||
check(
|
||
'an edit on resolved content is stored as an instance deviation, not as a new object',
|
||
(await storedObjects()).length === storedAfter + 1 &&
|
||
editedB.instanceEdits[redAPath]?.position.x === -100 &&
|
||
(await object(redB)).position.x === -100,
|
||
JSON.stringify(editedB.instanceEdits),
|
||
);
|
||
check(
|
||
'the other instance is untouched by it',
|
||
(await object(redA)).position.x === -135,
|
||
JSON.stringify((await object(redA)).position),
|
||
);
|
||
|
||
// 9i. Editing the template reaches every instance that has not been touched there – and only
|
||
// those. The untouched axis of the edited object follows along with the rest.
|
||
const setTemplate = (body) =>
|
||
mutate(
|
||
`const tpl = __gm.state.project.assets.find(a => a.id === '${containerAsset.id}')
|
||
.objects.find(o => o.id === '${redAPath}');
|
||
${body}`,
|
||
);
|
||
await setTemplate(`tpl.position.y = 55; tpl.name = 'Template crate'; tpl.overrides.color = 0x00ff00`);
|
||
check(
|
||
'a template edit reaches every instance at once',
|
||
(await object(redA)).position.y === 55 &&
|
||
(await object(redB)).position.y === 55 &&
|
||
(await object(redA)).name === 'Template crate' &&
|
||
(await object(redB)).name === 'Template crate' &&
|
||
(await object(redA)).overrides.color === 0x00ff00,
|
||
JSON.stringify({ a: await object(redA), b: await object(redB) }),
|
||
);
|
||
await setTemplate(`tpl.position.x = -200`);
|
||
check(
|
||
'but it does not overwrite the instance that was moved on its own',
|
||
(await object(redA)).position.x === -200 && (await object(redB)).position.x === -100,
|
||
JSON.stringify({ a: (await object(redA)).position.x, b: (await object(redB)).position.x }),
|
||
);
|
||
await setTemplate(
|
||
`tpl.position.x = -135; tpl.position.y = 40; tpl.name = 'Red crate'; delete tpl.overrides.color`,
|
||
);
|
||
check(
|
||
'putting the template back takes the untouched instance back with it',
|
||
(await object(redA)).position.x === -135 && (await object(redA)).name === 'Red crate',
|
||
);
|
||
|
||
// 9j. Structure is the template's business: resolved content cannot be removed, re-parented,
|
||
// given a collider or turned into a container of its own.
|
||
// The hierarchy menu is the only way to a collider, and a disabled entry must not even reach
|
||
// the command: the command refuses too, but with a notice.
|
||
const colliderFromMenu = async (id) => {
|
||
await selectObject(id);
|
||
const row = await json(`__gm.hierarchyRow(${JSON.stringify(id)})`);
|
||
const before = (await objects()).length;
|
||
const noticeNow = await notice();
|
||
await click(row.x, row.y, 'right');
|
||
await sleep(150);
|
||
await clickText('Add collider');
|
||
await sleep(200);
|
||
await press('Escape');
|
||
await sleep(100);
|
||
return { added: (await objects()).length - before, notice: await noticeNow() };
|
||
};
|
||
const refused = JSON.stringify({ added: 0, notice: '' });
|
||
const fromContent = await colliderFromMenu(redA);
|
||
check(
|
||
'the menu offers no collider for resolved content',
|
||
JSON.stringify(fromContent) === refused,
|
||
JSON.stringify(fromContent),
|
||
);
|
||
const fromInstance = await colliderFromMenu(instanceA.id);
|
||
check(
|
||
'nor for the placed container itself',
|
||
JSON.stringify(fromInstance) === refused,
|
||
JSON.stringify(fromInstance),
|
||
);
|
||
const plainId = await json(`(() => {
|
||
const p = __gm.state.project;
|
||
const sprite = __gm.state.activeScene.objects.find(
|
||
(o) => p.assets.find((a) => a.id === o.assetId)?.type === 'sprite',
|
||
);
|
||
return sprite ? sprite.id : null;
|
||
})()`);
|
||
await selectObject(plainId);
|
||
check('the inspector has no collider button of its own', !(await has('+ Add collider')));
|
||
const fromPlain = await colliderFromMenu(plainId);
|
||
check(
|
||
'while a plain sprite still gets one from the menu',
|
||
fromPlain.added === 1,
|
||
JSON.stringify(fromPlain),
|
||
);
|
||
await undo();
|
||
await sleep(200);
|
||
await selectObject(redA);
|
||
let noticeAfter = await notice();
|
||
await press('Delete');
|
||
await sleep(200);
|
||
check(
|
||
'removing resolved content is refused with a notice',
|
||
(await noticeAfter()).includes('container template') && Boolean(await object(redA)),
|
||
await noticeAfter(),
|
||
);
|
||
noticeAfter = await notice();
|
||
await press('g', { ctrl: true });
|
||
await sleep(200);
|
||
check(
|
||
'making a container out of resolved content is refused too',
|
||
(await noticeAfter()).includes('container template'),
|
||
await noticeAfter(),
|
||
);
|
||
const parentRow = await json("__gm.inspectorField('Parent')");
|
||
await click(parentRow.x, parentRow.y);
|
||
await sleep(150);
|
||
check(
|
||
'the Parent picker does not even open for resolved content',
|
||
same(await json('__gm.overlayTexts()'), []),
|
||
JSON.stringify(await json('__gm.overlayTexts()')),
|
||
);
|
||
await press('Escape');
|
||
noticeAfter = await notice();
|
||
// Rows are addressed by id, not by their text: both instances read "Red crate" by now.
|
||
const rowAt = async (id) => {
|
||
const p = await json(`__gm.hierarchyChevron(${JSON.stringify(id)})`);
|
||
if (!p) throw new Error(`no hierarchy row for ${id}`);
|
||
return { x: p.x + 60, y: p.y };
|
||
};
|
||
const redARow = await rowAt(redA);
|
||
const otherRow = await rowAt(instanceB.id);
|
||
await drag(redARow.x, redARow.y, otherRow.x, otherRow.y);
|
||
await sleep(250);
|
||
check(
|
||
're-parenting resolved content by dragging its row is refused',
|
||
(await noticeAfter()).includes('container template') && (await object(redA)).parentId === instanceA.id,
|
||
`${await noticeAfter()} / parent ${(await object(redA)).parentId}`,
|
||
);
|
||
await shot('26b-structure-refused');
|
||
|
||
// 9k. Unpack: the content is written into the scene as ordinary objects under the very same
|
||
// ids, the instance stops following the template – and now its structure *can* be changed.
|
||
await dragAsset(containerAsset.id, spot.x - 90, spot.y + 60);
|
||
const instanceC = (await storedObjects()).find(
|
||
(o) => o.assetId === containerAsset.id && o.id !== instanceA.id && o.id !== instanceB.id,
|
||
);
|
||
const redC = instanceChild(instanceC.id, redTemplate.id);
|
||
const listedBeforeUnpack = (await objects()).length;
|
||
const storedBeforeUnpack = (await storedObjects()).length;
|
||
await selectObject(instanceC.id);
|
||
check(
|
||
'the inspector shows what the placed container follows',
|
||
await has('Follows a template of 5 object(s).'),
|
||
);
|
||
await clickText('Unpack');
|
||
await sleep(250);
|
||
check(
|
||
'Unpack writes the content into the scene, keeping the ids and the listing',
|
||
(await storedObjects()).length === storedBeforeUnpack + 5 &&
|
||
(await objects()).length === listedBeforeUnpack &&
|
||
(await storedObjects()).some((o) => o.id === redC),
|
||
JSON.stringify({
|
||
stored: (await storedObjects()).length,
|
||
listed: (await objects()).length,
|
||
before: { stored: storedBeforeUnpack, listed: listedBeforeUnpack },
|
||
}),
|
||
);
|
||
await selectObject(instanceC.id);
|
||
check(
|
||
'and the inspector stops offering the unpack, there is nothing left to follow',
|
||
!(await has('Unpack')) && !(await has('Follows a template of 5 object(s).')),
|
||
);
|
||
await setTemplate(`tpl.position.x = -300`);
|
||
check(
|
||
'an unpacked instance no longer follows the template',
|
||
(await object(redC)).position.x === -135 && (await object(redA)).position.x === -300,
|
||
JSON.stringify({ c: (await object(redC)).position.x, a: (await object(redA)).position.x }),
|
||
);
|
||
await setTemplate(`tpl.position.x = -135`);
|
||
await selectObject(redC);
|
||
// Unpacking keeps the derived ids, so an id that merely *looks* like content of a template must
|
||
// not freeze the picker any more – the inspector has to read the holder, not the id.
|
||
const unpackedParentRow = await json("__gm.inspectorField('Parent')");
|
||
await click(unpackedParentRow.x, unpackedParentRow.y);
|
||
await sleep(150);
|
||
check(
|
||
'unpacked content offers the Parent picker like any ordinary object',
|
||
(await json('__gm.overlayTexts()')).includes('None'),
|
||
JSON.stringify(await json('__gm.overlayTexts()')),
|
||
);
|
||
await press('Escape');
|
||
await sleep(100);
|
||
noticeAfter = await notice();
|
||
const redCRow = await rowAt(redC);
|
||
await click(redCRow.x, redCRow.y, 'right');
|
||
await clickText('Unparent (move to top level)');
|
||
await sleep(250);
|
||
check(
|
||
'unpacked content can be re-parented, which was refused while it followed the template',
|
||
(await object(redC)).parentId === '' && (await noticeAfter()) === '',
|
||
`${await noticeAfter()} / parent "${(await object(redC)).parentId}"`,
|
||
);
|
||
noticeAfter = await notice();
|
||
await selectObject(redC);
|
||
await press('Delete');
|
||
await sleep(250);
|
||
check(
|
||
'and it can be removed – the refusal was about following a template, not about the id',
|
||
!(await object(redC)) && (await noticeAfter()) === '',
|
||
`${await noticeAfter()}`,
|
||
);
|
||
await selectObject(instanceC.id);
|
||
await press('Delete');
|
||
await sleep(250);
|
||
check(
|
||
'removing the unpacked container takes the rest of its content with it',
|
||
(await storedObjects()).length === storedBeforeUnpack - 1,
|
||
JSON.stringify({ now: (await storedObjects()).length, was: storedBeforeUnpack }),
|
||
);
|
||
|
||
// 9l. Spawning a container at runtime spells it out the same way Unpack does, under the same
|
||
// derived ids – and Stop throws the whole run away with it.
|
||
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.id.startsWith(`${spawnedRoot.id}~`)).length === 5,
|
||
JSON.stringify(fresh.map((o) => ({ id: o.id, assetId: o.assetId }))),
|
||
);
|
||
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');
|
||
|
||
// 9m. Apply / revert on resolved content. The red crate of instance B was moved on x, the one
|
||
// of instance A still matches the template; ↑ writes the value into the template and A
|
||
// follows, ↺ drops the deviation so B follows again.
|
||
const templateRed = async () => (await asset(containerAsset.id)).objects.find((o) => o.id === redAPath);
|
||
await selectObject(redA);
|
||
check('no apply/revert while the instance matches its template', !(await has('↑')) && !(await has('↺')));
|
||
await selectObject(redB);
|
||
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(150);
|
||
check(
|
||
'apply writes x into the template and the untouched instance follows',
|
||
(await templateRed()).position.x === -100 && (await object(redA)).position.x === -100,
|
||
JSON.stringify({ template: (await templateRed()).position, a: (await object(redA)).position }),
|
||
);
|
||
check('apply hides the buttons again', !(await has('↑')));
|
||
await undo();
|
||
check(
|
||
'undo restores template and instances',
|
||
(await templateRed()).position.x === -135 && (await object(redA)).position.x === -135,
|
||
);
|
||
const revertButton = await evaluate(`__gm.locate('↺')`);
|
||
await click(revertButton.x, revertButton.y);
|
||
await sleep(150);
|
||
const revertedB = (await storedObjects()).find((o) => o.id === instanceB.id);
|
||
check(
|
||
'revert drops the deviation, so the instance follows the template again',
|
||
(await object(redB)).position.x === -135 && revertedB.instanceEdits[redAPath] === undefined,
|
||
JSON.stringify(revertedB.instanceEdits),
|
||
);
|
||
|
||
// 9n. An override on resolved content deviates from two bases at once – the template it
|
||
// follows and, one step further up, the asset – so apply offers a menu. Reset never goes
|
||
// past the nearest base, so it stays a single button.
|
||
await selectObject(redB);
|
||
await setField('Width', 77);
|
||
const editedWidth = (await storedObjects()).find((o) => o.id === instanceB.id);
|
||
check(
|
||
'an override on resolved content is kept as an instance deviation',
|
||
editedWidth.instanceEdits[redAPath]?.overrides.width === 77 &&
|
||
(await object(redB)).overrides.width === 77 &&
|
||
(await object(redA)).overrides.width === undefined,
|
||
JSON.stringify(editedWidth.instanceEdits),
|
||
);
|
||
const applyMenu = await evaluate(`__gm.locate('↑ ▾')`);
|
||
check('an overridden property on an instance shows the apply menu button', Boolean(applyMenu));
|
||
check(
|
||
'reset stays a single button – inside a container it only ever goes to the template',
|
||
!(await has('↺ ▾')),
|
||
);
|
||
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(150);
|
||
check(
|
||
'Apply to asset writes the value into the asset and drops the deviation',
|
||
(await asset('crate')).props.width === 77 && (await object(redB)).overrides.width === undefined,
|
||
JSON.stringify((await asset('crate')).props),
|
||
);
|
||
await undo();
|
||
check(
|
||
'undo restores asset and deviation',
|
||
(await asset('crate')).props.width === 100 && (await object(redB)).overrides.width === 77,
|
||
);
|
||
await click(applyMenu.x, applyMenu.y);
|
||
await clickText('Apply to container');
|
||
await sleep(150);
|
||
check(
|
||
'Apply to container writes the override into the template, so every instance shows it',
|
||
(await templateRed()).overrides.width === 77 && (await object(redA)).overrides.width === 77,
|
||
JSON.stringify({ template: (await templateRed()).overrides, a: (await object(redA)).overrides }),
|
||
);
|
||
|
||
// 9o. redB matches its template again (both 77) and has nothing of its own left to reset –
|
||
// but the value still differs from the asset (100), which apply can still reach past it.
|
||
await sleep(200);
|
||
check(
|
||
'with the template carrying the value there is nothing left to reset, only an apply upwards',
|
||
(await has('↑')) && !(await has('↑ ▾')) && !(await has('↺')),
|
||
);
|
||
await shot('28b-single-apply');
|
||
const revertedEdits = (await storedObjects()).find((o) => o.id === instanceB.id);
|
||
check(
|
||
'and the instance stores no deviation for it any more',
|
||
revertedEdits.instanceEdits[redAPath] === undefined,
|
||
JSON.stringify(revertedEdits.instanceEdits),
|
||
);
|
||
await mutate(
|
||
`delete __gm.state.project.assets.find(a => a.id === '${containerAsset.id}')
|
||
.objects.find(o => o.id === '${redAPath}').overrides.width`,
|
||
);
|
||
|
||
// 9p. Save and load: an instance is a container object plus its deviations, nothing else.
|
||
// The content must be nowhere in the saved scene, and reloading must resolve it back.
|
||
await selectObject(redB);
|
||
await setField('Y', 12);
|
||
for (let i = 0; i < 30 && (await state()).save !== 'saved'; i++) await sleep(300);
|
||
const savedScene = sceneOf(await api(`projects/${projectId}`));
|
||
const savedInstance = savedScene.objects.find((o) => o.id === instanceB.id);
|
||
check(
|
||
'the saved scene holds the container object and its deviations, not its content',
|
||
Boolean(savedInstance) &&
|
||
savedInstance.instanceEdits[redAPath]?.position.y === 12 &&
|
||
!savedScene.objects.some((o) => o.id === redB),
|
||
JSON.stringify({ edits: savedInstance?.instanceEdits, stored: savedScene.objects.length }),
|
||
);
|
||
const listedBeforeReload = (await objects()).length;
|
||
await navigate();
|
||
await sleep(500);
|
||
check(
|
||
'a reloaded project resolves the same content again, deviations included',
|
||
(await objects()).length === listedBeforeReload &&
|
||
(await object(redB)).position.y === 12 &&
|
||
(await object(redA)).position.y === 40,
|
||
JSON.stringify({
|
||
listed: (await objects()).length,
|
||
expected: listedBeforeReload,
|
||
b: (await object(redB))?.position,
|
||
}),
|
||
);
|
||
}
|