Files
feuerwerk/tests/editor/list-slot.test.ts
T
rootandClaude Opus 5 8f92a06c84 Consolidate duplicated editor code, trim comments, refresh README
Split GraphCanvas (1076 -> 831 lines) into EdgeLayer and graphMenus;
the port and marker menus were the same list and are now one function.
Value specs of the inspector and the list tab share valueSpecs.ts, and
the remaining copies (compound commands, type glyphs, typed asset
lookup, name rule, play/editor world setup, marquee) are merged.

Four copies had already drifted apart; merging them fixes:
- "Disconnect N links" in the port menu is one undo step, not N
- "+ Add collider" is hidden on a placed container instance, as in
  the hierarchy menu, instead of always failing
- a bool property coerced from the text "false" becomes false
- a port command on an unknown node type reports instead of crashing

Comment-only edits leave the bundle byte-identical. README corrected in
35 places (step limit, jump-run clock, suite duration, 409 body, ...).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-11 11:00:30 +02:00

258 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. */
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' }]);
});
});