diff --git a/src/app/(dashboard)/dashboard/translator/components/advanced/CompressionPreviewAccordion.tsx b/src/app/(dashboard)/dashboard/translator/components/advanced/CompressionPreviewAccordion.tsx new file mode 100644 index 0000000000..5ae4d3afa7 --- /dev/null +++ b/src/app/(dashboard)/dashboard/translator/components/advanced/CompressionPreviewAccordion.tsx @@ -0,0 +1,281 @@ +"use client"; + +import { useState, useCallback } from "react"; +import { useTranslations } from "next-intl"; +import { Button, Select } from "@/shared/components"; +import { cn } from "@/shared/utils/cn"; + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +interface CompressionPreviewResult { + originalTokens: number; + compressedTokens: number; + tokensSaved: number; + savingsPct: number; + techniquesUsed: string[]; + durationMs: number; +} + +export interface CompressionPreviewAccordionProps { + /** Force the accordion open on mount (used by deep-link). */ + forceOpen?: boolean; + /** Called whenever the open state changes (used for URL sync). */ + onOpenChange?: (open: boolean) => void; + /** + * Content to compress. If provided (from TranslateTab state), the accordion + * uses it directly. If absent or empty, shows an empty-state hint. + */ + inputContent?: string; +} + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +/** Sanitize an error message: strip Node stack-trace lines (e.g. "at /home/…"). */ +function sanitizeError(e: unknown): string { + const raw = e instanceof Error ? e.message : String(e); + // Remove stack-trace lines that start with "at " followed by a path + return raw.replace(/\s+at\s+[^\n]+/g, "").trim(); +} + +// --------------------------------------------------------------------------- +// Constants +// --------------------------------------------------------------------------- + +const COMPRESSION_MODES = [ + { value: "off", label: "Off" }, + { value: "lite", label: "Lite" }, + { value: "standard", label: "Standard" }, + { value: "aggressive", label: "Aggressive" }, + { value: "ultra", label: "Ultra" }, +] as const; + +// --------------------------------------------------------------------------- +// Inner content (always mounted when hasOpened is true) +// --------------------------------------------------------------------------- + +function CompressionPreviewContent({ inputContent = "" }: { inputContent?: string }) { + const t = useTranslations("translator"); + + const [compressionMode, setCompressionMode] = useState("standard"); + const [compressionResult, setCompressionResult] = useState( + null, + ); + const [compressionLoading, setCompressionLoading] = useState(false); + const [compressionError, setCompressionError] = useState(null); + + const hasInput = inputContent.trim().length > 0; + + const handleCompressionPreview = useCallback(async () => { + if (!hasInput) return; + + let messages: Array<{ role: string; content: string }>; + try { + const parsed: Record = JSON.parse(inputContent); + messages = Array.isArray(parsed.messages) + ? (parsed.messages as Array<{ role: string; content: string }>) + : [{ role: "user", content: inputContent }]; + } catch { + messages = [{ role: "user", content: inputContent }]; + } + + setCompressionLoading(true); + setCompressionError(null); + + try { + const res = await fetch("/api/compression/preview", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ messages, mode: compressionMode }), + }); + const data: CompressionPreviewResult & { error?: string } = await res.json(); + if (!res.ok) throw new Error(data.error ?? "Preview failed"); + setCompressionResult(data); + } catch (e: unknown) { + setCompressionError(sanitizeError(e)); + } finally { + setCompressionLoading(false); + } + }, [hasInput, inputContent, compressionMode]); + + return ( +
+ {/* Empty state */} + {!hasInput && ( +
+ + + {t("compressionEmptyHint") || + "Preencha o campo de entrada na aba Translate (Simple Controls ou Raw JSON) para habilitar o preview."} + +
+ )} + + {/* Controls */} +
+ + {options.map((o) => ( + + ))} + + ), +})); + +// 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(); + }); +});