/** * Editing a value three levels deep (#239): an inline list reaches a nested list by a path of keys * and indices, an inspector row a nested struct field by its keys. Either way one edit is one * command on the whole value, so undo takes back exactly that edit and the neighbours stay. */ import { beforeEach, describe, expect, it } from 'vitest'; import { CreateAssetCommand, SetAssetPropertyCommand } from '../../src/core/commands/assetCommands'; import { AddStructFieldCommand, AddVariableCommand } from '../../src/core/commands/variableCommands'; import type { BehaviorAsset } from '../../src/core/model/Asset'; import type { PropertyValue } from '../../src/core/model/Properties'; import { createEmptyProject, findAsset } from '../../src/core/model/Project'; import { requirePropDef } from '../../src/core/commands/require'; import { structFieldsOf } from '../../src/editor/inspector/previewHandlers'; import { resolveList } from '../../src/editor/list/slot'; import { EditorState, type ListSlot } from '../../src/editor/state'; function setup(): { state: EditorState; id: string } { const state = new EditorState(); state.loadProject(createEmptyProject('p', 'P'), 1); const create = new CreateAssetCommand('behavior', 'B'); expect(state.execute(create)).toBe(true); return { state, id: create.asset.id }; } const props = (f: ReturnType) => (findAsset(f.state.project, f.id) as BehaviorAsset).props; describe('a list nested in the items of a list', () => { let f: ReturnType; const rows = [{ name: 'a', drops: [1, { x: [2, 3] }] }, 'plain']; beforeEach(() => { f = setup(); expect(f.state.execute(new AddVariableCommand(f.id, 'rows', 'list'))).toBe(true); resolveList(f.state, { kind: 'asset', assetId: f.id, key: 'rows' })!.replace(rows); }); const at = (path: ListSlot['path']) => resolveList(f.state, { kind: 'asset', assetId: f.id, key: 'rows', path }); it('is found by its path, and the title spells the path out', () => { expect(at([0, 'drops'])!.value).toEqual([1, { x: [2, 3] }]); expect(at([0, 'drops'])!.title).toBe('B · rows · [0] · drops'); expect(at([0, 'drops', 1, 'x'])!.value).toEqual([2, 3]); expect(at([0, 'drops', 1, 'x'])!.title).toBe('B · rows · [0] · drops · [1] · x'); }); it('is nothing where the path leads to no list', () => { expect(at([0, 'name'])).toBe(null); expect(at([1, 'drops'])).toBe(null); expect(at([5])).toBe(null); }); it('writes one deep item as one command on the whole list, and undo takes back just that', () => { at([0, 'drops', 1, 'x'])!.itemHandlers(1).onChange(9); expect(props(f).rows).toEqual([{ name: 'a', drops: [1, { x: [2, 9] }] }, 'plain']); at([0, 'drops'])!.replace([1, { x: [2, 9] }, [4]]); expect(props(f).rows).toEqual([{ name: 'a', drops: [1, { x: [2, 9] }, [4]] }, 'plain']); f.state.commands.undo(); expect(props(f).rows).toEqual([{ name: 'a', drops: [1, { x: [2, 9] }] }, 'plain']); f.state.commands.undo(); expect(props(f).rows).toEqual(rows); }); it('edits a value inside an item by its path, keeping the kind it has', () => { const list = at([])!; list.handlersAt([0, 'drops', 1, 'x', 0]).onChange('7'); expect(props(f).rows).toEqual([{ name: 'a', drops: [1, { x: [7, 3] }] }, 'plain']); }); it('previews a deep edit and still undoes to before the preview', () => { const handlers = at([])!.handlersAt([0, 'name']); handlers.onInput('ab'); handlers.onInput('abc'); handlers.onChange('abc'); expect(props(f).rows).toEqual([{ name: 'abc', drops: [1, { x: [2, 3] }] }, 'plain']); f.state.commands.undo(); expect(props(f).rows).toEqual(rows); }); it('opens a tab one level further down', () => { at([0, 'drops'])!.openList!([1, 'x']); expect(f.state.openedValues).toEqual({ kind: 'list', slot: { kind: 'asset', assetId: f.id, key: 'rows', path: [0, 'drops', 1, 'x'] }, }); }); it('reaches a list inside it for drawing it inline, writing the whole value', () => { const inner = at([0, 'drops'])!.sub([1, 'x'])!; expect(inner.title).toBe(at([0, 'drops', 1, 'x'])!.title); expect(inner.value).toEqual([2, 3]); inner.replace([2, 3, 4]); expect(props(f).rows).toEqual([{ name: 'a', drops: [1, { x: [2, 3, 4] }] }, 'plain']); expect(at([0, 'drops'])!.sub([0])).toBe(null); }); }); describe('a field of a struct declared inside a struct', () => { let f: ReturnType; beforeEach(() => { f = setup(); const run = (command: Parameters[0]) => expect(f.state.execute(command)).toBe(true); run(new AddVariableCommand(f.id, 'enemy', 'struct')); run(new AddStructFieldCommand(f.id, 'enemy', { key: 'hp', kind: 'number' })); run(new AddStructFieldCommand(f.id, 'enemy', { key: 'loot', kind: 'struct', fields: [] })); run(new AddStructFieldCommand(f.id, 'enemy', { key: 'count', kind: 'number' }, ['loot'])); run(new AddStructFieldCommand(f.id, 'enemy', { key: 'drops', kind: 'list' }, ['loot'])); run(new SetAssetPropertyCommand(f.id, 'enemy', { hp: 5, loot: { count: 1, drops: ['a'] } })); }); const handlers = () => { const def = requirePropDef(f.state.project, findAsset(f.state.project, f.id)!, 'enemy'); if (def.kind !== 'struct') throw new Error('enemy is not a struct'); return structFieldsOf<{ props: Record }>( { state: f.state }, def, { target: () => findAsset(f.state.project, f.id), read: (a) => a.props.enemy, write: (a, value) => (a.props.enemy = value), commit: (value) => new SetAssetPropertyCommand(f.id, 'enemy', value), }, (path) => ({ kind: 'asset', assetId: f.id, key: 'enemy', path }), ); }; it('writes the nested field and leaves every other one alone, one undo step', () => { handlers()(['loot', 'count'], { key: 'count', kind: 'number' }).onChange(4); expect(props(f).enemy).toEqual({ hp: 5, loot: { count: 4, drops: ['a'] } }); f.state.commands.undo(); expect(props(f).enemy).toEqual({ hp: 5, loot: { count: 1, drops: ['a'] } }); }); it('draws a list field inline by its path', () => { const list = handlers()(['loot', 'drops'], { key: 'drops', kind: 'list' }).list!()!; expect(list.title).toBe('B · enemy · loot · drops'); list.replace(['a', ['b']]); expect(props(f).enemy).toEqual({ hp: 5, loot: { count: 1, drops: ['a', ['b']] } }); }); });