diff --git a/src/app/(dashboard)/dashboard/translator/components/advanced/AdvancedSection.tsx b/src/app/(dashboard)/dashboard/translator/components/advanced/AdvancedSection.tsx new file mode 100644 index 0000000000..86da88edfe --- /dev/null +++ b/src/app/(dashboard)/dashboard/translator/components/advanced/AdvancedSection.tsx @@ -0,0 +1,80 @@ +"use client"; + +import { type ReactNode } from "react"; +import { useTranslations } from "next-intl"; +import { Card } from "@/shared/components"; +import type { AdvancedSlug } from "../../types"; + +export interface AdvancedSectionProps { + /** Slug to force-open on initial mount (deep-link from URL). */ + forceOpenSlug?: AdvancedSlug | null; + /** Callback when a sub-accordion opens or closes (F9 syncs with URL). */ + onSlugChange?: (slug: AdvancedSlug | null) => void; + /** F9 passes the 5 accordions as children, each with a slug prop. */ + children?: ReactNode; +} + +/** + * Container for the 5 Advanced accordions. + * Does NOT implement lazy-render itself — each accordion (RawJsonPanel, + * PipelineView, StreamTransformerAccordion, TestBenchAccordion, + * CompressionPreviewAccordion) controls its own mount guard (D7). + * + * forceOpenSlug is forwarded as data-slug on the wrapper div so each + * accordion child can read it via props passed down by F9's TranslateTab. + */ +export default function AdvancedSection({ + forceOpenSlug, + onSlugChange: _onSlugChange, + children, +}: AdvancedSectionProps) { + const t = useTranslations("translator"); + + /** Safe i18n with inline fallback — pattern from TranslatorPageClient. */ + const tr = (key: string, fallback: string): string => { + try { + const v = t(key as Parameters[0]); + // When next-intl returns the key itself (missing key), use fallback. + if (v === key || v === `translator.${key}`) return fallback; + return v as string; + } catch { + return fallback; + } + }; + + return ( + +
+ {/* Header */} +
+ +
+

+ {tr("advancedSectionTitle", "Advanced")} +

+

+ {tr( + "advancedSectionSubtitle", + "Raw JSON, pipeline e ferramentas técnicas. Tudo aqui é igual às tabs antigas — apenas reorganizado.", + )} +

+
+
+ + {/* Accordion slots — children provided by F9 (TranslateTab) */} +
+ {children} +
+
+
+ ); +} diff --git a/src/app/(dashboard)/dashboard/translator/components/advanced/PipelineView.tsx b/src/app/(dashboard)/dashboard/translator/components/advanced/PipelineView.tsx new file mode 100644 index 0000000000..39cea52fee --- /dev/null +++ b/src/app/(dashboard)/dashboard/translator/components/advanced/PipelineView.tsx @@ -0,0 +1,293 @@ +"use client"; + +import { useState, useEffect, useCallback } from "react"; +import { useTranslations } from "next-intl"; +import { Card, Badge } from "@/shared/components"; +import Collapsible from "@/shared/components/Collapsible"; +import { FORMAT_META } from "../../exampleTemplates"; +import type { AdvancedAccordionProps, FormatId } from "../../types"; + +export interface PipelineStep { + id: string; + name: string; + description: string; + format: FormatId | "openai" | null; + content: string; + status: "pending" | "active" | "done" | "error"; +} + +/** Props specific to PipelineView (extends shared accordion props). */ +export interface PipelineViewProps extends Omit { + slug?: AdvancedAccordionProps["slug"]; + /** Live pipeline steps injected by F9; when undefined, renders demo state. */ + pipelineSteps?: PipelineStep[]; +} + +/** Default demo steps shown when no real pipeline is running. */ +const DEMO_STEPS: PipelineStep[] = [ + { + id: "1", + name: "Client Request", + description: "Request received in client format", + format: "claude", + content: '{\n "model": "claude-sonnet-4-20250514",\n "messages": [\n { "role": "user", "content": "Hello!" }\n ]\n}', + status: "done", + }, + { + id: "2", + name: "Format Detected", + description: "Auto-detected source format", + format: "claude", + content: '{\n "detectedFormat": "claude",\n "confidence": "high"\n}', + status: "done", + }, + { + id: "3", + name: "OpenAI Intermediate", + description: "Translated to OpenAI hub format", + format: "openai", + content: '{\n "model": "claude-sonnet-4-20250514",\n "messages": [\n { "role": "user", "content": "Hello!" }\n ],\n "stream": true\n}', + status: "pending", + }, + { + id: "4", + name: "Provider Format", + description: "Translated to provider target format", + format: "gemini", + content: '{\n "model": "gemini-2.5-flash",\n "contents": [\n { "role": "user", "parts": [{ "text": "Hello!" }] }\n ]\n}', + status: "pending", + }, + { + id: "5", + name: "Provider Response", + description: "Streaming response from provider", + format: "openai", + content: "data: {\"choices\":[{\"delta\":{\"content\":\"Hello! How can I help you today?\"}}]}\ndata: [DONE]", + status: "pending", + }, +]; + +/** Maps step status to badge variant. */ +function statusVariant( + status: PipelineStep["status"], +): "default" | "primary" | "success" | "error" | "warning" | "info" { + switch (status) { + case "active": + return "primary"; + case "done": + return "success"; + case "error": + return "error"; + default: + return "default"; + } +} + +/** Maps step status to color for the step number circle. */ +function statusNumberClass(status: PipelineStep["status"]): string { + switch (status) { + case "active": + return "bg-primary/10 text-primary"; + case "done": + return "bg-emerald-500/10 text-emerald-500"; + case "error": + return "bg-red-500/10 text-red-500"; + default: + return "bg-bg-subtle text-text-muted"; + } +} + +/** + * PipelineView — Advanced accordion for hub-and-spoke pipeline visualization. + * + * Refactors the pipeline visualization portion of ChatTesterMode.tsx (steps + + * status badges + expandable content). When `pipelineSteps` is not provided, + * renders a static demo so the accordion is never empty. + * + * D7 lazy-render: step cards are NOT mounted until the first open. + */ +export default function PipelineView({ + forceOpen = false, + onOpenChange, + defaultOpen = false, + pipelineSteps, +}: PipelineViewProps) { + const t = useTranslations("translator"); + + /** D7 lazy-render guard: true only after the first open (or when forceOpen/defaultOpen). */ + const [hasOpened, setHasOpened] = useState(Boolean(defaultOpen) || Boolean(forceOpen)); + const [open, setOpen] = useState(Boolean(defaultOpen) || Boolean(forceOpen)); + const [expandedStepId, setExpandedStepId] = useState(null); + + // Notify parent on mount when forceOpen=true (deep-link sync). + useEffect(() => { + if (forceOpen) { + onOpenChange?.(true); + } + // Only run on mount — forceOpen is treated as an initial deep-link signal. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + // Sync forceOpen changes from parent after mount. + useEffect(() => { + if (forceOpen && !open) { + setOpen(true); + setHasOpened(true); + } + }, [forceOpen, open]); + + const handleOpenChange = useCallback( + (next: boolean) => { + setOpen(next); + if (next) setHasOpened(true); + onOpenChange?.(next); + }, + [onOpenChange], + ); + + const steps = pipelineSteps ?? DEMO_STEPS; + + const tr = (key: string, fallback: string): string => { + try { + const v = t(key as Parameters[0]); + if (v === key || v === `translator.${key}`) return fallback; + return v as string; + } catch { + return fallback; + } + }; + + return ( + + {/* D7 lazy-render container */} +
+ {hasOpened && ( + <> + {/* Demo badge when showing placeholder data */} + {!pipelineSteps && ( +
+ + + {tr( + "pipelineVisualizationHint", + "Envie um request pelo Chat Tester para ver o pipeline em tempo real. Abaixo: exemplo estático.", + )} + +
+ )} + + {/* Step list */} +
+ {steps.map((step, i) => { + const meta = (step.format && FORMAT_META[step.format]) ?? { + label: step.format ?? "unknown", + color: "gray", + icon: "code", + }; + const isExpanded = expandedStepId === step.id; + + return ( +
+ {/* Connector line between steps */} + {i > 0 && ( + + ); + })} +
+ + )} +
+ + ); +} diff --git a/src/app/(dashboard)/dashboard/translator/components/advanced/RawJsonPanel.tsx b/src/app/(dashboard)/dashboard/translator/components/advanced/RawJsonPanel.tsx new file mode 100644 index 0000000000..200670dd16 --- /dev/null +++ b/src/app/(dashboard)/dashboard/translator/components/advanced/RawJsonPanel.tsx @@ -0,0 +1,653 @@ +"use client"; + +import { useState, useCallback, useEffect, useMemo } from "react"; +import { useTranslations } from "next-intl"; +import { Card, Button, Select, Badge } from "@/shared/components"; +import Collapsible from "@/shared/components/Collapsible"; +import Editor from "@/shared/components/MonacoEditor"; +import { getExampleTemplates, FORMAT_META, FORMAT_OPTIONS } from "../../exampleTemplates"; +import type { AdvancedAccordionProps } from "../../types"; + +/** Props specific to RawJsonPanel (extends shared accordion props). */ +export interface RawJsonPanelProps extends Omit { + slug?: AdvancedAccordionProps["slug"]; +} + +/** + * RawJsonPanel — Advanced accordion wrapping the full Monaco-based JSON editor. + * + * Refactors PlaygroundMode.tsx lines 200-461 (split editor, format selects, + * swap button, translate, 8 templates, intermediate panel) MINUS the + * Compression Preview block (lines 506-584, which lives in F7). + * + * D7 lazy-render: the Monaco editors are NOT mounted until the first time the + * Collapsible opens. Once opened, `hasOpened` stays true so editors remain + * mounted through subsequent open/close cycles (preserving editor state). + */ +export default function RawJsonPanel({ + forceOpen = false, + onOpenChange, + defaultOpen = false, +}: RawJsonPanelProps) { + const t = useTranslations("translator"); + const tc = useTranslations("common"); + + /** D7 lazy-render guard. */ + const [hasOpened, setHasOpened] = useState(defaultOpen || forceOpen); + const [open, setOpen] = useState(defaultOpen || forceOpen); + + // Notify parent when starting open (initial mount with forceOpen or defaultOpen). + useEffect(() => { + if (defaultOpen || forceOpen) { + onOpenChange?.(true); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); // intentionally run only once on mount + + // Sync forceOpen changes from parent (deep-link after mount). + useEffect(() => { + if (forceOpen && !open) { + setOpen(true); + setHasOpened(true); + onOpenChange?.(true); + } + }, [forceOpen, open, onOpenChange]); + + const handleOpenChange = useCallback( + (next: boolean) => { + setOpen(next); + if (next) setHasOpened(true); + onOpenChange?.(next); + }, + [onOpenChange], + ); + + // ── Translator state (copied from PlaygroundMode.tsx) ────────────────────── + const [sourceFormat, setSourceFormat] = useState("claude"); + const [targetFormat, setTargetFormat] = useState("openai"); + const [inputContent, setInputContent] = useState(""); + const [outputContent, setOutputContent] = useState(""); + const [intermediateContent, setIntermediateContent] = useState(""); + const [translationPath, setTranslationPath] = useState(""); + const [detectedFormat, setDetectedFormat] = useState(null); + const [translating, setTranslating] = useState(false); + const [detecting, setDetecting] = useState(false); + const [activeTemplate, setActiveTemplate] = useState(null); + const [errorMessage, setErrorMessage] = useState(null); + + const templates = useMemo(() => getExampleTemplates(t), [t]); + + // ── Auto-detect (debounced, 600 ms) ─────────────────────────────────────── + const detectFormatFromInput = useCallback(async (content: string) => { + if (!content || content.trim().length < 5) { + setDetectedFormat(null); + return; + } + try { + const parsed = JSON.parse(content); + setDetecting(true); + const res = await fetch("/api/translator/detect", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ body: parsed }), + }); + const data: { success: boolean; format?: string } = await res.json(); + if (data.success && data.format) { + setDetectedFormat(data.format); + setSourceFormat(data.format); + } + } catch { + // Not valid JSON yet — ignore (no user-visible error). + } finally { + setDetecting(false); + } + }, []); + + useEffect(() => { + const timer = setTimeout(() => { + detectFormatFromInput(inputContent); + }, 600); + return () => clearTimeout(timer); + }, [inputContent, detectFormatFromInput]); + + // ── Translate handler ────────────────────────────────────────────────────── + const handleTranslate = async () => { + if (!inputContent.trim()) return; + + setTranslating(true); + setOutputContent(""); + setIntermediateContent(""); + setTranslationPath(""); + setErrorMessage(null); + + try { + const parsed: Record = JSON.parse(inputContent); + + if (sourceFormat === targetFormat) { + setOutputContent(JSON.stringify(parsed, null, 2)); + setTranslationPath("passthrough"); + setTranslating(false); + return; + } + + let intermediate: Record = parsed; + let hasIntermediate = false; + + if (sourceFormat !== "openai" && targetFormat !== "openai") { + const step1 = await fetch("/api/translator/translate", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + step: "direct", + sourceFormat, + targetFormat: "openai", + body: parsed, + }), + }); + const step1Data: { success: boolean; result?: Record; error?: string } = + await step1.json(); + if (!step1Data.success) { + setOutputContent(JSON.stringify({ error: step1Data.error }, null, 2)); + setTranslating(false); + return; + } + intermediate = step1Data.result ?? {}; + setIntermediateContent(JSON.stringify(intermediate, null, 2)); + hasIntermediate = true; + } + + const res = await fetch("/api/translator/translate", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + step: "direct", + sourceFormat: hasIntermediate ? "openai" : sourceFormat, + targetFormat, + body: hasIntermediate ? intermediate : parsed, + }), + }); + const data: { success: boolean; result?: Record; error?: string } = + await res.json(); + if (data.success) { + setOutputContent(JSON.stringify(data.result, null, 2)); + setTranslationPath(hasIntermediate ? "hub-and-spoke" : "direct"); + } else { + // Display a sanitized error — never expose raw stack traces (#12). + const sanitized = sanitizeError(data.error); + setOutputContent(JSON.stringify({ error: sanitized }, null, 2)); + setErrorMessage(sanitized); + } + } catch (err: unknown) { + const sanitized = sanitizeError(err instanceof Error ? err.message : String(err)); + setOutputContent(JSON.stringify({ error: sanitized }, null, 2)); + setErrorMessage(sanitized); + } finally { + setTranslating(false); + } + }; + + // ── Template loader ──────────────────────────────────────────────────────── + const loadTemplate = (template: { id: string; formats: Record }) => { + const formatData = + (template.formats[sourceFormat] as Record | undefined) ?? + (template.formats["openai"] as Record); + setInputContent(JSON.stringify(formatData, null, 2)); + setActiveTemplate(template.id); + setOutputContent(""); + setIntermediateContent(""); + setTranslationPath(""); + setErrorMessage(null); + }; + + // ── Copy helper ──────────────────────────────────────────────────────────── + const handleCopy = async (text: string) => { + try { + await navigator.clipboard.writeText(text); + } catch { + /* silent — clipboard API can fail in non-secure contexts */ + } + }; + + // ── Swap formats ─────────────────────────────────────────────────────────── + const handleSwapFormats = () => { + setSourceFormat(targetFormat); + setTargetFormat(sourceFormat); + setInputContent(outputContent); + setOutputContent(""); + setIntermediateContent(""); + setTranslationPath(""); + setDetectedFormat(null); + setErrorMessage(null); + }; + + // ── Format metadata ──────────────────────────────────────────────────────── + const srcMeta = FORMAT_META[sourceFormat] ?? FORMAT_META["openai"]; + const tgtMeta = FORMAT_META[targetFormat] ?? FORMAT_META["openai"]; + + // ── i18n safe getter ─────────────────────────────────────────────────────── + const tr = (key: string, fallback: string): string => { + try { + const v = t(key as Parameters[0]); + if (v === key || v === `translator.${key}`) return fallback; + return v as string; + } catch { + return fallback; + } + }; + + return ( + + {/* Internal open-state control — Collapsible owns its own open state, + but we mirror it here for the lazy-render guard and onOpenChange. */} +
{ + if (el) { + // Observe the Collapsible's internal open state by checking whether + // content is in the DOM. We use a one-time effect equivalent: + // `hasOpened` is set on first render of this div (open=true). + if (!hasOpened) { + setHasOpened(true); + handleOpenChange(true); + } + } + }} + className="space-y-5" + > + {/* Lazy-render guard: content only rendered once opened */} + {hasOpened && ( + <> + {/* Error banner */} + {errorMessage && ( +
+ + {errorMessage} +
+ )} + + {/* Format Controls Bar */} + +
+ {/* Source Format */} +
+ +
+ + ) => + setTargetFormat(e.target.value) + } + options={FORMAT_OPTIONS} + className="flex-1" + /> +
+
+ + {/* Translate Button */} +
+ +
+
+
+ + {/* Translation path indicator */} + {translationPath && ( +
+ + {translationPath === "hub-and-spoke" ? ( + + {tr("translationPathHubSpoke", "").replace("{source}", FORMAT_META[sourceFormat]?.label ?? sourceFormat).replace("{target}", FORMAT_META[targetFormat]?.label ?? targetFormat) || + `${FORMAT_META[sourceFormat]?.label ?? sourceFormat} → OpenAI → ${FORMAT_META[targetFormat]?.label ?? targetFormat}`} + + ) : translationPath === "direct" ? ( + + {tr("translationPathDirect", "").replace("{source}", FORMAT_META[sourceFormat]?.label ?? sourceFormat).replace("{target}", FORMAT_META[targetFormat]?.label ?? targetFormat) || + `${FORMAT_META[sourceFormat]?.label ?? sourceFormat} → ${FORMAT_META[targetFormat]?.label ?? targetFormat}`} + + ) : ( + {tr("translationPathPassthrough", "Passthrough (same format)")} + )} +
+ )} + + {/* Split Editor View */} +
+ {/* Input Panel */} + +
+
+
+ +

+ {tr("input", "Input")} +

+ {detectedFormat && ( + + {FORMAT_META[detectedFormat]?.label ?? detectedFormat} + + )} + {detecting && ( + + )} +
+
+ + +
+
+
+ setInputContent(value ?? "")} + theme="vs-dark" + options={{ + minimap: { enabled: false }, + fontSize: 12, + lineNumbers: "on", + scrollBeyondLastLine: false, + wordWrap: "on", + automaticLayout: true, + formatOnPaste: true, + }} + /> +
+
+
+ + {/* Intermediate Panel (hub-and-spoke only) */} + {intermediateContent && ( + +
+
+
+ +

+ {tr("openaiIntermediatePanel", "OpenAI Intermediate")} +

+ + Hub + +
+ +
+
+ +
+
+
+ )} + + {/* Output Panel */} + +
+
+
+ +

+ {tr("output", "Output")} +

+ {outputContent && ( + + {FORMAT_META[targetFormat]?.label ?? targetFormat} + + )} +
+ +
+
+ +
+
+
+
+ + {/* Example Templates Grid */} + +
+
+ +

+ {tr("exampleTemplates", "Example Templates")} +

+ + {tr("exampleTemplatesHint", "Load a sample request")} + +
+
+ {templates.map((template) => ( + + ))} +
+ {activeTemplate && ( +
+ + {tr("templateLoadHint", "Template loaded for format: {format}").replace( + "{format}", + FORMAT_META[sourceFormat]?.label ?? sourceFormat, + )} +
+ )} +
+
+ + )} +
+
+ ); +} + +/** Strip stack traces from error messages before displaying them (#12). */ +function sanitizeError(msg: string | undefined | null): string { + if (!msg) return "Translation failed"; + // Remove lines that look like stack frames: " at foo (/path/to/file:1:2)" + return msg + .split("\n") + .filter((line) => !/^\s+at\s+/.test(line)) + .join("\n") + .trim() + .slice(0, 500); +} diff --git a/tests/unit/translator-friendly-advanced-section.test.tsx b/tests/unit/translator-friendly-advanced-section.test.tsx new file mode 100644 index 0000000000..a7a9c210a0 --- /dev/null +++ b/tests/unit/translator-friendly-advanced-section.test.tsx @@ -0,0 +1,166 @@ +// @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"; + +// Minimal i18n stub — returns the key so tests can assert on fallback rendering +vi.mock("next-intl", () => ({ + useTranslations: () => (key: string) => key, +})); + +// Card stub +vi.mock("@/shared/components", () => ({ + Card: ({ + children, + className, + }: { + children: React.ReactNode; + className?: string; + }) => ( +
+ {children} +
+ ), +})); + +const cleanupCallbacks: Array<() => void> = []; + +function makeContainer(): HTMLElement { + const container = document.createElement("div"); + document.body.appendChild(container); + cleanupCallbacks.push(() => container.remove()); + return container; +} + +describe("AdvancedSection", () => { + 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 = ""; + }); + + it("exports a default function component", async () => { + const mod = await import( + "@/app/(dashboard)/dashboard/translator/components/advanced/AdvancedSection" + ); + expect(typeof mod.default).toBe("function"); + }); + + it("renders the card with header icon and title", async () => { + const { default: AdvancedSection } = await import( + "@/app/(dashboard)/dashboard/translator/components/advanced/AdvancedSection" + ); + const container = makeContainer(); + const root = createRoot(container); + await act(async () => { + root.render(); + }); + // Card should be in DOM + expect(container.querySelector("[data-testid='card']")).toBeTruthy(); + // Header icon + const icons = container.querySelectorAll(".material-symbols-outlined"); + const iconTexts = Array.from(icons).map((el) => el.textContent?.trim()); + expect(iconTexts).toContain("tune"); + // h3 heading present + const h3 = container.querySelector("h3"); + expect(h3).toBeTruthy(); + }); + + it("renders children passed to it", async () => { + const { default: AdvancedSection } = await import( + "@/app/(dashboard)/dashboard/translator/components/advanced/AdvancedSection" + ); + const container = makeContainer(); + const root = createRoot(container); + await act(async () => { + root.render( + +
child
+
, + ); + }); + expect(container.querySelector("[data-testid='child-accordion']")).toBeTruthy(); + expect(container.querySelector("[data-testid='child-accordion']")?.textContent).toBe("child"); + }); + + it("renders accordion container with data-slug attribute reflecting forceOpenSlug", async () => { + const { default: AdvancedSection } = await import( + "@/app/(dashboard)/dashboard/translator/components/advanced/AdvancedSection" + ); + const container = makeContainer(); + const root = createRoot(container); + await act(async () => { + root.render(); + }); + const wrapper = container.querySelector("[data-advanced-container='true']"); + expect(wrapper).toBeTruthy(); + expect(wrapper?.getAttribute("data-slug")).toBe("rawjson"); + }); + + it("renders data-slug=none when forceOpenSlug is null", async () => { + const { default: AdvancedSection } = await import( + "@/app/(dashboard)/dashboard/translator/components/advanced/AdvancedSection" + ); + const container = makeContainer(); + const root = createRoot(container); + await act(async () => { + root.render(); + }); + const wrapper = container.querySelector("[data-advanced-container='true']"); + expect(wrapper?.getAttribute("data-slug")).toBe("none"); + }); + + it("renders data-slug=none when forceOpenSlug is undefined", async () => { + const { default: AdvancedSection } = await import( + "@/app/(dashboard)/dashboard/translator/components/advanced/AdvancedSection" + ); + const container = makeContainer(); + const root = createRoot(container); + await act(async () => { + root.render(); + }); + const wrapper = container.querySelector("[data-advanced-container='true']"); + expect(wrapper?.getAttribute("data-slug")).toBe("none"); + }); + + it("renders subtitle text using i18n fallback", async () => { + const { default: AdvancedSection } = await import( + "@/app/(dashboard)/dashboard/translator/components/advanced/AdvancedSection" + ); + const container = makeContainer(); + const root = createRoot(container); + await act(async () => { + root.render(); + }); + const text = container.textContent ?? ""; + // Fallback subtitle text + expect(text).toContain("Raw JSON"); + expect(text).toContain("pipeline"); + }); + + it("renders multiple children", async () => { + const { default: AdvancedSection } = await import( + "@/app/(dashboard)/dashboard/translator/components/advanced/AdvancedSection" + ); + const container = makeContainer(); + const root = createRoot(container); + await act(async () => { + root.render( + +
RawJson
+
Pipeline
+
Stream
+
, + ); + }); + expect(container.querySelector("[data-testid='accordion-1']")).toBeTruthy(); + expect(container.querySelector("[data-testid='accordion-2']")).toBeTruthy(); + expect(container.querySelector("[data-testid='accordion-3']")).toBeTruthy(); + }); +}); diff --git a/tests/unit/translator-friendly-pipeline-view.test.tsx b/tests/unit/translator-friendly-pipeline-view.test.tsx new file mode 100644 index 0000000000..fe88082694 --- /dev/null +++ b/tests/unit/translator-friendly-pipeline-view.test.tsx @@ -0,0 +1,339 @@ +// @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 { PipelineStep } from "@/app/(dashboard)/dashboard/translator/components/advanced/PipelineView"; + +// Minimal i18n stub +vi.mock("next-intl", () => ({ + useTranslations: () => (key: string) => key, +})); + +// Collapsible stub — renders children directly (always open in tests) +vi.mock("@/shared/components/Collapsible", () => ({ + default: ({ + children, + title, + icon, + }: { + children: React.ReactNode; + title?: string; + icon?: string; + subtitle?: string; + defaultOpen?: boolean; + className?: string; + }) => ( +
+ {children} +
+ ), +})); + +// Shared component stubs +vi.mock("@/shared/components", () => ({ + Card: ({ + children, + className, + }: { + children: React.ReactNode; + className?: string; + }) => ( +
+ {children} +
+ ), + Badge: ({ + children, + variant, + }: { + children: React.ReactNode; + variant?: string; + size?: string; + }) => ( + + {children} + + ), +})); + +// exampleTemplates stub +vi.mock( + "@/app/(dashboard)/dashboard/translator/exampleTemplates", + () => ({ + FORMAT_META: { + openai: { label: "OpenAI", color: "blue", icon: "psychology" }, + claude: { label: "Claude", color: "amber", icon: "auto_awesome" }, + gemini: { label: "Gemini", color: "green", icon: "smart_toy" }, + }, + FORMAT_OPTIONS: [ + { value: "openai", label: "OpenAI" }, + { value: "claude", label: "Claude" }, + ], + getExampleTemplates: () => [], + }), +); + +const cleanupCallbacks: Array<() => void> = []; + +function makeContainer(): HTMLElement { + const container = document.createElement("div"); + document.body.appendChild(container); + cleanupCallbacks.push(() => container.remove()); + return container; +} + +const SAMPLE_STEPS: PipelineStep[] = [ + { + id: "1", + name: "Client Request", + description: "Request received", + format: "claude", + content: '{"model":"claude-sonnet-4-20250514"}', + status: "done", + }, + { + id: "2", + name: "Format Detected", + description: "Format detected", + format: "claude", + content: '{"detectedFormat":"claude"}', + status: "done", + }, + { + id: "3", + name: "OpenAI Intermediate", + description: "Translated to OpenAI", + format: "openai", + content: '{"model":"claude-sonnet-4-20250514","messages":[]}', + status: "active", + }, + { + id: "4", + name: "Provider Format", + description: "Translated to provider", + format: "gemini", + content: '{"model":"gemini-2.5-flash"}', + status: "pending", + }, + { + id: "5", + name: "Provider Response", + description: "Response from provider", + format: "openai", + content: "data: [DONE]", + status: "error", + }, +]; + +describe("PipelineView", () => { + 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 = ""; + }); + + it("exports a default function component", async () => { + const mod = await import( + "@/app/(dashboard)/dashboard/translator/components/advanced/PipelineView" + ); + expect(typeof mod.default).toBe("function"); + }); + + it("renders Collapsible wrapper with route icon", async () => { + const { default: PipelineView } = await import( + "@/app/(dashboard)/dashboard/translator/components/advanced/PipelineView" + ); + const container = makeContainer(); + const root = createRoot(container); + await act(async () => { + root.render(); + }); + const collapsible = container.querySelector("[data-testid='collapsible']"); + expect(collapsible).toBeTruthy(); + expect(collapsible?.getAttribute("data-icon")).toBe("route"); + }); + + it("renders demo steps when pipelineSteps is not provided (defaultOpen=true)", async () => { + const { default: PipelineView } = await import( + "@/app/(dashboard)/dashboard/translator/components/advanced/PipelineView" + ); + const container = makeContainer(); + const root = createRoot(container); + await act(async () => { + root.render(); + }); + // The pipeline container should be present + const pipelineContainer = container.querySelector("[data-pipeline-container='true']"); + expect(pipelineContainer).toBeTruthy(); + // Step list (role=list) should be present with items + const stepList = container.querySelector("[role='list']"); + expect(stepList).toBeTruthy(); + const items = container.querySelectorAll("[role='listitem']"); + expect(items.length).toBe(5); // 5 demo steps + }); + + it("renders provided pipelineSteps instead of demo", async () => { + const { default: PipelineView } = await import( + "@/app/(dashboard)/dashboard/translator/components/advanced/PipelineView" + ); + const container = makeContainer(); + const root = createRoot(container); + await act(async () => { + root.render(); + }); + const items = container.querySelectorAll("[role='listitem']"); + expect(items.length).toBe(5); + const text = container.textContent ?? ""; + expect(text).toContain("Client Request"); + expect(text).toContain("Format Detected"); + expect(text).toContain("OpenAI Intermediate"); + expect(text).toContain("Provider Format"); + expect(text).toContain("Provider Response"); + }); + + it("shows all 4 status values: done, active, pending, error", async () => { + const { default: PipelineView } = await import( + "@/app/(dashboard)/dashboard/translator/components/advanced/PipelineView" + ); + const container = makeContainer(); + const root = createRoot(container); + await act(async () => { + root.render(); + }); + const badges = container.querySelectorAll("[data-testid='badge']"); + const badgeVariants = Array.from(badges).map((b) => b.getAttribute("data-variant")); + // done → success + expect(badgeVariants).toContain("success"); + // active → primary + expect(badgeVariants).toContain("primary"); + // error → error + expect(badgeVariants).toContain("error"); + // pending → default + expect(badgeVariants).toContain("default"); + }); + + it("clicking a step expands its details and shows content", async () => { + const { default: PipelineView } = await import( + "@/app/(dashboard)/dashboard/translator/components/advanced/PipelineView" + ); + const container = makeContainer(); + const root = createRoot(container); + await act(async () => { + root.render(); + }); + + // Find all step toggle buttons (aria-expanded) + const stepButtons = container.querySelectorAll("button[aria-expanded]"); + expect(stepButtons.length).toBeGreaterThanOrEqual(1); + + // Click the first step + const firstStepBtn = stepButtons[0]; + expect(firstStepBtn.getAttribute("aria-expanded")).toBe("false"); + + await act(async () => { + firstStepBtn.click(); + }); + + expect(firstStepBtn.getAttribute("aria-expanded")).toBe("true"); + + // Step content region should be in DOM + const contentRegion = container.querySelector("[role='region']"); + expect(contentRegion).toBeTruthy(); + // Pre tag with JSON content + const pre = container.querySelector("pre"); + expect(pre).toBeTruthy(); + expect(pre?.textContent).toContain("claude-sonnet-4-20250514"); + }); + + it("clicking an expanded step collapses it", async () => { + const { default: PipelineView } = await import( + "@/app/(dashboard)/dashboard/translator/components/advanced/PipelineView" + ); + const container = makeContainer(); + const root = createRoot(container); + await act(async () => { + root.render(); + }); + + const stepButtons = container.querySelectorAll("button[aria-expanded]"); + const firstStepBtn = stepButtons[0]; + + // Expand + await act(async () => { + firstStepBtn.click(); + }); + expect(firstStepBtn.getAttribute("aria-expanded")).toBe("true"); + + // Collapse + await act(async () => { + firstStepBtn.click(); + }); + expect(firstStepBtn.getAttribute("aria-expanded")).toBe("false"); + expect(container.querySelector("[role='region']")).toBeNull(); + }); + + it("lazy-render: pipeline container not in DOM until forceOpen triggers mount", async () => { + const { default: PipelineView } = await import( + "@/app/(dashboard)/dashboard/translator/components/advanced/PipelineView" + ); + const container = makeContainer(); + const root = createRoot(container); + + // Render initially closed (defaultOpen=false, forceOpen=false) + await act(async () => { + root.render(); + }); + + // hasOpened is false → no pipeline content rendered + const pipelineContainer = container.querySelector("[data-pipeline-container='true']"); + // The outer div exists but the inner content (hasOpened guard) should not have items + if (pipelineContainer) { + const items = pipelineContainer.querySelectorAll("[role='listitem']"); + expect(items.length).toBe(0); + } + + // Now force open + await act(async () => { + root.render(); + }); + + const pipelineContainerAfter = container.querySelector("[data-pipeline-container='true']"); + expect(pipelineContainerAfter).toBeTruthy(); + }); + + it("onOpenChange fires when mounted with forceOpen=true", async () => { + const onOpenChange = vi.fn(); + const { default: PipelineView } = await import( + "@/app/(dashboard)/dashboard/translator/components/advanced/PipelineView" + ); + const container = makeContainer(); + const root = createRoot(container); + await act(async () => { + root.render( + , + ); + }); + expect(onOpenChange).toHaveBeenCalledWith(true); + }); + + it("renders connector lines between steps", async () => { + const { default: PipelineView } = await import( + "@/app/(dashboard)/dashboard/translator/components/advanced/PipelineView" + ); + const container = makeContainer(); + const root = createRoot(container); + await act(async () => { + root.render(); + }); + // Connector divs have aria-hidden=true + const connectors = container.querySelectorAll("[aria-hidden='true']"); + // At least 4 connectors for 5 steps (between each pair) + expect(connectors.length).toBeGreaterThanOrEqual(4); + }); +}); diff --git a/tests/unit/translator-friendly-raw-json-panel.test.tsx b/tests/unit/translator-friendly-raw-json-panel.test.tsx new file mode 100644 index 0000000000..1bb12f24be --- /dev/null +++ b/tests/unit/translator-friendly-raw-json-panel.test.tsx @@ -0,0 +1,371 @@ +// @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"; + +// Minimal i18n stub +vi.mock("next-intl", () => ({ + useTranslations: () => (key: string) => key, +})); + +// Monaco Editor stub — renders a simple textarea with data attributes +vi.mock("@/shared/components/MonacoEditor", () => ({ + default: ({ + value, + onChange, + options, + }: { + value?: string; + onChange?: (v: string) => void; + options?: { readOnly?: boolean }; + }) => ( +