The List tab could show one list at a time and was the only place to see its items. A list now shows its items right where it lives, and a new Variables tab shows all variables of an asset or placed object. - InlineList draws the items of a list under its row in the inspector, in struct fields and in the Variables tab. A list inside a list or a struct field is drawn the same way, one step indented. - The index column is the indent. Items sit under their list without an own margin, and the index column is 16 px wide instead of 28 px. - Above 20 items (INLINE_LIMIT) the rest collapses into "... N more - open in tab". During a run the rows show the values from the game. - ListHead puts "N items" and "+ Item" in one row. "N items" opens the list in a tab. A separate Open button squeezed the counter to zero width in the narrow inspector column. - VariablesEditor shows every variable of a behavior asset, a variables asset or a placed behavior. On an asset it edits the declarations. On a placed object an edit only overrides that object, with the usual reset and push buttons. - Opening a list inside a tab adds a new tab and keeps the open one. valuesKey gives each list its own document, so a list that is already open is shown instead of opened twice. - Titles read "Pickup · Variables" or "Listy · nums · [3]". The tab closes when its asset or object is deleted. - Lists on node inputs stay inline only, a node owns no variables. - The List tab, ListEditor and the __gm.list hook are gone. Tests: - ./check.sh: 181 files, 2645 tests, CHECK EXIT 0 - GM_STEPS=1,21,22,32: ALL PASSED, 123 checks - e2e 21 is rewritten for inline lists and the new tab, 22 and 32 are adapted. Sum in README: 1486 (+6 in 21, -1 in 22), counted by hand. - Counter-probes fail as expected: sub-lists not told apart (1 unit test), sub-list replacing the open tab, all items drawn inline (60 rows instead of 20), no live values (0 rows at 24 items in a run). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
642 lines
25 KiB
JavaScript
642 lines
25 KiB
JavaScript
/**
|
||
* Lists inline (#252): a list's items stand right under its row in the inspector, a row per item
|
||
* edited with the inspector's own widgets, and a list inside a list is drawn inline again. The head
|
||
* of a list opens it in a Variables tab – a list opened from inside such a tab gets a tab of its own
|
||
* beside it – and the VARIABLES header opens every variable of an asset or a placed object there.
|
||
*/
|
||
import { clickDocumentTab, MAKE_OBJECT } from './lib/scaffold.mjs';
|
||
|
||
export async function listTab(t) {
|
||
const { check, sleep, json, find, shot, click, clickText, press, typeText, mutate, undo } = t;
|
||
|
||
/**
|
||
* A kind menu entry, always the *last* drawn one: a menu is drawn on top, and the kind names are
|
||
* ordinary words ("Text") that the panels behind it may carry too – which is what made this click
|
||
* land in the asset list once the whole suite ran and those panels had something in them.
|
||
*/
|
||
const pickKind = async (label) => {
|
||
const at = await json(`__gm.locate(${JSON.stringify(label)}, true)`);
|
||
await click(at.x, at.y);
|
||
};
|
||
|
||
/** The hooks of one inline list, found by its title in the inspector or in the Variables tab. */
|
||
const hook = (fn, where, title, ...args) =>
|
||
json(`__gm.values.${fn}(${[where, title, ...args].map((a) => JSON.stringify(a)).join(', ')})`);
|
||
const listInfo = async (title, where = 'inspector') =>
|
||
(await json(`__gm.values.lists(${JSON.stringify(where)})`)).find((l) => l.title === title) ?? null;
|
||
const press_ = async (at, what) => {
|
||
if (!at) throw new Error(`nothing to click: ${what}`);
|
||
await click(at.x, at.y);
|
||
};
|
||
const valueDocs = async () => (await json('__gm.documents()')).docs.filter((d) => d.startsWith('values:'));
|
||
const closeValueDocs = async () => {
|
||
for (const doc of await valueDocs()) await mutate(`__gm.closeDocument(${JSON.stringify(doc)})`);
|
||
};
|
||
|
||
const NUMS = 'Listy · nums';
|
||
// 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: [] }],
|
||
graph: { nodes: [], edges: [] },
|
||
});
|
||
__gm.state.select({ kind: 'asset', assetId: 'listy' });
|
||
`);
|
||
|
||
// 21a. The inspector shows the count and, under it, the items themselves.
|
||
check('a list property reads as its item count', (await find('3 items')) !== null);
|
||
let info = await listInfo(NUMS);
|
||
check(
|
||
'and its items stand right under it, a row each',
|
||
info?.rows === 3 && JSON.stringify(info.items) === '["30","11","44"]',
|
||
JSON.stringify(info),
|
||
);
|
||
check('the editor is still on the scene tab', (await json('__gm.tab()')) === 'scene');
|
||
await shot('01-inline');
|
||
|
||
// 21b. A row edits its item, and it is one undo step.
|
||
await press_(await hook('fieldAt', 'inspector', NUMS, 1), 'item 1');
|
||
await typeText('42');
|
||
await press('Enter');
|
||
info = await listInfo(NUMS);
|
||
check('typing in a row writes that item', JSON.stringify(info.items) === '["30","42","44"]', info.items[1]);
|
||
await undo();
|
||
info = await listInfo(NUMS);
|
||
check('undo takes just that item back', JSON.stringify(info.items) === '["30","11","44"]', info.items[1]);
|
||
|
||
// 21b-ii. An item edit does not rebuild the rows, so a delete clicked after it must build on the
|
||
// edited list – with the one the rows were built from, the edit came back undone (#235).
|
||
await press_(await hook('fieldAt', 'inspector', NUMS, 2), 'item 2');
|
||
await typeText('66');
|
||
await press('Enter');
|
||
await press_(await hook('removeAt', 'inspector', NUMS, 0), 'remove 0');
|
||
await sleep(200);
|
||
info = await listInfo(NUMS);
|
||
check(
|
||
'deleting a row keeps an edit made to another row before',
|
||
JSON.stringify(info.items) === '["11","66"]',
|
||
JSON.stringify(info.items),
|
||
);
|
||
await undo();
|
||
await undo();
|
||
info = await listInfo(NUMS);
|
||
check(
|
||
'and two undos take delete and edit back',
|
||
JSON.stringify(info.items) === '["30","11","44"]',
|
||
info.items,
|
||
);
|
||
|
||
// 21c. A long list: the inspector draws the first 20 and says how many more there are; that line
|
||
// opens the whole list in a tab.
|
||
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(150);
|
||
info = await listInfo(NUMS);
|
||
const longRows = [await hook('fieldAt', 'inspector', NUMS, 0), await hook('fieldAt', 'inspector', NUMS, 1)];
|
||
check(
|
||
'a long list draws 20 rows in the inspector and counts the other 40',
|
||
info.rows === 20 && info.more === 40 && longRows[0].y !== longRows[1].y,
|
||
JSON.stringify({ rows: info.rows, more: info.more, longRows }),
|
||
);
|
||
await shot('02-more');
|
||
// The line sits below the inspector's fold: scrolled to with the wheel, as a user gets there.
|
||
await t.wheel(longRows[0].x - 140, longRows[0].y, 2000);
|
||
await sleep(150);
|
||
await press_(await hook('moreAt', 'inspector', NUMS), '… more');
|
||
await sleep(200);
|
||
const tabList = await listInfo(NUMS, 'tab');
|
||
check(
|
||
'clicking "… 40 more" opens the list in a Variables tab, every item drawn',
|
||
(await json('__gm.tab()')) === 'variables' && tabList?.rows === 60 && tabList.more === 0,
|
||
JSON.stringify({ tab: await json('__gm.tab()'), tabList }),
|
||
);
|
||
check(
|
||
'titled by the list, the inspector still beside it',
|
||
(await json('__gm.values.info()')).title === NUMS && (await find('VARIABLES')) !== null,
|
||
(await json('__gm.values.info()')).title,
|
||
);
|
||
await shot('03-tab');
|
||
await mutate(`
|
||
const a = __gm.state.project.assets.find((x) => x.id === 'listy');
|
||
a.props.nums = [30, 11, 44];
|
||
__gm.state.preview();
|
||
`);
|
||
await sleep(150);
|
||
const shortRows = [await hook('fieldAt', 'tab', NUMS, 0), await hook('fieldAt', 'tab', NUMS, 1)];
|
||
check(
|
||
'and a short list after it still stacks its rows',
|
||
(await listInfo(NUMS, 'tab')).rows === 3 && shortRows[0].y !== shortRows[1].y,
|
||
JSON.stringify(shortRows),
|
||
);
|
||
await closeValueDocs();
|
||
|
||
// 21d. `+ Item` asks for a kind, since the list does not hold one for it.
|
||
const addTo = async (title, where = 'inspector') =>
|
||
press_((await hook('headAt', where, title))?.add, '+ Item');
|
||
await addTo(NUMS);
|
||
check(
|
||
'a list of its own offers every kind, list and struct included',
|
||
(await listInfo(NUMS)).offers.join(',') ===
|
||
// In the order of the kind table (`core/properties/kinds.ts`) – a new kind is a row there,
|
||
// and `asset` (#237) and `object` (#238) are the newest of them. `list` since #239.
|
||
'number,text,boolean,color,texture,statemachine,sound,scene,animation,asset,object,list,struct',
|
||
JSON.stringify((await listInfo(NUMS)).offers),
|
||
);
|
||
await pickKind('Text');
|
||
await sleep(200);
|
||
info = await listInfo(NUMS);
|
||
check('+ Item appends one of the kind picked', info.items.length === 4, JSON.stringify(info.items));
|
||
check(
|
||
'and that row holds that kind',
|
||
info.kinds.join(',') === 'number,number,number,text',
|
||
info.kinds.join(','),
|
||
);
|
||
await undo();
|
||
await sleep(150);
|
||
check('undo removes it again', (await listInfo(NUMS)).items.length === 3);
|
||
|
||
// 21d-ii. An object item holds nothing in the file – only its place, filled while a game runs.
|
||
await addTo(NUMS);
|
||
await pickKind('Object');
|
||
await sleep(200);
|
||
info = await listInfo(NUMS);
|
||
check(
|
||
'an Object item is added as the empty object',
|
||
info.kinds.join(',') === 'number,number,number,object' && info.items[3] === '',
|
||
JSON.stringify(info),
|
||
);
|
||
await undo();
|
||
await sleep(150);
|
||
|
||
// 21d-iii. An item may be a list again (#239): it is drawn inline under its row, one step in, the
|
||
// path in its title – and opened from a tab, it gets a tab of its own beside that one.
|
||
const INNER = `${NUMS} · [3]`;
|
||
const nums = () => json("__gm.state.project.assets.find((a) => a.id === 'listy').props.nums");
|
||
await addTo(NUMS);
|
||
await pickKind('List');
|
||
await sleep(200);
|
||
info = await listInfo(NUMS);
|
||
check(
|
||
'a List item is added as an empty list',
|
||
info.kinds.join(',') === 'number,number,number,list' && JSON.stringify((await nums())[3]) === '[]',
|
||
JSON.stringify(info),
|
||
);
|
||
check('and drawn inline, the path in its title', (await listInfo(INNER))?.rows === 0);
|
||
await addTo(INNER);
|
||
await pickKind('Number');
|
||
await sleep(200);
|
||
check(
|
||
'an item added there lands in the list inside the list',
|
||
JSON.stringify((await nums()).slice(3)) === '[[0]]' && (await listInfo(INNER))?.rows === 1,
|
||
JSON.stringify(await nums()),
|
||
);
|
||
const outerRow = await hook('fieldAt', 'inspector', NUMS, 2);
|
||
const innerRow = await hook('fieldAt', 'inspector', INNER, 0);
|
||
check(
|
||
'its row sits below the outer rows, pushed further right',
|
||
innerRow.y > outerRow.y && innerRow.x > outerRow.x - 1,
|
||
JSON.stringify({ outerRow, innerRow }),
|
||
);
|
||
await shot('04-nested-inline');
|
||
|
||
await press_((await hook('headAt', 'inspector', NUMS)).open, 'Open of nums');
|
||
await sleep(200);
|
||
const outerDoc = (await json('__gm.documents()')).active;
|
||
check(
|
||
'Open on the head opens the list in a tab, with the inner list inline in it',
|
||
(await json('__gm.values.info()')).title === NUMS && (await listInfo(INNER, 'tab'))?.rows === 1,
|
||
JSON.stringify(await json('__gm.values.info()')),
|
||
);
|
||
await press_((await hook('headAt', 'tab', INNER)).open, 'Open of the inner list');
|
||
await sleep(200);
|
||
let docs = await json('__gm.documents()');
|
||
check(
|
||
'Open on a list inside the tab opens a second tab and leaves the first',
|
||
(await valueDocs()).length === 2 &&
|
||
docs.docs.includes(outerDoc) &&
|
||
docs.active !== outerDoc &&
|
||
(await json('__gm.values.info()')).title === INNER,
|
||
JSON.stringify({ docs, title: (await json('__gm.values.info()')).title }),
|
||
);
|
||
await shot('05-second-tab');
|
||
await clickDocumentTab(t, outerDoc);
|
||
await sleep(150);
|
||
await press_((await hook('headAt', 'tab', INNER)).open, 'Open of the inner list, again');
|
||
await sleep(150);
|
||
docs = await json('__gm.documents()');
|
||
check(
|
||
'asking again for a list that has its tab brings that tab up, no third one',
|
||
(await valueDocs()).length === 2 && (await json('__gm.values.info()')).title === INNER,
|
||
JSON.stringify(docs),
|
||
);
|
||
await undo();
|
||
await undo();
|
||
await sleep(150);
|
||
check(
|
||
'undo empties the inner list first, then takes it away – and its tab with it',
|
||
(await valueDocs()).length === 1 && JSON.stringify(await nums()) === '[30,11,44]',
|
||
JSON.stringify({ docs: await valueDocs(), nums: await nums() }),
|
||
);
|
||
await closeValueDocs();
|
||
|
||
// 21e. The count follows the value.
|
||
await mutate(`
|
||
__gm.state.project.assets.find((a) => a.id === 'listy').props.nums = [1, 2];
|
||
__gm.state.preview();
|
||
`);
|
||
await sleep(150);
|
||
check(
|
||
'the count and the rows follow the value',
|
||
(await find('2 items')) !== null && (await listInfo(NUMS)).rows === 2,
|
||
);
|
||
|
||
// 21f. A list declares nothing about its items, so the kind sits on the row: the mark in front of
|
||
// it is the picker, and picking changes that one item and no other.
|
||
const numsOf = () => json(`__gm.state.project.assets.find((a) => a.id === 'listy')`);
|
||
await press_(await hook('markAt', 'inspector', NUMS, 0), 'mark 0');
|
||
await pickKind('Text');
|
||
await sleep(250);
|
||
let listy = await numsOf();
|
||
check(
|
||
'the row mark changes that item alone',
|
||
JSON.stringify(listy.props.nums) === '["1",2]',
|
||
JSON.stringify(listy.props.nums),
|
||
);
|
||
check(
|
||
'and the list reads the two kinds off the values',
|
||
(await listInfo(NUMS)).kinds.join(',') === 'text,number',
|
||
JSON.stringify((await listInfo(NUMS)).kinds),
|
||
);
|
||
check('the variable itself says nothing about items', listy.variables[0].items === undefined);
|
||
await undo();
|
||
listy = await numsOf();
|
||
check(
|
||
'undo puts the item back as a number',
|
||
JSON.stringify(listy.props.nums) === '[1,2]',
|
||
JSON.stringify(listy.props.nums),
|
||
);
|
||
|
||
// 21g. A colour is the one kind a stored number cannot say for itself, so it is tagged.
|
||
await sleep(150);
|
||
await press_(await hook('markAt', 'inspector', NUMS, 1), 'mark 1');
|
||
await pickKind('Color');
|
||
await sleep(250);
|
||
listy = await numsOf();
|
||
check(
|
||
'a colour item keeps its number under a tag',
|
||
JSON.stringify(listy.props.nums) === '[1,{"$color":2}]',
|
||
JSON.stringify(listy.props.nums),
|
||
);
|
||
check(
|
||
'and is read back as a colour, its neighbour still a number',
|
||
(await listInfo(NUMS)).kinds.join(',') === 'number,color',
|
||
JSON.stringify((await listInfo(NUMS)).kinds),
|
||
);
|
||
await shot('06-kinds');
|
||
await undo();
|
||
await sleep(150);
|
||
|
||
// 21h. The second tagged kind: a picture is an id, which stored on its own would be a plain text.
|
||
// The row carries the picker the `texture` property has, frames of a sheet included.
|
||
// The pictures the earlier steps imported are set aside for this one: the menu lists every sheet
|
||
// with all of its frames, and a long one would put the entry to click outside the drawn menu.
|
||
await mutate(`
|
||
const p = __gm.state.project;
|
||
const isPicture = (a) => a.type === 'texture' || a.type === 'animation';
|
||
__gm.__pictures = p.assets.filter(isPicture);
|
||
p.assets = p.assets.filter((a) => !isPicture(a));
|
||
p.assets.push({
|
||
id: 'lt-sheet', type: 'texture', name: 'Sheet', props: { filter: 'nearest' },
|
||
file: '', width: 32, height: 32,
|
||
frames: [{ id: 'lt-f1', name: 'Frame 1', x: 0, y: 0, w: 16, h: 16 }],
|
||
});
|
||
__gm.state.preview();
|
||
`);
|
||
await press_(await hook('markAt', 'inspector', NUMS, 0), 'mark 0');
|
||
await pickKind('Texture');
|
||
await sleep(250);
|
||
// Switching kind carries the value along, here into an id that resolves to nothing – the same as
|
||
// a number turning into the text "1". What matters is that it is tagged and no longer a number.
|
||
check(
|
||
'a texture item is tagged, so it is no plain text beside one',
|
||
JSON.stringify((await numsOf()).props.nums) === '[{"$texture":"1"},2]',
|
||
JSON.stringify((await numsOf()).props.nums),
|
||
);
|
||
await press_(await hook('fieldAt', 'inspector', NUMS, 0), 'picture 0');
|
||
check('the row carries the picture picker, sheet frames and all', (await find('Sheet / Frame 1')) !== null);
|
||
await pickKind('Sheet / Frame 1');
|
||
await sleep(250);
|
||
check(
|
||
'and picking a frame of a sheet writes that id under the tag',
|
||
JSON.stringify((await numsOf()).props.nums) === '[{"$texture":"lt-f1"},2]',
|
||
JSON.stringify((await numsOf()).props.nums),
|
||
);
|
||
check(
|
||
'which the list reads back as a texture, its neighbour still a number',
|
||
(await listInfo(NUMS)).kinds.join(',') === 'texture,number',
|
||
JSON.stringify((await listInfo(NUMS)).kinds),
|
||
);
|
||
await undo();
|
||
await undo();
|
||
check(
|
||
'undo puts the plain number back',
|
||
JSON.stringify((await numsOf()).props.nums) === '[1,2]',
|
||
JSON.stringify((await numsOf()).props.nums),
|
||
);
|
||
await mutate(`
|
||
const p = __gm.state.project;
|
||
p.assets = [...p.assets.filter((a) => a.id !== 'lt-sheet'), ...__gm.__pictures];
|
||
delete __gm.__pictures;
|
||
__gm.state.preview();
|
||
`);
|
||
|
||
// The mark of a list is plain, and still 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 list mark keeps its row on the column every row shares',
|
||
lefts[0] !== null && lefts[0] === lefts[1],
|
||
JSON.stringify(lefts),
|
||
);
|
||
|
||
// 21i. A list whose owner is gone takes its tab along rather than showing a stale one.
|
||
await press_((await hook('headAt', 'inspector', NUMS)).open, 'Open of nums');
|
||
await sleep(150);
|
||
check('the list is open in a tab', (await valueDocs()).length === 1);
|
||
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(150);
|
||
check(
|
||
'a deleted owner closes the tab',
|
||
(await valueDocs()).length === 0 && (await json('__gm.tab()')) !== 'variables',
|
||
JSON.stringify({ docs: await valueDocs(), tab: await json('__gm.tab()') }),
|
||
);
|
||
|
||
// 21j. A list whose items are structs: each row is the bundle's fields under each other. Values
|
||
// are two digits so no row's index can match one.
|
||
const CELLS = 'Celly · cells';
|
||
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: [] }],
|
||
graph: { nodes: [], edges: [] },
|
||
});
|
||
__gm.state.select({ kind: 'asset', assetId: 'celly' });
|
||
`);
|
||
await sleep(150);
|
||
info = await listInfo(CELLS);
|
||
check(
|
||
'a list of structs draws a row per bundle',
|
||
info?.items.length === 2 && info.rows === 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('both items read as structs', info.kinds.join(',') === 'struct,struct', info.kinds.join(','));
|
||
check('the field labels of a bundle are on screen', (await find('x')) !== null);
|
||
await shot('07-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.
|
||
await press_(await hook('fieldAt', 'inspector', CELLS, 0, 'x'), 'field x');
|
||
await typeText('77');
|
||
await press('Enter');
|
||
const cellsOf = () => 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(await cellsOf()) ===
|
||
JSON.stringify([
|
||
{ x: 77, y: 11 },
|
||
{ x: 44, y: 55 },
|
||
]),
|
||
JSON.stringify(await cellsOf()),
|
||
);
|
||
await undo();
|
||
check(
|
||
'and undo takes back just that field',
|
||
JSON.stringify(await cellsOf()) ===
|
||
JSON.stringify([
|
||
{ x: 30, y: 11 },
|
||
{ x: 44, y: 55 },
|
||
]),
|
||
JSON.stringify(await cellsOf()),
|
||
);
|
||
|
||
// 21k. A struct item carries its own shape, so it is named, typed and grown on its own row.
|
||
check('a struct item carries the add-a-field control', (await find('+ Field')) !== null);
|
||
await clickText('+ Field');
|
||
await pickKind('Boolean');
|
||
await sleep(250);
|
||
check(
|
||
'+ Field grows the one item it belongs to, not every item',
|
||
JSON.stringify(await cellsOf()) ===
|
||
JSON.stringify([
|
||
{ x: 30, y: 11, field1: false },
|
||
{ x: 44, y: 55 },
|
||
]),
|
||
JSON.stringify(await cellsOf()),
|
||
);
|
||
info = await listInfo(CELLS);
|
||
check('and the row shows the new field', info.items[0] === 'x: 30, y: 11, field1: false', info.items[0]);
|
||
// Asked in the page: `undefined` comes back over the wire as `null`.
|
||
check(
|
||
'the variable has no item shape to show',
|
||
await json(`__gm.state.project.assets.find((a) => a.id === 'celly').variables[0].fields === undefined`),
|
||
);
|
||
|
||
// A field of a struct item is renamed and retyped where it sits, both being plain value edits.
|
||
await press_(await hook('nameAt', 'inspector', CELLS, 0, 'y'), 'name y');
|
||
await press('a', { ctrl: true });
|
||
await typeText('row');
|
||
await press('Enter');
|
||
check(
|
||
'renaming a field of one item leaves the other item alone',
|
||
JSON.stringify(await cellsOf()) ===
|
||
JSON.stringify([
|
||
{ x: 30, row: 11, field1: false },
|
||
{ x: 44, y: 55 },
|
||
]),
|
||
JSON.stringify(await cellsOf()),
|
||
);
|
||
await sleep(150);
|
||
await press_(await hook('markAt', 'inspector', CELLS, 0, 'x'), 'mark x');
|
||
await pickKind('Color');
|
||
await sleep(250);
|
||
check(
|
||
'and a field of a struct item can be a colour too, tagged like any item',
|
||
JSON.stringify((await cellsOf())[0]) === JSON.stringify({ x: { $color: 30 }, row: 11, field1: false }),
|
||
JSON.stringify((await cellsOf())[0]),
|
||
);
|
||
await undo();
|
||
await undo();
|
||
await undo();
|
||
check(
|
||
'and three undos take the shape back to what it was',
|
||
JSON.stringify(await cellsOf()) ===
|
||
JSON.stringify([
|
||
{ x: 30, y: 11 },
|
||
{ x: 44, y: 55 },
|
||
]),
|
||
JSON.stringify(await cellsOf()),
|
||
);
|
||
|
||
// 21l. A node's unconnected `list` input lives in the same params bag as a setting: inline only,
|
||
// no tab to open it in.
|
||
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(150);
|
||
const portHead = await hook('headAt', 'inspector', 'Count · list');
|
||
check(
|
||
'an unconnected list input is drawn inline, without an Open',
|
||
(await find('0 items')) !== null && portHead !== null && portHead.open === null,
|
||
JSON.stringify(portHead),
|
||
);
|
||
await press_(portHead.add, '+ Item of the port');
|
||
await pickKind('Number');
|
||
await sleep(250);
|
||
const params = await json(`__gm.state.project.assets.find((a) => a.id === 'celly').graph.nodes[0].params`);
|
||
check(
|
||
'and an item added there lands in the node params, where the node reads it',
|
||
JSON.stringify(params) === '{"list":[0]}' && (await listInfo('Count · list'))?.rows === 1,
|
||
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();
|
||
`);
|
||
|
||
// 21m. The Variables tab: every variable of a behavior, with the inspector's rows – the list all
|
||
// of it, where the inspector stops at 20.
|
||
const HITS = 'Varsy · hits';
|
||
// The inspector's header is the one with an Open beside it: an open Variables tab heads its rows
|
||
// with the same word, without one.
|
||
const openVariables = async () => {
|
||
const headers = await json(`__gm.locateAll('VARIABLES')`);
|
||
const opens = await json(`__gm.locateAll('Open')`);
|
||
const open = opens.find((p) => headers.some((h) => Math.abs(p.y - h.y) < 8 && p.x > h.x));
|
||
await press_(open, 'Open of VARIABLES');
|
||
await sleep(200);
|
||
};
|
||
await mutate(`
|
||
const p = __gm.state.project;
|
||
p.assets.push({
|
||
id: 'varsy',
|
||
type: 'behavior',
|
||
name: 'Varsy',
|
||
props: { enabled: true, runInEditor: false, hp: 5, hits: Array.from({ length: 25 }, (_, i) => 10 + i) },
|
||
variables: [
|
||
{ key: 'hp', label: 'hp', kind: 'number', default: 0 },
|
||
{ key: 'hits', label: 'hits', kind: 'list', default: [] },
|
||
],
|
||
graph: { nodes: [], edges: [] },
|
||
});
|
||
__gm.state.select({ kind: 'asset', assetId: 'varsy' });
|
||
`);
|
||
await sleep(150);
|
||
await openVariables();
|
||
let tab = await json('__gm.values.info()');
|
||
check(
|
||
'Open in the VARIABLES header opens a Variables tab on the behavior',
|
||
(await json('__gm.tab()')) === 'variables' &&
|
||
tab.title === 'Varsy · Variables' &&
|
||
tab.rows.join(',') === 'hp,hits',
|
||
JSON.stringify(tab),
|
||
);
|
||
check(
|
||
'where the list shows all 25 items, the inspector 20 of them',
|
||
(await listInfo(HITS, 'tab'))?.rows === 25 && (await listInfo(HITS))?.rows === 20,
|
||
JSON.stringify({ tab: await listInfo(HITS, 'tab'), inspector: await listInfo(HITS) }),
|
||
);
|
||
await shot('08-variables-tab');
|
||
await press_(await hook('fieldAt', 'tab', HITS, 0), 'hits 0 in the tab');
|
||
await typeText('99');
|
||
await press('Enter');
|
||
await sleep(150);
|
||
check(
|
||
'an item typed in the tab is in the asset and in the inspector',
|
||
(await json(`__gm.state.project.assets.find((a) => a.id === 'varsy').props.hits[0]`)) === 99 &&
|
||
(await listInfo(HITS)).items[0] === '99',
|
||
JSON.stringify((await listInfo(HITS)).items.slice(0, 2)),
|
||
);
|
||
await undo();
|
||
|
||
// 21n. The same for a placed behavior: its tab edits the overrides of that object, not the asset.
|
||
await mutate(`
|
||
const scene = __gm.state.activeScene;
|
||
${MAKE_OBJECT}
|
||
scene.objects.push(make('varsy-1', 'varsy', '', 0, 'Varsy One'));
|
||
__gm.state.select({ kind: 'object', holderId: scene.id, objectId: 'varsy-1', objectIds: ['varsy-1'] });
|
||
__gm.state.preview();
|
||
`);
|
||
await sleep(200);
|
||
await openVariables();
|
||
tab = await json('__gm.values.info()');
|
||
check(
|
||
'a placed behavior opens a tab of its own, named after the object',
|
||
tab.title === 'Varsy One · Variables' && (await valueDocs()).length === 2,
|
||
JSON.stringify({ tab, docs: await valueDocs() }),
|
||
);
|
||
await press_(await hook('fieldAt', 'tab', 'Varsy One · hits', 1), 'hits 1 of the object');
|
||
await typeText('77');
|
||
await press('Enter');
|
||
await sleep(150);
|
||
const placed = await json(`({
|
||
override: __gm.state.activeScene.objects.find((o) => o.id === 'varsy-1').overrides.hits?.[1] ?? null,
|
||
asset: __gm.state.project.assets.find((a) => a.id === 'varsy').props.hits[1],
|
||
})`);
|
||
check(
|
||
'an item typed there overrides the object and leaves the asset alone',
|
||
placed.override === 77 && placed.asset === 11,
|
||
JSON.stringify(placed),
|
||
);
|
||
await shot('09-object-tab');
|
||
await undo();
|
||
await closeValueDocs();
|
||
await mutate(`
|
||
const p = __gm.state.project;
|
||
__gm.state.activeScene.objects = __gm.state.activeScene.objects.filter((o) => o.id !== 'varsy-1');
|
||
p.assets = p.assets.filter((a) => a.id !== 'varsy');
|
||
__gm.state.select(null);
|
||
__gm.showTab('scene');
|
||
__gm.state.preview();
|
||
`);
|
||
|
||
const left = await json(
|
||
'__gm.state.project.assets.filter((a) => ["listy", "celly", "varsy"].includes(a.id)).length',
|
||
);
|
||
check(
|
||
'the step leaves the fixture as it found it',
|
||
left === 0 && (await valueDocs()).length === 0,
|
||
String(left),
|
||
);
|
||
}
|