Files
feuerwerk/tools/e2e/22-struct.mjs
T
rootandClaude Opus 5 8f92a06c84 Consolidate duplicated editor code, trim comments, refresh README
Split GraphCanvas (1076 -> 831 lines) into EdgeLayer and graphMenus;
the port and marker menus were the same list and are now one function.
Value specs of the inspector and the list tab share valueSpecs.ts, and
the remaining copies (compound commands, type glyphs, typed asset
lookup, name rule, play/editor world setup, marquee) are merged.

Four copies had already drifted apart; merging them fixes:
- "Disconnect N links" in the port menu is one undo step, not N
- "+ Add collider" is hidden on a placed container instance, as in
  the hierarchy menu, instead of always failing
- a bool property coerced from the text "false" becomes false
- a port command on an unknown node type reports instead of crashing

Comment-only edits leave the bundle byte-identical. README corrected in
35 places (step limit, jump-run clock, suite duration, 409 body, ...).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-11 11:00:30 +02:00

255 lines
10 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.
/**
* A variable of type struct: the inspector declares its fields and gives each one a value in the
* same row, and writing one field keeps the others – the trap a shared snapshot would fall into.
*/
export async function structVariable(t) {
const { check, sleep, json, find, shot, click, clickText, press, typeText, mutate, select, undo } = t;
/** `locate` throws when the text is absent, which is the wrong shape for "is it there?". */
const piece = () => json("__gm.state.project.assets.find((a) => a.id === 'structy').props.piece");
/** Left edge of each row's control – one column grid means one number, in the browser's own font. */
const leftsOf = (labels) =>
json(`[${labels.map((l) => `__gm.inspectorField(${JSON.stringify(l)})?.left ?? null`).join(',')}]`);
const oneColumn = (xs) => xs.every((x) => x !== null && x === xs[0]);
// A throwaway behavior carrying one struct variable; removed again at the end of the step.
await mutate(`
const p = __gm.state.project;
p.assets.push({
id: 'structy',
type: 'behavior',
name: 'Structy',
props: { enabled: true, runInEditor: false, piece: { x: 30, tag: 'tee' } },
variables: [
{
key: 'piece',
label: 'piece',
kind: 'struct',
default: { x: 0, tag: '' },
fields: [
{ key: 'x', kind: 'number' },
{ key: 'tag', kind: 'text' },
],
},
],
graph: { nodes: [], edges: [] },
});
__gm.state.select({ kind: 'asset', assetId: 'structy' });
`);
await sleep(150);
// 1. The declaration is what the inspector draws: a head that can add fields, a row per field.
check('a struct variable carries the add-a-field control', (await find('+ Field')) !== null);
check('and a row for each declared field', (await find('x')) !== null && (await find('tag')) !== null);
check('whose value is shown, not a summary of the bundle', (await find('30.000')) !== null);
const assetLefts = await leftsOf(['Name', 'Run in editor', 'x', 'tag']);
check(
'a field value starts on the same column as every other row',
oneColumn(assetLefts),
JSON.stringify(assetLefts),
);
await shot('01-struct-rows');
// 2. Writing one field keeps the others. Two edits in a row without a rebuild in between is
// exactly where handlers sharing one snapshot of the bundle would revive the first value.
const xField = await json("__gm.inspectorField('x')");
await click(xField.x, xField.y);
await typeText('42');
await press('Enter');
await sleep(200);
check('typing in a field writes that field', (await piece()).x === 42, JSON.stringify(await piece()));
const tagField = await json("__gm.inspectorField('tag')");
await click(tagField.x, tagField.y);
await press('a', { ctrl: true });
await typeText('ell');
await press('Enter');
await sleep(200);
check(
'and the next field edited keeps the one before it',
JSON.stringify(await piece()) === '{"x":42,"tag":"ell"}',
JSON.stringify(await piece()),
);
// 3. One undo step per field.
await undo();
check('undo takes back just the last field', (await piece()).tag === 'tee' && (await piece()).x === 42);
await undo();
check('and the one before it', (await piece()).x === 30, JSON.stringify(await piece()));
// 4. Adding a field reaches the declaration and the value at once.
await clickText('+ Field');
await sleep(150);
await clickText('Boolean');
await sleep(250);
const grown = await piece();
check('+ Field grows the bundle', 'field1' in grown && grown.field1 === false, JSON.stringify(grown));
check('and the new field has a row', (await find('field1')) !== null);
await shot('02-field-added');
await undo();
await sleep(200);
check('undo takes it out of the value too', !('field1' in (await piece())), JSON.stringify(await piece()));
// 5. Renaming a field carries its value along.
const nameField = await json("__gm.locate('x')");
await click(nameField.x, nameField.y);
await press('a', { ctrl: true });
await typeText('column');
await press('Enter');
await sleep(250);
check(
'renaming a field keeps its value',
JSON.stringify(await piece()) === '{"column":30,"tag":"tee"}',
JSON.stringify(await piece()),
);
// 6. A field may itself be a list. Its row is the same "n items" button a list property has,
// and it opens the List tab one level down – on the field, not on the whole bundle.
await clickText('+ Field');
await sleep(150);
// "List" is also the tab bar's own label, so the menu entry is reached as the last drawn one.
const listOption = await json(`__gm.locate('List', true)`);
await click(listOption.x, listOption.y);
await sleep(250);
check(
'+ Field ▸ List gives the bundle an empty list',
Array.isArray((await piece()).field1),
JSON.stringify(await piece()),
);
const itemsButton = await find('0 items');
check('and that field reads as an item count', itemsButton !== null);
await click(itemsButton.x, itemsButton.y);
await sleep(250);
check('clicking it opens the List tab', (await json('__gm.tab()')) === 'list');
const info = await json('__gm.list.info()');
check('whose title names the field inside the bundle', info.title.endsWith('· field1'), info.title);
await clickText('+ Item');
await sleep(250);
check(
'and an item added there leaves the neighbouring fields alone',
JSON.stringify(await piece()) === '{"column":30,"tag":"tee","field1":[0]}',
JSON.stringify(await piece()),
);
await shot('03-list-field');
await undo();
await undo();
await sleep(200);
check(
'undo takes the list field back out of the bundle',
!('field1' in (await piece())),
JSON.stringify(await piece()),
);
await mutate('__gm.state.openList(null)');
await sleep(200);
// 7. The same bundle on a placed object. An override is sparse – it starts out unset – while a
// field edit rebuilds the whole bundle, so the untouched fields have to come from what the row
// shows (the asset's values) rather than from the empty form of the declaration.
const placed = () => json("__gm.state.activeScene.objects.find((o) => o.id === 'ostruct').overrides.piece");
await mutate(`
__gm.state.activeScene.objects.push({
id: 'ostruct',
assetId: 'structy',
name: 'Placed structy',
position: { x: 0, y: 0, z: 0 },
editorOffset: { x: 0, y: 0 },
overrides: {},
parentId: '',
instanceEdits: {},
});
`);
await select({ kind: 'object', holderId: 'scene1', objectId: 'ostruct' });
await sleep(200);
check('a placed behavior shows its bundle, filled from the asset', (await find('tee')) !== null);
// 8. It reads exactly like the asset does: a head carrying the struct mark, then one row per
// field with its kind mark, its name and its value. What is missing is the shape editing –
// a placed object overrides values, the declaration stays with the asset that owns it.
const bundleMark = await find('⊞');
const headName = await find('piece');
const fieldMark = await find('#');
const fieldName = await find('column');
const fieldValue = await json("__gm.inspectorField('column')");
check(
'the bundle is a head above its fields, marked as a struct',
bundleMark !== null &&
headName !== null &&
fieldName !== null &&
bundleMark.x < headName.x &&
headName.y < fieldName.y,
JSON.stringify({ bundleMark, headName, fieldName }),
);
check(
'and a field row runs kind mark, name, value',
fieldMark !== null &&
fieldName !== null &&
fieldMark.x < fieldName.x &&
fieldName.x < fieldValue.x &&
Math.abs(fieldMark.y - fieldName.y) < 4,
JSON.stringify({ fieldMark, fieldName, fieldValue }),
);
check('the shape itself cannot be changed from here', (await find('+ Field')) === null);
const placedLefts = await leftsOf(['Name', 'Run in editor', 'Offset X', 'column', 'tag']);
check(
'and its values share the column of position, offset and the switches',
oneColumn(placedLefts) && placedLefts[0] === assetLefts[0],
JSON.stringify({ placedLefts, asset: assetLefts[0] }),
);
// Typing over a name has to do nothing: a Label is not a TextField, and the declaration is
// the asset's. Plain letters are safe here – the only shortcuts are modified ones and Delete.
const declaration = () =>
json(
"(() => { const v = __gm.state.project.assets.find((a) => a.id === 'structy').variables[0];" +
' return `${v.key}:${v.fields.map((f) => f.key).join(",")}`; })()',
);
const before = await declaration();
// A missing label would throw here and kill the step; clicking nowhere keeps it a plain failure.
for (const cell of [headName, fieldName]) {
await click(cell?.x ?? 0, cell?.y ?? 0);
await typeText('renamed');
await press('Enter');
}
await sleep(200);
check('typing over the names of a placed bundle renames nothing', (await declaration()) === before, before);
const placedField = await json("__gm.inspectorField('column')");
await click(placedField.x, placedField.y);
await press('a', { ctrl: true });
await typeText('7');
await press('Enter');
await sleep(250);
check(
'the first override of one field keeps the asset values of the others',
JSON.stringify(await placed()) === '{"column":7,"tag":"tee"}',
JSON.stringify(await placed()),
);
await shot('04-object-override');
await undo();
await sleep(200);
check(
'and undo takes the override away again, not just the one field',
(await json("__gm.state.activeScene.objects.find((o) => o.id === 'ostruct').overrides.piece")) === null,
JSON.stringify(await placed()),
);
await mutate(`
const p = __gm.state.project;
p.assets = p.assets.filter((a) => a.id !== 'structy');
const scene = __gm.state.activeScene;
scene.objects = scene.objects.filter((o) => o.id !== 'ostruct');
__gm.state.select(null);
__gm.state.preview();
`);
await sleep(200);
const left = await json(
'__gm.state.project.assets.filter((a) => a.id === "structy").length +' +
' __gm.state.activeScene.objects.filter((o) => o.id === "ostruct").length',
);
check('the step leaves the fixture as it found it', left === 0, String(left));
}