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>
354 lines
14 KiB
JavaScript
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');
|
|
`);
|
|
}
|