Adding a collider now lives only in the Scene objects context menu, deleting a node on the Delete key and the node context menu. The e2e checks that pinned the inspector button now drive the hierarchy menu instead, including that its entry is disabled on template content and on a placed container without raising a notice. Co-Authored-By: Claude Opus 5 <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);
|
||
};
|
||
|
||
// 16. 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),
|
||
);
|
||
|
||
// 16a. 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');
|
||
|
||
// 16b. 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,
|
||
);
|
||
|
||
// 16c. 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);
|
||
|
||
// 16d. 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');
|
||
|
||
// 16e. 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);
|
||
|
||
// 16f. 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');
|
||
|
||
// 16g. 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),
|
||
);
|
||
|
||
// 16h. 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',
|
||
);
|
||
|
||
// 16i. 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');
|
||
|
||
// 16j. 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 }),
|
||
);
|
||
|
||
// 16k. 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');
|
||
|
||
// 16l. 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),
|
||
);
|
||
|
||
// 16m. 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 }),
|
||
);
|
||
|
||
// 16n. 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`,
|
||
);
|
||
|
||
// 16o. 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,
|
||
}),
|
||
);
|
||
}
|