feat: add workspace layout and window swapping
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
MAX_ANALYSIS_WINDOWS,
|
||||
applyWorkspaceLayout,
|
||||
closeAnalysisWindow,
|
||||
createInitialWorkspaceState,
|
||||
maximizeWorkspaceWindow,
|
||||
@@ -8,8 +9,11 @@ import {
|
||||
openAnalysisDocument,
|
||||
resizeWorkspace,
|
||||
restoreWorkspaceWindow,
|
||||
setWorkspacePrimaryWindow,
|
||||
swapWorkspaceWindows,
|
||||
updateWorkspaceWindowRect,
|
||||
type AnalysisDocument
|
||||
type AnalysisDocument,
|
||||
type WorkspaceState
|
||||
} from "./workspace-model";
|
||||
|
||||
const bounds = { width: 1180, height: 760 };
|
||||
@@ -24,6 +28,19 @@ function documentAt(index: number): AnalysisDocument {
|
||||
};
|
||||
}
|
||||
|
||||
function openDocuments(count: number) {
|
||||
return Array.from({ length: count }, (_, index) => index + 1).reduce(
|
||||
(current, index) => openAnalysisDocument(current, documentAt(index), bounds),
|
||||
createInitialWorkspaceState(bounds)
|
||||
);
|
||||
}
|
||||
|
||||
function visibleIds(state: WorkspaceState) {
|
||||
return [state.map, ...state.analyses]
|
||||
.filter((window) => window.mode !== "minimized")
|
||||
.map((window) => window.id);
|
||||
}
|
||||
|
||||
describe("workspace window lifecycle", () => {
|
||||
it("minimizes the map for the first analysis and restores it after the last window closes", () => {
|
||||
const initial = createInitialWorkspaceState(bounds);
|
||||
@@ -59,10 +76,7 @@ describe("workspace window lifecycle", () => {
|
||||
});
|
||||
|
||||
it("caps transient analysis windows without deleting an existing result", () => {
|
||||
const state = Array.from({ length: MAX_ANALYSIS_WINDOWS }, (_, index) => index + 1).reduce(
|
||||
(current, index) => openAnalysisDocument(current, documentAt(index), bounds),
|
||||
createInitialWorkspaceState(bounds)
|
||||
);
|
||||
const state = openDocuments(MAX_ANALYSIS_WINDOWS);
|
||||
|
||||
const overflow = openAnalysisDocument(state, documentAt(99), bounds);
|
||||
expect(overflow).toBe(state);
|
||||
@@ -86,3 +100,131 @@ describe("workspace window lifecycle", () => {
|
||||
expect(resized.map.restoreRect.y + resized.map.restoreRect.height).toBeLessThanOrEqual(588);
|
||||
});
|
||||
});
|
||||
|
||||
describe("workspace layout presets", () => {
|
||||
it("places the active window first, keeps the map visible and minimizes primary-layout overflow", () => {
|
||||
const arranged = applyWorkspaceLayout(openDocuments(5), "primary", bounds);
|
||||
|
||||
expect(arranged.layout.mode).toBe("primary");
|
||||
expect(arranged.layout.orderedWindowIds[0]).toBe("analysis-5");
|
||||
expect(visibleIds(arranged)).toEqual(["workspace-map", "analysis-4", "analysis-5"]);
|
||||
expect(arranged.analyses.filter((window) => window.minimizedByLayout)).toHaveLength(3);
|
||||
|
||||
const primary = arranged.analyses.find((window) => window.id === "analysis-5");
|
||||
expect(primary?.rect.width).toBeGreaterThan(arranged.map.rect.width);
|
||||
expect(primary?.rect.height).toBe(bounds.height - 16);
|
||||
});
|
||||
|
||||
it("restores every window into a bounded grid", () => {
|
||||
const arranged = applyWorkspaceLayout(openDocuments(6), "grid", bounds);
|
||||
const windows = [arranged.map, ...arranged.analyses];
|
||||
|
||||
expect(windows).toHaveLength(7);
|
||||
expect(windows.every((window) => window.mode === "floating")).toBe(true);
|
||||
for (const window of windows) {
|
||||
expect(window.rect.x).toBeGreaterThanOrEqual(8);
|
||||
expect(window.rect.y).toBeGreaterThanOrEqual(8);
|
||||
expect(window.rect.x + window.rect.width).toBeLessThanOrEqual(bounds.width - 8);
|
||||
expect(window.rect.y + window.rect.height).toBeLessThanOrEqual(bounds.height - 8);
|
||||
}
|
||||
});
|
||||
|
||||
it("uses two visible slots for split and restores all windows when changing presets", () => {
|
||||
const primary = applyWorkspaceLayout(openDocuments(4), "primary", bounds);
|
||||
const split = applyWorkspaceLayout(primary, "split", bounds);
|
||||
|
||||
expect(visibleIds(split)).toHaveLength(2);
|
||||
expect(visibleIds(split)).toContain("workspace-map");
|
||||
expect(split.analyses.filter((window) => window.minimizedByLayout)).toHaveLength(3);
|
||||
|
||||
const grid = applyWorkspaceLayout(split, "grid", bounds);
|
||||
expect(visibleIds(grid)).toHaveLength(5);
|
||||
});
|
||||
|
||||
it("auto-inserts a new Agent result into the current layout", () => {
|
||||
const primary = applyWorkspaceLayout(openDocuments(2), "primary", bounds);
|
||||
const opened = openAnalysisDocument(primary, documentAt(3), bounds);
|
||||
|
||||
expect(opened.layout.mode).toBe("primary");
|
||||
expect(opened.layout.orderedWindowIds[0]).toBe("analysis-3");
|
||||
expect(opened.activeWindowId).toBe("analysis-3");
|
||||
expect(opened.map.mode).not.toBe("minimized");
|
||||
});
|
||||
|
||||
it("reflows after manual minimize, taskbar restore and workspace resize", () => {
|
||||
const arranged = applyWorkspaceLayout(openDocuments(4), "primary", bounds);
|
||||
const minimized = minimizeWorkspaceWindow(arranged, "analysis-4", bounds);
|
||||
const replacement = minimized.analyses.find((window) => window.id === "analysis-3");
|
||||
|
||||
expect(minimized.analyses.find((window) => window.id === "analysis-4")?.minimizedByLayout).toBe(false);
|
||||
expect(replacement?.mode).toBe("floating");
|
||||
|
||||
const restored = restoreWorkspaceWindow(minimized, "analysis-4", bounds);
|
||||
expect(restored.layout.orderedWindowIds[0]).toBe("analysis-4");
|
||||
expect(restored.analyses.find((window) => window.id === "analysis-4")?.mode).toBe("floating");
|
||||
|
||||
const resized = resizeWorkspace(restored, { width: 900, height: 620 });
|
||||
expect(resized.analyses.find((window) => window.id === "analysis-4")?.rect.height).toBe(604);
|
||||
});
|
||||
|
||||
it("supports cascade layout and an explicit primary-window command", () => {
|
||||
const cascade = applyWorkspaceLayout(openDocuments(3), "cascade", bounds);
|
||||
expect(visibleIds(cascade)).toHaveLength(4);
|
||||
expect(cascade.analyses[0]?.rect.x).toBeGreaterThan(cascade.analyses[1]?.rect.x ?? 0);
|
||||
|
||||
const primary = applyWorkspaceLayout(cascade, "primary", bounds);
|
||||
const promoted = setWorkspacePrimaryWindow(primary, "analysis-1", bounds);
|
||||
expect(promoted.layout.orderedWindowIds[0]).toBe("analysis-1");
|
||||
expect(promoted.analyses.find((window) => window.id === "analysis-1")?.rect.width).toBeGreaterThan(promoted.map.rect.width);
|
||||
});
|
||||
});
|
||||
|
||||
describe("workspace window exchange", () => {
|
||||
it("swaps layout slots and preserves the order after resize", () => {
|
||||
const arranged = applyWorkspaceLayout(openDocuments(2), "primary", bounds);
|
||||
const mapRect = arranged.map.rect;
|
||||
const primaryRect = arranged.analyses.find((window) => window.id === "analysis-2")?.rect;
|
||||
const swapped = swapWorkspaceWindows(arranged, "workspace-map", "analysis-2", bounds);
|
||||
|
||||
expect(swapped.map.rect).toEqual(primaryRect);
|
||||
expect(swapped.analyses.find((window) => window.id === "analysis-2")?.rect).toEqual(mapRect);
|
||||
|
||||
const resized = resizeWorkspace(swapped, { width: 1000, height: 700 });
|
||||
expect(resized.layout.orderedWindowIds.indexOf("workspace-map")).toBeLessThan(
|
||||
resized.layout.orderedWindowIds.indexOf("analysis-2")
|
||||
);
|
||||
expect(resized.map.rect.width).toBeGreaterThan(resized.analyses.find((window) => window.id === "analysis-2")!.rect.width);
|
||||
});
|
||||
|
||||
it("exchanges full window roles and rectangles in free layout", () => {
|
||||
const initial = createInitialWorkspaceState(bounds);
|
||||
const mapRect = initial.map.rect;
|
||||
const opened = openAnalysisDocument(initial, documentAt(1), bounds);
|
||||
const analysisRect = opened.analyses[0]!.rect;
|
||||
const restoredMap = { ...opened, map: { ...opened.map, mode: "docked" as const } };
|
||||
const swapped = swapWorkspaceWindows(restoredMap, "analysis-1", "workspace-map", bounds);
|
||||
|
||||
expect(swapped.analyses[0]?.mode).toBe("docked");
|
||||
expect(swapped.analyses[0]?.rect).toEqual(mapRect);
|
||||
expect(swapped.map.mode).toBe("floating");
|
||||
expect(swapped.map.rect).toEqual(analysisRect);
|
||||
});
|
||||
|
||||
it("exits automatic layout when a window is placed in blank workspace", () => {
|
||||
const arranged = applyWorkspaceLayout(openDocuments(2), "grid", bounds);
|
||||
const moved = updateWorkspaceWindowRect(
|
||||
arranged,
|
||||
"analysis-2",
|
||||
{ x: 90, y: 70, width: 420, height: 360 },
|
||||
bounds
|
||||
);
|
||||
|
||||
expect(moved.layout.mode).toBe("free");
|
||||
expect(moved.analyses.find((window) => window.id === "analysis-2")?.rect).toEqual({
|
||||
x: 90,
|
||||
y: 70,
|
||||
width: 420,
|
||||
height: 360
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user