A kind like colour or texture used to be spelled out in five parallel lists, its port type, its empty value, its glyph, its label and its list tag - 22 literal places for texture alone. core/properties/kinds.ts now holds one row per kind and the rest is derived from it, so adding a kind is one row. The five literal nodes collapse into literals.ts, the six kind menus into one helper. Number, Text, Boolean is now the one order everywhere; the variable and port menus used to list Boolean before Text. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
271 lines
11 KiB
TypeScript
271 lines
11 KiB
TypeScript
/**
|
||
* 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 { FIELD_KINDS, LIST_ITEM_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 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', 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.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, 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' },
|
||
],
|
||
});
|
||
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 dropped inside the struct items of a list', () => {
|
||
const def = variableDef('rows', 'list', [{ x: 1, inner: [1, 2] }]);
|
||
expect(def.default).toEqual([{ x: 1 }]);
|
||
});
|
||
|
||
/**
|
||
* And because they are dropped, the tab must not offer them: an item added here would vanish on
|
||
* the next write, which reads as the menu entry doing nothing at all.
|
||
*/
|
||
it('offers the flat kinds only, where the whole list has struct as well', () => {
|
||
expect(list().kinds).toEqual(FIELD_KINDS);
|
||
expect(list().kinds).not.toContain('struct');
|
||
const whole = resolveList(f.state, { kind: 'asset', assetId: f.behavior.id, key: 'nums' })!;
|
||
expect(whole.kinds).toEqual(LIST_ITEM_KINDS);
|
||
expect(whole.kinds).toContain('struct');
|
||
});
|
||
});
|