Two findings from #269. A reference from a container into a placement nested in it was dead from the start, and deleting an asset left references to the objects it took along showing "(missing)". - A reference is stored the way its holder shows the object. In a container tab that is q~x for content of a nested placement q. - referenceCandidates translates it when the container is placed: in placement r, q~x becomes r~q/x. pathOfId and idOfPath in instance.ts do the mapping, objectChoices offers the same spelling the other way. The rule holds at any depth. - Storing q/x as proposed in #285 was dropped. The container tab would then have had to translate its own ids. - No migration. All projects and snapshots hold three references, none of them with ~ or /. - Create container lets a reference to content of a member placement follow it. It was left dead before. - Paste lets such a reference point into the copy. It kept pointing at the original before. Both go through the new renamedReference. - Delete asset records what every holder shows before the cascade and clears every reference to an object that is gone afterwards, with dropReferencesToGone from #269. The row shows None, undo restores the references (#286). Tests: - Gate: 198 files, 2882 tests (2873 + 9), exit 0 - Unit object-references: 26 passed (17 + 9) - e2e 1-17,57: 566 checks passed, 57 has +57i and +57j - Six unit counter-probes fail between 1 and 3 tests each - The browser counter-probe fails 57i and 57j as described in the issues - e2e total 1609 is computed, not measured in a full run Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
453 lines
19 KiB
JavaScript
453 lines
19 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).
|
|
* Inside a container the Aim can pick content of a placement nested there, and the container's
|
|
* placement in the scene reaches it (#285). Deleting that nested container's asset empties the
|
|
* reference again (#286).
|
|
*
|
|
* 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 }),
|
|
);
|
|
|
|
// 57i. Outer 57 holds an Aim and a placement of Inner 57 (#285). In the Container tab the Aim
|
|
// picks Inner's Nest 57, stored as Outer's own view names it. Outer's placement in the scene
|
|
// shows that name on its Aim, and a run reads the copy of Nest 57 there.
|
|
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-inner',
|
|
type: 'container',
|
|
name: 'Inner 57',
|
|
props: {},
|
|
objects: [make('or57-i-nest', sprite.id, '', 0, 'Nest 57')],
|
|
});
|
|
p.assets.push({
|
|
id: 'or57-outer',
|
|
type: 'container',
|
|
name: 'Outer 57',
|
|
props: {},
|
|
objects: [
|
|
make('or57-o-aim', 'or57-aim', '', 0, 'Aim 57'),
|
|
make('or57-o-inner', 'or57-inner', '', 50, 'Inner 57'),
|
|
],
|
|
});
|
|
scene.objects.push(make('or57-outer-placed', 'or57-outer', '', ${LANES.nestedReference}, 'Outer 57'));
|
|
`);
|
|
const outerTarget = () =>
|
|
json(
|
|
"__gm.state.project.assets.find((a) => a.id === 'or57-outer').objects.find((o) => o.id === 'or57-o-aim')?.overrides.target ?? null",
|
|
);
|
|
const nestName = JSON.stringify({ $object: 'or57-o-inner~or57-i-nest' });
|
|
await select({ kind: 'object', holderId: 'or57-outer', objectId: 'or57-o-aim' });
|
|
await sleep(200);
|
|
const outerTab = await json('__gm.tab()');
|
|
await press_(await json("__gm.inspectorField('target')"), 'the target field in the Container tab');
|
|
const inOuter = (await json('__gm.overlayTexts()')) ?? [];
|
|
// Nest 57 sits one level below Inner 57, so the menu indents it.
|
|
const nestEntry = inOuter.find((text) => text.trim() === 'Nest 57');
|
|
await press_(await json(`__gm.locate(${JSON.stringify(nestEntry)}, true)`), 'Nest 57 in the menu');
|
|
const stored = await outerTarget();
|
|
await t.showTab('scene');
|
|
const nestedAim = { kind: 'object', holderId: sceneId, objectId: 'or57-outer-placed~or57-o-aim' };
|
|
await select(nestedAim);
|
|
await sleep(200);
|
|
const nestedRow = { nest: await inInspector('Nest 57'), missing: await inInspector('(missing)') };
|
|
await shot('05-nested-reference');
|
|
await evaluate('__gm.state.select(null)');
|
|
const runFrom = t.consoleLog.length;
|
|
await t.play();
|
|
await sleep(600);
|
|
await stop();
|
|
const nestedLogged = t.consoleLog
|
|
.slice(runFrom)
|
|
.filter((line) => line.startsWith('[behavior Aim 57]'))
|
|
.map((line) => line.split(']').pop().trim());
|
|
check(
|
|
'a pick in the Container tab reaches into a nested placement, and the placement in the scene shows and reads it',
|
|
outerTab === 'container' &&
|
|
nestEntry !== undefined &&
|
|
JSON.stringify(stored) === nestName &&
|
|
nestedRow.nest &&
|
|
!nestedRow.missing &&
|
|
nestedLogged.includes('Nest 57'),
|
|
JSON.stringify({ outerTab, inOuter, stored, nestedRow, nestedLogged }),
|
|
);
|
|
|
|
// 57j. Deleting Inner 57 in the asset list (#286) takes its placement out of Outer 57 and empties
|
|
// the Aim's reference to Nest 57 with it: the row says None, not (missing). One undo brings
|
|
// both back.
|
|
const innerRow = await json("__gm.assetRow('or57-inner')");
|
|
if (!innerRow) throw new Error('Inner 57 is not in the asset list');
|
|
await click(innerRow.x, innerRow.y, 'right');
|
|
await sleep(150);
|
|
await press_(await json("__gm.locate('Delete…', true)"), 'Delete… in the row menu');
|
|
await press_(await json("__gm.locate('Delete', true)"), 'Delete in the dialog');
|
|
const innerLeft = await json("__gm.state.project.assets.some((a) => a.id === 'or57-inner')");
|
|
const cleared = await outerTarget();
|
|
await select(nestedAim);
|
|
await sleep(200);
|
|
const clearedRow = await targetRow();
|
|
await shot('06-asset-deleted');
|
|
await undo();
|
|
await sleep(150);
|
|
const reinstated = await outerTarget();
|
|
check(
|
|
'deleting an asset empties a reference to an object it took along, undo brings it back',
|
|
!innerLeft &&
|
|
JSON.stringify(cleared) === JSON.stringify({ $object: '' }) &&
|
|
clearedRow.none &&
|
|
!clearedRow.missing &&
|
|
JSON.stringify(reinstated) === nestName,
|
|
JSON.stringify({ innerLeft, cleared, clearedRow, reinstated }),
|
|
);
|
|
|
|
// 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.startsWith('or57-'));
|
|
`);
|
|
}
|