import { describe, expect, it } from 'vitest'; import { buildAsset } from '../../src/core/commands/assetCommands'; import { CommandStack } from '../../src/core/commands/CommandStack'; import { AddNodeCommand, ConnectCommand, DisconnectCommand, RemoveNodeCommand, SetNodeParamCommand } from '../../src/core/commands/graphCommands'; import { PlaceAssetCommand, SetOwnerCommand } from '../../src/core/commands/sceneObjectCommands'; import { AddVariableCommand, RemoveVariableCommand, RenameVariableCommand } from '../../src/core/commands/variableCommands'; import { type EntityHandle, GraphInterpreter, type NodeContext, allNodeDefs, portsCompatible } from '../../src/core/graph'; import type { BehaviorAsset } from '../../src/core/model/Asset'; import { createEmptyProject } from '../../src/core/model/Project'; import { deserializeProject, serializeProject } from '../../src/core/store/serialize'; function entity(name: string, x = 0): EntityHandle { return { id: name, name, x, y: 0, z: 0, props: {} }; } function context(overrides: Partial = {}): NodeContext & { logs: string[]; stopped: boolean } { const logs: string[] = []; const ctx = { self: entity('self'), owner: entity('owner'), variables: { speed: 100 }, time: 0, dt: 0.5, logs, stopped: false, findByName: () => null, isKeyDown: () => false, log: (m: string) => logs.push(m), destroy: () => {}, stop: () => { ctx.stopped = true; }, ...overrides, }; return ctx; } /** Builds "On key held → Move(owner, dx = speed × dt)" through commands, like the editor would. */ function buildMoverProject() { const project = createEmptyProject('p', 'P'); const behavior = buildAsset('behavior', 'Mover', 'mover') as BehaviorAsset; project.assets.push(behavior); const stack = new CommandStack(project); stack.execute(new AddVariableCommand('mover', 'speed', 'number')); const held = new AddNodeCommand('mover', 'OnKeyHeld', 0, 0, { key: 'ArrowRight' }); const owner = new AddNodeCommand('mover', 'Owner', 0, 100); const speed = new AddNodeCommand('mover', 'GetVariable', 0, 200, { variable: 'speed' }); const mul = new AddNodeCommand('mover', 'Multiply', 200, 150); const move = new AddNodeCommand('mover', 'Move', 400, 0); for (const c of [held, owner, speed, mul, move]) stack.execute(c); stack.execute(new ConnectCommand('mover', { node: held.node.id, port: 'exec' }, { node: move.node.id, port: 'exec' })); stack.execute(new ConnectCommand('mover', { node: owner.node.id, port: 'object' }, { node: move.node.id, port: 'object' })); stack.execute(new ConnectCommand('mover', { node: speed.node.id, port: 'value' }, { node: mul.node.id, port: 'a' })); stack.execute(new ConnectCommand('mover', { node: held.node.id, port: 'dt' }, { node: mul.node.id, port: 'b' })); stack.execute(new ConnectCommand('mover', { node: mul.node.id, port: 'result' }, { node: move.node.id, port: 'dx' })); return { project, behavior, stack, nodes: { held, owner, speed, mul, move } }; } 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); if (def.category === 'event') expect(def.inputs).toHaveLength(0); else expect(def.run).toBeTypeOf('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 } = buildMoverProject(); const interpreter = new GraphInterpreter(behavior.graph); const ctx = context({ variables: { speed: 100 } }); const fired = interpreter.fire('OnKeyHeld', ctx, { dt: 0.5 }, (n) => n.params.key === 'ArrowRight'); expect(fired).toBe(1); expect(ctx.owner!.x).toBe(50); expect(interpreter.fire('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 project = createEmptyProject('p', 'P'); const behavior = buildAsset('behavior', 'B', 'b') as BehaviorAsset; project.assets.push(behavior); const stack = new CommandStack(project); const start = new AddNodeCommand('b', 'OnStart', 0, 0); const self = new AddNodeCommand('b', 'Self', 0, 0); const move = new AddNodeCommand('b', 'Move', 0, 0, { dx: 7, dy: -3 }); for (const c of [start, self, move]) stack.execute(c); stack.execute(new ConnectCommand('b', { node: start.node.id, port: 'exec' }, { node: move.node.id, port: 'exec' })); stack.execute(new ConnectCommand('b', { node: self.node.id, port: 'object' }, { node: move.node.id, port: 'object' })); const ctx = context(); new GraphInterpreter(behavior.graph).fire('OnStart', ctx); expect(ctx.self.x).toBe(7); expect(ctx.self.y).toBe(-3); }); it('follows Branch, writes variables and logs', () => { const project = createEmptyProject('p', 'P'); const behavior = buildAsset('behavior', 'B', 'b') as BehaviorAsset; project.assets.push(behavior); const stack = new CommandStack(project); stack.execute(new AddVariableCommand('b', 'count', 'number')); const start = new AddNodeCommand('b', 'OnStart', 0, 0); const get = new AddNodeCommand('b', 'GetVariable', 0, 0, { variable: 'count' }); const cmp = new AddNodeCommand('b', 'Compare', 0, 0, { op: '<', b: 3 }); const branch = new AddNodeCommand('b', 'Branch', 0, 0); const add = new AddNodeCommand('b', 'Add', 0, 0, { b: 1 }); const set = new AddNodeCommand('b', 'SetVariable', 0, 0, { variable: 'count' }); const logThen = new AddNodeCommand('b', 'Log', 0, 0, { message: 'then' }); const logElse = new AddNodeCommand('b', 'Log', 0, 0, { message: 'else' }); for (const c of [start, get, cmp, branch, add, set, logThen, logElse]) stack.execute(c); const connect = (from: AddNodeCommand, fp: string, to: AddNodeCommand, tp: string) => stack.execute(new ConnectCommand('b', { node: from.node.id, port: fp }, { node: to.node.id, port: tp })); connect(start, 'exec', branch, 'exec'); connect(get, 'value', cmp, 'a'); connect(cmp, 'result', branch, 'condition'); connect(branch, 'then', set, 'exec'); connect(get, 'value', add, 'a'); connect(add, 'result', set, 'value'); connect(set, 'exec', logThen, 'exec'); connect(branch, 'else', logElse, 'exec'); const interpreter = new GraphInterpreter(behavior.graph); const ctx = context({ variables: { count: 0 } }); for (let i = 0; i < 5; i++) interpreter.fire('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 } = buildMoverProject(); const interpreter = new GraphInterpreter(behavior.graph); const edges: string[] = []; const visited: string[] = []; interpreter.trace = { onEdge: (id) => edges.push(id), onNode: (id) => visited.push(id) }; interpreter.fire('OnKeyHeld', context(), { dt: 1 }); expect(edges).toHaveLength(1); expect(visited).toContain(nodes.mul.node.id); expect(visited).toContain(nodes.move.node.id); }); it('stops a runaway flow instead of hanging', () => { const project = createEmptyProject('p', 'P'); const behavior = buildAsset('behavior', 'B', 'b') as BehaviorAsset; project.assets.push(behavior); const stack = new CommandStack(project); const start = new AddNodeCommand('b', 'OnStart', 0, 0); const a = new AddNodeCommand('b', 'Sequence', 0, 0); const b = new AddNodeCommand('b', 'Sequence', 0, 0); for (const c of [start, a, b]) stack.execute(c); stack.execute(new ConnectCommand('b', { node: start.node.id, port: 'exec' }, { node: a.node.id, port: 'exec' })); stack.execute(new ConnectCommand('b', { node: a.node.id, port: 'a' }, { node: b.node.id, port: 'exec' })); stack.execute(new ConnectCommand('b', { node: b.node.id, port: 'a' }, { node: a.node.id, port: 'exec' })); expect(() => new GraphInterpreter(behavior.graph).fire('OnStart', context())).toThrow(/step limit/); }); }); describe('graph commands', () => { it('rejects incompatible, self and duplicate connections', () => { const { stack, nodes } = buildMoverProject(); expect(() => stack.execute(new ConnectCommand('mover', { node: nodes.held.node.id, port: 'exec' }, { node: nodes.mul.node.id, port: 'a' }))).toThrow(/Cannot connect/); expect(() => stack.execute(new ConnectCommand('mover', { node: nodes.mul.node.id, port: 'result' }, { node: nodes.mul.node.id, port: 'a' }))).toThrow(/itself/); expect(() => stack.execute(new ConnectCommand('mover', { node: nodes.held.node.id, port: 'exec' }, { node: nodes.move.node.id, port: 'exec' }))).toThrow(/Already/); expect(() => stack.execute(new ConnectCommand('mover', { node: nodes.held.node.id, port: 'nope' }, { node: nodes.move.node.id, port: 'exec' }))).toThrow(/Port/); }); it('replaces the edge of a data input and restores it on undo', () => { const { behavior, stack, nodes } = buildMoverProject(); const literal = new AddNodeCommand('mover', 'Number', 0, 0, { value: 9 }); stack.execute(literal); const before = behavior.graph.edges.length; stack.execute(new ConnectCommand('mover', { node: literal.node.id, port: 'value' }, { node: nodes.move.node.id, port: 'dx' })); expect(behavior.graph.edges.length).toBe(before); // one replaced, one added expect(behavior.graph.edges.find((e) => e.to.node === nodes.move.node.id && e.to.port === 'dx')!.from.node).toBe(literal.node.id); stack.undo(); expect(behavior.graph.edges.find((e) => e.to.node === nodes.move.node.id && e.to.port === 'dx')!.from.node).toBe(nodes.mul.node.id); }); it('removing a node drops its edges; undo restores both', () => { const { behavior, stack, nodes } = buildMoverProject(); const edgesBefore = behavior.graph.edges.length; stack.execute(new RemoveNodeCommand('mover', nodes.mul.node.id)); expect(behavior.graph.nodes.some((n) => n.id === nodes.mul.node.id)).toBe(false); expect(behavior.graph.edges.length).toBe(edgesBefore - 3); stack.undo(); expect(behavior.graph.nodes.some((n) => n.id === nodes.mul.node.id)).toBe(true); expect(behavior.graph.edges.length).toBe(edgesBefore); stack.execute(new DisconnectCommand('mover', 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 } = buildMoverProject(); stack.execute(new SetNodeParamCommand('mover', nodes.held.node.id, 'key', 'ArrowLeft')); stack.execute(new SetNodeParamCommand('mover', nodes.move.node.id, 'dy', 4)); const held = behavior.graph.nodes.find((n) => n.id === nodes.held.node.id)!; const move = behavior.graph.nodes.find((n) => n.id === nodes.move.node.id)!; expect(held.params.key).toBe('ArrowLeft'); expect(move.params.dy).toBe(4); stack.undo(); expect(move.params.dy).toBeUndefined(); }); }); describe('variables', () => { it('adds, renames (including graph references and overrides) and removes variables', () => { const { project, behavior, stack, nodes } = buildMoverProject(); const scene = buildAsset('scene', 'S', 's'); project.assets.push(scene); const place = new PlaceAssetCommand('s', 'mover', { x: 0, y: 0 }); stack.execute(place); const object = (scene as { objects: { id: string; overrides: Record }[] }).objects[0]!; object.overrides.speed = 5; stack.execute(new RenameVariableCommand('mover', 'speed', 'velocity')); expect(behavior.variables[0]!.key).toBe('velocity'); expect(behavior.props).toEqual({ velocity: 0 }); expect(object.overrides).toEqual({ velocity: 5 }); expect(behavior.graph.nodes.find((n) => n.id === nodes.speed.node.id)!.params.variable).toBe('velocity'); stack.undo(); expect(behavior.variables[0]!.key).toBe('speed'); expect(object.overrides).toEqual({ speed: 5 }); expect(() => stack.execute(new AddVariableCommand('mover', 'speed', 'number'))).toThrow(/already/); expect(() => stack.execute(new AddVariableCommand('mover', '1bad', 'number'))).toThrow(/names/); stack.execute(new RemoveVariableCommand('mover', 'speed')); expect(behavior.variables).toHaveLength(0); expect(object.overrides).toEqual({}); stack.undo(); expect(behavior.variables).toHaveLength(1); expect(object.overrides).toEqual({ speed: 5 }); }); it('owner references are validated and cleared when the owner disappears', () => { const project = createEmptyProject('p', 'P'); const scene = buildAsset('scene', 'S', 's'); const sprite = buildAsset('sprite', 'Box', 'box'); const behavior = buildAsset('behavior', 'B', 'b'); project.assets.push(scene, sprite, behavior); const stack = new CommandStack(project); const placeBox = new PlaceAssetCommand('s', 'box', { x: 0, y: 0 }); const placeB = new PlaceAssetCommand('s', 'b', { x: 0, y: 0 }); stack.execute(placeBox); stack.execute(placeB); expect(() => stack.execute(new SetOwnerCommand('s', placeB.objectId, placeB.objectId))).toThrow(/itself/); stack.execute(new SetOwnerCommand('s', placeB.objectId, placeBox.objectId)); const objects = () => (project.assets[0] as { objects: { id: string; ownerId: string }[] }).objects; expect(objects()[1]!.ownerId).toBe(placeBox.objectId); // Round trip keeps the owner; a dangling owner is dropped by the loader. const loaded = deserializeProject(serializeProject(project)); expect((loaded.assets[0] as { objects: { ownerId: string }[] }).objects[1]!.ownerId).toBe(placeBox.objectId); const raw = serializeProject(project) as { assets: { objects?: { ownerId: string }[] }[] }; raw.assets[0]!.objects![1]!.ownerId = 'ghost'; expect((deserializeProject(raw).assets[0] as { objects: { ownerId: string }[] }).objects[1]!.ownerId).toBe(''); }); }); describe('behavior serialization', () => { it('round-trips variables and graph, dropping unknown node types and dangling edges', () => { const { project, behavior } = buildMoverProject(); const loaded = deserializeProject(serializeProject(project)).assets.find((a) => a.id === 'mover') as BehaviorAsset; expect(loaded.variables).toEqual(behavior.variables); expect(loaded.graph).toEqual(behavior.graph); expect(loaded.props).toEqual({ speed: 0 }); const raw = serializeProject(project) as { assets: { graph?: { nodes: unknown[]; edges: unknown[] } }[] }; raw.assets[0]!.graph!.nodes.push({ id: 'zzz', type: 'DoesNotExist', x: 0, y: 0, params: {} }); raw.assets[0]!.graph!.edges.push({ id: 'e-bad', from: { node: 'zzz', port: 'exec' }, to: { node: behavior.graph.nodes[0]!.id, port: 'exec' } }); const cleaned = deserializeProject(raw).assets[0] as BehaviorAsset; expect(cleaned.graph.nodes.length).toBe(behavior.graph.nodes.length); expect(cleaned.graph.edges.length).toBe(behavior.graph.edges.length); }); });