An unconnected list input is a button into the List tab instead of a number field; a struct field can hold a list, so ListSlot carries an optional field name and the three owner cases only say what sits at a key; and a list variable picks its item kind in the inspector (marker doubles as the chooser), so a list of structs no longer has to be born from a project file. The declaration keeps the storable shapes finite: a struct field may be a list, but that list holds primitives only, so list->struct->primitive and struct->list->primitive are the two deepest forms. check 674, e2e 483, jump-run 24, Tetris 19. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
148 lines
5.6 KiB
JavaScript
148 lines
5.6 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, shot, click, clickText, press, typeText, mutate, undo } = t;
|
||
|
||
/** `locate` throws when the text is absent, which is the wrong shape for "is it there?". */
|
||
const find = (text) => json(`__gm.locate(${JSON.stringify(text)}, false)`);
|
||
const piece = () => json("__gm.state.project.assets.find((a) => a.id === 'structy').props.piece");
|
||
|
||
// 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);
|
||
|
||
// 1. 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);
|
||
await shot('01-struct-rows');
|
||
|
||
// 2. 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()),
|
||
);
|
||
|
||
// 3. 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()));
|
||
|
||
// 4. Adding a field reaches the declaration and the value at once.
|
||
await clickText('+ Field');
|
||
await sleep(150);
|
||
await clickText('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()));
|
||
|
||
// 5. 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()),
|
||
);
|
||
|
||
// 6. 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);
|
||
|
||
await clickText('+ Item');
|
||
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);
|
||
await mutate(`
|
||
const p = __gm.state.project;
|
||
p.assets = p.assets.filter((a) => a.id !== 'structy');
|
||
__gm.state.select(null);
|
||
__gm.state.preview();
|
||
`);
|
||
await sleep(200);
|
||
const left = await json('__gm.state.project.assets.filter((a) => a.id === "structy").length');
|
||
check('the step leaves the fixture as it found it', left === 0, String(left));
|
||
}
|