From a14947f70ce5d3540e45121d1dab6eca072e988b Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 23 Jan 2026 11:31:03 +0000 Subject: [PATCH] Add layout tree validation and improve error handling - Add validateLayoutTree() and assertValidLayoutTree() for validating layout trees before use (checks for duplicate IDs, invalid ratios, empty IDs, and missing children on split nodes) - Integrate validation into LayoutManager constructor and root setter - Export validation utilities from public API - Improve error messages with contextual information (operation name, node IDs involved) - Add comprehensive edge case tests for calculateLayoutRects and calculateMinSize (zero dimensions, extreme ratios, deeply nested layouts, large values) - Remove debug console.info from App.tsx https://claude.ai/code/session_01KhhYPJYSPD72JjHRVn3uCS --- src/App.tsx | 1 - src/index.ts | 6 + .../LayoutManager/LayoutManager.test.ts | 24 +- src/internal/LayoutManager/LayoutManager.ts | 58 +++- .../calculateLayoutRects.test.ts | 254 +++++++++++++++ .../LayoutManager/calculateMinSize.test.ts | 193 ++++++++++++ .../LayoutManager/validateLayoutTree.test.ts | 293 ++++++++++++++++++ .../LayoutManager/validateLayoutTree.ts | 132 ++++++++ 8 files changed, 935 insertions(+), 26 deletions(-) create mode 100644 src/internal/LayoutManager/validateLayoutTree.test.ts create mode 100644 src/internal/LayoutManager/validateLayoutTree.ts diff --git a/src/App.tsx b/src/App.tsx index 1257ce8..d23d5d0 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -13,7 +13,6 @@ export function App() { getDropIndicatorProps, getDragHandleProps, } = useDockLayout(() => { - console.info("working"); const root = localStorage.getItem("layout"); if (root === null) { return null; diff --git a/src/index.ts b/src/index.ts index 1f7c2fa..870b42b 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,3 +1,9 @@ export * from "./strategies"; export * from "./types"; export * from "./useDockLayout"; +export { + validateLayoutTree, + assertValidLayoutTree, + type ValidationError, + type ValidationResult, +} from "./internal/LayoutManager/validateLayoutTree"; diff --git a/src/internal/LayoutManager/LayoutManager.test.ts b/src/internal/LayoutManager/LayoutManager.test.ts index eddc5d4..9d82d3c 100644 --- a/src/internal/LayoutManager/LayoutManager.test.ts +++ b/src/internal/LayoutManager/LayoutManager.test.ts @@ -8,7 +8,7 @@ describe("LayoutManager", () => { const root = null; const layoutManager = new LayoutManager(root); expect(() => layoutManager.resizePanel("root", { x: 0, y: 0 })).toThrow( - "Root node is null", + /resizePanel.*Cannot resize.*empty layout/, ); }); @@ -20,7 +20,7 @@ describe("LayoutManager", () => { const layoutManager = new LayoutManager(root); expect(() => layoutManager.resizePanel("non-existent-id", { x: 0, y: 0 }), - ).toThrow("Rect with id non-existent-id not found"); + ).toThrow(/resizePanel.*non-existent-id.*not found/); }); it("should throw an error when the rect is not a split node", () => { @@ -30,7 +30,7 @@ describe("LayoutManager", () => { }; const layoutManager = new LayoutManager(root); expect(() => layoutManager.resizePanel("root", { x: 0, y: 0 })).toThrow( - "Rect with id root is not a split node", + /resizePanel.*Expected split.*got panel/, ); }); @@ -190,7 +190,7 @@ describe("LayoutManager", () => { it("should throw an error if the root is null", () => { const layoutManager = new LayoutManager(null); expect(() => layoutManager.removePanel("root")).toThrowError( - "Root node is null", + /removePanel.*Cannot remove.*empty layout/, ); }); @@ -200,7 +200,7 @@ describe("LayoutManager", () => { type: "panel", }); expect(() => layoutManager.removePanel("nonexistent")).toThrowError( - "Node with id nonexistent not found", + /removePanel.*nonexistent.*not found/, ); }); @@ -220,7 +220,7 @@ describe("LayoutManager", () => { }, }); expect(() => layoutManager.removePanel("root")).toThrowError( - "Node with id root is not a panel", + /removePanel.*root.*is a split.*expected panel/, ); }); @@ -312,7 +312,7 @@ describe("LayoutManager", () => { targetId: "target", point: { x: 0, y: 0 }, }), - ).toThrowError("Root node is null"); + ).toThrowError(/movePanel.*Cannot move.*empty layout/); }); it("should throw an error if the root is not a split node", () => { @@ -326,7 +326,7 @@ describe("LayoutManager", () => { targetId: "target", point: { x: 0, y: 0 }, }), - ).toThrowError("Root node is not a split node"); + ).toThrowError(/movePanel.*Cannot move.*only one panel/); }); it("should throw an error if the source node is not found", () => { @@ -350,7 +350,7 @@ describe("LayoutManager", () => { targetId: "target", point: { x: 0, y: 0 }, }), - ).toThrowError("Node with id nonexistent not found"); + ).toThrowError(/movePanel.*Source panel "nonexistent" not found/); }); it("should throw an error if the source node is not a panel node", () => { @@ -384,7 +384,7 @@ describe("LayoutManager", () => { targetId: "target", point: { x: 0, y: 0 }, }), - ).toThrowError("Node with id left is not a panel node"); + ).toThrowError(/movePanel.*Source "left" is a split.*expected panel/); }); it("should throw an error if the target node is not found", () => { @@ -408,7 +408,7 @@ describe("LayoutManager", () => { targetId: "nonexistent", point: { x: 0, y: 0 }, }), - ).toThrowError("Node with id nonexistent not found"); + ).toThrowError(/movePanel.*Target panel "nonexistent" not found/); }); it("should throw an error if the target node is not a panel node", () => { @@ -442,7 +442,7 @@ describe("LayoutManager", () => { targetId: "right", point: { x: 0, y: 0 }, }), - ).toThrowError("Node with id right is not a panel node"); + ).toThrowError(/movePanel.*Target "right" is a split.*expected panel/); }); it("should move the panel when the source node is the sibling of the target node", () => { diff --git a/src/internal/LayoutManager/LayoutManager.ts b/src/internal/LayoutManager/LayoutManager.ts index 9d8c5d8..a154dbd 100644 --- a/src/internal/LayoutManager/LayoutManager.ts +++ b/src/internal/LayoutManager/LayoutManager.ts @@ -16,6 +16,7 @@ import { calculateMinSize } from "./calculateMinSize"; import { findClosestDirection } from "./findClosestDirection"; import { LayoutTree } from "./LayoutTree"; import type { Direction, Point, Rect, Size } from "./types"; +import { assertValidLayoutTree } from "./validateLayoutTree"; export class LayoutManager { private readonly MIN_RESIZE_RATIO = 0.1; @@ -27,6 +28,9 @@ export class LayoutManager { private _layoutRects: LayoutRect[] = []; constructor(root: LayoutNode | null, options?: LayoutManagerOptions) { + // Validate the initial layout tree to catch errors early + assertValidLayoutTree(root); + this._tree = new LayoutTree(root); this._options = { gap: options?.gap ?? 10, @@ -42,6 +46,8 @@ export class LayoutManager { } set root(root: LayoutNode | null) { + // Validate before setting to catch errors early + assertValidLayoutTree(root); this._tree.root = root; this.syncLayoutRects(); } @@ -65,17 +71,23 @@ export class LayoutManager { removePanel(id: string) { if (this._tree.root === null) { - throw new Error("Root node is null"); + throw new Error( + `removePanel("${id}"): Cannot remove panel from empty layout`, + ); } const node = this._tree.findNode(id); if (node === null) { - throw new Error(`Node with id ${id} not found`); + throw new Error( + `removePanel("${id}"): Panel not found in layout tree`, + ); } if (node.type !== "panel") { - throw new Error(`Node with id ${id} is not a panel`); + throw new Error( + `removePanel("${id}"): Node is a ${node.type}, expected panel`, + ); } if (node.id === this._tree.root.id) { @@ -117,19 +129,27 @@ export class LayoutManager { point: Point; }) { if (this._tree.root === null) { - throw new Error("Root node is null"); + throw new Error( + `movePanel("${sourceId}" → "${targetId}"): Cannot move panel in empty layout`, + ); } if (this._tree.root.type !== "split") { - throw new Error("Root node is not a split node"); + throw new Error( + `movePanel("${sourceId}" → "${targetId}"): Cannot move panel when layout has only one panel`, + ); } const sourceNode = this._tree.findNode(sourceId); if (sourceNode === null) { - throw new Error(`Node with id ${sourceId} not found`); + throw new Error( + `movePanel("${sourceId}" → "${targetId}"): Source panel "${sourceId}" not found`, + ); } if (sourceNode.type !== "panel") { - throw new Error(`Node with id ${sourceId} is not a panel node`); + throw new Error( + `movePanel("${sourceId}" → "${targetId}"): Source "${sourceId}" is a ${sourceNode.type}, expected panel`, + ); } const sourceNodeParent = this._tree.findParentNode(sourceId); @@ -138,10 +158,14 @@ export class LayoutManager { const targetNode = this._tree.findNode(targetId); if (targetNode === null) { - throw new Error(`Node with id ${targetId} not found`); + throw new Error( + `movePanel("${sourceId}" → "${targetId}"): Target panel "${targetId}" not found`, + ); } if (targetNode.type !== "panel") { - throw new Error(`Node with id ${targetId} is not a panel node`); + throw new Error( + `movePanel("${sourceId}" → "${targetId}"): Target "${targetId}" is a ${targetNode.type}, expected panel`, + ); } const sourceNodeSibling = @@ -210,17 +234,23 @@ export class LayoutManager { resizePanel(id: string, point: Point) { if (this._tree.root === null) { - throw new Error("Root node is null"); + throw new Error( + `resizePanel("${id}"): Cannot resize in empty layout`, + ); } const resizingRect = this.findRect(id); if (resizingRect === null) { - throw new Error(`Rect with id ${id} not found`); + throw new Error( + `resizePanel("${id}"): Split bar not found in layout`, + ); } if (resizingRect.type !== "split") { - throw new Error(`Rect with id ${id} is not a split node`); + throw new Error( + `resizePanel("${id}"): Expected split bar, got ${resizingRect.type}`, + ); } const splitNode = this._tree.findNode(id); @@ -266,7 +296,9 @@ export class LayoutManager { const targetNode = this._tree.findNode(targetId); if (targetNode === null) { - throw new Error(`Node with id ${targetId} not found`); + throw new Error( + `addPanel("${id}"): Placement strategy returned target "${targetId}" which was not found in layout tree`, + ); } const targetNodeParent = this._tree.findParentNode(targetId); diff --git a/src/internal/LayoutManager/calculateLayoutRects.test.ts b/src/internal/LayoutManager/calculateLayoutRects.test.ts index c2401f9..d445152 100644 --- a/src/internal/LayoutManager/calculateLayoutRects.test.ts +++ b/src/internal/LayoutManager/calculateLayoutRects.test.ts @@ -3,6 +3,260 @@ import type { LayoutRect, PanelNode, SplitNode } from "../../types"; import { calculateLayoutRects } from "./calculateLayoutRects"; describe("calculateLayoutRects", () => { + describe("edge cases", () => { + it("should handle zero width container", () => { + const root: PanelNode = { + id: "root", + type: "panel", + }; + const options = { + gap: 10, + size: { width: 0, height: 100 }, + }; + const result = calculateLayoutRects(root, options); + expect(result).toEqual([ + { + id: "root", + type: "panel", + x: 0, + y: 0, + width: 0, + height: 100, + }, + ]); + }); + + it("should handle zero height container", () => { + const root: PanelNode = { + id: "root", + type: "panel", + }; + const options = { + gap: 10, + size: { width: 100, height: 0 }, + }; + const result = calculateLayoutRects(root, options); + expect(result).toEqual([ + { + id: "root", + type: "panel", + x: 0, + y: 0, + width: 100, + height: 0, + }, + ]); + }); + + it("should handle zero gap", () => { + const root: SplitNode = { + id: "root", + type: "split", + orientation: "horizontal", + ratio: 0.5, + left: { id: "left", type: "panel" }, + right: { id: "right", type: "panel" }, + }; + const options = { + gap: 0, + size: { width: 100, height: 100 }, + }; + const result = calculateLayoutRects(root, options); + expect(result).toEqual([ + { + id: "root", + type: "split", + orientation: "horizontal", + x: 50, + y: 0, + width: 0, + height: 100, + }, + { + id: "left", + type: "panel", + x: 0, + y: 0, + width: 50, + height: 100, + }, + { + id: "right", + type: "panel", + x: 50, + y: 0, + width: 50, + height: 100, + }, + ]); + }); + + it("should handle extreme ratio (0.1)", () => { + const root: SplitNode = { + id: "root", + type: "split", + orientation: "horizontal", + ratio: 0.1, + left: { id: "left", type: "panel" }, + right: { id: "right", type: "panel" }, + }; + const options = { + gap: 10, + size: { width: 100, height: 100 }, + }; + const result = calculateLayoutRects(root, options); + + const leftPanel = result.find((r) => r.id === "left"); + const rightPanel = result.find((r) => r.id === "right"); + + expect(leftPanel?.width).toBe(5); // 100 * 0.1 - 5 = 5 + expect(rightPanel?.width).toBe(85); // 100 * 0.9 - 5 = 85 + }); + + it("should handle extreme ratio (0.9)", () => { + const root: SplitNode = { + id: "root", + type: "split", + orientation: "horizontal", + ratio: 0.9, + left: { id: "left", type: "panel" }, + right: { id: "right", type: "panel" }, + }; + const options = { + gap: 10, + size: { width: 100, height: 100 }, + }; + const result = calculateLayoutRects(root, options); + + const leftPanel = result.find((r) => r.id === "left"); + const rightPanel = result.find((r) => r.id === "right"); + + expect(leftPanel?.width).toBe(85); // 100 * 0.9 - 5 = 85 + expect(rightPanel?.width).toBe(5); // 100 * 0.1 - 5 = 5 + }); + + it("should handle large gap relative to container", () => { + const root: SplitNode = { + id: "root", + type: "split", + orientation: "horizontal", + ratio: 0.5, + left: { id: "left", type: "panel" }, + right: { id: "right", type: "panel" }, + }; + const options = { + gap: 50, + size: { width: 100, height: 100 }, + }; + const result = calculateLayoutRects(root, options); + + const leftPanel = result.find((r) => r.id === "left"); + const rightPanel = result.find((r) => r.id === "right"); + + expect(leftPanel?.width).toBe(25); // 100 * 0.5 - 25 = 25 + expect(rightPanel?.width).toBe(25); // 100 * 0.5 - 25 = 25 + }); + + it("should handle deeply nested layout (4 levels)", () => { + const root: SplitNode = { + id: "split-1", + type: "split", + orientation: "horizontal", + ratio: 0.5, + left: { + id: "split-2", + type: "split", + orientation: "vertical", + ratio: 0.5, + left: { + id: "split-3", + type: "split", + orientation: "horizontal", + ratio: 0.5, + left: { + id: "split-4", + type: "split", + orientation: "vertical", + ratio: 0.5, + left: { id: "panel-1", type: "panel" }, + right: { id: "panel-2", type: "panel" }, + }, + right: { id: "panel-3", type: "panel" }, + }, + right: { id: "panel-4", type: "panel" }, + }, + right: { id: "panel-5", type: "panel" }, + }; + const options = { + gap: 10, + size: { width: 200, height: 200 }, + }; + const result = calculateLayoutRects(root, options); + + // Should have 5 panels and 4 splits = 9 total rects + expect(result).toHaveLength(9); + + const panels = result.filter((r) => r.type === "panel"); + const splits = result.filter((r) => r.type === "split"); + + expect(panels).toHaveLength(5); + expect(splits).toHaveLength(4); + + // All panels should have positive dimensions + for (const panel of panels) { + expect(panel.width).toBeGreaterThan(0); + expect(panel.height).toBeGreaterThan(0); + } + }); + + it("should handle non-integer container dimensions", () => { + const root: SplitNode = { + id: "root", + type: "split", + orientation: "horizontal", + ratio: 0.33, + left: { id: "left", type: "panel" }, + right: { id: "right", type: "panel" }, + }; + const options = { + gap: 10, + size: { width: 333, height: 333 }, + }; + const result = calculateLayoutRects(root, options); + + // All values should be rounded integers + for (const rect of result) { + expect(Number.isInteger(rect.x)).toBe(true); + expect(Number.isInteger(rect.y)).toBe(true); + expect(Number.isInteger(rect.width)).toBe(true); + expect(Number.isInteger(rect.height)).toBe(true); + } + }); + + it("should handle very large container dimensions", () => { + const root: SplitNode = { + id: "root", + type: "split", + orientation: "horizontal", + ratio: 0.5, + left: { id: "left", type: "panel" }, + right: { id: "right", type: "panel" }, + }; + const options = { + gap: 10, + size: { width: 10000, height: 10000 }, + }; + const result = calculateLayoutRects(root, options); + + const leftPanel = result.find((r) => r.id === "left"); + const rightPanel = result.find((r) => r.id === "right"); + + expect(leftPanel?.width).toBe(4995); + expect(rightPanel?.width).toBe(4995); + }); + }); + + it("should return an empty array when the root is null", () => { const root = null; const options = { diff --git a/src/internal/LayoutManager/calculateMinSize.test.ts b/src/internal/LayoutManager/calculateMinSize.test.ts index 9c099cc..c38f1f9 100644 --- a/src/internal/LayoutManager/calculateMinSize.test.ts +++ b/src/internal/LayoutManager/calculateMinSize.test.ts @@ -3,6 +3,199 @@ import type { PanelNode, SplitNode } from "../../types"; import { calculateMinSize } from "./calculateMinSize"; describe("calculateMinSize", () => { + describe("edge cases", () => { + it("should return 0 for panel without minSize", () => { + const node: PanelNode = { + type: "panel", + id: "panel", + }; + const result = calculateMinSize(node, 10); + expect(result).toEqual({ width: 0, height: 0 }); + }); + + it("should return 0 width when only height is specified", () => { + const node: PanelNode = { + type: "panel", + id: "panel", + minSize: { height: 100 }, + }; + const result = calculateMinSize(node, 10); + expect(result).toEqual({ width: 0, height: 100 }); + }); + + it("should return 0 height when only width is specified", () => { + const node: PanelNode = { + type: "panel", + id: "panel", + minSize: { width: 100 }, + }; + const result = calculateMinSize(node, 10); + expect(result).toEqual({ width: 100, height: 0 }); + }); + + it("should handle zero gap", () => { + const node: SplitNode = { + type: "split", + id: "split", + orientation: "horizontal", + ratio: 0.5, + left: { type: "panel", id: "left", minSize: { width: 50, height: 50 } }, + right: { + type: "panel", + id: "right", + minSize: { width: 50, height: 50 }, + }, + }; + const result = calculateMinSize(node, 0); + expect(result).toEqual({ width: 100, height: 50 }); + }); + + it("should handle mixed minSize specifications in split", () => { + const node: SplitNode = { + type: "split", + id: "split", + orientation: "horizontal", + ratio: 0.5, + left: { type: "panel", id: "left", minSize: { width: 100, height: 80 } }, + right: { type: "panel", id: "right" }, // No minSize + }; + const result = calculateMinSize(node, 10); + expect(result).toEqual({ width: 110, height: 80 }); // 100 + 10 + 0 + }); + + it("should handle deeply nested layout with varying minSizes", () => { + const node: SplitNode = { + type: "split", + id: "split-1", + orientation: "horizontal", + ratio: 0.5, + left: { + type: "split", + id: "split-2", + orientation: "vertical", + ratio: 0.5, + left: { + type: "panel", + id: "panel-1", + minSize: { width: 100, height: 50 }, + }, + right: { type: "panel", id: "panel-2", minSize: { width: 80, height: 60 } }, + }, + right: { + type: "split", + id: "split-3", + orientation: "vertical", + ratio: 0.5, + left: { type: "panel", id: "panel-3" }, // No minSize + right: { + type: "panel", + id: "panel-4", + minSize: { width: 120, height: 40 }, + }, + }, + }; + const gap = 10; + const result = calculateMinSize(node, gap); + + // Left branch: max(100, 80) = 100 width, 50 + 10 + 60 = 120 height + // Right branch: max(0, 120) = 120 width, 0 + 10 + 40 = 50 height + // Total: 100 + 10 + 120 = 230 width, max(120, 50) = 120 height + expect(result).toEqual({ width: 230, height: 120 }); + }); + + it("should handle alternating horizontal and vertical splits", () => { + const node: SplitNode = { + type: "split", + id: "split-1", + orientation: "horizontal", + ratio: 0.5, + left: { + type: "split", + id: "split-2", + orientation: "vertical", + ratio: 0.5, + left: { + type: "split", + id: "split-3", + orientation: "horizontal", + ratio: 0.5, + left: { + type: "panel", + id: "panel-1", + minSize: { width: 30, height: 30 }, + }, + right: { + type: "panel", + id: "panel-2", + minSize: { width: 30, height: 30 }, + }, + }, + right: { + type: "panel", + id: "panel-3", + minSize: { width: 50, height: 40 }, + }, + }, + right: { + type: "panel", + id: "panel-4", + minSize: { width: 60, height: 100 }, + }, + }; + const gap = 10; + const result = calculateMinSize(node, gap); + + // split-3: 30 + 10 + 30 = 70 width, max(30, 30) = 30 height + // split-2: max(70, 50) = 70 width, 30 + 10 + 40 = 80 height + // split-1: 70 + 10 + 60 = 140 width, max(80, 100) = 100 height + expect(result).toEqual({ width: 140, height: 100 }); + }); + + it("should handle very large minSize values", () => { + const node: SplitNode = { + type: "split", + id: "split", + orientation: "horizontal", + ratio: 0.5, + left: { + type: "panel", + id: "left", + minSize: { width: 10000, height: 5000 }, + }, + right: { + type: "panel", + id: "right", + minSize: { width: 10000, height: 5000 }, + }, + }; + const result = calculateMinSize(node, 10); + expect(result).toEqual({ width: 20010, height: 5000 }); + }); + + it("should handle asymmetric minSize requirements", () => { + const node: SplitNode = { + type: "split", + id: "split", + orientation: "vertical", + ratio: 0.5, + left: { + type: "panel", + id: "left", + minSize: { width: 200, height: 50 }, + }, + right: { + type: "panel", + id: "right", + minSize: { width: 100, height: 150 }, + }, + }; + const result = calculateMinSize(node, 10); + // Vertical split: max widths, sum heights + expect(result).toEqual({ width: 200, height: 210 }); // 50 + 10 + 150 + }); + }); + + it("should return the min size of a panel node", () => { const node: PanelNode = { type: "panel", diff --git a/src/internal/LayoutManager/validateLayoutTree.test.ts b/src/internal/LayoutManager/validateLayoutTree.test.ts new file mode 100644 index 0000000..c05d7c8 --- /dev/null +++ b/src/internal/LayoutManager/validateLayoutTree.test.ts @@ -0,0 +1,293 @@ +import { describe, expect, it } from "vitest"; +import type { LayoutNode, PanelNode, SplitNode } from "../../types"; +import { + assertValidLayoutTree, + validateLayoutTree, +} from "./validateLayoutTree"; + +describe("validateLayoutTree", () => { + it("should return valid for null root", () => { + const result = validateLayoutTree(null); + expect(result.valid).toBe(true); + expect(result.errors).toHaveLength(0); + }); + + it("should return valid for a single panel node", () => { + const root: PanelNode = { + type: "panel", + id: "panel-1", + }; + const result = validateLayoutTree(root); + expect(result.valid).toBe(true); + expect(result.errors).toHaveLength(0); + }); + + it("should return valid for a valid split tree", () => { + const root: SplitNode = { + type: "split", + id: "split-1", + orientation: "horizontal", + ratio: 0.5, + left: { type: "panel", id: "panel-1" }, + right: { type: "panel", id: "panel-2" }, + }; + const result = validateLayoutTree(root); + expect(result.valid).toBe(true); + expect(result.errors).toHaveLength(0); + }); + + it("should return valid for a deeply nested tree", () => { + const root: SplitNode = { + type: "split", + id: "split-1", + orientation: "horizontal", + ratio: 0.5, + left: { + type: "split", + id: "split-2", + orientation: "vertical", + ratio: 0.3, + left: { type: "panel", id: "panel-1" }, + right: { + type: "split", + id: "split-3", + orientation: "horizontal", + ratio: 0.7, + left: { type: "panel", id: "panel-2" }, + right: { type: "panel", id: "panel-3" }, + }, + }, + right: { type: "panel", id: "panel-4" }, + }; + const result = validateLayoutTree(root); + expect(result.valid).toBe(true); + expect(result.errors).toHaveLength(0); + }); + + describe("duplicate ID detection", () => { + it("should detect duplicate IDs in sibling panels", () => { + const root: SplitNode = { + type: "split", + id: "split-1", + orientation: "horizontal", + ratio: 0.5, + left: { type: "panel", id: "duplicate" }, + right: { type: "panel", id: "duplicate" }, + }; + const result = validateLayoutTree(root); + expect(result.valid).toBe(false); + expect(result.errors).toHaveLength(1); + expect(result.errors[0]!.code).toBe("DUPLICATE_ID"); + expect(result.errors[0]!.nodeId).toBe("duplicate"); + }); + + it("should detect duplicate IDs across different branches", () => { + const root: SplitNode = { + type: "split", + id: "split-1", + orientation: "horizontal", + ratio: 0.5, + left: { + type: "split", + id: "split-2", + orientation: "vertical", + ratio: 0.5, + left: { type: "panel", id: "panel-1" }, + right: { type: "panel", id: "shared-id" }, + }, + right: { + type: "split", + id: "split-3", + orientation: "vertical", + ratio: 0.5, + left: { type: "panel", id: "shared-id" }, + right: { type: "panel", id: "panel-2" }, + }, + }; + const result = validateLayoutTree(root); + expect(result.valid).toBe(false); + expect(result.errors.some((e) => e.code === "DUPLICATE_ID")).toBe(true); + }); + + it("should detect duplicate ID between split and panel", () => { + const root: SplitNode = { + type: "split", + id: "same-id", + orientation: "horizontal", + ratio: 0.5, + left: { type: "panel", id: "same-id" }, + right: { type: "panel", id: "panel-2" }, + }; + const result = validateLayoutTree(root); + expect(result.valid).toBe(false); + expect(result.errors[0]!.code).toBe("DUPLICATE_ID"); + }); + }); + + describe("ratio validation", () => { + it("should detect ratio less than 0", () => { + const root: SplitNode = { + type: "split", + id: "split-1", + orientation: "horizontal", + ratio: -0.5, + left: { type: "panel", id: "panel-1" }, + right: { type: "panel", id: "panel-2" }, + }; + const result = validateLayoutTree(root); + expect(result.valid).toBe(false); + expect(result.errors[0]!.code).toBe("INVALID_RATIO"); + }); + + it("should detect ratio greater than 1", () => { + const root: SplitNode = { + type: "split", + id: "split-1", + orientation: "horizontal", + ratio: 1.5, + left: { type: "panel", id: "panel-1" }, + right: { type: "panel", id: "panel-2" }, + }; + const result = validateLayoutTree(root); + expect(result.valid).toBe(false); + expect(result.errors[0]!.code).toBe("INVALID_RATIO"); + }); + + it("should accept ratio of 0", () => { + const root: SplitNode = { + type: "split", + id: "split-1", + orientation: "horizontal", + ratio: 0, + left: { type: "panel", id: "panel-1" }, + right: { type: "panel", id: "panel-2" }, + }; + const result = validateLayoutTree(root); + expect(result.valid).toBe(true); + }); + + it("should accept ratio of 1", () => { + const root: SplitNode = { + type: "split", + id: "split-1", + orientation: "horizontal", + ratio: 1, + left: { type: "panel", id: "panel-1" }, + right: { type: "panel", id: "panel-2" }, + }; + const result = validateLayoutTree(root); + expect(result.valid).toBe(true); + }); + + it("should detect NaN ratio", () => { + const root: SplitNode = { + type: "split", + id: "split-1", + orientation: "horizontal", + ratio: Number.NaN, + left: { type: "panel", id: "panel-1" }, + right: { type: "panel", id: "panel-2" }, + }; + const result = validateLayoutTree(root); + expect(result.valid).toBe(false); + expect(result.errors[0]!.code).toBe("INVALID_RATIO"); + }); + }); + + describe("empty ID detection", () => { + it("should detect empty string ID", () => { + const root: PanelNode = { + type: "panel", + id: "", + }; + const result = validateLayoutTree(root); + expect(result.valid).toBe(false); + expect(result.errors[0]!.code).toBe("EMPTY_ID"); + }); + + it("should detect whitespace-only ID", () => { + const root: PanelNode = { + type: "panel", + id: " ", + }; + const result = validateLayoutTree(root); + expect(result.valid).toBe(false); + expect(result.errors[0]!.code).toBe("EMPTY_ID"); + }); + }); + + describe("missing children detection", () => { + it("should detect missing left child", () => { + const root = { + type: "split", + id: "split-1", + orientation: "horizontal", + ratio: 0.5, + left: undefined, + right: { type: "panel", id: "panel-1" }, + } as unknown as LayoutNode; + const result = validateLayoutTree(root); + expect(result.valid).toBe(false); + expect(result.errors.some((e) => e.code === "MISSING_CHILDREN")).toBe( + true, + ); + }); + + it("should detect missing right child", () => { + const root = { + type: "split", + id: "split-1", + orientation: "horizontal", + ratio: 0.5, + left: { type: "panel", id: "panel-1" }, + right: undefined, + } as unknown as LayoutNode; + const result = validateLayoutTree(root); + expect(result.valid).toBe(false); + expect(result.errors.some((e) => e.code === "MISSING_CHILDREN")).toBe( + true, + ); + }); + }); + + describe("multiple errors", () => { + it("should report multiple errors in the same tree", () => { + const root: SplitNode = { + type: "split", + id: "split-1", + orientation: "horizontal", + ratio: 1.5, // Invalid ratio + left: { type: "panel", id: "duplicate" }, + right: { type: "panel", id: "duplicate" }, // Duplicate ID + }; + const result = validateLayoutTree(root); + expect(result.valid).toBe(false); + expect(result.errors.length).toBeGreaterThanOrEqual(2); + expect(result.errors.some((e) => e.code === "INVALID_RATIO")).toBe(true); + expect(result.errors.some((e) => e.code === "DUPLICATE_ID")).toBe(true); + }); + }); +}); + +describe("assertValidLayoutTree", () => { + it("should not throw for valid tree", () => { + const root: PanelNode = { + type: "panel", + id: "panel-1", + }; + expect(() => assertValidLayoutTree(root)).not.toThrow(); + }); + + it("should throw for invalid tree with descriptive message", () => { + const root: SplitNode = { + type: "split", + id: "split-1", + orientation: "horizontal", + ratio: 1.5, + left: { type: "panel", id: "panel-1" }, + right: { type: "panel", id: "panel-1" }, + }; + expect(() => assertValidLayoutTree(root)).toThrow(/Invalid layout tree/); + expect(() => assertValidLayoutTree(root)).toThrow(/DUPLICATE_ID|ratio/i); + }); +}); diff --git a/src/internal/LayoutManager/validateLayoutTree.ts b/src/internal/LayoutManager/validateLayoutTree.ts new file mode 100644 index 0000000..d7beaba --- /dev/null +++ b/src/internal/LayoutManager/validateLayoutTree.ts @@ -0,0 +1,132 @@ +import type { LayoutNode } from "../../types"; +import { assertNever } from "../assertNever"; + +export interface ValidationError { + code: + | "DUPLICATE_ID" + | "INVALID_RATIO" + | "EMPTY_ID" + | "INVALID_NODE_TYPE" + | "MISSING_CHILDREN"; + message: string; + path: string[]; + nodeId?: string; +} + +export interface ValidationResult { + valid: boolean; + errors: ValidationError[]; +} + +/** + * Validates a layout tree for common errors: + * - Duplicate IDs + * - Invalid ratios (must be between 0 and 1) + * - Empty IDs + * - Missing children on split nodes + */ +export function validateLayoutTree(root: LayoutNode | null): ValidationResult { + if (root === null) { + return { valid: true, errors: [] }; + } + + const errors: ValidationError[] = []; + const seenIds = new Map(); + + function validate(node: LayoutNode, path: string[]): void { + // Check for empty ID + if (!node.id || node.id.trim() === "") { + errors.push({ + code: "EMPTY_ID", + message: `Node at path [${path.join(" > ")}] has an empty ID`, + path, + }); + } + + // Track IDs for duplicate detection + if (node.id) { + const existingPath = seenIds.get(node.id); + if (existingPath !== undefined) { + errors.push({ + code: "DUPLICATE_ID", + message: `Duplicate ID "${node.id}" found at paths [${existingPath.join(" > ")}] and [${path.join(" > ")}]`, + path, + nodeId: node.id, + }); + } else { + seenIds.set(node.id, path); + } + } + + if (node.type === "panel") { + // Panel nodes are valid if they have an ID (already checked) + return; + } + + if (node.type === "split") { + // Validate ratio + if (typeof node.ratio !== "number" || Number.isNaN(node.ratio)) { + errors.push({ + code: "INVALID_RATIO", + message: `Split node "${node.id}" has an invalid ratio: ${node.ratio}. Ratio must be a number.`, + path, + nodeId: node.id, + }); + } else if (node.ratio < 0 || node.ratio > 1) { + errors.push({ + code: "INVALID_RATIO", + message: `Split node "${node.id}" has an invalid ratio: ${node.ratio}. Ratio must be between 0 and 1.`, + path, + nodeId: node.id, + }); + } + + // Check for missing children + if (!node.left) { + errors.push({ + code: "MISSING_CHILDREN", + message: `Split node "${node.id}" is missing a left child`, + path, + nodeId: node.id, + }); + } else { + validate(node.left, [...path, "left"]); + } + + if (!node.right) { + errors.push({ + code: "MISSING_CHILDREN", + message: `Split node "${node.id}" is missing a right child`, + path, + nodeId: node.id, + }); + } else { + validate(node.right, [...path, "right"]); + } + + return; + } + + // Unknown node type + assertNever(node); + } + + validate(root, ["root"]); + + return { + valid: errors.length === 0, + errors, + }; +} + +/** + * Throws an error if the layout tree is invalid. + * Useful for validating user-provided layout trees. + */ +export function assertValidLayoutTree(root: LayoutNode | null): void { + const result = validateLayoutTree(root); + if (!result.valid) { + const errorMessages = result.errors.map((e) => e.message).join("\n - "); + throw new Error(`Invalid layout tree:\n - ${errorMessages}`); + } +}