Merge F3 (SimpleControls/ResultNarrated/TranslateTab) into refactor/pages-v3-19

This commit is contained in:
diegosouzapw
2026-05-28 00:30:48 -03:00
6 changed files with 1600 additions and 0 deletions

View File

@@ -0,0 +1,190 @@
"use client";
import { useCallback } from "react";
import { useTranslations } from "next-intl";
import { Badge, Button, Card } from "@/shared/components";
import type { AdvancedSlug, TranslateNarratedResult } from "../types";
import { FORMAT_META } from "../exampleTemplates";
interface ResultNarratedProps {
result: TranslateNarratedResult;
onSeeTranslatedJson: () => void;
onSeePipeline: () => void;
}
// Resolve a display label for a FormatId
function formatLabel(id: string | null): string {
if (!id) return "—";
const meta = (FORMAT_META as Record<string, { label: string }>)[id];
return meta?.label ?? id;
}
// Ensure stack traces are never surfaced — safety net on top of hook sanitization
function safeErrorMessage(raw: string | null): string {
if (!raw) return "Unknown error";
return raw
.replace(/\sat\s\/[^\s]*/g, "")
.replace(/sk-[A-Za-z0-9_-]{16,}/g, "[REDACTED]")
.replace(/Bearer\s+[A-Za-z0-9_.-]+/g, "Bearer [REDACTED]");
}
export default function ResultNarrated({
result,
onSeeTranslatedJson,
onSeePipeline,
}: ResultNarratedProps) {
const t = useTranslations("translator");
const tr = useCallback(
(key: string, fallback: string, params?: Record<string, string | number>): string => {
try {
const translated = t(key as Parameters<typeof t>[0], params as Parameters<typeof t>[1]);
if (translated === key || translated === `translator.${key}`) {
// i18n key not found — use fallback with param substitution
if (params) {
return Object.entries(params).reduce(
(acc, [k, v]) => acc.replace(`{${k}}`, String(v)),
fallback
);
}
return fallback;
}
return translated;
} catch {
if (params && fallback) {
return Object.entries(params).reduce(
(acc, [k, v]) => acc.replace(`{${k}}`, String(v)),
fallback
);
}
return fallback;
}
},
[t]
);
const isSpinning = result.status === "translating" || result.status === "sending";
return (
<Card className="flex flex-col gap-4 p-4">
{/* Header */}
<div className="flex items-center gap-2">
<span className="material-symbols-outlined text-[20px] text-primary" aria-hidden="true">
translate
</span>
<h3 className="text-sm font-semibold text-text-main">
{tr("simpleResultPanelTitle", "Translation + Response")}
</h3>
</div>
{/* Status area — aria-live for screen-reader announcements (D20) */}
<div
aria-live="polite"
aria-atomic="true"
className="flex flex-1 flex-col gap-3"
>
{/* idle */}
{result.status === "idle" && (
<div className="flex flex-col items-center justify-center gap-2 py-8 text-center">
<span className="material-symbols-outlined text-[40px] text-text-muted/40" aria-hidden="true">
info
</span>
<p className="text-sm text-text-muted">
{tr("simpleStartWithExamplePlaceholder", "Select a ready-made example")}
</p>
</div>
)}
{/* translating or sending */}
{isSpinning && (
<div className="flex items-center gap-3 py-6">
<span className="material-symbols-outlined animate-spin text-[24px] text-primary" aria-hidden="true">
progress_activity
</span>
<span className="text-sm text-text-muted">
{result.status === "translating"
? tr("narratedTranslating", "Translating to {target}...", {
target: formatLabel(result.target),
})
: tr("narratedSending", "Sending to {target}...", {
target: formatLabel(result.target),
})}
</span>
</div>
)}
{/* ok */}
{result.status === "ok" && (
<div className="flex flex-col gap-3">
{/* Detection badge */}
{result.detected && (
<Badge variant="success">
{tr("narratedDetected", "✓ Detected: {format}", {
format: formatLabel(result.detected),
})}
</Badge>
)}
{/* Narrated success line */}
<p className="text-sm text-text-main">
{tr("narratedSuccess", "→ translated to {target} · response in {latency}ms", {
target: formatLabel(result.target),
latency: result.latencyMs ?? 0,
})}
</p>
{/* Response preview */}
{result.responsePreview && (
<div className="rounded-md border border-black/10 bg-black/5 p-3 dark:border-white/10 dark:bg-white/5">
<pre className="overflow-x-auto whitespace-pre-wrap break-words font-mono text-xs text-text-main">
{result.responsePreview.slice(0, 500)}
</pre>
</div>
)}
{/* Secondary action buttons */}
<div className="flex flex-wrap gap-2">
{result.translatedJson && (
<Button
variant="secondary"
size="sm"
icon="code"
onClick={onSeeTranslatedJson}
aria-label={tr("narratedSeeTranslatedJson", "see translated JSON")}
>
{tr("narratedSeeTranslatedJson", "see translated JSON")}
</Button>
)}
<Button
variant="secondary"
size="sm"
icon="account_tree"
onClick={onSeePipeline}
aria-label={tr("narratedSeePipeline", "see pipeline")}
>
{tr("narratedSeePipeline", "see pipeline")}
</Button>
</div>
</div>
)}
{/* error */}
{result.status === "error" && (
<div className="flex flex-col gap-2">
<Badge variant="error">
<span className="material-symbols-outlined text-[14px]" aria-hidden="true">
error
</span>
Error
</Badge>
<p className="text-sm text-text-main">
{tr("narratedError", "Failed: {reason}", {
reason: safeErrorMessage(result.errorMessage),
})}
</p>
</div>
)}
</div>
</Card>
);
}

View File

@@ -0,0 +1,225 @@
"use client";
import { useCallback } from "react";
import { useTranslations } from "next-intl";
import { Button, Select, SegmentedControl } from "@/shared/components";
import { InfoTooltip } from "@/shared/components";
import { FORMAT_OPTIONS, FORMAT_META, getExampleTemplates } from "../exampleTemplates";
import { useProviderOptions } from "../hooks/useProviderOptions";
import type { FormatId, TranslateMode } from "../types";
interface SimpleControlsProps {
source: FormatId;
target: FormatId;
provider: string;
inputText: string;
mode: TranslateMode;
onSourceChange: (source: FormatId) => void;
onTargetChange: (target: FormatId) => void;
onProviderChange: (provider: string) => void;
onInputChange: (text: string) => void;
onModeChange: (mode: TranslateMode) => void;
onSubmit: () => void;
onOpenAdvanced: () => void;
isLoading?: boolean;
}
export default function SimpleControls({
source,
target,
provider,
inputText,
mode,
onSourceChange,
onTargetChange,
onProviderChange,
onInputChange,
onModeChange,
onSubmit,
onOpenAdvanced,
isLoading = false,
}: SimpleControlsProps) {
const t = useTranslations("translator");
const { providerOptions } = useProviderOptions(provider);
const tr = useCallback(
(key: string, fallback: string): string => {
try {
const translated = t(key as Parameters<typeof t>[0]);
return translated === key || translated === `translator.${key}` ? fallback : translated;
} catch {
return fallback;
}
},
[t]
);
const examples = getExampleTemplates(t as (key: string) => string);
// Map provider string to a FormatId when a provider is selected
const providerToFormatId = useCallback((prov: string): FormatId => {
const normalized = prov.toLowerCase();
if (normalized.includes("gemini")) return "gemini";
if (normalized.includes("claude") || normalized.includes("anthropic")) return "claude";
if (normalized.includes("cursor")) return "cursor";
if (normalized.includes("kiro")) return "kiro";
if (normalized.includes("antigravity")) return "antigravity";
// Check FORMAT_META directly
const metaKeys = Object.keys(FORMAT_META) as FormatId[];
const exactMatch = metaKeys.find((k) => k === normalized);
if (exactMatch) return exactMatch;
return "openai";
}, []);
const handleProviderChange = useCallback(
(e: React.ChangeEvent<HTMLSelectElement>) => {
const prov = e.target.value;
onProviderChange(prov);
onTargetChange(providerToFormatId(prov));
},
[onProviderChange, onTargetChange, providerToFormatId]
);
const handleExampleChange = useCallback(
(e: React.ChangeEvent<HTMLSelectElement>) => {
const selectedId = e.target.value;
if (selectedId === "__custom__") {
onOpenAdvanced();
return;
}
const template = examples.find((ex) => ex.id === selectedId);
if (!template) return;
// Load template body for the current source format
const body =
template.formats[source] ??
template.formats["openai"] ??
Object.values(template.formats)[0];
if (body) {
onInputChange(JSON.stringify(body, null, 2));
}
},
[examples, source, onInputChange, onOpenAdvanced]
);
const modeOptions = [
{ value: "preview", label: tr("simpleModePreview", "Preview translation only") },
{ value: "send", label: tr("simpleModeSend", "Send and see response") },
];
const exampleSelectOptions = [
...examples.map((ex) => ({ value: ex.id, label: ex.name })),
{ value: "__custom__", label: tr("simpleStartWithCustomOption", "Paste your request (advanced)") },
];
const sourceOptions = FORMAT_OPTIONS;
return (
<div className="flex flex-col gap-4">
{/* Row 1: source format + provider (destination) */}
<div className="flex flex-col gap-3 sm:flex-row sm:items-start">
<div className="flex min-w-0 flex-1 flex-col gap-1">
<div className="flex items-center gap-1">
<span className="text-sm font-medium text-text-main">
{tr("simpleAppUsesLabel", "My app uses")}
</span>
<InfoTooltip text={tr("simpleAppUsesHint", "The API format your app speaks (e.g. Anthropic SDK = claude).")} />
</div>
<Select
aria-label={tr("simpleAppUsesLabel", "My app uses")}
options={sourceOptions}
value={source}
onChange={(e) => onSourceChange(e.target.value as FormatId)}
/>
</div>
<div className="hidden items-center pt-8 sm:flex">
<span className="material-symbols-outlined text-[20px] text-text-muted" aria-hidden="true">
arrow_forward
</span>
</div>
<div className="flex min-w-0 flex-1 flex-col gap-1">
<div className="flex items-center gap-1">
<span className="text-sm font-medium text-text-main">
{tr("simpleSendToLabel", "Send to")}
</span>
<InfoTooltip text={tr("simpleSendToHint", "Where to actually send the request (a provider connected in OmniRoute).")} />
</div>
<Select
aria-label={tr("simpleSendToLabel", "Send to")}
options={providerOptions.length > 0 ? providerOptions : [{ value: provider, label: provider }]}
value={provider}
onChange={handleProviderChange}
/>
</div>
</div>
{/* Row 2: example picker */}
<div className="flex flex-col gap-1">
<span className="text-sm font-medium text-text-main">
{tr("simpleStartWithLabel", "Start with")}
</span>
<Select
aria-label={tr("simpleStartWithLabel", "Start with")}
options={exampleSelectOptions}
value=""
onChange={handleExampleChange}
placeholder={tr("simpleStartWithExamplePlaceholder", "Select a ready-made example")}
/>
</div>
{/* Row 3: mode segmented control */}
<div className="flex flex-col gap-1">
<span className="text-sm font-medium text-text-main">
{tr("simpleModeLabel", "Mode")}
</span>
<SegmentedControl
options={modeOptions}
value={mode}
onChange={(v) => onModeChange(v as TranslateMode)}
aria-label={tr("simpleModeLabel", "Mode")}
/>
</div>
{/* Row 4: textarea */}
<div className="flex flex-col gap-1">
<span className="text-sm font-medium text-text-main">
{tr("simpleInputPanelTitle", "Input")}
</span>
<textarea
aria-label={tr("simpleInputPanelTitle", "Input")}
rows={6}
value={inputText}
onChange={(e) => onInputChange(e.target.value)}
placeholder={tr("simpleInputPanelHint", "Free-text message or ready-made example")}
className="w-full resize-y rounded-lg border border-black/10 bg-white px-3 py-2 font-mono text-sm text-text-main placeholder:text-text-muted focus:outline-none focus:ring-2 focus:ring-primary/40 dark:border-white/10 dark:bg-white/5"
/>
</div>
{/* Row 5: footer actions */}
<div className="flex items-center justify-between gap-3">
<Button
variant="primary"
onClick={onSubmit}
disabled={!inputText.trim() || isLoading}
loading={isLoading}
aria-label={tr("simpleModeSend", "Send and see response")}
>
{mode === "preview"
? tr("simpleModePreview", "Preview translation only")
: tr("simpleModeSend", "Send and see response")}
</Button>
<button
type="button"
onClick={onOpenAdvanced}
aria-label={tr("simpleAdvancedToggle", "Advanced")}
className="inline-flex items-center gap-1 rounded-md px-3 py-1.5 text-sm text-text-muted transition-colors hover:bg-black/5 hover:text-text-main dark:hover:bg-white/5"
>
<span className="material-symbols-outlined text-[16px]" aria-hidden="true">tune</span>
{tr("simpleAdvancedToggle", "Advanced")}
</button>
</div>
</div>
);
}

View File

@@ -0,0 +1,116 @@
"use client";
import { useState } from "react";
import { Card } from "@/shared/components";
import { useTranslateSession } from "../hooks/useTranslateSession";
import { useProviderOptions } from "../hooks/useProviderOptions";
import SimpleControls from "./SimpleControls";
import ResultNarrated from "./ResultNarrated";
import type { AdvancedSlug, FormatId, TranslateMode } from "../types";
interface TranslateTabProps {
/**
* F9 integration: tells TranslateTab to open a specific advanced accordion.
* When null, no accordion is forced open.
*/
forceOpenAdvancedSlug?: AdvancedSlug | null;
/**
* F9 integration: called when an advanced accordion slug should change
* (open or close). F9 syncs this with the URL query string.
*/
onAdvancedSlugChange?: (slug: AdvancedSlug | null) => void;
}
export default function TranslateTab({
forceOpenAdvancedSlug = null,
onAdvancedSlugChange,
}: TranslateTabProps) {
// Internal simple-mode state
const [source, setSource] = useState<FormatId>("claude");
const [inputText, setInputText] = useState<string>("");
const [mode, setMode] = useState<TranslateMode>("send");
// Provider/target state: derive from useProviderOptions
const { provider, setProvider, providerOptions } = useProviderOptions("openai");
// target FormatId mirrors provider selection; managed via SimpleControls callback
const [target, setTarget] = useState<FormatId>("openai");
const { result, run } = useTranslateSession();
const handleSubmit = () => {
run({ source, target, provider, inputText, mode });
};
const handleOpenAdvanced = (slug: AdvancedSlug = "rawjson") => {
if (onAdvancedSlugChange) {
onAdvancedSlugChange(slug);
}
// Scroll to advanced section if it exists (guard for environments without scrollIntoView)
const advancedEl = document.querySelector("[data-advanced-section]");
if (advancedEl && typeof advancedEl.scrollIntoView === "function") {
advancedEl.scrollIntoView({ behavior: "smooth", block: "start" });
}
};
const handleSeeTranslatedJson = () => {
handleOpenAdvanced("rawjson");
};
const handleSeePipeline = () => {
handleOpenAdvanced("pipeline");
};
// Expose forceOpenAdvancedSlug to the advanced section slot via data attribute
// F4 will read this; for now we write it to a data attribute that F9 will wire
const advancedSlug = forceOpenAdvancedSlug;
// Sync provider options: when providerOptions loads, keep provider in sync
// (useProviderOptions handles this internally; we just need to expose setProvider)
const handleProviderChange = (prov: string) => {
setProvider(prov);
};
return (
<div className="flex flex-col gap-6">
{/* 2-column grid: SimpleControls (left) + ResultNarrated (right) */}
<div className="grid grid-cols-1 gap-6 lg:grid-cols-2">
{/* Left: controls */}
<Card className="p-4">
<SimpleControls
source={source}
target={target}
provider={provider}
inputText={inputText}
mode={mode}
onSourceChange={setSource}
onTargetChange={setTarget}
onProviderChange={handleProviderChange}
onInputChange={setInputText}
onModeChange={setMode}
onSubmit={handleSubmit}
onOpenAdvanced={() => handleOpenAdvanced("rawjson")}
isLoading={result.status === "translating" || result.status === "sending"}
/>
</Card>
{/* Right: narrated result */}
<ResultNarrated
result={result}
onSeeTranslatedJson={handleSeeTranslatedJson}
onSeePipeline={handleSeePipeline}
/>
</div>
{/* Advanced section slot — F4 will mount AdvancedSection here.
F9 composes: <TranslateTab onAdvancedSlugChange={...} /> + <AdvancedSection forceOpenSlug={...} />.
For now we render the placeholder that F4/F9 will replace. */}
<div
data-advanced-section
data-force-open-slug={advancedSlug ?? ""}
data-provider-options={JSON.stringify(providerOptions.map((o) => o.value))}
data-source={source}
data-input-text={inputText.slice(0, 100)}
/>
</div>
);
}

View File

@@ -0,0 +1,367 @@
// @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 { TranslateNarratedResult } from "@/app/(dashboard)/dashboard/translator/types";
// --- Mock next-intl ---
vi.mock("next-intl", () => ({
useTranslations: () => (key: string, params?: Record<string, string | number>) => {
if (!params) return key;
return Object.entries(params).reduce(
(acc, [k, v]) => acc.replace(`{${k}}`, String(v)),
key
);
},
}));
// --- Mock shared components ---
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;
}) => (
<span data-testid="badge" data-variant={variant}>
{children}
</span>
),
Button: ({
children,
onClick,
"aria-label": ariaLabel,
}: {
children?: React.ReactNode;
onClick?: () => void;
"aria-label"?: string;
}) => (
<button data-testid="btn" onClick={onClick} aria-label={ariaLabel}>
{children}
</button>
),
}));
// --- Mock exampleTemplates (FORMAT_META) ---
vi.mock(
"@/app/(dashboard)/dashboard/translator/exampleTemplates",
() => ({
FORMAT_META: {
openai: { label: "OpenAI", color: "emerald", icon: "smart_toy" },
claude: { label: "Claude", color: "orange", icon: "psychology" },
gemini: { label: "Gemini", color: "blue", icon: "auto_awesome" },
},
FORMAT_OPTIONS: [],
getExampleTemplates: () => [],
})
);
// --- Setup ---
const cleanupCallbacks: Array<() => void> = [];
function makeContainer(): HTMLElement {
const container = document.createElement("div");
document.body.appendChild(container);
cleanupCallbacks.push(() => container.remove());
return container;
}
function idleResult(): TranslateNarratedResult {
return {
detected: null,
target: "openai",
status: "idle",
responsePreview: null,
translatedJson: null,
pipelinePath: null,
intermediateJson: null,
errorMessage: null,
latencyMs: null,
};
}
describe("ResultNarrated", () => {
beforeEach(() => {
(globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
});
afterEach(() => {
while (cleanupCallbacks.length > 0) cleanupCallbacks.pop()?.();
document.body.innerHTML = "";
vi.clearAllMocks();
});
it("exports a default function component", async () => {
const mod = await import(
"@/app/(dashboard)/dashboard/translator/components/ResultNarrated"
);
expect(typeof mod.default).toBe("function");
});
it("renders idle state without throwing", async () => {
const { default: ResultNarrated } = await import(
"@/app/(dashboard)/dashboard/translator/components/ResultNarrated"
);
const container = makeContainer();
const root = createRoot(container);
await act(async () => {
root.render(
<ResultNarrated
result={idleResult()}
onSeeTranslatedJson={vi.fn()}
onSeePipeline={vi.fn()}
/>
);
});
expect(container.querySelector("[data-testid='card']")).toBeTruthy();
});
it("idle state shows info icon", async () => {
const { default: ResultNarrated } = await import(
"@/app/(dashboard)/dashboard/translator/components/ResultNarrated"
);
const container = makeContainer();
const root = createRoot(container);
await act(async () => {
root.render(
<ResultNarrated
result={idleResult()}
onSeeTranslatedJson={vi.fn()}
onSeePipeline={vi.fn()}
/>
);
});
const icons = Array.from(container.querySelectorAll(".material-symbols-outlined")).map(
(el) => el.textContent?.trim()
);
expect(icons).toContain("info");
});
it("translating state shows spinner and translating text", async () => {
const { default: ResultNarrated } = await import(
"@/app/(dashboard)/dashboard/translator/components/ResultNarrated"
);
const container = makeContainer();
const root = createRoot(container);
const result: TranslateNarratedResult = { ...idleResult(), status: "translating", target: "gemini" };
await act(async () => {
root.render(
<ResultNarrated result={result} onSeeTranslatedJson={vi.fn()} onSeePipeline={vi.fn()} />
);
});
const icons = Array.from(container.querySelectorAll(".material-symbols-outlined")).map(
(el) => el.textContent?.trim()
);
expect(icons).toContain("progress_activity");
// Text should contain the i18n key with Gemini substituted
expect(container.textContent).toContain("Gemini");
});
it("sending state shows spinner and sending text", async () => {
const { default: ResultNarrated } = await import(
"@/app/(dashboard)/dashboard/translator/components/ResultNarrated"
);
const container = makeContainer();
const root = createRoot(container);
const result: TranslateNarratedResult = { ...idleResult(), status: "sending", target: "openai" };
await act(async () => {
root.render(
<ResultNarrated result={result} onSeeTranslatedJson={vi.fn()} onSeePipeline={vi.fn()} />
);
});
const icons = Array.from(container.querySelectorAll(".material-symbols-outlined")).map(
(el) => el.textContent?.trim()
);
expect(icons).toContain("progress_activity");
expect(container.textContent).toContain("OpenAI");
});
it("ok state shows success badge + narrated text + see pipeline button", async () => {
const { default: ResultNarrated } = await import(
"@/app/(dashboard)/dashboard/translator/components/ResultNarrated"
);
const container = makeContainer();
const root = createRoot(container);
const result: TranslateNarratedResult = {
...idleResult(),
status: "ok",
detected: "claude",
target: "openai",
latencyMs: 150,
translatedJson: '{"model":"gpt-4o"}',
responsePreview: "data: Hello",
};
await act(async () => {
root.render(
<ResultNarrated result={result} onSeeTranslatedJson={vi.fn()} onSeePipeline={vi.fn()} />
);
});
// Success badge should be present
const successBadge = container.querySelector("[data-testid='badge'][data-variant='success']");
expect(successBadge).toBeTruthy();
// Should contain detected format label
expect(container.textContent).toContain("Claude");
// See pipeline button must be present
const btns = Array.from(container.querySelectorAll("[data-testid='btn']"));
expect(btns.some((b) => b.getAttribute("aria-label")?.includes("pipeline"))).toBe(true);
});
it("ok state: 'see translated JSON' button calls onSeeTranslatedJson", async () => {
const { default: ResultNarrated } = await import(
"@/app/(dashboard)/dashboard/translator/components/ResultNarrated"
);
const container = makeContainer();
const root = createRoot(container);
const onSeeTranslatedJson = vi.fn();
const result: TranslateNarratedResult = {
...idleResult(),
status: "ok",
detected: "claude",
target: "openai",
latencyMs: 200,
translatedJson: '{"model":"gpt-4o"}',
responsePreview: null,
};
await act(async () => {
root.render(
<ResultNarrated
result={result}
onSeeTranslatedJson={onSeeTranslatedJson}
onSeePipeline={vi.fn()}
/>
);
});
const jsonBtn = container.querySelector(
"[data-testid='btn'][aria-label*='JSON'], [data-testid='btn'][aria-label*='json']"
) as HTMLButtonElement | null;
expect(jsonBtn).toBeTruthy();
await act(async () => {
jsonBtn?.click();
});
expect(onSeeTranslatedJson).toHaveBeenCalled();
});
it("ok state: 'see pipeline' button calls onSeePipeline", async () => {
const { default: ResultNarrated } = await import(
"@/app/(dashboard)/dashboard/translator/components/ResultNarrated"
);
const container = makeContainer();
const root = createRoot(container);
const onSeePipeline = vi.fn();
const result: TranslateNarratedResult = {
...idleResult(),
status: "ok",
detected: "openai",
target: "gemini",
latencyMs: 100,
translatedJson: null,
responsePreview: null,
};
await act(async () => {
root.render(
<ResultNarrated
result={result}
onSeeTranslatedJson={vi.fn()}
onSeePipeline={onSeePipeline}
/>
);
});
const pipelineBtn = container.querySelector(
"[data-testid='btn'][aria-label*='pipeline']"
) as HTMLButtonElement | null;
expect(pipelineBtn).toBeTruthy();
await act(async () => {
pipelineBtn?.click();
});
expect(onSeePipeline).toHaveBeenCalled();
});
it("error state shows error badge", async () => {
const { default: ResultNarrated } = await import(
"@/app/(dashboard)/dashboard/translator/components/ResultNarrated"
);
const container = makeContainer();
const root = createRoot(container);
const result: TranslateNarratedResult = {
...idleResult(),
status: "error",
errorMessage: "Connection refused",
};
await act(async () => {
root.render(
<ResultNarrated result={result} onSeeTranslatedJson={vi.fn()} onSeePipeline={vi.fn()} />
);
});
const errorBadge = container.querySelector("[data-testid='badge'][data-variant='error']");
expect(errorBadge).toBeTruthy();
});
it("SECURITY: error state with fake stack trace does NOT expose 'at /' in rendered text", async () => {
const { default: ResultNarrated } = await import(
"@/app/(dashboard)/dashboard/translator/components/ResultNarrated"
);
const container = makeContainer();
const root = createRoot(container);
// Simulate a message that already has trace-like content (belt-and-suspenders test)
const result: TranslateNarratedResult = {
...idleResult(),
status: "error",
errorMessage: "fake stack at /home/x.ts:1",
};
await act(async () => {
root.render(
<ResultNarrated result={result} onSeeTranslatedJson={vi.fn()} onSeePipeline={vi.fn()} />
);
});
const textContent = container.textContent ?? "";
// The safeErrorMessage function strips "at /path" patterns
expect(textContent).not.toMatch(/\bat\s\//);
});
it("SECURITY: error state does NOT leak Bearer tokens", async () => {
const { default: ResultNarrated } = await import(
"@/app/(dashboard)/dashboard/translator/components/ResultNarrated"
);
const container = makeContainer();
const root = createRoot(container);
const result: TranslateNarratedResult = {
...idleResult(),
status: "error",
errorMessage: "Unauthorized: Bearer sk-abc123XYZ456abcdef12",
};
await act(async () => {
root.render(
<ResultNarrated result={result} onSeeTranslatedJson={vi.fn()} onSeePipeline={vi.fn()} />
);
});
const textContent = container.textContent ?? "";
expect(textContent).not.toMatch(/sk-[A-Za-z0-9_-]{16,}/);
expect(textContent).toContain("[REDACTED]");
});
it("aria-live='polite' container is present for screen-reader announcements (D20)", async () => {
const { default: ResultNarrated } = await import(
"@/app/(dashboard)/dashboard/translator/components/ResultNarrated"
);
const container = makeContainer();
const root = createRoot(container);
await act(async () => {
root.render(
<ResultNarrated
result={idleResult()}
onSeeTranslatedJson={vi.fn()}
onSeePipeline={vi.fn()}
/>
);
});
const liveRegion = container.querySelector("[aria-live='polite']");
expect(liveRegion).toBeTruthy();
});
});

View File

@@ -0,0 +1,408 @@
// @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 { FormatId, TranslateMode } from "@/app/(dashboard)/dashboard/translator/types";
// --- Mock next-intl ---
vi.mock("next-intl", () => ({
useTranslations: () => (key: string) => key,
}));
// --- Mock shared components ---
vi.mock("@/shared/components", () => ({
Button: ({
children,
onClick,
disabled,
loading,
"aria-label": ariaLabel,
}: {
children?: React.ReactNode;
onClick?: () => void;
disabled?: boolean;
loading?: boolean;
"aria-label"?: string;
}) => (
<button
data-testid="button"
onClick={onClick}
disabled={disabled || loading}
aria-label={ariaLabel}
>
{children}
</button>
),
Select: ({
options = [],
value,
onChange,
placeholder,
"aria-label": ariaLabel,
}: {
options?: Array<{ value: string; label: string }>;
value?: string;
onChange?: (e: React.ChangeEvent<HTMLSelectElement>) => void;
placeholder?: string;
"aria-label"?: string;
}) => (
<select data-testid="select" value={value} onChange={onChange} aria-label={ariaLabel}>
{placeholder && <option value="">{placeholder}</option>}
{options.map((o) => (
<option key={o.value} value={o.value}>
{o.label}
</option>
))}
</select>
),
SegmentedControl: ({
options = [],
value,
onChange,
"aria-label": ariaLabel,
}: {
options?: Array<{ value: string; label: string }>;
value?: string;
onChange?: (v: string) => void;
"aria-label"?: string;
}) => (
<div data-testid="segmented-control" role="tablist" aria-label={ariaLabel}>
{options.map((o) => (
<button
key={o.value}
role="tab"
aria-selected={value === o.value}
onClick={() => onChange?.(o.value)}
data-value={o.value}
>
{o.label}
</button>
))}
</div>
),
InfoTooltip: ({ text }: { text: string }) => (
<span data-testid="info-tooltip" aria-label={text}>
info
</span>
),
}));
// --- Mock useProviderOptions ---
vi.mock(
"@/app/(dashboard)/dashboard/translator/hooks/useProviderOptions",
() => ({
useProviderOptions: () => ({
provider: "openai",
setProvider: vi.fn(),
providerOptions: [
{ value: "openai", label: "OpenAI" },
{ value: "anthropic", label: "Anthropic" },
],
loading: false,
}),
})
);
// --- Mock useAvailableModels ---
vi.mock(
"@/app/(dashboard)/dashboard/translator/hooks/useAvailableModels",
() => ({
useAvailableModels: () => ({
model: "gpt-4o",
setModel: vi.fn(),
availableModels: ["gpt-4o", "claude-sonnet-4-20250514"],
loading: false,
pickModelForFormat: () => "gpt-4o",
}),
})
);
// --- Mock exampleTemplates ---
vi.mock(
"@/app/(dashboard)/dashboard/translator/exampleTemplates",
() => ({
FORMAT_OPTIONS: [
{ value: "openai", label: "OpenAI" },
{ value: "claude", label: "Claude" },
{ value: "gemini", label: "Gemini" },
],
FORMAT_META: {
openai: { label: "OpenAI", color: "emerald", icon: "smart_toy" },
claude: { label: "Claude", color: "orange", icon: "psychology" },
gemini: { label: "Gemini", color: "blue", icon: "auto_awesome" },
},
getExampleTemplates: () => [
{
id: "simple-chat",
name: "Simple Chat",
icon: "chat",
description: "A simple chat example",
formats: {
openai: { model: "gpt-4o", messages: [{ role: "user", content: "Hello" }] },
claude: { model: "claude-sonnet-4-20250514", messages: [{ role: "user", content: "Hello" }] },
},
},
{
id: "tool-calling",
name: "Tool Calling",
icon: "build",
description: "Tool calling example",
formats: {
openai: { model: "gpt-4o", tools: [] },
},
},
],
})
);
// --- Setup ---
const cleanupCallbacks: Array<() => void> = [];
function makeContainer(): HTMLElement {
const container = document.createElement("div");
document.body.appendChild(container);
cleanupCallbacks.push(() => container.remove());
return container;
}
function makeProps(overrides: Partial<{
source: FormatId;
target: FormatId;
provider: string;
inputText: string;
mode: TranslateMode;
onSourceChange: (s: FormatId) => void;
onTargetChange: (t: FormatId) => void;
onProviderChange: (p: string) => void;
onInputChange: (text: string) => void;
onModeChange: (m: TranslateMode) => void;
onSubmit: () => void;
onOpenAdvanced: () => void;
isLoading: boolean;
}> = {}) {
return {
source: "claude" as FormatId,
target: "openai" as FormatId,
provider: "openai",
inputText: "",
mode: "send" as TranslateMode,
onSourceChange: vi.fn(),
onTargetChange: vi.fn(),
onProviderChange: vi.fn(),
onInputChange: vi.fn(),
onModeChange: vi.fn(),
onSubmit: vi.fn(),
onOpenAdvanced: vi.fn(),
isLoading: false,
...overrides,
};
}
describe("SimpleControls", () => {
beforeEach(() => {
(globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
});
afterEach(() => {
while (cleanupCallbacks.length > 0) cleanupCallbacks.pop()?.();
document.body.innerHTML = "";
vi.clearAllMocks();
});
it("exports a default function component", async () => {
const mod = await import(
"@/app/(dashboard)/dashboard/translator/components/SimpleControls"
);
expect(typeof mod.default).toBe("function");
});
it("renders smoke: mounts without throwing", async () => {
const { default: SimpleControls } = await import(
"@/app/(dashboard)/dashboard/translator/components/SimpleControls"
);
const container = makeContainer();
const root = createRoot(container);
const props = makeProps();
await act(async () => {
root.render(<SimpleControls {...props} />);
});
expect(container.innerHTML).not.toBe("");
});
it("renders 3 Select elements (source, provider, example) + 1 SegmentedControl", async () => {
const { default: SimpleControls } = await import(
"@/app/(dashboard)/dashboard/translator/components/SimpleControls"
);
const container = makeContainer();
const root = createRoot(container);
const props = makeProps();
await act(async () => {
root.render(<SimpleControls {...props} />);
});
const selects = container.querySelectorAll("[data-testid='select']");
expect(selects.length).toBeGreaterThanOrEqual(3);
const segmented = container.querySelectorAll("[data-testid='segmented-control']");
expect(segmented.length).toBe(1);
});
it("renders the submit button", async () => {
const { default: SimpleControls } = await import(
"@/app/(dashboard)/dashboard/translator/components/SimpleControls"
);
const container = makeContainer();
const root = createRoot(container);
const props = makeProps({ inputText: "Hello" });
await act(async () => {
root.render(<SimpleControls {...props} />);
});
const buttons = container.querySelectorAll("[data-testid='button']");
expect(buttons.length).toBeGreaterThanOrEqual(1);
});
it("calls onSourceChange when source select changes", async () => {
const { default: SimpleControls } = await import(
"@/app/(dashboard)/dashboard/translator/components/SimpleControls"
);
const container = makeContainer();
const root = createRoot(container);
const onSourceChange = vi.fn();
const props = makeProps({ onSourceChange });
await act(async () => {
root.render(<SimpleControls {...props} />);
});
// The first select is the source select (aria-label uses fallback "My app uses")
const sourceSelect = container.querySelector("select[aria-label='My app uses']") as HTMLSelectElement | null;
expect(sourceSelect).toBeTruthy();
await act(async () => {
if (sourceSelect) {
Object.defineProperty(sourceSelect, "value", { writable: true, value: "openai" });
sourceSelect.dispatchEvent(new Event("change", { bubbles: true }));
}
});
expect(onSourceChange).toHaveBeenCalled();
});
it("calls onModeChange when segmented control tab is clicked", async () => {
const { default: SimpleControls } = await import(
"@/app/(dashboard)/dashboard/translator/components/SimpleControls"
);
const container = makeContainer();
const root = createRoot(container);
const onModeChange = vi.fn();
const props = makeProps({ onModeChange, mode: "send" });
await act(async () => {
root.render(<SimpleControls {...props} />);
});
// Find the "preview" tab button in the segmented control
const previewTab = container.querySelector(
"[data-testid='segmented-control'] button[data-value='preview']"
) as HTMLButtonElement | null;
expect(previewTab).toBeTruthy();
await act(async () => {
previewTab?.click();
});
expect(onModeChange).toHaveBeenCalledWith("preview");
});
it("calls onInputChange when textarea content changes", async () => {
const { default: SimpleControls } = await import(
"@/app/(dashboard)/dashboard/translator/components/SimpleControls"
);
const container = makeContainer();
const root = createRoot(container);
const onInputChange = vi.fn();
const props = makeProps({ onInputChange });
await act(async () => {
root.render(<SimpleControls {...props} />);
});
const textarea = container.querySelector("textarea") as HTMLTextAreaElement | null;
expect(textarea).toBeTruthy();
await act(async () => {
if (textarea) {
Object.defineProperty(textarea, "value", { writable: true, value: "Hello world" });
textarea.dispatchEvent(new Event("change", { bubbles: true }));
}
});
expect(onInputChange).toHaveBeenCalled();
});
it("calls onOpenAdvanced when Advanced button is clicked", async () => {
const { default: SimpleControls } = await import(
"@/app/(dashboard)/dashboard/translator/components/SimpleControls"
);
const container = makeContainer();
const root = createRoot(container);
const onOpenAdvanced = vi.fn();
const props = makeProps({ onOpenAdvanced });
await act(async () => {
root.render(<SimpleControls {...props} />);
});
// Find the Advanced button (has aria-label fallback "Advanced")
const advancedBtn = container.querySelector(
"button[aria-label='Advanced']"
) as HTMLButtonElement | null;
expect(advancedBtn).toBeTruthy();
await act(async () => {
advancedBtn?.click();
});
expect(onOpenAdvanced).toHaveBeenCalled();
});
it("submit button is disabled when inputText is empty", async () => {
const { default: SimpleControls } = await import(
"@/app/(dashboard)/dashboard/translator/components/SimpleControls"
);
const container = makeContainer();
const root = createRoot(container);
const props = makeProps({ inputText: "" });
await act(async () => {
root.render(<SimpleControls {...props} />);
});
const submitBtn = container.querySelector(
"[data-testid='button']"
) as HTMLButtonElement | null;
expect(submitBtn?.disabled).toBe(true);
});
it("submit button is enabled when inputText has content", async () => {
const { default: SimpleControls } = await import(
"@/app/(dashboard)/dashboard/translator/components/SimpleControls"
);
const container = makeContainer();
const root = createRoot(container);
const props = makeProps({ inputText: "Hello" });
await act(async () => {
root.render(<SimpleControls {...props} />);
});
const submitBtn = container.querySelector(
"[data-testid='button']"
) as HTMLButtonElement | null;
expect(submitBtn?.disabled).toBe(false);
});
it("calls onOpenAdvanced when __custom__ example option is selected", async () => {
const { default: SimpleControls } = await import(
"@/app/(dashboard)/dashboard/translator/components/SimpleControls"
);
const container = makeContainer();
const root = createRoot(container);
const onOpenAdvanced = vi.fn();
const props = makeProps({ onOpenAdvanced });
await act(async () => {
root.render(<SimpleControls {...props} />);
});
// The example select has a __custom__ option (aria-label uses fallback "Start with")
const exampleSelect = container.querySelector(
"select[aria-label='Start with']"
) as HTMLSelectElement | null;
expect(exampleSelect).toBeTruthy();
await act(async () => {
if (exampleSelect) {
Object.defineProperty(exampleSelect, "value", { writable: true, value: "__custom__" });
exampleSelect.dispatchEvent(new Event("change", { bubbles: true }));
}
});
expect(onOpenAdvanced).toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,294 @@
// @vitest-environment jsdom
import React from "react";
import { act } from "react";
import { createRoot } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { AdvancedSlug } from "@/app/(dashboard)/dashboard/translator/types";
// --- Mock next-intl ---
vi.mock("next-intl", () => ({
useTranslations: () => (key: string) => key,
}));
// --- Mock next/navigation (used by deep-link hook, not by TranslateTab directly) ---
vi.mock("next/navigation", () => ({
useRouter: () => ({ replace: vi.fn() }),
useSearchParams: () => new URLSearchParams(),
}));
// --- Mock shared components ---
vi.mock("@/shared/components", () => ({
Card: ({ children, className }: { children: React.ReactNode; className?: string }) => (
<div data-testid="card" className={className}>{children}</div>
),
Button: ({ children, onClick, disabled, loading, "aria-label": ariaLabel }: {
children?: React.ReactNode;
onClick?: () => void;
disabled?: boolean;
loading?: boolean;
"aria-label"?: string;
}) => (
<button data-testid="button" onClick={onClick} disabled={disabled || loading} aria-label={ariaLabel}>
{children}
</button>
),
Select: ({ options = [], value, onChange, placeholder, "aria-label": ariaLabel }: {
options?: Array<{ value: string; label: string }>;
value?: string;
onChange?: (e: React.ChangeEvent<HTMLSelectElement>) => void;
placeholder?: string;
"aria-label"?: string;
}) => (
<select data-testid="select" value={value} onChange={onChange} aria-label={ariaLabel}>
{placeholder && <option value="">{placeholder}</option>}
{options.map((o) => (
<option key={o.value} value={o.value}>{o.label}</option>
))}
</select>
),
SegmentedControl: ({ options = [], value, onChange, "aria-label": ariaLabel }: {
options?: Array<{ value: string; label: string }>;
value?: string;
onChange?: (v: string) => void;
"aria-label"?: string;
}) => (
<div data-testid="segmented-control" role="tablist" aria-label={ariaLabel}>
{options.map((o) => (
<button key={o.value} role="tab" aria-selected={value === o.value} onClick={() => onChange?.(o.value)} data-value={o.value}>
{o.label}
</button>
))}
</div>
),
InfoTooltip: ({ text }: { text: string }) => <span aria-label={text}>i</span>,
Badge: ({ children, variant }: { children: React.ReactNode; variant?: string }) => (
<span data-testid="badge" data-variant={variant}>{children}</span>
),
}));
// --- Mock useProviderOptions ---
vi.mock(
"@/app/(dashboard)/dashboard/translator/hooks/useProviderOptions",
() => ({
useProviderOptions: () => ({
provider: "openai",
setProvider: vi.fn(),
providerOptions: [
{ value: "openai", label: "OpenAI" },
{ value: "anthropic", label: "Anthropic" },
],
loading: false,
}),
})
);
// --- Mock useAvailableModels ---
vi.mock(
"@/app/(dashboard)/dashboard/translator/hooks/useAvailableModels",
() => ({
useAvailableModels: () => ({
model: "gpt-4o",
setModel: vi.fn(),
availableModels: ["gpt-4o"],
loading: false,
pickModelForFormat: () => "gpt-4o",
}),
})
);
// --- Mock useTranslateSession ---
vi.mock(
"@/app/(dashboard)/dashboard/translator/hooks/useTranslateSession",
() => ({
useTranslateSession: () => ({
result: {
detected: null,
target: "openai",
status: "idle",
responsePreview: null,
translatedJson: null,
pipelinePath: null,
intermediateJson: null,
errorMessage: null,
latencyMs: null,
},
run: vi.fn(),
reset: vi.fn(),
}),
})
);
// --- Mock exampleTemplates ---
vi.mock(
"@/app/(dashboard)/dashboard/translator/exampleTemplates",
() => ({
FORMAT_OPTIONS: [
{ value: "openai", label: "OpenAI" },
{ value: "claude", label: "Claude" },
],
FORMAT_META: {
openai: { label: "OpenAI", color: "emerald", icon: "smart_toy" },
claude: { label: "Claude", color: "orange", icon: "psychology" },
},
getExampleTemplates: () => [
{
id: "simple-chat",
name: "Simple Chat",
icon: "chat",
description: "Chat example",
formats: { openai: { model: "gpt-4o", messages: [] } },
},
],
})
);
// --- Setup ---
const cleanupCallbacks: Array<() => void> = [];
function makeContainer(): HTMLElement {
const container = document.createElement("div");
document.body.appendChild(container);
cleanupCallbacks.push(() => container.remove());
return container;
}
describe("TranslateTab", () => {
beforeEach(() => {
(globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
});
afterEach(() => {
while (cleanupCallbacks.length > 0) cleanupCallbacks.pop()?.();
document.body.innerHTML = "";
vi.clearAllMocks();
});
it("exports a default function component", async () => {
const mod = await import(
"@/app/(dashboard)/dashboard/translator/components/TranslateTab"
);
expect(typeof mod.default).toBe("function");
});
it("renders smoke without throwing", async () => {
const { default: TranslateTab } = await import(
"@/app/(dashboard)/dashboard/translator/components/TranslateTab"
);
const container = makeContainer();
const root = createRoot(container);
await act(async () => {
root.render(<TranslateTab />);
});
expect(container.innerHTML).not.toBe("");
});
it("renders 2-column grid on desktop (has grid class)", async () => {
const { default: TranslateTab } = await import(
"@/app/(dashboard)/dashboard/translator/components/TranslateTab"
);
const container = makeContainer();
const root = createRoot(container);
await act(async () => {
root.render(<TranslateTab />);
});
// The grid div should exist with lg:grid-cols-2 class
const gridEl = container.querySelector(".grid");
expect(gridEl).toBeTruthy();
expect(gridEl?.className).toContain("lg:grid-cols-2");
});
it("renders the advanced section slot (data-advanced-section)", async () => {
const { default: TranslateTab } = await import(
"@/app/(dashboard)/dashboard/translator/components/TranslateTab"
);
const container = makeContainer();
const root = createRoot(container);
await act(async () => {
root.render(<TranslateTab />);
});
const advancedSlot = container.querySelector("[data-advanced-section]");
expect(advancedSlot).toBeTruthy();
});
it("calls onAdvancedSlugChange with 'rawjson' when the Advanced button is clicked", async () => {
const { default: TranslateTab } = await import(
"@/app/(dashboard)/dashboard/translator/components/TranslateTab"
);
const container = makeContainer();
const root = createRoot(container);
const onAdvancedSlugChange = vi.fn();
await act(async () => {
root.render(<TranslateTab onAdvancedSlugChange={onAdvancedSlugChange} />);
});
// Find the Advanced button by aria-label.
// SimpleControls uses tr("simpleAdvancedToggle", "Advanced"); with the i18n mock
// returning the key, tr() detects key===translated and returns the FALLBACK "Advanced".
const advancedBtn = container.querySelector(
"button[aria-label='Advanced']"
) as HTMLButtonElement | null;
expect(advancedBtn).toBeTruthy();
await act(async () => {
advancedBtn?.click();
});
expect(onAdvancedSlugChange).toHaveBeenCalledWith("rawjson");
});
it("reflects forceOpenAdvancedSlug in data attribute", async () => {
const { default: TranslateTab } = await import(
"@/app/(dashboard)/dashboard/translator/components/TranslateTab"
);
const container = makeContainer();
const root = createRoot(container);
const slug: AdvancedSlug = "pipeline";
await act(async () => {
root.render(
<TranslateTab forceOpenAdvancedSlug={slug} onAdvancedSlugChange={vi.fn()} />
);
});
const advancedSlot = container.querySelector("[data-advanced-section]");
expect(advancedSlot?.getAttribute("data-force-open-slug")).toBe("pipeline");
});
it("forceOpenAdvancedSlug=null results in empty data attribute", async () => {
const { default: TranslateTab } = await import(
"@/app/(dashboard)/dashboard/translator/components/TranslateTab"
);
const container = makeContainer();
const root = createRoot(container);
await act(async () => {
root.render(
<TranslateTab forceOpenAdvancedSlug={null} onAdvancedSlugChange={vi.fn()} />
);
});
const advancedSlot = container.querySelector("[data-advanced-section]");
expect(advancedSlot?.getAttribute("data-force-open-slug")).toBe("");
});
it("renders without onAdvancedSlugChange prop (optional)", async () => {
const { default: TranslateTab } = await import(
"@/app/(dashboard)/dashboard/translator/components/TranslateTab"
);
const container = makeContainer();
const root = createRoot(container);
// Should not throw
await act(async () => {
root.render(<TranslateTab />);
});
expect(container.innerHTML).not.toBe("");
});
it("renders both SimpleControls and ResultNarrated panels (2 Card children in grid)", async () => {
const { default: TranslateTab } = await import(
"@/app/(dashboard)/dashboard/translator/components/TranslateTab"
);
const container = makeContainer();
const root = createRoot(container);
await act(async () => {
root.render(<TranslateTab />);
});
// Grid should contain 2 direct Card children
const grid = container.querySelector(".grid");
const cards = grid?.querySelectorAll("[data-testid='card']");
expect(cards?.length).toBeGreaterThanOrEqual(2);
});
});