diff --git a/test/LGraph.test.ts b/test/LGraph.test.ts index 39b1cee702..d77e37a775 100644 --- a/test/LGraph.test.ts +++ b/test/LGraph.test.ts @@ -32,84 +32,84 @@ describe("LGraph", () => { const fromOldSchema = new LGraph(oldSchemaGraph) expect(fromOldSchema).toMatchSnapshot("oldSchemaGraph") }) +}) - describe("Reroutes", () => { - test("Floating reroute should be removed when node and link are removed", ({ expect, floatingLinkGraph }) => { - const graph = new LGraph(floatingLinkGraph) - expect(graph.nodes.length).toBe(1) - graph.remove(graph.nodes[0]) - expect(graph.nodes.length).toBe(0) - expect(graph.links.size).toBe(0) - expect(graph.floatingLinks.size).toBe(0) - expect(graph.reroutes.size).toBe(0) - }) +describe("Floating Links / Reroutes", () => { + test("Floating reroute should be removed when node and link are removed", ({ expect, floatingLinkGraph }) => { + const graph = new LGraph(floatingLinkGraph) + expect(graph.nodes.length).toBe(1) + graph.remove(graph.nodes[0]) + expect(graph.nodes.length).toBe(0) + expect(graph.links.size).toBe(0) + expect(graph.floatingLinks.size).toBe(0) + expect(graph.reroutes.size).toBe(0) + }) - test("Can add reroute to existing link", ({ expect, linkedNodesGraph }) => { - const graph = new LGraph(linkedNodesGraph) - expect(graph.nodes.length).toBe(2) - expect(graph.links.size).toBe(1) - expect(graph.reroutes.size).toBe(0) + test("Can add reroute to existing link", ({ expect, linkedNodesGraph }) => { + const graph = new LGraph(linkedNodesGraph) + expect(graph.nodes.length).toBe(2) + expect(graph.links.size).toBe(1) + expect(graph.reroutes.size).toBe(0) - graph.createReroute([0, 0], graph.links.values().next().value!) - expect(graph.links.size).toBe(1) - expect(graph.reroutes.size).toBe(1) - }) + graph.createReroute([0, 0], graph.links.values().next().value!) + expect(graph.links.size).toBe(1) + expect(graph.reroutes.size).toBe(1) + }) - test("Create floating reroute when one side of node is removed", ({ expect, linkedNodesGraph }) => { - const graph = new LGraph(linkedNodesGraph) - graph.createReroute([0, 0], graph.links.values().next().value!) - graph.remove(graph.nodes[0]) + test("Create floating reroute when one side of node is removed", ({ expect, linkedNodesGraph }) => { + const graph = new LGraph(linkedNodesGraph) + graph.createReroute([0, 0], graph.links.values().next().value!) + graph.remove(graph.nodes[0]) - expect(graph.links.size).toBe(0) - expect(graph.floatingLinks.size).toBe(1) - expect(graph.reroutes.size).toBe(1) - expect(graph.reroutes.values().next().value!.floating).not.toBeUndefined() - }) + expect(graph.links.size).toBe(0) + expect(graph.floatingLinks.size).toBe(1) + expect(graph.reroutes.size).toBe(1) + expect(graph.reroutes.values().next().value!.floating).not.toBeUndefined() + }) - test("Create floating reroute when one side of link is removed", ({ expect, linkedNodesGraph }) => { - const graph = new LGraph(linkedNodesGraph) - graph.createReroute([0, 0], graph.links.values().next().value!) - graph.nodes[0].disconnectOutput(0) + test("Create floating reroute when one side of link is removed", ({ expect, linkedNodesGraph }) => { + const graph = new LGraph(linkedNodesGraph) + graph.createReroute([0, 0], graph.links.values().next().value!) + graph.nodes[0].disconnectOutput(0) - expect(graph.links.size).toBe(0) - expect(graph.floatingLinks.size).toBe(1) - expect(graph.reroutes.size).toBe(1) - expect(graph.reroutes.values().next().value!.floating).not.toBeUndefined() - }) + expect(graph.links.size).toBe(0) + expect(graph.floatingLinks.size).toBe(1) + expect(graph.reroutes.size).toBe(1) + expect(graph.reroutes.values().next().value!.floating).not.toBeUndefined() + }) - test("Reroutes and branches should be retained when the input node is removed", ({ expect, floatingBranchGraph: graph }) => { - expect(graph.nodes.length).toBe(3) - graph.remove(graph.nodes[2]) - expect(graph.nodes.length).toBe(2) - expect(graph.links.size).toBe(1) - expect(graph.floatingLinks.size).toBe(1) - expect(graph.reroutes.size).toBe(4) - graph.remove(graph.nodes[1]) - expect(graph.nodes.length).toBe(1) - expect(graph.links.size).toBe(0) - expect(graph.floatingLinks.size).toBe(2) - expect(graph.reroutes.size).toBe(4) - }) + test("Reroutes and branches should be retained when the input node is removed", ({ expect, floatingBranchGraph: graph }) => { + expect(graph.nodes.length).toBe(3) + graph.remove(graph.nodes[2]) + expect(graph.nodes.length).toBe(2) + expect(graph.links.size).toBe(1) + expect(graph.floatingLinks.size).toBe(1) + expect(graph.reroutes.size).toBe(4) + graph.remove(graph.nodes[1]) + expect(graph.nodes.length).toBe(1) + expect(graph.links.size).toBe(0) + expect(graph.floatingLinks.size).toBe(2) + expect(graph.reroutes.size).toBe(4) + }) - test("Floating reroutes should be removed when neither input nor output is connected", ({ expect, floatingBranchGraph: graph }) => { - // Remove output node - graph.remove(graph.nodes[0]) - expect(graph.nodes.length).toBe(2) - expect(graph.links.size).toBe(0) - expect(graph.floatingLinks.size).toBe(2) - // The original floating reroute should be removed - expect(graph.reroutes.size).toBe(3) - graph.remove(graph.nodes[0]) - expect(graph.nodes.length).toBe(1) - expect(graph.links.size).toBe(0) - expect(graph.floatingLinks.size).toBe(1) - expect(graph.reroutes.size).toBe(3) - graph.remove(graph.nodes[0]) - expect(graph.nodes.length).toBe(0) - expect(graph.links.size).toBe(0) - expect(graph.floatingLinks.size).toBe(0) - expect(graph.reroutes.size).toBe(0) - }) + test("Floating reroutes should be removed when neither input nor output is connected", ({ expect, floatingBranchGraph: graph }) => { + // Remove output node + graph.remove(graph.nodes[0]) + expect(graph.nodes.length).toBe(2) + expect(graph.links.size).toBe(0) + expect(graph.floatingLinks.size).toBe(2) + // The original floating reroute should be removed + expect(graph.reroutes.size).toBe(3) + graph.remove(graph.nodes[0]) + expect(graph.nodes.length).toBe(1) + expect(graph.links.size).toBe(0) + expect(graph.floatingLinks.size).toBe(1) + expect(graph.reroutes.size).toBe(3) + graph.remove(graph.nodes[0]) + expect(graph.nodes.length).toBe(0) + expect(graph.links.size).toBe(0) + expect(graph.floatingLinks.size).toBe(0) + expect(graph.reroutes.size).toBe(0) }) }) diff --git a/test/LinkConnector.integration.test.ts b/test/LinkConnector.integration.test.ts new file mode 100644 index 0000000000..aaca241488 --- /dev/null +++ b/test/LinkConnector.integration.test.ts @@ -0,0 +1,801 @@ +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 + 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 ({}, 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) + } + } + } + }) + }, + + 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) + }, +}) + +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 + + 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]) + } + }) + }) + + 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() + }) + }) + + 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("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([ + { + 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([ + { + 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) + + 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) + } + }) +}) diff --git a/test/assets/reroutesComplex.json b/test/assets/reroutesComplex.json new file mode 100644 index 0000000000..941228baf0 --- /dev/null +++ b/test/assets/reroutesComplex.json @@ -0,0 +1 @@ +{"id":"e5ffd5e1-1c01-45ac-90dd-b7d83a206b0f","revision":0,"last_node_id":9,"last_link_id":12,"nodes":[{"id":3,"type":"InvertMask","pos":[390,270],"size":[140,26],"flags":{},"order":8,"mode":0,"inputs":[{"localized_name":"mask","name":"mask","type":"MASK","link":3}],"outputs":[{"localized_name":"MASK","name":"MASK","type":"MASK","links":null}],"properties":{"Node name for S&R":"InvertMask"},"widgets_values":[]},{"id":7,"type":"InvertMask","pos":[390,560],"size":[140,26],"flags":{},"order":4,"mode":0,"inputs":[{"localized_name":"mask","name":"mask","type":"MASK","link":10}],"outputs":[{"localized_name":"MASK","name":"MASK","type":"MASK","links":null}],"properties":{"Node name for S&R":"InvertMask"},"widgets_values":[]},{"id":8,"type":"InvertMask","pos":[390,640],"size":[140,26],"flags":{},"order":3,"mode":0,"inputs":[{"localized_name":"mask","name":"mask","type":"MASK","link":9}],"outputs":[{"localized_name":"MASK","name":"MASK","type":"MASK","links":null}],"properties":{"Node name for S&R":"InvertMask"},"widgets_values":[]},{"id":5,"type":"InvertMask","pos":[390,480],"size":[140,26],"flags":{"collapsed":false},"order":5,"mode":0,"inputs":[{"localized_name":"mask","name":"mask","type":"MASK","link":11}],"outputs":[{"localized_name":"MASK","name":"MASK","type":"MASK","links":null}],"properties":{"Node name for S&R":"InvertMask"},"widgets_values":[]},{"id":6,"type":"InvertMask","pos":[390,400],"size":[140,26],"flags":{},"order":6,"mode":0,"inputs":[{"localized_name":"mask","name":"mask","type":"MASK","link":12}],"outputs":[{"localized_name":"MASK","name":"MASK","type":"MASK","links":null}],"properties":{"Node name for S&R":"InvertMask"},"widgets_values":[]},{"id":4,"type":"InvertMask","pos":[50,640],"size":[140,26],"flags":{},"order":0,"mode":0,"inputs":[{"localized_name":"mask","name":"mask","type":"MASK","link":null}],"outputs":[{"localized_name":"MASK","name":"MASK","type":"MASK","links":[9,10,11,12]}],"properties":{"Node name for S&R":"InvertMask"},"widgets_values":[]},{"id":2,"type":"InvertMask","pos":[390,180],"size":[140,26],"flags":{},"order":7,"mode":0,"inputs":[{"localized_name":"mask","name":"mask","type":"MASK","link":2}],"outputs":[{"localized_name":"MASK","name":"MASK","type":"MASK","links":null}],"properties":{"Node name for S&R":"InvertMask"},"widgets_values":[]},{"id":1,"type":"InvertMask","pos":[50,170],"size":[140,26],"flags":{},"order":2,"mode":0,"inputs":[{"localized_name":"mask","name":"mask","type":"MASK","link":null}],"outputs":[{"localized_name":"MASK","name":"MASK","type":"MASK","links":[2,3]}],"properties":{"Node name for S&R":"InvertMask"},"widgets_values":[]},{"id":9,"type":"InvertMask","pos":[50,410],"size":[140,26],"flags":{},"order":1,"mode":0,"inputs":[{"localized_name":"mask","name":"mask","type":"MASK","link":null}],"outputs":[{"localized_name":"MASK","name":"MASK","type":"MASK","links":[]}],"properties":{"Node name for S&R":"InvertMask"},"widgets_values":[]}],"links":[[2,1,0,2,0,"MASK"],[3,1,0,3,0,"MASK"],[9,4,0,8,0,"MASK"],[10,4,0,7,0,"MASK"],[11,4,0,5,0,"MASK"],[12,4,0,6,0,"MASK"]],"floatingLinks":[{"id":6,"origin_id":1,"origin_slot":0,"target_id":-1,"target_slot":-1,"type":"MASK","parentId":1}],"groups":[],"config":{},"extra":{"ds":{"scale":1,"offset":[0,0]},"linkExtensions":[{"id":2,"parentId":3},{"id":3,"parentId":3},{"id":9,"parentId":12},{"id":10,"parentId":15},{"id":11,"parentId":7},{"id":12,"parentId":7}],"reroutes":[{"id":1,"parentId":2,"pos":[340,160],"linkIds":[],"floating":{"slotType":"output"}},{"id":2,"parentId":4,"pos":[290,190],"linkIds":[2,3]},{"id":3,"parentId":2,"pos":[350,220],"linkIds":[2,3]},{"id":4,"pos":[250,190],"linkIds":[2,3]},{"id":6,"parentId":8,"pos":[300,450],"linkIds":[11,12]},{"id":7,"parentId":6,"pos":[350,450],"linkIds":[11,12]},{"id":8,"parentId":13,"pos":[250,450],"linkIds":[11,12]},{"id":10,"pos":[250,650],"linkIds":[9,10,11,12]},{"id":11,"parentId":10,"pos":[300,650],"linkIds":[9]},{"id":12,"parentId":11,"pos":[350,650],"linkIds":[9]},{"id":13,"parentId":10,"pos":[250,570],"linkIds":[10,11,12]},{"id":14,"parentId":13,"pos":[300,570],"linkIds":[10]},{"id":15,"parentId":14,"pos":[350,570],"linkIds":[10]}]},"version":0.4} \ No newline at end of file diff --git a/test/testExtensions.ts b/test/testExtensions.ts index 8f627b3a4a..49e238d60d 100644 --- a/test/testExtensions.ts +++ b/test/testExtensions.ts @@ -8,6 +8,7 @@ import { LiteGraph } from "@/litegraph" import floatingBranch from "./assets/floatingBranch.json" import floatingLink from "./assets/floatingLink.json" import linkedNodes from "./assets/linkedNodes.json" +import reroutesComplex from "./assets/reroutesComplex.json" import { basicSerialisableGraph, minimalSerialisableGraph, oldSchemaGraph } from "./assets/testGraphs" interface LitegraphFixtures { @@ -16,8 +17,8 @@ interface LitegraphFixtures { oldSchemaGraph: ISerialisedGraph floatingLinkGraph: ISerialisedGraph linkedNodesGraph: ISerialisedGraph - floatingBranchSerialisedGraph: ISerialisedGraph floatingBranchGraph: LGraph + reroutesComplexGraph: LGraph } /** These fixtures alter global state, and are difficult to reset. Relies on a single test per-file to reset state. */ @@ -38,9 +39,13 @@ export const test = baseTest.extend({ oldSchemaGraph: structuredClone(oldSchemaGraph), floatingLinkGraph: structuredClone(floatingLink as unknown as ISerialisedGraph), linkedNodesGraph: structuredClone(linkedNodes as unknown as ISerialisedGraph), - floatingBranchSerialisedGraph: structuredClone(floatingBranch as unknown as ISerialisedGraph), - floatingBranchGraph: async ({ floatingBranchSerialisedGraph }, use) => { - const cloned = structuredClone(floatingBranchSerialisedGraph) + floatingBranchGraph: async ({}, use) => { + const cloned = structuredClone(floatingBranch as unknown as ISerialisedGraph) + const graph = new LGraph(cloned) + await use(graph) + }, + reroutesComplexGraph: async ({}, use) => { + const cloned = structuredClone(reroutesComplex as unknown as ISerialisedGraph) const graph = new LGraph(cloned) await use(graph) },