Files
feuerwerk/tests/editor/list-slot.test.ts
T
rootandClaude Opus 5 10d030376a Finish the struct and list leftovers: inline lists, list fields, item kinds
An unconnected list input is a button into the List tab instead of a number
field; a struct field can hold a list, so ListSlot carries an optional field
name and the three owner cases only say what sits at a key; and a list
variable picks its item kind in the inspector (marker doubles as the chooser),
so a list of structs no longer has to be born from a project file.

The declaration keeps the storable shapes finite: a struct field may be a list,
but that list holds primitives only, so list->struct->primitive and
struct->list->primitive are the two deepest forms.

check 674, e2e 483, jump-run 24, Tetris 19.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-10 16:36:15 +02:00

261 lines
11 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 { createEmptyProject, findAsset } from '../../src/core/model/Project';
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, its item kind and what it belongs to', () => {
expect(list().value).toEqual([3, 1, 4]);
expect(list().def.items).toBe('number');
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]);
});
it('coerces what it is given to the declared item kind', () => {
list().itemHandlers(1).onChange('42');
expect(list().value).toEqual([3, 42, 4]);
list().replace(['5', 'nonsense']);
expect(list().value).toEqual([5, 0]);
});
it('coerces a node setting the same way, where the value is not schema-checked on read', () => {
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, 0]);
});
/** 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', [], 'text'));
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,
* so it is the same slot – before this it offered a number field instead.
*/
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.items).toBe('number');
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', items: 'number' }),
),
).toBe(true);
slot = { kind: 'asset', assetId: id, key: 'route', field: '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.items).toBe('number');
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, field: 'name' })).toBe(null);
expect(resolveList(f.state, { ...slot, field: 'no-such' })).toBe(null);
expect(resolveList(f.state, { kind: 'asset', assetId: f.behavior.id, key: 'nums', field: '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', items: 'number' },
],
});
expect(asset.props.route).toEqual({ name: '', stops: [4, 5] });
});
/** The one shape the model does not store: a list inside a struct inside a list. */
it('is refused inside the struct items of a list', () => {
const def = variableDef('rows', 'list', [], 'struct', [
{ key: 'x', kind: 'number' },
{ key: 'inner', kind: 'list', items: 'number' },
]);
expect(def.kind === 'list' && def.fields).toEqual([{ key: 'x', kind: 'number' }]);
});
});