mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-07-26 09:52:11 +03:00
feat(translator): add AdvancedSection, RawJsonPanel, PipelineView (F4)
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
166
tests/unit/translator-friendly-advanced-section.test.tsx
Normal file
166
tests/unit/translator-friendly-advanced-section.test.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
339
tests/unit/translator-friendly-pipeline-view.test.tsx
Normal file
339
tests/unit/translator-friendly-pipeline-view.test.tsx
Normal 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);
|
||||
});
|
||||
});
|
||||
371
tests/unit/translator-friendly-raw-json-panel.test.tsx
Normal file
371
tests/unit/translator-friendly-raw-json-panel.test.tsx
Normal 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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user