Files
feuerwerk/tools/e2e/21-list.mjs
T
adminandClaude Opus 5.5 7979b8ff19
game-maker / gate (push) Successful in 3m28s
game-maker / suite (push) Successful in 10m14s
Let structs and lists hold objects across behavior runs (#238)
An object could only live in a dedicated object variable, kept in slots
beside the variable bag. Struct fields and list items could not hold
one, so a spawned object packed into a struct or list was lost. This
replaces the slots with one mechanism for all three places.

- "No object" has one resting form everywhere: {"$object": ""}. A
  running game can tell an object slot from an empty text that way.
- During play the object handle lives directly in the value, also
  nested in structs and lists. objectSlots.ts and the special paths for
  Keep and Properties are gone.
- liveObjects.ts strips objects to (null) at every exit: overrides the
  editor run writes back to the scene, saved values of persistent
  Variables assets, live values in the inspector, and the world
  snapshot of the test hooks (objects as names there).
- A destroyed object reads as (null) through isAlive, alone, in a
  struct field or as a list item.
- "+ Field" and "+ Item" offer Object. The inspector and the list tab
  show a fixed (null) row without input.
- Lists from Raycast all stay objects when written to a variable.
  Before they turned into names.
- A struct that only changes by an object writes no override. A list
  that grows by an object writes [(null)], its length really changed.

Found on the way: CI at 98211a8 failed only in e2e 21d. #237 added
asset to the field kinds without updating that fixed expectation. 21d
now expects ...,animation,asset,object,struct.

No project on the server has object variables, so no old data is
affected.

Tests:
- ./check.sh: 169 files, 2475 tests, CHECK EXIT 0.
- e2e 6, 21, 23, 32: ALL PASSED, 213 checks.
- object-variable.test.ts rewritten: variable, struct field and list
  item over several frames, destroyed objects, nothing in scene, file
  or save, editor run reload, Keep.
- New e2e 21d-ii adds an Object item by click. 21d and 23k updated.
- 7 counter-probes, each fails 1 to 9 tests. Originals back via cmp.
- Not tested on its own: stripping of inspector live values and the
  names in the test snapshot, the "+ Field -> Object" menu by click.
- e2e check total 1407, only calculated.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-26 21:12:44 +02:00

446 lines
18 KiB
JavaScript
Raw 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.
/**
* 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]);
// 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).
const edited = await json('__gm.list.fieldAt(2)');
await click(edited.x, edited.y);
await typeText('66');
await press('Enter');
const removeFirst = await json('__gm.list.removeAt(0)');
await click(removeFirst.x, removeFirst.y);
await sleep(200);
info = await json('__gm.list.info()');
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 json('__gm.list.info()');
check(
'and two undos take delete and edit back',
JSON.stringify(info.items) === '["30","11","44"]',
info.items,
);
// 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 `asset` (#237) and `object` (#238) are the newest of them.
'number,text,boolean,color,texture,statemachine,sound,scene,animation,asset,object,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);
// 21d-ii. An object item holds nothing in the file – only its place, filled while a game runs.
await clickText('+ Item');
await pickKind('Object');
await sleep(200);
info = await json('__gm.list.info()');
check(
'an Object item is added as the empty object',
info.kinds.join(',') === 'number,number,number,object' && info.items[3] === '',
JSON.stringify(info),
);
await shot('03a-object-item');
await undo();
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 closes its tab',
(await json('__gm.tab()')) !== 'list' && !(await json('__gm.documents()')).docs.includes('list'),
JSON.stringify({ tab: await json('__gm.tab()'), docs: await json('__gm.documents()') }),
);
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));
}