mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-02-05 23:50:08 +00:00
- Resolves https://github.com/Comfy-Org/ComfyUI_frontend/issues/3247 Bypasses the logic that automatically removed reroutes that had no remaining links. Reroutes are now always converted to floating whenever reroutes are reconnected.
995 lines
35 KiB
TypeScript
995 lines
35 KiB
TypeScript
import type { CanvasPointerEvent } from "@/types/events"
|
|
|
|
import { afterEach, describe, expect, vi } from "vitest"
|
|
|
|
import { LinkConnector } from "@/canvas/LinkConnector"
|
|
import { LGraph } from "@/LGraph"
|
|
import { LGraphNode } from "@/LGraphNode"
|
|
import { LLink } from "@/LLink"
|
|
import { Reroute, type RerouteId } from "@/Reroute"
|
|
|
|
import { test as baseTest } from "./testExtensions"
|
|
|
|
interface TestContext {
|
|
graph: LGraph
|
|
connector: LinkConnector
|
|
setConnectingLinks: ReturnType<typeof vi.fn>
|
|
createTestNode: (id: number) => LGraphNode
|
|
reroutesBeforeTest: [rerouteId: RerouteId, reroute: Reroute][]
|
|
validateIntegrityNoChanges: () => void
|
|
validateIntegrityFloatingRemoved: () => void
|
|
validateLinkIntegrity: () => void
|
|
getNextLinkIds: (linkIds: Set<number>, expectedExtraLinks?: number) => number[]
|
|
readonly floatingReroute: Reroute
|
|
}
|
|
|
|
const test = baseTest.extend<TestContext>({
|
|
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 ({}, use: (mock: ReturnType<typeof vi.fn>) => Promise<void>) => {
|
|
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)
|
|
}
|
|
}
|
|
}
|
|
})
|
|
},
|
|
|
|
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<TestContext>(({ 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
|
|
|
|
connector.dropLinks(graph, dropEvent)
|
|
expect(connector.renderLinks.length).toBe(0)
|
|
expect(connector.inputLinks.length).toBe(0)
|
|
|
|
expect(disconnectedNode.inputs[0].link).toBe(nextLinkId)
|
|
|
|
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)
|
|
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)
|
|
|
|
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)
|
|
|
|
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)
|
|
expect(connector.renderLinks.length).toBe(0)
|
|
expect(connector.outputLinks.length).toBe(0)
|
|
|
|
expect(disconnectedNode.outputs[0].links).toEqual(nextLinkIds)
|
|
|
|
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)
|
|
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)
|
|
|
|
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)
|
|
connector.dropLinks(graph, reroute7Event)
|
|
|
|
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)
|
|
|
|
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)
|
|
|
|
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)
|
|
|
|
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)
|
|
|
|
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)
|
|
|
|
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)
|
|
|
|
// 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)
|
|
|
|
// 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)
|
|
|
|
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)
|
|
|
|
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<TestData>([
|
|
{
|
|
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)
|
|
|
|
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).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<ReconnectTestData>([
|
|
{
|
|
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<number>) => [...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)
|
|
|
|
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)
|
|
|
|
// Parent reroutes should have lost the links or been removed
|
|
for (const rerouteId of shouldBeRemoved) {
|
|
const reroute = graph.reroutes.get(rerouteId)!
|
|
expect(reroute).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)
|
|
|
|
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)
|
|
|
|
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)
|
|
|
|
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)
|
|
|
|
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)
|
|
}
|
|
})
|
|
})
|