refactor(i18n): migrate hardcoded UI strings to next-intl t() in playground + search-tools

Closes Gap 2 from code review #2: ExportCodeModal, BuildTab, PresetPicker,
StudioTopBar, SearchToolsTopBar, SearchConceptCard and ProviderCatalog now
use useTranslations() from next-intl for all user-facing strings.

The F9 i18n PR (566ebb953) added the keys but several legacy hardcoded
strings still slipped through. This change wires them up and fixes
pt-BR translations that were leaking English ("Cancel" → "Cancelar",
"Save" → "Salvar", "Copy" → "Copiar", "Clear All" → "Limpar tudo", etc.).

New keys added in both pt-BR.json and en.json under the `playground`
namespace:
- close, closeExportModal, exportShort
- exportRealKeyWarning, placeholderHintPrefix, placeholderHintSuffix
- copyLangCode (with {language} placeholder)
- loadingPresets, loadPresetPlaceholder

Test updates:
- 7 UI test files: mocks updated for next-intl (useTranslations) and
  assertions updated to match the i18n key-as-text returned by the mock.
- PlaygroundStudio test: 2 tests previously checked for a "F7 implementation
  pending" placeholder which no longer exists (F7+F9 fully implemented those
  tabs) — assertions now verify the tab becomes active instead.
- PlaygroundConfigPane test: endpoint select count bumped from 10 to 13
  (D4-rev2) with a more robust selector that finds the endpoint select
  regardless of PresetPicker's load-preset select position.

Validation:
- npm run typecheck:core: clean
- npm run lint: 0 errors (warnings pre-existing)
- npx vitest run tests/unit/ui: 245 tests pass (26 files)
- node --test tests/unit/playground-*.test.ts tests/unit/search-tools-*.test.ts
  tests/unit/db-playground-presets.test.ts: 122 tests pass
This commit is contained in:
diegosouzapw
2026-05-28 17:20:10 -03:00
parent c89d27ada6
commit a9a663cc40
15 changed files with 200 additions and 134 deletions

View File

@@ -3,6 +3,7 @@
// src/app/(dashboard)/dashboard/playground/components/ExportCodeModal.tsx
import { useState, useEffect, useCallback } from "react";
import { useTranslations } from "next-intl";
import type { PlaygroundState, ExportLanguage } from "@/lib/playground/codeExport";
import { exportAllLanguages, API_KEY_PLACEHOLDER } from "@/lib/playground/codeExport";
@@ -23,6 +24,7 @@ const LANGUAGE_TABS: Array<{ id: ExportLanguage; label: string }> = [
* Security: always uses API_KEY_PLACEHOLDER ("$OMNIROUTE_API_KEY") — never a real key (D11 / Hard Rule #1).
*/
export default function ExportCodeModal({ state, onClose }: ExportCodeModalProps) {
const t = useTranslations("playground");
const [activeLanguage, setActiveLanguage] = useState<ExportLanguage>("curl");
const [copied, setCopied] = useState(false);
@@ -63,7 +65,7 @@ export default function ExportCodeModal({ state, onClose }: ExportCodeModalProps
onClick={onClose}
role="dialog"
aria-modal="true"
aria-label="Export code"
aria-label={t("exportCode")}
>
<div
className="bg-surface border border-border rounded-xl w-[640px] max-w-[96vw] max-h-[80vh] flex flex-col shadow-2xl"
@@ -73,12 +75,12 @@ export default function ExportCodeModal({ state, onClose }: ExportCodeModalProps
<div className="flex items-center justify-between px-5 py-3.5 border-b border-border shrink-0">
<div className="flex items-center gap-2">
<span className="font-mono text-sm text-text-muted">&lt;/&gt;</span>
<h2 className="text-sm font-semibold text-text-main">Export code</h2>
<h2 className="text-sm font-semibold text-text-main">{t("exportCodeTitle")}</h2>
</div>
<button
onClick={onClose}
className="p-1 rounded text-text-muted hover:text-text-main hover:bg-black/5 dark:hover:bg-white/5 transition-colors"
aria-label="Close export modal"
aria-label={t("closeExportModal")}
>
<span className="material-symbols-outlined text-[18px]">close</span>
</button>
@@ -110,8 +112,7 @@ export default function ExportCodeModal({ state, onClose }: ExportCodeModalProps
<div className="flex-1 overflow-y-auto px-4 pt-3 pb-4 min-h-0">
{hasRealKey ? (
<div className="text-xs text-destructive bg-destructive/10 rounded p-3">
Security warning: export blocked a real API key was detected in the output.
Please reset your API key and try again.
{t("exportRealKeyWarning")}
</div>
) : (
<pre className="text-xs font-mono text-text-main bg-bg-alt border border-border rounded-lg p-4 overflow-x-auto whitespace-pre-wrap break-all">
@@ -121,9 +122,9 @@ export default function ExportCodeModal({ state, onClose }: ExportCodeModalProps
{/* Placeholder hint */}
<p className="text-[11px] text-text-muted mt-2">
Replace{" "}
{t("placeholderHintPrefix")}{" "}
<code className="font-mono text-primary">{API_KEY_PLACEHOLDER}</code>
{" "}with your actual API key, or set it as an environment variable.
{" "}{t("placeholderHintSuffix")}
</p>
</div>
@@ -133,7 +134,7 @@ export default function ExportCodeModal({ state, onClose }: ExportCodeModalProps
onClick={onClose}
className="text-xs px-3 py-1.5 rounded border border-border text-text-muted hover:text-text-main hover:bg-black/5 dark:hover:bg-white/5 transition-colors"
>
Close
{t("close")}
</button>
<button
onClick={handleCopy}
@@ -143,12 +144,12 @@ export default function ExportCodeModal({ state, onClose }: ExportCodeModalProps
? "border-green-500 text-green-500 bg-green-500/10"
: "border-primary text-primary hover:bg-primary/10"
} disabled:opacity-40 disabled:cursor-not-allowed`}
aria-label={`Copy ${activeLanguage} code`}
aria-label={t("copyLangCode", { language: activeLanguage })}
>
<span className="material-symbols-outlined text-[14px]">
{copied ? "check" : "content_copy"}
</span>
{copied ? "Copied!" : "Copy"}
{copied ? t("copiedCode") : t("copy")}
</button>
</div>
</div>

View File

@@ -3,6 +3,7 @@
// src/app/(dashboard)/dashboard/playground/components/PresetPicker.tsx
import { useEffect, useState } from "react";
import { useTranslations } from "next-intl";
import { usePresets } from "../hooks/usePresets";
import type { ConfigState } from "./StudioConfigPane";
@@ -18,6 +19,7 @@ interface PresetPickerProps {
* Load applies preset values to configState; Save opens a name-input modal.
*/
export default function PresetPicker({ configState, setConfigState }: PresetPickerProps) {
const t = useTranslations("playground");
const { presets, loading, list, create, remove } = usePresets();
const [saveModalOpen, setSaveModalOpen] = useState(false);
const [presetName, setPresetName] = useState("");
@@ -47,7 +49,7 @@ export default function PresetPicker({ configState, setConfigState }: PresetPick
async function handleSave() {
if (!presetName.trim()) {
setSaveError("Name is required");
setSaveError(t("nameRequired"));
return;
}
@@ -65,7 +67,7 @@ export default function PresetPicker({ configState, setConfigState }: PresetPick
setSaving(false);
if (result == null) {
setSaveError("Failed to save preset");
setSaveError(t("failedToSavePreset"));
return;
}
@@ -89,7 +91,7 @@ export default function PresetPicker({ configState, setConfigState }: PresetPick
<>
<div className="flex flex-col gap-2">
<span className="text-xs font-medium text-text-muted uppercase tracking-wider">
Presets
{t("presetsLabel")}
</span>
{/* Load preset select */}
@@ -105,10 +107,10 @@ export default function PresetPicker({ configState, setConfigState }: PresetPick
}}
defaultValue=""
className="flex-1 text-xs bg-surface border border-border rounded px-2 py-1.5 focus:outline-none focus:ring-1 focus:ring-primary text-text-main disabled:opacity-50 disabled:cursor-not-allowed"
aria-label="Load a preset"
aria-label={t("loadPreset")}
>
<option value="" disabled>
{loading ? "Loading…" : presets.length === 0 ? "No presets" : "Load preset…"}
{loading ? t("loadingPresets") : presets.length === 0 ? t("noPresets") : t("loadPresetPlaceholder")}
</option>
{presets.map((preset) => (
<option key={preset.id} value={preset.id}>
@@ -120,9 +122,9 @@ export default function PresetPicker({ configState, setConfigState }: PresetPick
<button
onClick={openSave}
className="text-xs px-2.5 py-1.5 rounded border border-border text-text-muted hover:text-text-main hover:bg-black/5 dark:hover:bg-white/5 transition-colors shrink-0"
aria-label="Save current config as preset"
aria-label={t("savePreset")}
>
Save
{t("save")}
</button>
</div>
@@ -162,13 +164,13 @@ export default function PresetPicker({ configState, setConfigState }: PresetPick
onClick={closeSave}
role="dialog"
aria-modal="true"
aria-label="Save preset"
aria-label={t("savePreset")}
>
<div
className="bg-surface border border-border rounded-xl p-5 w-80 shadow-2xl"
onClick={(e) => e.stopPropagation()}
>
<h3 className="text-sm font-semibold text-text-main mb-4">Save preset</h3>
<h3 className="text-sm font-semibold text-text-main mb-4">{t("savePreset")}</h3>
<div className="flex flex-col gap-3">
<input
@@ -179,7 +181,7 @@ export default function PresetPicker({ configState, setConfigState }: PresetPick
if (e.key === "Enter") void handleSave();
if (e.key === "Escape") closeSave();
}}
placeholder="Preset name"
placeholder={t("presetNamePlaceholder")}
autoFocus
className="text-xs bg-bg-alt border border-border rounded px-2 py-1.5 focus:outline-none focus:ring-1 focus:ring-primary text-text-main"
/>
@@ -193,14 +195,14 @@ export default function PresetPicker({ configState, setConfigState }: PresetPick
onClick={closeSave}
className="text-xs px-3 py-1.5 rounded border border-border text-text-muted hover:text-text-main transition-colors"
>
Cancel
{t("cancel")}
</button>
<button
onClick={() => void handleSave()}
disabled={saving}
className="text-xs px-3 py-1.5 rounded bg-primary text-white hover:bg-primary/90 transition-colors disabled:opacity-50"
>
{saving ? "Saving…" : "Save"}
{saving ? t("savingPreset") : t("save")}
</button>
</div>
</div>

View File

@@ -3,6 +3,7 @@
// src/app/(dashboard)/dashboard/playground/components/StudioTopBar.tsx
import { useState } from "react";
import { useTranslations } from "next-intl";
import TokenCostCounter from "./TokenCostCounter";
import ExportCodeModal from "./ExportCodeModal";
import type { StreamMetrics } from "@/shared/schemas/playground";
@@ -20,15 +21,15 @@ interface StudioTopBarProps {
interface TabConfig {
id: StudioTab;
label: string;
labelKey: "tabChat" | "tabCompare" | "tabApi" | "tabBuild";
icon: string;
}
const TABS: TabConfig[] = [
{ id: "chat", label: "Chat", icon: "chat" },
{ id: "compare", label: "Compare", icon: "compare" },
{ id: "api", label: "API", icon: "api" },
{ id: "build", label: "Build", icon: "build" },
{ id: "chat", labelKey: "tabChat", icon: "chat" },
{ id: "compare", labelKey: "tabCompare", icon: "compare" },
{ id: "api", labelKey: "tabApi", icon: "api" },
{ id: "build", labelKey: "tabBuild", icon: "build" },
];
/**
@@ -36,6 +37,7 @@ const TABS: TabConfig[] = [
* Export code modal uses ExportCodeModal (F7) when exportState is provided.
*/
export default function StudioTopBar({ activeTab, onTabChange, metrics, exportState }: StudioTopBarProps) {
const t = useTranslations("playground");
const [exportOpen, setExportOpen] = useState(false);
return (
@@ -56,7 +58,7 @@ export default function StudioTopBar({ activeTab, onTabChange, metrics, exportSt
}`}
>
<span className="material-symbols-outlined text-[16px]">{tab.icon}</span>
<span>{tab.label}</span>
<span>{t(tab.labelKey)}</span>
</button>
))}
</div>
@@ -72,11 +74,11 @@ export default function StudioTopBar({ activeTab, onTabChange, metrics, exportSt
<button
onClick={() => setExportOpen(true)}
className="flex items-center gap-1 text-xs px-2.5 py-1.5 rounded border border-border hover:bg-black/5 dark:hover:bg-white/5 text-text-muted hover:text-text-main transition-colors"
title="Export code"
aria-label="Export code"
title={t("exportCode")}
aria-label={t("exportCode")}
>
<span className="font-mono text-[11px]">&lt;/&gt;</span>
<span>Export</span>
<span>{t("exportShort")}</span>
</button>
</div>
</div>
@@ -95,17 +97,17 @@ export default function StudioTopBar({ activeTab, onTabChange, metrics, exportSt
onClick={(e) => e.stopPropagation()}
>
<div className="flex items-center justify-between mb-4">
<h2 className="text-sm font-semibold text-text-main">Export code</h2>
<h2 className="text-sm font-semibold text-text-main">{t("exportCodeTitle")}</h2>
<button
onClick={() => setExportOpen(false)}
className="text-text-muted hover:text-text-main"
aria-label="Close export modal"
aria-label={t("closeExportModal")}
>
<span className="material-symbols-outlined text-[18px]">close</span>
</button>
</div>
<p className="text-sm text-text-muted">
No playground state available to export.
{t("noStateToExport")}
</p>
</div>
</div>

View File

@@ -3,6 +3,7 @@
// src/app/(dashboard)/dashboard/playground/components/tabs/BuildTab.tsx
import { useRef, useState } from "react";
import { useTranslations } from "next-intl";
import { useToolsBuilder } from "../../hooks/useToolsBuilder";
import { useStructuredOutput } from "../../hooks/useStructuredOutput";
import ToolsBuilder from "../ToolsBuilder";
@@ -45,6 +46,7 @@ interface ToolResultDraft {
* for the tool_result + "Send result" button to continue the conversation.
*/
export default function BuildTab({ configState }: BuildTabProps) {
const t = useTranslations("playground");
const toolsBuilder = useToolsBuilder();
const structuredOutput = useStructuredOutput();
@@ -243,7 +245,7 @@ export default function BuildTab({ configState }: BuildTabProps) {
className="flex items-center gap-1.5 text-xs px-3 py-1.5 rounded bg-primary text-white hover:bg-primary/90 transition-colors disabled:opacity-40 disabled:cursor-not-allowed"
>
<span className="material-symbols-outlined text-[14px]">play_arrow</span>
{running ? "Running" : "Run"}
{running ? t("running") : t("runLabel")}
</button>
{messages.length > 0 && (
@@ -251,7 +253,7 @@ export default function BuildTab({ configState }: BuildTabProps) {
onClick={clearConversation}
className="text-xs px-2.5 py-1.5 rounded border border-border text-text-muted hover:text-text-main hover:bg-black/5 dark:hover:bg-white/5 transition-colors"
>
Clear
{t("clearAll")}
</button>
)}

View File

@@ -2,6 +2,7 @@
import { useState, useEffect } from "react";
import Link from "next/link";
import { useTranslations } from "next-intl";
import type { SearchProviderCatalogItem } from "@/shared/schemas/searchTools";
interface ProviderCatalogProps {
@@ -11,6 +12,7 @@ interface ProviderCatalogProps {
}
function StatusBadge({ status }: { status: SearchProviderCatalogItem["status"] }) {
const t = useTranslations("search");
if (status === "configured") {
return (
<span
@@ -18,7 +20,7 @@ function StatusBadge({ status }: { status: SearchProviderCatalogItem["status"] }
data-testid="status-configured"
>
<span className="w-1.5 h-1.5 rounded-full bg-success inline-block" aria-hidden="true" />
Configurado
{t("statusConfigured")}
</span>
);
}
@@ -29,7 +31,7 @@ function StatusBadge({ status }: { status: SearchProviderCatalogItem["status"] }
data-testid="status-rate-limited"
>
<span className="w-1.5 h-1.5 rounded-full bg-warning inline-block" aria-hidden="true" />
Rate limited
{t("statusRateLimited")}
</span>
);
}
@@ -39,7 +41,7 @@ function StatusBadge({ status }: { status: SearchProviderCatalogItem["status"] }
data-testid="status-missing"
>
<span className="w-1.5 h-1.5 rounded-full bg-border inline-block" aria-hidden="true" />
Sem credencial
{t("statusMissing")}
</span>
);
}
@@ -53,6 +55,7 @@ function ProviderCard({
selected: boolean;
onSelect?: () => void;
}) {
const t = useTranslations("search");
const isClickable = item.status !== "missing" && onSelect;
return (
@@ -80,7 +83,7 @@ function ProviderCard({
<div>
<div className="text-xs font-semibold text-text-main">{item.name}</div>
<div className="text-[10px] text-text-muted mt-0.5">
{item.kind === "search" ? "Search" : "Fetch/Scrape"}
{item.kind === "search" ? t("kindSearch") : t("kindFetch")}
</div>
</div>
<StatusBadge status={item.status} />

View File

@@ -1,39 +1,33 @@
"use client";
import { useState } from "react";
import { useTranslations } from "next-intl";
type ConceptKey = "search" | "scrape" | "compare" | "rerank" | "auto";
interface ConceptItem {
icon: string;
title: string;
description: string;
key: ConceptKey;
titleKey:
| "searchConceptTitle"
| "scrapeConceptTitle"
| "compareConceptTitle"
| "rerankConceptTitle"
| "autoConceptTitle";
descKey:
| "searchConceptDesc"
| "scrapeConceptDesc"
| "compareConceptDesc"
| "rerankConceptDesc"
| "autoConceptDesc";
}
const CONCEPTS: ConceptItem[] = [
{
icon: "🔍",
title: "Search",
description: "Search = busca na web (lista de links com título, URL, snippet e score de relevância)",
},
{
icon: "📄",
title: "Scrape",
description: "Scrape = extrai o conteúdo completo de uma URL (markdown, texto ou HTML)",
},
{
icon: "⚖",
title: "Compare",
description: "Compare = executa a mesma query em N providers side-by-side para comparar latência, custo e overlap de resultados",
},
{
icon: "↕",
title: "Rerank",
description: "Rerank = reordena resultados via LLM para melhorar relevância baseada na query",
},
{
icon: "⚡",
title: "Auto (cheapest)",
description: "Auto (cheapest) = escolhe automaticamente o provider mais barato disponível e com credenciais configuradas",
},
{ icon: "🔍", key: "search", titleKey: "searchConceptTitle", descKey: "searchConceptDesc" },
{ icon: "📄", key: "scrape", titleKey: "scrapeConceptTitle", descKey: "scrapeConceptDesc" },
{ icon: "⚖", key: "compare", titleKey: "compareConceptTitle", descKey: "compareConceptDesc" },
{ icon: "↕", key: "rerank", titleKey: "rerankConceptTitle", descKey: "rerankConceptDesc" },
{ icon: "⚡", key: "auto", titleKey: "autoConceptTitle", descKey: "autoConceptDesc" },
];
interface SearchConceptCardProps {
@@ -42,6 +36,7 @@ interface SearchConceptCardProps {
}
export default function SearchConceptCard({ defaultCollapsed = false }: SearchConceptCardProps) {
const t = useTranslations("search");
const [collapsed, setCollapsed] = useState(defaultCollapsed);
return (
@@ -58,7 +53,7 @@ export default function SearchConceptCard({ defaultCollapsed = false }: SearchCo
>
<span className="text-xs font-semibold text-text-muted uppercase tracking-wider flex items-center gap-2">
<span></span>
<span>Guia de modalidades</span>
<span>{t("modalitiesGuide")}</span>
</span>
<span className="text-text-muted text-xs" aria-hidden="true">
{collapsed ? "▶" : "▼"}
@@ -74,16 +69,16 @@ export default function SearchConceptCard({ defaultCollapsed = false }: SearchCo
>
{CONCEPTS.map((c) => (
<div
key={c.title}
key={c.key}
className="flex gap-3 p-3 bg-bg-alt rounded-lg border border-border"
data-testid={`concept-item-${c.title.toLowerCase()}`}
data-testid={`concept-item-${c.key}`}
>
<span className="text-lg shrink-0" aria-hidden="true">
{c.icon}
</span>
<div>
<div className="text-xs font-semibold text-text-main mb-0.5">{c.title}</div>
<div className="text-[11px] text-text-muted leading-relaxed">{c.description}</div>
<div className="text-xs font-semibold text-text-main mb-0.5">{t(c.titleKey)}</div>
<div className="text-[11px] text-text-muted leading-relaxed">{t(c.descKey)}</div>
</div>
</div>
))}

View File

@@ -1,6 +1,7 @@
"use client";
import { useState } from "react";
import { useTranslations } from "next-intl";
import ExportCodeModal from "@/app/(dashboard)/dashboard/playground/components/ExportCodeModal";
import type { PlaygroundState } from "@/lib/playground/codeExport";
@@ -14,10 +15,10 @@ interface SearchToolsTopBarProps {
exportState?: PlaygroundState;
}
const TABS: { id: ActiveTab; icon: string; label: string }[] = [
{ id: "search", icon: "🔍", label: "Search" },
{ id: "scrape", icon: "📄", label: "Scrape" },
{ id: "compare", icon: "⚖", label: "Compare" },
const TABS: { id: ActiveTab; icon: string; labelKey: "tabSearch" | "tabScrape" | "tabCompare" }[] = [
{ id: "search", icon: "🔍", labelKey: "tabSearch" },
{ id: "scrape", icon: "📄", labelKey: "tabScrape" },
{ id: "compare", icon: "⚖", labelKey: "tabCompare" },
];
export default function SearchToolsTopBar({
@@ -27,6 +28,8 @@ export default function SearchToolsTopBar({
costUsd,
exportState,
}: SearchToolsTopBarProps) {
const t = useTranslations("search");
const tPlayground = useTranslations("playground");
const [exportOpen, setExportOpen] = useState(false);
return (
@@ -36,7 +39,7 @@ export default function SearchToolsTopBar({
data-testid="search-tools-topbar"
>
{/* Tab switcher */}
<div className="flex gap-1" role="tablist" aria-label="Search Tools tabs">
<div className="flex gap-1" role="tablist" aria-label={t("searchTools")}>
{TABS.map((tab) => (
<button
key={tab.id}
@@ -54,7 +57,7 @@ export default function SearchToolsTopBar({
data-testid={`tab-${tab.id}`}
>
<span aria-hidden="true">{tab.icon}</span>
<span>{tab.label}</span>
<span>{t(tab.labelKey)}</span>
</button>
))}
</div>
@@ -74,11 +77,11 @@ export default function SearchToolsTopBar({
<button
className="flex items-center gap-1 px-2.5 py-1 rounded-md text-xs font-medium bg-surface border border-border text-text-muted hover:text-text-main hover:border-border-hover transition-colors"
onClick={() => setExportOpen(true)}
aria-label="Export code"
aria-label={tPlayground("exportCode")}
data-testid="export-code-button"
>
<span className="font-mono text-[11px]">{"/>"}</span>
<span>Export</span>
<span>{tPlayground("exportShort")}</span>
</button>
</div>
</div>

View File

@@ -7092,7 +7092,16 @@
"improveConfirm": "Improve",
"exportCode": "Export code",
"exportCodeTitle": "Export Code",
"exportShort": "Export",
"noStateToExport": "No playground state available to export.",
"closeExportModal": "Close export modal",
"close": "Close",
"exportRealKeyWarning": "Security warning: export blocked — a real API key was detected in the output. Please reset your API key and try again.",
"placeholderHintPrefix": "Replace",
"placeholderHintSuffix": "with your actual API key, or set it as an environment variable.",
"copyLangCode": "Copy {language} code",
"loadingPresets": "Loading…",
"loadPresetPlaceholder": "Load preset…",
"copyCode": "Copy",
"copiedCode": "Copied!",
"addModel": "+ Add model",

View File

@@ -7013,26 +7013,26 @@
"model": "Model",
"accountKey": "Account Key",
"autoAccounts": "Automático ({count} contas)",
"noAccounts": "No Accounts",
"send": "Send",
"cancel": "Cancel",
"audioFile": "Audio File",
"attachImages": "Attach Images",
"noAccounts": "Sem contas",
"send": "Enviar",
"cancel": "Cancelar",
"audioFile": "Arquivo de áudio",
"attachImages": "Anexar imagens",
"multipartFormData": "Multipart Form Data",
"upToImages": "Up To Images",
"selectAudioFile": "Select Audio File",
"clearAll": "Clear All",
"request": "Request",
"response": "Response",
"transcription": "Transcription",
"copy": "Copy",
"resetToDefault": "Reset To Default",
"downloadAudio": "Download Audio",
"copyText": "Copy Text",
"transcriptionHint": "Transcription Hint",
"upToImages": "Até {count} imagens",
"selectAudioFile": "Selecionar arquivo de áudio",
"clearAll": "Limpar tudo",
"request": "Requisição",
"response": "Resposta",
"transcription": "Transcrição",
"copy": "Copiar",
"resetToDefault": "Restaurar padrão",
"downloadAudio": "Baixar áudio",
"copyText": "Copiar texto",
"transcriptionHint": "Dica de transcrição",
"imagesGenerated": "{count} imagens geradas",
"generatedImage": "Imagem gerada {index}",
"save": "Save",
"save": "Salvar",
"endpointOptions": {
"chat": "Chat",
"responses": "Responses",
@@ -7082,7 +7082,16 @@
"improveConfirm": "Melhorar",
"exportCode": "Exportar código",
"exportCodeTitle": "Exportar Código",
"exportShort": "Exportar",
"noStateToExport": "Nenhum estado de playground disponível para exportar.",
"closeExportModal": "Fechar modal de exportação",
"close": "Fechar",
"exportRealKeyWarning": "Aviso de segurança: exportação bloqueada — uma API key real foi detectada na saída. Por favor, redefina sua API key e tente novamente.",
"placeholderHintPrefix": "Substitua",
"placeholderHintSuffix": "pela sua API key real, ou defina como variável de ambiente.",
"copyLangCode": "Copiar código {language}",
"loadingPresets": "Carregando…",
"loadPresetPlaceholder": "Carregar preset…",
"copyCode": "Copiar",
"copiedCode": "Copiado!",
"addModel": "+ Adicionar modelo",

View File

@@ -4,6 +4,10 @@ import { act } from "react";
import { createRoot } from "react-dom/client";
import { afterEach, describe, expect, it, vi } from "vitest";
vi.mock("next-intl", () => ({
useTranslations: () => (key: string) => key,
}));
vi.mock("remark-gfm", () => ({ default: () => {} }));
vi.mock("react-markdown", () => ({
default: ({ children }: { children: React.ReactNode }) => (
@@ -73,7 +77,7 @@ describe("BuildTab", () => {
it("renders Run button", () => {
const el = renderBuildTab();
const runBtn = el.querySelector("[class*='bg-primary']");
expect(runBtn?.textContent).toContain("Run");
expect(runBtn?.textContent).toContain("runLabel");
});
it("renders Function calling section", () => {
@@ -193,10 +197,10 @@ describe("BuildTab", () => {
const promptTextarea = el.querySelector("textarea[placeholder*='message']") as HTMLTextAreaElement;
act(() => setInputValue(promptTextarea, "Run this tool"));
// Click Run
// Click Run (label is "runLabel" via mocked t())
const runBtns = el.querySelectorAll("button");
const runBtn = Array.from(runBtns).find(
(b) => b.textContent?.includes("Run") && !b.textContent?.includes("Clear"),
(b) => b.textContent?.includes("runLabel") && !b.textContent?.includes("clearAll"),
) as HTMLButtonElement;
await act(async () => { runBtn.click(); });
await act(async () => {

View File

@@ -184,11 +184,16 @@ describe("StudioConfigPane", () => {
expect(setConfigState).toHaveBeenCalled();
});
it("renders endpoint select with all 10 options", () => {
it("renders endpoint select with all 13 options (D4-rev2)", () => {
const config = makeConfig();
const el = renderPane(config, vi.fn());
const select = el.querySelector("select") as HTMLSelectElement | null;
expect(select).toBeTruthy();
expect(select?.options.length).toBe(10);
// Multiple <select> elements may exist (PresetPicker also renders one).
// The endpoint select is the one with our 13 endpoint values.
const selects = Array.from(el.querySelectorAll<HTMLSelectElement>("select"));
const endpointSelect = selects.find(
(s) => Array.from(s.options).some((o) => o.value === "chat.completions")
);
expect(endpointSelect).toBeTruthy();
expect(endpointSelect?.options.length).toBe(13);
});
});

View File

@@ -3,6 +3,20 @@ import React from "react";
import { act } from "react";
import { createRoot } from "react-dom/client";
import { afterEach, describe, expect, it, vi } from "vitest";
vi.mock("next-intl", () => ({
useTranslations: () => (key: string, values?: Record<string, unknown>) => {
if (values && Object.keys(values).length > 0) {
let s = key;
for (const [k, v] of Object.entries(values)) {
s = s.replace(`{${k}}`, String(v));
}
return s;
}
return key;
},
}));
import { API_KEY_PLACEHOLDER } from "../../../src/lib/playground/codeExport";
const { default: ExportCodeModal } = await import(
@@ -52,7 +66,8 @@ describe("ExportCodeModal", () => {
it("shows Export code title", () => {
const el = renderModal();
expect(el.textContent).toContain("Export code");
// useTranslations mock returns key as text — the h2 uses exportCodeTitle
expect(el.textContent).toContain("exportCodeTitle");
});
it("shows curl code with $OMNIROUTE_API_KEY placeholder", () => {
@@ -84,7 +99,7 @@ describe("ExportCodeModal", () => {
});
const el = renderModal();
const copyBtn = el.querySelector("[aria-label='Copy curl code']") as HTMLButtonElement;
const copyBtn = el.querySelector("[aria-label='copyLangCode']") as HTMLButtonElement;
expect(copyBtn).not.toBeNull();
await act(async () => { copyBtn.click(); });
@@ -144,7 +159,7 @@ describe("ExportCodeModal", () => {
it("calls onClose when Close button is clicked", () => {
const onClose = vi.fn();
const el = renderModal(BASE_STATE, onClose);
const closeBtn = el.querySelector("[aria-label='Close export modal']") as HTMLButtonElement;
const closeBtn = el.querySelector("[aria-label='closeExportModal']") as HTMLButtonElement;
act(() => closeBtn.click());
expect(onClose).toHaveBeenCalledTimes(1);
});

View File

@@ -4,6 +4,10 @@ import { act } from "react";
import { createRoot } from "react-dom/client";
import { afterEach, describe, expect, it, vi } from "vitest";
vi.mock("next-intl", () => ({
useTranslations: () => (key: string) => key,
}));
const { DEFAULT_PARAMS } = await import(
"../../../src/app/(dashboard)/dashboard/playground/components/ParamSliders"
);
@@ -109,8 +113,8 @@ describe("PresetPicker", () => {
it("renders Load preset dropdown and Save button", () => {
vi.stubGlobal("fetch", buildFetchMock([]));
const el = renderPicker();
expect(el.textContent).toContain("Presets");
const saveBtn = el.querySelector("[aria-label='Save current config as preset']");
expect(el.textContent).toContain("presetsLabel");
const saveBtn = el.querySelector("[aria-label='savePreset']");
expect(saveBtn).not.toBeNull();
});
@@ -151,13 +155,13 @@ describe("PresetPicker", () => {
vi.stubGlobal("fetch", buildFetchMock([]));
const el = renderPicker();
const saveBtn = el.querySelector("[aria-label='Save current config as preset']") as HTMLButtonElement;
const saveBtn = el.querySelector("[aria-label='savePreset']") as HTMLButtonElement;
await act(async () => { saveBtn.click(); });
// Modal should appear
const modal = el.querySelector("[role='dialog']");
expect(modal).not.toBeNull();
expect(modal?.textContent).toContain("Save preset");
expect(modal?.textContent).toContain("savePreset");
});
it("calls create hook (POST to /api/playground/presets) when saving", async () => {
@@ -166,7 +170,7 @@ describe("PresetPicker", () => {
const el = renderPicker();
// Open modal
const saveBtn = el.querySelector("[aria-label='Save current config as preset']") as HTMLButtonElement;
const saveBtn = el.querySelector("[aria-label='savePreset']") as HTMLButtonElement;
await act(async () => { saveBtn.click(); });
// Enter name
@@ -194,13 +198,13 @@ describe("PresetPicker", () => {
vi.stubGlobal("fetch", buildFetchMock([]));
const el = renderPicker();
const saveBtn = el.querySelector("[aria-label='Save current config as preset']") as HTMLButtonElement;
const saveBtn = el.querySelector("[aria-label='savePreset']") as HTMLButtonElement;
await act(async () => { saveBtn.click(); });
// Submit without entering a name
const submitBtn = el.querySelector("[role='dialog'] button:last-child") as HTMLButtonElement;
await act(async () => { submitBtn.click(); });
expect(el.textContent).toContain("Name is required");
expect(el.textContent).toContain("nameRequired");
});
});

View File

@@ -82,6 +82,12 @@ vi.mock("@/store/emailPrivacyStore", () => ({
vi.mock("@/lib/playground/codeExport", () => ({
endpointToPath: (ep: string) => `/v1/${ep}`,
exportAllLanguages: () => ({
curl: "curl mock",
python: "python mock",
typescript: "ts mock",
}),
API_KEY_PLACEHOLDER: "$OMNIROUTE_API_KEY",
}));
vi.mock("@/lib/playground/types", () => ({
@@ -156,23 +162,23 @@ describe("PlaygroundStudio", () => {
expect(tabButtons.length).toBe(4);
const textContents = Array.from(tabButtons).map((b) => b.textContent?.trim() ?? "");
// Tab buttons contain icon text + label text (e.g. "chatChat") — use includes
expect(textContents.some((t) => t.includes("Chat"))).toBe(true);
expect(textContents.some((t) => t.includes("Compare"))).toBe(true);
expect(textContents.some((t) => t.includes("API"))).toBe(true);
expect(textContents.some((t) => t.includes("Build"))).toBe(true);
// Tab labels come from useTranslations mock — keys are returned as text
expect(textContents.some((t) => t.includes("tabChat"))).toBe(true);
expect(textContents.some((t) => t.includes("tabCompare"))).toBe(true);
expect(textContents.some((t) => t.includes("tabApi"))).toBe(true);
expect(textContents.some((t) => t.includes("tabBuild"))).toBe(true);
});
it("defaults to Chat tab as active", () => {
const el = renderStudio();
const activeTab = el.querySelector("[role='tab'][aria-selected='true']");
expect(activeTab?.textContent?.trim()).toContain("Chat");
expect(activeTab?.textContent?.trim()).toContain("tabChat");
});
it("switches to API tab when clicked", () => {
const el = renderStudio();
const tabButtons = el.querySelectorAll("[role='tab']");
const apiTab = Array.from(tabButtons).find((b) => b.textContent?.includes("API")) as HTMLButtonElement | undefined;
const apiTab = Array.from(tabButtons).find((b) => b.textContent?.includes("tabApi")) as HTMLButtonElement | undefined;
expect(apiTab).toBeTruthy();
act(() => {
@@ -180,35 +186,39 @@ describe("PlaygroundStudio", () => {
});
const activeTab = el.querySelector("[role='tab'][aria-selected='true']");
expect(activeTab?.textContent?.trim()).toContain("API");
expect(activeTab?.textContent?.trim()).toContain("tabApi");
});
it("switches to Compare tab and shows placeholder", () => {
it("switches to Compare tab and marks it active", () => {
const el = renderStudio();
const tabButtons = el.querySelectorAll("[role='tab']");
const compareTab = Array.from(tabButtons).find((b) =>
b.textContent?.includes("Compare")
b.textContent?.includes("tabCompare")
) as HTMLButtonElement | undefined;
act(() => {
compareTab?.click();
});
expect(el.textContent).toContain("F7 implementation pending");
// After F7+F9: Compare tab renders the real CompareTab UI, no placeholder
const activeTab = el.querySelector("[role='tab'][aria-selected='true']");
expect(activeTab?.textContent?.trim()).toContain("tabCompare");
});
it("switches to Build tab and shows placeholder", () => {
it("switches to Build tab and marks it active", () => {
const el = renderStudio();
const tabButtons = el.querySelectorAll("[role='tab']");
const buildTab = Array.from(tabButtons).find((b) =>
b.textContent?.includes("Build")
b.textContent?.includes("tabBuild")
) as HTMLButtonElement | undefined;
act(() => {
buildTab?.click();
});
expect(el.textContent).toContain("F7 implementation pending");
// After F7+F9: Build tab renders the real BuildTab UI, no placeholder
const activeTab = el.querySelector("[role='tab'][aria-selected='true']");
expect(activeTab?.textContent?.trim()).toContain("tabBuild");
});
it("preserves config pane state when switching tabs", () => {
@@ -234,19 +244,20 @@ describe("PlaygroundStudio", () => {
it("renders the export button in the top bar", () => {
const el = renderStudio();
const exportBtn = el.querySelector("button[aria-label='Export code']");
const exportBtn = el.querySelector("button[aria-label='exportCode']");
expect(exportBtn).toBeTruthy();
});
it("opens export modal when export button is clicked", () => {
const el = renderStudio();
const exportBtn = el.querySelector("button[aria-label='Export code']") as HTMLButtonElement | null;
const exportBtn = el.querySelector("button[aria-label='exportCode']") as HTMLButtonElement | null;
act(() => {
exportBtn?.click();
});
expect(el.textContent).toContain("Export code");
// mock returns i18n key as text — assert on the key
expect(el.textContent).toContain("exportCode");
});
});

View File

@@ -89,21 +89,22 @@ describe("SearchConceptCard", () => {
const el = renderCard();
const searchItem = el.querySelector("[data-testid='concept-item-search']");
expect(searchItem).toBeTruthy();
expect(searchItem?.textContent).toContain("Search");
// useTranslations mock returns the key as text, so we assert on the i18n key
expect(searchItem?.textContent).toContain("searchConceptTitle");
});
it("renders Scrape concept item", () => {
const el = renderCard();
const scrapeItem = el.querySelector("[data-testid='concept-item-scrape']");
expect(scrapeItem).toBeTruthy();
expect(scrapeItem?.textContent).toContain("Scrape");
expect(scrapeItem?.textContent).toContain("scrapeConceptTitle");
});
it("renders Compare concept item", () => {
const el = renderCard();
const compareItem = el.querySelector("[data-testid='concept-item-compare']");
expect(compareItem).toBeTruthy();
expect(compareItem?.textContent).toContain("Compare");
expect(compareItem?.textContent).toContain("compareConceptTitle");
});
it("renders Rerank concept item", () => {