import { describe, expect, it } from 'vitest'; import { isSongRoutingDef } from '../../src/core/music/routingNodes'; import { requireNodeDef } from '../../src/core/commands/require'; import { CommandError } from '../../src/core/commands/Command'; import { AddNodeCommand, DisconnectCommand, RemoveNodeCommand, SetNodeParamCommand, } from '../../src/core/commands/graphCommands'; import { AddVariableCommand, RemoveVariableCommand, RenameVariableCommand, } from '../../src/core/commands/variableCommands'; import { GraphInterpreter, allNodeDefs, defAcceptsFrom, dropTargetPort, findNode, firstPortFor, NO_LOOKUP, type PortType, portsCompatible, } from '../../src/core/graph'; import type { BehaviorAsset } from '../../src/core/model/Asset'; import type { PropertyValue } from '../../src/core/model/Properties'; import { findAsset } from '../../src/core/model/Project'; import { deserializeProject, serializeProject } from '../../src/core/store/serialize'; import { type ProjectFixture, fakeContext, fakeEntity, graphEditor, makeBehavior, makeProject, makeSpriteScene, place, roundTrip, fire, } from '../helpers'; /** Adds "On key held(ArrowRight) → Move(owner, dx = speed × dt)" to a project, built through commands like the editor would. */ function withMover(fixture: F) { const behavior = makeBehavior(fixture, 'Mover', ['speed']); const edit = graphEditor(fixture, behavior); const held = edit.addNode('OnKeyHeld', { key: 'ArrowRight' }); const owner = edit.addNode('Owner'); const speed = edit.variables(['speed']); const mul = edit.addNode('Multiply'); const move = edit.addNode('Move'); edit.wire([ [held, 'exec', move, 'exec'], [owner, 'object', move, 'object'], [speed, 'speed', mul, 'a'], [held, 'dt', mul, 'b'], [mul, 'result', move, 'dx'], ]); return { ...fixture, behavior, edit, nodes: { held, owner, speed, mul, move } }; } /** A behavior "B" with the given number variables and an empty graph to edit. */ function emptyBehavior(variables: string[] = []) { const fixture = makeProject(); const behavior = makeBehavior(fixture, 'B', variables); return { ...fixture, behavior, edit: graphEditor(fixture, behavior) }; } describe('node registry', () => { it('registers the built-in node set with well-formed ports', () => { const defs = allNodeDefs(); expect(defs.length).toBeGreaterThan(20); for (const def of defs) { const keys = [...def.inputs, ...def.outputs].map((p) => p.key); expect(new Set(def.inputs.map((p) => p.key)).size).toBe(def.inputs.length); expect(new Set(def.outputs.map((p) => p.key)).size).toBe(def.outputs.length); // An event is seeded by the runtime, so no flow arrives at one. What it may take is a data // input that says which event it means – the machine `On state entered` watches. if (def.category === 'event') expect(def.inputs.filter((p) => p.type === 'exec')).toHaveLength(0); // A state is a name in a machine; its ports only anchor transitions, so it never runs. else if (def.category === 'state') expect(def.run).toBeUndefined(); // A shader node is compiled to GLSL, a particle node to a function, a sound FX node to a // stage of a voice, an instrument's own node to part of a note and a node of a song's routing // to its buses (#350): none is ever run. else if (def.category === 'shader' || def.category === 'particles' || def.category === 'sound') expect('emit' in def || 'instrument' in def || isSongRoutingDef(def)).toBe(true); // The end of a block runs as part of the node that opens it (`Find` … `Match`). else if (defs.some((d) => d.opens?.type === def.type)) expect(def.run).toBeUndefined(); // Everything else does one of the two: a step (`run`), or an answer whenever asked (`read`). else expect(typeof (def.run ?? def.read)).toBe('function'); expect(keys.every((k) => k.length > 0)).toBe(true); } }); it('type-checks port connections', () => { expect(portsCompatible('exec', 'exec')).toBe(true); expect(portsCompatible('exec', 'number')).toBe(false); expect(portsCompatible('number', 'any')).toBe(true); expect(portsCompatible('any', 'boolean')).toBe(true); expect(portsCompatible('number', 'boolean')).toBe(false); expect(portsCompatible('object', 'object')).toBe(true); }); }); describe('GraphInterpreter', () => { it('runs an event pulse and pulls data lazily (speed × dt moves the owner)', () => { const { behavior } = withMover(makeProject()); const interpreter = new GraphInterpreter(behavior.graph); const ctx = fakeContext({ variables: { speed: 100 } }); const fired = fire(interpreter, 'OnKeyHeld', ctx, { dt: 0.5 }, (n) => n.params.key === 'ArrowRight'); expect(fired).toBe(1); expect(ctx.owner!.x).toBe(50); expect(fire(interpreter, 'OnKeyHeld', ctx, { dt: 0.5 }, (n) => n.params.key === 'ArrowLeft')).toBe(0); expect(ctx.owner!.x).toBe(50); }); it('uses inline values for unconnected inputs', () => { const { behavior, edit } = emptyBehavior(); const start = edit.addNode('OnStart'); const self = edit.addNode('Self'); const move = edit.addNode('Move', { dx: 7, dy: -3 }); edit.wire([ [start, 'exec', move, 'exec'], [self, 'object', move, 'object'], ]); const ctx = fakeContext(); fire(new GraphInterpreter(behavior.graph), 'OnStart', ctx); expect(ctx.self.x).toBe(7); expect(ctx.self.y).toBe(-3); }); it('uses port defaults for unconnected inputs (Move scale = 1, Clamp 0…1)', () => { const { behavior, edit } = emptyBehavior(); const start = edit.addNode('OnStart'); const self = edit.addNode('Self'); const clamp = edit.addNode('Clamp', { value: 5 }); const move = edit.addNode('Move', { dx: 4 }); edit.wire([ [start, 'exec', move, 'exec'], [self, 'object', move, 'object'], [clamp, 'result', move, 'dy'], ]); const ctx = fakeContext(); fire(new GraphInterpreter(behavior.graph), 'OnStart', ctx); expect(ctx.self.x).toBe(4); // scale defaulted to 1, not 0 expect(ctx.self.y).toBe(1); // clamp(5, 0, 1) }); it('five-node mover: Axis input → Move(dt from On update) moves speed × dt, diagonals normalized', () => { const { behavior, edit } = emptyBehavior(['speed']); const update = edit.addNode('OnUpdate'); const owner = edit.addNode('Owner'); const axis = edit.addNode('AxisInput'); const speed = edit.variables(['speed']); const move = edit.addNode('Move'); edit.wire([ [update, 'exec', move, 'exec'], [update, 'dt', move, 'dt'], [owner, 'object', move, 'object'], [axis, 'x', move, 'dx'], [axis, 'y', move, 'dy'], [speed, 'speed', move, 'scale'], ]); expect(behavior.graph.nodes).toHaveLength(5); const interpreter = new GraphInterpreter(behavior.graph); const run = (keys: string[]) => { const ctx = fakeContext({ variables: { speed: 100 }, dt: 0.5, isKeyDown: (k) => keys.includes(k) }); fire(interpreter, 'OnUpdate', ctx, { dt: 0.5 }); return { x: ctx.owner!.x, y: ctx.owner!.y }; }; expect(run([])).toEqual({ x: 0, y: 0 }); expect(run(['ArrowRight'])).toEqual({ x: 50, y: 0 }); expect(run(['ArrowLeft'])).toEqual({ x: -50, y: 0 }); expect(run(['ArrowUp'])).toEqual({ x: 0, y: -50 }); const diagonal = run(['ArrowLeft', 'ArrowUp']); expect(Math.hypot(diagonal.x, diagonal.y)).toBeCloseTo(50, 6); // same distance as a straight move expect(diagonal.x).toBeCloseTo(-50 / Math.SQRT2, 6); expect(diagonal.y).toBeCloseTo(-50 / Math.SQRT2, 6); expect(run(['ArrowLeft', 'ArrowRight'])).toEqual({ x: 0, y: 0 }); // opposite keys cancel }); it('vector and math helpers', () => { const { behavior, edit } = emptyBehavior(); const start = edit.addNode('OnStart'); const sources = [ edit.addNode('Normalize', { x: 3, y: 4 }), edit.addNode('Length', { x: 3, y: 4 }), edit.addNode('Sqrt', { value: 16 }), edit.addNode('Abs', { value: -2 }), edit.addNode('Min', { a: 3, b: -1 }), edit.addNode('Max', { a: 3, b: -1 }), ]; const ports = ['x', 'result', 'result', 'result', 'result', 'result']; // One Log per source, chained after On start. let previous = start; sources.forEach((source, i) => { const log = edit.addNode('Log'); edit.wire([ [previous, 'exec', log, 'exec'], [source, ports[i]!, log, 'message'], ]); previous = log; }); const ctx = fakeContext(); fire(new GraphInterpreter(behavior.graph), 'OnStart', ctx); expect(ctx.logs).toEqual(['0.6', '5', '4', '2', '-1', '3']); }); it('follows Branch, writes variables and logs', () => { const { behavior, edit } = emptyBehavior(['count']); const start = edit.addNode('OnStart'); const get = edit.variables(['count']); const cmp = edit.addNode('Compare', { op: '<', b: 3 }); const branch = edit.addNode('Branch'); const add = edit.addNode('Add', { b: 1 }); const set = edit.variables(['count'], { write: true }); const logThen = edit.addNode('Log', { message: 'then' }); const logElse = edit.addNode('Log', { message: 'else' }); edit.wire([ [start, 'exec', branch, 'exec'], [get, 'count', cmp, 'a'], [cmp, 'result', branch, 'condition'], [branch, 'then', set, 'exec'], [get, 'count', add, 'a'], [add, 'result', set, 'count'], [set, 'exec', logThen, 'exec'], [branch, 'else', logElse, 'exec'], ]); const interpreter = new GraphInterpreter(behavior.graph); const ctx = fakeContext({ variables: { count: 0 } }); for (let i = 0; i < 5; i++) fire(interpreter, 'OnStart', ctx); expect(ctx.variables.count).toBe(3); expect(ctx.logs).toEqual(['then', 'then', 'then', 'else', 'else']); }); it('reports traversed edges and nodes to the trace listener', () => { const { behavior, nodes } = withMover(makeProject()); const interpreter = new GraphInterpreter(behavior.graph); const edges: string[] = []; const visited: string[] = []; interpreter.trace = { onEdge: (id) => edges.push(id), onNode: (id) => visited.push(id) }; fire(interpreter, 'OnKeyHeld', fakeContext(), { dt: 1 }); expect(edges).toHaveLength(1); expect(visited).toContain(nodes.mul.id); expect(visited).toContain(nodes.move.id); }); it('stops a runaway flow instead of hanging', () => { const { behavior, edit } = emptyBehavior(); const start = edit.addNode('OnStart'); const a = edit.addNode('Sequence'); const b = edit.addNode('Sequence'); edit.wire([ [start, 'exec', a, 'exec'], [a, 'a', b, 'exec'], [b, 'a', a, 'exec'], ]); expect(() => fire(new GraphInterpreter(behavior.graph), 'OnStart', fakeContext())).toThrow( /nested too deeply/, ); }); it('stops a loop that would run forever', () => { const { behavior, edit } = emptyBehavior(['count']); const start = edit.addNode('OnStart'); const repeat = edit.addNode('Repeat', { times: 10_000_000 }); const log = edit.addNode('Log', { message: 'x' }); edit.wire([ [start, 'exec', repeat, 'exec'], [repeat, 'body', log, 'exec'], ]); expect(() => fire(new GraphInterpreter(behavior.graph), 'OnStart', fakeContext())).toThrow(/step limit/); }); }); describe('Object by name', () => { /** Both graphs below name "Target"; only the way the name reaches the node differs. */ function lookup(nodeParams: Record, wireName: boolean) { const { behavior, edit } = emptyBehavior(); const start = edit.addNode('OnStart'); const byName = edit.addNode('ObjectByName', nodeParams); const log = edit.addNode('Log'); edit.wire([ [start, 'exec', log, 'exec'], [byName, 'object', log, 'message'], ]); if (wireName) { const join = edit.addNode('Join', { a: 'Tar', b: 'get' }); edit.wire([[join, 'text', byName, 'name']]); } const ctx = fakeContext({ findByName: (name) => (name === 'Target' ? fakeEntity('Target') : null) }); fire(new GraphInterpreter(behavior.graph), 'OnStart', ctx); return ctx.logs; } it('still reads a name stored as a node setting, the way every saved project holds it', () => { expect(lookup({ name: 'Target' }, false)).toEqual(['Target']); }); it('takes a name put together at runtime', () => { expect(lookup({}, true)).toEqual(['Target']); }); }); /** * A loop is the one place where the per-pulse data cache would lie: every round has to read its * inputs anew, or the body would keep computing with the values of the first round. */ describe('Repeat', () => { /** "count = count + index" in the body of a Repeat, so the sum proves each round saw its own index. */ function summingLoop(times: number) { const fixture = emptyBehavior(['count']); const { edit } = fixture; const start = edit.addNode('OnStart'); const repeat = edit.addNode('Repeat', { times }); const get = edit.variables(['count']); const add = edit.addNode('Add'); const set = edit.variables(['count'], { write: true }); edit.wire([ [start, 'exec', repeat, 'exec'], [repeat, 'body', set, 'exec'], [get, 'count', add, 'a'], [repeat, 'index', add, 'b'], [add, 'result', set, 'count'], ]); return { ...fixture, nodes: { repeat, set } }; } it('runs the body once per round, each round seeing its own index', () => { const { behavior } = summingLoop(5); const ctx = fakeContext({ variables: { count: 0 } }); fire(new GraphInterpreter(behavior.graph), 'OnStart', ctx); // 0+1+2+3+4. A stale data cache would add the first round's index five times (0) instead. expect(ctx.variables.count).toBe(10); }); it('runs nothing at all for a count of zero', () => { const { behavior } = summingLoop(0); const ctx = fakeContext({ variables: { count: 7 } }); fire(new GraphInterpreter(behavior.graph), 'OnStart', ctx); expect(ctx.variables.count).toBe(7); }); it('continues along done when the rounds are over', () => { const { behavior, edit, nodes } = summingLoop(3); const done = edit.addNode('Log', { message: 'done' }); edit.wire([[nodes.repeat, 'done', done, 'exec']]); const ctx = fakeContext({ variables: { count: 0 } }); fire(new GraphInterpreter(behavior.graph), 'OnStart', ctx); expect({ count: ctx.variables.count, logs: ctx.logs }).toEqual({ count: 3, logs: ['done'] }); }); it('nests: the inner loop starts over for every round of the outer one', () => { const { behavior, edit } = emptyBehavior(['count']); const start = edit.addNode('OnStart'); const outer = edit.addNode('Repeat', { times: 3 }); const inner = edit.addNode('Repeat', { times: 4 }); const get = edit.variables(['count']); const add = edit.addNode('Add', { b: 1 }); const set = edit.variables(['count'], { write: true }); edit.wire([ [start, 'exec', outer, 'exec'], [outer, 'body', inner, 'exec'], [inner, 'body', set, 'exec'], [get, 'count', add, 'a'], [add, 'result', set, 'count'], ]); const ctx = fakeContext({ variables: { count: 0 } }); fire(new GraphInterpreter(behavior.graph), 'OnStart', ctx); expect(ctx.variables.count).toBe(12); }); it('leaves the world alone after the loop: what follows reads current values, not the last round', () => { const { behavior, edit } = emptyBehavior(['count']); const start = edit.addNode('OnStart'); const repeat = edit.addNode('Repeat', { times: 3 }); const get = edit.variables(['count']); const add = edit.addNode('Add', { b: 1 }); const set = edit.variables(['count'], { write: true }); const after = edit.variables(['count'], { write: true }); const afterAdd = edit.addNode('Multiply', { b: 10 }); edit.wire([ [start, 'exec', repeat, 'exec'], [repeat, 'body', set, 'exec'], [get, 'count', add, 'a'], [add, 'result', set, 'count'], [repeat, 'done', after, 'exec'], [get, 'count', afterAdd, 'a'], [afterAdd, 'result', after, 'count'], ]); const ctx = fakeContext({ variables: { count: 0 } }); fire(new GraphInterpreter(behavior.graph), 'OnStart', ctx); // 3 rounds, then ×10 on the *current* count – a cache left over from the last round would give 20. expect(ctx.variables.count).toBe(30); }); }); /** * `For each` over the list variable `names`, "break" = (item == wanted). The body logs the item it * runs for, done logs item, index and stopped run together, so the logs show every round and where the loop ended. */ describe('For each', () => { function search(names: readonly PropertyValue[], wanted: string | null) { const fixture = emptyBehavior(); const { edit, behavior, stack } = fixture; stack.execute(new AddVariableCommand(behavior.id, 'names', 'list')); const start = edit.addNode('OnStart'); const each = edit.addNode('ForEach'); const get = edit.variables(['names']); const body = edit.addNode('Log'); const done = edit.addNode('Log'); const line = edit.addNode('Join'); const tail = edit.addNode('Join'); edit.wire([ [start, 'exec', each, 'exec'], [get, 'names', each, 'list'], [each, 'body', body, 'exec'], [each, 'item', body, 'message'], [each, 'done', done, 'exec'], [each, 'item', line, 'a'], [each, 'index', tail, 'a'], [each, 'stopped', tail, 'b'], [tail, 'text', line, 'b'], [line, 'text', done, 'message'], ]); if (wanted !== null) { const equals = edit.addNode('Equals', { b: wanted }); edit.wire([ [each, 'item', equals, 'a'], [equals, 'result', each, 'break'], ]); } const ctx = fakeContext({ variables: { names: [...names] } }); fire(new GraphInterpreter(behavior.graph), 'OnStart', ctx); return ctx.logs; } it('runs the body once per element, each round seeing its own item', () => { expect(search(['a', 'b', 'c'], null)).toEqual(['a', 'b', 'c', '-1false']); }); it('stops at the first element the break is true for, and done sees that one', () => { // The body of the matching round does not run: "b" is logged by done only. expect(search(['a', 'b', 'c', 'b'], 'b')).toEqual(['a', 'b1true']); }); it('reads the break anew every round: it is true for the element at hand, not for the first one', () => { expect(search(['a', 'b', 'c'], 'c')).toEqual(['a', 'b', 'c2true']); }); it('without a match runs every round and done sees no item, index -1', () => { expect(search(['a', 'b'], 'x')).toEqual(['a', 'b', '-1false']); }); it('runs nothing for an empty list and goes straight to done', () => { expect(search([], 'a')).toEqual(['-1false']); }); }); describe('graph commands', () => { it('rejects incompatible, self and duplicate connections', () => { const { edit, nodes: { held, mul, move }, } = withMover(makeProject()); expect(() => edit.connect(held, 'exec', mul, 'a')).toThrow(/Cannot connect/); expect(() => edit.connect(mul, 'result', mul, 'a')).toThrow(/itself/); expect(() => edit.connect(held, 'exec', move, 'exec')).toThrow(/Already/); expect(() => edit.connect(held, 'nope', move, 'exec')).toThrow(/Port/); }); it('replaces the edge of a data input and restores it on undo', () => { const { behavior, stack, edit, nodes } = withMover(makeProject()); const literal = edit.addNode('Number', { value: 9 }); const before = behavior.graph.edges.length; edit.connect(literal, 'value', nodes.move, 'dx'); expect(behavior.graph.edges.length).toBe(before); // one replaced, one added const sourceOfDx = () => behavior.graph.edges.find((e) => e.to.node === nodes.move.id && e.to.port === 'dx')!.from.node; expect(sourceOfDx()).toBe(literal.id); stack.undo(); expect(sourceOfDx()).toBe(nodes.mul.id); }); it('removing a node drops its edges; undo restores both', () => { const { behavior, stack, nodes } = withMover(makeProject()); const edgesBefore = behavior.graph.edges.length; stack.execute(new RemoveNodeCommand(behavior.id, nodes.mul.id)); expect(findNode(behavior.graph, nodes.mul.id)).toBeUndefined(); expect(behavior.graph.edges.length).toBe(edgesBefore - 3); stack.undo(); expect(findNode(behavior.graph, nodes.mul.id)).toBeDefined(); expect(behavior.graph.edges.length).toBe(edgesBefore); stack.execute(new DisconnectCommand(behavior.id, behavior.graph.edges[0]!.id)); expect(behavior.graph.edges.length).toBe(edgesBefore - 1); stack.undo(); expect(behavior.graph.edges.length).toBe(edgesBefore); }); it('coerces declared params and keeps inline input values', () => { const { behavior, stack, nodes } = withMover(makeProject()); stack.execute(new SetNodeParamCommand(behavior.id, nodes.held.id, 'key', 'ArrowLeft')); stack.execute(new SetNodeParamCommand(behavior.id, nodes.move.id, 'dy', 4)); const held = findNode(behavior.graph, nodes.held.id)!; const move = findNode(behavior.graph, nodes.move.id)!; expect(held.params.key).toBe('ArrowLeft'); expect(move.params.dy).toBe(4); stack.undo(); expect(move.params.dy).toBeUndefined(); }); }); describe('variable commands', () => { it('adds, renames (including graph references and overrides) and removes variables', () => { const f = withMover(makeSpriteScene()); const { behavior, stack, nodes, object } = f; const logic = place(f, behavior.id, { x: 0, y: 0 }); object(logic).overrides.speed = 5; stack.execute(new RenameVariableCommand(behavior.id, 'speed', 'velocity')); expect(behavior.variables[0]!.key).toBe('velocity'); expect(behavior.props).toEqual({ enabled: true, keepOnSceneLoad: false, runInEditor: false, runWhilePaused: false, velocity: 0, }); expect(object(logic).overrides).toEqual({ velocity: 5 }); expect(findNode(behavior.graph, nodes.speed.id)!.ports).toEqual([{ key: 'velocity', kind: 'number' }]); stack.undo(); expect(behavior.variables[0]!.key).toBe('speed'); expect(object(logic).overrides).toEqual({ speed: 5 }); expect(() => stack.execute(new AddVariableCommand(behavior.id, 'speed', 'number'))).toThrow(/already/); expect(() => stack.execute(new AddVariableCommand(behavior.id, '1bad', 'number'))).toThrow(/names/); stack.execute(new RemoveVariableCommand(behavior.id, 'speed')); expect(behavior.variables).toHaveLength(0); expect(object(logic).overrides).toEqual({}); stack.undo(); expect(behavior.variables).toHaveLength(1); expect(object(logic).overrides).toEqual({ speed: 5 }); }); }); describe('behavior serialization', () => { it('round-trips variables and graph, dropping unknown node types and dangling edges', () => { const { project, behavior } = withMover(makeProject()); const loaded = findAsset(roundTrip(project), behavior.id) as BehaviorAsset; expect(loaded.variables).toEqual(behavior.variables); expect(loaded.graph).toEqual(behavior.graph); expect(loaded.props).toEqual({ enabled: true, keepOnSceneLoad: false, runInEditor: false, runWhilePaused: false, speed: 0, }); const raw = serializeProject(project) as { assets: { id: string; graph?: { nodes: unknown[]; edges: unknown[] } }[]; }; const rawGraph = raw.assets.find((a) => a.id === behavior.id)!.graph!; rawGraph.nodes.push({ id: 'zzz', type: 'DoesNotExist', x: 0, y: 0, params: {} }); rawGraph.edges.push({ id: 'e-bad', from: { node: 'zzz', port: 'exec' }, to: { node: behavior.graph.nodes[0]!.id, port: 'exec' }, }); const cleaned = findAsset(deserializeProject(raw), behavior.id) as BehaviorAsset; expect(cleaned.graph.nodes.length).toBe(behavior.graph.nodes.length); expect(cleaned.graph.edges.length).toBe(behavior.graph.edges.length); }); it('keeps a node param that is a whole list or a struct, not just a single value', () => { const { project, behavior, edit } = emptyBehavior(); const list = edit.addNode('List', { value: [3, 1, 4] }); const struct = edit.addNode('Number', { bundle: { x: 2, tags: ['a'] } }); const loaded = findAsset(roundTrip(project), behavior.id) as BehaviorAsset; expect(findNode(loaded.graph, list.id)!.params.value).toEqual([3, 1, 4]); expect(findNode(loaded.graph, struct.id)!.params.bundle).toEqual({ x: 2, tags: ['a'] }); }); }); describe('unknown node types', () => { it('are rejected as a CommandError by execute, not thrown from the constructor', () => { const fixture = makeProject(); const behavior = makeBehavior(fixture, 'B'); // Building the command must not throw: callers construct it outside the editor's // error handling (`state.execute(new AddNodeCommand(…))`), so a throw here would crash. const command = new AddNodeCommand(behavior.id, 'DoesNotExist', 0, 0); expect(command.label).toBe('Add DoesNotExist'); expect(() => command.execute(fixture.project)).toThrow(CommandError); expect(behavior.graph.nodes).toHaveLength(0); }); }); describe('dropping an edge on a node', () => { /** A behavior with a number source and a Clamp, so a drag has several compatible inputs. */ function fixtureWithClamp() { const fixture = makeProject(); const behavior = makeBehavior(fixture, 'B'); const edit = graphEditor(fixture, behavior); return { graph: behavior.graph, edit, number: edit.addNode('Number'), clamp: edit.addNode('Clamp') }; } it('picks the first compatible input', () => { const { graph, number, clamp } = fixtureWithClamp(); const from = { nodeId: number.id, port: 'value', isOutput: true }; expect(dropTargetPort(graph, from, clamp.id, NO_LOOKUP)).toEqual({ nodeId: clamp.id, port: 'value', isOutput: false, }); }); it('skips inputs that already hold an edge', () => { const { graph, edit, number, clamp } = fixtureWithClamp(); const other = edit.addNode('Number'); edit.connect(other, 'value', clamp, 'value'); const from = { nodeId: number.id, port: 'value', isOutput: true }; expect(dropTargetPort(graph, from, clamp.id, NO_LOOKUP)?.port).toBe('min'); }); it('falls back to the last compatible input once every one is taken', () => { const { graph, edit, number, clamp } = fixtureWithClamp(); for (const port of ['value', 'min', 'max']) edit.connect(edit.addNode('Number'), 'value', clamp, port); const from = { nodeId: number.id, port: 'value', isOutput: true }; // Not free, but the drop still connects – it replaces the edge, as dropping on the port would. expect(dropTargetPort(graph, from, clamp.id, NO_LOOKUP)?.port).toBe('max'); }); it('keeps offering an exec input, which is never exclusive', () => { const fixture = makeProject(); const behavior = makeBehavior(fixture, 'B'); const edit = graphEditor(fixture, behavior); const held = edit.addNode('OnKeyHeld'); const move = edit.addNode('SetPosition'); edit.connect(edit.addNode('OnKeyHeld'), 'exec', move, 'exec'); const from = { nodeId: held.id, port: 'exec', isOutput: true }; expect(dropTargetPort(behavior.graph, from, move.id, NO_LOOKUP)?.port).toBe('exec'); }); it('refuses a node that offers nothing compatible, and the node itself', () => { const { graph, edit, number, clamp } = fixtureWithClamp(); const boolean = edit.addNode('Boolean'); const from = { nodeId: number.id, port: 'value', isOutput: true }; expect(dropTargetPort(graph, from, boolean.id, NO_LOOKUP)).toBeNull(); expect(dropTargetPort(graph, from, number.id, NO_LOOKUP)).toBeNull(); // A pair that is already wired is not offered again either. edit.connect(number, 'value', clamp, 'value'); edit.connect(number, 'value', clamp, 'min'); edit.connect(number, 'value', clamp, 'max'); expect(dropTargetPort(graph, from, clamp.id, NO_LOOKUP)).toBeNull(); }); it('resolves a drag started at an input to an output', () => { const { graph, number, clamp } = fixtureWithClamp(); const from = { nodeId: clamp.id, port: 'value', isOutput: false }; expect(dropTargetPort(graph, from, number.id, NO_LOOKUP)).toEqual({ nodeId: number.id, port: 'value', isOutput: true, }); }); }); describe('the palette offered when an edge is dropped into empty space', () => { const offers = (type: string, fromType: PortType, fromIsOutput: boolean) => defAcceptsFrom(requireNodeDef(type), fromType, fromIsOutput); it('offers only nodes with a matching input when the drag started at an output', () => { expect(offers('Move', 'exec', true)).toBe(true); expect(offers('Clamp', 'number', true)).toBe(true); // An exec output has no business at a data node, nor a number at a node without number inputs. expect(offers('Number', 'exec', true)).toBe(false); expect(offers('OnUpdate', 'number', true)).toBe(false); }); it('looks at outputs instead when the drag started at an input', () => { expect(offers('Number', 'number', false)).toBe(true); expect(offers('OnUpdate', 'exec', false)).toBe(true); expect(offers('Number', 'exec', false)).toBe(false); expect(offers('StopGame', 'number', false)).toBe(false); }); it('lets `any` match every data type, but never exec', () => { expect(offers('Log', 'number', true)).toBe(true); expect(offers('Log', 'string', true)).toBe(true); expect(offers('ToText', 'exec', true)).toBe(false); }); it('wires the picked node to its first compatible port', () => { expect(firstPortFor(requireNodeDef('Clamp'), 'number', true)).toBe('value'); expect(firstPortFor(requireNodeDef('Move'), 'exec', true)).toBe('exec'); expect(firstPortFor(requireNodeDef('Number'), 'number', false)).toBe('value'); expect(firstPortFor(requireNodeDef('Number'), 'exec', true)).toBeNull(); }); });