项目文件夹

文件
2026-07-13 13:39:12 +08:00

252 行
8.8 KiB
TypeScript

// @vitest-environment jsdom
/**
* Tests for BatchConceptCard and FilesConceptCard (F3 UI atoms).
*
* Covers:
* - Mount with i18n mock → renders expected keys
* - Collapse/expand toggle via button click
* - localStorage hydration (collapsed state restored)
* - FilesConceptCard: type pills rendered (input/output/error)
* - Sanitization: no stack/path in rendered output (these are static components, so trivially OK)
*/
import React, { act } from "react";
import { createRoot } from "react-dom/client";
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
// ── Mocks ─────────────────────────────────────────────────────────────────────
vi.mock("next-intl", () => ({
useTranslations: () => (key: string) => key,
}));
// ── Import components after mocks ─────────────────────────────────────────────
const { default: BatchConceptCard } = await import(
"../../../../src/app/(dashboard)/dashboard/batch/components/BatchConceptCard"
);
const { default: FilesConceptCard } = await import(
"../../../../src/app/(dashboard)/dashboard/batch/components/FilesConceptCard"
);
// ── Helpers ───────────────────────────────────────────────────────────────────
const containers: Array<{ root: ReturnType<typeof createRoot>; el: HTMLDivElement }> = [];
function renderBatchCard(props: { className?: string } = {}) {
const el = document.createElement("div");
document.body.appendChild(el);
const root = createRoot(el);
act(() => {
root.render(<BatchConceptCard {...props} />);
});
containers.push({ root, el });
return el;
}
function renderFilesCard(props: { className?: string } = {}) {
const el = document.createElement("div");
document.body.appendChild(el);
const root = createRoot(el);
act(() => {
root.render(<FilesConceptCard {...props} />);
});
containers.push({ root, el });
return el;
}
// ── Lifecycle ─────────────────────────────────────────────────────────────────
beforeEach(() => {
localStorage.clear();
vi.clearAllMocks();
});
afterEach(() => {
for (const { root, el } of containers.splice(0)) {
act(() => root.unmount());
el.remove();
}
});
// ── BatchConceptCard tests ────────────────────────────────────────────────────
describe("BatchConceptCard", () => {
it("renders title and subtitle keys", () => {
const el = renderBatchCard();
expect(el.textContent).toContain("batchConceptTitle");
expect(el.textContent).toContain("batchConceptSubtitle");
});
it("renders how-it-works button", () => {
const el = renderBatchCard();
expect(el.textContent).toContain("batchConceptHowItWorks");
});
it("renders expanded content by default (benefit/async/useCases keys visible)", () => {
const el = renderBatchCard();
// Default state is expanded (collapsed=false)
expect(el.textContent).toContain("batchConceptBenefit50pct");
expect(el.textContent).toContain("batchConceptAsync24h");
expect(el.textContent).toContain("batchConceptUseCases");
});
it("collapses when toggle button is clicked", async () => {
const el = renderBatchCard();
const toggleBtn = el.querySelector("button[aria-expanded='true']");
expect(toggleBtn).not.toBeNull();
await act(async () => {
toggleBtn!.click();
});
// After collapse: benefit key should not be in the DOM
expect(el.textContent).not.toContain("batchConceptBenefit50pct");
// aria-expanded should now be false
const toggleBtnAfter = el.querySelector("button[aria-expanded='false']");
expect(toggleBtnAfter).not.toBeNull();
});
it("stores collapsed state in localStorage on toggle", async () => {
const el = renderBatchCard();
const toggleBtn = el.querySelector("button[aria-expanded='true']");
await act(async () => {
toggleBtn!.click();
});
expect(localStorage.getItem("omniroute:concept-batch-collapsed")).toBe("true");
});
it("expands again when toggled twice", async () => {
const el = renderBatchCard();
const toggleBtn = el.querySelector("button");
await act(async () => {
toggleBtn!.click(); // collapse
});
await act(async () => {
const btn = el.querySelector("button");
btn!.click(); // expand again
});
expect(el.textContent).toContain("batchConceptBenefit50pct");
expect(localStorage.getItem("omniroute:concept-batch-collapsed")).toBe("false");
});
it("hydrates collapsed state from localStorage (shows collapsed on mount)", async () => {
// Pre-set collapsed=true in localStorage
localStorage.setItem("omniroute:concept-batch-collapsed", "true");
const el = document.createElement("div");
document.body.appendChild(el);
const root = createRoot(el);
await act(async () => {
root.render(<BatchConceptCard />);
});
containers.push({ root, el });
// After hydration, localStorage says collapsed=true — content should not be visible
// Note: hydration runs in useEffect (async), so we wait a tick
await act(async () => {
await new Promise((r) => setTimeout(r, 50));
});
expect(el.textContent).not.toContain("batchConceptBenefit50pct");
});
it("accepts optional className prop without crashing", () => {
const el = renderBatchCard({ className: "custom-class" });
const card = el.firstElementChild as HTMLElement;
expect(card?.className).toContain("custom-class");
});
it("sanitization: no stack trace or file path in rendered output", () => {
const el = renderBatchCard();
// Static component — should never contain paths/stacks
const text = el.textContent ?? "";
expect(text).not.toMatch(/\/home\//);
expect(text).not.toMatch(/at \//);
expect(text).not.toMatch(/route\.ts/);
});
});
// ── FilesConceptCard tests ────────────────────────────────────────────────────
describe("FilesConceptCard", () => {
it("renders title and subtitle keys", () => {
const el = renderFilesCard();
expect(el.textContent).toContain("filesConceptTitle");
expect(el.textContent).toContain("filesConceptSubtitle");
});
it("renders all three file type pills (input/output/error)", () => {
const el = renderFilesCard();
expect(el.textContent).toContain("filesConceptInput");
expect(el.textContent).toContain("filesConceptOutput");
expect(el.textContent).toContain("filesConceptError");
});
it("renders expanded bullet points by default", () => {
const el = renderFilesCard();
// Expanded = expanded content with filesConceptRetention visible
expect(el.textContent).toContain("filesConceptRetention");
});
it("collapses when toggle button is clicked", async () => {
const el = renderFilesCard();
const toggleBtn = el.querySelector("button[aria-expanded='true']");
expect(toggleBtn).not.toBeNull();
await act(async () => {
toggleBtn!.click();
});
// After collapse: retention key should not be in the dom
expect(el.textContent).not.toContain("filesConceptRetention");
expect(el.querySelector("button[aria-expanded='false']")).not.toBeNull();
});
it("persists collapsed state in localStorage", async () => {
const el = renderFilesCard();
const toggleBtn = el.querySelector("button[aria-expanded='true']");
await act(async () => {
toggleBtn!.click();
});
expect(localStorage.getItem("omniroute:concept-files-collapsed")).toBe("true");
});
it("hydrates from localStorage — collapsed=true persists across remounts", async () => {
localStorage.setItem("omniroute:concept-files-collapsed", "true");
const el = document.createElement("div");
document.body.appendChild(el);
const root = createRoot(el);
await act(async () => {
root.render(<FilesConceptCard />);
});
containers.push({ root, el });
await act(async () => {
await new Promise((r) => setTimeout(r, 50));
});
// Should not show retention text when collapsed
expect(el.textContent).not.toContain("filesConceptRetention");
});
it("sanitization: no stack trace in rendered output", () => {
const el = renderFilesCard();
const text = el.textContent ?? "";
expect(text).not.toMatch(/\/home\//);
expect(text).not.toMatch(/at \//);
expect(text).not.toMatch(/route\.ts/);
});
});