Files
openidea/src/ui/Tree.test.tsx
T

503 lines
19 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { beforeAll, afterAll, describe, expect, it, vi } from "vitest";
import { createRef } from "react";
import { act, fireEvent, render, screen, within } from "@testing-library/react";
import { Tree, type TreeHandle } from "./Tree";
import { getContext } from "@/platform/context";
interface N {
id: string;
children?: N[];
}
const data: N[] = [
{
id: "src",
children: [{ id: "main", children: [{ id: "App.tsx" }, { id: "index.ts" }] }, { id: "util.ts" }],
},
{ id: "README.md" },
];
function setup(props: Partial<React.ComponentProps<typeof Tree<N>>> = {}) {
const onActivate = vi.fn();
const utils = render(
<Tree<N>
aria-label="Files"
roots={data}
getKey={(n) => n.id}
getLabel={(n) => n.id}
getChildren={(n) => n.children}
isExpandable={(n) => !!n.children}
onActivate={onActivate}
{...props}
/>,
);
const tree = screen.getByRole("tree");
tree.focus();
const lead = () => {
const id = tree.getAttribute("aria-activedescendant");
return id ? document.getElementById(id)?.textContent : null;
};
const key = (k: string, init: Partial<KeyboardEventInit> = {}) => fireEvent.keyDown(tree, { key: k, ...init });
return { ...utils, tree, lead, key, onActivate };
}
const labels = (tree: HTMLElement) =>
within(tree)
.queryAllByRole("treeitem")
.map((el) => el.textContent);
describe("Tree directory context", () => {
it("does not contribute virtual document URIs as filesystem directories", () => {
const { key } = setup({ getDirectory: () => "jdt://contents/library.jar/package" });
key("Home");
expect(getContext().focusedDirectory).toBeUndefined();
});
it("follows the selected lead, clears on non-path rows, blur and unmount", () => {
const { tree, key, unmount } = setup({
getDirectory: (n) => (n.id === "main" ? null : `/p/${n.id}`),
});
expect(getContext().focusedDirectory).toBeUndefined();
key("ArrowDown");
expect(getContext().focusedDirectory).toBe("/p/src");
key("End", { shiftKey: true });
expect(getContext().focusedDirectory).toBe("/p/README.md");
key("Home");
key("ArrowRight");
key("ArrowRight");
expect(getContext().focusedDirectory).toBeUndefined();
key("Home");
fireEvent.blur(tree);
expect(getContext().focusedDirectory).toBeUndefined();
fireEvent.focus(tree);
expect(getContext().focusedDirectory).toBe("/p/src");
unmount();
expect(getContext().focusedDirectory).toBeUndefined();
});
it("updates a controlled selection while focused and clears a removed selection", () => {
const view = (selected: Set<string>, roots = data) => (
<Tree<N>
roots={roots}
getKey={(n) => n.id}
getLabel={(n) => n.id}
getChildren={(n) => n.children}
isExpandable={(n) => !!n.children}
selected={selected}
getDirectory={(n) => `/p/${n.id}`}
/>
);
const { rerender } = render(view(new Set(["src"])));
screen.getByRole("tree").focus();
expect(getContext().focusedDirectory).toBe("/p/src");
rerender(view(new Set()));
expect(getContext().focusedDirectory).toBeUndefined();
rerender(view(new Set(["src"])));
expect(getContext().focusedDirectory).toBe("/p/src");
rerender(view(new Set(["src"]), []));
expect(getContext().focusedDirectory).toBeUndefined();
});
it("clears on focus transfer and an old tree's unmount does not clear the new owner", () => {
const view = (showFirst: boolean) => (
<>
{showFirst && (
<Tree<N>
roots={data}
getKey={(n) => n.id}
getLabel={(n) => n.id}
getChildren={() => []}
isExpandable={() => false}
defaultSelected={["src"]}
getDirectory={() => "/first"}
/>
)}
<Tree<N>
roots={data}
getKey={(n) => n.id}
getLabel={(n) => n.id}
getChildren={() => []}
isExpandable={() => false}
defaultSelected={["src"]}
getDirectory={() => "/second"}
/>
<input aria-label="Editor" />
</>
);
const { rerender } = render(view(true));
const [first, second] = screen.getAllByRole("tree");
first!.focus();
expect(getContext().focusedDirectory).toBe("/first");
second!.focus();
expect(getContext().focusedDirectory).toBe("/second");
rerender(view(false));
expect(getContext().focusedDirectory).toBe("/second");
screen.getByLabelText("Editor").focus();
expect(getContext().focusedDirectory).toBeUndefined();
});
});
describe("Tree keyboard navigation", () => {
it("moves with arrows, expands with Right and collapses / goes to parent with Left", () => {
const { tree, lead, key } = setup();
expect(labels(tree)).toEqual(["src", "README.md"]);
key("ArrowDown");
expect(lead()).toBe("src");
key("ArrowRight");
expect(labels(tree)).toEqual(["src", "main", "util.ts", "README.md"]);
key("ArrowRight");
expect(lead()).toBe("main");
key("ArrowRight");
key("ArrowDown");
expect(lead()).toBe("App.tsx");
key("ArrowLeft");
expect(lead()).toBe("main");
key("ArrowLeft");
expect(labels(tree)).toEqual(["src", "main", "util.ts", "README.md"]);
key("End");
expect(lead()).toBe("README.md");
key("Home");
expect(lead()).toBe("src");
});
it("Enter toggles folders and activates leaves", () => {
const { tree, lead, key, onActivate } = setup();
key("ArrowDown");
key("Enter");
expect(labels(tree)).toContain("main");
key("End");
expect(lead()).toBe("README.md");
key("Enter");
expect(onActivate).toHaveBeenCalledWith(expect.objectContaining({ id: "README.md" }), expect.anything());
});
it("F4 and ⌘↓ activate like Enter (Jump to Source)", () => {
const { key, onActivate } = setup();
key("End");
key("F4");
expect(onActivate).toHaveBeenCalledTimes(1);
key("ArrowDown", { metaKey: true });
expect(onActivate).toHaveBeenCalledTimes(2);
expect(onActivate).toHaveBeenLastCalledWith(expect.objectContaining({ id: "README.md" }), expect.anything());
});
it("extends the selection with Shift+Arrow", () => {
const onSelectionChange = vi.fn();
const { key } = setup({ defaultExpanded: ["src"], onSelectionChange });
key("ArrowDown");
key("ArrowDown", { shiftKey: true });
key("ArrowDown", { shiftKey: true });
const last = onSelectionChange.mock.calls.at(-1)![0] as Set<string>;
expect([...last].sort()).toEqual(["main", "src", "util.ts"]);
});
it("lazy-loads children on expand", () => {
const loadChildren = vi.fn();
const { tree, key } = setup({ getChildren: () => undefined, loadChildren });
key("ArrowDown");
key("ArrowRight");
expect(loadChildren).toHaveBeenCalledWith(expect.objectContaining({ id: "src" }));
expect(tree.textContent).toContain("Loading");
});
});
describe("Tree speed search", () => {
it("jumps to and highlights matches, cycles with arrows and clears with Escape", () => {
const { tree, lead, key } = setup({ defaultExpanded: ["src", "main"] });
key("ArrowDown");
key("i");
key("n");
key("d");
expect(lead()).toBe("index.ts");
expect(screen.getByRole("status")).toHaveTextContent("ind");
expect([...tree.querySelectorAll("mark")].map((m) => m.textContent)).toEqual(["ind"]);
key("Backspace");
// "in" matches "main" (above) and "index.ts": arrows cycle between matches.
key("ArrowUp");
expect(lead()).toBe("main");
key("ArrowDown");
expect(lead()).toBe("index.ts");
key("ArrowDown");
expect(lead()).toBe("main");
key("Escape");
expect(screen.queryByRole("status")).toBeNull();
expect(tree.querySelectorAll("mark")).toHaveLength(0);
});
it("Home / End jump to the first / last match; Left closes the search and collapses", () => {
const { tree, lead, key } = setup({ defaultExpanded: ["src", "main"] });
key("ArrowDown");
key("t");
key("s");
// "ts" matches App.tsx, index.ts and util.ts.
key("End");
expect(lead()).toBe("util.ts");
key("Home");
expect(lead()).toBe("App.tsx");
key("ArrowLeft");
expect(screen.queryByRole("status")).toBeNull();
expect(lead()).toBe("main");
expect(labels(tree)).toContain("App.tsx");
});
it("filter mode hides non-matching rows but keeps ancestors", () => {
const { tree, key } = setup({ defaultExpanded: ["src", "main"], speedSearchMode: "filter" });
key("ArrowDown");
for (const c of "App") key(c);
expect(labels(tree)).toEqual(["src", "main", "App.tsx"]);
});
});
describe("Tree occurrences", () => {
it("selects the next / previous leaf, expanding its ancestors", () => {
const handle = createRef<TreeHandle>();
const onSelectionChange = vi.fn();
const { tree, lead } = setup({ handleRef: handle, onSelectionChange });
let step: ReturnType<TreeHandle["selectOccurrence"]> = null;
act(() => void (step = handle.current!.selectOccurrence(1)));
expect(step).toMatchObject({ key: "App.tsx", index: 1, total: 4 });
expect(labels(tree)).toEqual(["src", "main", "App.tsx", "index.ts", "util.ts", "README.md"]);
expect(lead()).toBe("App.tsx");
expect(onSelectionChange).toHaveBeenLastCalledWith(
new Set(["App.tsx"]),
expect.objectContaining({ id: "App.tsx" }),
);
act(() => void (step = handle.current!.selectOccurrence(1)));
expect(step).toMatchObject({ key: "index.ts", index: 2 });
act(() => void (step = handle.current!.selectOccurrence(-1)));
expect(step).toMatchObject({ key: "App.tsx", index: 1 });
act(() => void (step = handle.current!.selectOccurrence(-1)));
expect(step).toBeNull();
expect(lead()).toBe("App.tsx");
});
it("starts from the selected group and honours isOccurrence", () => {
const handle = createRef<TreeHandle>();
const { key } = setup({ handleRef: handle, isOccurrence: (n) => n.id !== "src" });
key("ArrowDown"); // src
let step: ReturnType<TreeHandle["selectOccurrence"]> = null;
act(() => void (step = handle.current!.selectOccurrence(1)));
expect(step).toMatchObject({ key: "main", index: 1, total: 5 });
act(() => void (step = handle.current!.selectOccurrence(-1)));
expect(step).toBeNull();
});
it("skips the lead's subtree on request", () => {
const handle = createRef<TreeHandle>();
const { key } = setup({ handleRef: handle, defaultExpanded: ["src"] });
key("ArrowDown");
key("ArrowDown"); // main
let step: ReturnType<TreeHandle["selectOccurrence"]> = null;
act(() => void (step = handle.current!.selectOccurrence(1, { skipSubtree: true })));
expect(step).toMatchObject({ key: "util.ts", index: 3 });
});
});
describe("siblingPositions", () => {
it("numbers rows among siblings", async () => {
const { siblingPositions } = await import("./Tree");
expect(
siblingPositions([{ parentKey: null }, { parentKey: "a" }, { parentKey: "a" }, { parentKey: null }]),
).toEqual([
[1, 2],
[1, 2],
[2, 2],
[2, 2],
]);
});
});
describe("Tree horizontal scrolling", () => {
const spacer = (tree: HTMLElement) => tree.querySelector<HTMLElement>("[data-content-width]");
it("is off unless asked for", () => {
const { tree } = setup();
expect(spacer(tree)).toBeNull();
});
it("remeasures mounted rows with the same key, including narrower content", () => {
const width = vi.spyOn(HTMLElement.prototype, "offsetWidth", "get").mockReturnValue(640);
try {
const { tree, key } = setup({ horizontalScroll: true, defaultExpanded: ["src"] });
expect(spacer(tree)?.style.width).toBe("640px");
width.mockReturnValue(100);
key("ArrowDown");
expect(spacer(tree)?.style.width).toBe("100px");
} finally {
width.mockRestore();
}
});
it("removes widths belonging to collapsed descendants", () => {
const width = vi.spyOn(HTMLElement.prototype, "offsetWidth", "get").mockImplementation(function (
this: HTMLElement,
) {
return this.textContent === "util.ts" ? 640 : 100;
});
try {
const { tree, key } = setup({ horizontalScroll: true, defaultExpanded: ["src"] });
expect(spacer(tree)?.style.width).toBe("640px");
key("Home");
key("ArrowLeft");
expect(spacer(tree)?.style.width).toBe("100px");
} finally {
width.mockRestore();
}
});
it("measures without the viewport minimum and restores it before returning", () => {
const minimums: string[] = [];
const width = vi.spyOn(HTMLElement.prototype, "offsetWidth", "get").mockImplementation(function (
this: HTMLElement,
) {
if (this.hasAttribute("data-row")) minimums.push(this.style.minWidth);
return parseFloat(this.style.minWidth) === 0 ? 100 : 800;
});
try {
const { tree } = setup({ horizontalScroll: true });
expect(spacer(tree)?.style.width).toBe("100px");
expect(minimums.length).toBeGreaterThan(0);
expect(minimums.every((value) => parseFloat(value) === 0)).toBe(true);
for (const row of tree.querySelectorAll<HTMLElement>("[data-row]")) expect(row.style.minWidth).toBe("");
} finally {
width.mockRestore();
}
});
});
describe("Tree scroll following", () => {
let scrollTo: ReturnType<typeof vi.fn>;
let original: PropertyDescriptor | undefined;
beforeAll(() => {
// jsdom has no Element.scrollTo; virtual-core calls it to write scrollTop.
scrollTo = vi.fn();
original = Object.getOwnPropertyDescriptor(Element.prototype, "scrollTo");
Object.defineProperty(Element.prototype, "scrollTo", { value: scrollTo, configurable: true, writable: true });
});
afterAll(() => {
if (original) Object.defineProperty(Element.prototype, "scrollTo", original);
else delete (Element.prototype as { scrollTo?: unknown }).scrollTo;
});
it("brings the keyboard lead into view", () => {
const { key } = setup();
scrollTo.mockClear();
key("End");
expect(scrollTo).toHaveBeenCalled();
});
it("keeps the viewport when expanding a folder above the selection", () => {
// The selected file stays selected while rows inserted above it shift its
// index; scrolling then would jump to the selection from the opened folder.
const { tree } = setup({ defaultSelected: ["README.md"] });
scrollTo.mockClear();
const chevron = tree.querySelector("[data-tree-toggle]");
expect(chevron).not.toBeNull();
fireEvent.mouseDown(chevron!, { button: 0 });
expect(scrollTo).not.toHaveBeenCalled();
// The folder did open, inserting rows above the selection.
expect(within(tree).queryAllByRole("treeitem").length).toBeGreaterThan(2);
});
it.each(["defaultSelected", "selected"] as const)("scrolls a late %s lead once when its row loads", (selection) => {
const view = (roots: N[]) => (
<Tree<N>
roots={roots}
getKey={(n) => n.id}
getLabel={(n) => n.id}
getChildren={(n) => n.children}
isExpandable={(n) => n.id === "src" || !!n.children}
defaultExpanded={["src"]}
{...{ [selection]: new Set(["late.ts"]) }}
/>
);
const { rerender } = render(view([{ id: "src" }]));
scrollTo.mockClear();
// An intermediate load still does not contain the selected row.
rerender(view([{ id: "src", children: [{ id: "main", children: [{ id: "App.tsx" }] }] }]));
expect(scrollTo).not.toHaveBeenCalled();
rerender(view([{ id: "src", children: [{ id: "main", children: [{ id: "App.tsx" }] }, { id: "late.ts" }] }]));
expect(scrollTo).toHaveBeenCalledTimes(1);
expect(screen.getByRole("treeitem", { name: "late.ts" })).toHaveAttribute("aria-selected", "true");
scrollTo.mockClear();
const folder = screen.getByRole("treeitem", { name: "main" });
fireEvent.mouseDown(folder.querySelector("[data-tree-toggle]")!, { button: 0 });
expect(screen.getByRole("treeitem", { name: "App.tsx" })).toBeInTheDocument();
expect(scrollTo).not.toHaveBeenCalled();
fireEvent.mouseDown(folder.querySelector("[data-tree-toggle]")!, { button: 0 });
expect(scrollTo).not.toHaveBeenCalled();
});
it("replaces a pending lead when the user selects another row", () => {
const view = (roots: N[]) => (
<Tree<N>
roots={roots}
getKey={(n) => n.id}
getLabel={(n) => n.id}
getChildren={(n) => n.children}
isExpandable={(n) => !!n.children}
defaultSelected={["late.ts"]}
/>
);
const { rerender } = render(view([{ id: "README.md" }]));
fireEvent.keyDown(screen.getByRole("tree"), { key: "End" });
scrollTo.mockClear();
rerender(view([{ id: "late.ts" }, { id: "README.md" }]));
expect(screen.getByRole("treeitem", { name: "README.md" })).toHaveAttribute("aria-selected", "true");
expect(scrollTo).not.toHaveBeenCalled();
});
it("explicitly reveals an unchanged lead", () => {
const handle = createRef<TreeHandle>();
setup({ handleRef: handle, defaultSelected: ["README.md"] });
scrollTo.mockClear();
act(() => handle.current!.reveal("README.md"));
expect(scrollTo).toHaveBeenCalledTimes(1);
});
it("brings speed search matches into view", () => {
const { key, lead } = setup({ defaultExpanded: ["src", "main"] });
key("Home");
scrollTo.mockClear();
key("u");
expect(lead()).toBe("util.ts");
expect(scrollTo).toHaveBeenCalledTimes(1);
});
});
describe("Tree indents", () => {
// Settings › Appearance › "Use smaller indents in trees" sets `--tree-indent`
// on the root; the row padding reads it so the setting reaches every tree at
// once, without a restart, and chevrons keep aligning with it.
const pads = (root: HTMLElement) =>
within(root)
.queryAllByRole("treeitem")
.map((el) => el.style.paddingLeft);
it("pads each level through --tree-indent, falling back to the indent prop", () => {
const { tree } = setup({ defaultExpanded: ["src"] });
expect(pads(tree)).toEqual([
"calc(4px + 0 * var(--tree-indent, 20px))", // src
"calc(4px + 1 * var(--tree-indent, 20px))", // main
"calc(4px + 1 * var(--tree-indent, 20px))", // util.ts
"calc(4px + 0 * var(--tree-indent, 20px))", // README.md
]);
});
it("honours a custom indent prop as the fallback value", () => {
const { tree } = setup({ defaultExpanded: ["src"], indent: 16 });
expect(pads(tree)[1]).toBe("calc(4px + 1 * var(--tree-indent, 16px))");
});
it("indents lazy-loading placeholder rows past their parent level", () => {
const { tree, key } = setup({ getChildren: () => undefined, loadChildren: () => undefined });
key("ArrowDown");
key("ArrowRight");
// Loading placeholders are `role="presentation"`, not tree items.
const placeholder = tree.querySelector<HTMLElement>('[role="presentation"]');
expect(placeholder?.style.paddingLeft).toBe("calc(24px + 1 * var(--tree-indent, 20px))");
});
});