Files
feuerwerk/tools/e2e/09-containers.mjs
T
adminandClaude Opus 5.5 0e8585665a
game-maker / gate (push) Successful in 2m47s
game-maker / suite (push) Successful in 8m41s
Load the 500 weight and draw missing glyphs as SVG icons (#214)
The theme asks for Source Sans 3 at weight 500, but fonts.ts only
loaded 400, 600 and 700. The browser synthesised the 500 text. The
issue comment also asked for the symbols the font lacks to become SVG
icons. A scan of src/ against the font's cmap found 20 such characters
in 30 places.

- fonts.ts loads source-sans-3-latin-500-normal.woff2 as well.
- Menus draw their ticks and the "current" dot as icons. The dot is a
  new MenuItem.current field and no longer part of the label text.
- Inspector action buttons (reset, apply, fit width/height) are
  icons. The menu arrow behind them is the drawn Dropdown chevron.
- Kind marks come from a table in markIcons.ts. Picture kinds use an
  icon, # and Aa stay text because the font has them.
- Scene marks in the asset panel, sound play/stop and the transition
  arrows are icons too.
- Apply is an icon although the font has the arrow. It sits next to
  the reset icon and would look different as a glyph.
- A new Icon widget draws on redraw like Button, so Node tests need no
  DOMParser.
- Tests find icons through the SVG id. The new hook
  __gm.locateIcons(id) and the harness helpers findIcon/clickIcon
  replace locate() on the old characters.

Findings:
- Weight 700 is loaded but the theme never uses it. Left as is.
- The old scene-mark check in module 35 accepted any play glyph on
  screen. It now checks the mark on the start scene's row.
- Module 44 needs the textures from module 37 and fails without it.

Tests:
- ./check.sh: 145 files, 2187 tests, exit 0 (before 144 / 2143).
- New tests/ui/fonts.test.ts: every theme weight is loaded, and every
  character in src/ exists in every shipped face. The cmap reader
  matches fontTools (231 characters).
- Icon tests cover the 19 new files. Each kind mark is either an
  icon or text, never both.
- e2e 1-16,22,30,34,35,43,44: 586 green, 0 FAIL, then 44 stopped
  for lack of module 37. e2e 1-16,37,44,47,48: 618 green.
  e2e 1-14 on the final build: 454 green.
- Two new e2e checks: loaded weights in 14, a reset icon pixel in 04.
- Counter-probes all fail as expected: 500 removed, a play glyph put
  back, the struct icon removed (one unit test each). In the browser,
  500 removed and reset icon hidden fail exactly the two new checks.
- Editor-suite check sum 1312 (1310 measured by CI at 056a622, +2).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-23 08:13:22 +02:00

610 lines
26 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');
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',
await has('Follows a template of 5 object(s).'),
);
await clickText('Unpack');
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);
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);
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,
}),
);
}