Files
feuerwerk/tools/e2e/21-list.mjs
T
rootandClaude Opus 5 f60358c951 Let a list hold structs, and read or write variables in bundles
A list item may now be a struct. The depth is bounded in the declaration
rather than by a comment: FieldKind (number|text|boolean|color) says what a
struct field holds, ListItemKind adds struct on top and says what a list
holds, so list -> struct -> primitive is the deepest storable shape and
sameValue stays finite. A struct item renders inline in the List tab, which
is why it needs no path in the slot address.

Four paths silently assumed a list item was a primitive: sameValue compared
items with ===, textOf would have printed [object Object], asListItem turned
a struct into text on Set item and Fill, and Index of used indexOf, which is
reference equality and could never have found a struct.

Tetris now runs on bundles. shapes is 112 {x, y} structs instead of 224 loose
numbers, piece and the candidate are structs, so Next move hands back a whole
candidate and each strand packs only the field it changes, since mergeStruct
leaves the rest standing. Declared behavior ports drop 49 -> 35, the master's
variables 18 -> 13, its graph 84/94 -> 69/76 nodes/edges.

Get variables and Set variables declare their ports from a list of variable
names, the way Pack and Expand already do, so one node can read or write
several at once. Renaming a variable rewires them, because there the port key
is the variable name.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-10 14:01:56 +02:00

194 lines
7.7 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);
// 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),
);
await mutate(`
const p = __gm.state.project;
p.assets = p.assets.filter((a) => a.id !== 'celly');
__gm.state.select(null);
__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));
}