mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-07-26 09:52:11 +03:00
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:
@@ -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"></></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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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]"></></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>
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
|
||||
@@ -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} />
|
||||
|
||||
@@ -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>
|
||||
))}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
Reference in New Issue
Block a user