/** * Split outputs: a struct output shown as one output per field of a Struct asset. The fields follow * the asset like a typed `Expand` does, the node still produces the whole value, and reading a field * port picks the field out of it. */ import { describe, expect, it } from 'vitest'; import { CreateAssetCommand, DeleteAssetCommand, SetAssetPropertyCommand, } from '../../src/core/commands/assetCommands'; import { AddNodeCommand } from '../../src/core/commands/graphCommands'; import { SetListItemTypeCommand, SetStructTypeCommand, SplitOutputCommand, WithStructTypes, } from '../../src/core/commands/structTypeCommands'; import { AddVariableCommand, RemoveVariableCommand, RenameVariableCommand, } from '../../src/core/commands/variableCommands'; import { edgesOutOf, FIND, GraphInterpreter, type GraphNode, resolveDef } from '../../src/core/graph'; import type { BehaviorAsset, StructAsset } from '../../src/core/model/Asset'; import { graphLookup } from '../../src/core/model/graphLookup'; import { outputStructType, structTypeUsages } from '../../src/core/model/structTypes'; import { deserializeProject, serializeProject } from '../../src/core/store/serialize'; import { fakeContext, graphEditor, makeBehavior, makeProject, type ProjectFixture, fire } from '../helpers'; /** `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, 'Name', 'Coin')); return create.asset as StructAsset; } /** * A behavior with a list `bag` of `Loot` (typed unless told otherwise) and On start → Find over it, * the test `item == "Gem"` wired from the Find's `item` through an Equals into the Match. */ function finder(typed = true) { const f = makeProject(); const loot = lootType(f); const behavior = makeBehavior(f, 'Seeker'); f.stack.execute(new AddVariableCommand(behavior.id, 'bag', 'list')); if (typed) f.stack.execute(new SetListItemTypeCommand({ ownerId: behavior.id, key: 'bag', path: [] }, loot.id)); const edit = graphEditor(f, behavior); const add = new AddNodeCommand(behavior.id, FIND, 0, 0); f.stack.execute(add); const find = add.node; const match = add.closing!; const start = edit.addNode('OnStart'); const get = edit.variables(['bag']); const equals = edit.addNode('Equals', { b: 'Gem' }); edit.wire([ [start, 'exec', find, 'exec'], [get, 'bag', find, 'list'], [find, 'item', equals, 'a'], [equals, 'result', match, 'match'], ]); return { f, loot, behavior, edit, find, match, equals }; } const split = (f: ProjectFixture, b: BehaviorAsset, node: GraphNode, port: string, typeId: string) => f.stack.execute(new SplitOutputCommand(b.id, node.id, port, typeId)); const outputs = (f: ProjectFixture, b: BehaviorAsset, node: GraphNode) => resolveDef(node, b.graph, graphLookup(f.project))!.outputs.map((p) => `${p.key}:${p.type}`); describe('splitting an output', () => { it('knows the type an item of a typed list carries, at Find and at Match', () => { const { f, behavior, find, match, loot } = finder(); expect(outputStructType(f.project, behavior.graph, behavior.id, find, 'item')).toBe(loot.id); expect(outputStructType(f.project, behavior.graph, behavior.id, match, 'item')).toBe(loot.id); expect(outputStructType(f.project, behavior.graph, behavior.id, find, 'index')).toBe(''); }); it('knows nothing of an item of a mixed list', () => { const { f, behavior, find } = finder(false); expect(outputStructType(f.project, behavior.graph, behavior.id, find, 'item')).toBe(''); }); it('knows a struct variable declared with a type, and the value of a typed Pack', () => { const f = makeProject(); const loot = lootType(f); const behavior = makeBehavior(f); f.stack.execute(new AddVariableCommand(behavior.id, 'drop', 'struct')); const target = { kind: 'variable', ownerId: behavior.id, key: 'drop', path: [] } as const; f.stack.execute(new SetStructTypeCommand(target, loot.id)); const edit = graphEditor(f, behavior); const get = edit.variables(['drop']); const pack = edit.addNode('PackStruct', { structType: loot.id }); expect(outputStructType(f.project, behavior.graph, behavior.id, get, 'drop')).toBe(loot.id); expect(outputStructType(f.project, behavior.graph, behavior.id, pack, 'value')).toBe(loot.id); }); it('puts one port per field where the output stood, typed as the field, and drops its edges', () => { const { f, behavior, find, loot } = finder(); expect(outputs(f, behavior, find)).toEqual(['item:any', 'index:number']); split(f, behavior, find, 'item', loot.id); expect(outputs(f, behavior, find)).toEqual(['item.Name:string', 'item.Amount:number', 'index:number']); expect(edgesOutOf(behavior.graph, find.id, 'item')).toEqual([]); f.stack.undo(); expect(outputs(f, behavior, find)).toEqual(['item:any', 'index:number']); expect(edgesOutOf(behavior.graph, find.id, 'item')).toHaveLength(1); }); it('reads a field port as that field of the value the node produced', () => { const { f, behavior, edit, find, match, equals, loot } = finder(); split(f, behavior, find, 'item', loot.id); split(f, behavior, match, 'item', loot.id); const log = edit.addNode('Log'); edit.wire([ [find, 'item.Name', equals, 'a'], [match, 'found', log, 'exec'], [match, 'item.Amount', log, 'message'], ]); const bag = [ { Name: 'Coin', Amount: 1 }, { Name: 'Gem', Amount: 7 }, ]; const ctx = fakeContext({ variables: { bag } }); fire(new GraphInterpreter(behavior.graph), 'OnStart', ctx); expect(ctx.logs).toEqual(['7']); }); it('refuses an output that holds no struct, and one split already', () => { const { f, behavior, find, loot } = finder(); expect(() => split(f, behavior, find, 'index', loot.id)).toThrow('cannot be split'); split(f, behavior, find, 'item', loot.id); expect(() => split(f, behavior, find, 'item', loot.id)).toThrow('cannot be split'); }); it('recombines: the whole output comes back, the edges on its fields go', () => { const { f, behavior, find, equals, edit, loot } = finder(); split(f, behavior, find, 'item', loot.id); edit.connect(find, 'item.Name', equals, 'a'); split(f, behavior, find, 'item', ''); expect(find.splits).toBeUndefined(); expect(outputs(f, behavior, find)).toEqual(['item:any', 'index:number']); expect(behavior.graph.edges.some((e) => e.from.port.startsWith('item.'))).toBe(false); f.stack.undo(); expect(edgesOutOf(behavior.graph, find.id, 'item.Name')).toHaveLength(1); }); }); describe('a split output following its Struct asset', () => { it('moves the edge of a renamed field to the new field port, in one undo step', () => { const { f, behavior, find, equals, edit, loot } = finder(); split(f, behavior, find, 'item', loot.id); edit.connect(find, 'item.Name', equals, 'a'); f.stack.execute( new WithStructTypes(new RenameVariableCommand(loot.id, 'Name', 'Title'), { structType: loot.id, path: [], from: 'Name', to: 'Title', }), ); expect(outputs(f, behavior, find)).toEqual(['item.Title:string', 'item.Amount:number', 'index:number']); expect(edgesOutOf(behavior.graph, find.id, 'item.Title')).toHaveLength(1); f.stack.undo(); expect(edgesOutOf(behavior.graph, find.id, 'item.Name')).toHaveLength(1); expect(outputs(f, behavior, find)[0]).toBe('item.Name:string'); }); it('gets a new field and loses a removed one together with its edge', () => { const { f, behavior, find, equals, edit, loot } = finder(); split(f, behavior, find, 'item', loot.id); edit.connect(find, 'item.Name', equals, 'a'); f.stack.execute(new WithStructTypes(new AddVariableCommand(loot.id, 'Rare', 'boolean'))); expect(outputs(f, behavior, find)).toContain('item.Rare:boolean'); f.stack.execute(new WithStructTypes(new RemoveVariableCommand(loot.id, 'Name'))); expect(outputs(f, behavior, find)).toEqual(['item.Amount:number', 'item.Rare:boolean', 'index:number']); expect(behavior.graph.edges.some((e) => e.from.port === 'item.Name')).toBe(false); }); it('keeps its last fields when the type is deleted, and the delete dialog counts it', () => { const { f, behavior, find, loot } = finder(); split(f, behavior, find, 'item', loot.id); expect(structTypeUsages(f.project, loot.id).nodes).toBe(1); f.stack.execute(new DeleteAssetCommand(loot.id)); expect(outputs(f, behavior, find)).toEqual(['item.Name:string', 'item.Amount:number', 'index:number']); }); it('survives saving and loading', () => { const { f, find, loot } = finder(); const behavior = f.project.assets.find((a) => a.name === 'Seeker') as BehaviorAsset; split(f, behavior, find, 'item', loot.id); const loaded = deserializeProject(serializeProject(f.project)); const again = loaded.assets.find((a) => a.id === behavior.id) as BehaviorAsset; expect(again.graph.nodes.find((n) => n.id === find.id)?.splits).toEqual(find.splits); }); it('follows its variable when that is renamed, field edges included', () => { const f = makeProject(); const loot = lootType(f); const behavior = makeBehavior(f); f.stack.execute(new AddVariableCommand(behavior.id, 'drop', 'struct')); const edit = graphEditor(f, behavior); const get = edit.variables(['drop']); const log = edit.addNode('Log'); split(f, behavior, get, 'drop', loot.id); edit.connect(get, 'drop.Name', log, 'message'); f.stack.execute(new RenameVariableCommand(behavior.id, 'drop', 'loot')); expect(get.splits?.[0]?.port).toBe('loot'); expect(edgesOutOf(behavior.graph, get.id, 'loot.Name')).toHaveLength(1); f.stack.undo(); expect(get.splits?.[0]?.port).toBe('drop'); expect(edgesOutOf(behavior.graph, get.id, 'drop.Name')).toHaveLength(1); }); });