sentiment-scope / frontend /src /components /CompareModels.test.tsx
melkholy's picture
Ship hardening wave: off-loop CSV inference, model_ids cap/dedupe, real detector Hub ids, comment fixes
019b6f4 verified
Raw
History Blame Contribute Delete
5.39 kB
import { expect, it, vi } from "vitest";
import { act, render, screen, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import CompareModels from "./CompareModels";
import { compareModels, getModels } from "../api";
// CompareModels suspends on first render while the registry promise settles,
// so React 19 requires the initial render inside an awaited act().
const renderCompare = () => act(async () => render(<CompareModels />));
vi.mock("../api", () => ({ compareModels: vi.fn(), getModels: vi.fn() }));
const registry = {
models: [
{
id: "twitter-roberta",
name: "cardiffnlp/twitter-roberta-base-sentiment-latest",
task: "sentiment",
labels: ["negative", "neutral", "positive"],
domain: "social / short English text",
note: "",
default: true,
loaded: true,
},
{
id: "distilbert-sst2",
name: "distilbert-base-uncased-finetuned-sst-2-english",
task: "sentiment",
labels: ["negative", "positive"],
domain: "general binary sentiment",
note: "",
default: true,
loaded: true,
},
{
id: "finbert",
name: "ProsusAI/finbert",
task: "sentiment",
labels: ["negative", "neutral", "positive"],
domain: "financial text",
note: "Useful for finance/news sentences; misleading outside that domain.",
default: false,
loaded: false,
},
],
};
it("checks the two default models and leaves lazy optional models off with a load note", async () => {
vi.mocked(getModels).mockResolvedValue(registry);
await renderCompare();
expect(await screen.findByRole("checkbox", { name: /twitter-roberta/ })).toBeChecked();
expect(screen.getByRole("checkbox", { name: /distilbert-sst2/ })).toBeChecked();
expect(screen.getByRole("checkbox", { name: /finbert/ })).not.toBeChecked();
// Optional models are lazy-loaded server-side; the UI must warn about the
// one-time load cost before the user opts in.
expect(screen.getByText(/loads on first use/i)).toBeInTheDocument();
expect(getModels).toHaveBeenCalledWith("sentiment");
expect(compareModels).not.toHaveBeenCalled();
});
it("compares the selected models and renders one readout per model with its own score keys", async () => {
vi.mocked(getModels).mockResolvedValue(registry);
vi.mocked(compareModels).mockResolvedValue({
results: [
{
model_id: "twitter-roberta",
name: "cardiffnlp/twitter-roberta-base-sentiment-latest",
domain: "social / short English text",
label: "positive",
scores: { negative: 0.03, neutral: 0.17, positive: 0.8 },
confidence: 0.8,
latency_ms: 42.6,
note: "",
},
{
model_id: "distilbert-sst2",
name: "distilbert-base-uncased-finetuned-sst-2-english",
domain: "general binary sentiment",
label: "negative",
scores: { negative: 0.61, positive: 0.39 },
confidence: 0.61,
latency_ms: 17.2,
note: "Binary model: it has no neutral class to fall back on.",
},
],
});
const user = userEvent.setup();
await renderCompare();
await user.click(await screen.findByRole("button", { name: "Compare models" }));
const roberta = await screen.findByRole("group", { name: "twitter-roberta" });
const distilbert = screen.getByRole("group", { name: "distilbert-sst2" });
// Each readout shows its prediction (badge + score bar), confidence
// (readout + winning bar), and latency.
expect(within(roberta).getAllByText("positive")).toHaveLength(2);
expect(within(roberta).getAllByText("80.0%")).toHaveLength(2);
expect(within(roberta).getByText("43 ms")).toBeInTheDocument();
expect(within(distilbert).getAllByText("negative")).toHaveLength(2);
expect(within(distilbert).getByText("17 ms")).toBeInTheDocument();
expect(within(distilbert).getByText(/no neutral class/)).toBeInTheDocument();
// Dynamic scores: the 2-class model renders only its own keys.
expect(within(distilbert).queryByText("neutral")).not.toBeInTheDocument();
expect(within(roberta).getByText("neutral")).toBeInTheDocument();
// The default textarea text and the default selection go to the API as-is.
expect(compareModels).toHaveBeenCalledWith("Our quarterly revenue outlook improved", [
"twitter-roberta",
"distilbert-sst2",
]);
});
it("includes an opted-in lazy model in the request", async () => {
vi.mocked(getModels).mockResolvedValue(registry);
vi.mocked(compareModels).mockResolvedValue({ results: [] });
const user = userEvent.setup();
await renderCompare();
await user.click(await screen.findByRole("checkbox", { name: /finbert/ }));
await user.click(screen.getByRole("button", { name: "Compare models" }));
expect(compareModels).toHaveBeenCalledWith("Our quarterly revenue outlook improved", [
"twitter-roberta",
"distilbert-sst2",
"finbert",
]);
});
it("surfaces the API error detail when the comparison fails", async () => {
vi.mocked(getModels).mockResolvedValue(registry);
vi.mocked(compareModels).mockRejectedValue(new Error("Unknown model id: finbert"));
const user = userEvent.setup();
await renderCompare();
await user.click(await screen.findByRole("button", { name: "Compare models" }));
expect(await screen.findByText("Unknown model id: finbert")).toBeInTheDocument();
});