/** * Reordering what the inspector lets be dragged (#310): variables, the fields of a struct at any * depth, a Struct asset's fields with every place of its type, and the declared ports of a start node. * Only the order may change – values, edges and what runs stay as they were. */ import { describe, expect, it } from 'vitest'; import { CreateAssetCommand, SetAssetPropertyCommand } from '../../src/core/commands/assetCommands'; import { CommandError } from '../../src/core/commands/Command'; import { AddPortCommand, MovePortCommand } from '../../src/core/commands/portCommands'; import { SetStructTypeCommand, WithStructTypes } from '../../src/core/commands/structTypeCommands'; import { AddStructFieldCommand, AddVariableCommand, MoveStructFieldCommand, MoveVariableCommand, } from '../../src/core/commands/variableCommands'; import { BEHAVIOR_END, BEHAVIOR_PARAM, BEHAVIOR_START, GraphInterpreter, RUN_BEHAVIOR, resolveDef, } from '../../src/core/graph'; import type { BehaviorAsset, StructAsset } from '../../src/core/model/Asset'; import { graphLookup } from '../../src/core/model/graphLookup'; import type { StructDef } from '../../src/core/properties/PropertySchema'; import { fakeContext, fire, graphEditor, makeBehavior, makeProject, type ProjectFixture } from '../helpers'; const keys = (list: readonly { key: string }[] | undefined) => (list ?? []).map((item) => item.key); describe('reordering variables and struct fields', () => { it('moves a variable and leaves the values alone; undo moves it back', () => { const f = makeProject(); const behavior = makeBehavior(f, 'B', ['a', 'b', 'c']); const props = structuredClone(behavior.props); f.stack.execute(new MoveVariableCommand(behavior.id, 0, 2)); expect(keys(behavior.variables)).toEqual(['b', 'c', 'a']); expect(behavior.props).toEqual(props); f.stack.undo(); expect(keys(behavior.variables)).toEqual(['a', 'b', 'c']); }); it('refuses a place the list does not have', () => { const f = makeProject(); const behavior = makeBehavior(f, 'B', ['a', 'b']); expect(() => new MoveVariableCommand(behavior.id, 0, 2).execute(f.project)).toThrow(CommandError); expect(keys(behavior.variables)).toEqual(['a', 'b']); }); it('moves a field inside a struct inside a struct, the rest of the declaration as it was', () => { const f = makeProject(); const behavior = makeBehavior(f, 'B'); f.stack.execute(new AddVariableCommand(behavior.id, 'hero', 'struct')); for (const field of ['name', 'pos']) f.stack.execute( new AddStructFieldCommand( behavior.id, 'hero', field === 'pos' ? { key: field, kind: 'struct', fields: [] } : { key: field, kind: 'text' }, ), ); for (const field of ['x', 'y', 'z']) f.stack.execute( new AddStructFieldCommand(behavior.id, 'hero', { key: field, kind: 'number' }, ['pos']), ); const hero = () => behavior.variables[0] as StructDef; const pos = () => hero().fields.find((field) => field.key === 'pos') as StructDef; const value = structuredClone(behavior.props.hero); f.stack.execute(new MoveStructFieldCommand(behavior.id, 'hero', 2, 0, ['pos'])); expect(keys(pos().fields)).toEqual(['z', 'x', 'y']); expect(keys(hero().fields)).toEqual(['name', 'pos']); expect(behavior.props.hero).toEqual(value); f.stack.execute(new MoveStructFieldCommand(behavior.id, 'hero', 1, 0)); expect(keys(hero().fields)).toEqual(['pos', 'name']); f.stack.undo(); f.stack.undo(); expect(keys(hero().fields)).toEqual(['name', 'pos']); expect(keys(pos().fields)).toEqual(['x', 'y', 'z']); }); }); /** `Loot` = { Name: "Coin", Amount: 1 } */ function lootType(f: ProjectFixture): StructAsset { const create = new CreateAssetCommand('struct', 'Loot'); f.stack.execute(create); const id = create.asset.id; f.stack.execute(new AddVariableCommand(id, 'Name', 'text')); f.stack.execute(new AddVariableCommand(id, 'Amount', 'number')); f.stack.execute(new SetAssetPropertyCommand(id, 'Amount', 1)); return create.asset as StructAsset; } describe('reordering the fields of a Struct asset', () => { it('reaches every place of the type: typed variables and the ports of Pack, edges kept', () => { const f = makeProject(); const loot = lootType(f); const chest = makeBehavior(f, 'Chest'); f.stack.execute(new AddVariableCommand(chest.id, 'drop', 'struct')); f.stack.execute( new SetStructTypeCommand({ kind: 'variable', ownerId: chest.id, key: 'drop', path: [] }, loot.id), ); const g = graphEditor(f, chest); const pack = g.addNode('PackStruct'); f.stack.execute(new SetStructTypeCommand({ kind: 'node', assetId: chest.id, nodeId: pack.id }, loot.id)); const number = g.addNode('Number'); g.connect(number, 'value', pack, 'Amount'); const drop = () => chest.variables[0] as StructDef; f.stack.execute(new WithStructTypes(new MoveVariableCommand(loot.id, 1, 0))); expect(keys(loot.variables)).toEqual(['Amount', 'Name']); expect(keys(drop().fields)).toEqual(['Amount', 'Name']); expect(keys(pack.ports)).toEqual(['Amount', 'Name']); expect(chest.graph.edges.map((e) => `${e.from.port}>${e.to.port}`)).toEqual(['value>Amount']); expect(chest.props.drop).toEqual({ Name: '', Amount: 1 }); f.stack.undo(); expect(keys(drop().fields)).toEqual(['Name', 'Amount']); expect(keys(pack.ports)).toEqual(['Name', 'Amount']); }); }); /** Hands `a - b` back: two inputs whose order matters to the arithmetic, not to the wiring. */ function subtracter(f: ProjectFixture): BehaviorAsset { const asset = makeBehavior(f, 'Subtract'); const edit = graphEditor(f, asset); const start = edit.addNode(BEHAVIOR_START); const end = edit.addNode(BEHAVIOR_END); f.stack.execute(new AddPortCommand(asset.id, start.id, { key: 'a', kind: 'number' })); f.stack.execute(new AddPortCommand(asset.id, start.id, { key: 'b', kind: 'number' })); f.stack.execute(new AddPortCommand(asset.id, end.id, { key: 'out1', kind: 'number' })); const minus = edit.addNode('Subtract'); edit.wire([ [start, 'exec', end, 'exec'], [start, 'a', minus, 'a'], [start, 'b', minus, 'b'], [minus, 'result', end, 'out1'], ]); return asset; } describe('reordering the ports of a start node', () => { it('changes the order on the calling node and nothing else: edges, values and result stay', () => { const f = makeProject(); const callee = subtracter(f); const caller = makeBehavior(f, 'Caller', ['result']); const edit = graphEditor(f, caller); const onStart = edit.addNode('OnStart'); const call = edit.addNode(RUN_BEHAVIOR, { [BEHAVIOR_PARAM]: callee.id, a: 10, b: 3 }); const set = edit.variables(['result'], { write: true }); edit.wire([ [onStart, 'exec', call, 'exec'], [call, 'exec', set, 'exec'], [call, 'out1', set, 'result'], ]); const start = callee.graph.nodes.find((n) => n.type === BEHAVIOR_START)!; const edges = structuredClone(callee.graph.edges); const inputs = () => keys(resolveDef(call, caller.graph, graphLookup(f.project))!.inputs); const run = () => { const ctx = fakeContext({ variables: { result: 0 } }); fire(new GraphInterpreter(caller.graph, graphLookup(f.project)), 'OnStart', ctx); return ctx.variables.result; }; expect(inputs()).toEqual(['exec', 'a', 'b']); f.stack.execute(new MovePortCommand(callee.id, start.id, 1, 0)); expect(keys(start.ports)).toEqual(['b', 'a']); expect(inputs()).toEqual(['exec', 'b', 'a']); expect(callee.graph.edges).toEqual(edges); expect(call.params).toMatchObject({ a: 10, b: 3 }); expect(run()).toBe(7); f.stack.undo(); expect(inputs()).toEqual(['exec', 'a', 'b']); }); });