Split GraphCanvas (1076 -> 831 lines) into EdgeLayer and graphMenus; the port and marker menus were the same list and are now one function. Value specs of the inspector and the list tab share valueSpecs.ts, and the remaining copies (compound commands, type glyphs, typed asset lookup, name rule, play/editor world setup, marquee) are merged. Four copies had already drifted apart; merging them fixes: - "Disconnect N links" in the port menu is one undo step, not N - "+ Add collider" is hidden on a placed container instance, as in the hierarchy menu, instead of always failing - a bool property coerced from the text "false" becomes false - a port command on an unknown node type reports instead of crashing Comment-only edits leave the bundle byte-identical. README corrected in 35 places (step limit, jump-run clock, suite duration, 409 body, ...). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
291 lines
11 KiB
JavaScript
291 lines
11 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, find, shot, click, clickText, press, typeText, mutate, undo } = t;
|
||
|
||
/** `locate` throws when the text is absent, which is the wrong shape for "is it there?". */
|
||
|
||
// 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');
|
||
|
||
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),
|
||
);
|
||
|
||
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 lives in the same params bag as a setting, so it is a count
|
||
// button too.
|
||
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));
|
||
}
|