// @vitest-environment jsdom /** * Tests for CompressionPreviewAccordion (F7). * * Coverage targets: * - smoke render (component mounts without errors) * - lazy-render guard (D7): children only mount after accordion opens * - mode select changes (off / lite / standard / aggressive / ultra) * - Preview button dispatches POST /api/compression/preview with { messages, mode } * - result grid (4 cards) renders on success * - error path is sanitized (no stack-trace leak — "at /" not in DOM) */ import React from "react"; import { act } from "react"; import { createRoot } from "react-dom/client"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; // --------------------------------------------------------------------------- // Module mocks // --------------------------------------------------------------------------- vi.mock("next-intl", () => ({ useTranslations: () => (key: string) => key, })); // Stub shared components vi.mock("@/shared/components", () => ({ Button: ({ children, onClick, disabled, loading: _loading, icon: _icon, className: _className, }: { children: React.ReactNode; onClick?: () => void; disabled?: boolean; loading?: boolean; icon?: string; className?: string; }) => ( ), Select: ({ value, onChange, options, className: _className, "aria-label": ariaLabel, }: { value: string; onChange: (e: React.ChangeEvent) => void; options: ReadonlyArray<{ value: string; label: string }>; className?: string; "aria-label"?: string; }) => ( ), })); // Stub cn utility vi.mock("@/shared/utils/cn", () => ({ cn: (...classes: (string | undefined | false)[]) => classes.filter(Boolean).join(" "), })); // --------------------------------------------------------------------------- // Helpers // --------------------------------------------------------------------------- const cleanupCallbacks: Array<() => void> = []; function makeContainer(): HTMLElement { const container = document.createElement("div"); document.body.appendChild(container); cleanupCallbacks.push(() => container.remove()); return container; } async function renderComponent( props: { forceOpen?: boolean; inputContent?: string; onOpenChange?: (open: boolean) => void; } = {}, ) { const { default: CompressionPreviewAccordion } = await import( "@/app/(dashboard)/dashboard/translator/components/advanced/CompressionPreviewAccordion" ); const container = makeContainer(); const root = createRoot(container); await act(async () => { root.render(); }); return { container, root }; } /** Click the accordion toggle button to open/close it. */ async function clickToggle(container: HTMLElement) { const btn = container.querySelector( "button[aria-expanded]", ) as HTMLButtonElement | null; expect(btn).toBeTruthy(); await act(async () => { btn?.click(); }); } // --------------------------------------------------------------------------- // Setup / teardown // --------------------------------------------------------------------------- beforeEach(() => { ( globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean } ).IS_REACT_ACT_ENVIRONMENT = true; vi.clearAllMocks(); }); afterEach(() => { while (cleanupCallbacks.length > 0) cleanupCallbacks.pop()?.(); document.body.innerHTML = ""; }); // --------------------------------------------------------------------------- // Tests // --------------------------------------------------------------------------- describe("CompressionPreviewAccordion — export", () => { it("exports a default function component", async () => { const mod = await import( "@/app/(dashboard)/dashboard/translator/components/advanced/CompressionPreviewAccordion" ); expect(typeof mod.default).toBe("function"); }); }); describe("CompressionPreviewAccordion — smoke render", () => { it("renders without crashing (closed by default)", async () => { const { container } = await renderComponent(); expect(container.querySelector("[data-testid='compression-accordion']")).toBeTruthy(); }); it("renders the toggle button with compress icon and i18n title", async () => { const { container } = await renderComponent(); // compress icon in header const icons = container.querySelectorAll(".material-symbols-outlined"); const iconTexts = Array.from(icons).map((el) => el.textContent?.trim()); expect(iconTexts).toContain("compress"); // title text (mock returns the key) const text = container.textContent ?? ""; expect(text).toContain("advancedCompressionTitle"); }); it("renders the subtitle", async () => { const { container } = await renderComponent(); const text = container.textContent ?? ""; expect(text).toContain("advancedCompressionSubtitle"); }); it("toggle button starts closed (aria-expanded=false)", async () => { const { container } = await renderComponent(); const btn = container.querySelector("button[aria-expanded]"); expect(btn?.getAttribute("aria-expanded")).toBe("false"); }); it("toggle button starts open when forceOpen=true (aria-expanded=true)", async () => { const { container } = await renderComponent({ forceOpen: true }); const btn = container.querySelector("button[aria-expanded]"); expect(btn?.getAttribute("aria-expanded")).toBe("true"); }); }); describe("CompressionPreviewAccordion — lazy-render guard (D7)", () => { it("does NOT mount content when closed (forceOpen=false)", async () => { const { container } = await renderComponent({ forceOpen: false }); // Content region should not exist expect(container.querySelector("#compression-preview-content")).toBeNull(); // Mode select should not be in DOM expect(container.querySelector("[data-testid='select']")).toBeNull(); }); it("mounts content after opening accordion", async () => { const { container } = await renderComponent({ forceOpen: false }); // Initially closed expect(container.querySelector("[data-testid='select']")).toBeNull(); // Open await clickToggle(container); // Content should now be mounted expect(container.querySelector("#compression-preview-content")).toBeTruthy(); expect(container.querySelector("[data-testid='select']")).toBeTruthy(); }); it("mounts content immediately when forceOpen=true", async () => { const { container } = await renderComponent({ forceOpen: true }); expect(container.querySelector("#compression-preview-content")).toBeTruthy(); expect(container.querySelector("[data-testid='select']")).toBeTruthy(); }); it("toggle opens accordion (aria-expanded flips to true)", async () => { const { container } = await renderComponent(); const btn = container.querySelector("button[aria-expanded]"); expect(btn?.getAttribute("aria-expanded")).toBe("false"); await clickToggle(container); expect(btn?.getAttribute("aria-expanded")).toBe("true"); }); it("toggle closes accordion again (aria-expanded flips back to false)", async () => { const { container } = await renderComponent({ forceOpen: true }); const btn = container.querySelector("button[aria-expanded]"); expect(btn?.getAttribute("aria-expanded")).toBe("true"); await clickToggle(container); expect(btn?.getAttribute("aria-expanded")).toBe("false"); }); it("calls onOpenChange with true when opening", async () => { const onOpenChange = vi.fn(); const { container } = await renderComponent({ forceOpen: false, onOpenChange }); await clickToggle(container); expect(onOpenChange).toHaveBeenCalledWith(true); }); it("calls onOpenChange with false when closing", async () => { const onOpenChange = vi.fn(); const { container } = await renderComponent({ forceOpen: true, onOpenChange }); await clickToggle(container); expect(onOpenChange).toHaveBeenCalledWith(false); }); }); describe("CompressionPreviewAccordion — empty state", () => { it("shows empty-state hint when inputContent is empty string", async () => { const { container } = await renderComponent({ forceOpen: true, inputContent: "" }); const text = container.textContent ?? ""; expect(text).toContain("compressionEmptyHint"); }); it("shows empty-state hint when inputContent is absent", async () => { const { container } = await renderComponent({ forceOpen: true }); const text = container.textContent ?? ""; expect(text).toContain("compressionEmptyHint"); }); it("Preview button is disabled when inputContent is empty", async () => { const { container } = await renderComponent({ forceOpen: true, inputContent: "" }); const btn = container.querySelector("[data-testid='button']") as HTMLButtonElement | null; expect(btn?.disabled).toBe(true); }); it("shows preview button enabled when inputContent is non-empty", async () => { const { container } = await renderComponent({ forceOpen: true, inputContent: JSON.stringify({ messages: [{ role: "user", content: "Hello" }] }), }); const btn = container.querySelector("[data-testid='button']") as HTMLButtonElement | null; expect(btn).toBeTruthy(); expect(btn?.disabled).toBe(false); }); }); describe("CompressionPreviewAccordion — mode select", () => { const MODES = ["off", "lite", "standard", "aggressive", "ultra"] as const; it("renders all 5 mode options", async () => { const { container } = await renderComponent({ forceOpen: true, inputContent: "hello" }); const select = container.querySelector("[data-testid='select']") as HTMLSelectElement | null; expect(select).toBeTruthy(); const options = Array.from(select?.options ?? []).map((o) => o.value); for (const mode of MODES) { expect(options).toContain(mode); } }); it("default mode is 'standard'", async () => { const { container } = await renderComponent({ forceOpen: true, inputContent: "hello" }); const select = container.querySelector("[data-testid='select']") as HTMLSelectElement | null; expect(select?.value).toBe("standard"); }); for (const mode of MODES) { it(`changing mode to '${mode}' updates select value`, async () => { const { container } = await renderComponent({ forceOpen: true, inputContent: "hello" }); const select = container.querySelector("[data-testid='select']") as HTMLSelectElement | null; expect(select).toBeTruthy(); await act(async () => { // Set the value and fire change event select!.value = mode; select!.dispatchEvent(new Event("change", { bubbles: true })); }); expect(select?.value).toBe(mode); }); } }); describe("CompressionPreviewAccordion — Preview fetch", () => { it("calls POST /api/compression/preview with { messages, mode } on button click", async () => { const mockResult = { originalTokens: 100, compressedTokens: 80, tokensSaved: 20, savingsPct: 20, techniquesUsed: ["dedup", "trim"], durationMs: 42, }; const fetchMock = vi.fn().mockResolvedValue({ ok: true, json: async () => mockResult, }); vi.stubGlobal("fetch", fetchMock); const inputContent = JSON.stringify({ messages: [{ role: "user", content: "Hello world" }], }); const { container } = await renderComponent({ forceOpen: true, inputContent }); const btn = container.querySelector("[data-testid='button']") as HTMLButtonElement | null; expect(btn).toBeTruthy(); await act(async () => { btn?.click(); }); expect(fetchMock).toHaveBeenCalledTimes(1); expect(fetchMock).toHaveBeenCalledWith( "/api/compression/preview", expect.objectContaining({ method: "POST", headers: expect.objectContaining({ "Content-Type": "application/json" }), }), ); // Verify body has correct shape const callArgs = fetchMock.mock.calls[0] as [string, RequestInit]; const body = JSON.parse(callArgs[1].body as string) as { messages: Array<{ role: string; content: string }>; mode: string; }; expect(body).toMatchObject({ messages: [{ role: "user", content: "Hello world" }], mode: "standard", }); vi.unstubAllGlobals(); }); it("wraps plain-text inputContent as { role: 'user', content } when not valid JSON", async () => { const fetchMock = vi.fn().mockResolvedValue({ ok: true, json: async () => ({ originalTokens: 10, compressedTokens: 8, tokensSaved: 2, savingsPct: 20, techniquesUsed: [], durationMs: 5, }), }); vi.stubGlobal("fetch", fetchMock); const { container } = await renderComponent({ forceOpen: true, inputContent: "plain text" }); const btn = container.querySelector("[data-testid='button']") as HTMLButtonElement | null; await act(async () => { btn?.click(); }); const callArgs = fetchMock.mock.calls[0] as [string, RequestInit]; const body = JSON.parse(callArgs[1].body as string) as { messages: Array<{ role: string; content: string }>; }; expect(body.messages).toEqual([{ role: "user", content: "plain text" }]); vi.unstubAllGlobals(); }); it("sends selected mode in the request body", async () => { const fetchMock = vi.fn().mockResolvedValue({ ok: true, json: async () => ({ originalTokens: 50, compressedTokens: 40, tokensSaved: 10, savingsPct: 20, techniquesUsed: [], durationMs: 20, }), }); vi.stubGlobal("fetch", fetchMock); const { container } = await renderComponent({ forceOpen: true, inputContent: "some text" }); // Change mode to "aggressive" const select = container.querySelector("[data-testid='select']") as HTMLSelectElement | null; await act(async () => { select!.value = "aggressive"; select!.dispatchEvent(new Event("change", { bubbles: true })); }); // Click preview const btn = container.querySelector("[data-testid='button']") as HTMLButtonElement | null; await act(async () => { btn?.click(); }); const callArgs = fetchMock.mock.calls[0] as [string, RequestInit]; const body = JSON.parse(callArgs[1].body as string) as { mode: string }; expect(body.mode).toBe("aggressive"); vi.unstubAllGlobals(); }); }); describe("CompressionPreviewAccordion — result grid (4 cards)", () => { it("renders 4 metric cards after successful preview", async () => { const mockResult = { originalTokens: 200, compressedTokens: 150, tokensSaved: 50, savingsPct: 25, techniquesUsed: ["dedup"], durationMs: 88, }; const fetchMock = vi.fn().mockResolvedValue({ ok: true, json: async () => mockResult, }); vi.stubGlobal("fetch", fetchMock); const { container } = await renderComponent({ forceOpen: true, inputContent: "some input", }); const btn = container.querySelector("[data-testid='button']") as HTMLButtonElement | null; await act(async () => { btn?.click(); }); const grid = container.querySelector("[data-testid='compression-result-grid']"); expect(grid).toBeTruthy(); const cards = grid?.querySelectorAll(".card"); expect(cards?.length).toBe(4); const text = container.textContent ?? ""; expect(text).toContain("200"); // originalTokens expect(text).toContain("150"); // compressedTokens expect(text).toContain("50"); // tokensSaved expect(text).toContain("88"); // durationMs vi.unstubAllGlobals(); }); it("renders techniquesUsed list when non-empty", async () => { const mockResult = { originalTokens: 100, compressedTokens: 90, tokensSaved: 10, savingsPct: 10, techniquesUsed: ["dedup", "trim", "compact"], durationMs: 33, }; const fetchMock = vi.fn().mockResolvedValue({ ok: true, json: async () => mockResult, }); vi.stubGlobal("fetch", fetchMock); const { container } = await renderComponent({ forceOpen: true, inputContent: "some input", }); await act(async () => { (container.querySelector("[data-testid='button']") as HTMLButtonElement)?.click(); }); const text = container.textContent ?? ""; expect(text).toContain("dedup"); expect(text).toContain("trim"); expect(text).toContain("compact"); vi.unstubAllGlobals(); }); it("does NOT render result grid before a successful preview", async () => { const { container } = await renderComponent({ forceOpen: true, inputContent: "hello" }); expect(container.querySelector("[data-testid='compression-result-grid']")).toBeNull(); }); }); describe("CompressionPreviewAccordion — error path (Hard Rule #12)", () => { it("shows sanitized error message on fetch failure", async () => { const fetchMock = vi.fn().mockResolvedValue({ ok: false, json: async () => ({ error: "Internal Server Error" }), }); vi.stubGlobal("fetch", fetchMock); const { container } = await renderComponent({ forceOpen: true, inputContent: "some input", }); await act(async () => { (container.querySelector("[data-testid='button']") as HTMLButtonElement)?.click(); }); const errorEl = container.querySelector("[role='alert']"); expect(errorEl).toBeTruthy(); expect(errorEl?.textContent).toContain("Internal Server Error"); vi.unstubAllGlobals(); }); it("error message does NOT contain stack-trace lines (at /path/...)", async () => { const stackError = new Error( "Something went wrong\n at /home/user/app/src/file.ts:42:13\n at Object. /home/user/app/tests/test.ts:10:5", ); const fetchMock = vi.fn().mockRejectedValue(stackError); vi.stubGlobal("fetch", fetchMock); const { container } = await renderComponent({ forceOpen: true, inputContent: "some input", }); await act(async () => { (container.querySelector("[data-testid='button']") as HTMLButtonElement)?.click(); }); const errorEl = container.querySelector("[role='alert']"); expect(errorEl).toBeTruthy(); const errorText = errorEl?.textContent ?? ""; // Must NOT contain "at /" (stack-trace pattern) expect(errorText).not.toMatch(/\sat\s\//); // Should still contain the core message expect(errorText).toContain("Something went wrong"); vi.unstubAllGlobals(); }); it("shows 'Preview failed' when fetch returns non-ok without error field", async () => { const fetchMock = vi.fn().mockResolvedValue({ ok: false, json: async () => ({}), // no error field }); vi.stubGlobal("fetch", fetchMock); const { container } = await renderComponent({ forceOpen: true, inputContent: "some input", }); await act(async () => { (container.querySelector("[data-testid='button']") as HTMLButtonElement)?.click(); }); const errorEl = container.querySelector("[role='alert']"); expect(errorEl).toBeTruthy(); expect(errorEl?.textContent).toContain("Preview failed"); vi.unstubAllGlobals(); }); });