Files
feuerwerk/tools/e2e/57-object-references.mjs
T
adminandClaude Opus 5.5 214218d1b2 Clear references to a template object in every scene on delete (#269)
Deleting an object in a container tab only cleared references inside
the template. Placements in scenes and other containers kept their
instance edit and showed "(missing)" in the inspector.

- shownObjectIds records which objects every holder shows before the
  delete. dropReferencesToGone compares that with the state after it
  and clears each reference that no longer resolves.
- A reference is resolved from its own place with referenceCandidates,
  the same way the runtime does. One rule covers instance edits,
  nested placements and full ids like p~crate.
- A cleared instance edit is dropped when it now says the same as the
  template's own value. Without such a value a marked None stays, as
  if picked by hand, and Reset hands the row back to the template.
- All cleared references come back with a single undo.
- References that were already dead in saved projects stay untouched.

Findings, not fixed here:
- A reference from container D into a nested placement q is stored as
  q~<id>, but the copy is named r~q/<id> in every placement of D. The
  runtime finds nothing, so that reference is dead from the start.
- Delete asset removes placed objects by cascade but does not clear
  references to them.

Tests:
- Unit object-references: 17 passed (13 + 4)
- Gate: 198 files, 2873 tests, exit 0
- e2e 1-17,57: 564 checks passed, 57 has 13 (+1 for 57h)
- Five unit counter-probes and one browser counter-probe fail as
  expected
- e2e total 1607 is computed, not measured in a full run

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 22:35:51 +02:00

354 lines
14 KiB
JavaScript

/**
* Picking an object of the same scene for an object variable (#258), with the real mouse: the
* placed behavior offers None and the scene's objects, the pick is stored as the object's id and a
* run reads the object itself. Removing that object drops the reference; undo brings it back. On the
* behavior asset there is no scene, so the row still says "(null)".
*
* The eyedropper beside the dropdown (#276) picks in the scene view instead: over two stacked
* objects a click takes the one in front and a second click the one behind, Enter writes it, Escape,
* a right-click and a second press of the eyedropper leave it as it was. From a list item in a
* Variables tab it goes to the scene view and comes back with Take.
*
* Removing an object in the Container tab empties a reference a placement of it holds (#269).
*
* Everything it adds carries an `or57` id and goes again at the end.
*/
import { LANES, MAKE_OBJECT } from './lib/scaffold.mjs';
export async function objectReferences(t) {
const { check, json, evaluate, mutate, click, press, typeText, sleep, shot, select, undo, stop } = t;
const press_ = async (at, what) => {
if (!at) throw new Error(`nothing to click: ${what}`);
await click(at.x, at.y);
await sleep(150);
};
const target = () =>
json("__gm.state.activeScene.objects.find((o) => o.id === 'or57-obj')?.overrides.target ?? null");
// On start the behavior logs the object its `target` names.
const graph = {
nodes: [
{ id: 'or57-start', type: 'OnStart', x: 0, y: 0, params: {} },
{
id: 'or57-vars',
type: 'Variables',
x: 0,
y: 200,
params: {},
ports: [{ key: 'target', kind: 'object' }],
},
{ id: 'or57-log', type: 'Log', x: 320, y: 0, params: {} },
],
edges: [
{ id: 'or57-e1', from: { node: 'or57-start', port: 'exec' }, to: { node: 'or57-log', port: 'exec' } },
{
id: 'or57-e2',
from: { node: 'or57-vars', port: 'target' },
to: { node: 'or57-log', port: 'message' },
},
],
};
await mutate(`
const p = __gm.state.project;
const scene = __gm.state.activeScene;
p.assets.push({
id: 'or57-aim',
type: 'behavior',
name: 'Aim 57',
props: { enabled: true, runInEditor: false, target: { $object: '' } },
variables: [{ key: 'target', label: 'target', kind: 'object', default: { $object: '' } }],
graph: ${JSON.stringify(graph)},
});
${MAKE_OBJECT}
const sprite = p.assets.find((a) => a.type === 'sprite');
scene.objects.push(make('or57-obj', 'or57-aim', '', 400, 'Aim 57'));
scene.objects.push(make('or57-crate', sprite.id, '', 450, 'Crate 57'));
`);
const sceneId = await json('__gm.state.activeScene.id');
// 57a. The behavior asset has no scene: its object variable only says "(null)".
await select({ kind: 'asset', assetId: 'or57-aim' });
await sleep(200);
check(
'on the behavior asset the object variable says (null)',
(await json("__gm.locate('(null)')")) !== null,
);
// 57b. On the placed object the row is a dropdown: None, the object itself and the Crate. A click
// on the Crate stores its id.
await select({ kind: 'object', holderId: sceneId, objectId: 'or57-obj' });
await sleep(200);
await press_(await json("__gm.inspectorField('target')"), 'the target field');
const offered = (await json('__gm.overlayTexts()')) ?? [];
// In the full suite the scene holds more objects than the menu shows at once, so the two new ones
// at its end are only reached through the search box.
await typeText('57');
const found = (await json('__gm.overlayTexts()')) ?? [];
await shot('01-menu');
check(
'the placed object offers None, itself and the other objects of the scene',
offered.includes('None') && ['Aim 57', 'Crate 57'].every((label) => found.includes(label)),
JSON.stringify({ offered, found }),
);
await press_(await json("__gm.locate('Crate 57', true)"), 'Crate 57 in the menu');
const picked = await target();
check(
'picking the Crate stores its id and the row shows its name',
JSON.stringify(picked) === JSON.stringify({ $object: 'or57-crate' }) &&
(await json("__gm.locate('Crate 57')")) !== null,
JSON.stringify(picked),
);
await shot('02-picked');
// 57c. A run reads the object itself.
await evaluate('__gm.state.select(null)');
const logFrom = t.consoleLog.length;
await t.play();
await sleep(600);
await stop();
const logged = t.consoleLog
.slice(logFrom)
.filter((line) => line.startsWith('[behavior Aim 57]'))
.map((line) => line.split(']').pop().trim());
check('in a run the variable is the picked object', logged.includes('Crate 57'), JSON.stringify(logged));
// 57d. Removing the Crate drops the reference with it; one undo brings both back.
await select({ kind: 'object', holderId: sceneId, objectId: 'or57-crate' });
await sleep(150);
await press('Delete');
await sleep(150);
const dropped = await target();
await undo();
const back = await target();
check(
'removing the picked object drops the reference, undo brings it back',
JSON.stringify(dropped) === JSON.stringify({ $object: '' }) &&
JSON.stringify(back) === JSON.stringify({ $object: 'or57-crate' }),
JSON.stringify({ dropped, back }),
);
// 57e. The eyedropper (#276): two objects stacked on a lane of their own, the target back to
// None, and a list of one object item for 57g.
const Y = LANES.eyedropper;
await mutate(`
const p = __gm.state.project;
const scene = __gm.state.activeScene;
${MAKE_OBJECT}
const sprite = p.assets.find((a) => a.type === 'sprite');
scene.objects.push(make('or57-back', sprite.id, '', ${Y}, 'Back 57'));
scene.objects.push(make('or57-front', sprite.id, '', ${Y}, 'Front 57'));
const aim = p.assets.find((a) => a.id === 'or57-aim');
aim.variables.push({ key: 'crowd', label: 'crowd', kind: 'list', default: [] });
aim.props.crowd = [];
const obj = scene.objects.find((o) => o.id === 'or57-obj');
obj.overrides.target = { $object: '' };
obj.overrides.crowd = [{ $object: '' }];
`);
await select({ kind: 'object', holderId: sceneId, objectId: 'or57-obj' });
await evaluate(`__gm.lookAt(0, ${Y})`);
await sleep(200);
const pick = () => json('__gm.objectPick()');
const eyedropper = () => json("__gm.inspectorEyedropper('target')");
const inInspector = async (text) => {
const box = await json('__gm.inspectorRect()');
const hits = (await json(`__gm.locateAll(${JSON.stringify(text)})`)) ?? [];
return hits.some((p) => p.x >= box.x && p.x <= box.x + box.w && p.y >= box.y && p.y <= box.y + box.h);
};
const spot = await json("__gm.objectScreen('or57-front')");
const stack = await json(`__gm.sceneStack(${spot.x}, ${spot.y})`);
await press_(await eyedropper(), 'the eyedropper of target');
const started = await pick();
const lit = (await eyedropper())?.lit;
check(
'the eyedropper starts in the scene view, lit, and the bar over it names the field',
started.picking &&
started.tab === 'scene' &&
lit === true &&
started.candidate === null &&
started.strip?.text === 'Target for Aim 57 · target: click an object',
JSON.stringify({ started, lit }),
);
await click(spot.x, spot.y);
const first = await pick();
await click(spot.x, spot.y);
const second = await pick();
await shot('03-eyedropper');
const empty = await json('__gm.emptySpot()');
await click(empty.x, empty.y);
const afterEmpty = await pick();
check(
'a click takes the object in front, a second one the object behind, empty space keeps it, nothing is written',
JSON.stringify(stack) === JSON.stringify(['or57-front', 'or57-back']) &&
first.candidate === 'or57-front' &&
second.candidate === 'or57-back' &&
second.outline === 'or57-back' &&
second.strip?.text === 'Target for Aim 57 · target: Back 57' &&
afterEmpty.candidate === 'or57-back' &&
afterEmpty.picking &&
JSON.stringify(await target()) === JSON.stringify({ $object: '' }),
JSON.stringify({ stack, first, second, afterEmpty }),
);
await press('Enter');
const taken = await pick();
const written = await target();
const shown = await inInspector('Back 57');
const litAfter = (await eyedropper())?.lit;
await undo();
const undone = await target();
check(
'Enter writes the candidate, the row shows it, the eyedropper goes off, one undo takes it back',
!taken.picking &&
taken.ended === 'take' &&
taken.strip === null &&
JSON.stringify(written) === JSON.stringify({ $object: 'or57-back' }) &&
shown &&
litAfter === false &&
JSON.stringify(undone) === JSON.stringify({ $object: '' }),
JSON.stringify({ taken, written, shown, litAfter, undone }),
);
// 57f. Escape, a right-click in the view and a second press of the eyedropper leave it as it was.
const cancels = [];
for (const [how, leave] of [
['Escape', () => press('Escape')],
['right-click', () => click(spot.x, spot.y, 'right')],
['eyedropper', async () => press_(await eyedropper(), 'the eyedropper, again')],
]) {
await press_(await eyedropper(), 'the eyedropper of target');
await click(spot.x, spot.y);
const before = (await pick()).candidate;
await leave();
const after = await pick();
const menu = (await json('__gm.overlayTexts()')) ?? [];
cancels.push({ how, before, picking: after.picking, ended: after.ended, menu, target: await target() });
}
check(
'Escape, a right-click and a second press of the eyedropper end it without writing',
cancels.every(
(c) =>
c.before === 'or57-front' &&
!c.picking &&
c.ended === 'cancel' &&
c.menu.length === 0 &&
JSON.stringify(c.target) === JSON.stringify({ $object: '' }),
),
JSON.stringify(cancels),
);
// 57g. A list item in a Variables tab: the eyedropper goes to the scene view, Take writes the item
// and brings the tab back.
await evaluate(
`__gm.state.openValues({ kind: 'list', slot: { kind: 'object', holderId: ${JSON.stringify(sceneId)}, objectId: 'or57-obj', key: 'crowd' } })`,
);
await sleep(200);
const tabShown = (await pick()).tab;
const inspectorLeft = (await json('__gm.inspectorRect()')).x;
const inTab = ((await json("__gm.locateIcons('eyedropper')")) ?? []).filter((p) => p.x < inspectorLeft);
await press_(inTab[0], 'the eyedropper of the list item');
const fromList = await pick();
await click(spot.x, spot.y);
const listCandidate = (await pick()).candidate;
const take = (await pick()).strip?.take;
await press_(take, 'Take');
const backIn = await pick();
const crowd = await json(
"__gm.state.activeScene.objects.find((o) => o.id === 'or57-obj')?.overrides.crowd ?? null",
);
check(
'from a list item in a Variables tab it picks in the scene view, and Take writes it and goes back',
tabShown === 'variables' &&
inTab.length === 1 &&
fromList.tab === 'scene' &&
fromList.strip?.text === 'Target for Aim 57 · crowd · [0]: click an object' &&
listCandidate === 'or57-front' &&
backIn.ended === 'take' &&
backIn.tab === 'variables' &&
JSON.stringify(crowd) === JSON.stringify([{ $object: 'or57-front' }]),
JSON.stringify({ tabShown, inTab, fromList, listCandidate, backIn, crowd }),
);
// 57h. A container placed in the scene, its Aim pointed at its own Crate there (#269). Removing
// the Crate in the Container tab empties that reference too: the row says None, not
// (missing). One undo brings both back.
await t.showTab('scene');
await mutate(`
const p = __gm.state.project;
const scene = __gm.state.activeScene;
${MAKE_OBJECT}
const sprite = p.assets.find((a) => a.type === 'sprite');
p.assets.push({
id: 'or57-box',
type: 'container',
name: 'Box 57',
props: {},
objects: [make('or57-t-aim', 'or57-aim', '', 0, 'Aim 57'), make('or57-t-crate', sprite.id, '', 50, 'Crate 57')],
});
const placed = make('or57-placed', 'or57-box', '', ${LANES.containerReference}, 'Box 57');
placed.instanceEdits = {
'or57-t-aim': { position: {}, overrides: { target: { $object: 'or57-t-crate' } } },
};
scene.objects.push(placed);
`);
const placedTarget = () =>
json(
"__gm.state.activeScene.objects.find((o) => o.id === 'or57-placed')?.instanceEdits['or57-t-aim']?.overrides.target ?? null",
);
const targetRow = async () => {
const field = await json("__gm.inspectorField('target')");
const hits = (await json("__gm.locateAll('None')")) ?? [];
return {
none: hits.some((p) => field && p.x >= field.left && Math.abs(p.y - field.y) < 12),
missing: await inInspector('(missing)'),
crate: await inInspector('Crate 57'),
};
};
const placedAim = { kind: 'object', holderId: sceneId, objectId: 'or57-placed~or57-t-aim' };
await select(placedAim);
await sleep(200);
const rowBefore = await targetRow();
await select({ kind: 'object', holderId: 'or57-box', objectId: 'or57-t-crate' });
await sleep(200);
const tabThen = await json('__gm.tab()');
await press('Delete');
await sleep(150);
const templateLeft = await json(
"__gm.state.project.assets.find((a) => a.id === 'or57-box').objects.map((o) => o.id)",
);
const emptied = await placedTarget();
await t.showTab('scene');
await select(placedAim);
await sleep(200);
const rowAfter = await targetRow();
await shot('04-container-reference');
await undo();
await sleep(150);
const restored = await placedTarget();
check(
'removing an object in the Container tab empties the placement pointing at it, undo brings it back',
rowBefore.crate &&
!rowBefore.missing &&
tabThen === 'container' &&
JSON.stringify(templateLeft) === JSON.stringify(['or57-t-aim']) &&
JSON.stringify(emptied) === JSON.stringify({ $object: '' }) &&
rowAfter.none &&
!rowAfter.missing &&
JSON.stringify(restored) === JSON.stringify({ $object: 'or57-t-crate' }),
JSON.stringify({ rowBefore, tabThen, templateLeft, emptied, rowAfter, restored }),
);
// Clean up. Back to the scene view first: removing Aim 57 closes 57g's Variables tab, and the
// editor then shows whatever document earlier steps left open, not the scene view.
await t.showTab('scene');
await evaluate('__gm.state.select(null)');
await mutate(`
const p = __gm.state.project;
const scene = __gm.state.activeScene;
scene.objects = scene.objects.filter((o) => !o.id.startsWith('or57-'));
p.assets = p.assets.filter((a) => a.id !== 'or57-aim' && a.id !== 'or57-box');
`);
}