mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-07-26 09:52:11 +03:00
Merge F8 (MonitorTab) into refactor/pages-v3-19
This commit is contained in:
@@ -0,0 +1,381 @@
|
||||
"use client";
|
||||
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useState, useEffect, useRef, useCallback } from "react";
|
||||
import { Card, Badge, EmptyState } from "@/shared/components";
|
||||
import { FORMAT_META } from "../exampleTemplates";
|
||||
|
||||
interface MonitorTabProps {
|
||||
// F9 passes callback for empty state CTA.
|
||||
onGoToTranslate?: () => void;
|
||||
}
|
||||
|
||||
interface TranslationEvent {
|
||||
id?: string;
|
||||
timestamp?: string | number;
|
||||
provider?: string;
|
||||
model?: string;
|
||||
sourceFormat?: string;
|
||||
targetFormat?: string;
|
||||
status?: string;
|
||||
statusCode?: number | string;
|
||||
latency?: number;
|
||||
endpoint?: string;
|
||||
isComboRouted?: boolean;
|
||||
routeEndpoint?: string;
|
||||
routeProvider?: string;
|
||||
routeCombo?: string;
|
||||
routeConnectionShortId?: string;
|
||||
}
|
||||
|
||||
interface StatCardProps {
|
||||
icon: string;
|
||||
label: string;
|
||||
value: string | number;
|
||||
color: "blue" | "green" | "red" | "purple" | "amber" | "cyan";
|
||||
}
|
||||
|
||||
const COLOR_MAP: Record<
|
||||
StatCardProps["color"],
|
||||
{ shell: string; icon: string }
|
||||
> = {
|
||||
blue: { shell: "bg-blue-500/10", icon: "text-blue-500" },
|
||||
green: { shell: "bg-green-500/10", icon: "text-green-500" },
|
||||
red: { shell: "bg-red-500/10", icon: "text-red-500" },
|
||||
purple: { shell: "bg-purple-500/10", icon: "text-purple-500" },
|
||||
amber: { shell: "bg-amber-500/10", icon: "text-amber-500" },
|
||||
cyan: { shell: "bg-cyan-500/10", icon: "text-cyan-500" },
|
||||
};
|
||||
|
||||
function StatCard({ icon, label, value, color }: StatCardProps) {
|
||||
const resolved = COLOR_MAP[color] ?? COLOR_MAP.blue;
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<div className="p-4 flex items-center gap-3">
|
||||
<div className={`flex items-center justify-center w-10 h-10 rounded-lg ${resolved.shell}`}>
|
||||
<span
|
||||
className={`material-symbols-outlined text-[22px] ${resolved.icon}`}
|
||||
aria-hidden="true"
|
||||
>
|
||||
{icon}
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-lg font-bold text-text-main">{value}</p>
|
||||
<p className="text-[10px] text-text-muted uppercase tracking-wider">{label}</p>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* MonitorTab
|
||||
*
|
||||
* Refactor of LiveMonitorMode with 100% functional parity + additions:
|
||||
* - monitorOriginHint header always visible (explains event origin)
|
||||
* - empty state CTA with "Ir para Translate" button (onGoToTranslate)
|
||||
* - preserves 3s polling, auto-refresh toggle, 6 stat cards, events table
|
||||
* - cleanup useEffect: clearInterval on unmount
|
||||
*/
|
||||
export default function MonitorTab({ onGoToTranslate }: MonitorTabProps) {
|
||||
const t = useTranslations("translator");
|
||||
const tc = useTranslations("common");
|
||||
|
||||
const translateOrFallback = useCallback(
|
||||
(key: string, fallback: string, values?: Record<string, unknown>) => {
|
||||
try {
|
||||
const translated = t(key, values);
|
||||
return translated === key || translated === `translator.${key}` ? fallback : translated;
|
||||
} catch {
|
||||
return fallback;
|
||||
}
|
||||
},
|
||||
[t],
|
||||
);
|
||||
|
||||
const [events, setEvents] = useState<TranslationEvent[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [autoRefresh, setAutoRefresh] = useState(true);
|
||||
const intervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||
|
||||
const notAvailable = t("notAvailableSymbol");
|
||||
const formatLatency = (value: number) => t("millisecondsShort", { value });
|
||||
|
||||
const fetchHistory = useCallback(async () => {
|
||||
try {
|
||||
const res = await fetch("/api/translator/history?limit=50");
|
||||
if (res.ok) {
|
||||
const data = (await res.json()) as { events?: TranslationEvent[] };
|
||||
setEvents(data.events ?? []);
|
||||
}
|
||||
} catch {
|
||||
// ignore fetch errors in polling context — do not leak stack traces
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
void fetchHistory();
|
||||
if (autoRefresh) {
|
||||
intervalRef.current = setInterval(() => {
|
||||
void fetchHistory();
|
||||
}, 3000);
|
||||
}
|
||||
return () => {
|
||||
if (intervalRef.current) {
|
||||
clearInterval(intervalRef.current);
|
||||
intervalRef.current = null;
|
||||
}
|
||||
};
|
||||
}, [autoRefresh, fetchHistory]);
|
||||
|
||||
// Computed stats
|
||||
const successCount = events.filter((e) => e.status === "success").length;
|
||||
const errorCount = events.filter((e) => e.status === "error").length;
|
||||
const comboCount = events.filter((e) => e.isComboRouted).length;
|
||||
const uniqueEndpoints = new Set(
|
||||
events.map((e) => e.routeEndpoint ?? e.endpoint).filter(Boolean),
|
||||
).size;
|
||||
const avgLatency =
|
||||
events.length > 0
|
||||
? Math.round(events.reduce((sum, e) => sum + (e.latency ?? 0), 0) / events.length)
|
||||
: 0;
|
||||
|
||||
return (
|
||||
<div className="space-y-5 min-w-0">
|
||||
{/* Origin hint — always visible (monitorOriginHint) */}
|
||||
<div
|
||||
className="flex items-start gap-3 px-4 py-3 rounded-lg bg-primary/5 border border-primary/10 text-sm text-text-muted"
|
||||
data-testid="monitor-origin-hint"
|
||||
>
|
||||
<span
|
||||
className="material-symbols-outlined text-primary text-[20px] mt-0.5 shrink-0"
|
||||
aria-hidden="true"
|
||||
>
|
||||
info
|
||||
</span>
|
||||
<p>
|
||||
{translateOrFallback(
|
||||
"monitorOriginHint",
|
||||
"Eventos gerados pelo Translate ou pelo pipeline principal aparecem aqui em tempo real.",
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Stat Cards — 6 cards: total, success, errors, avg latency, combo-routed, unique endpoints */}
|
||||
<div className="grid grid-cols-2 md:grid-cols-3 xl:grid-cols-6 gap-3">
|
||||
<StatCard
|
||||
icon="translate"
|
||||
label={t("totalTranslations")}
|
||||
value={events.length}
|
||||
color="blue"
|
||||
/>
|
||||
<StatCard icon="check_circle" label={t("successful")} value={successCount} color="green" />
|
||||
<StatCard icon="error" label={t("errors")} value={errorCount} color="red" />
|
||||
<StatCard
|
||||
icon="speed"
|
||||
label={t("avgLatency")}
|
||||
value={formatLatency(avgLatency)}
|
||||
color="purple"
|
||||
/>
|
||||
<StatCard
|
||||
icon="hub"
|
||||
label={translateOrFallback("comboRouted", "Combo-routed")}
|
||||
value={comboCount}
|
||||
color="amber"
|
||||
/>
|
||||
<StatCard
|
||||
icon="lan"
|
||||
label={translateOrFallback("uniqueEndpoints", "Unique endpoints")}
|
||||
value={uniqueEndpoints}
|
||||
color="cyan"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Memory note */}
|
||||
<div className="flex items-center gap-2 rounded-lg border border-amber-500/10 bg-amber-500/5 px-3 py-2 text-xs text-amber-600 dark:text-amber-400">
|
||||
<span className="material-symbols-outlined text-[14px]">memory</span>
|
||||
<p>
|
||||
{t("liveMonitorMemoryNote")}{" "}
|
||||
<span className="text-text-muted">{t("liveMonitorMemoryCapNote")}</span>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Auto-refresh controls */}
|
||||
<Card>
|
||||
<div className="p-3 flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<span
|
||||
className={`material-symbols-outlined text-[18px] ${autoRefresh ? "text-green-500 animate-pulse" : "text-text-muted"}`}
|
||||
aria-hidden="true"
|
||||
>
|
||||
{autoRefresh ? "radio_button_checked" : "radio_button_unchecked"}
|
||||
</span>
|
||||
<button
|
||||
onClick={() => setAutoRefresh((prev) => !prev)}
|
||||
className="text-sm text-text-main hover:text-primary transition-colors"
|
||||
aria-label={
|
||||
autoRefresh
|
||||
? translateOrFallback("pauseAutoRefresh", "Pause auto-refresh")
|
||||
: translateOrFallback("resumeAutoRefresh", "Resume auto-refresh")
|
||||
}
|
||||
data-testid="auto-refresh-toggle"
|
||||
>
|
||||
{autoRefresh
|
||||
? translateOrFallback("liveAutoRefreshing", "Atualizando ao vivo")
|
||||
: translateOrFallback("paused", "Pausado")}
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{/* Live/Paused badge */}
|
||||
<Badge variant={autoRefresh ? "success" : "default"} size="sm" dot>
|
||||
{autoRefresh
|
||||
? translateOrFallback("live", "Live")
|
||||
: translateOrFallback("paused", "Paused")}
|
||||
</Badge>
|
||||
<button
|
||||
onClick={() => void fetchHistory()}
|
||||
className="flex items-center gap-1 text-xs text-text-muted hover:text-primary transition-colors"
|
||||
aria-label={tc("refresh")}
|
||||
>
|
||||
<span className="material-symbols-outlined text-[16px]" aria-hidden="true">
|
||||
refresh
|
||||
</span>
|
||||
{tc("refresh")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Events table */}
|
||||
<Card>
|
||||
<div className="p-4">
|
||||
<h3 className="text-sm font-semibold text-text-main mb-3">{t("recentTranslations")}</h3>
|
||||
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center py-12 text-text-muted">
|
||||
<span className="material-symbols-outlined animate-spin mr-2" aria-hidden="true">
|
||||
progress_activity
|
||||
</span>
|
||||
{tc("loading")}
|
||||
</div>
|
||||
) : events.length === 0 ? (
|
||||
/* Empty state with CTA (new in MonitorTab) */
|
||||
<div data-testid="monitor-empty-state">
|
||||
<EmptyState
|
||||
icon="📊"
|
||||
title={translateOrFallback("noTranslations", "Nenhuma tradução ainda")}
|
||||
description={translateOrFallback(
|
||||
"monitorEmptyCta",
|
||||
"Volte para a aba Translate e envie um request — ele aparecerá aqui.",
|
||||
)}
|
||||
actionLabel={translateOrFallback("monitorOpenTranslateButton", "Ir para Translate")}
|
||||
onAction={onGoToTranslate ?? null}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div className="overflow-x-auto" data-testid="monitor-events-table">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="text-left text-xs text-text-muted border-b border-border">
|
||||
<th className="pb-2 pr-4">{t("time")}</th>
|
||||
<th className="pb-2 pr-4">
|
||||
{translateOrFallback("routeDetails", "Route")}
|
||||
</th>
|
||||
<th className="pb-2 pr-4">{t("source")}</th>
|
||||
<th className="pb-2 pr-4">{t("target")}</th>
|
||||
<th className="pb-2 pr-4">{t("model")}</th>
|
||||
<th className="pb-2 pr-4">{t("status")}</th>
|
||||
<th className="pb-2 text-right">{t("latency")}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{events.map((event, i) => {
|
||||
const srcMeta = FORMAT_META[event.sourceFormat as keyof typeof FORMAT_META] ?? {
|
||||
label: event.sourceFormat ?? "?",
|
||||
color: "gray",
|
||||
};
|
||||
const tgtMeta = FORMAT_META[event.targetFormat as keyof typeof FORMAT_META] ?? {
|
||||
label: event.targetFormat ?? "?",
|
||||
color: "gray",
|
||||
};
|
||||
|
||||
return (
|
||||
<tr
|
||||
key={event.id ?? i}
|
||||
className="border-b border-border/50 hover:bg-bg-subtle/50 transition-colors"
|
||||
data-testid="monitor-event-row"
|
||||
>
|
||||
<td className="py-2 pr-4 text-xs text-text-muted whitespace-nowrap">
|
||||
{event.timestamp
|
||||
? new Date(event.timestamp).toLocaleTimeString()
|
||||
: notAvailable}
|
||||
</td>
|
||||
<td className="py-2 pr-4 min-w-[220px]">
|
||||
<div className="flex flex-col gap-1">
|
||||
<div className="flex flex-wrap items-center gap-1.5">
|
||||
<Badge variant="default" size="sm">
|
||||
{event.routeProvider ?? event.provider ?? notAvailable}
|
||||
</Badge>
|
||||
{event.routeCombo ? (
|
||||
<Badge variant="primary" size="sm">
|
||||
{translateOrFallback("comboBadge", "Combo")}: {event.routeCombo}
|
||||
</Badge>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-x-3 gap-y-1 text-[11px] text-text-muted">
|
||||
<span>
|
||||
{translateOrFallback("routeEndpointLabel", "Endpoint")}:{" "}
|
||||
{event.routeEndpoint ?? event.endpoint ?? notAvailable}
|
||||
</span>
|
||||
{event.routeConnectionShortId ? (
|
||||
<span>
|
||||
{translateOrFallback("routeConnectionLabel", "Conn")}:{" "}
|
||||
<span className="font-mono">{event.routeConnectionShortId}</span>
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td className="py-2 pr-4">
|
||||
<Badge variant="default" size="sm">
|
||||
{srcMeta.label}
|
||||
</Badge>
|
||||
</td>
|
||||
<td className="py-2 pr-4">
|
||||
<Badge variant="primary" size="sm">
|
||||
{tgtMeta.label}
|
||||
</Badge>
|
||||
</td>
|
||||
<td className="py-2 pr-4 text-xs font-mono text-text-muted break-all">
|
||||
{event.model ?? notAvailable}
|
||||
</td>
|
||||
<td className="py-2 pr-4">
|
||||
{event.status === "success" ? (
|
||||
<Badge variant="success" size="sm" dot>
|
||||
{t("ok")}
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge variant="error" size="sm" dot>
|
||||
{event.statusCode ?? t("errorShort")}
|
||||
</Badge>
|
||||
)}
|
||||
</td>
|
||||
<td className="py-2 text-right text-xs text-text-muted">
|
||||
{event.latency ? formatLatency(event.latency) : notAvailable}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
477
tests/unit/translator-friendly-monitor-tab.test.tsx
Normal file
477
tests/unit/translator-friendly-monitor-tab.test.tsx
Normal file
@@ -0,0 +1,477 @@
|
||||
// @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";
|
||||
|
||||
// ── i18n stub — returns fallback key so we can assert on translateOrFallback ──
|
||||
vi.mock("next-intl", () => ({
|
||||
useTranslations: () => (key: string) => key,
|
||||
}));
|
||||
|
||||
// ── Shared component stubs ────────────────────────────────────────────────────
|
||||
vi.mock("@/shared/components", () => ({
|
||||
Card: ({ children, className }: { children: React.ReactNode; className?: string }) => (
|
||||
<div data-testid="card" className={className}>
|
||||
{children}
|
||||
</div>
|
||||
),
|
||||
Badge: ({
|
||||
children,
|
||||
variant,
|
||||
dot,
|
||||
size,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
variant?: string;
|
||||
dot?: boolean;
|
||||
size?: string;
|
||||
}) => (
|
||||
<span data-testid="badge" data-variant={variant} data-dot={dot} data-size={size}>
|
||||
{children}
|
||||
</span>
|
||||
),
|
||||
EmptyState: ({
|
||||
title,
|
||||
description,
|
||||
actionLabel,
|
||||
onAction,
|
||||
icon,
|
||||
}: {
|
||||
title?: string;
|
||||
description?: string;
|
||||
actionLabel?: string;
|
||||
onAction?: (() => void) | null;
|
||||
icon?: string;
|
||||
}) => (
|
||||
<div data-testid="empty-state">
|
||||
{icon && <span data-testid="empty-icon">{icon}</span>}
|
||||
{title && <p data-testid="empty-title">{title}</p>}
|
||||
{description && <p data-testid="empty-description">{description}</p>}
|
||||
{actionLabel && onAction && (
|
||||
<button data-testid="empty-action" onClick={onAction}>
|
||||
{actionLabel}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
|
||||
// ── FORMAT_META stub ──────────────────────────────────────────────────────────
|
||||
vi.mock(
|
||||
"@/app/(dashboard)/dashboard/translator/exampleTemplates",
|
||||
() => ({
|
||||
FORMAT_META: {
|
||||
openai: { label: "OpenAI", color: "green" },
|
||||
claude: { label: "Claude", color: "orange" },
|
||||
gemini: { label: "Gemini", color: "blue" },
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
// ── fetch mock helpers ────────────────────────────────────────────────────────
|
||||
function mockFetchEmpty() {
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({ success: true, events: [] }),
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
function mockFetchWithEvents(
|
||||
events: Array<{
|
||||
id?: string;
|
||||
timestamp?: string;
|
||||
provider?: string;
|
||||
model?: string;
|
||||
sourceFormat?: string;
|
||||
targetFormat?: string;
|
||||
status?: string;
|
||||
latency?: number;
|
||||
}>,
|
||||
) {
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({ success: true, events }),
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
// ── DOM lifecycle helpers ─────────────────────────────────────────────────────
|
||||
const cleanupCallbacks: Array<() => void> = [];
|
||||
|
||||
function makeContainer(): HTMLElement {
|
||||
const container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
cleanupCallbacks.push(() => container.remove());
|
||||
return container;
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper: mount the component and flush the initial async fetch
|
||||
* WITHOUT triggering the recurring setInterval loop.
|
||||
* Uses vi.advanceTimersByTimeAsync(0) to drain microtask queue
|
||||
* after the initial fetch resolves, then stops — does NOT advance
|
||||
* by 3000ms so the interval does not fire.
|
||||
*/
|
||||
async function mountAndFlushInitialFetch(
|
||||
component: React.ReactElement,
|
||||
container: HTMLElement,
|
||||
): Promise<ReturnType<typeof createRoot>> {
|
||||
const root = createRoot(container);
|
||||
await act(async () => {
|
||||
root.render(component);
|
||||
});
|
||||
// Drain pending microtasks (the initial fetchHistory Promise) without
|
||||
// advancing time so setInterval doesn't trigger.
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
});
|
||||
return root;
|
||||
}
|
||||
|
||||
describe("MonitorTab", () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
(globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT =
|
||||
true;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
vi.restoreAllMocks();
|
||||
while (cleanupCallbacks.length > 0) {
|
||||
cleanupCallbacks.pop()?.();
|
||||
}
|
||||
document.body.innerHTML = "";
|
||||
});
|
||||
|
||||
// ── 1. Smoke render ──────────────────────────────────────────────────────────
|
||||
it("exports a default function component", async () => {
|
||||
const mod = await import(
|
||||
"@/app/(dashboard)/dashboard/translator/components/MonitorTab"
|
||||
);
|
||||
expect(typeof mod.default).toBe("function");
|
||||
});
|
||||
|
||||
it("renders the origin hint header (monitorOriginHint) always visible", async () => {
|
||||
mockFetchEmpty();
|
||||
const { default: MonitorTab } = await import(
|
||||
"@/app/(dashboard)/dashboard/translator/components/MonitorTab"
|
||||
);
|
||||
const container = makeContainer();
|
||||
await mountAndFlushInitialFetch(<MonitorTab />, container);
|
||||
|
||||
const hint = container.querySelector("[data-testid='monitor-origin-hint']");
|
||||
expect(hint).toBeTruthy();
|
||||
// The hint should contain the info icon
|
||||
const icons = hint?.querySelectorAll(".material-symbols-outlined");
|
||||
const iconTexts = Array.from(icons ?? []).map((el) => el.textContent?.trim());
|
||||
expect(iconTexts).toContain("info");
|
||||
});
|
||||
|
||||
it("renders 6 StatCards with correct icons", async () => {
|
||||
mockFetchEmpty();
|
||||
const { default: MonitorTab } = await import(
|
||||
"@/app/(dashboard)/dashboard/translator/components/MonitorTab"
|
||||
);
|
||||
const container = makeContainer();
|
||||
await mountAndFlushInitialFetch(<MonitorTab />, container);
|
||||
|
||||
// Stat card icons: translate, check_circle, error, speed, hub, lan
|
||||
const icons = container.querySelectorAll(".material-symbols-outlined");
|
||||
const iconTexts = Array.from(icons).map((el) => el.textContent?.trim());
|
||||
expect(iconTexts).toContain("translate");
|
||||
expect(iconTexts).toContain("check_circle");
|
||||
expect(iconTexts).toContain("error");
|
||||
expect(iconTexts).toContain("speed");
|
||||
expect(iconTexts).toContain("hub");
|
||||
expect(iconTexts).toContain("lan");
|
||||
});
|
||||
|
||||
// ── 2. Empty state ───────────────────────────────────────────────────────────
|
||||
it("shows empty state when events array is empty", async () => {
|
||||
mockFetchEmpty();
|
||||
const { default: MonitorTab } = await import(
|
||||
"@/app/(dashboard)/dashboard/translator/components/MonitorTab"
|
||||
);
|
||||
const container = makeContainer();
|
||||
await mountAndFlushInitialFetch(<MonitorTab />, container);
|
||||
|
||||
const emptyState = container.querySelector("[data-testid='empty-state']");
|
||||
expect(emptyState).toBeTruthy();
|
||||
// Table should NOT be rendered
|
||||
expect(container.querySelector("[data-testid='monitor-events-table']")).toBeNull();
|
||||
});
|
||||
|
||||
it("empty state shows CTA description text from monitorEmptyCta", async () => {
|
||||
mockFetchEmpty();
|
||||
const { default: MonitorTab } = await import(
|
||||
"@/app/(dashboard)/dashboard/translator/components/MonitorTab"
|
||||
);
|
||||
const container = makeContainer();
|
||||
await mountAndFlushInitialFetch(<MonitorTab />, container);
|
||||
|
||||
// When t() mock returns key, translateOrFallback detects key === translation and uses hardcoded fallback
|
||||
const emptyDescription = container.querySelector("[data-testid='empty-description']");
|
||||
expect(emptyDescription?.textContent).toContain("Volte para a aba Translate");
|
||||
});
|
||||
|
||||
it("empty state 'Ir para Translate' button calls onGoToTranslate callback", async () => {
|
||||
mockFetchEmpty();
|
||||
const { default: MonitorTab } = await import(
|
||||
"@/app/(dashboard)/dashboard/translator/components/MonitorTab"
|
||||
);
|
||||
const container = makeContainer();
|
||||
const onGoToTranslate = vi.fn();
|
||||
await mountAndFlushInitialFetch(<MonitorTab onGoToTranslate={onGoToTranslate} />, container);
|
||||
|
||||
const actionBtn = container.querySelector("[data-testid='empty-action']") as HTMLButtonElement | null;
|
||||
expect(actionBtn).toBeTruthy();
|
||||
// Label comes from monitorOpenTranslateButton fallback
|
||||
expect(actionBtn?.textContent).toContain("Ir para Translate");
|
||||
|
||||
await act(async () => {
|
||||
actionBtn?.click();
|
||||
});
|
||||
expect(onGoToTranslate).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("empty state action button is not rendered when onGoToTranslate is not provided", async () => {
|
||||
mockFetchEmpty();
|
||||
const { default: MonitorTab } = await import(
|
||||
"@/app/(dashboard)/dashboard/translator/components/MonitorTab"
|
||||
);
|
||||
const container = makeContainer();
|
||||
await mountAndFlushInitialFetch(<MonitorTab />, container);
|
||||
|
||||
// EmptyState stub only renders button when onAction is truthy
|
||||
const actionBtn = container.querySelector("[data-testid='empty-action']");
|
||||
expect(actionBtn).toBeNull();
|
||||
});
|
||||
|
||||
// ── 3. Events table ──────────────────────────────────────────────────────────
|
||||
it("renders events table with rows when events are present", async () => {
|
||||
const sampleEvents = [
|
||||
{
|
||||
id: "evt-1",
|
||||
timestamp: new Date("2026-05-27T10:00:00Z").toISOString(),
|
||||
provider: "openai",
|
||||
model: "gpt-4",
|
||||
sourceFormat: "claude",
|
||||
targetFormat: "openai",
|
||||
status: "success",
|
||||
latency: 320,
|
||||
},
|
||||
{
|
||||
id: "evt-2",
|
||||
timestamp: new Date("2026-05-27T10:01:00Z").toISOString(),
|
||||
provider: "gemini",
|
||||
model: "gemini-pro",
|
||||
sourceFormat: "openai",
|
||||
targetFormat: "gemini",
|
||||
status: "error",
|
||||
latency: 150,
|
||||
},
|
||||
];
|
||||
mockFetchWithEvents(sampleEvents);
|
||||
|
||||
const { default: MonitorTab } = await import(
|
||||
"@/app/(dashboard)/dashboard/translator/components/MonitorTab"
|
||||
);
|
||||
const container = makeContainer();
|
||||
await mountAndFlushInitialFetch(<MonitorTab />, container);
|
||||
|
||||
// Table must be present
|
||||
const table = container.querySelector("[data-testid='monitor-events-table']");
|
||||
expect(table).toBeTruthy();
|
||||
|
||||
// EmptyState must NOT be rendered
|
||||
expect(container.querySelector("[data-testid='empty-state']")).toBeNull();
|
||||
|
||||
// 2 event rows
|
||||
const rows = container.querySelectorAll("[data-testid='monitor-event-row']");
|
||||
expect(rows).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("table renders source and target format labels via FORMAT_META", async () => {
|
||||
const sampleEvents = [
|
||||
{
|
||||
id: "evt-1",
|
||||
sourceFormat: "claude",
|
||||
targetFormat: "openai",
|
||||
status: "success",
|
||||
},
|
||||
];
|
||||
mockFetchWithEvents(sampleEvents);
|
||||
|
||||
const { default: MonitorTab } = await import(
|
||||
"@/app/(dashboard)/dashboard/translator/components/MonitorTab"
|
||||
);
|
||||
const container = makeContainer();
|
||||
await mountAndFlushInitialFetch(<MonitorTab />, container);
|
||||
|
||||
const text = container.textContent ?? "";
|
||||
expect(text).toContain("Claude"); // FORMAT_META["claude"].label
|
||||
expect(text).toContain("OpenAI"); // FORMAT_META["openai"].label
|
||||
});
|
||||
|
||||
it("table columns include: time, route, source, target, model, status, latency headers", async () => {
|
||||
const sampleEvents = [{ id: "x", status: "success" }];
|
||||
mockFetchWithEvents(sampleEvents);
|
||||
|
||||
const { default: MonitorTab } = await import(
|
||||
"@/app/(dashboard)/dashboard/translator/components/MonitorTab"
|
||||
);
|
||||
const container = makeContainer();
|
||||
await mountAndFlushInitialFetch(<MonitorTab />, container);
|
||||
|
||||
// Column headers use t() keys — mock returns the key itself
|
||||
const tableText = container.querySelector("thead")?.textContent ?? "";
|
||||
expect(tableText).toContain("time");
|
||||
expect(tableText).toContain("source");
|
||||
expect(tableText).toContain("target");
|
||||
expect(tableText).toContain("model");
|
||||
expect(tableText).toContain("status");
|
||||
expect(tableText).toContain("latency");
|
||||
});
|
||||
|
||||
// ── 4. Auto-refresh toggle ───────────────────────────────────────────────────
|
||||
it("toggle auto-refresh button is present with aria-label and shows live state text", async () => {
|
||||
mockFetchEmpty();
|
||||
const { default: MonitorTab } = await import(
|
||||
"@/app/(dashboard)/dashboard/translator/components/MonitorTab"
|
||||
);
|
||||
const container = makeContainer();
|
||||
await mountAndFlushInitialFetch(<MonitorTab />, container);
|
||||
|
||||
const toggleBtn = container.querySelector(
|
||||
"[data-testid='auto-refresh-toggle']",
|
||||
) as HTMLButtonElement | null;
|
||||
expect(toggleBtn).toBeTruthy();
|
||||
|
||||
// Initial state: auto-refresh is ON — translateOrFallback detects key === translation → uses fallback
|
||||
expect(toggleBtn?.textContent?.trim()).toContain("Atualizando ao vivo");
|
||||
// aria-label should be set
|
||||
expect(toggleBtn?.getAttribute("aria-label")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("clicking toggle changes button text from live to paused", async () => {
|
||||
mockFetchEmpty();
|
||||
const { default: MonitorTab } = await import(
|
||||
"@/app/(dashboard)/dashboard/translator/components/MonitorTab"
|
||||
);
|
||||
const container = makeContainer();
|
||||
await mountAndFlushInitialFetch(<MonitorTab />, container);
|
||||
|
||||
const toggleBtn = container.querySelector(
|
||||
"[data-testid='auto-refresh-toggle']",
|
||||
) as HTMLButtonElement | null;
|
||||
expect(toggleBtn).toBeTruthy();
|
||||
|
||||
// Click to pause
|
||||
await act(async () => {
|
||||
toggleBtn?.click();
|
||||
});
|
||||
|
||||
// After pause: button text should switch to the "paused" fallback
|
||||
expect(toggleBtn?.textContent?.trim()).toContain("Pausado");
|
||||
});
|
||||
|
||||
it("auto-refresh polling fires fetch again after 3 seconds", async () => {
|
||||
const fetchMock = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({ success: true, events: [] }),
|
||||
});
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
const { default: MonitorTab } = await import(
|
||||
"@/app/(dashboard)/dashboard/translator/components/MonitorTab"
|
||||
);
|
||||
const container = makeContainer();
|
||||
await mountAndFlushInitialFetch(<MonitorTab />, container);
|
||||
|
||||
const callsAfterMount = fetchMock.mock.calls.length;
|
||||
expect(callsAfterMount).toBeGreaterThanOrEqual(1);
|
||||
|
||||
// Advance 3 seconds → one more interval tick
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(3000);
|
||||
});
|
||||
expect(fetchMock.mock.calls.length).toBeGreaterThan(callsAfterMount);
|
||||
});
|
||||
|
||||
it("pausing auto-refresh stops additional polling after toggle", async () => {
|
||||
const fetchMock = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({ success: true, events: [] }),
|
||||
});
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
const { default: MonitorTab } = await import(
|
||||
"@/app/(dashboard)/dashboard/translator/components/MonitorTab"
|
||||
);
|
||||
const container = makeContainer();
|
||||
await mountAndFlushInitialFetch(<MonitorTab />, container);
|
||||
|
||||
// Pause auto-refresh
|
||||
const toggleBtn = container.querySelector(
|
||||
"[data-testid='auto-refresh-toggle']",
|
||||
) as HTMLButtonElement | null;
|
||||
await act(async () => {
|
||||
toggleBtn?.click();
|
||||
});
|
||||
// After toggling, the component re-renders with autoRefresh=false.
|
||||
// The new useEffect runs with autoRefresh=false → no new interval.
|
||||
// But the old interval was cleared on cleanup.
|
||||
// Drain any pending microtasks from the state update.
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
});
|
||||
|
||||
const callsAfterPause = fetchMock.mock.calls.length;
|
||||
|
||||
// Advance 9 seconds — should NOT trigger more interval fetches
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(9000);
|
||||
});
|
||||
|
||||
// Allow any pending promises to settle
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
});
|
||||
|
||||
expect(fetchMock.mock.calls.length).toBe(callsAfterPause);
|
||||
});
|
||||
|
||||
// ── 5. Error sanitization ────────────────────────────────────────────────────
|
||||
it("fetch error does not leak stack traces into the DOM", async () => {
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn().mockRejectedValue(
|
||||
new Error("Network Error\n at fetch (/some/internal/path.ts:42:10)"),
|
||||
),
|
||||
);
|
||||
|
||||
const { default: MonitorTab } = await import(
|
||||
"@/app/(dashboard)/dashboard/translator/components/MonitorTab"
|
||||
);
|
||||
const container = makeContainer();
|
||||
// Don't use mountAndFlushInitialFetch here — we want to let the rejection settle
|
||||
const root = createRoot(container);
|
||||
await act(async () => {
|
||||
root.render(<MonitorTab />);
|
||||
});
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(100);
|
||||
});
|
||||
|
||||
const domText = container.textContent ?? "";
|
||||
expect(domText).not.toMatch(/at\s+\//);
|
||||
expect(domText).not.toMatch(/Network Error/);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user