import { describe, it, expect } from "vitest"; import { newLeaf, newSplit, replaceById, splitLeaf, closeLeaf, findLeaf, leafCount, walkLeaves, changeDistro, setLeafShell, changeLabel, toggleBroadcast, adjustFontSize, adjustAllFontSizes, resolveFontSize, DEFAULT_FONT_SIZE, MIN_FONT_SIZE, MAX_FONT_SIZE, serialize, deserialize, presetSingle, presetTwoColumns, presetThreeColumns, presetTwoRows, presetTwoByTwo, type TreeNode, type LeafNode, type SplitNode, } from "./tree"; function leafIds(root: TreeNode): string[] { return Array.from(walkLeaves(root)).map((l) => l.id); } function leafDistros(root: TreeNode): (string | undefined)[] { return Array.from(walkLeaves(root)).map((l) => l.distro); } describe("newLeaf", () => { it("returns a leaf with a unique id, default shellKind=wsl, no other metadata", () => { const a = newLeaf(); const b = newLeaf(); expect(a.kind).toBe("leaf"); expect(typeof a.id).toBe("string"); expect(a.id).not.toEqual(b.id); expect(a.shellKind).toBe("wsl"); expect(a.distro).toBeUndefined(); expect(a.cwd).toBeUndefined(); expect(a.sshHostId).toBeUndefined(); expect(a.label).toBeUndefined(); expect(a.broadcast).toBeUndefined(); }); it("applies provided props", () => { const leaf = newLeaf({ distro: "Ubuntu", cwd: "/home", label: "ml" }); expect(leaf.distro).toBe("Ubuntu"); expect(leaf.cwd).toBe("/home"); expect(leaf.label).toBe("ml"); }); it("respects an explicit non-wsl shellKind", () => { const ps = newLeaf({ shellKind: "powershell" }); expect(ps.shellKind).toBe("powershell"); const ssh = newLeaf({ shellKind: "ssh", sshHostId: "host-1" }); expect(ssh.shellKind).toBe("ssh"); expect(ssh.sshHostId).toBe("host-1"); }); }); describe("newSplit", () => { it("defaults ratio to 0.5", () => { const split = newSplit("h", newLeaf(), newLeaf()); expect(split.ratio).toBe(0.5); expect(split.kind).toBe("split"); expect(split.orientation).toBe("h"); }); it("respects an explicit ratio", () => { const split = newSplit("v", newLeaf(), newLeaf(), 0.33); expect(split.ratio).toBeCloseTo(0.33); }); }); describe("replaceById", () => { it("replaces the root if its id matches", () => { const a = newLeaf({ label: "old" }); const next = replaceById(a, a.id, () => newLeaf({ label: "new" })); expect((next as LeafNode).label).toBe("new"); }); it("returns the same root reference when no match", () => { const a = newSplit("h", newLeaf(), newLeaf()); const same = replaceById(a, "no-such-id", () => newLeaf()); expect(same).toBe(a); }); it("returns a new root when a nested node is replaced (immutability)", () => { const target = newLeaf({ label: "target" }); const sibling = newLeaf({ label: "sibling" }); const root = newSplit("h", target, sibling); const next = replaceById(root, target.id, (n) => ({ ...(n as LeafNode), label: "edited", })); expect(next).not.toBe(root); expect((next as SplitNode).b).toBe(sibling); // untouched branch reused expect(((next as SplitNode).a as LeafNode).label).toBe("edited"); }); }); describe("splitLeaf", () => { it("replaces a leaf with a horizontal split", () => { const leaf = newLeaf({ distro: "Ubuntu" }); const next = splitLeaf(leaf, leaf.id, "h"); expect(next.kind).toBe("split"); const s = next as SplitNode; expect(s.orientation).toBe("h"); expect(s.a).toBe(leaf); expect((s.b as LeafNode).kind).toBe("leaf"); }); it("propagates inherited distro / cwd to the new leaf", () => { const leaf = newLeaf({ distro: "Ubuntu", cwd: "/projects/x" }); const next = splitLeaf(leaf, leaf.id, "v", { distro: leaf.distro, cwd: leaf.cwd, }); const newSide = (next as SplitNode).b as LeafNode; expect(newSide.distro).toBe("Ubuntu"); expect(newSide.cwd).toBe("/projects/x"); }); it("is a no-op when the leaf id is not found", () => { const leaf = newLeaf(); const same = splitLeaf(leaf, "no-such-id", "h"); expect(same).toBe(leaf); }); it("preserves siblings when splitting a nested leaf", () => { const target = newLeaf(); const sibling = newLeaf(); const root = newSplit("h", target, sibling); const next = splitLeaf(root, target.id, "v") as SplitNode; expect(next.b).toBe(sibling); expect(next.a.kind).toBe("split"); }); }); describe("closeLeaf", () => { it("returns null when closing the only (root) leaf", () => { const leaf = newLeaf(); expect(closeLeaf(leaf, leaf.id)).toBeNull(); }); it("collapses to the sibling when closing one of two children", () => { const target = newLeaf(); const sibling = newLeaf(); const root = newSplit("h", target, sibling); expect(closeLeaf(root, target.id)).toBe(sibling); expect(closeLeaf(root, sibling.id)).toBe(target); }); it("preserves the rest of the tree when removing a nested leaf", () => { const target = newLeaf({ label: "close-me" }); const sib = newLeaf({ label: "stay" }); const farRight = newLeaf({ label: "right" }); const root = newSplit("h", newSplit("v", target, sib), farRight); const next = closeLeaf(root, target.id) as SplitNode; // The inner split collapses to `sib`; the outer split is rebuilt. expect(next.kind).toBe("split"); expect(next.b).toBe(farRight); expect((next.a as LeafNode).label).toBe("stay"); }); it("is a no-op when the leaf id is not found", () => { const a = newLeaf(); const b = newLeaf(); const root = newSplit("h", a, b); expect(closeLeaf(root, "no-such-id")).toBe(root); }); }); describe("findLeaf", () => { it("finds the root leaf", () => { const leaf = newLeaf({ label: "x" }); expect(findLeaf(leaf, leaf.id)).toBe(leaf); }); it("finds a deeply nested leaf", () => { const target = newLeaf({ label: "deep" }); const root = newSplit( "h", newSplit("v", newLeaf(), newSplit("h", target, newLeaf())), newLeaf(), ); const found = findLeaf(root, target.id); expect(found).toBe(target); }); it("returns null for unknown id", () => { const root = newSplit("h", newLeaf(), newLeaf()); expect(findLeaf(root, "no-such-id")).toBeNull(); }); it("returns null when searching for a split node's id", () => { const root = newSplit("h", newLeaf(), newLeaf()); expect(findLeaf(root, root.id)).toBeNull(); }); }); describe("leafCount", () => { it("counts a single leaf as 1", () => { expect(leafCount(newLeaf())).toBe(1); }); it("counts leaves across nested splits", () => { const tree = newSplit( "h", newSplit("v", newLeaf(), newLeaf()), newSplit("h", newLeaf(), newSplit("v", newLeaf(), newLeaf())), ); expect(leafCount(tree)).toBe(5); }); }); describe("walkLeaves", () => { it("yields a single leaf for a leaf root", () => { const leaf = newLeaf(); expect(Array.from(walkLeaves(leaf))).toEqual([leaf]); }); it("yields leaves in left-to-right (a-first) order", () => { const l1 = newLeaf({ label: "1" }); const l2 = newLeaf({ label: "2" }); const l3 = newLeaf({ label: "3" }); const l4 = newLeaf({ label: "4" }); const root = newSplit("h", newSplit("v", l1, l2), newSplit("h", l3, l4)); const labels = Array.from(walkLeaves(root)).map((l) => l.label); expect(labels).toEqual(["1", "2", "3", "4"]); }); }); describe("changeDistro", () => { it("sets the distro on the leaf and forces shellKind back to wsl", () => { const leaf = newLeaf({ shellKind: "powershell" }); const next = changeDistro(leaf, leaf.id, "Debian") as LeafNode; expect(next.distro).toBe("Debian"); expect(next.shellKind).toBe("wsl"); }); it("MUST swap the leaf id (so {#key} remounts XtermPane and kills the PTY)", () => { const leaf = newLeaf({ distro: "Ubuntu" }); const next = changeDistro(leaf, leaf.id, "Debian") as LeafNode; expect(next.id).not.toBe(leaf.id); }); it("preserves other leaves in a nested tree", () => { const target = newLeaf({ distro: "Ubuntu" }); const other = newLeaf({ distro: "Ubuntu" }); const root = newSplit("h", target, other); const next = changeDistro(root, target.id, "Debian") as SplitNode; expect(next.b).toBe(other); expect((next.a as LeafNode).distro).toBe("Debian"); }); }); describe("setLeafShell", () => { it("switches a wsl leaf to powershell (and clears wsl-specific fields)", () => { const leaf = newLeaf({ distro: "Ubuntu", cwd: "/work", label: "keep" }); const next = setLeafShell(leaf, leaf.id, { shellKind: "powershell" }) as LeafNode; expect(next.shellKind).toBe("powershell"); expect(next.distro).toBeUndefined(); expect(next.cwd).toBeUndefined(); expect(next.label).toBe("keep"); }); it("switches a leaf to ssh and records sshHostId", () => { const leaf = newLeaf({ distro: "Ubuntu" }); const next = setLeafShell(leaf, leaf.id, { shellKind: "ssh", sshHostId: "host-abc", }) as LeafNode; expect(next.shellKind).toBe("ssh"); expect(next.sshHostId).toBe("host-abc"); expect(next.distro).toBeUndefined(); }); it("MUST swap the leaf id (forces PTY respawn)", () => { const leaf = newLeaf({ shellKind: "powershell" }); const next = setLeafShell(leaf, leaf.id, { shellKind: "ssh", sshHostId: "h1", }) as LeafNode; expect(next.id).not.toBe(leaf.id); }); it("preserves label / broadcast / fontSizeOffset across the shell change", () => { const leaf = newLeaf({ distro: "Ubuntu", label: "my pane", broadcast: true, fontSizeOffset: 2, }); const next = setLeafShell(leaf, leaf.id, { shellKind: "powershell", }) as LeafNode; expect(next.label).toBe("my pane"); expect(next.broadcast).toBe(true); expect(next.fontSizeOffset).toBe(2); }); }); describe("changeLabel", () => { it("sets a label", () => { const leaf = newLeaf(); const next = changeLabel(leaf, leaf.id, "my pane") as LeafNode; expect(next.label).toBe("my pane"); }); it("MUST NOT swap the leaf id (metadata-only — pane should not respawn)", () => { const leaf = newLeaf({ label: "old" }); const next = changeLabel(leaf, leaf.id, "new") as LeafNode; expect(next.id).toBe(leaf.id); }); it("trims whitespace from the label", () => { const leaf = newLeaf(); const next = changeLabel(leaf, leaf.id, " spaced ") as LeafNode; expect(next.label).toBe("spaced"); }); it("clears the label when given empty / whitespace / undefined", () => { const leaf = newLeaf({ label: "had-a-name" }); expect((changeLabel(leaf, leaf.id, "") as LeafNode).label).toBeUndefined(); expect((changeLabel(leaf, leaf.id, " ") as LeafNode).label).toBeUndefined(); expect( (changeLabel(leaf, leaf.id, undefined) as LeafNode).label, ).toBeUndefined(); }); }); describe("toggleBroadcast", () => { it("toggles undefined -> true", () => { const leaf = newLeaf(); const next = toggleBroadcast(leaf, leaf.id) as LeafNode; expect(next.broadcast).toBe(true); }); it("toggles true -> false", () => { const leaf = newLeaf({ distro: "Ubuntu" }); const on = toggleBroadcast(leaf, leaf.id) as LeafNode; const off = toggleBroadcast(on, on.id) as LeafNode; expect(off.broadcast).toBe(false); }); it("MUST NOT swap the leaf id (metadata-only)", () => { const leaf = newLeaf(); const next = toggleBroadcast(leaf, leaf.id) as LeafNode; expect(next.id).toBe(leaf.id); }); }); describe("resolveFontSize", () => { it("returns the default when offset is undefined or 0", () => { expect(resolveFontSize(undefined)).toBe(DEFAULT_FONT_SIZE); expect(resolveFontSize(0)).toBe(DEFAULT_FONT_SIZE); }); it("clamps to [MIN_FONT_SIZE, MAX_FONT_SIZE]", () => { expect(resolveFontSize(-9999)).toBe(MIN_FONT_SIZE); expect(resolveFontSize(9999)).toBe(MAX_FONT_SIZE); }); }); describe("adjustFontSize", () => { it("bumps a leaf's offset by delta", () => { const leaf = newLeaf(); const next = adjustFontSize(leaf, leaf.id, 2) as LeafNode; expect(next.fontSizeOffset).toBe(2); }); it("MUST NOT swap the leaf id (metadata-only — pane should not respawn)", () => { const leaf = newLeaf(); const next = adjustFontSize(leaf, leaf.id, 1) as LeafNode; expect(next.id).toBe(leaf.id); }); it("clamps the offset so the resolved font size stays within bounds", () => { const leaf = newLeaf(); const bigUp = adjustFontSize(leaf, leaf.id, 999) as LeafNode; expect(resolveFontSize(bigUp.fontSizeOffset)).toBe(MAX_FONT_SIZE); const bigDown = adjustFontSize(leaf, leaf.id, -999) as LeafNode; expect(resolveFontSize(bigDown.fontSizeOffset)).toBe(MIN_FONT_SIZE); }); it("strips the offset field entirely when the result is 0", () => { const leaf = newLeaf({ fontSizeOffset: 1 }); const next = adjustFontSize(leaf, leaf.id, -1) as LeafNode; expect(next.fontSizeOffset).toBeUndefined(); expect("fontSizeOffset" in next).toBe(false); }); it("delta=null resets to default", () => { const leaf = newLeaf({ fontSizeOffset: 5 }); const next = adjustFontSize(leaf, leaf.id, null) as LeafNode; expect(next.fontSizeOffset).toBeUndefined(); }); it("only touches the targeted leaf", () => { const target = newLeaf({ label: "a" }); const sibling = newLeaf({ label: "b", fontSizeOffset: 3 }); const root = newSplit("h", target, sibling); const next = adjustFontSize(root, target.id, 2) as SplitNode; expect((next.a as LeafNode).fontSizeOffset).toBe(2); expect((next.b as LeafNode).fontSizeOffset).toBe(3); }); }); describe("adjustAllFontSizes", () => { it("shifts every leaf by the same delta and preserves independence", () => { const a = newLeaf({ fontSizeOffset: 0 }); const b = newLeaf({ fontSizeOffset: 2 }); const c = newLeaf({ fontSizeOffset: -1 }); const root = newSplit("h", a, newSplit("v", b, c)); const next = adjustAllFontSizes(root, 1); const offsets = Array.from(walkLeaves(next)).map((l) => l.fontSizeOffset ?? 0); expect(offsets).toEqual([1, 3, 0]); }); it("delta=null resets every leaf to default", () => { const a = newLeaf({ fontSizeOffset: 4 }); const b = newLeaf({ fontSizeOffset: -3 }); const root = newSplit("h", a, b); const next = adjustAllFontSizes(root, null); for (const leaf of walkLeaves(next)) { expect(leaf.fontSizeOffset).toBeUndefined(); } }); it("MUST NOT swap any leaf id", () => { const a = newLeaf({ fontSizeOffset: 1 }); const b = newLeaf(); const root = newSplit("h", a, b); const idsBefore = leafIds(root); const next = adjustAllFontSizes(root, 1); expect(leafIds(next)).toEqual(idsBefore); }); it("returns the same root reference when nothing changes (e.g. all at min, delta < 0)", () => { const minOffset = MIN_FONT_SIZE - DEFAULT_FONT_SIZE; const a = newLeaf({ fontSizeOffset: minOffset }); const b = newLeaf({ fontSizeOffset: minOffset }); const root = newSplit("h", a, b); expect(adjustAllFontSizes(root, -1)).toBe(root); }); }); describe("presets", () => { it("presetSingle returns a single leaf with the provided distro", () => { const t = presetSingle({ distro: "Ubuntu" }); expect(t.kind).toBe("leaf"); expect((t as LeafNode).distro).toBe("Ubuntu"); }); it("presetTwoColumns returns a horizontal split with two leaves", () => { const t = presetTwoColumns({ distro: "Ubuntu" }) as SplitNode; expect(t.kind).toBe("split"); expect(t.orientation).toBe("h"); expect(leafCount(t)).toBe(2); expect(leafDistros(t)).toEqual(["Ubuntu", "Ubuntu"]); }); it("presetThreeColumns has 3 leaves with the outer split at 1/3", () => { const t = presetThreeColumns({ distro: "Ubuntu" }) as SplitNode; expect(leafCount(t)).toBe(3); expect(t.orientation).toBe("h"); expect(t.ratio).toBeCloseTo(1 / 3); }); it("presetTwoRows returns a vertical split", () => { const t = presetTwoRows() as SplitNode; expect(t.orientation).toBe("v"); expect(leafCount(t)).toBe(2); }); it("presetTwoByTwo returns 4 leaves in a v(h, h) shape, distro propagated", () => { const t = presetTwoByTwo({ distro: "Ubuntu" }) as SplitNode; expect(leafCount(t)).toBe(4); expect(t.orientation).toBe("v"); expect((t.a as SplitNode).orientation).toBe("h"); expect((t.b as SplitNode).orientation).toBe("h"); expect(leafDistros(t)).toEqual(["Ubuntu", "Ubuntu", "Ubuntu", "Ubuntu"]); }); it("each preset call yields fresh ids (no collisions across applications)", () => { const a = presetTwoByTwo(); const b = presetTwoByTwo(); const intersection = leafIds(a).filter((id) => leafIds(b).includes(id)); expect(intersection).toEqual([]); }); }); describe("serialize / deserialize", () => { it("roundtrips a complex tree", () => { const leaf1 = newLeaf({ distro: "Ubuntu", label: "left", broadcast: true }); const leaf2 = newLeaf({ distro: "Debian", cwd: "/projects/y" }); const leaf3 = newLeaf(); const tree = newSplit("h", leaf1, newSplit("v", leaf2, leaf3, 0.7), 0.4); const back = deserialize(serialize(tree)); expect(back).toEqual(tree); }); it("returns null on syntactically invalid JSON", () => { expect(deserialize("not json")).toBeNull(); }); it("returns null on JSON that doesn't match the tree shape", () => { expect(deserialize('{"not": "a tree"}')).toBeNull(); expect(deserialize('{"kind": "leaf"}')).toBeNull(); // missing id expect( deserialize('{"kind": "split", "id": "x", "orientation": "h"}'), ).toBeNull(); // missing ratio + children }); it("accepts a minimal leaf shape (backfilling shellKind for legacy data)", () => { expect(deserialize('{"kind": "leaf", "id": "x"}')).toEqual({ kind: "leaf", id: "x", shellKind: "wsl", }); }); it("migrates legacy PowerShell-sentinel leaves to shellKind=powershell", () => { const legacy = JSON.stringify({ kind: "split", id: "s1", orientation: "h", ratio: 0.5, a: { kind: "leaf", id: "a", distro: "PowerShell" }, b: { kind: "leaf", id: "b", distro: "Ubuntu" }, }); const back = deserialize(legacy) as SplitNode; const left = back.a as LeafNode; const right = back.b as LeafNode; expect(left.shellKind).toBe("powershell"); expect(left.distro).toBeUndefined(); expect(right.shellKind).toBe("wsl"); expect(right.distro).toBe("Ubuntu"); }); it("leaves shellKind alone on already-migrated leaves", () => { const fresh = JSON.stringify({ kind: "leaf", id: "x", shellKind: "ssh", sshHostId: "h-1", }); const back = deserialize(fresh) as LeafNode; expect(back.shellKind).toBe("ssh"); expect(back.sshHostId).toBe("h-1"); }); });