// @vitest-environment jsdom import React from "react"; import { act } from "react"; import { createRoot } from "react-dom/client"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import type { AdvancedSlug } from "@/app/(dashboard)/dashboard/translator/types"; // --- Mock next-intl --- vi.mock("next-intl", () => ({ useTranslations: () => (key: string) => key, })); // --- Mock next/navigation (used by deep-link hook, not by TranslateTab directly) --- vi.mock("next/navigation", () => ({ useRouter: () => ({ replace: vi.fn() }), useSearchParams: () => new URLSearchParams(), })); // --- Mock shared components --- vi.mock("@/shared/components", () => ({ Card: ({ children, className }: { children: React.ReactNode; className?: string }) => (
{children}
), Button: ({ children, onClick, disabled, loading, "aria-label": ariaLabel }: { children?: React.ReactNode; onClick?: () => void; disabled?: boolean; loading?: boolean; "aria-label"?: string; }) => ( ), Select: ({ options = [], value, onChange, placeholder, "aria-label": ariaLabel }: { options?: Array<{ value: string; label: string }>; value?: string; onChange?: (e: React.ChangeEvent) => void; placeholder?: string; "aria-label"?: string; }) => ( ), SegmentedControl: ({ options = [], value, onChange, "aria-label": ariaLabel }: { options?: Array<{ value: string; label: string }>; value?: string; onChange?: (v: string) => void; "aria-label"?: string; }) => (
{options.map((o) => ( ))}
), InfoTooltip: ({ text }: { text: string }) => i, Badge: ({ children, variant }: { children: React.ReactNode; variant?: string }) => ( {children} ), })); // --- Mock useProviderOptions --- vi.mock( "@/app/(dashboard)/dashboard/translator/hooks/useProviderOptions", () => ({ useProviderOptions: () => ({ provider: "openai", setProvider: vi.fn(), providerOptions: [ { value: "openai", label: "OpenAI" }, { value: "anthropic", label: "Anthropic" }, ], loading: false, }), }) ); // --- Mock useAvailableModels --- vi.mock( "@/app/(dashboard)/dashboard/translator/hooks/useAvailableModels", () => ({ useAvailableModels: () => ({ model: "gpt-4o", setModel: vi.fn(), availableModels: ["gpt-4o"], loading: false, pickModelForFormat: () => "gpt-4o", }), }) ); // --- Mock useTranslateSession --- vi.mock( "@/app/(dashboard)/dashboard/translator/hooks/useTranslateSession", () => ({ useTranslateSession: () => ({ result: { detected: null, target: "openai", status: "idle", responsePreview: null, translatedJson: null, pipelinePath: null, intermediateJson: null, errorMessage: null, latencyMs: null, }, run: vi.fn(), reset: vi.fn(), }), }) ); // --- Mock exampleTemplates --- vi.mock( "@/app/(dashboard)/dashboard/translator/exampleTemplates", () => ({ FORMAT_OPTIONS: [ { value: "openai", label: "OpenAI" }, { value: "claude", label: "Claude" }, ], FORMAT_META: { openai: { label: "OpenAI", color: "emerald", icon: "smart_toy" }, claude: { label: "Claude", color: "orange", icon: "psychology" }, }, getExampleTemplates: () => [ { id: "simple-chat", name: "Simple Chat", icon: "chat", description: "Chat example", formats: { openai: { model: "gpt-4o", messages: [] } }, }, ], }) ); // --- Setup --- const cleanupCallbacks: Array<() => void> = []; function makeContainer(): HTMLElement { const container = document.createElement("div"); document.body.appendChild(container); cleanupCallbacks.push(() => container.remove()); return container; } describe("TranslateTab", () => { beforeEach(() => { (globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; }); afterEach(() => { while (cleanupCallbacks.length > 0) cleanupCallbacks.pop()?.(); document.body.innerHTML = ""; vi.clearAllMocks(); }); it("exports a default function component", async () => { const mod = await import( "@/app/(dashboard)/dashboard/translator/components/TranslateTab" ); expect(typeof mod.default).toBe("function"); }); it("renders smoke without throwing", async () => { const { default: TranslateTab } = await import( "@/app/(dashboard)/dashboard/translator/components/TranslateTab" ); const container = makeContainer(); const root = createRoot(container); await act(async () => { root.render(); }); expect(container.innerHTML).not.toBe(""); }); it("renders 2-column grid on desktop (has grid class)", async () => { const { default: TranslateTab } = await import( "@/app/(dashboard)/dashboard/translator/components/TranslateTab" ); const container = makeContainer(); const root = createRoot(container); await act(async () => { root.render(); }); // The grid div should exist with lg:grid-cols-2 class const gridEl = container.querySelector(".grid"); expect(gridEl).toBeTruthy(); expect(gridEl?.className).toContain("lg:grid-cols-2"); }); it("does not expose data-advanced-section placeholder div (GAP-5)", async () => { const { default: TranslateTab } = await import( "@/app/(dashboard)/dashboard/translator/components/TranslateTab" ); const container = makeContainer(); const root = createRoot(container); await act(async () => { root.render(); }); // GAP-5: the data-advanced-section DOM data-leak placeholder must not exist expect(container.querySelector("[data-advanced-section]")).toBeNull(); }); it("calls onAdvancedSlugChange with 'rawjson' when the Advanced button is clicked", async () => { const { default: TranslateTab } = await import( "@/app/(dashboard)/dashboard/translator/components/TranslateTab" ); const container = makeContainer(); const root = createRoot(container); const onAdvancedSlugChange = vi.fn(); await act(async () => { root.render(); }); // Find the Advanced button by aria-label. // SimpleControls uses tr("simpleAdvancedToggle", "Advanced"); with the i18n mock // returning the key, tr() detects key===translated and returns the FALLBACK "Advanced". const advancedBtn = container.querySelector( "button[aria-label='Advanced']" ) as HTMLButtonElement | null; expect(advancedBtn).toBeTruthy(); await act(async () => { advancedBtn?.click(); }); expect(onAdvancedSlugChange).toHaveBeenCalledWith("rawjson"); }); it("renders without onAdvancedSlugChange prop (optional)", async () => { const { default: TranslateTab } = await import( "@/app/(dashboard)/dashboard/translator/components/TranslateTab" ); const container = makeContainer(); const root = createRoot(container); // Should not throw await act(async () => { root.render(); }); expect(container.innerHTML).not.toBe(""); }); it("renders both SimpleControls and ResultNarrated panels (2 Card children in grid)", async () => { const { default: TranslateTab } = await import( "@/app/(dashboard)/dashboard/translator/components/TranslateTab" ); const container = makeContainer(); const root = createRoot(container); await act(async () => { root.render(); }); // Grid should contain 2 direct Card children const grid = container.querySelector(".grid"); const cards = grid?.querySelectorAll("[data-testid='card']"); expect(cards?.length).toBeGreaterThanOrEqual(2); }); it("calls onInputChange callback when inputText changes via SimpleControls (GAP-NOVO-2)", async () => { const { default: TranslateTab } = await import( "@/app/(dashboard)/dashboard/translator/components/TranslateTab" ); const container = makeContainer(); const root = createRoot(container); const onInputChange = vi.fn(); await act(async () => { root.render(); }); // Find the textarea/input used by SimpleControls for inputText const textarea = container.querySelector("textarea") as HTMLTextAreaElement | null; if (textarea) { await act(async () => { // Simulate change event const nativeInputValueSetter = Object.getOwnPropertyDescriptor( HTMLTextAreaElement.prototype, "value" )?.set; nativeInputValueSetter?.call(textarea, "hello world"); textarea.dispatchEvent(new Event("input", { bubbles: true })); textarea.dispatchEvent(new Event("change", { bubbles: true })); }); // If callback was invoked, it should have been called with the new value if (onInputChange.mock.calls.length > 0) { expect(onInputChange).toHaveBeenCalledWith(expect.any(String)); } // At minimum, onInputChange should be wired as optional prop without throwing } // The component must render without throwing when onInputChange is provided expect(container.innerHTML).not.toBe(""); }); });