Files
feuerwerk/tools/e2e/22-struct.mjs
T
rootandClaude Fable 5.1 6471671f7d Refactor round: ship web/index.html, drop the remote browser, fix stale comments and README
- `.gitignore` said `index.html` unanchored, which also caught the Vite source page;
  a fresh clone could neither `dev` nor `build`. Anchored to `/index.html`, page added.
- Removed GM_REMOTE (tools/remote/gm-browser.ps1 and the ssh/scp branch in browser.mjs):
  the Windows test machine is gone and the only flaky check that lived on it is closed (#37).
- Removed `npm run preview`, undocumented and unused.
- Comments: wrong number in collider.test.ts (250 → 200), the "declarations used to…"
  history in listItems.ts and friends, the 24 px/28 contradiction, section banners in
  nodes/index.ts, node-catalog.test.ts and api/index.php, name paraphrases, and the stale
  step numbers in tools/e2e/11-…23- from the old global count.
- README: asset types include `animation`, kind order Number/Text/Boolean, tagged colour
  and texture items, 6/5 kinds, the `Texture` node, `./build.sh`, PROJECT_FORMAT_VERSION,
  and the file tree (kinds.ts, listItems.ts, propertyPorts.ts, Breadcrumb.ts, beads.ts,
  tools/assets/).
- tests/helpers.ts takes the duplicated `findObject` and `sceneWithCamera`.

Proof: ./check.sh 797 tests, full e2e ALL PASSED 633 checks against this bundle.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-12 15:44:05 +02:00

270 lines
11 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?". */
/** 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' });
`);
await sleep(150);
// 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.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');
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()),
);
// 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 sleep(150);
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();
await sleep(200);
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');
await sleep(250);
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');
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);
// The depth the stored value goes ends here: this list sits in a struct field, so a struct item
// would be dropped by the next write. It is left out of the menu rather than doing nothing.
await clickText('+ Item');
await sleep(150);
check(
'the menu of a list in a struct field leaves the struct kind out',
(await find('Number')) !== null && (await find('Struct')) === null,
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();
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);
// 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' });
await sleep(200);
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 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));
}