Files
adminandClaude Opus 5.5 c74b6b1cab
game-maker / gate (push) Successful in 3m10s
game-maker / suite (push) Failing after 16m38s
Show where an inspector row can be dragged and answer the pointer
After #310 rows could be dragged by their mark, but nothing said which
marks take a drag, and the pointer on one changed nothing but the
cursor.

- A mark that drags is now a Grip: the same widget wrapped, the same
  size, so the value column stays where it was. Once sortBlocks takes
  it, it carries six faint dots at its left edge.
- Marks that cannot drag stay plain: the variables of a placed object
  and the fields of a typed struct show no dots.
- With the pointer on a grip its dots light up and the whole block that
  would move is washed in the accent colour, a struct with its fields.
  While the block is carried the wash gives way to the dimming, and the
  grip stays lit.
- While a block is carried the other grips let the pointer pass: no
  lit dots, no wash. Every grip of the column shows grabbing instead of
  grab until the drop.
- The wash first used controlHover like list rows. In the inspector it
  is darker than the panel and vanished next to the dark fields, so it
  became the accent at 15 percent, the colour of the drop line.
- __gm.inspectorMark also reports the dots: none, idle or lit.

Tests:
- Gate on melektaus-atx: 236 files, 3576 tests (+4 in
  block-drag.test.ts, one of them a drag passing over another grip).
- e2e 22: 40 checks (+1, sum 1740). 22f-iii reads idle dots, moves the
  mouse onto the mark and reads them lit.
- Two counter-checks bite: no wash on hover fails 1 test, grips never
  made grabbable fail 3. A third: hover ignoring the drag fails 1.
- Screenshots of a lit single row and a lit struct block looked at.

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

374 lines
15 KiB
JavaScript
Raw Permalink 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,
mouse,
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);
};
const count = await json('__gm.inspectorMark("count")');
await mouse('mouseMoved', count.x, count.y);
await mouse('mouseMoved', count.x + 1, count.y);
await sleep(50);
const lit = await json('__gm.inspectorMark("count")');
check(
'a mark that drags shows its dots, lit under the pointer',
count.dots === 'idle' && lit.dots === 'lit',
`${count.dots} -> ${lit.dots}`,
);
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));
}