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

142 lines
6.4 KiB
TypeScript

/**
* Editing a value three levels deep (#239): an inline list reaches a nested list by a path of keys
* and indices, an inspector row a nested struct field by its keys. Either way one edit is one
* command on the whole value, so undo takes back exactly that edit and the neighbours stay.
*/
import { beforeEach, describe, expect, it } from 'vitest';
import { CreateAssetCommand, SetAssetPropertyCommand } from '../../src/core/commands/assetCommands';
import { AddStructFieldCommand, AddVariableCommand } from '../../src/core/commands/variableCommands';
import type { BehaviorAsset } from '../../src/core/model/Asset';
import type { PropertyValue } from '../../src/core/model/Properties';
import { createEmptyProject, findAsset } from '../../src/core/model/Project';
import { requirePropDef } from '../../src/core/commands/require';
import { structFieldsOf } from '../../src/editor/inspector/previewHandlers';
import { resolveList } from '../../src/editor/list/slot';
import { EditorState, type ListSlot } from '../../src/editor/state';
function setup(): { state: EditorState; id: string } {
const state = new EditorState();
state.loadProject(createEmptyProject('p', 'P'), 1);
const create = new CreateAssetCommand('behavior', 'B');
expect(state.execute(create)).toBe(true);
return { state, id: create.asset.id };
}
const props = (f: ReturnType<typeof setup>) => (findAsset(f.state.project, f.id) as BehaviorAsset).props;
describe('a list nested in the items of a list', () => {
let f: ReturnType<typeof setup>;
const rows = [{ name: 'a', drops: [1, { x: [2, 3] }] }, 'plain'];
beforeEach(() => {
f = setup();
expect(f.state.execute(new AddVariableCommand(f.id, 'rows', 'list'))).toBe(true);
resolveList(f.state, { kind: 'asset', assetId: f.id, key: 'rows' })!.replace(rows);
});
const at = (path: ListSlot['path']) =>
resolveList(f.state, { kind: 'asset', assetId: f.id, key: 'rows', path });
it('is found by its path, and the title spells the path out', () => {
expect(at([0, 'drops'])!.value).toEqual([1, { x: [2, 3] }]);
expect(at([0, 'drops'])!.title).toBe('B · rows · [0] · drops');
expect(at([0, 'drops', 1, 'x'])!.value).toEqual([2, 3]);
expect(at([0, 'drops', 1, 'x'])!.title).toBe('B · rows · [0] · drops · [1] · x');
});
it('is nothing where the path leads to no list', () => {
expect(at([0, 'name'])).toBe(null);
expect(at([1, 'drops'])).toBe(null);
expect(at([5])).toBe(null);
});
it('writes one deep item as one command on the whole list, and undo takes back just that', () => {
at([0, 'drops', 1, 'x'])!.itemHandlers(1).onChange(9);
expect(props(f).rows).toEqual([{ name: 'a', drops: [1, { x: [2, 9] }] }, 'plain']);
at([0, 'drops'])!.replace([1, { x: [2, 9] }, [4]]);
expect(props(f).rows).toEqual([{ name: 'a', drops: [1, { x: [2, 9] }, [4]] }, 'plain']);
f.state.commands.undo();
expect(props(f).rows).toEqual([{ name: 'a', drops: [1, { x: [2, 9] }] }, 'plain']);
f.state.commands.undo();
expect(props(f).rows).toEqual(rows);
});
it('edits a value inside an item by its path, keeping the kind it has', () => {
const list = at([])!;
list.handlersAt([0, 'drops', 1, 'x', 0]).onChange('7');
expect(props(f).rows).toEqual([{ name: 'a', drops: [1, { x: [7, 3] }] }, 'plain']);
});
it('previews a deep edit and still undoes to before the preview', () => {
const handlers = at([])!.handlersAt([0, 'name']);
handlers.onInput('ab');
handlers.onInput('abc');
handlers.onChange('abc');
expect(props(f).rows).toEqual([{ name: 'abc', drops: [1, { x: [2, 3] }] }, 'plain']);
f.state.commands.undo();
expect(props(f).rows).toEqual(rows);
});
it('opens a tab one level further down', () => {
at([0, 'drops'])!.openList!([1, 'x']);
expect(f.state.openedValues).toEqual({
kind: 'list',
slot: { kind: 'asset', assetId: f.id, key: 'rows', path: [0, 'drops', 1, 'x'] },
});
});
it('reaches a list inside it for drawing it inline, writing the whole value', () => {
const inner = at([0, 'drops'])!.sub([1, 'x'])!;
expect(inner.title).toBe(at([0, 'drops', 1, 'x'])!.title);
expect(inner.value).toEqual([2, 3]);
inner.replace([2, 3, 4]);
expect(props(f).rows).toEqual([{ name: 'a', drops: [1, { x: [2, 3, 4] }] }, 'plain']);
expect(at([0, 'drops'])!.sub([0])).toBe(null);
});
});
describe('a field of a struct declared inside a struct', () => {
let f: ReturnType<typeof setup>;
beforeEach(() => {
f = setup();
const run = (command: Parameters<EditorState['execute']>[0]) =>
expect(f.state.execute(command)).toBe(true);
run(new AddVariableCommand(f.id, 'enemy', 'struct'));
run(new AddStructFieldCommand(f.id, 'enemy', { key: 'hp', kind: 'number' }));
run(new AddStructFieldCommand(f.id, 'enemy', { key: 'loot', kind: 'struct', fields: [] }));
run(new AddStructFieldCommand(f.id, 'enemy', { key: 'count', kind: 'number' }, ['loot']));
run(new AddStructFieldCommand(f.id, 'enemy', { key: 'drops', kind: 'list' }, ['loot']));
run(new SetAssetPropertyCommand(f.id, 'enemy', { hp: 5, loot: { count: 1, drops: ['a'] } }));
});
const handlers = () => {
const def = requirePropDef(f.state.project, findAsset(f.state.project, f.id)!, 'enemy');
if (def.kind !== 'struct') throw new Error('enemy is not a struct');
return structFieldsOf<{ props: Record<string, PropertyValue> }>(
{ state: f.state },
def,
{
target: () => findAsset(f.state.project, f.id),
read: (a) => a.props.enemy,
write: (a, value) => (a.props.enemy = value),
commit: (value) => new SetAssetPropertyCommand(f.id, 'enemy', value),
},
(path) => ({ kind: 'asset', assetId: f.id, key: 'enemy', path }),
);
};
it('writes the nested field and leaves every other one alone, one undo step', () => {
handlers()(['loot', 'count'], { key: 'count', kind: 'number' }).onChange(4);
expect(props(f).enemy).toEqual({ hp: 5, loot: { count: 4, drops: ['a'] } });
f.state.commands.undo();
expect(props(f).enemy).toEqual({ hp: 5, loot: { count: 1, drops: ['a'] } });
});
it('draws a list field inline by its path', () => {
const list = handlers()(['loot', 'drops'], { key: 'drops', kind: 'list' }).list!()!;
expect(list.title).toBe('B · enemy · loot · drops');
list.replace(['a', ['b']]);
expect(props(f).enemy).toEqual({ hp: 5, loot: { count: 1, drops: ['a', ['b']] } });
});
});