Files
feuerwerk/tests/editor/list-slot.test.ts
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

282 lines
12 KiB
TypeScript
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 address an inline list edits through (#252). What matters here is not that a list can be read, but
* that writing one item goes through the same command (and the same undo) as the inspector row
* it replaces, and that a row keeps answering for the list as it is *now*.
*/
import { beforeEach, describe, expect, it } from 'vitest';
import { CreateAssetCommand } from '../../src/core/commands/assetCommands';
import { AddNodeCommand, ConnectCommand } from '../../src/core/commands/graphCommands';
import { AddStructFieldCommand, AddVariableCommand } from '../../src/core/commands/variableCommands';
import { deserializeProject, serializeProject } from '../../src/core/store/serialize';
import type { BehaviorAsset } from '../../src/core/model/Asset';
import { tagged } from '../../src/core/model/Properties';
import { createEmptyProject, findAsset } from '../../src/core/model/Project';
import { NESTED_KINDS } from '../../src/core/properties/kinds';
import { variableDef } from '../../src/core/properties/PropertySchema';
import { resolveList } from '../../src/editor/list/slot';
import { EditorState, type ListSlot } from '../../src/editor/state';
function setup(): { state: EditorState; behavior: BehaviorAsset } {
const state = new EditorState();
state.loadProject(createEmptyProject('p', 'P'), 1);
const create = new CreateAssetCommand('behavior', 'B');
expect(state.execute(create)).toBe(true);
const behavior = create.asset as BehaviorAsset;
expect(state.execute(new AddVariableCommand(behavior.id, 'nums', 'list'))).toBe(true);
return { state, behavior };
}
describe('the address a list is edited through', () => {
let f: ReturnType<typeof setup>;
let slot: ListSlot;
beforeEach(() => {
f = setup();
slot = { kind: 'asset', assetId: f.behavior.id, key: 'nums' };
resolveList(f.state, slot)!.replace([3, 1, 4]);
});
const list = () => resolveList(f.state, slot)!;
it('finds the list and what it belongs to', () => {
expect(list().value).toEqual([3, 1, 4]);
expect(list().def.kind).toBe('list');
expect(list().title).toBe('B · nums');
});
it('writes one item through the property command, and undo puts it back', () => {
list().itemHandlers(1).onChange(99);
expect(list().value).toEqual([3, 99, 4]);
f.state.commands.undo();
expect(list().value).toEqual([3, 1, 4]);
});
it('takes a preview back before committing, so undo lands before the preview', () => {
const handlers = list().itemHandlers(0);
handlers.onInput(7);
handlers.onInput(8);
handlers.onChange(8);
expect(list().value).toEqual([8, 1, 4]);
f.state.commands.undo();
expect(list().value).toEqual([3, 1, 4]);
});
/**
* A row's handlers outlive a commit – the tab only rebuilds when the length changes. Reading
* the list at edit time rather than at build time is what keeps the earlier edit alive.
*/
it('lets two rows built at the same time be edited one after the other', () => {
const first = list().itemHandlers(0);
const second = list().itemHandlers(2);
first.onChange(10);
second.onChange(20);
expect(list().value).toEqual([10, 1, 20]);
});
/**
* The tab resolves once per rebuild and keeps that access for its delete, add and kind buttons,
* and an item edit does not rebuild. A delete clicked after an edit has to keep that edit.
*/
it('deletes from the list as it is now, so an edit made after the rows were built stays', () => {
const held = list();
held.itemHandlers(0).onChange(10);
held.replace([...held.value, 5]);
held.itemHandlers(3).onChange(50);
held.replace(held.value.filter((_, i) => i !== 1));
expect(list().value).toEqual([10, 4, 50]);
expect(held.value).toEqual([10, 4, 50]);
});
/** The row keeps the kind of the item in it: a number field may only ever hand back a number. */
it('writes what it is given as the kind the item already is', () => {
list().itemHandlers(1).onChange('42');
expect(list().value).toEqual([3, 42, 4]);
list().itemHandlers(1).onChange('nonsense');
expect(list().value).toEqual([3, 0, 4]);
});
it('takes a whole list of mixed items as it is, here and on a node setting', () => {
list().replace(['5', true, tagged('color', 0xff0000)]);
expect(list().value).toEqual(['5', true, tagged('color', 0xff0000)]);
const add = new AddNodeCommand(f.behavior.id, 'List', 0, 0);
expect(f.state.execute(add)).toBe(true);
const node: ListSlot = { kind: 'node', assetId: f.behavior.id, nodeId: add.node.id, key: 'value' };
resolveList(f.state, node)!.replace(['5', 'nonsense']);
expect(resolveList(f.state, node)!.value).toEqual(['5', 'nonsense']);
});
/** A preview writes past the command, so it is the one write that has to coerce for itself. */
it('leaves a well-typed list behind even mid-preview', () => {
list().itemHandlers(0).onInput('7');
const previewed = (findAsset(f.state.project, f.behavior.id) as BehaviorAsset).props.nums;
expect(previewed).toEqual([7, 1, 4]);
});
it('adds and removes whole items in one command', () => {
list().replace([...list().value, 9]);
expect(list().value).toEqual([3, 1, 4, 9]);
list().replace(list().value.filter((_, i) => i !== 0));
expect(list().value).toEqual([1, 4, 9]);
f.state.commands.undo();
expect(list().value).toEqual([3, 1, 4, 9]);
});
it('is nothing at all once its target is gone', () => {
expect(resolveList(f.state, { kind: 'asset', assetId: 'no-such', key: 'nums' })).toBe(null);
expect(resolveList(f.state, { kind: 'asset', assetId: f.behavior.id, key: 'no-such' })).toBe(null);
});
it('refuses a key that is not a list', () => {
expect(resolveList(f.state, { kind: 'asset', assetId: f.behavior.id, key: 'runInEditor' })).toBe(null);
});
it('keeps text items as text rather than coercing them to numbers', () => {
const behavior = findAsset(f.state.project, f.behavior.id) as BehaviorAsset;
behavior.variables.push(variableDef('words', 'list', []));
const words: ListSlot = { kind: 'asset', assetId: f.behavior.id, key: 'words' };
resolveList(f.state, words)!.replace(['red', 'green']);
resolveList(f.state, words)!.itemHandlers(1).onChange('blue');
expect(resolveList(f.state, words)!.value).toEqual(['red', 'blue']);
});
/** An unconnected `list` input carries its inline value in the same params bag a setting does. */
it('reaches the inline value of an unconnected list input', () => {
const add = new AddNodeCommand(f.behavior.id, 'Count', 0, 0);
expect(f.state.execute(add)).toBe(true);
const port: ListSlot = { kind: 'node', assetId: f.behavior.id, nodeId: add.node.id, key: 'list' };
expect(resolveList(f.state, port)!.def.kind).toBe('list');
expect(resolveList(f.state, port)!.title).toBe('Count · list');
resolveList(f.state, port)!.replace([1, 2, 3]);
resolveList(f.state, port)!.itemHandlers(1).onChange('9');
expect(resolveList(f.state, port)!.value).toEqual([1, 9, 3]);
f.state.commands.undo();
expect(resolveList(f.state, port)!.value).toEqual([1, 2, 3]);
});
/** A fed port ignores its inline value, so editing it would be editing nothing. */
it('stops resolving a list input once an edge feeds it', () => {
const source = new AddNodeCommand(f.behavior.id, 'List', 0, 0);
const sink = new AddNodeCommand(f.behavior.id, 'Count', 0, 0);
expect(f.state.execute(source)).toBe(true);
expect(f.state.execute(sink)).toBe(true);
const port: ListSlot = { kind: 'node', assetId: f.behavior.id, nodeId: sink.node.id, key: 'list' };
expect(resolveList(f.state, port)).not.toBe(null);
expect(
f.state.execute(
new ConnectCommand(
f.behavior.id,
{ node: source.node.id, port: 'value' },
{ node: sink.node.id, port: 'list' },
),
),
).toBe(true);
expect(resolveList(f.state, port)).toBe(null);
});
it('reaches a node setting too, which is the other place a list lives', () => {
const add = new AddNodeCommand(f.behavior.id, 'List', 0, 0);
expect(f.state.execute(add)).toBe(true);
const node: ListSlot = { kind: 'node', assetId: f.behavior.id, nodeId: add.node.id, key: 'value' };
resolveList(f.state, node)!.replace([1, 2]);
expect(resolveList(f.state, node)!.value).toEqual([1, 2]);
expect(resolveList(f.state, node)!.title).toBe('List · Value');
f.state.commands.undo();
expect(resolveList(f.state, node)!.value).toEqual([]);
});
});
/**
* A list one level down, inside a struct field. The bundle is what gets written, so the point of
* every check here is that the neighbouring fields survive an edit to the list.
*/
describe('a list a struct field holds', () => {
let f: ReturnType<typeof setup>;
let slot: ListSlot;
beforeEach(() => {
f = setup();
const id = f.behavior.id;
expect(f.state.execute(new AddVariableCommand(id, 'route', 'struct'))).toBe(true);
expect(f.state.execute(new AddStructFieldCommand(id, 'route', { key: 'name', kind: 'text' }))).toBe(true);
expect(f.state.execute(new AddStructFieldCommand(id, 'route', { key: 'stops', kind: 'list' }))).toBe(
true,
);
slot = { kind: 'asset', assetId: id, key: 'route', path: ['stops'] };
});
const list = () => resolveList(f.state, slot)!;
const bundle = () => (findAsset(f.state.project, f.behavior.id) as BehaviorAsset).props.route;
it('starts empty and says where it sits', () => {
expect(list().value).toEqual([]);
expect(list().def.kind).toBe('list');
expect(list().title).toBe('B · route · stops');
});
it('writes the whole bundle, so the neighbouring field keeps its value', () => {
resolveList(f.state, { kind: 'asset', assetId: f.behavior.id, key: 'route' });
(findAsset(f.state.project, f.behavior.id) as BehaviorAsset).props.route = { name: 'north', stops: [] };
list().replace([1, 2, 3]);
expect(bundle()).toEqual({ name: 'north', stops: [1, 2, 3] });
list().itemHandlers(1).onChange('9');
expect(bundle()).toEqual({ name: 'north', stops: [1, 9, 3] });
f.state.commands.undo();
expect(bundle()).toEqual({ name: 'north', stops: [1, 2, 3] });
});
/** A preview writes past the command, so mid-edit the bundle has to stay whole as well. */
it('keeps the bundle whole mid-preview', () => {
list().replace([5]);
list().itemHandlers(0).onInput('7');
expect(bundle()).toEqual({ name: '', stops: [7] });
});
it('is nothing at all for a field that is not a list, or is not there', () => {
expect(resolveList(f.state, { ...slot, path: ['name'] })).toBe(null);
expect(resolveList(f.state, { ...slot, path: ['no-such'] })).toBe(null);
expect(resolveList(f.state, { kind: 'asset', assetId: f.behavior.id, key: 'nums', path: ['x'] })).toBe(
null,
);
});
it('survives being written out and read back in', () => {
list().replace([4, 5]);
const back = deserializeProject(serializeProject(f.state.project));
const asset = findAsset(back, f.behavior.id) as BehaviorAsset;
expect(asset.variables.find((v) => v.key === 'route')).toMatchObject({
fields: [
{ key: 'name', kind: 'text' },
{ key: 'stops', kind: 'list' },
],
});
expect(asset.props.route).toEqual({ name: '', stops: [4, 5] });
});
/** Once the one shape the model dropped (#239): a list inside a struct inside a list. */
it('keeps a list inside the struct items of a list', () => {
const def = variableDef('rows', 'list', [{ x: 1, inner: [1, 2] }]);
expect(def.default).toEqual([{ x: 1, inner: [1, 2] }]);
});
/** Nothing is dropped any more, so a list in a struct field offers every kind, as the whole list does. */
it('offers every kind, in a struct field as in the whole list', () => {
expect(list().kinds).toEqual(NESTED_KINDS);
expect(list().kinds).toContain('struct');
expect(list().kinds).toContain('list');
const whole = resolveList(f.state, { kind: 'asset', assetId: f.behavior.id, key: 'nums' })!;
expect(whole.kinds).toEqual(NESTED_KINDS);
});
});