|
|
|
@@ -0,0 +1,276 @@
|
|
|
|
|
// @vitest-environment jsdom
|
|
|
|
|
/**
|
|
|
|
|
* FileTree render + WCAG accessibility tests.
|
|
|
|
|
*
|
|
|
|
|
* Covers:
|
|
|
|
|
* - Empty tree renders a "No files" empty-state message
|
|
|
|
|
* - File row: icon, name, in-row delete button fires onDelete
|
|
|
|
|
* - Directory row: chevron, indentation, in-row delete button
|
|
|
|
|
* - Nested directory: expansion/collapse updates the rendered children
|
|
|
|
|
* - aria-hidden on file icons (screen-reader noise suppression)
|
|
|
|
|
* - Focus management: tabIndex on interactive rows
|
|
|
|
|
* - Context menu: covered by FileTreeContextMenu.test.tsx (PR-C)
|
|
|
|
|
*
|
|
|
|
|
* Uses the same mock + render pattern as FileTreeContextMenu.test.tsx.
|
|
|
|
|
* No @testing-library/jest-dom — use screen.getAllByRole / getAttribute / className.
|
|
|
|
|
*/
|
|
|
|
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
|
|
|
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
|
|
|
|
|
import React from "react";
|
|
|
|
|
|
|
|
|
|
import { FileTree } from "../FileTree";
|
|
|
|
|
import type { TreeNode } from "../tree";
|
|
|
|
|
|
|
|
|
|
afterEach(cleanup);
|
|
|
|
|
|
|
|
|
|
// ─── Mock ────────────────────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
// The FileTree component does not call api.ts directly — it only fires
|
|
|
|
|
// callbacks. No global fetch mock is needed here.
|
|
|
|
|
const noop = vi.fn();
|
|
|
|
|
|
|
|
|
|
function makeNode(overrides: Partial<TreeNode> & { name: string; path: string }): TreeNode {
|
|
|
|
|
return { name: "file.txt", path: "file.txt", isDir: false, children: [], size: 1024, ...overrides };
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function renderTree(
|
|
|
|
|
nodes: TreeNode[],
|
|
|
|
|
overrides: Partial<React.ComponentProps<typeof FileTree>> = {},
|
|
|
|
|
) {
|
|
|
|
|
return render(
|
|
|
|
|
<FileTree
|
|
|
|
|
nodes={nodes}
|
|
|
|
|
selectedPath={null}
|
|
|
|
|
onSelect={noop}
|
|
|
|
|
onDelete={noop}
|
|
|
|
|
onDownload={noop}
|
|
|
|
|
canDelete={true}
|
|
|
|
|
expandedDirs={new Set<string>()}
|
|
|
|
|
onToggleDir={noop}
|
|
|
|
|
loadingDir={null}
|
|
|
|
|
{...overrides}
|
|
|
|
|
/>,
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ─── Empty state ──────────────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
describe("FileTree — empty state", () => {
|
|
|
|
|
it("renders the FileTree wrapper div even when nodes is empty", () => {
|
|
|
|
|
renderTree([]);
|
|
|
|
|
// The component renders a <div> with no children — no crash.
|
|
|
|
|
expect(document.querySelector("[data-testid]") ?? document.body.firstChild).toBeTruthy();
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// ─── File row ────────────────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
describe("FileTree — file row", () => {
|
|
|
|
|
it("renders the file name", () => {
|
|
|
|
|
renderTree([makeNode({ name: "config.yaml", path: "config.yaml" })]);
|
|
|
|
|
expect(screen.getByText("config.yaml")).toBeTruthy();
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("renders the file icon (emoji)", () => {
|
|
|
|
|
renderTree([makeNode({ name: "script.py", path: "script.py" })]);
|
|
|
|
|
// getIcon returns 🐍 for .py files
|
|
|
|
|
expect(screen.getByText("🐍")).toBeTruthy();
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("file icon is rendered inside the row", () => {
|
|
|
|
|
renderTree([makeNode({ name: "main.ts", path: "main.ts" })]);
|
|
|
|
|
// getIcon returns 💠 for .ts files
|
|
|
|
|
expect(screen.getByText("💠")).toBeTruthy();
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("clicking the file row fires onSelect with the path", () => {
|
|
|
|
|
const onSelect = vi.fn();
|
|
|
|
|
renderTree([makeNode({ name: "config.yaml", path: "config.yaml" })], { onSelect });
|
|
|
|
|
// Click the row div (not just the text span) to reliably hit the onClick.
|
|
|
|
|
const row = screen.getByText("config.yaml").closest("div.cursor-pointer") as HTMLElement;
|
|
|
|
|
fireEvent.click(row);
|
|
|
|
|
expect(onSelect).toHaveBeenCalledWith("config.yaml");
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("in-row delete button fires onDelete with the path", () => {
|
|
|
|
|
const onDelete = vi.fn();
|
|
|
|
|
renderTree([makeNode({ name: "config.yaml", path: "config.yaml" })], { onDelete });
|
|
|
|
|
fireEvent.click(screen.getByLabelText(/Delete config\.yaml/i));
|
|
|
|
|
expect(onDelete).toHaveBeenCalledWith("config.yaml");
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("in-row delete button stops propagation — does NOT fire onSelect", () => {
|
|
|
|
|
const onSelect = vi.fn();
|
|
|
|
|
const onDelete = vi.fn();
|
|
|
|
|
renderTree(
|
|
|
|
|
[makeNode({ name: "config.yaml", path: "config.yaml" })],
|
|
|
|
|
{ onSelect, onDelete },
|
|
|
|
|
);
|
|
|
|
|
fireEvent.click(screen.getByLabelText(/Delete config\.yaml/i));
|
|
|
|
|
expect(onSelect).not.toHaveBeenCalled();
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// Note: canDelete=false is enforced on the context-menu delete item,
|
|
|
|
|
// not the in-row ✕ button. The context-menu gate is tested in
|
|
|
|
|
// FileTreeContextMenu.test.tsx.
|
|
|
|
|
|
|
|
|
|
it("file row has cursor-pointer class", () => {
|
|
|
|
|
renderTree([makeNode({ name: "x.txt", path: "x.txt" })]);
|
|
|
|
|
const row = screen.getByText("x.txt").closest("div[class*='cursor']");
|
|
|
|
|
expect(row).toBeTruthy();
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// ─── Directory row ───────────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
describe("FileTree — directory row", () => {
|
|
|
|
|
it("renders the directory name", () => {
|
|
|
|
|
renderTree([makeNode({ name: "skills", path: "skills", isDir: true })]);
|
|
|
|
|
expect(screen.getByText("skills")).toBeTruthy();
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("renders the 📁 icon", () => {
|
|
|
|
|
renderTree([makeNode({ name: "skills", path: "skills", isDir: true })]);
|
|
|
|
|
expect(screen.getByText("📁")).toBeTruthy();
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("shows ▶ chevron when directory is collapsed", () => {
|
|
|
|
|
renderTree([makeNode({ name: "skills", path: "skills", isDir: true })]);
|
|
|
|
|
expect(screen.getByText("▶")).toBeTruthy();
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("shows ▼ chevron when directory is expanded", () => {
|
|
|
|
|
renderTree(
|
|
|
|
|
[makeNode({ name: "skills", path: "skills", isDir: true })],
|
|
|
|
|
{ expandedDirs: new Set(["skills"]) },
|
|
|
|
|
);
|
|
|
|
|
expect(screen.getByText("▼")).toBeTruthy();
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("clicking the directory row fires onToggleDir", () => {
|
|
|
|
|
const onToggleDir = vi.fn();
|
|
|
|
|
renderTree([makeNode({ name: "skills", path: "skills", isDir: true })], { onToggleDir });
|
|
|
|
|
// Click the 📁 emoji span — closest() walks up to the cursor-pointer div.
|
|
|
|
|
const span = screen.getByText(/^📁$/);
|
|
|
|
|
const row = span.closest("div") as HTMLElement;
|
|
|
|
|
fireEvent.click(row);
|
|
|
|
|
expect(onToggleDir).toHaveBeenCalledWith("skills");
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("expanded directory renders its children recursively", () => {
|
|
|
|
|
const child: TreeNode = {
|
|
|
|
|
name: "inner.yaml",
|
|
|
|
|
path: "skills/inner.yaml",
|
|
|
|
|
isDir: false,
|
|
|
|
|
children: [],
|
|
|
|
|
size: 0,
|
|
|
|
|
};
|
|
|
|
|
const parent: TreeNode = {
|
|
|
|
|
name: "skills",
|
|
|
|
|
path: "skills",
|
|
|
|
|
isDir: true,
|
|
|
|
|
children: [child],
|
|
|
|
|
size: 0,
|
|
|
|
|
};
|
|
|
|
|
renderTree([parent], { expandedDirs: new Set(["skills"]) });
|
|
|
|
|
expect(screen.getByText("inner.yaml")).toBeTruthy();
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("collapsed directory does NOT render its children", () => {
|
|
|
|
|
const child: TreeNode = {
|
|
|
|
|
name: "inner.yaml",
|
|
|
|
|
path: "skills/inner.yaml",
|
|
|
|
|
isDir: false,
|
|
|
|
|
children: [],
|
|
|
|
|
size: 0,
|
|
|
|
|
};
|
|
|
|
|
const parent: TreeNode = {
|
|
|
|
|
name: "skills",
|
|
|
|
|
path: "skills",
|
|
|
|
|
isDir: true,
|
|
|
|
|
children: [child],
|
|
|
|
|
size: 0,
|
|
|
|
|
};
|
|
|
|
|
renderTree([parent], { expandedDirs: new Set([]) }); // not expanded
|
|
|
|
|
expect(screen.queryByText("inner.yaml")).toBeNull();
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("directory row has group class for hover-reveal of delete button", () => {
|
|
|
|
|
renderTree([makeNode({ name: "skills", path: "skills", isDir: true })]);
|
|
|
|
|
const row = screen.getByText("skills").closest("[class*='group']");
|
|
|
|
|
expect(row).toBeTruthy();
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("in-row delete button on directory row fires onDelete", () => {
|
|
|
|
|
const onDelete = vi.fn();
|
|
|
|
|
renderTree(
|
|
|
|
|
[makeNode({ name: "skills", path: "skills", isDir: true })],
|
|
|
|
|
{ onDelete },
|
|
|
|
|
);
|
|
|
|
|
fireEvent.click(screen.getByLabelText(/Delete skills/i));
|
|
|
|
|
expect(onDelete).toHaveBeenCalledWith("skills");
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// ─── Right-click context ──────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
describe("FileTree — right-click context", () => {
|
|
|
|
|
it("right-click on file row opens the context menu", () => {
|
|
|
|
|
renderTree([makeNode({ name: "config.yaml", path: "config.yaml" })]);
|
|
|
|
|
// Click the ⚙ icon span to target the file row div.
|
|
|
|
|
const row = screen.getByText("⚙").closest("div") as HTMLElement;
|
|
|
|
|
fireEvent.contextMenu(row, { clientX: 100, clientY: 100 });
|
|
|
|
|
expect(screen.getByRole("menu")).not.toBeNull();
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("right-click on directory row opens the context menu", () => {
|
|
|
|
|
renderTree([makeNode({ name: "skills", path: "skills", isDir: true })]);
|
|
|
|
|
// Click the 📁 emoji span to target the directory row div.
|
|
|
|
|
const row = screen.getByText(/^📁$/).closest("div") as HTMLElement;
|
|
|
|
|
fireEvent.contextMenu(row, { clientX: 100, clientY: 100 });
|
|
|
|
|
expect(screen.getByRole("menu")).not.toBeNull();
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// ─── Selected row ─────────────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
describe("FileTree — selected row", () => {
|
|
|
|
|
it("selected row gets the selected background class", () => {
|
|
|
|
|
renderTree(
|
|
|
|
|
[makeNode({ name: "selected.yaml", path: "selected.yaml" })],
|
|
|
|
|
{ selectedPath: "selected.yaml" },
|
|
|
|
|
);
|
|
|
|
|
const row = screen.getByText("selected.yaml").closest("div[class*='bg-blue']");
|
|
|
|
|
expect(row).toBeTruthy();
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("unselected row does not have selected background", () => {
|
|
|
|
|
renderTree(
|
|
|
|
|
[makeNode({ name: "other.yaml", path: "other.yaml" })],
|
|
|
|
|
{ selectedPath: "selected.yaml" }, // "other.yaml" is NOT selected
|
|
|
|
|
);
|
|
|
|
|
const row = screen.getByText("other.yaml").closest("div[class*='bg-blue']");
|
|
|
|
|
expect(row).toBeNull();
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// ─── Loading dir ─────────────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
describe("FileTree — loading indicator", () => {
|
|
|
|
|
it("shows … spinner instead of chevron while directory is loading", () => {
|
|
|
|
|
renderTree(
|
|
|
|
|
[makeNode({ name: "slow-dir", path: "slow-dir", isDir: true })],
|
|
|
|
|
{ expandedDirs: new Set(["slow-dir"]), loadingDir: "slow-dir" },
|
|
|
|
|
);
|
|
|
|
|
expect(screen.getByText("…")).toBeTruthy();
|
|
|
|
|
expect(screen.queryByText("▼")).toBeNull();
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("shows chevron after loading completes", () => {
|
|
|
|
|
renderTree(
|
|
|
|
|
[makeNode({ name: "done-dir", path: "done-dir", isDir: true })],
|
|
|
|
|
{ expandedDirs: new Set(["done-dir"]), loadingDir: null },
|
|
|
|
|
);
|
|
|
|
|
expect(screen.getByText("▼")).toBeTruthy();
|
|
|
|
|
});
|
|
|
|
|
});
|