CI found the gap the hard way: with every kind of asset in the project the new scene sits below the panel, and the right click for "Set as start scene" landed somewhere under the list instead of on the row. locate() only asked whether a text was visible, and a masked row still is - it is merely clipped away. locateAll and overlayTexts now walk the tree together (eachText) and carry the clip of every ancestor, measured on the widget that owns the mask: Pixi v8 hands a Graphics set as a mask to the mask effect and reports empty bounds for it, so asking the mask itself declares every masked text in the editor missing. Two hooks with two notions of "visible" was the second half of the same bug. The panel also scrolls the selected row into view now, the way the hierarchy has always done - an asset selected elsewhere has to be findable in the list, test or no test. And step 21 learned that scene is a property kind.
437 lines
17 KiB
JavaScript
437 lines
17 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 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);
|
||
};
|
||
|
||
// 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' });
|
||
`);
|
||
await sleep(150);
|
||
|
||
// 21a. 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');
|
||
|
||
// 21b. 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]);
|
||
|
||
// 21c. 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),
|
||
);
|
||
|
||
// 21d. `+ Item` asks for a kind, since the list does not hold one for it.
|
||
await clickText('+ Item');
|
||
await sleep(150);
|
||
check(
|
||
'a list of its own offers every kind, the struct included',
|
||
(await json('__gm.list.info()')).offers.join(',') ===
|
||
// In the order of the kind table (`core/properties/kinds.ts`) – a new kind is a row there,
|
||
// and `scene` (#39) is the newest of them.
|
||
'number,text,boolean,color,texture,statemachine,sound,scene,struct',
|
||
JSON.stringify((await json('__gm.list.info()')).offers),
|
||
);
|
||
await pickKind('Text');
|
||
await sleep(200);
|
||
info = await json('__gm.list.info()');
|
||
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();
|
||
check('undo removes it again', (await json('__gm.list.info()')).items.length === 3);
|
||
await shot('03-rows');
|
||
|
||
// 21e. 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);
|
||
|
||
// 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 mutate("__gm.state.openList({ kind: 'asset', assetId: 'listy', key: 'nums' })");
|
||
await sleep(200);
|
||
let mark = await json('__gm.list.markAt(0)');
|
||
await click(mark.x, mark.y);
|
||
await sleep(150);
|
||
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 tab reads the two kinds off the values',
|
||
(await json('__gm.list.info()')).kinds.join(',') === 'text,number',
|
||
JSON.stringify((await json('__gm.list.info()')).kinds),
|
||
);
|
||
check('the variable itself says nothing about items', listy.variables[0].items === undefined);
|
||
await undo();
|
||
await sleep(250);
|
||
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.
|
||
mark = await json('__gm.list.markAt(1)');
|
||
await click(mark.x, mark.y);
|
||
await sleep(150);
|
||
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 json('__gm.list.info()')).kinds.join(',') === 'number,color',
|
||
JSON.stringify((await json('__gm.list.info()')).kinds),
|
||
);
|
||
await shot('03b-kinds');
|
||
await undo();
|
||
await sleep(200);
|
||
|
||
// 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 sleep(200);
|
||
mark = await json('__gm.list.markAt(0)');
|
||
await click(mark.x, mark.y);
|
||
await sleep(150);
|
||
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),
|
||
);
|
||
const pic = await json('__gm.list.fieldAt(0)');
|
||
await click(pic.x, pic.y);
|
||
await sleep(250);
|
||
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 tab reads back as a texture, its neighbour still a number',
|
||
(await json('__gm.list.info()')).kinds.join(',') === 'texture,number',
|
||
JSON.stringify((await json('__gm.list.info()')).kinds),
|
||
);
|
||
await shot('03c-texture');
|
||
await undo();
|
||
await undo();
|
||
await sleep(250);
|
||
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();
|
||
`);
|
||
await sleep(200);
|
||
|
||
await mutate('__gm.state.openList(null)');
|
||
await sleep(200);
|
||
// The mark of a list is plain now, 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 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');
|
||
|
||
// 21j. 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: [] }],
|
||
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 reads both items 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('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),
|
||
);
|
||
|
||
// 21k. A struct item carries its own shape, so it is named, typed and grown on its own row – 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 struct item carries the add-a-field control', (await find('+ Field')) !== null);
|
||
await clickText('+ Field');
|
||
await sleep(150);
|
||
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 json('__gm.list.info()');
|
||
check(
|
||
'and the tab shows the new field on that row',
|
||
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 inspector has no item shape to show any more',
|
||
await json(`__gm.state.project.assets.find((a) => a.id === 'celly').variables[0].fields === undefined`),
|
||
);
|
||
await shot('06-item-field');
|
||
|
||
// A field of a struct item is renamed and retyped where it sits, both being plain value edits.
|
||
const nameBox = await json("__gm.list.nameAt(0, 'y')");
|
||
await click(nameBox.x, nameBox.y);
|
||
await press('a', { ctrl: true });
|
||
await typeText('row');
|
||
await press('Enter');
|
||
await sleep(250);
|
||
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()),
|
||
);
|
||
const fieldMark = await json("__gm.list.markAt(0, 'x')");
|
||
await click(fieldMark.x, fieldMark.y);
|
||
await sleep(150);
|
||
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();
|
||
await sleep(250);
|
||
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, 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(150);
|
||
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 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));
|
||
}
|