/** * 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; 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; 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); }); });