Files
feuerwerk/tools/e2e/09-containers.mjs
T
adminandClaude Opus 5.5 963f8a152a Open a scene framed, and thin out two inspector sections (#295)
Three small things asked for in one issue. A scene came up wherever
the view happened to stand, and the inspector carried a button and a
list that are better reached somewhere else.

- A scene comes up framed as if Fit had been clicked: after a
  project is loaded and when another scene is opened. From then on
  the view belongs to the user.
- An empty scene has nothing to frame and stands at the origin, one
  pixel per unit. A scene the running game loads is not framed anew.
- The Container tab is left out. It keeps the view of each container
  as before (#171).
- The Unpack button and the two lines under it are gone from the
  inspector of a placed container. Unpack container stays in the
  right-click menu of the hierarchy and of the scene view.
- The corners of a polygon collider are no row in the inspector any
  more. They are edited on the outline in the scene view. The new
  schema option noRow keeps a property out of the inspector while it
  stays a property.

Tests:
- Gate on melektaus-atx: 219 files, 3401 tests, CHECK EXIT 0.
- One new unit test in tests/core/polygon.test.ts: the corners are
  a property of the collider and no row.
- Three new e2e checks: a loaded project is framed like Fit (module
  03), an opened scene is (04), and after a real reload a click on
  Fit moves nothing (09).
- Module 09 unpacks through the right-click menu now and checks that
  the inspector offers no button.
- Full suite on melektaus-atx: 1695 checks, all pass. Smokes 24, 19
  and 21, all pass.
- No counter-probe evaluated, and nothing looked at after the build.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 23:52:49 +02:00

626 lines
27 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';
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 {
saved,
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,
findIcon,
} = 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');
};
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] });
};
// 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');
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');
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 clickText('Add collider…');
// An enabled entry opens the second menu (#222); a disabled one leaves the first standing.
if (await has('Create new collider')) await clickText('Create new collider');
await press('Escape');
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 selectObject(redA);
let noticeAfter = await notice();
await press('Delete');
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 });
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);
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);
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, without a button to unpack it (#295)',
(await has('Follows a template of 5 object(s).')) && !(await has('Unpack')),
);
const instanceCRow = await json(`__gm.hierarchyRow(${JSON.stringify(instanceC.id)})`);
await click(instanceCRow.x, instanceCRow.y, 'right');
await clickText('Unpack container');
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 says so, there is nothing left to follow',
(await has('Unpacked: its content is stored in the scene.')) &&
!(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);
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');
noticeAfter = await notice();
const redCRow = await rowAt(redC);
await click(redCRow.x, redCRow.y, 'right');
await clickText('Unparent (move to top level)');
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');
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');
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');
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; apply writes the value into the template and A
// follows, reset 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 findIcon('apply', false)) && !(await findIcon('reset', false)),
);
await selectObject(redB);
const applyButton = await findIcon('apply', false);
check(
'a moved instance shows apply and revert on its X row',
Boolean(applyButton) && (await findIcon('reset', false)),
);
await shot('27-apply-buttons');
await click(applyButton.x, applyButton.y);
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 findIcon('apply', false)));
await undo();
check(
'undo restores template and instances',
(await templateRed()).position.x === -135 && (await object(redA)).position.x === -135,
);
const revertButton = await findIcon('reset', false);
await click(revertButton.x, revertButton.y);
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 findIcon('apply', true);
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 findIcon('reset', true)),
);
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');
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');
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 findIcon('apply', false)) && !(await findIcon('apply', true)) && !(await findIcon('reset', false)),
);
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);
await saved();
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);
// The reloaded scene stands framed already (#295): a click on Fit moves nothing.
const afterReload = await json(`__gm.objectRect(${JSON.stringify(instanceB.id)})`);
const fitButton = await json(`__gm.sceneHeaderAt('fit')`);
await click(fitButton.x, fitButton.y);
const afterFit = await json(`__gm.objectRect(${JSON.stringify(instanceB.id)})`);
check(
'after a reload the scene is framed as Fit frames it',
Boolean(afterReload && afterFit) &&
['x', 'y', 'w', 'h'].every((k) => Math.abs(afterReload[k] - afterFit[k]) < 0.01),
JSON.stringify({ afterReload, afterFit }),
);
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,
}),
);
}