A list property in the inspector was a single text field holding a comma
separated line -- unreadable past a handful of items, and it could not
carry anything but text. It becomes a button ("3 items") that opens a
sixth centre tab, where every item is a row built by the very widget the
inspector would build for that item kind.
The renderer was free: a PropertyDef of kind 'list' already carries
`items`, so item i becomes a synthetic def of that kind -- number, text,
boolean and colour rows appear without a new branch.
The way into the tab goes through the state (EditorState.listSlot,
openList(), event 'list'), like every other tab switch here; a tab may
not call showTab itself. ListSlot lives next to Selection rather than in
the tab folder, or state.ts would have to import upwards.
A row's handlers must not close over a snapshot of the list: they outlive
a commit, since the tab only rebuilds when the length changes, and the
second edited row would otherwise revive the value the first replaced.
showTab switched five `visible` lines by hand, where a sixth tab is
silently forgotten; it is a Record<CentreTab, Widget> now.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
138 lines
5.7 KiB
TypeScript
138 lines
5.7 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 } from '../../src/core/commands/graphCommands';
|
||
import { AddVariableCommand } from '../../src/core/commands/variableCommands';
|
||
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']);
|
||
});
|
||
|
||
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([]);
|
||
});
|
||
});
|