/** * @file paneLayout.test.ts * @description Tests for the session view's split-pane tree: opening a run * (replace the focused pane, or split it), closing a pane and handing focus to * the pane that takes its place, swapping/moving panes, resizing a split, * pruning panes whose run is gone, the rectangles the grid draws, and the * per-workspace storage that must survive bad or stale data. * @author Nguyễn Ngọc Trí Vĩ */ import { describe, it, expect, beforeEach } from "vitest"; import { type PaneLayout, type PaneNode, MAX_PANES, closeInLayout, layoutRects, leaves, loadLayout, moveInLayout, openInLayout, pane, parseLayout, pruneLayout, saveLayout, setRatio, split, swapInLayout, } from "../paneLayout"; const one: PaneLayout = { root: pane(0), focus: 0 }; describe("openInLayout", () => { it("starts a single pane on an empty layout", () => { const l = openInLayout({ root: null, focus: null }, 2, "replace"); expect(l).toEqual({ root: pane(2), focus: 2 }); }); it("only focuses a slot that is already shown", () => { const l: PaneLayout = { root: split("row", pane(0), pane(1)), focus: 0 }; expect(openInLayout(l, 1, "split")).toEqual({ ...l, focus: 1 }); }); it("replaces the focused pane on a plain open", () => { const l: PaneLayout = { root: split("row", pane(0), pane(1)), focus: 1 }; const out = openInLayout(l, 3, "replace"); expect(out.root).toEqual(split("row", pane(0), pane(3))); expect(out.focus).toBe(3); }); it("splits the focused pane to the right on a split open", () => { const l: PaneLayout = { root: split("row", pane(0), pane(1)), focus: 0 }; const out = openInLayout(l, 2, "split"); expect(out.root).toEqual(split("row", split("row", pane(0), pane(2)), pane(1))); expect(out.focus).toBe(2); }); it("replaces instead of splitting once MAX_PANES are shown", () => { let l: PaneLayout = one; for (const s of [1, 2, 3]) l = openInLayout(l, s, "split"); expect(leaves(l.root)).toHaveLength(MAX_PANES); // A fifth run cannot exist (4 slots), but a stale focus must still be safe. const out = openInLayout({ ...l, focus: 9 }, 3, "split"); expect(leaves(out.root)).toHaveLength(MAX_PANES); }); it("falls back to the first pane when the stored focus is not in the tree", () => { const l: PaneLayout = { root: split("row", pane(0), pane(1)), focus: 7 }; expect(openInLayout(l, 2, "replace").root).toEqual(split("row", pane(2), pane(1))); }); }); describe("closeInLayout", () => { it("hands focus to the pane that takes the closed one's place", () => { const l: PaneLayout = { root: split("row", pane(0), split("col", pane(1), pane(2))), focus: 0, }; const out = closeInLayout(l, 0); expect(out.root).toEqual(split("col", pane(1), pane(2))); expect(out.focus).toBe(1); }); it("keeps focus when another pane closes", () => { const l: PaneLayout = { root: split("row", pane(0), pane(1)), focus: 0 }; expect(closeInLayout(l, 1)).toEqual({ root: pane(0), focus: 0 }); }); it("empties the layout when the last pane closes", () => { expect(closeInLayout(one, 0)).toEqual({ root: null, focus: null }); }); it("ignores a slot that is not shown", () => { expect(closeInLayout(one, 3)).toBe(one); }); }); describe("swapInLayout / moveInLayout", () => { it("swaps two shown panes", () => { const l: PaneLayout = { root: split("row", pane(0), pane(1)), focus: 0 }; expect(swapInLayout(l, 0, 1).root).toEqual(split("row", pane(1), pane(0))); }); it("puts a hidden run into the target pane when swapping", () => { const l: PaneLayout = { root: split("row", pane(0), pane(1)), focus: 0 }; expect(swapInLayout(l, 3, 1).root).toEqual(split("row", pane(0), pane(3))); }); it("moves a pane to another pane's edge", () => { const l: PaneLayout = { root: split("row", pane(0), split("row", pane(1), pane(2))), focus: 2 }; const out = moveInLayout(l, 0, 2, "bottom"); expect(out.root).toEqual(split("row", pane(1), split("col", pane(2), pane(0)))); expect(out.focus).toBe(0); }); it("puts the moved pane first on a left or top edge", () => { const l: PaneLayout = { root: split("row", pane(0), pane(1)), focus: 0 }; expect(moveInLayout(l, 1, 0, "top").root).toEqual(split("col", pane(1), pane(0))); }); it("does nothing when a pane is dropped on itself", () => { const l: PaneLayout = { root: split("row", pane(0), pane(1)), focus: 0 }; expect(moveInLayout(l, 1, 1, "left")).toBe(l); }); it("refuses to add a fifth pane", () => { let l: PaneLayout = one; for (const s of [1, 2, 3]) l = openInLayout(l, s, "split"); const four = { ...l, root: closeInLayout(l, 3).root }; // 3 shown + slot 3 hidden: moving the hidden one in is allowed … expect(leaves(moveInLayout({ ...four, focus: 0 }, 3, 0, "right").root)).toHaveLength(4); // … a full layout keeps its shape. expect(moveInLayout(l, 9, 0, "right")).toBe(l); }); }); describe("setRatio / layoutRects", () => { it("clamps a dragged ratio so no pane can vanish", () => { const root = split("row", pane(0), pane(1)); expect((setRatio(root, "", 0.01) as Extract).ratio).toBe(0.1); expect((setRatio(root, "", 0.99) as Extract).ratio).toBe(0.9); }); it("resizes the split a path names", () => { const root = split("row", pane(0), split("col", pane(1), pane(2))); const out = setRatio(root, "b", 0.25) as Extract; expect((out.b as Extract).ratio).toBe(0.25); expect(out.ratio).toBe(0.5); }); it("turns the tree into pane rectangles and divider lines", () => { const root = split("row", pane(0), split("col", pane(1), pane(2), 0.25), 0.6); const { panes, dividers } = layoutRects(root); expect(panes).toEqual([ { slot: 0, x: 0, y: 0, w: 0.6, h: 1 }, { slot: 1, x: 0.6, y: 0, w: 0.4, h: 0.25 }, { slot: 2, x: 0.6, y: 0.25, w: 0.4, h: 0.75 }, ]); expect(dividers).toEqual([ { path: "", dir: "row", at: 0.6, box: { x: 0, y: 0, w: 1, h: 1 } }, { path: "b", dir: "col", at: 0.25, box: { x: 0.6, y: 0, w: 0.4, h: 1 } }, ]); }); it("has nothing to draw for an empty layout", () => { expect(layoutRects(null)).toEqual({ panes: [], dividers: [] }); }); }); describe("pruneLayout", () => { it("drops panes whose run is gone, keeping the pending ones", () => { const l: PaneLayout = { root: split("row", pane(0), split("row", pane(1), pane(2))), focus: 1, }; const out = pruneLayout(l, new Set([0]), new Set([2])); expect(out.root).toEqual(split("row", pane(0), pane(2))); expect(out.focus).toBe(2); }); it("returns the same object when nothing is gone", () => { expect(pruneLayout(one, new Set([0]), new Set())).toBe(one); }); }); describe("parseLayout / storage", () => { beforeEach(() => window.localStorage.clear()); it("accepts a valid stored layout", () => { const l: PaneLayout = { root: split("col", pane(1), pane(3), 0.3), focus: 3 }; expect(parseLayout(JSON.parse(JSON.stringify(l)))).toEqual(l); }); it("rejects junk, out-of-range slots, duplicates and oversized trees", () => { expect(parseLayout(null)).toEqual({ root: null, focus: null }); expect(parseLayout({ root: { type: "pane", slot: 7 }, focus: 7 }).root).toBeNull(); expect(parseLayout({ root: split("row", pane(1), pane(1)), focus: 1 }).root).toBeNull(); const five = split( "row", split("row", pane(0), pane(1)), split("row", pane(2), split("row", pane(3), pane(0))) ); expect(parseLayout({ root: five, focus: 0 }).root).toBeNull(); }); it("clamps a stored ratio and repairs a focus that is not shown", () => { const out = parseLayout({ root: { ...split("row", pane(0), pane(1)), ratio: 5 }, focus: 3 }); expect(out.root).toEqual(split("row", pane(0), pane(1), 0.9)); expect(out.focus).toBe(0); }); it("keeps one layout per workspace", () => { saveLayout(1, { root: split("row", pane(0), pane(1)), focus: 1 }); saveLayout(2, one); expect(loadLayout(1).focus).toBe(1); expect(loadLayout(2)).toEqual(one); expect(loadLayout(3)).toEqual({ root: null, focus: null }); }); it("survives unreadable storage", () => { window.localStorage.setItem("agenthub.workspace.paneLayout", "{not json"); expect(loadLayout(1)).toEqual({ root: null, focus: null }); }); });