Files
kandev/apps/web/lib/state/dockview-layout-builders-fixups.test.ts
2026-07-28 23:29:32 +01:00

214 lines
8.4 KiB
TypeScript

import { describe, it, expect, vi, beforeEach } from "vitest";
import type { DockviewApi } from "dockview-react";
// Dedicated file for `applyLayoutFixups` pinned-target capture. Mocks
// `./layout-manager` so we can drive the splitview geometry and spy on
// `setPinnedTarget` without standing up a real dockview instance. Kept
// separate from `dockview-layout-builders.test.ts` (which exercises
// `fallbackGroupPosition` against the real layout-manager constants).
const SIDEBAR_CAP = 441;
const RIGHT_CAP = 1029;
vi.mock("@/lib/debug/log", () => ({
createDebugLogger: () => () => {},
isDebug: () => false,
}));
vi.mock("@/lib/local-storage", () => ({
getGlobalSidebarWidth: vi.fn(() => null),
}));
vi.mock("./layout-manager", () => ({
SIDEBAR_LOCK: "no-drop-target",
SIDEBAR_GROUP: "group-sidebar",
CENTER_GROUP: "group-center",
RIGHT_TOP_GROUP: "group-right-top",
RIGHT_BOTTOM_GROUP: "group-right-bottom",
LAYOUT_PINNED_MIN_PX: 180,
computeSidebarMaxPx: vi.fn(() => SIDEBAR_CAP),
computeRightMaxPx: vi.fn(() => RIGHT_CAP),
getPinnedWidth: vi.fn(() => 350),
getRootSplitview: vi.fn(),
resolveGroupIds: vi.fn(() => ({
sidebarGroupId: "group-sidebar",
centerGroupId: "group-center",
rightTopGroupId: "group-right-top",
rightBottomGroupId: "group-right-bottom",
})),
setPinnedTarget: vi.fn(),
}));
import { applyLayoutFixups } from "./dockview-layout-builders";
import {
getRootSplitview,
setPinnedTarget,
computeSidebarMaxPx,
computeRightMaxPx,
getPinnedWidth,
RIGHT_TOP_GROUP,
} from "./layout-manager";
import { getGlobalSidebarWidth } from "@/lib/local-storage";
const SIDEBAR_GROUP = "group-sidebar";
const CENTER_GROUP = "group-center";
const RIGHT_BOTTOM_GROUP = "group-right-bottom";
let mockResizeView: ReturnType<typeof vi.fn>;
function mockSplitview(sizesByIndex: number[]): void {
mockResizeView = vi.fn();
vi.mocked(getRootSplitview).mockReturnValue({
length: sizesByIndex.length,
getViewSize: (idx: number) => sizesByIndex[idx],
resizeView: mockResizeView,
} as unknown as NonNullable<ReturnType<typeof getRootSplitview>>);
}
function makeApi(groupIds: string[]): DockviewApi {
const sidebarGroup = {
locked: undefined as unknown,
header: { hidden: true },
width: 0,
api: { setConstraints: vi.fn() },
};
return {
width: 1470,
groups: groupIds.map((id) => ({ id, api: { setConstraints: vi.fn() } })),
getPanel: (id: string) => (id === "sidebar" ? { group: sidebarGroup } : null),
} as unknown as DockviewApi;
}
describe("applyLayoutFixups — pinned target capture", () => {
beforeEach(() => {
vi.clearAllMocks();
vi.mocked(getGlobalSidebarWidth).mockReturnValue(null);
vi.mocked(getPinnedWidth).mockReturnValue(350);
});
it("does NOT record a right target in the 2-column fallback (center is last)", () => {
// Regression: global-fallback restore strips env-scoped right panels,
// leaving [sidebar, center]. The last splitview child is the CENTER
// column — its width must never be recorded as the "right" target, or it
// inflates the real right column and gets persisted into the env layout.
mockSplitview([474, 996]); // idx0 = sidebar, idx1 = center (NOT right)
const api = makeApi([SIDEBAR_GROUP, CENTER_GROUP]); // no right groups
applyLayoutFixups(api);
expect(setPinnedTarget).not.toHaveBeenCalledWith("right", expect.anything());
});
it("uses the default sidebar target instead of an env-saved live width when no global pref exists", () => {
// Slow fromJSON restore can bring back an env-specific sidebar width. With
// sidebar now a global pref, no pref means "use the default", not the
// env-saved live width.
mockSplitview([420, 1050]);
const api = makeApi([SIDEBAR_GROUP, CENTER_GROUP]);
applyLayoutFixups(api);
expect(setPinnedTarget).toHaveBeenCalledWith("sidebar", 350);
});
it("clamps a global sidebar pref down to the cap", () => {
vi.mocked(getGlobalSidebarWidth).mockReturnValue(900);
mockSplitview([474, 996]);
const api = makeApi([SIDEBAR_GROUP, CENTER_GROUP]);
applyLayoutFixups(api);
expect(setPinnedTarget).toHaveBeenCalledWith("sidebar", SIDEBAR_CAP);
});
it("uses the column default for the pinned right column when no saved width is given", () => {
// Regression (dockview-wrong-width): the default-preset right column must
// anchor to a STABLE width — the per-env saved width, or the column default
// when none — NOT dockview's post-fromJSON live size (400 here). Capturing
// the live size ratcheted the column wider on every restore. Mirrors
// captureSidebarTarget, which anchors to the pref/default, never the live
// size. getPinnedWidth is mocked to 350 (the default).
mockSplitview([350, 720, 400]); // live right = 400 (transient/rescaled)
const api = makeApi([SIDEBAR_GROUP, CENTER_GROUP, RIGHT_TOP_GROUP, RIGHT_BOTTOM_GROUP]);
applyLayoutFixups(api);
expect(setPinnedTarget).toHaveBeenCalledWith("sidebar", 350);
expect(setPinnedTarget).toHaveBeenCalledWith("right", 441); // responsive default, NOT live 400
expect(setPinnedTarget).not.toHaveBeenCalledWith("right", 400);
});
it("treats legacy saved geometry as responsive default width", () => {
// A legacy restore has no manual-width marker. Its serialized width (420)
// must not become sticky when the workbench moves to another monitor.
mockSplitview([350, 720, 400]);
const api = makeApi([SIDEBAR_GROUP, CENTER_GROUP, RIGHT_TOP_GROUP, RIGHT_BOTTOM_GROUP]);
applyLayoutFixups(api, 420);
expect(setPinnedTarget).toHaveBeenCalledWith("right", 441);
expect(setPinnedTarget).not.toHaveBeenCalledWith("right", 420);
expect(mockResizeView).toHaveBeenCalledWith(2, 441);
});
it("derives the caps from api.width, not the window.innerWidth fallback", () => {
// Regression: caps computed without the measured width fall back to
// window.innerWidth, which can be transiently stale and clamp the captured
// target too narrow. They must be derived from api.width (1470 here).
mockSplitview([350, 720, 400]);
const api = makeApi([SIDEBAR_GROUP, CENTER_GROUP, RIGHT_TOP_GROUP]);
applyLayoutFixups(api);
expect(computeSidebarMaxPx).toHaveBeenCalledWith(1470);
expect(computeRightMaxPx).toHaveBeenCalledWith(1470, 350);
});
it("records the side-column target for a 3-column preset without RIGHT_TOP_GROUP", () => {
// Regression: vscode/preview/plan presets put their side column in a group
// with a generated id (not RIGHT_TOP_GROUP). The target must still be
// captured per-env, or switching to such a task leaks the previous task's
// right target and snaps its side column to the wrong width.
mockSplitview([350, 720, 420]); // sidebar, center, vscode/preview side col
const api = makeApi([SIDEBAR_GROUP, CENTER_GROUP, "group-generated-7"]);
applyLayoutFixups(api);
expect(setPinnedTarget).toHaveBeenCalledWith("right", 420);
});
it("clamps an over-cap saved right width down to the cap", () => {
mockSplitview([350, 200, 800]);
const api = makeApi([SIDEBAR_GROUP, CENTER_GROUP, RIGHT_TOP_GROUP]);
applyLayoutFixups(api, 1200, 1200); // manual 1200 exceeds RIGHT_CAP (1029)
expect(setPinnedTarget).toHaveBeenCalledWith("right", RIGHT_CAP);
});
it("captures the right target in a 2-column layout with the pinned right column (no sidebar)", () => {
// Regression: a task whose saved layout has the sidebar hidden restores
// as a 2-column splitview [center, right]. Without per-env capture there,
// the stale global right target from a previous task's drag leaks through
// enforcePinnedTargets and snaps the restored column to the wrong width.
// The pinned right groups exist, so the last child IS the right column.
mockSplitview([720, 400]); // idx0 = center, idx1 = right
const api = makeApi([CENTER_GROUP, RIGHT_TOP_GROUP, RIGHT_BOTTOM_GROUP]);
applyLayoutFixups(api, 420, 420);
expect(setPinnedTarget).toHaveBeenCalledWith("right", 420);
expect(mockResizeView).toHaveBeenCalledWith(1, 420);
});
it("uses the column default for a 2-column pinned right layout when no saved width is given", () => {
mockSplitview([720, 400]);
const api = makeApi([CENTER_GROUP, RIGHT_TOP_GROUP]);
applyLayoutFixups(api);
expect(setPinnedTarget).toHaveBeenCalledWith("right", 441); // responsive default
expect(setPinnedTarget).not.toHaveBeenCalledWith("right", 400); // not live
});
});