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>
363 lines
14 KiB
JavaScript
363 lines
14 KiB
JavaScript
/**
|
||
* 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));
|
||
}
|