Merge pull request #230 from amal66/olp-pr/frontend-unit-tests
Some checks failed
CI / Backend build and tests (push) Has been cancelled
CI / Frontend build and tests (push) Has been cancelled
CI / Eval harness (push) Has been cancelled

[Testing 07] test: component and hook unit tests for the web app
This commit is contained in:
Will Chen 2026-07-22 16:59:39 +08:00 committed by GitHub
commit 2ec89a7c52
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
10 changed files with 2374 additions and 3 deletions

File diff suppressed because it is too large Load diff

View file

@ -62,15 +62,20 @@
},
"devDependencies": {
"@tailwindcss/postcss": "^4",
"@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.3.2",
"@testing-library/user-event": "^14.6.1",
"@types/marked": "^5.0.2",
"@types/node": "^20",
"@types/react": "^19",
"@types/react-dom": "^19",
"@types/uuid": "^10.0.0",
"@vitejs/plugin-react": "^4.7.0",
"babel-plugin-react-compiler": "1.0.0",
"baseline-browser-mapping": "^2.9.11",
"eslint": "^9",
"eslint-config-next": "^16.2.6",
"jsdom": "^27.0.1",
"tailwindcss": "^4",
"tw-animate-css": "^1.4.0",
"typescript": "^5",

View file

@ -0,0 +1,47 @@
import { describe, expect, it } from "vitest";
import { act, renderHook } from "@testing-library/react";
import { useSmoothedReveal } from "./useSmoothedReveal";
const FULL = "**Demo mode** — no AI provider key is configured.";
describe("useSmoothedReveal", () => {
it("snaps to the full text when the stream ends mid-reveal", async () => {
// Stream a long body in one chunk while active: the rAF pacer will only
// have revealed a prefix by the time the stream ends.
const { result, rerender } = renderHook(
({ text, active }) => useSmoothedReveal(text, active),
{ initialProps: { text: "", active: true } },
);
rerender({ text: FULL, active: true });
expect(result.current.length).toBeLessThan(FULL.length);
// Stream ends. The hook must snap to the full text — it drives the
// rendered slice off `revealedInt`, so updating only the internal ref
// would leave the reply frozen at a partial prefix (e.g. "**Demo mo").
await act(async () => {
rerender({ text: FULL, active: false });
});
expect(result.current).toBe(FULL);
});
it("returns the full text immediately for a replayed (non-streaming) message", () => {
const { result } = renderHook(() => useSmoothedReveal(FULL, false));
expect(result.current).toBe(FULL);
});
it("never returns more than the text it was given", async () => {
const { result, rerender } = renderHook(
({ text, active }) => useSmoothedReveal(text, active),
{ initialProps: { text: FULL, active: false } },
);
// Text replaced by something shorter (edited / retried turn).
await act(async () => {
rerender({ text: "short", active: false });
});
expect(result.current).toBe("short");
});
});

View file

@ -0,0 +1,92 @@
import { render } from "@testing-library/react";
import { describe, expect, it } from "vitest";
import { FileTypeIcon, fileTypeKind } from "./FileTypeIcon";
describe("fileTypeKind", () => {
it("maps bare file_type values to a kind", () => {
expect(fileTypeKind("pdf")).toBe("pdf");
expect(fileTypeKind("docx")).toBe("word");
expect(fileTypeKind("doc")).toBe("word");
expect(fileTypeKind("xlsx")).toBe("excel");
expect(fileTypeKind("xlsm")).toBe("excel");
expect(fileTypeKind("xls")).toBe("excel");
expect(fileTypeKind("pptx")).toBe("ppt");
expect(fileTypeKind("ppt")).toBe("ppt");
});
it("maps filenames by their extension", () => {
expect(fileTypeKind("report.pdf")).toBe("pdf");
expect(fileTypeKind("Quarterly Deck.PPTX")).toBe("ppt");
expect(fileTypeKind("model.final.xlsx")).toBe("excel");
});
it("is case-insensitive and trims whitespace", () => {
expect(fileTypeKind(" PDF ")).toBe("pdf");
expect(fileTypeKind("DOCX")).toBe("word");
});
it("falls back to other for unknown, empty, or nullish input", () => {
expect(fileTypeKind("txt")).toBe("other");
expect(fileTypeKind("")).toBe("other");
expect(fileTypeKind(null)).toBe("other");
expect(fileTypeKind(undefined)).toBe("other");
});
});
describe("FileTypeIcon", () => {
const svgOf = (container: HTMLElement) => container.querySelector("svg");
const imgOf = (container: HTMLElement) => container.querySelector("img");
it("renders the PDF icon image", () => {
const { container } = render(<FileTypeIcon fileType="pdf" />);
expect(imgOf(container)).toHaveAttribute(
"src",
expect.stringContaining("/icons/file-types/pdf.svg"),
);
});
it("renders the Word icon image", () => {
const { container } = render(<FileTypeIcon fileType="deck.docx" />);
expect(imgOf(container)).toHaveAttribute(
"src",
expect.stringContaining("/icons/file-types/word.svg"),
);
});
it("renders the Excel icon image", () => {
const { container } = render(<FileTypeIcon fileType="xlsx" />);
expect(imgOf(container)).toHaveAttribute(
"src",
expect.stringContaining("/icons/file-types/excel.svg"),
);
});
it("renders a grey icon for unknown types", () => {
const { container } = render(<FileTypeIcon fileType={null} />);
expect(svgOf(container)).toHaveClass("text-gray-500");
});
it("renders a muted grayscale image for a known kind", () => {
const { container } = render(<FileTypeIcon fileType="pdf" muted />);
const img = imgOf(container);
expect(img).toHaveClass("grayscale");
expect(img).toHaveClass("opacity-35");
});
it("renders a muted grey placeholder for unknown types", () => {
const { container } = render(<FileTypeIcon fileType={null} muted />);
const svg = svgOf(container);
expect(svg).toHaveClass("text-gray-300");
});
it("always applies shrink-0 and merges a custom className", () => {
const { container } = render(
<FileTypeIcon fileType="pdf" className="h-6 w-6" />,
);
const img = imgOf(container);
expect(img).toHaveClass("shrink-0");
expect(img).toHaveClass("h-6");
expect(img).toHaveClass("w-6");
});
});

View file

@ -0,0 +1,39 @@
import { render, screen } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import { TRTable } from "./TRTable";
import type { Document } from "../shared/types";
const doc = { id: "doc-1", filename: "report.pdf" } as Document;
function renderTable() {
return render(
<TRTable
loading={false}
columns={[]}
documents={[doc]}
cells={[]}
savingColumn={false}
savingColumnsConfig={false}
selectedDocIds={[]}
onSelectionChange={vi.fn()}
onExpand={vi.fn()}
onCitationClick={vi.fn()}
onUpdateColumn={vi.fn()}
onDeleteColumn={vi.fn()}
onAddColumn={vi.fn()}
onAddDocuments={vi.fn()}
/>,
);
}
describe("TRTable", () => {
// The grid here is div-based (no table/columnheader/rowheader roles), so
// this asserts on rendered content rather than ARIA table semantics.
it("renders the Document header and a row for each document", () => {
renderTable();
expect(screen.getByText("Document")).toBeInTheDocument();
expect(screen.getByText("report.pdf")).toBeInTheDocument();
// One select-all checkbox in the header plus one per document row.
expect(screen.getAllByRole("checkbox")).toHaveLength(2);
});
});

View file

@ -0,0 +1,44 @@
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { describe, expect, it, vi } from "vitest";
import { Button } from "./button";
describe("Button", () => {
it("renders its children", () => {
render(<Button>Click me</Button>);
expect(
screen.getByRole("button", { name: "Click me" }),
).toBeInTheDocument();
});
it("fires onClick when activated", async () => {
const onClick = vi.fn();
const user = userEvent.setup();
render(<Button onClick={onClick}>Submit</Button>);
await user.click(screen.getByRole("button", { name: "Submit" }));
expect(onClick).toHaveBeenCalledTimes(1);
});
it("applies the variant class for destructive buttons", () => {
render(<Button variant="destructive">Delete</Button>);
expect(screen.getByRole("button", { name: "Delete" })).toHaveClass(
"bg-destructive",
);
});
it("does not fire onClick while disabled", async () => {
const onClick = vi.fn();
const user = userEvent.setup();
render(
<Button disabled onClick={onClick}>
Disabled
</Button>,
);
await user.click(screen.getByRole("button", { name: "Disabled" }));
expect(onClick).not.toHaveBeenCalled();
});
});

View file

@ -0,0 +1,40 @@
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { afterEach, describe, expect, it, vi } from "vitest";
import { CiteButton } from "./cite-button";
describe("CiteButton", () => {
afterEach(() => {
vi.restoreAllMocks();
});
it("renders the default 'Cite' label", () => {
render(<CiteButton quoteText="hello" citationText="Doe 2020" />);
expect(
screen.getByRole("button", { name: /cite/i }),
).toBeInTheDocument();
});
it("hides the label when showText is false", () => {
render(
<CiteButton
quoteText="hello"
citationText="Doe 2020"
showText={false}
/>,
);
expect(screen.queryByText("Cite")).not.toBeInTheDocument();
});
it("copies the quote and citation, then shows 'Copied'", async () => {
// userEvent.setup() installs a clipboard stub on navigator; spy on it.
const user = userEvent.setup();
const writeText = vi.spyOn(navigator.clipboard, "writeText");
render(<CiteButton quoteText={`he said "hi"`} citationText="Doe 2020" />);
await user.click(screen.getByRole("button"));
expect(writeText).toHaveBeenCalledWith(`"he said 'hi'" Doe 2020`);
expect(await screen.findByText("Copied")).toBeInTheDocument();
});
});

View file

@ -0,0 +1,91 @@
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { describe, expect, it, vi } from "vitest";
import { PillButton } from "./pill-button";
describe("PillButton", () => {
it("renders its children as a button by default", () => {
render(<PillButton tone="black">Save</PillButton>);
expect(
screen.getByRole("button", { name: "Save" }),
).toBeInTheDocument();
});
it("defaults to type=button", () => {
render(<PillButton tone="black">Save</PillButton>);
expect(screen.getByRole("button", { name: "Save" })).toHaveAttribute(
"type",
"button",
);
});
it("applies the tone class", () => {
render(<PillButton tone="danger">Delete</PillButton>);
expect(screen.getByRole("button", { name: "Delete" })).toHaveClass(
"bg-red-600/90",
);
});
it("applies the normal size class when requested", () => {
render(
<PillButton tone="blue" size="normal">
Continue
</PillButton>,
);
expect(screen.getByRole("button", { name: "Continue" })).toHaveClass(
"text-sm",
);
});
it("defaults to the sm size class", () => {
render(<PillButton tone="blue">Next</PillButton>);
expect(screen.getByRole("button", { name: "Next" })).toHaveClass(
"text-xs",
);
});
it("fires onClick when activated", async () => {
const onClick = vi.fn();
const user = userEvent.setup();
render(
<PillButton tone="white" onClick={onClick}>
Click me
</PillButton>,
);
await user.click(screen.getByRole("button", { name: "Click me" }));
expect(onClick).toHaveBeenCalledTimes(1);
});
it("does not fire onClick while disabled", async () => {
const onClick = vi.fn();
const user = userEvent.setup();
render(
<PillButton tone="black" disabled onClick={onClick}>
Disabled
</PillButton>,
);
await user.click(screen.getByRole("button", { name: "Disabled" }));
expect(onClick).not.toHaveBeenCalled();
});
it("renders as its child element via asChild", () => {
render(
<PillButton tone="blue" asChild>
<a href="/docs">Docs</a>
</PillButton>,
);
const link = screen.getByRole("link", { name: "Docs" });
expect(link).toBeInTheDocument();
expect(link).toHaveAttribute("href", "/docs");
// asChild drops the intrinsic button type onto the child.
expect(link).not.toHaveAttribute("type");
// Pill styling still lands on the rendered child.
expect(link).toHaveClass("rounded-full");
});
});

View file

@ -0,0 +1,45 @@
import { fileURLToPath } from "node:url";
import react from "@vitejs/plugin-react";
import { defineConfig } from "vitest/config";
const resolvePath = (relative: string) =>
fileURLToPath(new URL(relative, import.meta.url));
export default defineConfig({
plugins: [react()],
resolve: {
// Mirror the `@/*` path alias from tsconfig.json so unit tests resolve
// the same module specifiers the app uses.
alias: [
{
find: /^@\/(.*)$/,
replacement: resolvePath("./src/$1"),
},
],
},
test: {
globals: true,
environment: "jsdom",
setupFiles: ["./vitest.setup.ts"],
// app/lib/supabase.ts creates its client at module load, so any
// component whose import graph reaches it needs these set. Dummy
// values — unit tests never talk to Supabase.
env: {
NEXT_PUBLIC_SUPABASE_URL: "http://localhost:54321",
NEXT_PUBLIC_SUPABASE_PUBLISHABLE_DEFAULT_KEY: "test-anon-key",
},
// jsdom 27's CSS-color parser (@asamuzakjp/css-color) is CJS but
// require()s the ESM-only @csstools/css-calc. That require() happens
// in the worker process while the jsdom environment boots — before
// Vite's transform pipeline is involved — so deps.inline can't fix it.
// Instead, let Node itself handle require(esm): default on >=22.12,
// and enabled by this (there harmless) flag on 22.022.11.
execArgv: ["--experimental-require-module"],
// Unit tests only. Keep any Playwright e2e specs (*.spec.ts) out.
include: ["src/**/*.test.{ts,tsx}"],
exclude: ["node_modules/**", "e2e/**", "**/*.spec.ts"],
// Generous timeouts to absorb cold-start jsdom + transform latency on CI.
testTimeout: 20000,
hookTimeout: 20000,
},
});

1
frontend/vitest.setup.ts Normal file
View file

@ -0,0 +1 @@
import "@testing-library/jest-dom/vitest";