Files
2026-07-13 13:39:12 +08:00

214 lines
8.2 KiB
TypeScript

// @vitest-environment jsdom
import React, { act } from "react";
import { createRoot } from "react-dom/client";
import { describe, it, expect, beforeAll, beforeEach, afterEach, vi } from "vitest";
import type { ComboRunModel } from "@/app/(dashboard)/dashboard/combos/live/comboFlowModel";
// ── Polyfill ResizeObserver (required by ReactFlow) ───────────────────────
beforeAll(() => {
globalThis.ResizeObserver = class {
observe() {}
unobserve() {}
disconnect() {}
} as unknown as typeof ResizeObserver;
});
// ── Mocks ─────────────────────────────────────────────────────────────────
// Stub @xyflow/react so ReactFlow renders without canvas/DOM measurement APIs
vi.mock("@xyflow/react", async () => {
const actual = (await vi.importActual("@xyflow/react")) as Record<string, unknown>;
return {
...actual,
Handle: (_props: Record<string, unknown>) => null,
Position: { Left: "left", Right: "right", Top: "top", Bottom: "bottom" },
};
});
// Stub next/image to avoid Next.js internals in jsdom
vi.mock("next/image", () => ({
default: (props: Record<string, unknown>) =>
React.createElement("img", { src: props.src as string, alt: props.alt as string }),
}));
// ── Import after mocks ─────────────────────────────────────────────────────
const { ComboLiveStudio } = await import("@/app/(dashboard)/dashboard/combos/live/ComboLiveStudio");
// ── Helpers ───────────────────────────────────────────────────────────────
const containers: HTMLElement[] = [];
function mount(ui: React.ReactElement): HTMLElement {
const container = document.createElement("div");
document.body.appendChild(container);
containers.push(container);
const root = createRoot(container);
act(() => {
root.render(ui);
});
return container;
}
beforeEach(() => {
(
globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }
).IS_REACT_ACT_ENVIRONMENT = true;
});
afterEach(() => {
while (containers.length > 0) {
containers.pop()?.remove();
}
document.body.innerHTML = "";
});
// ── Sample run: 2 failures then 1 success ────────────────────────────────
const SAMPLE_RUN: ComboRunModel = {
comboName: "daily-cascade",
strategy: "priority",
targets: [
{
targetIndex: 0,
provider: "openai",
model: "gpt-4o",
state: "failed",
latencyMs: 1200,
error: "429 rate limit exceeded",
failKind: "rate-limit",
},
{
targetIndex: 1,
provider: "anthropic",
model: "claude-3-sonnet",
state: "failed",
latencyMs: 800,
error: "circuit open after 5 failures",
failKind: "circuit-open",
},
{
targetIndex: 2,
provider: "gemini",
model: "gemini-1.5-pro",
state: "succeeded",
latencyMs: 620,
},
],
outcome: "succeeded",
startedAt: 1718000000000,
finishedAt: 1718000003000,
};
// ── Tests ─────────────────────────────────────────────────────────────────
describe("ComboLiveStudio", () => {
describe("with a static run prop", () => {
it("renders the studio wrapper", () => {
const container = mount(<ComboLiveStudio run={SAMPLE_RUN} />);
expect(container.querySelector("[data-testid='combo-live-studio']")).toBeTruthy();
});
it("renders the ReactFlow canvas (.react-flow)", () => {
const container = mount(<ComboLiveStudio run={SAMPLE_RUN} />);
expect(container.querySelector(".react-flow")).toBeTruthy();
});
it("shows the combo name in the toolbar", () => {
const container = mount(<ComboLiveStudio run={SAMPLE_RUN} />);
expect(container.textContent).toContain("daily-cascade");
});
it("shows the strategy in the toolbar", () => {
const container = mount(<ComboLiveStudio run={SAMPLE_RUN} />);
expect(container.textContent).toContain("priority");
});
it("shows the outcome in the toolbar", () => {
const container = mount(<ComboLiveStudio run={SAMPLE_RUN} />);
expect(container.querySelector("[data-testid='run-outcome']")?.textContent).toBe("succeeded");
});
it("shows provider names from target nodes", () => {
const container = mount(<ComboLiveStudio run={SAMPLE_RUN} />);
const text = container.textContent ?? "";
// Node internals rendered via ReactFlow in jsdom — at minimum the toolbar
// shows the combo name. Provider names appear in node elements if ReactFlow
// renders custom node interiors.
expect(text).toContain("daily-cascade");
});
it("does NOT render the combo selector when a run prop is supplied", () => {
const container = mount(<ComboLiveStudio run={SAMPLE_RUN} />);
expect(container.querySelector("[data-testid='combo-selector']")).toBeNull();
});
it("renders the Single/Fleet toggle buttons", () => {
const container = mount(<ComboLiveStudio run={SAMPLE_RUN} />);
expect(container.querySelector("[data-testid='mode-single']")).toBeTruthy();
expect(container.querySelector("[data-testid='mode-fleet']")).toBeTruthy();
});
it("does NOT show the disconnected banner when isConnected=true", () => {
const container = mount(<ComboLiveStudio run={SAMPLE_RUN} isConnected={true} />);
expect(container.querySelector("[data-testid='combo-disconnected-banner']")).toBeNull();
});
it("shows the disconnected banner when isConnected=false", () => {
const container = mount(<ComboLiveStudio run={SAMPLE_RUN} isConnected={false} />);
expect(container.querySelector("[data-testid='combo-disconnected-banner']")).toBeTruthy();
});
});
describe("empty state (no run, no events)", () => {
it("renders the empty state when run=null", () => {
const container = mount(<ComboLiveStudio run={null} />);
expect(container.querySelector("[data-testid='combo-live-studio-empty']")).toBeTruthy();
});
it("shows the empty state message", () => {
const container = mount(<ComboLiveStudio run={null} />);
expect(container.textContent).toContain("No combo run available");
});
it("shows the combo selector with no events", () => {
const container = mount(<ComboLiveStudio comboEvents={[]} />);
// No run prop → selector should appear
expect(container.querySelector("[data-testid='combo-selector']")).toBeTruthy();
});
it("renders the studio wrapper even when empty", () => {
const container = mount(<ComboLiveStudio />);
expect(container.querySelector("[data-testid='combo-live-studio']")).toBeTruthy();
});
});
describe("fleet mode", () => {
it("shows fleet overview after clicking Fleet toggle", () => {
const container = mount(<ComboLiveStudio run={SAMPLE_RUN} comboEvents={[]} />);
const fleetBtn = container.querySelector(
"[data-testid='mode-fleet']"
) as HTMLButtonElement | null;
expect(fleetBtn).toBeTruthy();
act(() => {
fleetBtn!.click();
});
// Fleet panel renders (either FleetOverview or its empty state)
expect(container.querySelector("[data-testid='combo-live-studio']")).toBeTruthy();
// The .react-flow canvas should no longer be visible in fleet mode
expect(container.querySelector(".react-flow")).toBeNull();
});
});
describe("disconnected state with a run", () => {
it("still renders the canvas when disconnected but run is provided", () => {
const container = mount(<ComboLiveStudio run={SAMPLE_RUN} isConnected={false} />);
// Canvas renders (graceful degrade — show last known state)
expect(container.querySelector(".react-flow")).toBeTruthy();
// Banner also shows
expect(container.querySelector("[data-testid='combo-disconnected-banner']")).toBeTruthy();
});
});
});