Files
feuerwerk/tests/editor/nested-list-slot.test.ts
T
adminandClaude Opus 5.5 0c5ac32b59
game-maker / gate (push) Successful in 3m52s
game-maker / suite (push) Successful in 10m24s
Let structs and lists nest to any depth (#239)
Structs and lists were capped at two fixed levels (list of structs,
struct with a list field). Anything deeper was dropped silently on
load, in node settings and when written to a variable. The cap came
from an early design choice, not from a technical need, so it is
lifted completely instead of adding a third fixed level.

- One kind list NESTED_KINDS replaces ListItemKind and
  StructFieldKind. The flat special cases in listItems.ts are gone,
  every value goes through one recursive path.
- A struct inside a struct is declared, like a top-level struct.
  Values coming in over an edge are brought into that shape. Struct
  items in lists still take their fields from the value.
- New valuePath.ts addresses any nested spot by keys and indices.
  ListSlot.field is now ListSlot.path.
- Field commands (add, rename, remove) take the path to the struct.
  They change declaration, own value and all overrides in one undo
  step, as before on the top level.
- Inspector: "+ Field" offers Struct and List on every level. A
  nested struct is a header row with its own "+ Field", its fields
  are indented one step further.
- List tab: a list item shows "n items" and opens one level deeper.
  The title shows the path, e.g. "Listy · nums · [3]".
- Pack in Pack now writes a struct into a struct field instead of
  text. Objects are kept at any depth and stripped at every exit.
- A list edit in a struct field without an existing object override
  now starts from the shown value instead of an empty struct.
- Old projects load unchanged. Save, load and save again gives the
  same result.

Tests:
- ./check.sh: 172 files, 2498 tests, CHECK EXIT 0
- e2e 21 + 22: ALL PASSED, 90 checks (new 21d-iii, 22f-ii,
  21d and 22f updated)
- 20 new unit tests in nested-values.test.ts and
  nested-list-slot.test.ts, three tests that pinned the old cap
  turned around
- 6 counter-probes, each fails 1 to 8 tests, originals restored
  and checked with cmp
- Not covered: a nested struct inside a struct item in the list
  tab has no e2e click

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-26 22:33:20 +02:00

136 lines
6.0 KiB
TypeScript

/**
* Editing a value three levels deep (#239): the List tab 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 the tab one level further down', () => {
at([0, 'drops'])!.openList([1, 'x']);
expect(f.state.listSlot).toEqual({
kind: 'asset',
assetId: f.id,
key: 'rows',
path: [0, 'drops', 1, 'x'],
});
});
});
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('opens a list field in the List tab by its path', () => {
handlers()(['loot', 'drops'], { key: 'drops', kind: 'list' }).openList!();
const list = resolveList(f.state, f.state.listSlot!)!;
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']] } });
});
});