Files
feuerwerk/tests/core/ghost-ports.test.ts
T
adminandClaude Opus 5 d7a2521ca7
game-maker / gate (push) Canceled after 1m8s
game-maker / suite (push) Canceled after 1m8s
A port can take its type from what is plugged into it (#207)
Until now every port of "First not empty" was grey and typeless, so the
editor could not tell you what actually travels through the node. The
output is now typed as soon as both inputs agree, and every any-input is
painted in the colour of its own edge. Nothing changes at runtime.

- A NodeDef can declare which of its ports carry the same value
  (`sharedType: { inputs, outputs }`). The rule itself lives as a pure
  function `agreedType` in `src/core/graph/portTypes.ts`.
- Only the output inherits. It becomes the shared type when every input
  of the group is connected and they all carry the same type, otherwise
  it stays `any`.
- The inputs stay `any` on purpose. `canConnect` checks against the port
  type and a busy input is replaced by the new edge, so a typed `a`
  could never be re-plugged with a text without disconnecting first.
- Chains do not propagate. When looking up what feeds an input, a node
  of the same kind reports the `any` it is registered with, so two of
  them in a row stay untyped and a data cycle cannot ask itself in a
  circle.
- An edge that the new type makes invalid stays where it is and turns
  red. `EdgeLayer` already paints a mismatching edge with `danger`,
  nothing is disconnected on its own.
- New `PortDef.paintType`: colour only, never truth. It is set in
  `drawnDef` and read in `NodeView.redraw`, so `canConnect`, the
  interpreter and the compiler keep seeing `any`. Every connected
  any-input is painted, not just members of a `sharedType` group; ghost
  ports keep their hollow red look.
- The pin now matches the edge that ends in it, because both take the
  colour from the resolved output type of the neighbour. Mixed sources
  therefore show two colours, which explains why the output stays grey.
- New hooks `__gm.graph.portTypes(id)`, `__gm.graph.edgeColor(id)` and
  `__gm.themeColor(name)`. `portTypes` reports type and paint colour
  separately so the type checks do not silently become colour checks.

Findings:

- `resolveDef` only appends derived ports to the registered ones, so a
  fixed port cannot be retyped that way -- it would appear twice. The
  shared type is therefore resolved before the ports are built.
- Answer 5 of the issue (inline field follows the type) has no case left
  under variant A: an unconnected input stays `any` and keeps its text
  field.
- `Item at`, `Join` and `Equals` do not profit: `Item at` has no
  any-input, the other two have fixed outputs. The next candidates are
  the shader arithmetic nodes, which were left alone.

Tests:

- ./check.sh -> 143 files, 2127 tests, exit 0 (was 142 / 2113)
- GM_STEPS=1-6 fresh -> ALL PASSED, 245 checks (was 242), 9 of them new
- 14 new unit tests in tests/core/shared-port-type.test.ts
- 9 new e2e checks in block 6z, one of them a pixel check straight on
  the pin via pixelAt + themeColor
- Counter-probes all bite: filtering unconnected inputs out of
  `agreedType` fails 3 unit tests, ignoring `inherit` fails 1, killing
  the derivation in the bundle fails exactly the 2 expected e2e checks,
  and never setting `paintType` fails 4 unit tests plus the 3 pin
  checks (#bdbdbd instead of #e6a8ff / #6fcf97)
- Screenshot read: `a` pink, `b` green, output `value` grey, edges in
  the same colours
- Check total 1302 (1299 + 3), 1293 was measured by CI at 1a3babf

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-22 21:15:21 +02:00

192 lines
8.6 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, graphLookup(f.project)),
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(), graphLookup(f.project)).inputs.filter((p) => p.ghost),
).toHaveLength(0);
f.stack.execute(new RemoveNodeCommand(callee.id, start.id));
expect(
drawnDef(node(), caller.graph, real(), graphLookup(f.project))
.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();
});
});