fix(ui): v3.8.0 polish — connections border, sticky tabs, EN translations, save toasts, auto-combo catalog (#2305)

Integrated into release/v3.8.0
This commit is contained in:
Mourad Maatoug
2026-05-16 17:06:12 +02:00
committed by GitHub
parent 9efad44fc9
commit 2af6923e6e
9 changed files with 297 additions and 66 deletions

View File

@@ -0,0 +1,85 @@
"use client";
import { useState } from "react";
import { useTranslations } from "next-intl";
import { Card } from "@/shared/components";
import { AUTO_COMBO_TEMPLATES } from "@/domain/assessment/types";
// Informational catalog of zero-config auto-routing combos.
// Auto combos are resolved at request time by the chat handler based on the
// currently connected providers / models — they have no row in the combos
// table, so they were previously invisible in the UI. This panel surfaces
// the static catalog (name, intent, categories, tiers, strategy) so users
// can discover the auto/ prefix without reading source.
export default function AutoComboCatalog() {
const t = useTranslations("combos");
const [open, setOpen] = useState(false);
return (
<Card className="p-4">
<button
type="button"
onClick={() => setOpen((prev) => !prev)}
className="flex w-full items-start justify-between gap-3 text-left"
aria-expanded={open}
aria-label={open ? t("autoCatalogCollapse") : t("autoCatalogExpand")}
>
<div className="min-w-0">
<div className="flex items-center gap-2">
<span className="material-symbols-outlined text-xl text-primary">auto_awesome</span>
<h2 className="text-base font-bold text-text-main">{t("autoCatalogTitle")}</h2>
<span className="rounded-full bg-primary/10 px-2 py-0.5 text-[10px] font-semibold text-primary">
{t("autoCatalogTemplateCount", { count: AUTO_COMBO_TEMPLATES.length })}
</span>
</div>
<p className="mt-1 text-xs text-text-muted">{t("autoCatalogDescription")}</p>
</div>
<span className="material-symbols-outlined text-base text-text-muted">
{open ? "expand_less" : "expand_more"}
</span>
</button>
{open && (
<div className="mt-4 grid grid-cols-1 gap-2 md:grid-cols-2 xl:grid-cols-3">
{AUTO_COMBO_TEMPLATES.map((tpl) => (
<div
key={tpl.name}
className="rounded-lg border border-border bg-bg-subtle p-3 text-xs"
>
<div className="flex items-center justify-between gap-2">
<code className="font-mono text-sm text-text-main">{tpl.name}</code>
<span className="rounded bg-black/5 px-1.5 py-0.5 text-[10px] uppercase tracking-wide text-text-muted dark:bg-white/5">
{tpl.strategy}
</span>
</div>
<p className="mt-1 text-[11px] font-semibold text-text-main">{tpl.displayName}</p>
<div className="mt-2 flex flex-wrap gap-1">
{tpl.categories.map((cat) => (
<span
key={cat}
className="rounded-full bg-primary/10 px-2 py-0.5 text-[10px] text-primary"
>
{cat}
</span>
))}
{tpl.tiers.map((tier) => (
<span
key={tier}
className="rounded-full bg-black/[0.04] px-2 py-0.5 text-[10px] text-text-muted dark:bg-white/[0.04]"
>
{tier}
</span>
))}
</div>
{tpl.systemMessage && (
<p className="mt-2 text-[10px] italic text-text-muted line-clamp-2">
{tpl.systemMessage}
</p>
)}
</div>
))}
</div>
)}
</Card>
);
}

View File

@@ -34,6 +34,7 @@ import {
resolveComboBuilderProviderId,
} from "@/lib/combos/builderDraft";
import { normalizeComboConfigMode } from "@/shared/constants/comboConfigMode";
import AutoComboCatalog from "./AutoComboCatalog";
import BuilderIntelligentStep from "./BuilderIntelligentStep";
import IntelligentComboPanel from "./IntelligentComboPanel";
import {
@@ -950,6 +951,7 @@ export default function CombosPage() {
<h1 className="text-2xl font-semibold">{t("title")}</h1>
<p className="text-sm text-text-muted mt-1">{t("description")}</p>
</div>
<div className="flex flex-wrap items-center gap-2">
<div className="inline-flex items-center gap-2 rounded-lg border border-black/8 dark:border-white/8 bg-black/[0.02] dark:bg-white/[0.02] px-2.5 py-1.5">
<span className="hidden lg:inline text-xs text-text-muted">
@@ -988,6 +990,8 @@ export default function CombosPage() {
</div>
</div>
<AutoComboCatalog />
{showUsageGuide && (
<ComboUsageGuide
onHide={() => setShowUsageGuide(false)}

View File

@@ -3380,7 +3380,7 @@ export default function ProviderDetailPage() {
</Button>
)}
</div>
<div className="flex flex-col divide-y divide-black/[0.03] dark:divide-white/[0.03]">
<div className="flex flex-col divide-y divide-black/[0.03] dark:divide-white/[0.03] border border-t-0 border-border rounded-b-lg overflow-hidden">
{sorted.map((conn, index) => (
<ConnectionRow
key={conn.id}
@@ -3511,7 +3511,7 @@ export default function ProviderDetailPage() {
)}
</div>
) : null}
<div className="flex flex-col gap-0">
<div className="flex flex-col gap-0 border border-t-0 border-border rounded-b-lg overflow-hidden">
{groupKeys.map((tag, gi) => {
const groupConns = groupMap.get(tag)!;
return (

View File

@@ -58,7 +58,7 @@ export default function ModelCooldownsCard() {
});
const json = await res.json();
if (!res.ok) throw new Error(json?.error || `HTTP ${res.status}`);
notify.success(`Modelo reativado: ${provider}/${model}`);
notify.success(`Model reactivated: ${provider}/${model}`);
await load();
} catch (error) {
notify.error(error instanceof Error ? error.message : "Failed to clear cooldown");
@@ -79,7 +79,7 @@ export default function ModelCooldownsCard() {
});
const json = await res.json();
if (!res.ok) throw new Error(json?.error || `HTTP ${res.status}`);
notify.success("Todos os modelos em cooldown foram reativados.");
notify.success("All models in cooldown have been reactivated.");
await load();
} catch (error) {
notify.error(error instanceof Error ? error.message : "Failed to clear cooldowns");
@@ -95,15 +95,15 @@ export default function ModelCooldownsCard() {
<Card className="p-6">
<div className="flex items-start justify-between gap-4">
<div>
<h2 className="text-lg font-bold text-text-main">Modelos em cooldown</h2>
<h2 className="text-lg font-bold text-text-main">Models in cooldown</h2>
<p className="mt-1 text-sm text-text-muted">
Lista de modelos temporariamente isolados por falha. Quando o cooldown expira, eles
voltam automaticamente.
Models temporarily isolated after a failure. When the cooldown expires they come back
automatically.
</p>
</div>
<div className="flex gap-2">
<Button size="sm" variant="secondary" onClick={() => void load()} disabled={loading}>
Atualizar
Refresh
</Button>
<Button
size="sm"
@@ -111,16 +111,16 @@ export default function ModelCooldownsCard() {
onClick={() => void clearAll()}
disabled={!hasItems || busyKey === "ALL"}
>
Reativar todos
Reactivate all
</Button>
</div>
</div>
<div className="mt-4 space-y-2">
{loading ? (
<p className="text-sm text-text-muted">Carregando...</p>
<p className="text-sm text-text-muted">Loading...</p>
) : !hasItems ? (
<p className="text-sm text-text-muted">Nenhum modelo em cooldown no momento.</p>
<p className="text-sm text-text-muted">No models in cooldown right now.</p>
) : (
sorted.map((item) => {
const rowKey = `${item.provider}::${item.model}`;
@@ -134,7 +134,7 @@ export default function ModelCooldownsCard() {
{item.provider}/{item.model}
</p>
<p className="text-xs text-text-muted">
motivo: {item.reason} restante: {formatRemaining(item.remainingMs)}
reason: {item.reason} remaining: {formatRemaining(item.remainingMs)}
</p>
</div>
<Button
@@ -143,7 +143,7 @@ export default function ModelCooldownsCard() {
onClick={() => void clearOne(item.provider, item.model)}
disabled={busyKey === rowKey}
>
Reativar
Reactivate
</Button>
</div>
);

View File

@@ -65,13 +65,13 @@ function SectionDescription({
return (
<div className="grid grid-cols-1 gap-2 text-xs text-text-muted sm:grid-cols-3">
<div>
<span className="font-semibold text-text-main">Escopo:</span> {scope}
<span className="font-semibold text-text-main">Scope:</span> {scope}
</div>
<div>
<span className="font-semibold text-text-main">Gatilho:</span> {trigger}
<span className="font-semibold text-text-main">Trigger:</span> {trigger}
</div>
<div>
<span className="font-semibold text-text-main">Efeito:</span> {effect}
<span className="font-semibold text-text-main">Effect:</span> {effect}
</div>
</div>
);
@@ -216,12 +216,12 @@ function RequestQueueCard({
<div className="space-y-2">
<div className="flex items-center gap-2">
<span className="material-symbols-outlined text-xl text-primary">speed</span>
<h2 className="text-lg font-bold">Fila de Requisições e Ritmo</h2>
<h2 className="text-lg font-bold">Request Queue & Rate</h2>
</div>
<SectionDescription
scope="Por fila de requisição"
trigger="Antes de enviar para o upstream"
effect="Enfileira requisições, limita concorrência e espaça as chamadas"
scope="Per request queue"
trigger="Before sending to upstream"
effect="Queues requests, limits concurrency, and spaces calls"
/>
</div>
<ActionRow
@@ -240,29 +240,29 @@ function RequestQueueCard({
</div>
<p className="mb-4 text-sm text-text-muted">
Esta camada controla apenas enfileiramento e ritmo. Ela não grava cooldown nem abre circuit
breaker.
This layer only controls queueing and pacing. It does not write cooldown state nor open the
circuit breaker.
</p>
<div className="grid grid-cols-1 gap-3 lg:grid-cols-2">
{editing ? (
<>
<BooleanField
label="Autoativar para provedores com API key"
description="Ativa proteção de fila por padrão para conexões de API key ativas."
label="Auto-enable for API-key providers"
description="Enable queue protection by default for active API-key connections."
checked={draft.autoEnableApiKeyProviders}
onChange={(autoEnableApiKeyProviders) =>
setDraft((prev) => ({ ...prev, autoEnableApiKeyProviders }))
}
/>
<NumberField
label="Requisições por minuto"
label="Requests per minute"
value={draft.requestsPerMinute}
min={1}
onChange={(requestsPerMinute) => setDraft((prev) => ({ ...prev, requestsPerMinute }))}
/>
<NumberField
label="Tempo mínimo entre requisições"
label="Minimum time between requests"
value={draft.minTimeBetweenRequestsMs}
suffix="ms"
onChange={(minTimeBetweenRequestsMs) =>
@@ -270,7 +270,7 @@ function RequestQueueCard({
}
/>
<NumberField
label="Requisições concorrentes"
label="Concurrent requests"
value={draft.concurrentRequests}
min={1}
onChange={(concurrentRequests) =>
@@ -278,7 +278,7 @@ function RequestQueueCard({
}
/>
<NumberField
label="Tempo máximo de espera em fila"
label="Maximum queue wait time"
value={draft.maxWaitMs}
min={1}
suffix="ms"
@@ -288,31 +288,31 @@ function RequestQueueCard({
) : (
<>
<div className="rounded-xl border border-border bg-bg-subtle p-4">
<div className="text-xs text-text-muted">Autoativar para provedores com API key</div>
<div className="text-xs text-text-muted">Auto-enable for API-key providers</div>
<div className="mt-1 text-sm font-semibold text-text-main">
{value.autoEnableApiKeyProviders ? "Ativado" : "Desativado"}
{value.autoEnableApiKeyProviders ? "Enabled" : "Disabled"}
</div>
</div>
<div className="rounded-xl border border-border bg-bg-subtle p-4">
<div className="text-xs text-text-muted">Requisições por minuto</div>
<div className="text-xs text-text-muted">Requests per minute</div>
<div className="mt-1 text-sm font-semibold text-text-main">
{value.requestsPerMinute}
</div>
</div>
<div className="rounded-xl border border-border bg-bg-subtle p-4">
<div className="text-xs text-text-muted">Tempo mínimo entre requisições</div>
<div className="text-xs text-text-muted">Minimum time between requests</div>
<div className="mt-1 text-sm font-semibold text-text-main">
{formatMs(value.minTimeBetweenRequestsMs)}
</div>
</div>
<div className="rounded-xl border border-border bg-bg-subtle p-4">
<div className="text-xs text-text-muted">Requisições concorrentes</div>
<div className="text-xs text-text-muted">Concurrent requests</div>
<div className="mt-1 text-sm font-semibold text-text-main">
{value.concurrentRequests}
</div>
</div>
<div className="rounded-xl border border-border bg-bg-subtle p-4">
<div className="text-xs text-text-muted">Tempo ximo de espera em fila</div>
<div className="text-xs text-text-muted">Maximum queue wait time</div>
<div className="mt-1 text-sm font-semibold text-text-main">
{formatMs(value.maxWaitMs)}
</div>
@@ -347,7 +347,7 @@ function ConnectionCooldownCard({
{editing ? (
<>
<NumberField
label="Cooldown base"
label="Base cooldown"
value={current.baseCooldownMs}
min={0}
suffix="ms"
@@ -420,7 +420,7 @@ function ConnectionCooldownCard({
) : (
<>
<div className="flex items-center justify-between text-sm">
<span className="text-text-muted">Cooldown base</span>
<span className="text-text-muted">Base cooldown</span>
<span className="font-mono text-text-main">{formatMs(current.baseCooldownMs)}</span>
</div>
<div className="flex items-center justify-between text-sm">
@@ -533,7 +533,7 @@ function ProviderBreakerCard({
{editing ? (
<>
<NumberField
label="Limite de falhas"
label="Failure threshold"
value={current.failureThreshold}
min={1}
onChange={(failureThreshold) =>
@@ -541,7 +541,7 @@ function ProviderBreakerCard({
}
/>
<NumberField
label="Tempo para reset"
label="Reset timeout"
value={current.resetTimeoutMs}
min={1000}
suffix="ms"
@@ -553,11 +553,11 @@ function ProviderBreakerCard({
) : (
<>
<div className="flex items-center justify-between text-sm">
<span className="text-text-muted">Limite de falhas</span>
<span className="text-text-muted">Failure threshold</span>
<span className="font-mono text-text-main">{current.failureThreshold}</span>
</div>
<div className="flex items-center justify-between text-sm">
<span className="text-text-muted">Tempo para reset</span>
<span className="text-text-muted">Reset timeout</span>
<span className="font-mono text-text-main">{formatMs(current.resetTimeoutMs)}</span>
</div>
</>
@@ -574,12 +574,12 @@ function ProviderBreakerCard({
<span className="material-symbols-outlined text-xl text-primary">
electrical_services
</span>
<h2 className="text-lg font-bold">Circuit Breaker por Provedor</h2>
<h2 className="text-lg font-bold">Circuit Breaker per Provider</h2>
</div>
<SectionDescription
scope="Provedor inteiro"
trigger="Falhas finais de transporte/servidor após esgotar fallback de conexão"
effect="Bloqueia temporariamente esse provedor até o tempo de reset expirar"
scope="Entire provider"
trigger="Final transport/server failures after exhausting connection fallback"
effect="Temporarily blocks this provider until the reset timeout expires"
/>
</div>
<ActionRow
@@ -598,13 +598,13 @@ function ProviderBreakerCard({
</div>
<p className="mb-4 text-sm text-text-muted">
O estado em tempo real do breaker aparece apenas na página Saúde. Rate limits 429 no escopo
de conexão ficam no Cooldown de Conexão e não disparam o breaker de provedor.
The live breaker state is shown only on the Health page. 429 rate limits at the connection
scope stay in Connection Cooldown and do not trip the provider breaker.
</p>
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
{renderProfile("oauth", "Provedores OAuth", "lock")}
{renderProfile("apikey", "Provedores API Key", "key")}
{renderProfile("oauth", "OAuth Providers", "lock")}
{renderProfile("apikey", "API Key Providers", "key")}
</div>
</Card>
);
@@ -632,12 +632,12 @@ function WaitForCooldownCard({
<div className="space-y-2">
<div className="flex items-center gap-2">
<span className="material-symbols-outlined text-xl text-primary">hourglass_top</span>
<h2 className="text-lg font-bold">Aguardar Cooldown</h2>
<h2 className="text-lg font-bold">Wait for Cooldown</h2>
</div>
<SectionDescription
scope="Requisição atual do cliente"
trigger="Quando todas as conexões candidatas já estão em cooldown"
effect="Aguarda no servidor e tenta novamente quando o primeiro cooldown expira"
scope="Current client request"
trigger="When all candidate connections are already in cooldown"
effect="Waits on the server and retries when the first cooldown expires"
/>
</div>
<ActionRow
@@ -656,26 +656,26 @@ function WaitForCooldownCard({
</div>
<p className="mb-4 text-sm text-text-muted">
Isso afeta apenas a requisição atual. Não grava estado de conexão nem de provedor.
This only affects the current request. No connection or provider state is written.
</p>
<div className="grid grid-cols-1 gap-3 lg:grid-cols-2">
{editing ? (
<>
<BooleanField
label="Ativar espera no servidor"
description="Quando ativo, o OmniRoute aguarda o primeiro cooldown expirar e tenta novamente automaticamente."
label="Enable server-side wait"
description="When enabled, OmniRoute waits for the first cooldown to expire and retries automatically."
checked={draft.enabled}
onChange={(enabled) => setDraft((prev) => ({ ...prev, enabled }))}
/>
<NumberField
label="Máximo de tentativas"
label="Maximum retries"
value={draft.maxRetries}
min={0}
onChange={(maxRetries) => setDraft((prev) => ({ ...prev, maxRetries }))}
/>
<NumberField
label="Tempo máximo de espera por tentativa"
label="Maximum wait per retry"
value={draft.maxRetryWaitSec}
min={0}
suffix="sec"
@@ -685,17 +685,17 @@ function WaitForCooldownCard({
) : (
<>
<div className="rounded-xl border border-border bg-bg-subtle p-4">
<div className="text-xs text-text-muted">Ativar espera no servidor</div>
<div className="text-xs text-text-muted">Enable server-side wait</div>
<div className="mt-1 text-sm font-semibold text-text-main">
{value.enabled ? "Ativado" : "Desativado"}
{value.enabled ? "Enabled" : "Disabled"}
</div>
</div>
<div className="rounded-xl border border-border bg-bg-subtle p-4">
<div className="text-xs text-text-muted">Máximo de tentativas</div>
<div className="text-xs text-text-muted">Maximum retries</div>
<div className="mt-1 text-sm font-semibold text-text-main">{value.maxRetries}</div>
</div>
<div className="rounded-xl border border-border bg-bg-subtle p-4">
<div className="text-xs text-text-muted">Tempo ximo de espera por tentativa</div>
<div className="text-xs text-text-muted">Maximum wait per retry</div>
<div className="mt-1 text-sm font-semibold text-text-main">
{value.maxRetryWaitSec}s
</div>

View File

@@ -3,6 +3,7 @@
import { useEffect, useMemo, useState } from "react";
import { Button, Card, Collapsible, Input, Select, Toggle } from "@/shared/components";
import { useTranslations } from "next-intl";
import { useNotificationStore } from "@/store/notificationStore";
import FallbackChainsEditor from "./FallbackChainsEditor";
import {
CLI_COMPAT_PROVIDER_DISPLAY,
@@ -629,6 +630,7 @@ export default function RoutingTab() {
const [lkgpCacheLoading, setLkgpCacheLoading] = useState(false);
const [lkgpCacheStatus, setLkgpCacheStatus] = useState({ type: "", message: "" });
const t = useTranslations("settings");
const notify = useNotificationStore();
useEffect(() => {
fetch("/api/settings")
@@ -670,11 +672,15 @@ export default function RoutingTab() {
} catch {
// body wasn't JSON — keep the HTTP status fallback
}
notify.error(t("saveFailed"), serverMsg);
if (onError) onError(serverMsg);
else console.error("Failed to update settings:", serverMsg);
} else {
notify.success(t("savedSuccessfully"));
}
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
notify.error(t("saveFailed"), msg);
if (onError) onError(msg);
else console.error("Failed to update settings:", msg);
}

View File

@@ -45,7 +45,7 @@ export default function SettingsPage() {
<div className="max-w-6xl mx-auto min-w-0">
<div className="flex flex-col gap-6">
{/* Tab navigation */}
<div className="w-full overflow-x-auto pb-1">
<div className="sticky top-0 z-20 w-full overflow-x-auto pb-1 pt-1 bg-bg-primary/95 supports-[backdrop-filter]:bg-bg-primary/80 backdrop-blur">
<div
role="tablist"
aria-label={t("settingsSectionsAria")}

View File

@@ -672,6 +672,8 @@
"cloudAgents": "Cloud Agents",
"memory": "Memory",
"skills": "Skills",
"omniSkills": "Omni Skills",
"agentSkills": "Agent Skills",
"docs": "Docs",
"issues": "Issues",
"endpoints": "Endpoints",
@@ -1458,6 +1460,11 @@
"combos": {
"title": "Combos",
"description": "Create model combos with weighted routing and fallback support",
"autoCatalogTitle": "Auto-routing catalog",
"autoCatalogTemplateCount": "{count} templates",
"autoCatalogDescription": "Built-in auto/* combos resolved dynamically from connected providers. Use any of these IDs as the model field — no setup needed.",
"autoCatalogExpand": "Expand auto-routing catalog",
"autoCatalogCollapse": "Collapse auto-routing catalog",
"createCombo": "Create Combo",
"editCombo": "Edit Combo",
"deleteCombo": "Delete Combo",
@@ -3993,7 +4000,9 @@
"multi-turn": "Multi-turn",
"thinking": "Thinking",
"system-prompt": "System Prompt",
"streaming": "Streaming"
"streaming": "Streaming",
"vision": "Vision",
"schema-coercion": "Schema Coercion"
},
"templateDescriptions": {
"simple-chat": "Basic text message",
@@ -4001,7 +4010,9 @@
"multi-turn": "Conversation with history",
"thinking": "Extended thinking / reasoning",
"system-prompt": "Complex system instructions",
"streaming": "SSE streaming request"
"streaming": "SSE streaming request",
"vision": "Multimodal request with image input",
"schema-coercion": "Structured-output / JSON schema enforcement"
},
"templatePayloads": {
"simpleChat": {
@@ -4929,7 +4940,9 @@
"system-prompt": "System Prompt",
"thinking": "Thinking",
"tool-calling": "Tool Calling",
"multi-turn": "Multi-turn"
"multi-turn": "Multi-turn",
"vision": "Vision",
"schema-coercion": "Schema Coercion"
},
"templateDescriptions": {
"simple-chat": "Basic chat template with system message",
@@ -4937,7 +4950,9 @@
"system-prompt": "Template with custom system prompt",
"thinking": "Template with reasoning/thinking budget",
"tool-calling": "Template for tool/function calling",
"multi-turn": "Template for multi-turn conversations"
"multi-turn": "Template for multi-turn conversations",
"vision": "Multimodal template with image input",
"schema-coercion": "Structured-output / JSON schema enforcement"
},
"templatePayloads": {
"simpleChat": {

View File

@@ -0,0 +1,121 @@
// @vitest-environment jsdom
import React from "react";
import { act } from "react";
import { createRoot } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { AUTO_COMBO_TEMPLATES } from "@/domain/assessment/types";
// Minimal i18n stub — return interpolated value so {count} works.
vi.mock("next-intl", () => ({
useTranslations: () => (key: string, values?: Record<string, unknown>) => {
if (values && typeof values.count !== "undefined") {
return `${values.count} ${key}`;
}
return key;
},
}));
const cleanupCallbacks: Array<() => void> = [];
function makeContainer(): HTMLElement {
const container = document.createElement("div");
document.body.appendChild(container);
cleanupCallbacks.push(() => {
container.remove();
});
return container;
}
describe("AutoComboCatalog", () => {
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 = "";
});
it("renders the header with translated title and template-count badge", async () => {
const { default: AutoComboCatalog } =
await import("@/app/(dashboard)/dashboard/combos/AutoComboCatalog");
const container = makeContainer();
const root = createRoot(container);
await act(async () => {
root.render(<AutoComboCatalog />);
});
expect(container.textContent).toContain("autoCatalogTitle");
expect(container.textContent).toContain(
`${AUTO_COMBO_TEMPLATES.length} autoCatalogTemplateCount`
);
});
it("stays collapsed by default — no template rows in the DOM", async () => {
const { default: AutoComboCatalog } =
await import("@/app/(dashboard)/dashboard/combos/AutoComboCatalog");
const container = makeContainer();
const root = createRoot(container);
await act(async () => {
root.render(<AutoComboCatalog />);
});
const first = AUTO_COMBO_TEMPLATES[0];
expect(container.textContent ?? "").not.toContain(first.name);
});
it("expands when toggled and lists every template name", async () => {
const { default: AutoComboCatalog } =
await import("@/app/(dashboard)/dashboard/combos/AutoComboCatalog");
const container = makeContainer();
const root = createRoot(container);
await act(async () => {
root.render(<AutoComboCatalog />);
});
const toggle = container.querySelector("button");
expect(toggle).toBeTruthy();
await act(async () => {
toggle?.click();
});
for (const tpl of AUTO_COMBO_TEMPLATES) {
expect(container.textContent ?? "").toContain(tpl.name);
}
});
it("flips the toggle aria-label between expand and collapse", async () => {
const { default: AutoComboCatalog } =
await import("@/app/(dashboard)/dashboard/combos/AutoComboCatalog");
const container = makeContainer();
const root = createRoot(container);
await act(async () => {
root.render(<AutoComboCatalog />);
});
const toggle = container.querySelector("button");
expect(toggle?.getAttribute("aria-label")).toBe("autoCatalogExpand");
expect(toggle?.getAttribute("aria-expanded")).toBe("false");
await act(async () => {
toggle?.click();
});
expect(toggle?.getAttribute("aria-label")).toBe("autoCatalogCollapse");
expect(toggle?.getAttribute("aria-expanded")).toBe("true");
});
it("renders the strategy badge for each template when expanded", async () => {
const { default: AutoComboCatalog } =
await import("@/app/(dashboard)/dashboard/combos/AutoComboCatalog");
const container = makeContainer();
const root = createRoot(container);
await act(async () => {
root.render(<AutoComboCatalog />);
});
await act(async () => {
(container.querySelector("button") as HTMLButtonElement | null)?.click();
});
const strategies = new Set(AUTO_COMBO_TEMPLATES.map((t) => t.strategy));
for (const s of strategies) {
expect(container.textContent ?? "").toContain(s);
}
});
});