A list no longer says what its items are. ListDef.items, ListDef.fields,
ListFieldDef.items and SetListItemsCommand are gone; the single seam is
src/core/properties/listItems.ts. JSON already tells number, text, boolean
and struct apart, so only two kinds need a tag: {"$color": n} and the new
{"$texture": id}. Field names cannot start with "$", so a tag never
collides with a struct field. On a data link a texture item arrives as
its bare id; the graph has no images.
List tab: the mark in front of each row is the kind picker, "+ Item"
asks for the kind, both are ordinary value edits (one undo step). A list
inside a struct field only offers the flat kinds because the depth rule
forbids a struct item there (ListAccess.kinds); the menu used to offer
it and silently do nothing.
Legacy lists that declared items: 'color' are tagged on load.
Actor demo: the four walk animations and four standing frames live in
two list variables of texture items indexed by facing (Item at), so the
graph can be rebuilt in the editor. The stand frames get their grid ids
back; 44 nodes instead of 46.
Also: the user's edits to on-start-during-play.md and the new note
reseed-because-of-list.md.
Verified: check.sh 794 tests, full e2e 631 checks, smokes 24/19/20.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
269 lines
11 KiB
TypeScript
269 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 { colorItem } from '../../src/core/model/Properties';
|
||
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 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, colorItem(0xff0000)]);
|
||
expect(list().value).toEqual(['5', true, colorItem(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. A texture goes the same
|
||
* way – flat, it would keep only its id and stop being a picture.
|
||
*/
|
||
it('offers the flat kinds only, where the whole list has all six', () => {
|
||
expect(list().kinds).toEqual(['number', 'text', 'boolean', 'color']);
|
||
const whole = resolveList(f.state, { kind: 'asset', assetId: f.behavior.id, key: 'nums' })!;
|
||
expect(whole.kinds).toEqual(['number', 'text', 'boolean', 'color', 'texture', 'struct']);
|
||
});
|
||
});
|