game-maker / suite (push) Successful in 6m48s
The suite spent 88% of its step time asleep: 564 s of steps, 496 s of
them in sleep(n) - fixed spans estimated by hand and then copied from
step to step. That is why it was slow, and why it flickered.
The signal is afterFrames(count, timeout) in ui/render.ts, exposed as
__gm.afterFrames(). It wakes the loop itself, because an editor that
draws nothing would never redeem a promise of a frame, and it gives up
after a timeout instead of hanging the run. settle() in the harness
waits two drawn frames (~32 ms) instead of 150-300 ms and falls back to
sleep(150) when the hook is missing (an old build): silently not
waiting would make every later check a coin toss.
Every action goes through it now - click, doubleClick, wheel, drag,
dragAsset, press, typeText, select, mutate, undo, redo - and with that
about 550 sleep() calls behind those actions became redundant, helpers
that are nothing but a mutate in a coat included. What a frame cannot
answer got its own condition instead: waitUntil(expr), play()/stop()
waiting on __gm.state.mode rather than on 300 ms, saved() for the
autosave (the two hand-written polling loops sampled at 300 ms for
something that arrives after a second), and showTab() as a real tool -
47 places called evaluate("__gm.showTab(...)") raw, and 24 of them
waited for nothing at all and read the old panel.
564 s -> 243 s of step time, the waiting share 88% -> 76%. Three kinds
of wait were kept, and each one bit when it was taken away: loading
bytes (textures, fonts), game time that a repaint cannot stand in for
(time scale, shader and particle clocks, the live values a running
graph carries), and the inertia the scroll step measures on purpose.
Two things the new speed uncovered, both real: 25-animation read the
frame menu unfiltered and had frozen an incomplete list (the CI failure
on c990dbc), and 33-render-on-change measured idleness while the
autosave of the step before was still painting - it waits for
saveStatus to leave dirty/saving now, so it measures that nothing
happens any more instead of that it waited long enough.
404 lines
16 KiB
JavaScript
404 lines
16 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' });
|
||
`);
|
||
|
||
// 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);
|
||
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');
|
||
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();
|
||
`);
|
||
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();
|
||
`);
|
||
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');
|
||
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)');
|
||
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();
|
||
`);
|
||
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' })");
|
||
let mark = await json('__gm.list.markAt(0)');
|
||
await click(mark.x, mark.y);
|
||
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();
|
||
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 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();
|
||
|
||
// 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();
|
||
`);
|
||
mark = await json('__gm.list.markAt(0)');
|
||
await click(mark.x, mark.y);
|
||
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);
|
||
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();
|
||
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 mutate('__gm.state.openList(null)');
|
||
// 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' })");
|
||
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();
|
||
`);
|
||
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' });
|
||
`);
|
||
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');
|
||
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();
|
||
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 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');
|
||
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 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, 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();
|
||
`);
|
||
const portButton = await find('0 items');
|
||
check('an unconnected list input reads as an item count', portButton !== null);
|
||
await click(portButton.x, portButton.y);
|
||
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 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 mutate('__gm.state.openList(null)');
|
||
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));
|
||
}
|