Files
feuerwerk/tests/core/ghost-ports.test.ts
T
root a041322a0e
game-maker / suite (push) Successful in 10m7s
An edge to a vanished port needs a handle, so the port stays as a ghost (#60)
Four ways erase a derived port and take no edge with them: deleting a state,
deleting the `State machine` literal, deleting `Behavior start`/`end`, deleting
the asset. The common cause is `RemoveNodeCommand` clearing only its own graph,
so the fix sits in `drawnDef` (graph/nested.ts) and touches no delete site.

A right-click on an edge goes through `view.portAt` only — there is no curve
hit-test. Without a port row such an edge is not merely invisible, it is
unreachable, so the ghost row is the handle Disconnect hangs on.

`resolveDef` stays untouched: it is the truth for interpreter, compiler,
ConnectCommand and dropTarget, so nothing runs or connects through a ghost
without a second guard.
2026-09-14 11:50:36 +02:00

190 lines
8.5 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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();
});
});