Files
adminandClaude Opus 5.5 4857bb75e6
game-maker / gate (push) Successful in 3m24s
game-maker / suite (push) Successful in 10m22s
Show lists in the inspector and variables in their own tab (#252)
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>
2026-09-27 21:56:21 +02:00

642 lines
25 KiB
JavaScript
Raw Permalink 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.
/**
* 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),
);
}