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",
},
];
const mockHiddenProjectFile = {
path: ".hidden/notes.md",
name: "notes.md",
size: 512,
mtime: "2026-04-19T10: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("keeps hidden project files off by default and reveals them when toggled on", async () => {
const refreshMock = vi.fn().mockResolvedValue(undefined);
mockUseProjectMarkdownFiles.mockImplementation((_, options) => ({
files: options?.showHidden
? [...mockProjectFiles, mockHiddenProjectFile]
: mockProjectFiles,
loading: false,
error: null,
refresh: refreshMock,
}));
render();
expect(screen.queryByRole("button", { name: "Open .hidden/notes.md" })).not.toBeInTheDocument();
expect(screen.getByRole("button", { name: /show hidden project files/i })).toHaveAttribute("aria-pressed", "false");
fireEvent.click(screen.getByRole("button", { name: /show hidden project files/i }));
await waitFor(() => {
expect(screen.getByRole("button", { name: "Open .hidden/notes.md" })).toBeInTheDocument();
expect(screen.getByRole("button", { name: /hide hidden project files/i })).toHaveAttribute("aria-pressed", "true");
});
});
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");
});
it("project file preview defaults to raw text mode", async () => {
mockFetchWorkspaceFileContent.mockResolvedValue({
content: "# Hello\n\nThis is **bold**",
mtime: "2026-04-19T12:00:00.000Z",
size: 28,
});
render();
fireEvent.click(screen.getByRole("button", { name: "Open README.md" }));
await waitFor(() => {
expect(mockFetchWorkspaceFileContent).toHaveBeenCalled();
});
// Should show raw text by default
expect(screen.getByText(/# Hello/)).toBeInTheDocument();
expect(screen.getByText(/\*\*bold\*\*/)).toBeInTheDocument();
});
it("project file preview can toggle to markdown mode", async () => {
mockFetchWorkspaceFileContent.mockResolvedValue({
content: "# Hello\n\nThis is **bold**",
mtime: "2026-04-19T12:00:00.000Z",
size: 28,
});
render();
fireEvent.click(screen.getByRole("button", { name: "Open README.md" }));
await waitFor(() => {
expect(mockFetchWorkspaceFileContent).toHaveBeenCalled();
});
// Toggle button should exist with raw mode
const toggleBtn = screen.getByRole("button", { name: /switch to markdown/i });
expect(toggleBtn).toHaveAttribute("aria-pressed", "false");
// Click to toggle
fireEvent.click(toggleBtn);
// Should now be in markdown mode
expect(screen.getByRole("button", { name: /switch to plain text/i })).toHaveAttribute("aria-pressed", "true");
// Bold text should be rendered as
const strongEl = await screen.findByText("bold");
expect(strongEl.tagName).toBe("STRONG");
});
it("project file markdown toggle state is independent from task document toggles", async () => {
// Set up project files with markdown content
mockFetchWorkspaceFileContent.mockResolvedValue({
content: "# Project README",
mtime: "2026-04-19T12:00:00.000Z",
size: 18,
});
render();
// Toggle project file to markdown mode
fireEvent.click(screen.getByRole("button", { name: "Open README.md" }));
await waitFor(() => {
expect(mockFetchWorkspaceFileContent).toHaveBeenCalled();
});
const projectToggle = screen.getByRole("button", { name: /switch to markdown/i });
fireEvent.click(projectToggle);
// Switch to tasks tab
fireEvent.click(screen.getByRole("tab", { name: /show task documents/i }));
await waitFor(() => {
expect(screen.getByText("KB-001")).toBeInTheDocument();
});
// Expand a task group
fireEvent.click(screen.getByRole("button", { name: /expand documents for task KB-001/i }));
// Expand the document card
const expandBtn = screen.getByRole("button", { name: /expand content/i });
fireEvent.click(expandBtn);
// Task document toggle should default to raw (not influenced by project toggle)
const taskToggle = screen.getByRole("button", { name: /switch to markdown/i });
expect(taskToggle).toHaveAttribute("aria-pressed", "false");
// Toggle task document
fireEvent.click(taskToggle);
expect(screen.getByRole("button", { name: /switch to plain text/i })).toHaveAttribute("aria-pressed", "true");
// Switch back to project - project toggle should still be on
fireEvent.click(screen.getByRole("tab", { name: /show project markdown files/i }));
expect(screen.getByRole("button", { name: /switch to plain text/i })).toHaveAttribute("aria-pressed", "true");
});
it("task document cards support markdown toggle when expanded", 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");
});
// Expand task group
fireEvent.click(screen.getByRole("button", { name: /expand documents for task KB-001/i }));
// Expand the document card
const expandBtn = screen.getByRole("button", { name: /expand content/i });
fireEvent.click(expandBtn);
// Should show raw text by default
expect(screen.getByText("Alpha document content")).toBeInTheDocument();
// Toggle should exist
const toggleBtn = screen.getByRole("button", { name: /switch to markdown/i });
expect(toggleBtn).toHaveAttribute("aria-pressed", "false");
// Click to toggle to markdown mode
fireEvent.click(toggleBtn);
expect(screen.getByRole("button", { name: /switch to plain text/i })).toHaveAttribute("aria-pressed", "true");
});
});