import { afterEach, describe, expect, vi } from 'vitest' import { LGraph, LGraphNode, LLink, Reroute, type RerouteId } from '@/lib/litegraph/src/litegraph' import { LinkConnector } from '@/lib/litegraph/src/litegraph' import type { CanvasPointerEvent } from '@/lib/litegraph/src/types/events' import { test as baseTest } from './testExtensions' interface TestContext { graph: LGraph connector: LinkConnector setConnectingLinks: ReturnType createTestNode: (id: number) => LGraphNode reroutesBeforeTest: [rerouteId: RerouteId, reroute: Reroute][] validateIntegrityNoChanges: () => void validateIntegrityFloatingRemoved: () => void validateLinkIntegrity: () => void getNextLinkIds: ( linkIds: Set, expectedExtraLinks?: number ) => number[] readonly floatingReroute: Reroute } const test = baseTest.extend({ reroutesBeforeTest: async ({ reroutesComplexGraph }, use) => { await use([...reroutesComplexGraph.reroutes]) }, graph: async ({ reroutesComplexGraph }, use) => { const ctx = vi.fn(() => ({ measureText: vi.fn(() => ({ width: 10 })) })) for (const node of reroutesComplexGraph.nodes) { node.updateArea(ctx() as unknown as CanvasRenderingContext2D) } await use(reroutesComplexGraph) }, setConnectingLinks: async ( // eslint-disable-next-line no-empty-pattern {}, use: (mock: ReturnType) => Promise ) => { const mock = vi.fn() await use(mock) }, connector: async ({ setConnectingLinks }, use) => { const connector = new LinkConnector(setConnectingLinks) await use(connector) }, createTestNode: async ({ graph }, use) => { await use((id): LGraphNode => { const node = new LGraphNode('test') node.id = id graph.add(node) return node }) }, validateIntegrityNoChanges: async ( { graph, reroutesBeforeTest, expect }, use ) => { await use(() => { expect(graph.floatingLinks.size).toBe(1) expect([...graph.reroutes]).toEqual(reroutesBeforeTest) // Only the original reroute should be floating const reroutesExceptOne = [...graph.reroutes.values()].filter( (reroute) => reroute.id !== 1 ) for (const reroute of reroutesExceptOne) { expect(reroute.floating).toBeUndefined() } }) }, validateIntegrityFloatingRemoved: async ( { graph, reroutesBeforeTest, expect }, use ) => { await use(() => { expect(graph.floatingLinks.size).toBe(0) expect([...graph.reroutes]).toEqual(reroutesBeforeTest) for (const reroute of graph.reroutes.values()) { expect(reroute.floating).toBeUndefined() } }) }, validateLinkIntegrity: async ({ graph, expect }, use) => { await use(() => { for (const reroute of graph.reroutes.values()) { if (reroute.origin_id === undefined) { expect(reroute.linkIds.size).toBe(0) expect(reroute.floatingLinkIds.size).toBeGreaterThan(0) } for (const linkId of reroute.linkIds) { const link = graph.links.get(linkId) expect(link).toBeDefined() expect(link!.origin_id).toEqual(reroute.origin_id) expect(link!.origin_slot).toEqual(reroute.origin_slot) } for (const linkId of reroute.floatingLinkIds) { const link = graph.floatingLinks.get(linkId) expect(link).toBeDefined() if (link!.target_id === -1) { expect(link!.origin_id).not.toBe(-1) expect(link!.origin_slot).not.toBe(-1) expect(link!.target_slot).toBe(-1) } else { expect(link!.origin_id).toBe(-1) expect(link!.origin_slot).toBe(-1) expect(link!.target_slot).not.toBe(-1) } } } // Check that all link references are valid (Can be found in the graph) for (const node of graph.nodes.values()) { for (const input of node.inputs) { if (input.link) { expect(graph.links.keys()).toContain(input.link) expect(graph.links.get(input.link)?.target_id).toBe(node.id) } } for (const output of node.outputs) { for (const linkId of output.links ?? []) { expect(graph.links.keys()).toContain(linkId) expect(graph.links.get(linkId)?.origin_id).toBe(node.id) } } } for (const link of graph._links.values()) { expect( graph.getNodeById(link!.origin_id)?.outputs[link!.origin_slot].links ).toContain(link.id) expect( graph.getNodeById(link!.target_id)?.inputs[link!.target_slot].link ).toBe(link.id) } for (const link of graph.floatingLinks.values()) { if (link.target_id === -1) { expect(link.origin_id).not.toBe(-1) expect(link.origin_slot).not.toBe(-1) expect(link.target_slot).toBe(-1) const outputFloatingLinks = graph.getNodeById(link.origin_id) ?.outputs[link.origin_slot]._floatingLinks expect(outputFloatingLinks).toBeDefined() expect(outputFloatingLinks).toContain(link) } else { expect(link.origin_id).toBe(-1) expect(link.origin_slot).toBe(-1) expect(link.target_slot).not.toBe(-1) const inputFloatingLinks = graph.getNodeById(link.target_id)?.inputs[ link.target_slot ]._floatingLinks expect(inputFloatingLinks).toBeDefined() expect(inputFloatingLinks).toContain(link) } } }) }, getNextLinkIds: async ({ graph }, use) => { await use((linkIds, expectedExtraLinks = 0) => { const indexes = [...new Array(linkIds.size + expectedExtraLinks).keys()] return indexes.map((index) => graph.last_link_id + index + 1) }) }, floatingReroute: async ({ graph, expect }, use) => { const floatingReroute = graph.reroutes.get(1)! expect(floatingReroute.floating).toEqual({ slotType: 'output' }) await use(floatingReroute) } }) function mockedNodeTitleDropEvent(node: LGraphNode): CanvasPointerEvent { return { canvasX: node.pos[0] + node.size[0] / 2, canvasY: node.pos[1] + 16 } as any } function mockedInputDropEvent( node: LGraphNode, slot: number ): CanvasPointerEvent { const pos = node.getInputPos(slot) return { canvasX: pos[0], canvasY: pos[1] } as any } function mockedOutputDropEvent( node: LGraphNode, slot: number ): CanvasPointerEvent { const pos = node.getOutputPos(slot) return { canvasX: pos[0], canvasY: pos[1] } as any } describe('LinkConnector Integration', () => { afterEach(({ validateLinkIntegrity }) => { validateLinkIntegrity() }) describe('Moving input links', () => { test('Should move input links', ({ graph, connector }) => { const nextLinkId = graph.last_link_id + 1 const hasInputNode = graph.getNodeById(2)! const disconnectedNode = graph.getNodeById(9)! const reroutesBefore = LLink.getReroutes( graph, graph.links.get(hasInputNode.inputs[0].link!)! ) connector.moveInputLink(graph, hasInputNode.inputs[0]) expect(connector.state.connectingTo).toBe('input') expect(connector.state.draggingExistingLinks).toBe(true) expect(connector.renderLinks.length).toBe(1) expect(connector.inputLinks.length).toBe(1) const canvasX = disconnectedNode.pos[0] + disconnectedNode.size[0] / 2 const canvasY = disconnectedNode.pos[1] + 16 const dropEvent = { canvasX, canvasY } as any // Drop links, ensure reset has not been run connector.dropLinks(graph, dropEvent) expect(connector.renderLinks.length).toBe(1) // Test reset connector.reset() expect(connector.renderLinks.length).toBe(0) expect(connector.inputLinks.length).toBe(0) expect(disconnectedNode.inputs[0].link).toBe(nextLinkId) expect(hasInputNode.inputs[0].link).toBeNull() const reroutesAfter = LLink.getReroutes( graph, graph.links.get(disconnectedNode.inputs[0].link!)! ) expect(reroutesAfter).toEqual(reroutesBefore) }) test('Should connect from floating reroutes', ({ graph, connector, reroutesBeforeTest }) => { const nextLinkId = graph.last_link_id + 1 const floatingLink = graph.floatingLinks.values().next().value! expect(floatingLink).toBeInstanceOf(LLink) const floatingReroute = graph.reroutes.get(floatingLink.parentId!)! const disconnectedNode = graph.getNodeById(9)! connector.dragFromReroute(graph, floatingReroute) expect(connector.state.connectingTo).toBe('input') expect(connector.state.draggingExistingLinks).toBe(false) expect(connector.renderLinks.length).toBe(1) expect(connector.inputLinks.length).toBe(0) const canvasX = disconnectedNode.pos[0] + disconnectedNode.size[0] / 2 const canvasY = disconnectedNode.pos[1] + 16 const dropEvent = { canvasX, canvasY } as any connector.dropLinks(graph, dropEvent) connector.reset() expect(connector.renderLinks.length).toBe(0) expect(connector.inputLinks.length).toBe(0) // New link should have been created expect(disconnectedNode.inputs[0].link).toBe(nextLinkId) // Check graph integrity expect(graph.floatingLinks.size).toBe(0) expect([...graph.reroutes]).toEqual(reroutesBeforeTest) // All reroute floating property should be cleared for (const reroute of graph.reroutes.values()) { expect(reroute.floating).toBeUndefined() } }) test('Should drop floating links when both sides are disconnected', ({ graph, reroutesBeforeTest }) => { expect(graph.floatingLinks.size).toBe(1) const floatingOutNode = graph.getNodeById(1)! floatingOutNode.disconnectOutput(0) // Should have lost one reroute expect(graph.reroutes.size).toBe(reroutesBeforeTest.length - 1) expect(graph.reroutes.get(1)).toBeUndefined() // The two normal links should now be floating expect(graph.floatingLinks.size).toBe(2) graph.getNodeById(2)!.disconnectInput(0, true) expect(graph.floatingLinks.size).toBe(1) graph.getNodeById(3)!.disconnectInput(0, false) expect(graph.floatingLinks.size).toBe(0) // Removed 4 reroutes expect(graph.reroutes.size).toBe(9) // All four nodes should have no links for (const nodeId of [1, 2, 3, 9]) { const { inputs: [input], outputs: [output] } = graph.getNodeById(nodeId)! expect(input.link).toBeNull() expect(output.links?.length).toBeOneOf([0, undefined]) expect(input._floatingLinks?.size).toBeOneOf([0, undefined]) expect(output._floatingLinks?.size).toBeOneOf([0, undefined]) } }) test('Should prevent node loopback when dropping on node', ({ graph, connector }) => { const hasOutputNode = graph.getNodeById(1)! const hasInputNode = graph.getNodeById(2)! const hasInputNode2 = graph.getNodeById(3)! const reroutesBefore = LLink.getReroutes( graph, graph.links.get(hasInputNode.inputs[0].link!)! ) const atOutputNodeEvent = mockedNodeTitleDropEvent(hasOutputNode) connector.moveInputLink(graph, hasInputNode.inputs[0]) connector.dropLinks(graph, atOutputNodeEvent) connector.reset() const outputNodes = hasOutputNode.getOutputNodes(0) expect(outputNodes).toEqual([hasInputNode, hasInputNode2]) const reroutesAfter = LLink.getReroutes( graph, graph.links.get(hasInputNode.inputs[0].link!)! ) expect(reroutesAfter).toEqual(reroutesBefore) }) test('Should prevent node loopback when dropping on input', ({ graph, connector }) => { const hasOutputNode = graph.getNodeById(1)! const hasInputNode = graph.getNodeById(2)! const originalOutputNodes = hasOutputNode.getOutputNodes(0) const reroutesBefore = LLink.getReroutes( graph, graph.links.get(hasInputNode.inputs[0].link!)! ) const atHasOutputNode = mockedInputDropEvent(hasOutputNode, 0) connector.moveInputLink(graph, hasInputNode.inputs[0]) connector.dropLinks(graph, atHasOutputNode) connector.reset() const outputNodes = hasOutputNode.getOutputNodes(0) expect(outputNodes).toEqual(originalOutputNodes) const reroutesAfter = LLink.getReroutes( graph, graph.links.get(hasInputNode.inputs[0].link!)! ) expect(reroutesAfter).toEqual(reroutesBefore) }) }) describe('Moving output links', () => { test('Should move output links', ({ graph, connector }) => { const nextLinkIds = [graph.last_link_id + 1, graph.last_link_id + 2] const hasOutputNode = graph.getNodeById(1)! const disconnectedNode = graph.getNodeById(9)! const reroutesBefore = hasOutputNode.outputs[0].links ?.map((linkId) => graph.links.get(linkId)!) .map((link) => LLink.getReroutes(graph, link)) connector.moveOutputLink(graph, hasOutputNode.outputs[0]) expect(connector.state.connectingTo).toBe('output') expect(connector.state.draggingExistingLinks).toBe(true) expect(connector.renderLinks.length).toBe(3) expect(connector.outputLinks.length).toBe(2) expect(connector.floatingLinks.length).toBe(1) const canvasX = disconnectedNode.pos[0] + disconnectedNode.size[0] / 2 const canvasY = disconnectedNode.pos[1] + 16 const dropEvent = { canvasX, canvasY } as any connector.dropLinks(graph, dropEvent) connector.reset() expect(connector.renderLinks.length).toBe(0) expect(connector.outputLinks.length).toBe(0) expect(disconnectedNode.outputs[0].links).toEqual(nextLinkIds) expect(hasOutputNode.outputs[0].links).toEqual([]) const reroutesAfter = disconnectedNode.outputs[0].links ?.map((linkId) => graph.links.get(linkId)!) .map((link) => LLink.getReroutes(graph, link)) expect(reroutesAfter).toEqual(reroutesBefore) }) test('Should connect to floating reroutes from outputs', ({ graph, connector, reroutesBeforeTest }) => { const nextLinkIds = [graph.last_link_id + 1, graph.last_link_id + 2] const floatingOutNode = graph.getNodeById(1)! floatingOutNode.disconnectOutput(0) // Should have lost one reroute expect(graph.reroutes.size).toBe(reroutesBeforeTest.length - 1) expect(graph.reroutes.get(1)).toBeUndefined() // The two normal links should now be floating expect(graph.floatingLinks.size).toBe(2) const disconnectedNode = graph.getNodeById(9)! connector.dragNewFromOutput( graph, disconnectedNode, disconnectedNode.outputs[0] ) expect(connector.state.connectingTo).toBe('input') expect(connector.state.draggingExistingLinks).toBe(false) expect(connector.renderLinks.length).toBe(1) expect(connector.outputLinks.length).toBe(0) expect(connector.floatingLinks.length).toBe(0) const floatingLink = graph.floatingLinks.values().next().value! expect(floatingLink).toBeInstanceOf(LLink) const floatingReroute = LLink.getReroutes(graph, floatingLink)[0] const canvasX = floatingReroute.pos[0] const canvasY = floatingReroute.pos[1] const dropEvent = { canvasX, canvasY } as any connector.dropLinks(graph, dropEvent) connector.reset() expect(connector.renderLinks.length).toBe(0) expect(connector.outputLinks.length).toBe(0) // New link should have been created expect(disconnectedNode.outputs[0].links).toEqual(nextLinkIds) // Check graph integrity expect(graph.floatingLinks.size).toBe(0) expect([...graph.reroutes]).toEqual(reroutesBeforeTest.slice(1)) for (const reroute of graph.reroutes.values()) { expect(reroute.floating).toBeUndefined() } }) test('Should drop floating links when both sides are disconnected', ({ graph, reroutesBeforeTest }) => { expect(graph.floatingLinks.size).toBe(1) graph.getNodeById(2)!.disconnectInput(0, true) expect(graph.floatingLinks.size).toBe(1) // Only the original reroute should be floating const reroutesExceptOne = [...graph.reroutes.values()].filter( (reroute) => reroute.id !== 1 ) for (const reroute of reroutesExceptOne) { expect(reroute.floating).toBeUndefined() } graph.getNodeById(3)!.disconnectInput(0, true) expect([...graph.reroutes]).toEqual(reroutesBeforeTest) // The normal link should now be floating expect(graph.floatingLinks.size).toBe(2) expect(graph.reroutes.get(3)!.floating).toEqual({ slotType: 'output' }) const floatingOutNode = graph.getNodeById(1)! floatingOutNode.disconnectOutput(0) // Should have lost one reroute expect(graph.reroutes.size).toBe(9) expect(graph.reroutes.get(1)).toBeUndefined() // Removed 4 reroutes expect(graph.reroutes.size).toBe(9) // All four nodes should have no links for (const nodeId of [1, 2, 3, 9]) { const { inputs: [input], outputs: [output] } = graph.getNodeById(nodeId)! expect(input.link).toBeNull() expect(output.links?.length).toBeOneOf([0, undefined]) expect(input._floatingLinks?.size).toBeOneOf([0, undefined]) expect(output._floatingLinks?.size).toBeOneOf([0, undefined]) } }) test('Should support moving multiple output links to a floating reroute', ({ graph, connector, floatingReroute, validateIntegrityFloatingRemoved }) => { const manyOutputsNode = graph.getNodeById(4)! const canvasX = floatingReroute.pos[0] const canvasY = floatingReroute.pos[1] const floatingRerouteEvent = { canvasX, canvasY } as any connector.moveOutputLink(graph, manyOutputsNode.outputs[0]) connector.dropLinks(graph, floatingRerouteEvent) connector.reset() expect(manyOutputsNode.outputs[0].links).toEqual([]) expect(floatingReroute.linkIds.size).toBe(4) validateIntegrityFloatingRemoved() }) test('Should prevent dragging from an output to a child reroute', ({ graph, connector, floatingReroute }) => { const manyOutputsNode = graph.getNodeById(4)! const reroute7 = graph.reroutes.get(7)! const reroute10 = graph.reroutes.get(10)! const reroute13 = graph.reroutes.get(13)! const canvasX = reroute7.pos[0] const canvasY = reroute7.pos[1] const reroute7Event = { canvasX, canvasY } as any const toSortedRerouteChain = (linkIds: number[]) => linkIds .map((x) => graph.links.get(x)!) .map((x) => LLink.getReroutes(graph, x)) .sort((a, b) => a.at(-1)!.id - b.at(-1)!.id) const reroutesBefore = toSortedRerouteChain( manyOutputsNode.outputs[0].links! ) connector.moveOutputLink(graph, manyOutputsNode.outputs[0]) expect(connector.isRerouteValidDrop(reroute7)).toBe(false) expect(connector.isRerouteValidDrop(reroute10)).toBe(false) expect(connector.isRerouteValidDrop(reroute13)).toBe(false) // Prevent link disconnect when dropped on canvas (just for this test) connector.events.addEventListener( 'dropped-on-canvas', (e) => e.preventDefault(), { once: true } ) connector.dropLinks(graph, reroute7Event) connector.reset() const reroutesAfter = toSortedRerouteChain( manyOutputsNode.outputs[0].links! ) expect(reroutesAfter).toEqual(reroutesBefore) expect(graph.floatingLinks.size).toBe(1) expect(floatingReroute.linkIds.size).toBe(0) }) test('Should prevent node loopback when dropping on node', ({ graph, connector }) => { const hasOutputNode = graph.getNodeById(1)! const hasInputNode = graph.getNodeById(2)! const reroutesBefore = LLink.getReroutes( graph, graph.links.get(hasOutputNode.outputs[0].links![0])! ) const atInputNodeEvent = mockedNodeTitleDropEvent(hasInputNode) connector.moveOutputLink(graph, hasOutputNode.outputs[0]) connector.dropLinks(graph, atInputNodeEvent) connector.reset() expect(hasOutputNode.getOutputNodes(0)).toEqual([hasInputNode]) expect(hasInputNode.getOutputNodes(0)).toEqual([graph.getNodeById(3)]) // Moved link should have the same reroutes const reroutesAfter = LLink.getReroutes( graph, graph.links.get(hasInputNode.outputs[0].links![0])! ) expect(reroutesAfter).toEqual(reroutesBefore) // Link recreated to avoid loopback should have no reroutes const reroutesAfter2 = LLink.getReroutes( graph, graph.links.get(hasOutputNode.outputs[0].links![0])! ) expect(reroutesAfter2).toEqual([]) }) test('Should prevent node loopback when dropping on output', ({ graph, connector }) => { const hasOutputNode = graph.getNodeById(1)! const hasInputNode = graph.getNodeById(2)! const reroutesBefore = LLink.getReroutes( graph, graph.links.get(hasOutputNode.outputs[0].links![0])! ) const atInputNodeOutSlot = mockedOutputDropEvent(hasInputNode, 0) connector.moveOutputLink(graph, hasOutputNode.outputs[0]) connector.dropLinks(graph, atInputNodeOutSlot) connector.reset() expect(hasOutputNode.getOutputNodes(0)).toEqual([hasInputNode]) expect(hasInputNode.getOutputNodes(0)).toEqual([graph.getNodeById(3)]) // Moved link should have the same reroutes const reroutesAfter = LLink.getReroutes( graph, graph.links.get(hasInputNode.outputs[0].links![0])! ) expect(reroutesAfter).toEqual(reroutesBefore) // Link recreated to avoid loopback should have no reroutes const reroutesAfter2 = LLink.getReroutes( graph, graph.links.get(hasOutputNode.outputs[0].links![0])! ) expect(reroutesAfter2).toEqual([]) }) }) describe('Floating links', () => { test('Removed when connecting from reroute to input', ({ graph, connector, floatingReroute }) => { const disconnectedNode = graph.getNodeById(9)! const canvasX = disconnectedNode.pos[0] const canvasY = disconnectedNode.pos[1] connector.dragFromReroute(graph, floatingReroute) connector.dropLinks(graph, { canvasX, canvasY } as any) connector.reset() expect(graph.floatingLinks.size).toBe(0) expect(floatingReroute.floating).toBeUndefined() }) test('Removed when connecting from reroute to another reroute', ({ graph, connector, floatingReroute, validateIntegrityFloatingRemoved }) => { const reroute8 = graph.reroutes.get(8)! const canvasX = reroute8.pos[0] const canvasY = reroute8.pos[1] connector.dragFromReroute(graph, floatingReroute) connector.dropLinks(graph, { canvasX, canvasY } as any) connector.reset() expect(graph.floatingLinks.size).toBe(0) expect(floatingReroute.floating).toBeUndefined() expect(reroute8.floating).toBeUndefined() validateIntegrityFloatingRemoved() }) test('Dropping a floating input link onto input slot disconnects the existing link', ({ graph, connector }) => { const manyOutputsNode = graph.getNodeById(4)! manyOutputsNode.disconnectOutput(0) const floatingInputNode = graph.getNodeById(6)! const fromFloatingInput = floatingInputNode.inputs[0] const hasInputNode = graph.getNodeById(2)! const toInput = hasInputNode.inputs[0] connector.moveInputLink(graph, fromFloatingInput) const dropEvent = mockedInputDropEvent(hasInputNode, 0) connector.dropLinks(graph, dropEvent) connector.reset() expect(fromFloatingInput.link).toBeNull() expect(fromFloatingInput._floatingLinks?.size).toBe(0) expect(toInput.link).toBeNull() expect(toInput._floatingLinks?.size).toBe(1) }) test('Allow reroutes to be used as manual switches', ({ graph, connector, floatingReroute, validateIntegrityNoChanges }) => { const rerouteWithTwoLinks = graph.reroutes.get(3)! const targetNode = graph.getNodeById(2)! const targetDropEvent = mockedInputDropEvent(targetNode, 0) connector.dragFromReroute(graph, floatingReroute) connector.dropLinks(graph, targetDropEvent) connector.reset() // Link should have been moved to the floating reroute, and no floating links should remain expect(rerouteWithTwoLinks.floating).toBeUndefined() expect(floatingReroute.floating).toBeUndefined() expect(rerouteWithTwoLinks.floatingLinkIds.size).toBe(0) expect(floatingReroute.floatingLinkIds.size).toBe(0) expect(rerouteWithTwoLinks.linkIds.size).toBe(1) expect(floatingReroute.linkIds.size).toBe(1) // Move the link again connector.dragFromReroute(graph, rerouteWithTwoLinks) connector.dropLinks(graph, targetDropEvent) connector.reset() // Everything should be back the way it was when we started expect(rerouteWithTwoLinks.floating).toBeUndefined() expect(floatingReroute.floating).toEqual({ slotType: 'output' }) expect(rerouteWithTwoLinks.floatingLinkIds.size).toBe(0) expect(floatingReroute.floatingLinkIds.size).toBe(1) expect(rerouteWithTwoLinks.linkIds.size).toBe(2) expect(floatingReroute.linkIds.size).toBe(0) validateIntegrityNoChanges() }) }) test('Should drop floating links when both sides are disconnected', ({ graph, connector, reroutesBeforeTest, validateIntegrityNoChanges }) => { const floatingOutNode = graph.getNodeById(1)! connector.moveOutputLink(graph, floatingOutNode.outputs[0]) const manyOutputsNode = graph.getNodeById(4)! const dropEvent = { canvasX: manyOutputsNode.pos[0], canvasY: manyOutputsNode.pos[1] } as any connector.dropLinks(graph, dropEvent) connector.reset() const output = manyOutputsNode.outputs[0] expect(output.links!.length).toBe(6) expect(output._floatingLinks!.size).toBe(1) validateIntegrityNoChanges() // Move again connector.moveOutputLink(graph, manyOutputsNode.outputs[0]) const disconnectedNode = graph.getNodeById(9)! dropEvent.canvasX = disconnectedNode.pos[0] dropEvent.canvasY = disconnectedNode.pos[1] connector.dropLinks(graph, dropEvent) connector.reset() const newOutput = disconnectedNode.outputs[0] expect(newOutput.links!.length).toBe(6) expect(newOutput._floatingLinks!.size).toBe(1) validateIntegrityNoChanges() disconnectedNode.disconnectOutput(0) expect(newOutput._floatingLinks!.size).toBe(0) expect(graph.floatingLinks.size).toBe(6) // The final reroutes should all be floating for (const reroute of graph.reroutes.values()) { if ([3, 7, 15, 12].includes(reroute.id)) { expect(reroute.floating).toEqual({ slotType: 'input' }) } else { expect(reroute.floating).toBeUndefined() } } // Removed one reroute expect(graph.reroutes.size).toBe(reroutesBeforeTest.length - 1) // Original nodes should have no links for (const nodeId of [1, 4]) { const { inputs: [input], outputs: [output] } = graph.getNodeById(nodeId)! expect(input.link).toBeNull() expect(output.links?.length).toBeOneOf([0, undefined]) expect(input._floatingLinks?.size).toBeOneOf([0, undefined]) expect(output._floatingLinks?.size).toBeOneOf([0, undefined]) } }) type TestData = { /** Drop link on this reroute */ targetRerouteId: number /** Parent reroutes of the target reroute */ parentIds: number[] /** Number of links before the drop */ linksBefore: number[] /** Number of links after the drop */ linksAfter: (number | undefined)[] /** Whether to run the integrity check */ runIntegrityCheck: boolean } test.for([ { targetRerouteId: 8, parentIds: [13, 10], linksBefore: [3, 4], linksAfter: [1, 2], runIntegrityCheck: true }, { targetRerouteId: 7, parentIds: [6, 8, 13, 10], linksBefore: [2, 2, 3, 4], linksAfter: [undefined, undefined, 1, 2], runIntegrityCheck: false }, { targetRerouteId: 6, parentIds: [8, 13, 10], linksBefore: [2, 3, 4], linksAfter: [undefined, 1, 2], runIntegrityCheck: false }, { targetRerouteId: 13, parentIds: [10], linksBefore: [4], linksAfter: [1], runIntegrityCheck: true }, { targetRerouteId: 4, parentIds: [], linksBefore: [], linksAfter: [], runIntegrityCheck: true }, { targetRerouteId: 2, parentIds: [4], linksBefore: [2], linksAfter: [undefined], runIntegrityCheck: false }, { targetRerouteId: 3, parentIds: [2, 4], linksBefore: [2, 2], linksAfter: [0, 0], runIntegrityCheck: true } ])( 'Should allow reconnect from output to any reroute', ( { targetRerouteId, parentIds, linksBefore, linksAfter, runIntegrityCheck }, { graph, connector, validateIntegrityNoChanges, getNextLinkIds } ) => { const linkCreatedCallback = vi.fn() connector.listenUntilReset('link-created', linkCreatedCallback) const disconnectedNode = graph.getNodeById(9)! // Parent reroutes of the target reroute for (const [index, parentId] of parentIds.entries()) { const reroute = graph.reroutes.get(parentId)! expect(reroute.linkIds.size).toBe(linksBefore[index]) } const targetReroute = graph.reroutes.get(targetRerouteId)! const nextLinkIds = getNextLinkIds(targetReroute.linkIds) const dropEvent = { canvasX: targetReroute.pos[0], canvasY: targetReroute.pos[1] } as any connector.dragNewFromOutput( graph, disconnectedNode, disconnectedNode.outputs[0] ) connector.dropLinks(graph, dropEvent) connector.reset() expect(disconnectedNode.outputs[0].links).toEqual(nextLinkIds) expect([...targetReroute.linkIds.values()]).toEqual(nextLinkIds) // Parent reroutes should have lost the links or been removed for (const [index, parentId] of parentIds.entries()) { const reroute = graph.reroutes.get(parentId)! if (linksAfter[index] === undefined) { expect(reroute).not.toBeUndefined() } else { expect(reroute.linkIds.size).toBe(linksAfter[index]) } } expect(linkCreatedCallback).toHaveBeenCalledTimes(nextLinkIds.length) if (runIntegrityCheck) { validateIntegrityNoChanges() } } ) type ReconnectTestData = { /** Drag link from this reroute */ fromRerouteId: number /** Drop link on this reroute */ toRerouteId: number /** Reroute IDs that should be removed from the resultant reroute chain */ shouldBeRemoved: number[] /** Reroutes that should have NONE of the link IDs that toReroute has */ shouldHaveLinkIdsRemoved: number[] /** Whether to test floating inputs */ testFloatingInputs?: true /** Number of expected extra links to be created */ expectedExtraLinks?: number } test.for([ { fromRerouteId: 10, toRerouteId: 15, shouldBeRemoved: [14], shouldHaveLinkIdsRemoved: [13, 8, 6, 7] }, { fromRerouteId: 8, toRerouteId: 2, shouldBeRemoved: [4], shouldHaveLinkIdsRemoved: [] }, { fromRerouteId: 3, toRerouteId: 12, shouldBeRemoved: [11], shouldHaveLinkIdsRemoved: [10, 13, 14, 15, 8, 6, 7] }, { fromRerouteId: 15, toRerouteId: 7, shouldBeRemoved: [8, 6], shouldHaveLinkIdsRemoved: [] }, { fromRerouteId: 1, toRerouteId: 7, shouldBeRemoved: [8, 6], shouldHaveLinkIdsRemoved: [] }, { fromRerouteId: 1, toRerouteId: 10, shouldBeRemoved: [], shouldHaveLinkIdsRemoved: [] }, { fromRerouteId: 4, toRerouteId: 8, shouldBeRemoved: [], shouldHaveLinkIdsRemoved: [], testFloatingInputs: true, expectedExtraLinks: 2 }, { fromRerouteId: 2, toRerouteId: 12, shouldBeRemoved: [11], shouldHaveLinkIdsRemoved: [], testFloatingInputs: true, expectedExtraLinks: 1 } ])( 'Should allow connecting from reroutes to another reroute', ( { fromRerouteId, toRerouteId, shouldBeRemoved, shouldHaveLinkIdsRemoved, testFloatingInputs, expectedExtraLinks }, { graph, connector, getNextLinkIds } ) => { if (testFloatingInputs) { // Start by disconnecting the output of the 3x3 array of reroutes graph.getNodeById(4)!.disconnectOutput(0) } const fromReroute = graph.reroutes.get(fromRerouteId)! const toReroute = graph.reroutes.get(toRerouteId)! const nextLinkIds = getNextLinkIds(toReroute.linkIds, expectedExtraLinks) const originalParentChain = LLink.getReroutes(graph, toReroute) const sortAndJoin = (numbers: Iterable) => [...numbers].sort().join(',') const hasIdenticalLinks = (a: Reroute, b: Reroute) => sortAndJoin(a.linkIds) === sortAndJoin(b.linkIds) && sortAndJoin(a.floatingLinkIds) === sortAndJoin(b.floatingLinkIds) // Sanity check shouldBeRemoved const reroutesWithIdenticalLinkIds = originalParentChain.filter( (parent) => hasIdenticalLinks(parent, toReroute) ) expect(reroutesWithIdenticalLinkIds.map((reroute) => reroute.id)).toEqual( shouldBeRemoved ) connector.dragFromReroute(graph, fromReroute) const dropEvent = { canvasX: toReroute.pos[0], canvasY: toReroute.pos[1] } as any connector.dropLinks(graph, dropEvent) connector.reset() const newParentChain = LLink.getReroutes(graph, toReroute) for (const rerouteId of shouldBeRemoved) { expect(originalParentChain.map((reroute) => reroute.id)).toContain( rerouteId ) expect(newParentChain.map((reroute) => reroute.id)).not.toContain( rerouteId ) } expect([...toReroute.linkIds.values()]).toEqual(nextLinkIds) for (const rerouteId of shouldBeRemoved) { const reroute = graph.reroutes.get(rerouteId)! if (testFloatingInputs) { // Already-floating reroutes should be removed expect(reroute).toBeUndefined() } else { // Non-floating reroutes should still exist expect(reroute).not.toBeUndefined() } } for (const rerouteId of shouldHaveLinkIdsRemoved) { const reroute = graph.reroutes.get(rerouteId)! for (const linkId of toReroute.linkIds) { expect(reroute.linkIds).not.toContain(linkId) } } // Validate all links in a reroute share the same origin for (const reroute of graph.reroutes.values()) { for (const linkId of reroute.linkIds) { const link = graph.links.get(linkId) expect(link?.origin_id).toEqual(reroute.origin_id) expect(link?.origin_slot).toEqual(reroute.origin_slot) } for (const linkId of reroute.floatingLinkIds) { if (reroute.origin_id === undefined) continue const link = graph.floatingLinks.get(linkId) expect(link?.origin_id).toEqual(reroute.origin_id) expect(link?.origin_slot).toEqual(reroute.origin_slot) } } } ) test.for([ { from: 8, to: 13 }, { from: 7, to: 13 }, { from: 6, to: 13 }, { from: 13, to: 10 }, { from: 14, to: 10 }, { from: 15, to: 10 }, { from: 14, to: 13 }, { from: 10, to: 10 } ])( 'Connecting reroutes to invalid targets should do nothing', ({ from, to }, { graph, connector, validateIntegrityNoChanges }) => { const listener = vi.fn() connector.listenUntilReset('link-created', listener) const fromReroute = graph.reroutes.get(from)! const toReroute = graph.reroutes.get(to)! const dropEvent = { canvasX: toReroute.pos[0], canvasY: toReroute.pos[1] } as any connector.dragFromReroute(graph, fromReroute) connector.dropLinks(graph, dropEvent) connector.reset() expect(listener).not.toHaveBeenCalled() validateIntegrityNoChanges() } ) const nodeReroutePairs = [ { nodeId: 1, rerouteId: 1 }, { nodeId: 1, rerouteId: 3 }, { nodeId: 1, rerouteId: 4 }, { nodeId: 1, rerouteId: 2 }, { nodeId: 4, rerouteId: 7 }, { nodeId: 4, rerouteId: 6 }, { nodeId: 4, rerouteId: 8 }, { nodeId: 4, rerouteId: 10 }, { nodeId: 4, rerouteId: 12 } ] test.for(nodeReroutePairs)( 'Should ignore connections from input to same node via reroutes', ( { nodeId, rerouteId }, { graph, connector, validateIntegrityNoChanges } ) => { const listener = vi.fn() connector.listenUntilReset('link-created', listener) const node = graph.getNodeById(nodeId)! const input = node.inputs[0] const reroute = graph.getReroute(rerouteId)! const dropEvent = { canvasX: reroute.pos[0], canvasY: reroute.pos[1] } as any connector.dragNewFromInput(graph, node, input) connector.dropLinks(graph, dropEvent) connector.reset() expect(listener).not.toHaveBeenCalled() validateIntegrityNoChanges() // No links should have the same origin_id and target_id for (const link of graph.links.values()) { expect(link.origin_id).not.toEqual(link.target_id) } } ) test.for(nodeReroutePairs)( 'Should ignore connections looping back to the origin node from a reroute', ( { nodeId, rerouteId }, { graph, connector, validateIntegrityNoChanges } ) => { const listener = vi.fn() connector.listenUntilReset('link-created', listener) const node = graph.getNodeById(nodeId)! const reroute = graph.getReroute(rerouteId)! const dropEvent = { canvasX: node.pos[0], canvasY: node.pos[1] } as any connector.dragFromReroute(graph, reroute) connector.dropLinks(graph, dropEvent) connector.reset() expect(listener).not.toHaveBeenCalled() validateIntegrityNoChanges() // No links should have the same origin_id and target_id for (const link of graph.links.values()) { expect(link.origin_id).not.toEqual(link.target_id) } } ) test.for(nodeReroutePairs)( 'Should ignore connections looping back to the origin node input from a reroute', ( { nodeId, rerouteId }, { graph, connector, validateIntegrityNoChanges } ) => { const listener = vi.fn() connector.listenUntilReset('link-created', listener) const node = graph.getNodeById(nodeId)! const reroute = graph.getReroute(rerouteId)! const inputPos = node.getInputPos(0) const dropOnInputEvent = { canvasX: inputPos[0], canvasY: inputPos[1] } as any connector.dragFromReroute(graph, reroute) connector.dropLinks(graph, dropOnInputEvent) connector.reset() expect(listener).not.toHaveBeenCalled() validateIntegrityNoChanges() // No links should have the same origin_id and target_id for (const link of graph.links.values()) { expect(link.origin_id).not.toEqual(link.target_id) } } ) })