mirror of
https://github.com/HKUDS/nanobot.git
synced 2026-08-31 16:21:50 +03:00
438 lines
15 KiB
TypeScript
438 lines
15 KiB
TypeScript
import { createPortal } from "react-dom";
|
|
import { type ReactNode, useState } from "react";
|
|
import { act, fireEvent, render, screen, waitFor, within } from "@testing-library/react";
|
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
|
|
import { PaneWorkbench } from "@/components/workbench/PaneWorkbench";
|
|
import {
|
|
EMPTY_WORKBENCH_STATE,
|
|
addWorkbenchPane,
|
|
setWorkbenchLayout,
|
|
setWorkbenchPaneLayoutOrder,
|
|
workbenchTab,
|
|
workbenchTabForPane,
|
|
} from "@/components/workbench/workbench-model";
|
|
|
|
function rect(left: number, top: number, width: number, height: number): DOMRect {
|
|
return {
|
|
x: left,
|
|
y: top,
|
|
left,
|
|
top,
|
|
width,
|
|
height,
|
|
right: left + width,
|
|
bottom: top + height,
|
|
toJSON: () => ({}),
|
|
};
|
|
}
|
|
|
|
function WorkbenchHarness({
|
|
initialLayout = "columns",
|
|
onPaneOrderChange = () => {},
|
|
onSplitRatiosChange = () => {},
|
|
}: {
|
|
initialLayout?: "columns" | "rows";
|
|
onPaneOrderChange?: (paneKeys: string[]) => void;
|
|
onSplitRatiosChange?: (splitRatios: number[]) => void;
|
|
} = {}) {
|
|
const [activePaneKey, setActivePaneKey] = useState("beta");
|
|
const [state, setState] = useState(() => {
|
|
const initial = addWorkbenchPane(EMPTY_WORKBENCH_STATE, "alpha", "beta");
|
|
const tabKey = workbenchTabForPane(initial, "alpha").tabKey;
|
|
return setWorkbenchLayout(initial, tabKey, initialLayout);
|
|
});
|
|
const tabKey = workbenchTabForPane(state, "alpha").tabKey;
|
|
const tab = workbenchTab(state, tabKey);
|
|
if (!tab) return null;
|
|
const titles: Record<string, string> = { alpha: "Alpha", beta: "Beta" };
|
|
|
|
return (
|
|
<PaneWorkbench
|
|
panes={tab.layoutPaneKeys.map((key) => ({ key, title: titles[key] }))}
|
|
activePaneKey={activePaneKey}
|
|
layout={tab.layout}
|
|
splitRatios={tab.splitRatios}
|
|
showLayoutControl
|
|
onActivatePane={setActivePaneKey}
|
|
onAddPane={vi.fn()}
|
|
onLayoutChange={(layout) => setState((current) => (
|
|
setWorkbenchLayout(current, tabKey, layout)
|
|
))}
|
|
onPaneOrderChange={(paneKeys) => {
|
|
onPaneOrderChange(paneKeys);
|
|
setState((current) => (
|
|
setWorkbenchPaneLayoutOrder(current, tabKey, paneKeys)
|
|
));
|
|
}}
|
|
onSplitRatiosChange={onSplitRatiosChange}
|
|
renderPane={(pane, context) => (
|
|
<>
|
|
<button type="button">Focus {pane.title}</button>
|
|
{context.headerPortalTarget && context.active ? createPortal(
|
|
context.headerActions,
|
|
context.headerPortalTarget,
|
|
) : null}
|
|
{context.composerPortalTarget ? createPortal(
|
|
<div hidden={!context.active}>
|
|
<textarea aria-label={`Composer ${pane.title}`} />
|
|
</div>,
|
|
context.composerPortalTarget,
|
|
) : null}
|
|
</>
|
|
)}
|
|
/>
|
|
);
|
|
}
|
|
|
|
function BspWorkbenchHarness() {
|
|
const panes = ["alpha", "beta", "gamma", "delta"].map((key) => ({
|
|
key,
|
|
title: key,
|
|
}));
|
|
return (
|
|
<PaneWorkbench
|
|
panes={panes}
|
|
activePaneKey="delta"
|
|
layout="bsp"
|
|
splitRatios={[]}
|
|
showLayoutControl
|
|
onActivatePane={vi.fn()}
|
|
onAddPane={vi.fn()}
|
|
onLayoutChange={vi.fn()}
|
|
onPaneOrderChange={vi.fn()}
|
|
onSplitRatiosChange={vi.fn()}
|
|
renderPane={(pane) => <span>{pane.title}</span>}
|
|
/>
|
|
);
|
|
}
|
|
|
|
describe("PaneWorkbench", () => {
|
|
const originalGetBoundingClientRect = HTMLElement.prototype.getBoundingClientRect;
|
|
const originalAnimate = HTMLElement.prototype.animate;
|
|
const animate = vi.fn(() => ({
|
|
addEventListener: vi.fn(),
|
|
cancel: vi.fn(),
|
|
}) as unknown as Animation);
|
|
|
|
beforeEach(() => {
|
|
vi.stubGlobal("matchMedia", vi.fn((query: string) => ({
|
|
matches: false,
|
|
media: query,
|
|
onchange: null,
|
|
addListener: vi.fn(),
|
|
removeListener: vi.fn(),
|
|
addEventListener: vi.fn(),
|
|
removeEventListener: vi.fn(),
|
|
dispatchEvent: vi.fn(),
|
|
})));
|
|
HTMLElement.prototype.animate = animate;
|
|
HTMLElement.prototype.getBoundingClientRect = function getBoundingClientRect() {
|
|
if (this.dataset.testid === "pane-grid") return rect(0, 0, 1000, 1000);
|
|
if (!this.classList.contains("workbench-pane")) {
|
|
return originalGetBoundingClientRect.call(this);
|
|
}
|
|
const layout = this.parentElement?.dataset.layout;
|
|
const index = Array.from(this.parentElement?.children ?? []).indexOf(this);
|
|
return layout === "rows"
|
|
? rect(0, index * 500, 1000, 500)
|
|
: rect(index * 500, 0, 500, 1000);
|
|
};
|
|
});
|
|
|
|
afterEach(() => {
|
|
HTMLElement.prototype.animate = originalAnimate;
|
|
HTMLElement.prototype.getBoundingClientRect = originalGetBoundingClientRect;
|
|
vi.unstubAllGlobals();
|
|
vi.clearAllMocks();
|
|
});
|
|
|
|
it("focuses without reordering and keeps only the focused composer visible", () => {
|
|
render(<WorkbenchHarness />);
|
|
|
|
const grid = screen.getByTestId("pane-grid");
|
|
expect(Array.from(grid.children).map((pane) => pane.getAttribute("aria-label")))
|
|
.toEqual(["Alpha", "Beta"]);
|
|
expect(screen.getByLabelText("Composer Beta")).toBeVisible();
|
|
expect(screen.getByLabelText("Composer Alpha")).not.toBeVisible();
|
|
|
|
fireEvent.pointerDown(
|
|
within(screen.getByRole("region", { name: "Alpha" }))
|
|
.getByRole("button", { name: "Focus Alpha" }),
|
|
);
|
|
|
|
expect(Array.from(grid.children).map((pane) => pane.getAttribute("aria-label")))
|
|
.toEqual(["Alpha", "Beta"]);
|
|
expect(screen.getByLabelText("Composer Alpha")).toBeVisible();
|
|
expect(screen.getByLabelText("Composer Beta")).not.toBeVisible();
|
|
});
|
|
|
|
it("moves the focused pane between workspace slots from its bottom handle", () => {
|
|
const onPaneOrderChange = vi.fn();
|
|
render(<WorkbenchHarness onPaneOrderChange={onPaneOrderChange} />);
|
|
|
|
const grid = screen.getByTestId("pane-grid");
|
|
const handle = screen.getByRole("button", { name: "Move Beta pane" });
|
|
expect(Array.from(grid.children).map((pane) => pane.getAttribute("aria-label")))
|
|
.toEqual(["Alpha", "Beta"]);
|
|
|
|
fireEvent.pointerDown(handle, { button: 0, pointerId: 1, clientX: 750, clientY: 990 });
|
|
fireEvent.pointerMove(window, {
|
|
buttons: 1,
|
|
pointerId: 1,
|
|
clientX: 250,
|
|
clientY: 500,
|
|
});
|
|
expect(Array.from(grid.children).map((pane) => pane.getAttribute("aria-label")))
|
|
.toEqual(["Beta", "Alpha"]);
|
|
expect(onPaneOrderChange).toHaveBeenCalledOnce();
|
|
expect(onPaneOrderChange).toHaveBeenCalledWith(["beta", "alpha"]);
|
|
|
|
fireEvent.pointerMove(window, {
|
|
buttons: 1,
|
|
pointerId: 1,
|
|
clientX: 750,
|
|
clientY: 500,
|
|
});
|
|
expect(Array.from(grid.children).map((pane) => pane.getAttribute("aria-label")))
|
|
.toEqual(["Alpha", "Beta"]);
|
|
expect(onPaneOrderChange).toHaveBeenLastCalledWith(["alpha", "beta"]);
|
|
fireEvent.pointerUp(window, { pointerId: 1, clientX: 750, clientY: 500 });
|
|
|
|
expect(Array.from(grid.children).map((pane) => pane.getAttribute("aria-label")))
|
|
.toEqual(["Alpha", "Beta"]);
|
|
expect(onPaneOrderChange).toHaveBeenCalledTimes(2);
|
|
});
|
|
|
|
it("moves panes up and down through stable workspace slots", () => {
|
|
const onPaneOrderChange = vi.fn();
|
|
render(
|
|
<WorkbenchHarness initialLayout="rows" onPaneOrderChange={onPaneOrderChange} />,
|
|
);
|
|
|
|
const grid = screen.getByTestId("pane-grid");
|
|
const handle = screen.getByRole("button", { name: "Move Beta pane" });
|
|
fireEvent.pointerDown(handle, { button: 0, pointerId: 1, clientX: 500, clientY: 990 });
|
|
fireEvent.pointerMove(window, {
|
|
buttons: 1,
|
|
pointerId: 1,
|
|
clientX: 500,
|
|
clientY: 250,
|
|
});
|
|
expect(Array.from(grid.children).map((pane) => pane.getAttribute("aria-label")))
|
|
.toEqual(["Beta", "Alpha"]);
|
|
|
|
fireEvent.pointerMove(window, {
|
|
buttons: 1,
|
|
pointerId: 1,
|
|
clientX: 500,
|
|
clientY: 750,
|
|
});
|
|
expect(Array.from(grid.children).map((pane) => pane.getAttribute("aria-label")))
|
|
.toEqual(["Alpha", "Beta"]);
|
|
expect(onPaneOrderChange.mock.calls).toEqual([
|
|
[["beta", "alpha"]],
|
|
[["alpha", "beta"]],
|
|
]);
|
|
fireEvent.pointerUp(window, { pointerId: 1, clientX: 500, clientY: 750 });
|
|
});
|
|
|
|
it("moves the focused pane between workspace slots with arrow keys", () => {
|
|
render(<WorkbenchHarness />);
|
|
|
|
const handle = screen.getByRole("button", { name: "Move Beta pane" });
|
|
act(() => handle.focus());
|
|
expect(handle).toHaveFocus();
|
|
fireEvent.keyDown(handle, { key: "ArrowLeft" });
|
|
|
|
expect(Array.from(screen.getByTestId("pane-grid").children)
|
|
.map((pane) => pane.getAttribute("aria-label")))
|
|
.toEqual(["Beta", "Alpha"]);
|
|
});
|
|
|
|
it("previews edge resizing locally and commits one ratio when dragging ends", () => {
|
|
const onSplitRatiosChange = vi.fn();
|
|
render(<WorkbenchHarness onSplitRatiosChange={onSplitRatiosChange} />);
|
|
|
|
const grid = screen.getByTestId("pane-grid");
|
|
const separator = screen.getByRole("separator", {
|
|
name: "Resize pane boundary 1",
|
|
});
|
|
fireEvent.pointerDown(separator, {
|
|
button: 0,
|
|
pointerId: 7,
|
|
clientX: 500,
|
|
clientY: 400,
|
|
});
|
|
fireEvent.pointerMove(window, {
|
|
buttons: 1,
|
|
pointerId: 7,
|
|
clientX: 700,
|
|
clientY: 400,
|
|
});
|
|
|
|
expect(grid.style.gridTemplateColumns)
|
|
.toBe("minmax(0, 700fr) minmax(0, 300fr)");
|
|
expect(onSplitRatiosChange).not.toHaveBeenCalled();
|
|
|
|
fireEvent.pointerUp(window, { pointerId: 7, clientX: 700, clientY: 400 });
|
|
expect(onSplitRatiosChange).toHaveBeenCalledOnce();
|
|
expect(onSplitRatiosChange).toHaveBeenCalledWith([0.7]);
|
|
});
|
|
|
|
it("resizes a pane boundary with the matching arrow keys", () => {
|
|
const onSplitRatiosChange = vi.fn();
|
|
render(<WorkbenchHarness onSplitRatiosChange={onSplitRatiosChange} />);
|
|
|
|
const separator = screen.getByRole("separator", {
|
|
name: "Resize pane boundary 1",
|
|
});
|
|
fireEvent.keyDown(separator, { key: "ArrowLeft" });
|
|
|
|
expect(onSplitRatiosChange).toHaveBeenCalledWith([0.47]);
|
|
});
|
|
|
|
it("keeps one shared layout control and animates geometry changes", async () => {
|
|
render(<WorkbenchHarness />);
|
|
|
|
const header = screen.getByTestId("workbench-header-host");
|
|
expect(within(header).getAllByRole("button", { name: "Pane layout" })).toHaveLength(1);
|
|
fireEvent.pointerDown(within(header).getByRole("button", { name: "Pane layout" }), {
|
|
button: 0,
|
|
ctrlKey: false,
|
|
});
|
|
fireEvent.click(screen.getByRole("menuitemradio", { name: "Rows" }));
|
|
expect(screen.getByTestId("pane-grid")).toHaveAttribute("data-layout", "rows");
|
|
await waitFor(() => expect(animate).toHaveBeenCalledTimes(2));
|
|
|
|
fireEvent.pointerDown(within(header).getByRole("button", { name: "Pane layout" }), {
|
|
button: 0,
|
|
ctrlKey: false,
|
|
});
|
|
fireEvent.click(screen.getByRole("menuitemradio", { name: "BSP" }));
|
|
expect(screen.getByTestId("pane-grid")).toHaveAttribute("data-layout", "bsp");
|
|
await waitFor(() => expect(animate).toHaveBeenCalledTimes(4));
|
|
});
|
|
|
|
it("keeps a retargeted pane surface in the same physical motion", async () => {
|
|
const common = {
|
|
layout: "columns" as const,
|
|
showLayoutControl: false,
|
|
onActivatePane: vi.fn(),
|
|
onAddPane: vi.fn(),
|
|
onLayoutChange: vi.fn(),
|
|
onPaneOrderChange: vi.fn(),
|
|
renderPane: (pane: { title: string }) => <span>{pane.title}</span>,
|
|
};
|
|
const { rerender } = render(
|
|
<PaneWorkbench
|
|
{...common}
|
|
panes={[
|
|
{ key: "alpha", reactKey: "tab-root", title: "Alpha" },
|
|
{ key: "beta", reactKey: "pane:beta", title: "Beta" },
|
|
]}
|
|
activePaneKey="alpha"
|
|
/>,
|
|
);
|
|
animate.mockClear();
|
|
|
|
rerender(
|
|
<PaneWorkbench
|
|
{...common}
|
|
panes={[
|
|
{ key: "beta", reactKey: "pane:beta", title: "Beta" },
|
|
{ key: "gamma", reactKey: "tab-root", title: "Gamma" },
|
|
]}
|
|
activePaneKey="gamma"
|
|
/>,
|
|
);
|
|
|
|
await waitFor(() => expect(animate).toHaveBeenCalledWith(
|
|
[
|
|
{ transform: "translate(-500px, 0px) scale(1, 1)" },
|
|
{ transform: "translate(0, 0) scale(1, 1)" },
|
|
],
|
|
{
|
|
duration: 260,
|
|
easing: "cubic-bezier(0.2, 0, 0, 1)",
|
|
},
|
|
));
|
|
});
|
|
|
|
it("renders only the active pane and hides workbench controls on mobile", () => {
|
|
vi.stubGlobal("matchMedia", vi.fn((query: string) => ({
|
|
matches: query.includes("max-width: 767px"),
|
|
media: query,
|
|
onchange: null,
|
|
addListener: vi.fn(),
|
|
removeListener: vi.fn(),
|
|
addEventListener: vi.fn(),
|
|
removeEventListener: vi.fn(),
|
|
dispatchEvent: vi.fn(),
|
|
})));
|
|
const panes = ["alpha", "beta", "gamma", "delta"].map((key) => ({
|
|
key,
|
|
title: key,
|
|
}));
|
|
const props = {
|
|
panes,
|
|
layout: "bsp" as const,
|
|
showLayoutControl: true,
|
|
onActivatePane: vi.fn(),
|
|
onAddPane: vi.fn(),
|
|
onLayoutChange: vi.fn(),
|
|
onPaneOrderChange: vi.fn(),
|
|
renderPane: (pane: { key: string; title: string }, context: {
|
|
headerActions: ReactNode;
|
|
}) => <>{context.headerActions}<span>{pane.title}</span></>,
|
|
};
|
|
|
|
const { rerender } = render(<PaneWorkbench {...props} activePaneKey="gamma" />);
|
|
|
|
expect(screen.getByTestId("pane-grid").children).toHaveLength(1);
|
|
expect(screen.getByTestId("workbench-pane-gamma")).toBeInTheDocument();
|
|
expect(screen.queryByRole("button", { name: "Pane layout" })).not.toBeInTheDocument();
|
|
expect(screen.queryByRole("button", { name: "Add pane" })).not.toBeInTheDocument();
|
|
expect(screen.queryByRole("separator")).not.toBeInTheDocument();
|
|
|
|
rerender(<PaneWorkbench {...props} activePaneKey="delta" />);
|
|
expect(screen.getByTestId("pane-grid").children).toHaveLength(1);
|
|
expect(screen.getByTestId("workbench-pane-delta")).toBeInTheDocument();
|
|
expect(screen.queryByTestId("workbench-pane-gamma")).not.toBeInTheDocument();
|
|
});
|
|
|
|
it("explains why the desktop add-pane control is disabled", () => {
|
|
render(
|
|
<PaneWorkbench
|
|
panes={[{ key: "alpha", title: "Alpha" }]}
|
|
activePaneKey="alpha"
|
|
layout="columns"
|
|
showLayoutControl={false}
|
|
addPaneDisabled
|
|
addPaneDisabledLabel="Maximum 4 panes"
|
|
onActivatePane={vi.fn()}
|
|
onAddPane={vi.fn()}
|
|
onLayoutChange={vi.fn()}
|
|
onPaneOrderChange={vi.fn()}
|
|
renderPane={(_pane, context) => context.headerActions}
|
|
/>,
|
|
);
|
|
|
|
expect(screen.getByRole("button", { name: "Add pane" }))
|
|
.toHaveAttribute("title", "Maximum 4 panes");
|
|
});
|
|
|
|
it("fills the workbench through alternating binary splits", () => {
|
|
render(<BspWorkbenchHarness />);
|
|
|
|
const alpha = screen.getByTestId("workbench-pane-alpha");
|
|
const beta = screen.getByTestId("workbench-pane-beta");
|
|
const gamma = screen.getByTestId("workbench-pane-gamma");
|
|
const delta = screen.getByTestId("workbench-pane-delta");
|
|
expect([alpha.style.gridColumn, alpha.style.gridRow]).toEqual(["1 / 2", "1 / 3"]);
|
|
expect([beta.style.gridColumn, beta.style.gridRow]).toEqual(["2 / 4", "1 / 2"]);
|
|
expect([gamma.style.gridColumn, gamma.style.gridRow]).toEqual(["2 / 3", "2 / 3"]);
|
|
expect([delta.style.gridColumn, delta.style.gridRow]).toEqual(["3 / 4", "2 / 3"]);
|
|
});
|
|
});
|