mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-07-26 09:52:11 +03:00
Merge F3 (SimpleControls/ResultNarrated/TranslateTab) into refactor/pages-v3-19
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
367
tests/unit/translator-friendly-result-narrated.test.tsx
Normal file
367
tests/unit/translator-friendly-result-narrated.test.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
408
tests/unit/translator-friendly-simple-controls.test.tsx
Normal file
408
tests/unit/translator-friendly-simple-controls.test.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
294
tests/unit/translator-friendly-translate-tab.test.tsx
Normal file
294
tests/unit/translator-friendly-translate-tab.test.tsx
Normal 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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user