feat(translator): add AdvancedSection, RawJsonPanel, PipelineView (F4)

This commit is contained in:
diegosouzapw
2026-05-28 00:19:43 -03:00
parent bb200cd0ba
commit 3852656e8b
6 changed files with 1902 additions and 0 deletions

View File

@@ -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<typeof t>[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 (
<Card className="border-amber-500/10 bg-amber-500/[0.02]">
<div className="p-4 space-y-3">
{/* Header */}
<div className="flex items-start gap-3">
<span
className="material-symbols-outlined text-amber-500 text-[20px] mt-0.5 shrink-0"
aria-hidden="true"
>
tune
</span>
<div className="min-w-0">
<h3 className="text-sm font-semibold text-text-main">
{tr("advancedSectionTitle", "Advanced")}
</h3>
<p className="text-xs text-text-muted">
{tr(
"advancedSectionSubtitle",
"Raw JSON, pipeline e ferramentas técnicas. Tudo aqui é igual às tabs antigas — apenas reorganizado.",
)}
</p>
</div>
</div>
{/* Accordion slots — children provided by F9 (TranslateTab) */}
<div
className="space-y-2"
data-advanced-container="true"
data-slug={forceOpenSlug ?? "none"}
>
{children}
</div>
</div>
</Card>
);
}

View File

@@ -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<AdvancedAccordionProps, "slug"> {
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<string | null>(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<typeof t>[0]);
if (v === key || v === `translator.${key}`) return fallback;
return v as string;
} catch {
return fallback;
}
};
return (
<Collapsible
title={tr("advancedPipelineTitle", "Pipeline OpenAI intermediário")}
subtitle={tr("advancedPipelineSubtitle", "Visualize cada passo da tradução (hub-and-spoke).")}
icon="route"
defaultOpen={defaultOpen || forceOpen}
className="border-black/5 dark:border-white/5"
>
{/* D7 lazy-render container */}
<div
className="space-y-2"
data-pipeline-container="true"
>
{hasOpened && (
<>
{/* Demo badge when showing placeholder data */}
{!pipelineSteps && (
<div className="flex items-center gap-2 text-xs text-text-muted px-1">
<span
className="material-symbols-outlined text-[14px]"
aria-hidden="true"
>
info
</span>
<span>
{tr(
"pipelineVisualizationHint",
"Envie um request pelo Chat Tester para ver o pipeline em tempo real. Abaixo: exemplo estático.",
)}
</span>
</div>
)}
{/* Step list */}
<div className="space-y-1" role="list" aria-label="Pipeline steps">
{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 (
<div key={step.id} role="listitem">
{/* Connector line between steps */}
{i > 0 && (
<div className="flex justify-center py-1" aria-hidden="true">
<div className="w-px h-3 bg-border" />
</div>
)}
<Card
className={
step.status === "error"
? "border-red-500/30"
: isExpanded
? "border-primary/30"
: step.status === "pending"
? "opacity-60"
: ""
}
>
<button
type="button"
onClick={() => setExpandedStepId(isExpanded ? null : step.id)}
className="w-full p-3 flex items-center gap-3 text-left"
aria-expanded={isExpanded}
aria-controls={`pipeline-step-content-${step.id}`}
>
{/* Step number circle */}
<div
className={`flex items-center justify-center w-7 h-7 rounded-full text-xs font-bold shrink-0 ${statusNumberClass(step.status)}`}
aria-hidden="true"
>
{step.status === "error" ? "!" : i + 1}
</div>
{/* Step info */}
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-text-main">{step.name}</p>
{step.description && (
<p className="text-[10px] text-text-muted truncate">
{step.description}
</p>
)}
</div>
{/* Status + format badge */}
<div className="flex items-center gap-1.5 shrink-0">
<Badge variant={statusVariant(step.status)} size="sm">
{step.status === "pending"
? "pending"
: step.status === "active"
? "active"
: step.status === "error"
? "error"
: (meta.label as string)}
</Badge>
</div>
{/* Expand chevron */}
<span
className="material-symbols-outlined text-[18px] text-text-muted shrink-0"
aria-hidden="true"
>
{isExpanded ? "expand_less" : "expand_more"}
</span>
</button>
{/* Expanded content — pre-formatted JSON/SSE */}
{isExpanded && (
<div
id={`pipeline-step-content-${step.id}`}
className="px-3 pb-3"
role="region"
aria-label={`${step.name} details`}
>
<pre className="text-xs text-text-muted bg-bg-subtle border border-border rounded-lg p-3 overflow-x-auto whitespace-pre-wrap break-words max-h-60 overflow-y-auto font-mono">
{step.content || tr("noContent", "(no content)")}
</pre>
</div>
)}
</Card>
</div>
);
})}
</div>
</>
)}
</div>
</Collapsible>
);
}

View File

@@ -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<AdvancedAccordionProps, "slug"> {
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<string | null>(null);
const [translating, setTranslating] = useState(false);
const [detecting, setDetecting] = useState(false);
const [activeTemplate, setActiveTemplate] = useState<string | null>(null);
const [errorMessage, setErrorMessage] = useState<string | null>(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<string, unknown> = JSON.parse(inputContent);
if (sourceFormat === targetFormat) {
setOutputContent(JSON.stringify(parsed, null, 2));
setTranslationPath("passthrough");
setTranslating(false);
return;
}
let intermediate: Record<string, unknown> = 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<string, unknown>; 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<string, unknown>; 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<string, unknown> }) => {
const formatData =
(template.formats[sourceFormat] as Record<string, unknown> | undefined) ??
(template.formats["openai"] as Record<string, unknown>);
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<typeof t>[0]);
if (v === key || v === `translator.${key}`) return fallback;
return v as string;
} catch {
return fallback;
}
};
return (
<Collapsible
title={tr("advancedRawJsonTitle", "Raw JSON (auto-detecção + Monaco)")}
subtitle={tr("advancedRawJsonSubtitle", "Cole um request JSON; o formato é detectado automaticamente.")}
icon="code"
defaultOpen={defaultOpen || forceOpen}
className="border-black/5 dark:border-white/5"
>
{/* Internal open-state control — Collapsible owns its own open state,
but we mirror it here for the lazy-render guard and onOpenChange. */}
<div
ref={(el) => {
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 && (
<div className="flex items-start gap-2 px-3 py-2 rounded-lg bg-red-500/10 border border-red-500/20 text-sm text-red-500">
<span
className="material-symbols-outlined text-[16px] mt-0.5 shrink-0"
aria-hidden="true"
>
error
</span>
<span>{errorMessage}</span>
</div>
)}
{/* Format Controls Bar */}
<Card>
<div className="p-4 flex flex-col sm:flex-row items-center gap-4">
{/* Source Format */}
<div className="flex-1 w-full">
<label className="block text-xs font-medium text-text-muted mb-1.5 uppercase tracking-wider">
{tr("source", "Source")}
</label>
<div className="flex items-center gap-2">
<span
className={`material-symbols-outlined text-[20px] text-${srcMeta.color}-500`}
aria-hidden="true"
>
{srcMeta.icon}
</span>
<Select
value={sourceFormat}
onChange={(e: React.ChangeEvent<HTMLSelectElement>) => {
setSourceFormat(e.target.value);
setDetectedFormat(null);
}}
options={FORMAT_OPTIONS}
className="flex-1"
/>
{detectedFormat && (
<Badge variant="primary" size="sm" icon="auto_awesome">
{tr("auto", "auto")}
</Badge>
)}
</div>
</div>
{/* Swap Button */}
<button
type="button"
onClick={handleSwapFormats}
className="p-2 rounded-full hover:bg-primary/10 text-text-muted hover:text-primary transition-all mt-4 sm:mt-5"
title={tr("swapFormats", "Swap formats")}
aria-label={tr("swapFormats", "Swap formats")}
>
<span className="material-symbols-outlined text-[24px]" aria-hidden="true">
swap_horiz
</span>
</button>
{/* Target Format */}
<div className="flex-1 w-full">
<label className="block text-xs font-medium text-text-muted mb-1.5 uppercase tracking-wider">
{tr("target", "Target")}
</label>
<div className="flex items-center gap-2">
<span
className={`material-symbols-outlined text-[20px] text-${tgtMeta.color}-500`}
aria-hidden="true"
>
{tgtMeta.icon}
</span>
<Select
value={targetFormat}
onChange={(e: React.ChangeEvent<HTMLSelectElement>) =>
setTargetFormat(e.target.value)
}
options={FORMAT_OPTIONS}
className="flex-1"
/>
</div>
</div>
{/* Translate Button */}
<div className="pt-0 sm:pt-5">
<Button
icon="arrow_forward"
onClick={handleTranslate}
loading={translating}
disabled={!inputContent.trim() || translating}
className="w-full sm:w-auto"
>
{tr("translateAction", "Translate")}
</Button>
</div>
</div>
</Card>
{/* Translation path indicator */}
{translationPath && (
<div className="flex items-center gap-2 text-xs text-text-muted">
<span className="material-symbols-outlined text-[14px]" aria-hidden="true">
route
</span>
{translationPath === "hub-and-spoke" ? (
<span>
{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}`}
</span>
) : translationPath === "direct" ? (
<span>
{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}`}
</span>
) : (
<span>{tr("translationPathPassthrough", "Passthrough (same format)")}</span>
)}
</div>
)}
{/* Split Editor View */}
<div
className={`grid grid-cols-1 gap-4 ${
intermediateContent ? "xl:grid-cols-3" : "lg:grid-cols-2"
}`}
>
{/* Input Panel */}
<Card>
<div className="p-4 space-y-3">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<span
className="material-symbols-outlined text-[18px] text-text-muted"
aria-hidden="true"
>
input
</span>
<h3 className="text-sm font-semibold text-text-main">
{tr("input", "Input")}
</h3>
{detectedFormat && (
<Badge variant="info" size="sm" dot>
{FORMAT_META[detectedFormat]?.label ?? detectedFormat}
</Badge>
)}
{detecting && (
<span
className="material-symbols-outlined text-[14px] text-text-muted animate-spin"
aria-hidden="true"
>
progress_activity
</span>
)}
</div>
<div className="flex items-center gap-1">
<button
type="button"
onClick={() => handleCopy(inputContent)}
className="p-1.5 rounded hover:bg-black/5 dark:hover:bg-white/5 text-text-muted hover:text-text-main transition-colors"
title={tc("copy" as Parameters<typeof tc>[0])}
aria-label={tr("input", "Input") + " — copy"}
>
<span className="material-symbols-outlined text-[16px]" aria-hidden="true">
content_copy
</span>
</button>
<button
type="button"
onClick={() => {
setInputContent("");
setOutputContent("");
setDetectedFormat(null);
setActiveTemplate(null);
setErrorMessage(null);
}}
className="p-1.5 rounded hover:bg-black/5 dark:hover:bg-white/5 text-text-muted hover:text-text-main transition-colors"
title={tr("clear", "Clear")}
aria-label={tr("clear", "Clear") + " input"}
>
<span className="material-symbols-outlined text-[16px]" aria-hidden="true">
delete
</span>
</button>
</div>
</div>
<div className="border border-border rounded-lg overflow-hidden">
<Editor
height="400px"
defaultLanguage="json"
value={inputContent}
onChange={(value: string | undefined) => setInputContent(value ?? "")}
theme="vs-dark"
options={{
minimap: { enabled: false },
fontSize: 12,
lineNumbers: "on",
scrollBeyondLastLine: false,
wordWrap: "on",
automaticLayout: true,
formatOnPaste: true,
}}
/>
</div>
</div>
</Card>
{/* Intermediate Panel (hub-and-spoke only) */}
{intermediateContent && (
<Card>
<div className="p-4 space-y-3">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<span
className="material-symbols-outlined text-[18px] text-amber-500"
aria-hidden="true"
>
hub
</span>
<h3 className="text-sm font-semibold text-text-main">
{tr("openaiIntermediatePanel", "OpenAI Intermediate")}
</h3>
<Badge variant="warning" size="sm">
Hub
</Badge>
</div>
<button
type="button"
onClick={() => handleCopy(intermediateContent)}
className="p-1.5 rounded hover:bg-black/5 dark:hover:bg-white/5 text-text-muted hover:text-text-main transition-colors"
title={tc("copy" as Parameters<typeof tc>[0])}
aria-label="Copy intermediate JSON"
>
<span className="material-symbols-outlined text-[16px]" aria-hidden="true">
content_copy
</span>
</button>
</div>
<div className="border border-border rounded-lg overflow-hidden">
<Editor
height="400px"
defaultLanguage="json"
value={intermediateContent}
theme="vs-dark"
options={{
minimap: { enabled: false },
fontSize: 12,
lineNumbers: "on",
scrollBeyondLastLine: false,
wordWrap: "on",
automaticLayout: true,
readOnly: true,
}}
/>
</div>
</div>
</Card>
)}
{/* Output Panel */}
<Card>
<div className="p-4 space-y-3">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<span
className="material-symbols-outlined text-[18px] text-text-muted"
aria-hidden="true"
>
output
</span>
<h3 className="text-sm font-semibold text-text-main">
{tr("output", "Output")}
</h3>
{outputContent && (
<Badge variant="success" size="sm" dot>
{FORMAT_META[targetFormat]?.label ?? targetFormat}
</Badge>
)}
</div>
<button
type="button"
onClick={() => handleCopy(outputContent)}
className="p-1.5 rounded hover:bg-black/5 dark:hover:bg-white/5 text-text-muted hover:text-text-main transition-colors"
title={tc("copy" as Parameters<typeof tc>[0])}
aria-label="Copy output JSON"
>
<span className="material-symbols-outlined text-[16px]" aria-hidden="true">
content_copy
</span>
</button>
</div>
<div className="border border-border rounded-lg overflow-hidden">
<Editor
height="400px"
defaultLanguage="json"
value={outputContent}
theme="vs-dark"
options={{
minimap: { enabled: false },
fontSize: 12,
lineNumbers: "on",
scrollBeyondLastLine: false,
wordWrap: "on",
automaticLayout: true,
readOnly: true,
}}
/>
</div>
</div>
</Card>
</div>
{/* Example Templates Grid */}
<Card>
<div className="p-4 space-y-3">
<div className="flex items-center gap-2">
<span
className="material-symbols-outlined text-[18px] text-primary"
aria-hidden="true"
>
library_books
</span>
<h3 className="text-sm font-semibold text-text-main">
{tr("exampleTemplates", "Example Templates")}
</h3>
<span className="text-xs text-text-muted">
{tr("exampleTemplatesHint", "Load a sample request")}
</span>
</div>
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-6 gap-2">
{templates.map((template) => (
<button
key={template.id}
type="button"
onClick={() => loadTemplate(template)}
className={`
group flex flex-col items-center gap-1.5 p-3 rounded-lg border transition-all text-center
${
activeTemplate === template.id
? "border-primary bg-primary/5 text-primary"
: "border-border hover:border-primary/30 hover:bg-primary/5 text-text-muted hover:text-text-main"
}
`}
>
<span
className={`material-symbols-outlined text-[22px] ${
activeTemplate === template.id
? "text-primary"
: "text-text-muted group-hover:text-primary"
} transition-colors`}
aria-hidden="true"
>
{template.icon}
</span>
<span className="text-xs font-medium leading-tight">{template.name}</span>
</button>
))}
</div>
{activeTemplate && (
<div className="flex items-center gap-2 text-xs text-text-muted">
<span
className="material-symbols-outlined text-[14px]"
aria-hidden="true"
>
info
</span>
{tr("templateLoadHint", "Template loaded for format: {format}").replace(
"{format}",
FORMAT_META[sourceFormat]?.label ?? sourceFormat,
)}
</div>
)}
</div>
</Card>
</>
)}
</div>
</Collapsible>
);
}
/** 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);
}

View File

@@ -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;
}) => (
<div data-testid="card" className={className}>
{children}
</div>
),
}));
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(<AdvancedSection />);
});
// 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(
<AdvancedSection>
<div data-testid="child-accordion">child</div>
</AdvancedSection>,
);
});
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(<AdvancedSection forceOpenSlug="rawjson" />);
});
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(<AdvancedSection forceOpenSlug={null} />);
});
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(<AdvancedSection />);
});
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(<AdvancedSection />);
});
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(
<AdvancedSection>
<div data-testid="accordion-1">RawJson</div>
<div data-testid="accordion-2">Pipeline</div>
<div data-testid="accordion-3">Stream</div>
</AdvancedSection>,
);
});
expect(container.querySelector("[data-testid='accordion-1']")).toBeTruthy();
expect(container.querySelector("[data-testid='accordion-2']")).toBeTruthy();
expect(container.querySelector("[data-testid='accordion-3']")).toBeTruthy();
});
});

View File

@@ -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;
}) => (
<div data-testid="collapsible" data-title={title} data-icon={icon}>
{children}
</div>
),
}));
// Shared component stubs
vi.mock("@/shared/components", () => ({
Card: ({
children,
className,
}: {
children: React.ReactNode;
className?: string;
}) => (
<div data-testid="card" className={className}>
{children}
</div>
),
Badge: ({
children,
variant,
}: {
children: React.ReactNode;
variant?: string;
size?: string;
}) => (
<span data-testid="badge" data-variant={variant}>
{children}
</span>
),
}));
// 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(<PipelineView />);
});
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(<PipelineView defaultOpen={true} />);
});
// 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(<PipelineView defaultOpen={true} pipelineSteps={SAMPLE_STEPS} />);
});
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(<PipelineView defaultOpen={true} pipelineSteps={SAMPLE_STEPS} />);
});
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(<PipelineView defaultOpen={true} pipelineSteps={SAMPLE_STEPS} />);
});
// Find all step toggle buttons (aria-expanded)
const stepButtons = container.querySelectorAll<HTMLButtonElement>("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(<PipelineView defaultOpen={true} pipelineSteps={SAMPLE_STEPS} />);
});
const stepButtons = container.querySelectorAll<HTMLButtonElement>("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(<PipelineView defaultOpen={false} forceOpen={false} pipelineSteps={SAMPLE_STEPS} />);
});
// 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(<PipelineView defaultOpen={false} forceOpen={true} pipelineSteps={SAMPLE_STEPS} />);
});
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(
<PipelineView forceOpen={true} onOpenChange={onOpenChange} pipelineSteps={SAMPLE_STEPS} />,
);
});
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(<PipelineView defaultOpen={true} pipelineSteps={SAMPLE_STEPS} />);
});
// 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);
});
});

View File

@@ -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 };
}) => (
<textarea
data-testid="monaco-editor"
data-readonly={options?.readOnly ? "true" : "false"}
value={value ?? ""}
readOnly={options?.readOnly}
onChange={(e) => onChange?.(e.target.value)}
/>
),
}));
// Collapsible stub — renders children directly (always open in tests)
vi.mock("@/shared/components/Collapsible", () => ({
default: ({
children,
title,
subtitle,
icon,
}: {
children: React.ReactNode;
title?: string;
subtitle?: string;
icon?: string;
}) => (
<div data-testid="collapsible" data-title={title} data-subtitle={subtitle} data-icon={icon}>
{children}
</div>
),
}));
// Shared component stubs
vi.mock("@/shared/components", () => ({
Card: ({ children, className }: { children: React.ReactNode; className?: string }) => (
<div data-testid="card" className={className}>
{children}
</div>
),
Button: ({
children,
onClick,
disabled,
loading,
icon,
}: {
children?: React.ReactNode;
onClick?: () => void;
disabled?: boolean;
loading?: boolean;
icon?: string;
}) => (
<button
type="button"
data-testid="button"
data-icon={icon}
onClick={onClick}
disabled={disabled || loading}
>
{children}
</button>
),
Select: ({
value,
onChange,
options,
}: {
value: string;
onChange: (e: React.ChangeEvent<HTMLSelectElement>) => void;
options: Array<{ value: string; label: string }>;
className?: string;
}) => (
<select data-testid="select" value={value} onChange={onChange}>
{options.map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
</select>
),
Badge: ({ children, variant }: { children: React.ReactNode; variant?: string; size?: string; icon?: string; dot?: boolean }) => (
<span data-testid="badge" data-variant={variant}>
{children}
</span>
),
}));
// exampleTemplates stub
vi.mock(
"@/app/(dashboard)/dashboard/translator/exampleTemplates",
() => ({
getExampleTemplates: () => [
{
id: "simple-chat",
name: "Simple Chat",
icon: "chat",
description: "Simple chat template",
formats: {
openai: { model: "gpt-4o", messages: [{ role: "user", content: "Hello" }] },
claude: {
model: "claude-sonnet-4-20250514",
messages: [{ role: "user", content: "Hello" }],
},
},
},
],
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" },
{ value: "gemini", label: "Gemini" },
],
}),
);
const cleanupCallbacks: Array<() => void> = [];
function makeContainer(): HTMLElement {
const container = document.createElement("div");
document.body.appendChild(container);
cleanupCallbacks.push(() => container.remove());
return container;
}
describe("RawJsonPanel", () => {
beforeEach(() => {
(
globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }
).IS_REACT_ACT_ENVIRONMENT = true;
vi.resetAllMocks();
});
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/RawJsonPanel"
);
expect(typeof mod.default).toBe("function");
});
it("renders Collapsible wrapper with correct icon", async () => {
const { default: RawJsonPanel } = await import(
"@/app/(dashboard)/dashboard/translator/components/advanced/RawJsonPanel"
);
const container = makeContainer();
const root = createRoot(container);
await act(async () => {
root.render(<RawJsonPanel />);
});
const collapsible = container.querySelector("[data-testid='collapsible']");
expect(collapsible).toBeTruthy();
expect(collapsible?.getAttribute("data-icon")).toBe("code");
});
it("lazy-render: content mounts when defaultOpen=true", async () => {
const { default: RawJsonPanel } = await import(
"@/app/(dashboard)/dashboard/translator/components/advanced/RawJsonPanel"
);
const container = makeContainer();
const root = createRoot(container);
await act(async () => {
root.render(<RawJsonPanel defaultOpen={true} />);
});
// Monaco editors should be rendered
const editors = container.querySelectorAll("[data-testid='monaco-editor']");
expect(editors.length).toBeGreaterThanOrEqual(2); // input + output
});
it("lazy-render: content mounts when forceOpen=true", async () => {
const { default: RawJsonPanel } = await import(
"@/app/(dashboard)/dashboard/translator/components/advanced/RawJsonPanel"
);
const container = makeContainer();
const root = createRoot(container);
await act(async () => {
root.render(<RawJsonPanel forceOpen={true} />);
});
const editors = container.querySelectorAll("[data-testid='monaco-editor']");
expect(editors.length).toBeGreaterThanOrEqual(2);
});
it("renders two format selects (source and target)", async () => {
const { default: RawJsonPanel } = await import(
"@/app/(dashboard)/dashboard/translator/components/advanced/RawJsonPanel"
);
const container = makeContainer();
const root = createRoot(container);
await act(async () => {
root.render(<RawJsonPanel defaultOpen={true} />);
});
const selects = container.querySelectorAll("[data-testid='select']");
expect(selects.length).toBeGreaterThanOrEqual(2);
});
it("renders the translate button", async () => {
const { default: RawJsonPanel } = await import(
"@/app/(dashboard)/dashboard/translator/components/advanced/RawJsonPanel"
);
const container = makeContainer();
const root = createRoot(container);
await act(async () => {
root.render(<RawJsonPanel defaultOpen={true} />);
});
const buttons = container.querySelectorAll("[data-testid='button']");
expect(buttons.length).toBeGreaterThanOrEqual(1);
});
it("renders example templates grid", async () => {
const { default: RawJsonPanel } = await import(
"@/app/(dashboard)/dashboard/translator/components/advanced/RawJsonPanel"
);
const container = makeContainer();
const root = createRoot(container);
await act(async () => {
root.render(<RawJsonPanel defaultOpen={true} />);
});
// The template "Simple Chat" should appear
const text = container.textContent ?? "";
expect(text).toContain("Simple Chat");
});
it("translate button calls /api/translator/translate on click", async () => {
const mockFetch = vi.fn().mockResolvedValue({
ok: true,
json: async () => ({ success: true, result: { model: "gpt-4o" } }),
});
vi.stubGlobal("fetch", mockFetch);
const { default: RawJsonPanel } = await import(
"@/app/(dashboard)/dashboard/translator/components/advanced/RawJsonPanel"
);
const container = makeContainer();
const root = createRoot(container);
await act(async () => {
root.render(<RawJsonPanel defaultOpen={true} />);
});
// Type valid JSON into the input Monaco editor
const editors = container.querySelectorAll<HTMLTextAreaElement>("[data-testid='monaco-editor']");
const inputEditor = editors[0]; // first editor is input
await act(async () => {
const nativeInputValueSetter = Object.getOwnPropertyDescriptor(
window.HTMLTextAreaElement.prototype,
"value",
)?.set;
nativeInputValueSetter?.call(inputEditor, '{"model":"gpt-4o","messages":[]}');
inputEditor.dispatchEvent(new Event("change", { bubbles: true }));
});
// Click translate button
const translateBtn = Array.from(
container.querySelectorAll<HTMLButtonElement>("[data-testid='button']"),
).find((b) => !b.disabled);
if (translateBtn) {
await act(async () => {
translateBtn.click();
});
}
// fetch should have been called (detect or translate)
// Note: auto-detect fires after 600ms debounce, translate fires immediately
expect(mockFetch).toHaveBeenCalled();
vi.unstubAllGlobals();
});
it("error path: error response does not contain stack trace", async () => {
const mockFetch = vi.fn().mockResolvedValue({
ok: true,
json: async () => ({
success: false,
error: "Translation failed\n at Object.<anonymous> (/src/translator.ts:42:5)",
}),
});
vi.stubGlobal("fetch", mockFetch);
const { default: RawJsonPanel } = await import(
"@/app/(dashboard)/dashboard/translator/components/advanced/RawJsonPanel"
);
const container = makeContainer();
const root = createRoot(container);
await act(async () => {
root.render(<RawJsonPanel defaultOpen={true} />);
});
// Type valid JSON and trigger translate
const editors = container.querySelectorAll<HTMLTextAreaElement>("[data-testid='monaco-editor']");
const inputEditor = editors[0];
await act(async () => {
const setter = Object.getOwnPropertyDescriptor(
window.HTMLTextAreaElement.prototype,
"value",
)?.set;
setter?.call(inputEditor, '{"model":"gpt-4o","messages":[]}');
inputEditor.dispatchEvent(new Event("change", { bubbles: true }));
});
const translateBtn = Array.from(
container.querySelectorAll<HTMLButtonElement>("[data-testid='button']"),
).find((b) => !b.disabled);
if (translateBtn) {
await act(async () => {
translateBtn.click();
});
}
// The rendered error text must NOT include a stack-trace line
const errorBanner = container.querySelector("[data-testid='card']");
const displayedText = container.textContent ?? "";
expect(displayedText).not.toMatch(/\s+at\s+[A-Za-z]/);
vi.unstubAllGlobals();
});
it("swap formats button is rendered", async () => {
const { default: RawJsonPanel } = await import(
"@/app/(dashboard)/dashboard/translator/components/advanced/RawJsonPanel"
);
const container = makeContainer();
const root = createRoot(container);
await act(async () => {
root.render(<RawJsonPanel defaultOpen={true} />);
});
// Swap button has title/aria-label
const swapBtn = container.querySelector("button[title]");
// There should be at least one swap_horiz icon
const icons = container.querySelectorAll(".material-symbols-outlined");
const iconTexts = Array.from(icons).map((el) => el.textContent?.trim());
expect(iconTexts).toContain("swap_horiz");
});
it("onOpenChange fires when component mounts open", async () => {
const onOpenChange = vi.fn();
const { default: RawJsonPanel } = await import(
"@/app/(dashboard)/dashboard/translator/components/advanced/RawJsonPanel"
);
const container = makeContainer();
const root = createRoot(container);
await act(async () => {
root.render(<RawJsonPanel forceOpen={true} onOpenChange={onOpenChange} />);
});
expect(onOpenChange).toHaveBeenCalledWith(true);
});
});