import { describe, expect, it } from 'vitest'; import { CommandError } from '../../src/core/commands/Command'; import { ConnectCommand, FoldEdgeCommand } from '../../src/core/commands/graphCommands'; import { GraphInterpreter } from '../../src/core/graph'; import type { BehaviorAsset } from '../../src/core/model/Asset'; import { findAsset } from '../../src/core/model/Project'; import { deserializeProject, serializeProject } from '../../src/core/store/serialize'; import { buildFoldMarks, foldedEdgeShown, type FoldMarkEnd, foldMarkAt, foldMarkKey, foldMarkLabel, foldMarkRadius, } from '../../src/editor/graph/folding'; import { fakeContext, graphEditor, makeBehavior, makeProject, roundTrip } from '../helpers'; /** "On start → Move(self, dx = 7)": two edges, so a fold can hide one and leave the other. */ function mover() { const fixture = makeProject(); const behavior = makeBehavior(fixture, 'Mover'); const edit = graphEditor(fixture, behavior); const start = edit.addNode('OnStart'); const self = edit.addNode('Self'); const move = edit.addNode('Move', { dx: 7 }); edit.wire([ [start, 'exec', move, 'exec'], [self, 'object', move, 'object'], ]); return { ...fixture, behavior, edit, nodes: { start, self, move } }; } describe('folding an edge', () => { it('changes nothing about what the graph does – the value still arrives', () => { const { project, stack, behavior } = mover(); for (const edge of behavior.graph.edges) stack.execute(new FoldEdgeCommand(behavior.id, edge.id, true)); expect(behavior.graph.edges.every((e) => e.folded === true)).toBe(true); const ctx = fakeContext(); new GraphInterpreter(behavior.graph).fire('OnStart', ctx); expect(ctx.self.x).toBe(7); // And the edges themselves are untouched apart from the flag. expect(findAsset(project, behavior.id)).toBe(behavior); expect(behavior.graph.edges).toHaveLength(2); }); it('survives the save, and unfolding leaves no trace in the file', () => { const { project, stack, behavior } = mover(); const [first, second] = behavior.graph.edges; stack.execute(new FoldEdgeCommand(behavior.id, first!.id, true)); const loaded = findAsset(roundTrip(project), behavior.id) as BehaviorAsset; expect(loaded.graph.edges.find((e) => e.id === first!.id)!.folded).toBe(true); expect('folded' in loaded.graph.edges.find((e) => e.id === second!.id)!).toBe(false); stack.execute(new FoldEdgeCommand(behavior.id, first!.id, false)); const again = findAsset(roundTrip(project), behavior.id) as BehaviorAsset; expect(again.graph.edges.every((e) => !('folded' in e))).toBe(true); }); it('is undoable, and undo restores the state the edge really had', () => { const { stack, behavior } = mover(); const edge = behavior.graph.edges[0]!; stack.execute(new FoldEdgeCommand(behavior.id, edge.id, true)); // Folding an already folded edge must not make undo unfold it. stack.execute(new FoldEdgeCommand(behavior.id, edge.id, true)); stack.undo(); expect(edge.folded).toBe(true); stack.undo(); expect(edge.folded).toBeUndefined(); }); it('reports a missing edge as a CommandError instead of throwing from the constructor', () => { const { project, behavior } = mover(); const command = new FoldEdgeCommand(behavior.id, 'no-such-edge', true); expect(command.label).toBe('Hide link'); expect(() => command.execute(project)).toThrow(CommandError); }); it('belongs to the edge, not to the port: a replacement starts out shown', () => { const { stack, behavior, edit, nodes } = mover(); const edge = behavior.graph.edges.find((e) => e.to.port === 'object')!; stack.execute(new FoldEdgeCommand(behavior.id, edge.id, true)); const owner = edit.addNode('Owner'); stack.execute( new ConnectCommand( behavior.id, { node: owner.id, port: 'object' }, { node: nodes.move.id, port: 'object' }, ), ); const now = behavior.graph.edges.find((e) => e.to.port === 'object')!; expect(now.id).not.toBe(edge.id); expect(now.folded).toBeUndefined(); // Undo puts the old edge back exactly as it was – folded. stack.undo(); expect(behavior.graph.edges.find((e) => e.to.port === 'object')!.folded).toBe(true); }); it('ignores a folded flag in the file that is not literally true', () => { const { project, behavior } = mover(); const raw = serializeProject(project) as { assets: { id: string; graph?: { edges: Record[] } }[]; }; const edges = raw.assets.find((a) => a.id === behavior.id)!.graph!.edges; edges[0]!.folded = 'yes'; edges[1]!.folded = false; const loaded = findAsset(deserializeProject(raw), behavior.id) as BehaviorAsset; expect(loaded.graph.edges.every((e) => !('folded' in e))).toBe(true); }); }); describe('when a folded edge shows its curve anyway', () => { const edge = { id: 'e', from: { node: 'a', port: 'out' }, to: { node: 'b', port: 'in' } }; it('stays hidden while neither end is selected', () => { expect(foldedEdgeShown(edge, new Set(['c']), false)).toBe(false); expect(foldedEdgeShown(edge, new Set(), false)).toBe(false); }); it('comes back for either end, not just the source', () => { expect(foldedEdgeShown(edge, new Set(['a']), false)).toBe(true); expect(foldedEdgeShown(edge, new Set(['b']), false)).toBe(true); }); it('always shows while a pulse is on it, so the live trace keeps its thread', () => { expect(foldedEdgeShown(edge, new Set(), true)).toBe(true); }); }); describe('several hidden links at one port', () => { it('share a mark, while a different port or side gets its own', () => { const out = foldMarkKey({ node: 'a', port: 'value' }, true); expect(foldMarkKey({ node: 'a', port: 'value' }, true)).toBe(out); expect(foldMarkKey({ node: 'a', port: 'other' }, true)).not.toBe(out); expect(foldMarkKey({ node: 'b', port: 'value' }, true)).not.toBe(out); // The same port on the other side of the node is a mark of its own. expect(foldMarkKey({ node: 'a', port: 'value' }, false)).not.toBe(out); }); it('cannot be confused by a node or port name that contains the separator', () => { expect(foldMarkKey({ node: 'a b', port: 'c' }, true)).not.toBe( foldMarkKey({ node: 'a', port: 'b c' }, true), ); }); it('name one far node and count the rest, so nothing is written on top of anything', () => { expect(foldMarkLabel(['Add'])).toBe('Add'); expect(foldMarkLabel(['Add', 'Compare'])).toBe('Add +1'); expect(foldMarkLabel(['Add', 'Compare', 'Multiply'])).toBe('Add +2'); expect(foldMarkLabel([])).toBe(''); }); }); /** The bundling that keeps two hidden links out of the same port from stacking on one pixel. */ describe('fold mark bundling', () => { const end = (over: Partial & { edgeId: string; key: string }): FoldMarkEnd => ({ other: 'far', otherLabel: 'Far', isOutput: true, portX: 100, portY: 50, color: 1, hot: false, ...over, }); it('gives one port one mark, however many links it hides', () => { const marks = buildFoldMarks( [ end({ edgeId: 'e1', key: 'a', other: 'n1', otherLabel: 'Add' }), end({ edgeId: 'e2', key: 'a', other: 'n2', otherLabel: 'Compare' }), end({ edgeId: 'e3', key: 'b', other: 'n3', otherLabel: 'Move' }), ], 16, ); expect(marks).toHaveLength(2); expect(marks[0]!.edgeIds).toEqual(['e1', 'e2']); expect(foldMarkLabel(marks[0]!.names)).toBe('Add +1'); expect(marks[1]!.edgeIds).toEqual(['e3']); }); it('dedupes the far nodes, so two links to one node select it once', () => { const marks = buildFoldMarks( [end({ edgeId: 'e1', key: 'a', other: 'n1' }), end({ edgeId: 'e2', key: 'a', other: 'n1' })], 16, ); expect(marks[0]!.others).toEqual(['n1']); }); it('puts the mark a stub out of its port, on the side the port faces', () => { const [out] = buildFoldMarks([end({ edgeId: 'e1', key: 'a', isOutput: true })], 16); const [into] = buildFoldMarks([end({ edgeId: 'e2', key: 'b', isOutput: false })], 16); expect(out!.x).toBe(116); expect(into!.x).toBe(84); }); it('lets a pulse anywhere in the bundle colour the whole mark', () => { const marks = buildFoldMarks( [ end({ edgeId: 'e1', key: 'a', color: 1, hot: false }), end({ edgeId: 'e2', key: 'a', color: 9, hot: true }), ], 16, ); expect(marks[0]!.hot).toBe(true); expect(marks[0]!.color).toBe(9); }); it('grows the dot for a bundle and finds it under a click', () => { expect(foldMarkRadius(1, 5)).toBe(5); expect(foldMarkRadius(2, 5)).toBe(7); const marks = [{ edgeIds: ['e1', 'e2'], x: 100, y: 50 }]; expect(foldMarkAt(marks, { x: 108, y: 50 }, 5)).toBe(marks[0]); expect(foldMarkAt(marks, { x: 120, y: 50 }, 5)).toBeNull(); }); });