import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { render, screen, fireEvent, waitFor } from "@testing-library/react";
import type { TaskDocumentWithTask, TaskDetail } from "@fusion/core";
import { DocumentsView } from "../DocumentsView";
import { fetchTaskDetail, fetchWorkspaceFileContent } from "../../api";
import { useDocuments } from "../../hooks/useDocuments";
import { useProjectMarkdownFiles } from "../../hooks/useProjectMarkdownFiles";
vi.mock("../../api", () => ({
fetchProjectMarkdownFiles: vi.fn(),
fetchAllDocuments: vi.fn(),
fetchWorkspaceFileContent: vi.fn(),
fetchTaskDetail: vi.fn(),
}));
vi.mock("../../hooks/useDocuments", () => ({
useDocuments: vi.fn(),
}));
vi.mock("../../hooks/useProjectMarkdownFiles", () => ({
useProjectMarkdownFiles: vi.fn(),
}));
const mockUseDocuments = vi.mocked(useDocuments);
const mockUseProjectMarkdownFiles = vi.mocked(useProjectMarkdownFiles);
const mockFetchWorkspaceFileContent = vi.mocked(fetchWorkspaceFileContent);
const mockFetchTaskDetail = vi.mocked(fetchTaskDetail);
const mockTaskDocuments: TaskDocumentWithTask[] = [
{
id: "doc-1",
taskId: "KB-001",
key: "plan",
content: "Alpha document content",
revision: 1,
author: "agent",
createdAt: "2026-04-19T10:00:00.000Z",
updatedAt: "2026-04-19T12:00:00.000Z",
taskTitle: "Alpha task",
taskColumn: "in-progress",
},
{
id: "doc-2",
taskId: "KB-002",
key: "notes",
content: "Beta document content",
revision: 2,
author: "agent",
createdAt: "2026-04-19T09:00:00.000Z",
updatedAt: "2026-04-19T11:00:00.000Z",
taskTitle: "Beta task",
taskColumn: "todo",
},
];
const mockProjectFiles = [
{
path: "README.md",
name: "README.md",
size: 1024,
mtime: "2026-04-19T12:00:00.000Z",
},
{
path: "docs/guide.md",
name: "guide.md",
size: 2048,
mtime: "2026-04-19T11:00:00.000Z",
},
];
function setupHookDefaults(): void {
mockUseDocuments.mockReturnValue({
documents: mockTaskDocuments,
projectFiles: [],
loading: false,
error: null,
refresh: vi.fn().mockResolvedValue(undefined),
});
mockUseProjectMarkdownFiles.mockReturnValue({
files: mockProjectFiles,
loading: false,
error: null,
refresh: vi.fn().mockResolvedValue(undefined),
});
}
describe("DocumentsView", () => {
const addToast = vi.fn();
const onOpenDetail = vi.fn();
const originalInnerWidth = window.innerWidth;
beforeEach(() => {
vi.clearAllMocks();
window.innerWidth = 1200;
setupHookDefaults();
mockFetchWorkspaceFileContent.mockResolvedValue({
content: "# README\nHello docs",
mtime: "2026-04-19T12:00:00.000Z",
size: 18,
});
mockFetchTaskDetail.mockResolvedValue({ id: "KB-001" } as TaskDetail);
});
afterEach(() => {
window.innerWidth = originalInnerWidth;
});
it("renders project files tab with markdown file list", () => {
render();
expect(screen.getByRole("tab", { name: /show project markdown files/i })).toHaveAttribute("aria-selected", "true");
expect(screen.getByRole("button", { name: "Open README.md" })).toBeInTheDocument();
expect(screen.getByRole("button", { name: "Open docs/guide.md" })).toBeInTheDocument();
});
it("renders task documents tab when there are no project files", async () => {
mockUseProjectMarkdownFiles.mockReturnValue({
files: [],
loading: false,
error: null,
refresh: vi.fn().mockResolvedValue(undefined),
});
render();
await waitFor(() => {
expect(screen.getByRole("tab", { name: /show task documents/i })).toHaveAttribute("aria-selected", "true");
});
expect(screen.getByText("KB-001")).toBeInTheDocument();
expect(screen.getByText("KB-002")).toBeInTheDocument();
});
it("tab switching works", () => {
render();
fireEvent.click(screen.getByRole("tab", { name: /show task documents/i }));
expect(screen.getByRole("tab", { name: /show task documents/i })).toHaveAttribute("aria-selected", "true");
expect(screen.getByText("KB-001")).toBeInTheDocument();
expect(screen.queryByRole("button", { name: "Open README.md" })).not.toBeInTheDocument();
});
it("clicking project file shows content", async () => {
render();
fireEvent.click(screen.getByRole("button", { name: "Open README.md" }));
await waitFor(() => {
expect(mockFetchWorkspaceFileContent).toHaveBeenCalledWith("project", "README.md", undefined);
});
expect(await screen.findByText(/Hello docs/)).toBeInTheDocument();
});
it("search filters task documents", async () => {
mockUseProjectMarkdownFiles.mockReturnValue({
files: [],
loading: false,
error: null,
refresh: vi.fn().mockResolvedValue(undefined),
});
mockUseDocuments.mockImplementation((options) => {
const query = options?.searchQuery?.toLowerCase();
const documents = query
? mockTaskDocuments.filter((doc) =>
doc.taskTitle?.toLowerCase().includes(query) ||
doc.taskId.toLowerCase().includes(query))
: mockTaskDocuments;
return {
documents,
projectFiles: [],
loading: false,
error: null,
refresh: vi.fn().mockResolvedValue(undefined),
};
});
render();
await waitFor(() => {
expect(screen.getByRole("tab", { name: /show task documents/i })).toHaveAttribute("aria-selected", "true");
});
fireEvent.change(screen.getByRole("textbox", { name: /search task documents/i }), {
target: { value: "alpha" },
});
await waitFor(() => {
expect(screen.getByText("KB-001")).toBeInTheDocument();
expect(screen.queryByText("KB-002")).not.toBeInTheDocument();
});
});
it("shows empty and error states", async () => {
const projectRefresh = vi.fn().mockResolvedValue(undefined);
mockUseDocuments.mockReturnValue({
documents: [],
projectFiles: [],
loading: false,
error: null,
refresh: vi.fn().mockResolvedValue(undefined),
});
mockUseProjectMarkdownFiles.mockReturnValue({
files: [],
loading: false,
error: "boom",
refresh: projectRefresh,
});
render();
expect(screen.getByText(/failed to load project files/i)).toBeInTheDocument();
fireEvent.click(screen.getByRole("button", { name: /retry loading documents/i }));
await waitFor(() => {
expect(projectRefresh).toHaveBeenCalledTimes(1);
});
});
it("shows loading states", async () => {
mockUseDocuments.mockReturnValue({
documents: [],
projectFiles: [],
loading: true,
error: null,
refresh: vi.fn().mockResolvedValue(undefined),
});
mockUseProjectMarkdownFiles.mockReturnValue({
files: [],
loading: true,
error: null,
refresh: vi.fn().mockResolvedValue(undefined),
});
render();
expect(screen.getByText("Loading project markdown files…")).toBeInTheDocument();
fireEvent.click(screen.getByRole("tab", { name: /show task documents/i }));
expect(screen.getByText("Loading task documents…")).toBeInTheDocument();
});
it("supports mobile list/detail navigation for project files", async () => {
window.innerWidth = 600;
render();
fireEvent.click(screen.getByRole("button", { name: "Open README.md" }));
expect(await screen.findByRole("button", { name: /back to project files list/i })).toBeInTheDocument();
expect(screen.queryByRole("button", { name: "Open README.md" })).not.toBeInTheDocument();
fireEvent.click(screen.getByRole("button", { name: /back to project files list/i }));
expect(screen.getByRole("button", { name: "Open README.md" })).toBeInTheDocument();
});
it("opens task details from task document groups", async () => {
mockUseProjectMarkdownFiles.mockReturnValue({
files: [],
loading: false,
error: null,
refresh: vi.fn().mockResolvedValue(undefined),
});
render();
await waitFor(() => {
expect(screen.getByRole("tab", { name: /show task documents/i })).toHaveAttribute("aria-selected", "true");
});
fireEvent.click(screen.getByRole("button", { name: /open task KB-001/i }));
await waitFor(() => {
expect(mockFetchTaskDetail).toHaveBeenCalledWith("KB-001", undefined);
expect(onOpenDetail).toHaveBeenCalledWith({ id: "KB-001" });
});
});
it("shows file content error when loading fails", async () => {
mockFetchWorkspaceFileContent.mockRejectedValue(new Error("cannot read file"));
render();
fireEvent.click(screen.getByRole("button", { name: "Open README.md" }));
expect(await screen.findByText("cannot read file")).toBeInTheDocument();
expect(addToast).toHaveBeenCalledWith("cannot read file", "error");
});
});