Files
feuerwerk/tools/e2e/21-list.mjs
T
rootandClaude Opus 5 631cf54c72 Give every behavior variable one row shape on asset and instance
buildStructBlock only covered structs, so a placed instance drew numbers,
text, bools and lists through createFieldRow on a 64 px name column while
its struct block sat on the asset's grid below it. variableRows.ts now
builds the row for every variable kind on both sides; the edit option
decides what the owner may change (asset: rename, x, item kind; instance:
revert and push). Label and Button take a fixed width so the mark column
no longer shifts names when a list shows its item-kind picker.

The two structFields calls that became dead with 9f48257 are gone. The
e2e steps locate the Enabled toggle through __gm.inspectorField instead
of clicking a fixed offset from its label.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-11 07:27:29 +02:00

294 lines
12 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.
/**
* The List tab: a list property is a button saying how many items it has, clicking it opens the
* tab, and there a row per item edits, adds and removes them with the inspector's own widgets.
*/
export async function listTab(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)`);
// A throwaway behavior carrying one list variable; removed again at the end of the step. Its
// items are two digits so that no value can be confused with a row's index label.
await mutate(`
const p = __gm.state.project;
p.assets.push({
id: 'listy',
type: 'behavior',
name: 'Listy',
props: { enabled: true, runInEditor: false, nums: [30, 11, 44] },
variables: [{ key: 'nums', label: 'nums', kind: 'list', default: [], items: 'number' }],
graph: { nodes: [], edges: [] },
});
__gm.state.select({ kind: 'asset', assetId: 'listy' });
`);
await sleep(150);
// 1. The inspector shows the count, not a comma-separated line.
const button = await find('3 items');
check('a list property reads as its item count', button !== null, JSON.stringify(button));
check('the editor is still on the scene tab', (await json('__gm.tab()')) === 'scene');
await shot('01-count-button');
// 2. Clicking it opens the List tab on that very list.
await click(button.x, button.y);
await sleep(200);
check('clicking it switches to the List tab', (await json('__gm.tab()')) === 'list');
let info = await json('__gm.list.info()');
check('which shows the list it was opened from', info.title === 'Listy · nums', JSON.stringify(info));
check('with a row per item', JSON.stringify(info.items) === '["30","11","44"]', JSON.stringify(info.items));
check('and the inspector is still beside it', (await find('VARIABLES')) !== null);
await shot('02-list-tab');
// 3. A row edits its item, and it is one undo step.
const second = await json('__gm.list.fieldAt(1)');
await click(second.x, second.y);
await typeText('42');
await press('Enter');
await sleep(200);
info = await json('__gm.list.info()');
check('typing in a row writes that item', JSON.stringify(info.items) === '["30","42","44"]', info.items[1]);
await undo();
info = await json('__gm.list.info()');
check('undo takes just that item back', JSON.stringify(info.items) === '["30","11","44"]', info.items[1]);
// 4. A short list opened after a long one. Its rows leave the content height pinned to the
// viewport, so a layout skipped on "same size" would stack them all at the top.
await mutate(`
const a = __gm.state.project.assets.find((x) => x.id === 'listy');
a.props.nums = Array.from({ length: 60 }, (_, i) => i);
__gm.state.preview();
`);
await sleep(250);
const longRows = [await json('__gm.list.fieldAt(0)'), await json('__gm.list.fieldAt(1)')];
check('a long list stacks its rows', longRows[0].y !== longRows[1].y, JSON.stringify(longRows));
await mutate(`
const a = __gm.state.project.assets.find((x) => x.id === 'listy');
a.props.nums = [30, 11, 44];
__gm.state.preview();
`);
await sleep(250);
const shortRows = [await json('__gm.list.fieldAt(0)'), await json('__gm.list.fieldAt(1)')];
check(
'and a short list after it still stacks its own',
shortRows[0].y !== shortRows[1].y,
JSON.stringify(shortRows),
);
// 5. Adding and removing whole items.
await clickText('+ Item');
await sleep(200);
info = await json('__gm.list.info()');
check('+ Item appends one', info.items.length === 4, JSON.stringify(info.items));
await undo();
check('undo removes it again', (await json('__gm.list.info()')).items.length === 3);
await shot('03-rows');
// 6. Back on the scene tab the button carries the current count.
await mutate('__gm.state.openList(null)');
await sleep(200);
check('closing the list leaves the tab', (await json('__gm.tab()')) === 'scene');
await mutate(`
__gm.state.project.assets.find((a) => a.id === 'listy').props.nums = [1, 2];
__gm.state.preview();
`);
await sleep(200);
check('and the count follows the value', (await find('2 items')) !== null);
// 6b. What a list holds is a choice in the inspector, not only in a project file: the kind mark
// doubles as the picker, and changing it runs every value through the new declaration.
const numsOf = () => json(`__gm.state.project.assets.find((a) => a.id === 'listy')`);
await clickText('\u2261#');
await sleep(150);
await clickText('Texts');
await sleep(250);
let listy = await numsOf();
check(
'the kind mark picks what the list holds',
listy.variables[0].items === 'text',
listy.variables[0].items,
);
check('and the mark itself says what it holds now', (await find('≡Aa')) !== null);
check(
'and every value follows the declaration',
JSON.stringify(listy.props.nums) === '["1","2"]',
JSON.stringify(listy.props.nums),
);
await undo();
await sleep(250);
listy = await numsOf();
check(
'undo puts the kind and the values back',
listy.variables[0].items === 'number' && JSON.stringify(listy.props.nums) === '[1,2]',
JSON.stringify(listy.props.nums),
);
// The picker sits in the mark column, so it may not push the value off the inspector's grid.
const lefts = await json(
`[__gm.inspectorField('Name')?.left ?? null, __gm.inspectorField('nums')?.left ?? null]`,
);
check(
'the picking mark keeps the list on the column every row shares',
lefts[0] !== null && lefts[0] === lefts[1],
JSON.stringify(lefts),
);
// 7. A list whose target is gone empties the tab rather than showing a stale one.
await mutate("__gm.state.openList({ kind: 'asset', assetId: 'listy', key: 'nums' })");
await sleep(200);
check('a list can be opened from outside the inspector too', (await json('__gm.tab()')) === 'list');
await mutate(`
const p = __gm.state.project;
p.assets = p.assets.filter((a) => a.id !== 'listy');
__gm.state.select(null);
__gm.state.preview();
`);
await sleep(200);
info = await json('__gm.list.info()');
check('a deleted target empties the tab', info.title === '' && info.items.length === 0, info.title);
await shot('04-empty');
// 8. A list whose items are structs: each row is the bundle's fields under each other, built by
// the inspector's own struct renderer. Values are two digits so no row's index can match one.
await mutate(`
const p = __gm.state.project;
p.assets.push({
id: 'celly',
type: 'behavior',
name: 'Celly',
props: { enabled: true, runInEditor: false, cells: [{ x: 30, y: 11 }, { x: 44, y: 55 }] },
variables: [
{
key: 'cells',
label: 'cells',
kind: 'list',
default: [],
items: 'struct',
fields: [{ key: 'x', kind: 'number' }, { key: 'y', kind: 'number' }],
},
],
graph: { nodes: [], edges: [] },
});
__gm.state.select({ kind: 'asset', assetId: 'celly' });
__gm.state.openList({ kind: 'asset', assetId: 'celly', key: 'cells' });
`);
await sleep(250);
info = await json('__gm.list.info()');
check('a list of structs opens with a row per bundle', info.items.length === 2, JSON.stringify(info));
check(
'and a row reads as its fields, not as an object',
info.items[0] === 'x: 30, y: 11',
JSON.stringify(info.items),
);
check('the tab knows its items are structs', info.kind === 'struct', info.kind);
check('the field labels of a bundle are on screen', (await find('x')) !== null);
await shot('05-struct-rows');
// Editing one field must leave the others and the neighbouring item alone – the whole item is
// rewritten on every keystroke, so a snapshot anywhere would silently revive an old value.
const firstField = await json("__gm.list.fieldAt(0, 'x')");
await click(firstField.x, firstField.y);
await typeText('77');
await press('Enter');
await sleep(250);
const cells = await json(`__gm.state.project.assets.find((a) => a.id === 'celly').props.cells`);
check(
'editing one field of one item leaves every other value untouched',
JSON.stringify(cells) ===
JSON.stringify([
{ x: 77, y: 11 },
{ x: 44, y: 55 },
]),
JSON.stringify(cells),
);
await undo();
await sleep(200);
const back = await json(`__gm.state.project.assets.find((a) => a.id === 'celly').props.cells`);
check(
'and undo takes back just that field',
JSON.stringify(back) ===
JSON.stringify([
{ x: 30, y: 11 },
{ x: 44, y: 55 },
]),
JSON.stringify(back),
);
// 8b. The item shape is declared in the inspector as well, with the control a struct has – and
// primitives only, because a list inside a list item is the one shape the model does not store.
const cellsOf = () => json(`__gm.state.project.assets.find((a) => a.id === 'celly').props.cells`);
check('a list of structs carries the add-a-field control', (await find('+ Field')) !== null);
await clickText('+ Field');
await sleep(150);
await clickText('Boolean');
await sleep(250);
check(
'+ Field grows every item of the list at once',
JSON.stringify(await cellsOf()) ===
JSON.stringify([
{ x: 30, y: 11, field1: false },
{ x: 44, y: 55, field1: false },
]),
JSON.stringify(await cellsOf()),
);
info = await json('__gm.list.info()');
check(
'and the tab shows the new field on every row',
info.items[0] === 'x: 30, y: 11, field1: false',
info.items[0],
);
await shot('06-item-field');
await undo();
await sleep(250);
check(
'undo takes it out of every item again',
JSON.stringify(await cellsOf()) ===
JSON.stringify([
{ x: 30, y: 11 },
{ x: 44, y: 55 },
]),
JSON.stringify(await cellsOf()),
);
// 9. A node's unconnected `list` input carries its value in the very same params bag a setting
// does, so it is a count button too – before this it offered a number field.
await mutate(`
const a = __gm.state.project.assets.find((x) => x.id === 'celly');
a.graph.nodes.push({ id: 'counter', type: 'Count', x: 40, y: 40, params: {} });
__gm.showTab('graph');
__gm.state.select({ kind: 'node', assetId: 'celly', nodeId: 'counter', nodeIds: ['counter'] });
__gm.state.preview();
`);
await sleep(300);
const portButton = await find('0 items');
check('an unconnected list input reads as an item count', portButton !== null);
await click(portButton.x, portButton.y);
await sleep(250);
check('clicking it opens the List tab on the port', (await json('__gm.tab()')) === 'list');
info = await json('__gm.list.info()');
check('titled by the node and the port it belongs to', info.title === 'Count · list', info.title);
await clickText('+ Item');
await sleep(250);
const params = await json(`__gm.state.project.assets.find((a) => a.id === 'celly').graph.nodes[0].params`);
check(
'and the item lands in the node params, where the node reads it',
JSON.stringify(params) === '{"list":[0]}',
JSON.stringify(params),
);
await mutate(`
const p = __gm.state.project;
p.assets = p.assets.filter((a) => a.id !== 'celly');
__gm.state.select(null);
__gm.showTab('scene');
__gm.state.preview();
`);
await sleep(200);
await mutate('__gm.state.openList(null)');
await sleep(200);
const left = await json(
'__gm.state.project.assets.filter((a) => a.id === "listy" || a.id === "celly").length',
);
check('the step leaves the fixture as it found it', left === 0, String(left));
}