Files
feuerwerk/tools/e2e/21-list.mjs
T
rootandClaude Opus 5 e52e95c0e9 Give a list its own tab instead of a comma line
A list property in the inspector was a single text field holding a comma
separated line -- unreadable past a handful of items, and it could not
carry anything but text. It becomes a button ("3 items") that opens a
sixth centre tab, where every item is a row built by the very widget the
inspector would build for that item kind.

The renderer was free: a PropertyDef of kind 'list' already carries
`items`, so item i becomes a synthetic def of that kind -- number, text,
boolean and colour rows appear without a new branch.

The way into the tab goes through the state (EditorState.listSlot,
openList(), event 'list'), like every other tab switch here; a tab may
not call showTab itself. ListSlot lives next to Selection rather than in
the tab folder, or state.ts would have to import upwards.

A row's handlers must not close over a snapshot of the list: they outlive
a commit, since the tab only rebuilds when the length changes, and the
second edited row would otherwise revive the value the first replaced.

showTab switched five `visible` lines by hand, where a sixth tab is
silently forgotten; it is a Record<CentreTab, Widget> now.

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

95 lines
4.1 KiB
JavaScript

/**
* 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. 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');
// 5. 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);
// 6. 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');
await mutate('__gm.state.openList(null)');
await sleep(200);
const left = await json('__gm.state.project.assets.filter((a) => a.id === "listy").length');
check('the step leaves the fixture as it found it', left === 0, String(left));
}