mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-07-26 09:52:11 +03:00
feat(translator): add TestBenchAccordion (F6)
- Refactor of TestBenchMode wrapped in Collapsible with lazy-render (D7) - Preserves 8 scenarios, runAll, per-scenario re-run, pass/fail badges, compatibility report - Reuses useProviderOptions + useAvailableModels (D12) - POST /api/translator/translate + /api/translator/send unchanged - Hard Rule #12: error display uses err.message only (no stack trace) - 17 Vitest tests: smoke render, lazy-render guard, Run All 8 fetches, results running→pass, per-scenario re-run, error sanitization
This commit is contained in:
@@ -0,0 +1,491 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useMemo } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import Collapsible from "@/shared/components/Collapsible";
|
||||
import { Card, Button, Select, Badge } from "@/shared/components";
|
||||
import { getExampleTemplates, FORMAT_META, FORMAT_OPTIONS } from "../../exampleTemplates";
|
||||
import { useProviderOptions } from "../../hooks/useProviderOptions";
|
||||
import { useAvailableModels } from "../../hooks/useAvailableModels";
|
||||
import type { AdvancedAccordionProps } from "../../types";
|
||||
|
||||
/**
|
||||
* TestBenchAccordion — Refactor of TestBenchMode wrapped in Collapsible.
|
||||
*
|
||||
* Preserves 100% functional parity with TestBenchMode.tsx:
|
||||
* - 8 scenarios (simple-chat, tool-calling, multi-turn, thinking, system-prompt,
|
||||
* streaming, vision, schema-coercion)
|
||||
* - runScenario: translate + send per scenario
|
||||
* - runAll: sequential execution of all 8
|
||||
* - per-scenario re-run
|
||||
* - pass/fail/running badges
|
||||
* - compatibility % report
|
||||
*
|
||||
* Wrapped in Collapsible with lazy-render guard (D7).
|
||||
* Reuses useProviderOptions("openai") + useAvailableModels() (D12).
|
||||
*/
|
||||
|
||||
const SCENARIOS = [
|
||||
{ id: "simple-chat", icon: "chat", templateId: "simple-chat" },
|
||||
{ id: "tool-calling", icon: "build", templateId: "tool-calling" },
|
||||
{ id: "multi-turn", icon: "forum", templateId: "multi-turn" },
|
||||
{ id: "thinking", icon: "psychology", templateId: "thinking" },
|
||||
{ id: "system-prompt", icon: "settings", templateId: "system-prompt" },
|
||||
{ id: "streaming", icon: "stream", templateId: "streaming" },
|
||||
{ id: "vision", icon: "image", templateId: "vision" },
|
||||
{ id: "schema-coercion", icon: "schema", templateId: "schema-coercion" },
|
||||
];
|
||||
|
||||
interface ScenarioResult {
|
||||
status: "running" | "pass" | "error";
|
||||
latency?: number;
|
||||
chunks?: number;
|
||||
error?: string;
|
||||
httpStatus?: number;
|
||||
}
|
||||
|
||||
type ResultsMap = Record<string, ScenarioResult>;
|
||||
|
||||
interface TestBenchAccordionProps extends Omit<AdvancedAccordionProps, "slug"> {
|
||||
forceOpen?: boolean;
|
||||
onOpenChange?: (open: boolean) => void;
|
||||
}
|
||||
|
||||
function TestBenchContent() {
|
||||
const t = useTranslations("translator");
|
||||
|
||||
const translateOrFallback = (key: string, fallback: string): string => {
|
||||
try {
|
||||
const translated = t(key);
|
||||
return translated === key || translated === `translator.${key}` ? fallback : translated;
|
||||
} catch {
|
||||
return fallback;
|
||||
}
|
||||
};
|
||||
|
||||
const scenarioLabels: Record<string, string> = {
|
||||
"simple-chat": t("scenarioSimpleChat"),
|
||||
"tool-calling": t("scenarioToolCalling"),
|
||||
"multi-turn": t("scenarioMultiTurn"),
|
||||
thinking: t("scenarioThinking"),
|
||||
"system-prompt": t("scenarioSystemPrompt"),
|
||||
streaming: t("scenarioStreaming"),
|
||||
vision: translateOrFallback("scenarioVision", "Vision"),
|
||||
"schema-coercion": translateOrFallback("scenarioSchemaCoercion", "Schema Coercion"),
|
||||
};
|
||||
|
||||
const templates = useMemo(() => getExampleTemplates(t), [t]);
|
||||
const [sourceFormat, setSourceFormat] = useState("claude");
|
||||
const { provider, setProvider, providerOptions } = useProviderOptions("openai");
|
||||
const { model, setModel, availableModels, pickModelForFormat } = useAvailableModels();
|
||||
const [results, setResults] = useState<ResultsMap>({});
|
||||
const [runningAll, setRunningAll] = useState(false);
|
||||
|
||||
// Pick a smart default model when source format changes or models finish loading
|
||||
useEffect(() => {
|
||||
const picked = pickModelForFormat(sourceFormat);
|
||||
if (picked) setModel(picked);
|
||||
}, [sourceFormat, pickModelForFormat, setModel]);
|
||||
|
||||
const runScenario = async (scenario: { id: string; icon: string; templateId: string }) => {
|
||||
setResults((prev) => ({ ...prev, [scenario.id]: { status: "running" } }));
|
||||
|
||||
const start = Date.now();
|
||||
try {
|
||||
// Find template
|
||||
const template = templates.find((item) => item.id === scenario.templateId);
|
||||
const formatKey = sourceFormat as keyof typeof template.formats;
|
||||
const body = template?.formats[formatKey] || template?.formats.openai;
|
||||
|
||||
if (!body) {
|
||||
setResults((prev) => ({
|
||||
...prev,
|
||||
[scenario.id]: {
|
||||
status: "error",
|
||||
error: t("noTemplateForFormat"),
|
||||
latency: 0,
|
||||
},
|
||||
}));
|
||||
return;
|
||||
}
|
||||
|
||||
// Override model in template body with user-selected model
|
||||
const bodyWithModel: Record<string, unknown> = { ...body, model };
|
||||
// For Gemini format that uses 'contents' instead of 'messages'
|
||||
if ((body as Record<string, unknown>).contents) bodyWithModel.model = model;
|
||||
|
||||
// Step 1: Translate
|
||||
const translateRes = await fetch("/api/translator/translate", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ step: "direct", sourceFormat, provider, body: bodyWithModel }),
|
||||
});
|
||||
const translateData = (await translateRes.json()) as {
|
||||
success: boolean;
|
||||
result?: Record<string, unknown>;
|
||||
error?: string;
|
||||
};
|
||||
|
||||
if (!translateData.success) {
|
||||
setResults((prev) => ({
|
||||
...prev,
|
||||
[scenario.id]: {
|
||||
status: "error",
|
||||
error: t("translationFailed", { error: translateData.error ?? "" }),
|
||||
latency: Date.now() - start,
|
||||
},
|
||||
}));
|
||||
return;
|
||||
}
|
||||
|
||||
// Step 2: Send to provider
|
||||
const sendRes = await fetch("/api/translator/send", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ provider, body: translateData.result }),
|
||||
});
|
||||
|
||||
const latency = Date.now() - start;
|
||||
|
||||
if (!sendRes.ok) {
|
||||
const errData = await sendRes.json().catch(() => ({})) as { error?: string };
|
||||
setResults((prev) => ({
|
||||
...prev,
|
||||
[scenario.id]: {
|
||||
status: "error",
|
||||
error: errData.error || t("errorMessage", { message: `HTTP ${sendRes.status}` }),
|
||||
latency,
|
||||
httpStatus: sendRes.status,
|
||||
},
|
||||
}));
|
||||
return;
|
||||
}
|
||||
|
||||
// Read response to consume stream
|
||||
const reader = sendRes.body?.getReader();
|
||||
let chunks = 0;
|
||||
if (reader) {
|
||||
while (true) {
|
||||
const { done } = await reader.read();
|
||||
if (done) break;
|
||||
chunks++;
|
||||
}
|
||||
}
|
||||
|
||||
setResults((prev) => ({
|
||||
...prev,
|
||||
[scenario.id]: { status: "pass", latency: Date.now() - start, chunks },
|
||||
}));
|
||||
} catch (err) {
|
||||
const errorMessage =
|
||||
err instanceof Error ? err.message : t("errorMessage", { message: "Unknown error" });
|
||||
setResults((prev) => ({
|
||||
...prev,
|
||||
[scenario.id]: { status: "error", error: errorMessage, latency: Date.now() - start },
|
||||
}));
|
||||
}
|
||||
};
|
||||
|
||||
const handleRunAll = async () => {
|
||||
setRunningAll(true);
|
||||
setResults({});
|
||||
for (const scenario of SCENARIOS) {
|
||||
await runScenario(scenario);
|
||||
}
|
||||
setRunningAll(false);
|
||||
};
|
||||
|
||||
const passCount = Object.values(results).filter((r) => r.status === "pass").length;
|
||||
const failCount = Object.values(results).filter((r) => r.status === "error").length;
|
||||
const totalRun = passCount + failCount;
|
||||
const compatibility = totalRun > 0 ? Math.round((passCount / totalRun) * 100) : 0;
|
||||
const srcMeta = FORMAT_META[sourceFormat as keyof typeof FORMAT_META] || FORMAT_META.openai;
|
||||
|
||||
return (
|
||||
<div className="space-y-5 min-w-0">
|
||||
{/* Info Banner */}
|
||||
<div className="flex items-start gap-3 px-4 py-3 rounded-lg bg-primary/5 border border-primary/10 text-sm text-text-muted">
|
||||
<span className="material-symbols-outlined text-primary text-[20px] mt-0.5 shrink-0">
|
||||
info
|
||||
</span>
|
||||
<div>
|
||||
<p className="font-medium text-text-main mb-0.5">{t("compatibilityTester")}</p>
|
||||
<p>{t("testBenchDescription")}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Controls */}
|
||||
<Card>
|
||||
<div className="p-4 flex flex-col gap-4">
|
||||
<div className="flex flex-col sm:flex-row items-end gap-4 min-w-0">
|
||||
<div className="flex-1 w-full">
|
||||
<label className="block text-xs font-medium text-text-muted mb-1.5 uppercase tracking-wider">
|
||||
{t("source")}
|
||||
</label>
|
||||
<Select
|
||||
value={sourceFormat}
|
||||
onChange={(e) => {
|
||||
setSourceFormat(e.target.value);
|
||||
setResults({});
|
||||
}}
|
||||
options={FORMAT_OPTIONS}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center justify-center px-2">
|
||||
<span className="material-symbols-outlined text-[22px] text-text-muted">
|
||||
arrow_forward
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex-1 w-full">
|
||||
<label className="block text-xs font-medium text-text-muted mb-1.5 uppercase tracking-wider">
|
||||
{t("targetProvider")}
|
||||
</label>
|
||||
<Select
|
||||
value={provider}
|
||||
onChange={(e) => {
|
||||
setProvider(e.target.value);
|
||||
setResults({});
|
||||
}}
|
||||
options={providerOptions}
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
icon="play_arrow"
|
||||
onClick={handleRunAll}
|
||||
loading={runningAll}
|
||||
disabled={runningAll}
|
||||
className="w-full sm:w-auto"
|
||||
>
|
||||
{t("runAllTests")}
|
||||
</Button>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-text-muted mb-1.5 uppercase tracking-wider">
|
||||
{t("model")}
|
||||
</label>
|
||||
<div className="relative">
|
||||
<input
|
||||
type="text"
|
||||
value={model}
|
||||
onChange={(e) => setModel(e.target.value)}
|
||||
list="testbench-acc-model-suggestions"
|
||||
placeholder={t("modelPlaceholder")}
|
||||
className="w-full bg-bg-subtle border border-border rounded-lg px-3 py-2 text-sm text-text-main placeholder:text-text-muted focus:outline-none focus:border-primary transition-colors"
|
||||
/>
|
||||
<datalist id="testbench-acc-model-suggestions">
|
||||
{availableModels.map((m) => (
|
||||
<option key={m} value={m} />
|
||||
))}
|
||||
</datalist>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Results summary bar */}
|
||||
{totalRun > 0 && (
|
||||
<Card>
|
||||
<div className="p-4">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<h3 className="text-sm font-semibold text-text-main">{t("compatibilityReport")}</h3>
|
||||
<Badge
|
||||
variant={
|
||||
compatibility >= 80 ? "success" : compatibility >= 50 ? "warning" : "error"
|
||||
}
|
||||
size="lg"
|
||||
>
|
||||
{compatibility}%
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="flex items-center gap-3 text-xs text-text-muted">
|
||||
<span className="flex items-center gap-1">
|
||||
<span className="size-2 rounded-full bg-green-500" /> {passCount} {t("passed")}
|
||||
</span>
|
||||
<span className="flex items-center gap-1">
|
||||
<span className="size-2 rounded-full bg-red-500" /> {failCount} {t("failed")}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
{/* Progress bar */}
|
||||
<div className="w-full h-2 bg-bg-subtle rounded-full overflow-hidden">
|
||||
<div
|
||||
className="h-full bg-gradient-to-r from-green-500 to-emerald-400 rounded-full transition-all duration-500"
|
||||
style={{ width: `${compatibility}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Scenario cards */}
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3">
|
||||
{SCENARIOS.map((scenario) => {
|
||||
const result = results[scenario.id];
|
||||
const isRunning = result?.status === "running";
|
||||
|
||||
return (
|
||||
<Card
|
||||
key={scenario.id}
|
||||
className={`transition-all ${
|
||||
result?.status === "pass"
|
||||
? "border-green-500/30"
|
||||
: result?.status === "error"
|
||||
? "border-red-500/30"
|
||||
: ""
|
||||
}`}
|
||||
>
|
||||
<div className="p-4 space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<div
|
||||
className={`flex items-center justify-center w-9 h-9 rounded-lg ${
|
||||
result?.status === "pass"
|
||||
? "bg-green-500/10 text-green-500"
|
||||
: result?.status === "error"
|
||||
? "bg-red-500/10 text-red-500"
|
||||
: "bg-bg-subtle text-text-muted"
|
||||
}`}
|
||||
>
|
||||
<span className="material-symbols-outlined text-[20px]">
|
||||
{isRunning
|
||||
? "progress_activity"
|
||||
: result?.status === "pass"
|
||||
? "check_circle"
|
||||
: result?.status === "error"
|
||||
? "error"
|
||||
: scenario.icon}
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-medium text-text-main">
|
||||
{scenarioLabels[scenario.id] || scenario.id}
|
||||
</p>
|
||||
<p className="text-[10px] text-text-muted uppercase">
|
||||
{srcMeta.label} →{" "}
|
||||
{providerOptions.find((o) => o.value === provider)?.label || provider}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Result details */}
|
||||
{result && result.status !== "running" && (
|
||||
<div
|
||||
className={`rounded-lg p-2 text-xs ${
|
||||
result.status === "pass"
|
||||
? "bg-green-500/5 text-green-600 dark:text-green-400"
|
||||
: "bg-red-500/5 text-red-600 dark:text-red-400"
|
||||
}`}
|
||||
>
|
||||
{result.status === "pass" ? (
|
||||
<div className="flex items-center justify-between">
|
||||
<span>{t("passedIconLabel")}</span>
|
||||
<span className="text-text-muted">
|
||||
{result.latency}ms • {result.chunks} {t("chunks")}
|
||||
</span>
|
||||
</div>
|
||||
) : (
|
||||
<div>
|
||||
<p>❌ {result.error}</p>
|
||||
<p className="text-text-muted mt-0.5">{result.latency}ms</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Button
|
||||
size="sm"
|
||||
variant={result?.status === "pass" ? "ghost" : "outline"}
|
||||
icon={isRunning ? "progress_activity" : "play_arrow"}
|
||||
onClick={() => runScenario(scenario)}
|
||||
disabled={isRunning || runningAll}
|
||||
className="w-full"
|
||||
aria-label={`${isRunning ? t("running") : result ? t("reRun") : t("runTest")} ${scenarioLabels[scenario.id] || scenario.id}`}
|
||||
>
|
||||
{isRunning ? t("running") : result ? t("reRun") : t("runTest")}
|
||||
</Button>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function TestBenchAccordion({
|
||||
forceOpen,
|
||||
onOpenChange,
|
||||
}: TestBenchAccordionProps) {
|
||||
const t = useTranslations("translator");
|
||||
|
||||
const translateOrFallback = (key: string, fallback: string): string => {
|
||||
try {
|
||||
const translated = t(key);
|
||||
return translated === key || translated === `translator.${key}` ? fallback : translated;
|
||||
} catch {
|
||||
return fallback;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Lazy-render guard (D7): Collapsible already gates children behind `open && ...`
|
||||
* so children are not rendered when closed. But once the user opens it the first
|
||||
* time, we want to keep TestBenchContent mounted even after re-closing (so state
|
||||
* like results/runningAll is preserved across open/close cycles).
|
||||
*
|
||||
* Strategy:
|
||||
* - `hasOpened` starts as `forceOpen ?? false`.
|
||||
* - We pass a sentinel as children when `!hasOpened`. Because Collapsible only
|
||||
* renders children when open=true, the sentinel mounts on first open → fires
|
||||
* onFirstOpen → `hasOpened` flips to true → TestBenchContent mounts and stays.
|
||||
* - When `hasOpened` is true, TestBenchContent renders inside Collapsible.
|
||||
* Collapsible hides it via CSS (via `open &&`) on close, but since hasOpened
|
||||
* is true, it will re-mount on next open with preserved state.
|
||||
*
|
||||
* Note: Collapsible does not expose onOpenChange, so we call `onOpenChange` prop
|
||||
* from the sentinel's mount (first open) and rely on it being optional.
|
||||
*/
|
||||
const [hasOpened, setHasOpened] = useState(forceOpen ?? false);
|
||||
|
||||
return (
|
||||
<Collapsible
|
||||
title={translateOrFallback("advancedTestBenchTitle", "Test Bench (8 cenários)")}
|
||||
subtitle={translateOrFallback(
|
||||
"advancedTestBenchSubtitle",
|
||||
"Roda todos os cenários e reporta pass/fail + compatibilidade %.",
|
||||
)}
|
||||
icon="science"
|
||||
defaultOpen={forceOpen ?? false}
|
||||
className="w-full"
|
||||
>
|
||||
{hasOpened ? (
|
||||
<TestBenchContent />
|
||||
) : (
|
||||
// Sentinel: Collapsible only renders children when open=true.
|
||||
// Mounting this means we just opened for the first time.
|
||||
<TestBenchAccordionLazyMount
|
||||
onFirstOpen={() => {
|
||||
setHasOpened(true);
|
||||
onOpenChange?.(true);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</Collapsible>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sentinel component for the lazy-render guard (D7).
|
||||
* Because Collapsible only renders children when open=true, mounting this
|
||||
* component signals the first open event. Calls onFirstOpen once on mount.
|
||||
*/
|
||||
function TestBenchAccordionLazyMount({ onFirstOpen }: { onFirstOpen: () => void }) {
|
||||
useEffect(() => {
|
||||
onFirstOpen();
|
||||
// Intentionally run only once on mount.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
return null;
|
||||
}
|
||||
811
tests/unit/translator-friendly-test-bench.test.tsx
Normal file
811
tests/unit/translator-friendly-test-bench.test.tsx
Normal file
@@ -0,0 +1,811 @@
|
||||
// @vitest-environment jsdom
|
||||
/**
|
||||
* Unit tests for TestBenchAccordion (F6).
|
||||
*
|
||||
* Covers:
|
||||
* - Smoke render (default closed — lazy-render guard)
|
||||
* - Lazy-render: content not mounted when accordion is closed
|
||||
* - forceOpen=true mounts content immediately
|
||||
* - "Run All" fires 8 sequential fetches (translate + send each)
|
||||
* - Results state transitions: running → pass
|
||||
* - Per-scenario re-run fires only that scenario's fetches
|
||||
* - Error display: error message shown without stack trace
|
||||
* - Error sanitization: stack trace patterns not leaked to UI
|
||||
*/
|
||||
import React from "react";
|
||||
import { act } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
// ── i18n stub ──────────────────────────────────────────────────────────────
|
||||
vi.mock("next-intl", () => ({
|
||||
useTranslations: () => (key: string, params?: Record<string, unknown>) => {
|
||||
// Return key-based human-readable labels for assertions
|
||||
if (key === "runAllTests") return "Run All Tests";
|
||||
if (key === "runTest") return "Run Test";
|
||||
if (key === "reRun") return "Re-Run";
|
||||
if (key === "running") return "Running...";
|
||||
if (key === "passed") return "passed";
|
||||
if (key === "failed") return "failed";
|
||||
if (key === "compatibilityReport") return "Compatibility Report";
|
||||
if (key === "passedIconLabel") return "✓ Passed";
|
||||
if (key === "chunks") return "chunks";
|
||||
if (key === "source") return "Source";
|
||||
if (key === "targetProvider") return "Target Provider";
|
||||
if (key === "model") return "Model";
|
||||
if (key === "modelPlaceholder") return "Enter model name";
|
||||
if (key === "compatibilityTester") return "Compatibility Tester";
|
||||
if (key === "testBenchDescription") return "Run translation scenarios";
|
||||
if (key === "noTemplateForFormat") return "No template for format";
|
||||
if (key === "translationFailed") return `Translation failed: ${params?.error ?? ""}`;
|
||||
if (key === "errorMessage") return `Error: ${params?.message ?? ""}`;
|
||||
if (key === "scenarioSimpleChat") return "Simple Chat";
|
||||
if (key === "scenarioToolCalling") return "Tool Calling";
|
||||
if (key === "scenarioMultiTurn") return "Multi-Turn";
|
||||
if (key === "scenarioThinking") return "Thinking";
|
||||
if (key === "scenarioSystemPrompt") return "System Prompt";
|
||||
if (key === "scenarioStreaming") return "Streaming";
|
||||
if (key === "advancedTestBenchTitle") return "Test Bench (8 cenários)";
|
||||
if (key === "advancedTestBenchSubtitle") return "Roda todos os cenários e reporta pass/fail + compatibilidade %.";
|
||||
return key;
|
||||
},
|
||||
}));
|
||||
|
||||
// ── Collapsible stub ────────────────────────────────────────────────────────
|
||||
// Renders children directly (open by default in tests, unless we override).
|
||||
// We expose a data attribute to let tests verify the title is passed.
|
||||
vi.mock("@/shared/components/Collapsible", () => ({
|
||||
default: ({
|
||||
children,
|
||||
title,
|
||||
subtitle,
|
||||
icon,
|
||||
defaultOpen,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
title?: React.ReactNode;
|
||||
subtitle?: React.ReactNode;
|
||||
icon?: string;
|
||||
defaultOpen?: boolean;
|
||||
className?: string;
|
||||
}) => (
|
||||
<div
|
||||
data-testid="collapsible"
|
||||
data-title={typeof title === "string" ? title : undefined}
|
||||
data-subtitle={typeof subtitle === "string" ? subtitle : undefined}
|
||||
data-icon={icon}
|
||||
data-default-open={defaultOpen ? "true" : "false"}
|
||||
>
|
||||
{defaultOpen !== false && children}
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
|
||||
// ── Shared components stubs ─────────────────────────────────────────────────
|
||||
vi.mock("@/shared/components", () => ({
|
||||
Card: ({
|
||||
children,
|
||||
className,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
}) => <div data-testid="card" className={className}>{children}</div>,
|
||||
|
||||
Button: ({
|
||||
children,
|
||||
onClick,
|
||||
disabled,
|
||||
loading,
|
||||
icon,
|
||||
"aria-label": ariaLabel,
|
||||
className,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
onClick?: () => void;
|
||||
disabled?: boolean;
|
||||
loading?: boolean;
|
||||
icon?: string;
|
||||
"aria-label"?: string;
|
||||
className?: string;
|
||||
size?: string;
|
||||
variant?: string;
|
||||
}) => (
|
||||
<button
|
||||
data-testid="button"
|
||||
data-icon={icon}
|
||||
disabled={disabled || loading}
|
||||
onClick={onClick}
|
||||
aria-label={ariaLabel}
|
||||
className={className}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
),
|
||||
|
||||
Select: ({
|
||||
value,
|
||||
onChange,
|
||||
options,
|
||||
}: {
|
||||
value: string;
|
||||
onChange: (e: { target: { value: string } }) => void;
|
||||
options: Array<{ value: string; label: string }>;
|
||||
}) => (
|
||||
<select
|
||||
data-testid="select"
|
||||
value={value}
|
||||
onChange={(e) => onChange({ target: { value: e.target.value } })}
|
||||
>
|
||||
{options.map((o) => (
|
||||
<option key={o.value} value={o.value}>
|
||||
{o.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
),
|
||||
|
||||
Badge: ({
|
||||
children,
|
||||
variant,
|
||||
size,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
variant?: string;
|
||||
size?: string;
|
||||
}) => (
|
||||
<span data-testid="badge" data-variant={variant} data-size={size}>
|
||||
{children}
|
||||
</span>
|
||||
),
|
||||
}));
|
||||
|
||||
// ── Hook stubs ──────────────────────────────────────────────────────────────
|
||||
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,
|
||||
}),
|
||||
}),
|
||||
);
|
||||
|
||||
vi.mock(
|
||||
"@/app/(dashboard)/dashboard/translator/hooks/useAvailableModels",
|
||||
() => ({
|
||||
useAvailableModels: () => ({
|
||||
model: "gpt-4o",
|
||||
setModel: vi.fn(),
|
||||
availableModels: ["gpt-4o", "gpt-3.5-turbo", "claude-sonnet-4-20250514"],
|
||||
loading: false,
|
||||
pickModelForFormat: (format: string) => {
|
||||
if (format === "claude") return "claude-sonnet-4-20250514";
|
||||
return "gpt-4o";
|
||||
},
|
||||
}),
|
||||
}),
|
||||
);
|
||||
|
||||
// ── exampleTemplates stub ───────────────────────────────────────────────────
|
||||
vi.mock(
|
||||
"@/app/(dashboard)/dashboard/translator/exampleTemplates",
|
||||
() => ({
|
||||
getExampleTemplates: () => [
|
||||
{
|
||||
id: "simple-chat",
|
||||
name: "Simple Chat",
|
||||
icon: "chat",
|
||||
description: "Simple chat",
|
||||
formats: {
|
||||
claude: { model: "claude-sonnet-4-20250514", messages: [{ role: "user", content: "Hello" }] },
|
||||
openai: { model: "gpt-4o", messages: [{ role: "user", content: "Hello" }] },
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "tool-calling",
|
||||
name: "Tool Calling",
|
||||
icon: "build",
|
||||
description: "Tool calling",
|
||||
formats: {
|
||||
openai: { model: "gpt-4o", messages: [{ role: "user", content: "Weather?" }] },
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "multi-turn",
|
||||
name: "Multi-Turn",
|
||||
icon: "forum",
|
||||
description: "Multi-turn",
|
||||
formats: {
|
||||
openai: { model: "gpt-4o", messages: [] },
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "thinking",
|
||||
name: "Thinking",
|
||||
icon: "psychology",
|
||||
description: "Thinking",
|
||||
formats: {
|
||||
openai: { model: "o3-mini", messages: [] },
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "system-prompt",
|
||||
name: "System Prompt",
|
||||
icon: "settings",
|
||||
description: "System prompt",
|
||||
formats: {
|
||||
openai: { model: "gpt-4o", messages: [] },
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "streaming",
|
||||
name: "Streaming",
|
||||
icon: "stream",
|
||||
description: "Streaming",
|
||||
formats: {
|
||||
openai: { model: "gpt-4o", messages: [] },
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "vision",
|
||||
name: "Vision",
|
||||
icon: "image",
|
||||
description: "Vision",
|
||||
formats: {
|
||||
openai: { model: "gpt-4o", messages: [] },
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "schema-coercion",
|
||||
name: "Schema Coercion",
|
||||
icon: "schema",
|
||||
description: "Schema coercion",
|
||||
formats: {
|
||||
openai: { model: "gpt-4o", messages: [] },
|
||||
},
|
||||
},
|
||||
],
|
||||
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: [
|
||||
{ value: "openai", label: "OpenAI" },
|
||||
{ value: "claude", label: "Claude" },
|
||||
{ value: "gemini", label: "Gemini" },
|
||||
],
|
||||
}),
|
||||
);
|
||||
|
||||
// ── Helpers ─────────────────────────────────────────────────────────────────
|
||||
|
||||
const cleanupCallbacks: Array<() => void> = [];
|
||||
|
||||
function makeContainer(): HTMLElement {
|
||||
const container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
cleanupCallbacks.push(() => container.remove());
|
||||
return container;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a mock fetch that returns success for translate + a readable stream for send.
|
||||
*/
|
||||
function makeFetchMock(opts: {
|
||||
translateOk?: boolean;
|
||||
sendOk?: boolean;
|
||||
translateError?: string;
|
||||
sendHttpStatus?: number;
|
||||
} = {}) {
|
||||
const { translateOk = true, sendOk = true, translateError, sendHttpStatus = 200 } = opts;
|
||||
|
||||
return vi.fn().mockImplementation((url: string) => {
|
||||
if ((url as string).includes("/api/translator/translate")) {
|
||||
return Promise.resolve({
|
||||
ok: true,
|
||||
json: () =>
|
||||
Promise.resolve(
|
||||
translateOk
|
||||
? { success: true, result: { model: "gpt-4o", messages: [] } }
|
||||
: { success: false, error: translateError ?? "translate error" },
|
||||
),
|
||||
});
|
||||
}
|
||||
if ((url as string).includes("/api/translator/send")) {
|
||||
if (!sendOk) {
|
||||
return Promise.resolve({
|
||||
ok: false,
|
||||
status: sendHttpStatus,
|
||||
json: () => Promise.resolve({ error: `HTTP ${sendHttpStatus}` }),
|
||||
body: null,
|
||||
});
|
||||
}
|
||||
// Readable stream with 2 chunks
|
||||
const encoder = new TextEncoder();
|
||||
let step = 0;
|
||||
const readable = new ReadableStream({
|
||||
pull(controller) {
|
||||
if (step === 0) {
|
||||
controller.enqueue(encoder.encode("data: chunk1\n\n"));
|
||||
step++;
|
||||
} else {
|
||||
controller.close();
|
||||
}
|
||||
},
|
||||
});
|
||||
return Promise.resolve({
|
||||
ok: true,
|
||||
status: 200,
|
||||
body: readable,
|
||||
json: () => Promise.resolve({}),
|
||||
});
|
||||
}
|
||||
return Promise.reject(new Error(`Unexpected fetch: ${url}`));
|
||||
});
|
||||
}
|
||||
|
||||
// ── Tests ────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe("TestBenchAccordion", () => {
|
||||
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.restoreAllMocks();
|
||||
});
|
||||
|
||||
// ── Module export ──────────────────────────────────────────────────────────
|
||||
|
||||
it("exports a default function component", async () => {
|
||||
const mod = await import(
|
||||
"@/app/(dashboard)/dashboard/translator/components/advanced/TestBenchAccordion"
|
||||
);
|
||||
expect(typeof mod.default).toBe("function");
|
||||
});
|
||||
|
||||
// ── Smoke render (closed by default) ─────────────────────────────────────
|
||||
|
||||
it("renders Collapsible with correct title and icon when defaultOpen=false", async () => {
|
||||
const { default: TestBenchAccordion } = await import(
|
||||
"@/app/(dashboard)/dashboard/translator/components/advanced/TestBenchAccordion"
|
||||
);
|
||||
const container = makeContainer();
|
||||
const root = createRoot(container);
|
||||
await act(async () => {
|
||||
root.render(<TestBenchAccordion />);
|
||||
});
|
||||
const collapsible = container.querySelector("[data-testid='collapsible']");
|
||||
expect(collapsible).toBeTruthy();
|
||||
expect(collapsible?.getAttribute("data-icon")).toBe("science");
|
||||
// defaultOpen=false means content not rendered (lazy-render guard)
|
||||
expect(collapsible?.getAttribute("data-default-open")).toBe("false");
|
||||
});
|
||||
|
||||
// ── Lazy-render guard ──────────────────────────────────────────────────────
|
||||
|
||||
it("does not render scenario cards when defaultOpen is false (lazy-render guard)", async () => {
|
||||
const { default: TestBenchAccordion } = await import(
|
||||
"@/app/(dashboard)/dashboard/translator/components/advanced/TestBenchAccordion"
|
||||
);
|
||||
const container = makeContainer();
|
||||
const root = createRoot(container);
|
||||
await act(async () => {
|
||||
root.render(<TestBenchAccordion />);
|
||||
});
|
||||
// When Collapsible stub renders with defaultOpen=false, children are suppressed
|
||||
const cards = container.querySelectorAll("[data-testid='card']");
|
||||
expect(cards.length).toBe(0);
|
||||
});
|
||||
|
||||
// ── forceOpen renders content immediately ─────────────────────────────────
|
||||
|
||||
it("renders TestBenchContent immediately when forceOpen=true", async () => {
|
||||
const { default: TestBenchAccordion } = await import(
|
||||
"@/app/(dashboard)/dashboard/translator/components/advanced/TestBenchAccordion"
|
||||
);
|
||||
const container = makeContainer();
|
||||
const root = createRoot(container);
|
||||
await act(async () => {
|
||||
root.render(<TestBenchAccordion forceOpen={true} />);
|
||||
});
|
||||
// Should have rendered cards (info banner + controls + 8 scenarios)
|
||||
const cards = container.querySelectorAll("[data-testid='card']");
|
||||
expect(cards.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("Collapsible gets defaultOpen=true when forceOpen=true", async () => {
|
||||
const { default: TestBenchAccordion } = await import(
|
||||
"@/app/(dashboard)/dashboard/translator/components/advanced/TestBenchAccordion"
|
||||
);
|
||||
const container = makeContainer();
|
||||
const root = createRoot(container);
|
||||
await act(async () => {
|
||||
root.render(<TestBenchAccordion forceOpen={true} />);
|
||||
});
|
||||
const collapsible = container.querySelector("[data-testid='collapsible']");
|
||||
expect(collapsible?.getAttribute("data-default-open")).toBe("true");
|
||||
});
|
||||
|
||||
// ── Controls render ───────────────────────────────────────────────────────
|
||||
|
||||
it("renders source select, provider select, and Run All button when open", async () => {
|
||||
const { default: TestBenchAccordion } = await import(
|
||||
"@/app/(dashboard)/dashboard/translator/components/advanced/TestBenchAccordion"
|
||||
);
|
||||
const container = makeContainer();
|
||||
const root = createRoot(container);
|
||||
await act(async () => {
|
||||
root.render(<TestBenchAccordion forceOpen={true} />);
|
||||
});
|
||||
const selects = container.querySelectorAll("[data-testid='select']");
|
||||
expect(selects.length).toBeGreaterThanOrEqual(2);
|
||||
|
||||
const buttons = container.querySelectorAll("[data-testid='button']");
|
||||
const runAllBtn = Array.from(buttons).find((b) => b.textContent?.includes("Run All"));
|
||||
expect(runAllBtn).toBeTruthy();
|
||||
});
|
||||
|
||||
it("renders 8 scenario buttons (one per scenario) when open", async () => {
|
||||
const { default: TestBenchAccordion } = await import(
|
||||
"@/app/(dashboard)/dashboard/translator/components/advanced/TestBenchAccordion"
|
||||
);
|
||||
const container = makeContainer();
|
||||
const root = createRoot(container);
|
||||
await act(async () => {
|
||||
root.render(<TestBenchAccordion forceOpen={true} />);
|
||||
});
|
||||
const buttons = container.querySelectorAll("[data-testid='button']");
|
||||
// 1 Run All + 8 scenario Run Test buttons
|
||||
const runTestBtns = Array.from(buttons).filter((b) =>
|
||||
b.textContent?.includes("Run Test") || b.textContent?.includes("Re-Run")
|
||||
);
|
||||
expect(runTestBtns.length).toBe(8);
|
||||
});
|
||||
|
||||
// ── Run All fires 8 fetches (translate + send each) ───────────────────────
|
||||
|
||||
it("clicking Run All fires 8 translate + 8 send fetches sequentially", async () => {
|
||||
const fetchMock = makeFetchMock();
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
const { default: TestBenchAccordion } = await import(
|
||||
"@/app/(dashboard)/dashboard/translator/components/advanced/TestBenchAccordion"
|
||||
);
|
||||
const container = makeContainer();
|
||||
const root = createRoot(container);
|
||||
await act(async () => {
|
||||
root.render(<TestBenchAccordion forceOpen={true} />);
|
||||
});
|
||||
|
||||
const buttons = container.querySelectorAll("[data-testid='button']");
|
||||
const runAllBtn = Array.from(buttons).find((b) => b.textContent?.includes("Run All")) as
|
||||
| HTMLButtonElement
|
||||
| undefined;
|
||||
expect(runAllBtn).toBeTruthy();
|
||||
|
||||
await act(async () => {
|
||||
runAllBtn?.click();
|
||||
});
|
||||
|
||||
const translateCalls = fetchMock.mock.calls.filter((c) =>
|
||||
(c[0] as string).includes("/api/translator/translate"),
|
||||
);
|
||||
const sendCalls = fetchMock.mock.calls.filter((c) =>
|
||||
(c[0] as string).includes("/api/translator/send"),
|
||||
);
|
||||
// 8 scenarios × 1 translate each
|
||||
expect(translateCalls.length).toBe(8);
|
||||
// 8 scenarios × 1 send each (translate succeeded for all)
|
||||
expect(sendCalls.length).toBe(8);
|
||||
});
|
||||
|
||||
// ── Results state: running → pass ──────────────────────────────────────────
|
||||
|
||||
it("results map updates from running to pass after Run All completes", async () => {
|
||||
const fetchMock = makeFetchMock();
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
const { default: TestBenchAccordion } = await import(
|
||||
"@/app/(dashboard)/dashboard/translator/components/advanced/TestBenchAccordion"
|
||||
);
|
||||
const container = makeContainer();
|
||||
const root = createRoot(container);
|
||||
await act(async () => {
|
||||
root.render(<TestBenchAccordion forceOpen={true} />);
|
||||
});
|
||||
|
||||
const buttons = container.querySelectorAll("[data-testid='button']");
|
||||
const runAllBtn = Array.from(buttons).find((b) => b.textContent?.includes("Run All")) as
|
||||
| HTMLButtonElement
|
||||
| undefined;
|
||||
|
||||
await act(async () => {
|
||||
runAllBtn?.click();
|
||||
});
|
||||
|
||||
// After completion, scenario buttons should show "Re-Run" (result exists)
|
||||
const reRunBtns = Array.from(
|
||||
container.querySelectorAll("[data-testid='button']"),
|
||||
).filter((b) => b.textContent?.includes("Re-Run"));
|
||||
// All 8 should show re-run
|
||||
expect(reRunBtns.length).toBe(8);
|
||||
});
|
||||
|
||||
it("compatibility report badge appears after Run All completes", async () => {
|
||||
const fetchMock = makeFetchMock();
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
const { default: TestBenchAccordion } = await import(
|
||||
"@/app/(dashboard)/dashboard/translator/components/advanced/TestBenchAccordion"
|
||||
);
|
||||
const container = makeContainer();
|
||||
const root = createRoot(container);
|
||||
await act(async () => {
|
||||
root.render(<TestBenchAccordion forceOpen={true} />);
|
||||
});
|
||||
|
||||
const buttons = container.querySelectorAll("[data-testid='button']");
|
||||
const runAllBtn = Array.from(buttons).find((b) => b.textContent?.includes("Run All")) as
|
||||
| HTMLButtonElement
|
||||
| undefined;
|
||||
|
||||
await act(async () => {
|
||||
runAllBtn?.click();
|
||||
});
|
||||
|
||||
// Compatibility Report section should be visible
|
||||
const text = container.textContent ?? "";
|
||||
expect(text).toContain("Compatibility Report");
|
||||
// Badge with percentage
|
||||
const badges = container.querySelectorAll("[data-testid='badge']");
|
||||
expect(badges.length).toBeGreaterThan(0);
|
||||
const badgeTexts = Array.from(badges).map((b) => b.textContent?.trim());
|
||||
const hasPercent = badgeTexts.some((t) => t?.includes("%"));
|
||||
expect(hasPercent).toBe(true);
|
||||
});
|
||||
|
||||
// ── Per-scenario re-run ───────────────────────────────────────────────────
|
||||
|
||||
it("clicking re-run on one scenario fires only that scenario's fetches", async () => {
|
||||
const fetchMock = makeFetchMock();
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
const { default: TestBenchAccordion } = await import(
|
||||
"@/app/(dashboard)/dashboard/translator/components/advanced/TestBenchAccordion"
|
||||
);
|
||||
const container = makeContainer();
|
||||
const root = createRoot(container);
|
||||
await act(async () => {
|
||||
root.render(<TestBenchAccordion forceOpen={true} />);
|
||||
});
|
||||
|
||||
// Run All first to populate results
|
||||
const buttons = container.querySelectorAll("[data-testid='button']");
|
||||
const runAllBtn = Array.from(buttons).find((b) => b.textContent?.includes("Run All")) as
|
||||
| HTMLButtonElement
|
||||
| undefined;
|
||||
await act(async () => {
|
||||
runAllBtn?.click();
|
||||
});
|
||||
|
||||
const callCountAfterAll = fetchMock.mock.calls.length;
|
||||
// Each scenario: 1 translate + 1 send = 2 calls; 8 scenarios = 16 total
|
||||
expect(callCountAfterAll).toBe(16);
|
||||
|
||||
// Now click Re-Run on first scenario
|
||||
const reRunBtns = Array.from(
|
||||
container.querySelectorAll("[data-testid='button']"),
|
||||
).filter((b) => b.textContent?.includes("Re-Run")) as HTMLButtonElement[];
|
||||
expect(reRunBtns.length).toBeGreaterThan(0);
|
||||
|
||||
await act(async () => {
|
||||
reRunBtns[0]?.click();
|
||||
});
|
||||
|
||||
// Should have added exactly 2 more calls (1 translate + 1 send)
|
||||
const callCountAfterRerun = fetchMock.mock.calls.length;
|
||||
expect(callCountAfterRerun).toBe(callCountAfterAll + 2);
|
||||
});
|
||||
|
||||
// ── Error display sanitized ───────────────────────────────────────────────
|
||||
|
||||
it("displays error without stack trace when translate fails", async () => {
|
||||
const fetchMock = makeFetchMock({ translateOk: false, translateError: "Invalid format" });
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
const { default: TestBenchAccordion } = await import(
|
||||
"@/app/(dashboard)/dashboard/translator/components/advanced/TestBenchAccordion"
|
||||
);
|
||||
const container = makeContainer();
|
||||
const root = createRoot(container);
|
||||
await act(async () => {
|
||||
root.render(<TestBenchAccordion forceOpen={true} />);
|
||||
});
|
||||
|
||||
// Run first scenario only
|
||||
const buttons = container.querySelectorAll("[data-testid='button']");
|
||||
const firstRunBtn = Array.from(buttons).find((b) =>
|
||||
b.textContent?.includes("Run Test"),
|
||||
) as HTMLButtonElement | undefined;
|
||||
await act(async () => {
|
||||
firstRunBtn?.click();
|
||||
});
|
||||
|
||||
const text = container.textContent ?? "";
|
||||
// Error should be visible
|
||||
expect(text).toContain("❌");
|
||||
// Stack trace must NOT be exposed (Hard Rule #12)
|
||||
expect(text).not.toMatch(/\sat\s\//);
|
||||
expect(text).not.toMatch(/Error: .+\.tsx?:\d+/);
|
||||
});
|
||||
|
||||
it("displays error without stack trace when send fails with non-ok HTTP", async () => {
|
||||
const fetchMock = makeFetchMock({ translateOk: true, sendOk: false, sendHttpStatus: 503 });
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
const { default: TestBenchAccordion } = await import(
|
||||
"@/app/(dashboard)/dashboard/translator/components/advanced/TestBenchAccordion"
|
||||
);
|
||||
const container = makeContainer();
|
||||
const root = createRoot(container);
|
||||
await act(async () => {
|
||||
root.render(<TestBenchAccordion forceOpen={true} />);
|
||||
});
|
||||
|
||||
const buttons = container.querySelectorAll("[data-testid='button']");
|
||||
const firstRunBtn = Array.from(buttons).find((b) =>
|
||||
b.textContent?.includes("Run Test"),
|
||||
) as HTMLButtonElement | undefined;
|
||||
await act(async () => {
|
||||
firstRunBtn?.click();
|
||||
});
|
||||
|
||||
const text = container.textContent ?? "";
|
||||
expect(text).toContain("❌");
|
||||
// No stack trace
|
||||
expect(text).not.toMatch(/\sat\s\//);
|
||||
});
|
||||
|
||||
it("displays error without stack trace when fetch throws (network error)", async () => {
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn().mockImplementation((url: string) => {
|
||||
if ((url as string).includes("/api/translator/translate")) {
|
||||
return Promise.reject(new Error("Network error"));
|
||||
}
|
||||
return Promise.reject(new Error("Unexpected"));
|
||||
}),
|
||||
);
|
||||
|
||||
const { default: TestBenchAccordion } = await import(
|
||||
"@/app/(dashboard)/dashboard/translator/components/advanced/TestBenchAccordion"
|
||||
);
|
||||
const container = makeContainer();
|
||||
const root = createRoot(container);
|
||||
await act(async () => {
|
||||
root.render(<TestBenchAccordion forceOpen={true} />);
|
||||
});
|
||||
|
||||
const buttons = container.querySelectorAll("[data-testid='button']");
|
||||
const firstRunBtn = Array.from(buttons).find((b) =>
|
||||
b.textContent?.includes("Run Test"),
|
||||
) as HTMLButtonElement | undefined;
|
||||
await act(async () => {
|
||||
firstRunBtn?.click();
|
||||
});
|
||||
|
||||
const text = container.textContent ?? "";
|
||||
expect(text).toContain("❌");
|
||||
// No stack trace (err.message used, not err.stack)
|
||||
expect(text).not.toMatch(/\sat\s\//);
|
||||
// Should contain the sanitized error message
|
||||
expect(text).toContain("Network error");
|
||||
});
|
||||
|
||||
// ── onOpenChange callback ─────────────────────────────────────────────────
|
||||
|
||||
it("calls onOpenChange(true) when accordion opens for the first time", async () => {
|
||||
const onOpenChange = vi.fn();
|
||||
const { default: TestBenchAccordion } = await import(
|
||||
"@/app/(dashboard)/dashboard/translator/components/advanced/TestBenchAccordion"
|
||||
);
|
||||
const container = makeContainer();
|
||||
const root = createRoot(container);
|
||||
// forceOpen=true triggers content mount → sentinel fires onFirstOpen → onOpenChange(true)
|
||||
await act(async () => {
|
||||
root.render(<TestBenchAccordion forceOpen={true} onOpenChange={onOpenChange} />);
|
||||
});
|
||||
// hasOpened starts as true when forceOpen=true, so sentinel doesn't render.
|
||||
// onOpenChange is not called in this path.
|
||||
// Test the default-closed path where sentinel fires:
|
||||
const container2 = makeContainer();
|
||||
const root2 = createRoot(container2);
|
||||
// Reset: render closed, then open via sentinel
|
||||
await act(async () => {
|
||||
root2.render(<TestBenchAccordion onOpenChange={onOpenChange} />);
|
||||
});
|
||||
// Default is closed, so no sentinel fires yet
|
||||
expect(onOpenChange).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// ── Translate POST body shape ──────────────────────────────────────────────
|
||||
|
||||
it("sends correct body to /api/translator/translate with step=direct", async () => {
|
||||
const fetchMock = makeFetchMock();
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
const { default: TestBenchAccordion } = await import(
|
||||
"@/app/(dashboard)/dashboard/translator/components/advanced/TestBenchAccordion"
|
||||
);
|
||||
const container = makeContainer();
|
||||
const root = createRoot(container);
|
||||
await act(async () => {
|
||||
root.render(<TestBenchAccordion forceOpen={true} />);
|
||||
});
|
||||
|
||||
const buttons = container.querySelectorAll("[data-testid='button']");
|
||||
const firstRunBtn = Array.from(buttons).find((b) =>
|
||||
b.textContent?.includes("Run Test"),
|
||||
) as HTMLButtonElement | undefined;
|
||||
await act(async () => {
|
||||
firstRunBtn?.click();
|
||||
});
|
||||
|
||||
const translateCall = fetchMock.mock.calls.find((c) =>
|
||||
(c[0] as string).includes("/api/translator/translate"),
|
||||
);
|
||||
expect(translateCall).toBeTruthy();
|
||||
const bodyStr = (translateCall?.[1] as RequestInit)?.body as string;
|
||||
const body = JSON.parse(bodyStr);
|
||||
expect(body.step).toBe("direct");
|
||||
expect(typeof body.sourceFormat).toBe("string");
|
||||
expect(typeof body.provider).toBe("string");
|
||||
expect(typeof body.body).toBe("object");
|
||||
});
|
||||
|
||||
// ── translate:send POST body shape ────────────────────────────────────────
|
||||
|
||||
it("sends translated result to /api/translator/send", async () => {
|
||||
const fetchMock = makeFetchMock();
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
const { default: TestBenchAccordion } = await import(
|
||||
"@/app/(dashboard)/dashboard/translator/components/advanced/TestBenchAccordion"
|
||||
);
|
||||
const container = makeContainer();
|
||||
const root = createRoot(container);
|
||||
await act(async () => {
|
||||
root.render(<TestBenchAccordion forceOpen={true} />);
|
||||
});
|
||||
|
||||
const buttons = container.querySelectorAll("[data-testid='button']");
|
||||
const firstRunBtn = Array.from(buttons).find((b) =>
|
||||
b.textContent?.includes("Run Test"),
|
||||
) as HTMLButtonElement | undefined;
|
||||
await act(async () => {
|
||||
firstRunBtn?.click();
|
||||
});
|
||||
|
||||
const sendCall = fetchMock.mock.calls.find((c) =>
|
||||
(c[0] as string).includes("/api/translator/send"),
|
||||
);
|
||||
expect(sendCall).toBeTruthy();
|
||||
const bodyStr = (sendCall?.[1] as RequestInit)?.body as string;
|
||||
const body = JSON.parse(bodyStr);
|
||||
expect(typeof body.provider).toBe("string");
|
||||
expect(typeof body.body).toBe("object");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user