diff --git a/understand-anything-plugin/packages/dashboard/src/utils/__tests__/elk-layout.test.ts b/understand-anything-plugin/packages/dashboard/src/utils/__tests__/elk-layout.test.ts index fc13b597..0274a9d6 100644 --- a/understand-anything-plugin/packages/dashboard/src/utils/__tests__/elk-layout.test.ts +++ b/understand-anything-plugin/packages/dashboard/src/utils/__tests__/elk-layout.test.ts @@ -42,6 +42,21 @@ describe("repairElkInput", () => { expect(issues.some((i) => i.level === "dropped" && /edge/.test(i.message))).toBe(true); }); + it("drops edges pointing at children removed by the orphan-child pass", () => { + const input: ElkInput = { + id: "root", + children: [ + { id: "a", width: 1, height: 1 }, + { id: "orphan", width: 1, height: 1, parentId: "ghost" } as ElkInput["children"][0] & { parentId: string }, + ], + edges: [{ id: "e1", sources: ["a"], targets: ["orphan"] }], + }; + const { input: out, issues } = repairElkInput(input); + expect(out.children!.find((c) => c.id === "orphan")).toBeUndefined(); + expect(out.edges).toHaveLength(0); + expect(issues.some((i) => i.level === "dropped" && i.category === "elk-orphan-edge")).toBe(true); + }); + it("drops children referencing nonexistent parents", () => { const input: ElkInput = { id: "root", diff --git a/understand-anything-plugin/packages/dashboard/src/utils/elk-layout.ts b/understand-anything-plugin/packages/dashboard/src/utils/elk-layout.ts index 8bc9f862..ade4e1af 100644 --- a/understand-anything-plugin/packages/dashboard/src/utils/elk-layout.ts +++ b/understand-anything-plugin/packages/dashboard/src/utils/elk-layout.ts @@ -140,28 +140,7 @@ export function repairElkInput( maybeThrow(strict, issue); } - // 4. dropOrphanEdges - let orphanEdges = 0; - const edges = input.edges.filter((e) => { - const ok = e.sources.every((s) => allIds.has(s)) && - e.targets.every((t) => allIds.has(t)); - if (!ok) { - orphanEdges++; - return false; - } - return true; - }); - if (orphanEdges > 0) { - const issue = makeIssue( - "dropped", - "elk-orphan-edge", - `Dropped ${orphanEdges} edge(s) referencing nonexistent nodes.`, - ); - issues.push(issue); - maybeThrow(strict, issue); - } - - // 5. dropCircularContainment + // 4. dropCircularContainment const parentOf = new Map(); const fillParents = (children: ElkChild[], parent?: string) => { for (const c of children) { @@ -205,6 +184,37 @@ export function repairElkInput( maybeThrow(strict, issue); } + // 5. dropOrphanEdges — validate against the FINAL child tree so edges + // pointing at nodes removed by the orphan-child (step 3) or cycle (step 4) + // passes are dropped too; otherwise ELK gets edges referencing missing nodes. + const finalIds = new Set(); + const walkFinal = (children: ElkChild[]) => { + for (const c of children) { + finalIds.add(c.id); + if (c.children) walkFinal(c.children); + } + }; + walkFinal(childrenD); + let orphanEdges = 0; + const edges = input.edges.filter((e) => { + const ok = e.sources.every((s) => finalIds.has(s)) && + e.targets.every((t) => finalIds.has(t)); + if (!ok) { + orphanEdges++; + return false; + } + return true; + }); + if (orphanEdges > 0) { + const issue = makeIssue( + "dropped", + "elk-orphan-edge", + `Dropped ${orphanEdges} edge(s) referencing nonexistent nodes.`, + ); + issues.push(issue); + maybeThrow(strict, issue); + } + return { input: { ...input, children: childrenD, edges }, issues,