Files
feuerwerk/tools/e2e/22-struct.mjs
T
adminandClaude Opus 5.5 0c5ac32b59
game-maker / gate (push) Successful in 3m52s
game-maker / suite (push) Successful in 10m24s
Let structs and lists nest to any depth (#239)
Structs and lists were capped at two fixed levels (list of structs,
struct with a list field). Anything deeper was dropped silently on
load, in node settings and when written to a variable. The cap came
from an early design choice, not from a technical need, so it is
lifted completely instead of adding a third fixed level.

- One kind list NESTED_KINDS replaces ListItemKind and
  StructFieldKind. The flat special cases in listItems.ts are gone,
  every value goes through one recursive path.
- A struct inside a struct is declared, like a top-level struct.
  Values coming in over an edge are brought into that shape. Struct
  items in lists still take their fields from the value.
- New valuePath.ts addresses any nested spot by keys and indices.
  ListSlot.field is now ListSlot.path.
- Field commands (add, rename, remove) take the path to the struct.
  They change declaration, own value and all overrides in one undo
  step, as before on the top level.
- Inspector: "+ Field" offers Struct and List on every level. A
  nested struct is a header row with its own "+ Field", its fields
  are indented one step further.
- List tab: a list item shows "n items" and opens one level deeper.
  The title shows the path, e.g. "Listy · nums · [3]".
- Pack in Pack now writes a struct into a struct field instead of
  text. Objects are kept at any depth and stripped at every exit.
- A list edit in a struct field without an existing object override
  now starts from the shown value instead of an empty struct.
- Old projects load unchanged. Save, load and save again gives the
  same result.

Tests:
- ./check.sh: 172 files, 2498 tests, CHECK EXIT 0
- e2e 21 + 22: ALL PASSED, 90 checks (new 21d-iii, 22f-ii,
  21d and 22f updated)
- 20 new unit tests in nested-values.test.ts and
  nested-list-slot.test.ts, three tests that pinned the old cap
  turned around
- 6 counter-probes, each fails 1 to 8 tests, originals restored
  and checked with cmp
- Not covered: a nested struct inside a struct item in the list
  tab has no e2e click

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-26 22:33:20 +02:00

318 lines
12 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,
findIcon,
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?". */
/** A kind menu entry, always the last drawn one: the panels behind carry those words too. */
const pickKind = async (label) => {
const at = await json(`__gm.locate(${JSON.stringify(label)}, true)`);
await click(at.x, at.y);
};
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' });
`);
// 22a. 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')) !== null && (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');
// 22b. 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');
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');
check(
'and the next field edited keeps the one before it',
JSON.stringify(await piece()) === '{"x":42,"tag":"ell"}',
JSON.stringify(await piece()),
);
// 22c. 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()));
// 22d. Adding a field reaches the declaration and the value at once.
await clickText('+ Field');
await pickKind('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();
check('undo takes it out of the value too', !('field1' in (await piece())), JSON.stringify(await piece()));
// 22e. 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');
check(
'renaming a field keeps its value',
JSON.stringify(await piece()) === '{"column":30,"tag":"tee"}',
JSON.stringify(await piece()),
);
// 22f. 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');
// "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);
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);
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);
// Nothing bounds the depth any more (#239): a list in a struct field offers what any list does.
await clickText('+ Item');
check(
'the menu of a list in a struct field offers every kind, the struct included',
(await find('Number')) !== null &&
(await find('Struct')) !== null &&
(await json('__gm.list.info()')).offers.includes('list'),
JSON.stringify((await json('__gm.list.info()')).offers),
);
await pickKind('Number');
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();
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)');
// 22f-ii. A field may be a struct again (#239): a head row with a `+ Field` of its own, and its
// fields one indent step further in. Both menus with real clicks.
const declaredLast = () =>
json("__gm.state.project.assets.find((a) => a.id === 'structy').variables[0].fields.at(-1)");
await clickText('+ Field');
await pickKind('Struct');
await sleep(250);
check(
'+ Field ▸ Struct gives the bundle an empty struct',
JSON.stringify((await piece()).field1) === '{}',
JSON.stringify(await piece()),
);
// The nested `+ Field` is drawn after the head's, so it is the last one.
const nestedAdd = await json(`__gm.locate('+ Field', true)`);
const headAdd = await json(`__gm.locate('+ Field')`);
check(
'and that struct has its own + Field',
nestedAdd.y > headAdd.y,
JSON.stringify({ nestedAdd, headAdd }),
);
await click(nestedAdd.x, nestedAdd.y);
await pickKind('Number');
await sleep(250);
check(
'which adds a field one level down, in the value',
JSON.stringify(await piece()) === '{"column":30,"tag":"tee","field1":{"field1":0}}',
JSON.stringify(await piece()),
);
check(
'and in the declaration',
JSON.stringify(await declaredLast()) ===
'{"key":"field1","kind":"struct","fields":[{"key":"field1","kind":"number"}]}',
JSON.stringify(await declaredLast()),
);
const outerName = await json(`__gm.locate('field1')`);
const innerName = await json(`__gm.locate('field1', true)`);
check(
'its field sits below it, one indent step further in',
innerName.y > outerName.y && innerName.x > outerName.x,
JSON.stringify({ outerName, innerName }),
);
await shot('03b-nested-struct');
await undo();
await undo();
check('undo takes both back out', !('field1' in (await piece())), JSON.stringify(await piece()));
// 22g. 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' });
check('a placed behavior shows its bundle, filled from the asset', (await find('tee')) !== null);
// 22h. 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 findIcon('kind-struct');
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');
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();
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();
`);
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));
}