The List tab could show one list at a time and was the only place to see its items. A list now shows its items right where it lives, and a new Variables tab shows all variables of an asset or placed object. - InlineList draws the items of a list under its row in the inspector, in struct fields and in the Variables tab. A list inside a list or a struct field is drawn the same way, one step indented. - The index column is the indent. Items sit under their list without an own margin, and the index column is 16 px wide instead of 28 px. - Above 20 items (INLINE_LIMIT) the rest collapses into "... N more - open in tab". During a run the rows show the values from the game. - ListHead puts "N items" and "+ Item" in one row. "N items" opens the list in a tab. A separate Open button squeezed the counter to zero width in the narrow inspector column. - VariablesEditor shows every variable of a behavior asset, a variables asset or a placed behavior. On an asset it edits the declarations. On a placed object an edit only overrides that object, with the usual reset and push buttons. - Opening a list inside a tab adds a new tab and keeps the open one. valuesKey gives each list its own document, so a list that is already open is shown instead of opened twice. - Titles read "Pickup · Variables" or "Listy · nums · [3]". The tab closes when its asset or object is deleted. - Lists on node inputs stay inline only, a node owns no variables. - The List tab, ListEditor and the __gm.list hook are gone. Tests: - ./check.sh: 181 files, 2645 tests, CHECK EXIT 0 - GM_STEPS=1,21,22,32: ALL PASSED, 123 checks - e2e 21 is rewritten for inline lists and the new tab, 22 and 32 are adapted. Sum in README: 1486 (+6 in 21, -1 in 22), counted by hand. - Counter-probes fail as expected: sub-lists not told apart (1 unit test), sub-list replacing the open tab, all items drawn inline (60 rows instead of 20), no live values (0 rows at 24 items in a run). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
282 lines
12 KiB
TypeScript
282 lines
12 KiB
TypeScript
/**
|
||
* The address an inline list edits through (#252). 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 { NESTED_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 tab resolves once per rebuild and keeps that access for its delete, add and kind buttons,
|
||
* and an item edit does not rebuild. A delete clicked after an edit has to keep that edit.
|
||
*/
|
||
it('deletes from the list as it is now, so an edit made after the rows were built stays', () => {
|
||
const held = list();
|
||
held.itemHandlers(0).onChange(10);
|
||
held.replace([...held.value, 5]);
|
||
held.itemHandlers(3).onChange(50);
|
||
held.replace(held.value.filter((_, i) => i !== 1));
|
||
expect(list().value).toEqual([10, 4, 50]);
|
||
expect(held.value).toEqual([10, 4, 50]);
|
||
});
|
||
|
||
/** 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', path: ['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, path: ['name'] })).toBe(null);
|
||
expect(resolveList(f.state, { ...slot, path: ['no-such'] })).toBe(null);
|
||
expect(resolveList(f.state, { kind: 'asset', assetId: f.behavior.id, key: 'nums', path: ['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] });
|
||
});
|
||
|
||
/** Once the one shape the model dropped (#239): a list inside a struct inside a list. */
|
||
it('keeps a list inside the struct items of a list', () => {
|
||
const def = variableDef('rows', 'list', [{ x: 1, inner: [1, 2] }]);
|
||
expect(def.default).toEqual([{ x: 1, inner: [1, 2] }]);
|
||
});
|
||
|
||
/** Nothing is dropped any more, so a list in a struct field offers every kind, as the whole list does. */
|
||
it('offers every kind, in a struct field as in the whole list', () => {
|
||
expect(list().kinds).toEqual(NESTED_KINDS);
|
||
expect(list().kinds).toContain('struct');
|
||
expect(list().kinds).toContain('list');
|
||
const whole = resolveList(f.state, { kind: 'asset', assetId: f.behavior.id, key: 'nums' })!;
|
||
expect(whole.kinds).toEqual(NESTED_KINDS);
|
||
});
|
||
});
|