Files
feuerwerk/tests/editor/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

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 the List tab edits through. 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);
});
});