/** * A derived port list can lose a key without anything touching the edges: a state deleted in a * machine, the `machine` edge cut, a terminal node or a whole asset removed. Such an edge draws as * nothing yet survives every save. `drawnDef` brings the key back as a ghost port so the canvas can * draw the edge and offer its right-click menu; `ghostProblem` marks the node. `resolveDef` stays * the truth – the interpreter, the compilers and ConnectCommand read it, and it never sees a ghost. */ import { describe, expect, it } from 'vitest'; import { CreateAssetCommand, DeleteAssetCommand } from '../../src/core/commands/assetCommands'; import { AddNodeCommand, ConnectCommand, RemoveNodeCommand, SetNodeParamCommand, } from '../../src/core/commands/graphCommands'; import { AddPortCommand } from '../../src/core/commands/portCommands'; import { CommandError } from '../../src/core/commands/Command'; import { BEHAVIOR_END, BEHAVIOR_PARAM, BEHAVIOR_START, drawnDef, ghostProblem, MACHINE_PORT, resolveDef, RUN_BEHAVIOR, STATE, STATE_NAME_PARAM, } from '../../src/core/graph'; import type { BehaviorAsset } from '../../src/core/model/Asset'; import { graphLookup } from '../../src/core/model/graphLookup'; import { graphEditor, makeBehavior, makeProject, type ProjectFixture } from '../helpers'; /** A machine "Doors" with the states Open and Closed, added the way the tab does it. */ function makeMachine(fixture: ProjectFixture, names = ['Open', 'Closed']) { const create = new CreateAssetCommand('statemachine', 'Doors'); fixture.stack.execute(create); const ids = names.map((name) => { const add = new AddNodeCommand(create.asset.id, STATE, 0, 0, { [STATE_NAME_PARAM]: name }); fixture.stack.execute(add); return add.node.id; }); return { id: create.asset.id, ids }; } function setup() { const f = makeProject(); const behavior = makeBehavior(f, 'B'); const edit = graphEditor(f, behavior); const seen = (asset: BehaviorAsset, id: string) => { const node = asset.graph.nodes.find((n) => n.id === id)!; const real = resolveDef(node, asset.graph, graphLookup(f.project))!; return { real, drawn: drawnDef(node, asset.graph, real), problem: ghostProblem(node, asset.graph, real), }; }; const ghosts = (asset: BehaviorAsset, id: string, side: 'inputs' | 'outputs') => seen(asset, id) .drawn[side].filter((p) => p.ghost) .map((p) => p.key); return { f, behavior, edit, seen, ghosts }; } describe('an edge on a port that is gone', () => { it('is no ghost while every edge names a port that exists', () => { const { f, behavior, edit, seen, ghosts } = setup(); const machine = makeMachine(f); const start = edit.addNode('OnStart'); const set = edit.addNode('SetState', { [MACHINE_PORT]: machine.id }); edit.connect(start, 'exec', set, machine.ids[0]!); expect(ghosts(behavior, set.id, 'inputs')).toEqual([]); expect(seen(behavior, set.id).problem).toBeNull(); }); it('comes back as a ghost when the state it names is deleted in the machine', () => { const { f, behavior, edit, seen, ghosts } = setup(); const machine = makeMachine(f); const start = edit.addNode('OnStart'); const set = edit.addNode('SetState', { [MACHINE_PORT]: machine.id }); edit.connect(start, 'exec', set, machine.ids[0]!); // Deleting the state reaches only the machine's own graph – the behavior's edge stays behind. f.stack.execute(new RemoveNodeCommand(machine.id, machine.ids[0]!)); expect(behavior.graph.edges).toHaveLength(1); expect(ghosts(behavior, set.id, 'inputs')).toEqual([machine.ids[0]!]); expect(seen(behavior, set.id).problem).toBe('1 edge on a port that is gone'); f.stack.undo(); expect(ghosts(behavior, set.id, 'inputs')).toEqual([]); }); it('appears once per state when the State machine literal feeding the input is deleted', () => { const { f, behavior, edit, seen, ghosts } = setup(); const machine = makeMachine(f); const literal = edit.addNode('StateMachine', { value: machine.id }); const branch = edit.addNode('StateBranch'); edit.connect(literal, 'value', branch, MACHINE_PORT); const log = edit.addNode('Log', { message: 'x' }); const other = edit.addNode('Log', { message: 'y' }); edit.connect(branch, machine.ids[0]!, log, 'exec'); edit.connect(branch, machine.ids[1]!, other, 'exec'); // The literal takes its own edge with it; the two exec edges out of the branch it cannot see. f.stack.execute(new RemoveNodeCommand(behavior.id, literal.id)); expect(ghosts(behavior, branch.id, 'outputs')).toEqual(machine.ids); expect(seen(behavior, branch.id).problem).toBe('2 edges on ports that are gone'); }); it('appears on every caller when a behavior loses its start node', () => { const f = makeProject(); const callee = makeBehavior(f, 'Double'); const calleeEdit = graphEditor(f, callee); const start = calleeEdit.addNode(BEHAVIOR_START); const end = calleeEdit.addNode(BEHAVIOR_END); f.stack.execute(new AddPortCommand(callee.id, start.id, { key: 'in1', kind: 'number' })); f.stack.execute(new AddPortCommand(callee.id, end.id, { key: 'out1', kind: 'number' })); const caller = makeBehavior(f, 'Caller'); const edit = graphEditor(f, caller); const call = edit.addNode(RUN_BEHAVIOR, { [BEHAVIOR_PARAM]: callee.id }); const number = edit.addNode('Number', { value: 3 }); edit.connect(number, 'value', call, 'in1'); const node = () => caller.graph.nodes.find((n) => n.id === call.id)!; const real = () => resolveDef(node(), caller.graph, graphLookup(f.project))!; expect(drawnDef(node(), caller.graph, real()).inputs.filter((p) => p.ghost)).toHaveLength(0); f.stack.execute(new RemoveNodeCommand(callee.id, start.id)); expect( drawnDef(node(), caller.graph, real()) .inputs.filter((p) => p.ghost) .map((p) => p.key), ).toEqual(['in1']); }); it('appears when the machine asset itself is deleted', () => { const { f, behavior, edit, ghosts } = setup(); const machine = makeMachine(f); const start = edit.addNode('OnStart'); const set = edit.addNode('SetState', { [MACHINE_PORT]: machine.id }); edit.connect(start, 'exec', set, machine.ids[0]!); f.stack.execute(new DeleteAssetCommand(machine.id)); expect(ghosts(behavior, set.id, 'inputs')).toEqual([machine.ids[0]!]); }); it('goes last, so no real port row moves because of it', () => { const { f, behavior, edit, seen } = setup(); const machine = makeMachine(f); const start = edit.addNode('OnStart'); const set = edit.addNode('SetState', { [MACHINE_PORT]: machine.id }); edit.connect(start, 'exec', set, machine.ids[0]!); f.stack.execute(new RemoveNodeCommand(machine.id, machine.ids[0]!)); const { real, drawn } = seen(behavior, set.id); const keys = drawn.inputs.map((p) => p.key); expect(keys.slice(0, real.inputs.length)).toEqual(real.inputs.map((p) => p.key)); expect(keys.at(-1)).toBe(machine.ids[0]!); }); it('is a handle only: resolveDef has no ghost, so nothing can be connected to one', () => { const { f, behavior, edit, seen } = setup(); const machine = makeMachine(f); const start = edit.addNode('OnStart'); const set = edit.addNode('SetState', { [MACHINE_PORT]: machine.id }); edit.connect(start, 'exec', set, machine.ids[0]!); f.stack.execute(new RemoveNodeCommand(machine.id, machine.ids[0]!)); const { real } = seen(behavior, set.id); expect(real.inputs.some((p) => p.key === machine.ids[0]!)).toBe(false); const second = edit.addNode('OnUpdate'); expect(() => f.stack.execute( new ConnectCommand( behavior.id, { node: second.id, port: 'exec' }, { node: set.id, port: machine.ids[0]! }, ), ), ).toThrow(CommandError); }); it('survives a param change that orphans the edge, until that command takes it', () => { const { f, behavior, edit, seen, ghosts } = setup(); const machine = makeMachine(f); const start = edit.addNode('OnStart'); const set = edit.addNode('SetState', { [MACHINE_PORT]: machine.id }); edit.connect(start, 'exec', set, machine.ids[0]!); // SetNodeParamCommand is the one path that already cleans up: no edge left, so no ghost. f.stack.execute(new SetNodeParamCommand(behavior.id, set.id, MACHINE_PORT, '')); expect(behavior.graph.edges).toHaveLength(0); expect(ghosts(behavior, set.id, 'inputs')).toEqual([]); f.stack.undo(); expect(seen(behavior, set.id).problem).toBeNull(); }); });