Files
feuerwerk/tools/e2e/22-struct.mjs
T
adminandClaude Opus 5.5 2c19c89878
game-maker / gate (push) Successful in 3m10s
game-maker / suite (push) Failing after 19m34s
Let variables, fields, ports and list items be dragged into order (#310)
Variables of a behavior, the fields of a struct, the ports of a start
or end node and the items of a list could only be added and removed.
Changing their order meant removing and adding them again. Now each of
them is dragged by the mark in front of its row.

- sortBlocks (ui/blockDrag.ts) makes the rows of one column sortable.
  A block is every row it covers, so a struct moves with its fields and
  a list item with its nested list. The block is dimmed while it is
  carried, an accent line shows where it lands, and a scrolled panel
  follows the pointer at its edge. Less than 4 px stays a click.
- The arithmetic is pure and lives in ui/sortable.ts: blockGap,
  gapLineY, dropSlot and edgeScroll. dropIndex of the tile strip moved
  there too, so the reorder maths sits in one file.
- MoveVariableCommand, MoveStructFieldCommand and MovePortCommand
  build on the existing MoveCommand. Undo moves back.
- Lists need no command. InlineList replaces the list as a whole, as
  it does for every other edit of its items.
- The grip is the kind mark, for a list item its number. Moving only
  happens among siblings: a field stays in its struct.
- On a Struct asset the move runs through WithStructTypes. Typed
  variables, Pack/Expand ports and split outputs follow the new order.
- Values and edges hang on names, so only the order changes. Nodes
  running a behavior read their ports from the start node, the new
  order reaches them without a copy.
- Not sortable by design: the variables of a placed object, the fields
  of a typed struct (moved on the Struct asset) and the picked ports
  of Variables/Properties nodes.
- Test hook __gm.inspectorMark(label) finds the mark of a row.

Tests:
- Gate on melektaus-atx: 235 files, 3572 tests, CHECK EXIT 0 (+9).
- tests/core/reorder.test.ts: a variable, a field two levels deep, a
  Struct asset reaching a typed variable and Pack, and start ports
  reaching the calling node with edges and result kept.
- e2e 22: ALL PASSED, 39 checks (+4, sum 1739). 22f-iii drags a
  variable past a struct of several rows, then a field inside it.
- Counter-probes: dropSlot by top edge instead of middle and a field
  move that ignores its path fail 3 unit tests. With sortBlocks
  switched off, the three order checks of 22f-iii fail.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 21:45:04 +02:00

363 lines
14 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,
drag,
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 items are drawn inline under it (#252), and its
// title names the field inside the bundle – not the whole bundle.
await clickText('+ Field');
// "List" is also a word elsewhere on screen, 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()),
);
check('and that field reads as an item count', (await find('0 items')) !== null);
const fieldList = async () =>
(await json(`__gm.values.lists('inspector')`)).find((l) => l.title.endsWith('· field1')) ?? null;
const drawn = await fieldList();
check(
'whose items are drawn inline, titled by the field inside the bundle',
drawn !== null,
JSON.stringify(drawn),
);
// Nothing bounds the depth any more (#239): a list in a struct field offers what any list does.
const head = await json(`__gm.values.headAt('inspector', ${JSON.stringify(drawn?.title ?? '')})`);
await click(head.add.x, head.add.y);
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 fieldList()).offers.includes('list'),
JSON.stringify((await fieldList()).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]}' &&
(await fieldList()).rows === 1,
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()),
);
// 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()));
// 22f-iii. Reordering by dragging the mark (#310): a variable past a struct of several rows, and a
// field among the fields of its struct. A drop just above a mark lands in front of its row.
await mutate(`
const asset = __gm.state.project.assets.find((a) => a.id === 'structy');
asset.variables.push({ key: 'count', label: 'count', kind: 'number', default: 0 });
asset.props.count = 5;
`);
const order = () =>
json(
"(() => { const v = __gm.state.project.assets.find((a) => a.id === 'structy').variables;" +
" return v.map((d) => d.key).join(',') + '|' + v[v.findIndex((d) => d.key === 'piece')].fields.map((f) => f.key).join(','); })()",
);
const dragMark = async (from, onto) => {
const grip = await json(`__gm.inspectorMark(${JSON.stringify(from)})`);
const target = await json(`__gm.inspectorMark(${JSON.stringify(onto)})`);
await drag(grip.x, grip.y, target.x, target.y - 6);
};
await dragMark('count', 'piece');
check(
'dragging a variable by its mark puts it in front of the struct, fields and all',
(await order()) === 'count,piece|column,tag',
await order(),
);
await dragMark('tag', 'column');
check(
'a field moves among the fields of its struct',
(await order()) === 'count,piece|tag,column',
await order(),
);
check(
'and the values stay with their names',
JSON.stringify(await piece()) === '{"column":30,"tag":"tee"}',
);
await undo();
await undo();
check('each move is one undo step', (await order()) === 'piece,count|column,tag', await order());
await mutate(`
const asset = __gm.state.project.assets.find((a) => a.id === 'structy');
asset.variables = asset.variables.filter((v) => v.key !== 'count');
delete asset.props.count;
`);
// 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));
}