Files
feuerwerk/tools/e2e/32-scope.mjs
T
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

393 lines
16 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.
import { edge, node, port, variable, written } from '../lib/builders.mjs';
import { MAKE_OBJECT } from './lib/scaffold.mjs';
/**
* Shared state: a Variables asset holds one value per key for the whole run, and a state machine
* declared `global` one current state. The step checks the run (two objects counting into one
* number), that the node offers the asset's declarations, the switch on the machine, and a
* persistent asset going on from the last run (#166).
*
* The step brings its own boxes, behavior, Variables asset and machine; everything goes at the end.
*/
const SOURCE = { variablesAsset: 'sc-vars' };
/** `On start → Variables{set score} ← Add(score, 1)`, counting on the shared asset rather than its own. */
const counterGraph = {
nodes: [
node('sc-start', 'OnStart', 0, 0),
node('sc-read', 'Variables', 0, 200, SOURCE, [port('score', 'number')]),
node('sc-add', 'Add', 260, 200, { b: 1 }),
node('sc-write', 'Variables', 560, 0, SOURCE, [written('score', 'number')]),
],
edges: [
edge('sc-e1', 'sc-start', 'exec', 'sc-write', 'exec'),
edge('sc-e2', 'sc-read', 'score', 'sc-add', 'a'),
edge('sc-e3', 'sc-add', 'result', 'sc-write', 'score'),
],
};
/** `On update → Variables{set bag} ← Join lists(bag, 7)`: a shared list that grows every frame. */
const fillerGraph = {
nodes: [
node('sc-f-update', 'OnUpdate', 0, 0),
node('sc-f-read', 'Variables', 0, 200, { variablesAsset: 'sc-bag' }, [port('bag', 'list')]),
node('sc-f-join', 'Concat', 260, 200, { b: [7] }),
node('sc-f-write', 'Variables', 560, 0, { variablesAsset: 'sc-bag' }, [written('bag', 'list')]),
],
edges: [
edge('sc-f-e1', 'sc-f-update', 'exec', 'sc-f-write', 'exec'),
edge('sc-f-e2', 'sc-f-read', 'bag', 'sc-f-join', 'a'),
edge('sc-f-e3', 'sc-f-join', 'result', 'sc-f-write', 'bag'),
],
};
export async function scope(t) {
const { check, sleep, json, shot, click, locate, asset, mutate, stop, has } = t;
const selectAsset = async (id) => {
await t.select({ kind: 'asset', assetId: id });
};
const selectNode = async (nodeId) => {
await t.select({ kind: 'node', assetId: 'sc-logic', nodeId });
};
/** The inspector is drawn over the graph, so the last match is the row rather than a port. */
const clickLast = async (text) => {
const p = await locate(text, true);
await click(p.x, p.y);
};
// 32a. A Variables asset, a behavior counting on it, and two boxes wearing that behavior.
await mutate(`
const p = __gm.state.project;
const scene = __gm.state.activeScene;
const mover = p.assets.find((a) => a.id === 'mover');
const a = structuredClone(mover);
Object.assign(a, {
id: 'sc-logic',
name: 'Counter',
props: { enabled: true, runInEditor: false },
variables: [],
graph: ${JSON.stringify(counterGraph)},
});
p.assets.unshift(a, {
id: 'sc-vars',
type: 'variables',
name: 'Score',
props: { score: 0 },
variables: ${JSON.stringify([variable('score', 'number', 0)])},
});
${MAKE_OBJECT}
scene.objects.push(
make('sc-box-a', 'crate', '', 1500, 'Counter box A'),
make('sc-brain-a', 'sc-logic', 'sc-box-a', 0, 'Counter A'),
make('sc-box-b', 'crate', '', 1700, 'Counter box B'),
make('sc-brain-b', 'sc-logic', 'sc-box-b', 0, 'Counter B'),
);
`);
// 32b. The run: two instances, one number. A per-instance variable would stand at 1.
await t.play();
await sleep(600);
const world = await json('__gm.world()');
const carriers = world.entities.filter((e) => e.props && 'score' in e.props).map((e) => e.name);
const shared = world.shared.variables['sc-vars'];
check(
'two behavior instances count into one shared variable',
shared && shared.score === 2,
`score = ${JSON.stringify(shared && shared.score)} after both started – 1 would mean each kept its own`,
);
check(
'and the value belongs to the run, not to either placed object',
carriers.length === 0,
`objects carrying "score": ${JSON.stringify(carriers)}`,
);
await stop();
// 32b-ii. A list the run fills: the inspector row counts the run's items, so the items drawn
// under it and in the Variables tab it opens have to be those too, and follow them – not
// the empty list the document declares.
await mutate(`
const p = __gm.state.project;
const scene = __gm.state.activeScene;
const mover = p.assets.find((a) => a.id === 'mover');
const f = structuredClone(mover);
Object.assign(f, {
id: 'sc-filler',
name: 'Filler',
props: { enabled: true, runInEditor: false },
variables: [],
graph: ${JSON.stringify(fillerGraph)},
});
p.assets.unshift(f, {
id: 'sc-bag',
type: 'variables',
name: 'Bag',
props: { bag: [] },
variables: ${JSON.stringify([variable('bag', 'list', [])])},
});
${MAKE_OBJECT}
scene.objects.push(make('sc-brain-f', 'sc-filler', 'sc-box-a', 0, 'Filler'));
`);
const bagIn = async (where) =>
(await json(`__gm.values.lists(${JSON.stringify(where)})`)).find((l) => l.title === 'Bag · bag') ?? null;
// Held, so the count the row shows stands still long enough to be found and clicked.
await t.play();
await sleep(300);
await t.clickToolbar('pause');
await sleep(150);
const held = (await json('__gm.world()')).shared.variables['sc-bag']?.bag ?? [];
await selectAsset('sc-bag');
await sleep(200);
const countRow = await t.find(`${held.length} items`);
const inline = await bagIn('inspector');
const head = await json(`__gm.values.headAt('inspector', 'Bag · bag')`);
if (head?.open) await click(head.open.x, head.open.y);
await sleep(150);
const first = await bagIn('tab');
await shot('01b-live-list');
await t.clickToolbar('pause');
await sleep(500);
const later = await bagIn('tab');
await stop();
await sleep(200);
const stopped = await bagIn('tab');
check(
'while playing, a shared list shows the items the run put in – inline and in its tab',
held.length > 0 &&
Boolean(countRow) &&
inline?.rows === Math.min(held.length, 20) &&
(await json('__gm.tab()')) === 'variables' &&
first?.rows === held.length &&
first.items.every((item) => item === '7'),
JSON.stringify({ run: held.length, row: countRow, inline: inline?.rows, tab: first?.rows }),
);
check(
'and the tab follows the list as it grows, then shows the declared list again once the run stops',
later?.rows > first?.rows && stopped?.rows === 0 && stopped.items.length === 0,
JSON.stringify({ first: first?.rows, later: later?.rows, stopped: stopped?.rows }),
);
await mutate(`
const p = __gm.state.project;
__gm.state.activeScene.objects = __gm.state.activeScene.objects.filter((o) => o.id !== 'sc-brain-f');
p.assets = p.assets.filter((a) => a.id !== 'sc-filler' && a.id !== 'sc-bag');
__gm.state.select(null);
__gm.state.preview();
`);
// 32c. The asset is where the declaration lives, and it says what it is for.
await selectAsset('sc-vars');
const declared = await has('score');
const explains = await has('Every graph pointing here shares them.');
check(
'the Variables asset lists its declarations and says what they are',
declared && explains,
`row: ${declared}, note: ${explains}`,
);
await shot('01-variables-asset');
// 32d. The node picks its ports from the asset it names – the whole point of naming one.
await selectNode('sc-read');
const offersShared = await has('score');
const complains = await has('"score" is not a variable of this behavior.');
check(
'a node naming the asset offers its variables instead of the behavior’s',
offersShared && !complains,
`row: ${offersShared}, warning shown: ${complains}`,
);
await shot('02-node-source');
// Emptying the setting points the node back at the behavior, which declares nothing – so the
// very same port is now a stranger. That is the switch doing something visible.
await mutate(`
const g = __gm.state.project.assets.find((a) => a.id === 'sc-logic').graph;
g.nodes.find((n) => n.id === 'sc-read').params.variablesAsset = '';
`);
const complainsNow = await has('"score" is not a variable of this behavior.');
check(
'and clearing it points the node back at the behavior, which has no such variable',
complainsNow,
`warning after clearing: ${complainsNow}`,
);
// 32e. A machine carries its scope on the asset too, with its own switch.
await mutate(`
const p = __gm.state.project;
p.assets.unshift({
id: 'sc-machine',
type: 'statemachine',
name: 'Shared doors',
props: {},
graph: { nodes: [], edges: [] },
});
`);
await selectAsset('sc-machine');
const machineScope = await has('Local');
const pick = await locate('Local', true);
await click(pick.x, pick.y);
await clickLast('Global');
const machine = await asset('sc-machine');
check(
'a state machine is switched between one current state per object and one for the run',
machineScope && machine.scope === 'global',
`dropdown found: ${machineScope}, scope now ${JSON.stringify(machine.scope)}`,
);
await shot('03-machine-scope');
// 32f. Persistent (#166): the switch on the asset, a second run going on from the first, the
// editor run saving apart, and both ways of clearing. Saves live in this browser, so the
// step starts by forgetting whatever an earlier run of the suite left there.
// 32d pointed the reading node at the behavior; counting on from a save needs it back on the asset.
await mutate(`
const g = __gm.state.project.assets.find((a) => a.id === 'sc-logic').graph;
g.nodes.find((n) => n.id === 'sc-read').params.variablesAsset = 'sc-vars';
`);
const pid = await json('__gm.state.project.id');
const saveKey = (area) => `game-maker.saves.${pid}.${area}`;
const saved = (area) =>
json(`JSON.parse(localStorage.getItem(${JSON.stringify(saveKey(area))}) ?? 'null')`);
await t.evaluate(
`localStorage.removeItem(${JSON.stringify(saveKey('play'))}); localStorage.removeItem(${JSON.stringify(saveKey('editor'))})`,
);
await selectAsset('sc-vars');
const persistentBox = await json(`__gm.inspectorField('Persistent')`);
if (persistentBox) await click(persistentBox.x, persistentBox.y);
await sleep(200);
const kept = await asset('sc-vars');
const keptNote = await has('Kept between runs, in this browser.');
check(
'the Persistent switch on a Variables asset marks it and says what it means',
Boolean(persistentBox) && kept.persistent === true && keptNote,
JSON.stringify({ box: persistentBox, persistent: kept.persistent, note: keptNote }),
);
await shot('04-persistent');
// 32f-ii. An object variable in a persistent asset is allowed, but is never saved: the inspector
// says so once there is one, and the note goes again with it.
const objectNote = 'Object variables are not kept,';
const noteBefore = await has(objectNote);
await clickLast('+ Variable');
await clickLast('Object');
const noteWith = await has(objectNote);
await shot('04b-persistent-object');
await t.undo();
const noteAfter = await has(objectNote);
check(
'a persistent asset with an Object variable notes that the object is not kept',
!noteBefore && noteWith && !noteAfter && (await asset('sc-vars')).variables.length === 1,
JSON.stringify({ noteBefore, noteWith, noteAfter }),
);
const scoreOfRun = async () => {
await t.play();
await sleep(600);
const score = (await json('__gm.world()')).shared.variables['sc-vars']?.score;
await stop();
return score;
};
const firstRun = await scoreOfRun();
const secondRun = await scoreOfRun();
const afterPlay = await saved('play');
check(
'the next run goes on where the last one ended, and the save says so',
firstRun === 2 && secondRun === 4 && afterPlay?.['sc-vars']?.score === 4,
JSON.stringify({ firstRun, secondRun, saved: afterPlay }),
);
const declaredScore = (await asset('sc-vars')).props.score;
check(
'the project itself keeps the declared start value',
declaredScore === 0,
`props.score = ${JSON.stringify(declaredScore)}`,
);
// One box runs in the editor: a run of its own, which must not touch what Play saved.
await t.select({
kind: 'object',
holderId: await json('__gm.state.project.activeSceneId'),
objectId: 'sc-brain-a',
});
await sleep(200);
const runBox = await json(`__gm.inspectorField('Run in editor')`);
if (runBox) await click(runBox.x, runBox.y);
await sleep(600);
const editorSave = await saved('editor');
const playSave = await saved('play');
if (runBox) await click(runBox.x, runBox.y);
await sleep(200);
check(
'an editor run saves into an area of its own and leaves the played save alone',
editorSave?.['sc-vars']?.score === 1 && playSave?.['sc-vars']?.score === 4,
JSON.stringify({ box: runBox, editor: editorSave, play: playSave }),
);
await selectAsset('sc-vars');
const clearButton = await t.find('Clear saved values');
if (clearButton) await click(clearButton.x, clearButton.y);
await sleep(200);
const clearedNotice = await json('__gm.state.notice');
const clearedBoth = (await saved('play')) === null && (await saved('editor')) === null;
const freshRun = await scoreOfRun();
check(
'"Clear saved values" forgets both saves, and the next run starts from the declared value',
Boolean(clearButton) &&
clearedBoth &&
freshRun === 2 &&
clearedNotice === 'Saved values of Score cleared',
JSON.stringify({ button: Boolean(clearButton), clearedBoth, freshRun, notice: clearedNotice }),
);
await t.clickToolbar('menu');
await t.clickText('Clear saved data');
await sleep(200);
const menuCleared = await saved('play');
check(
'"Clear saved data" in the menu forgets the saves of the whole project',
menuCleared === null && (await json('__gm.state.notice')) === 'Saved data cleared',
JSON.stringify({ play: menuCleared }),
);
// 32h. The third scope (#164): the switch beside Persistent, what it offers, and that the two
// cannot both be on – `scene` throws the values away with every scene, so there is nothing
// left to keep between runs and the Persistent switch is not even shown.
await selectAsset('sc-vars');
await sleep(200);
const scopeRow = await json(`__gm.inspectorField('Scope')`);
await click(scopeRow.x, scopeRow.y);
const scopes = await json('__gm.menuRows()');
await clickLast('Scene');
await sleep(200);
const scoped = await asset('sc-vars');
const sceneNote = await has('every scene that is loaded.');
check(
'a Variables asset offers Global and Scene, and taking Scene says what it means',
JSON.stringify(scopes) === JSON.stringify(['Global', 'Scene']) && scoped.scope === 'scene' && sceneNote,
JSON.stringify({ scopes, scope: scoped.scope, note: sceneNote }),
);
check(
'a scene-scoped asset is not persistent any more, and cannot be switched to it',
scoped.persistent === undefined && (await json(`__gm.inspectorField('Persistent')`)) === null,
JSON.stringify({ persistent: scoped.persistent, row: await json(`__gm.inspectorField('Persistent')`) }),
);
await shot('05-scene-scope');
const backRow = await json(`__gm.inspectorField('Scope')`);
await click(backRow.x, backRow.y);
await clickLast('Global');
await sleep(200);
const back = await asset('sc-vars');
check(
'switching back to Global takes the scope out of the document and brings the switch back',
back.scope === undefined && Boolean(await json(`__gm.inspectorField('Persistent')`)),
JSON.stringify({ scope: back.scope, persistent: back.persistent }),
);
// 32g. Everything this step added goes again.
await mutate(`
const p = __gm.state.project;
const scene = __gm.state.activeScene;
scene.objects = scene.objects.filter((o) => !o.id.startsWith('sc-'));
p.assets = p.assets.filter((a) => !a.id.startsWith('sc-'));
__gm.state.select(null);
`);
}