Files
fusion/packages/dashboard/app/components/settings/sections/ModelPricingSection.test.tsx
gsxdsm d739223de2 FN-7049: collapse model pricing behind modal editor
Collapse the Global Models pricing editor into a compact summary with a modal table for detailed edits.

- Add a View pricing table modal with escape, overlay, and close-button dismissal.
- Keep add, edit, delete, and LiteLLM fetch workflows available while showing override counts in the collapsed settings view.
- Update model pricing tests, styling, and documentation for the collapsed summary and modal editor.

Files changed:
 docs/dashboard-guide.md                            |   2 +-
 docs/settings-reference.md                         |   4 +-
 .../settings/sections/ModelPricingSection.css      |  36 +++++-
 .../settings/sections/ModelPricingSection.test.tsx |  62 ++++++++-
 .../settings/sections/ModelPricingSection.tsx      | 144 +++++++++++++++------
 5 files changed, 198 insertions(+), 50 deletions(-)

Fusion-Task-Id: FN-7049
Fusion-Task-Lineage: 9ba63354-28e7-4717-81e6-805f1f05fd97
2026-06-26 01:04:03 -07:00

175 lines
7.5 KiB
TypeScript

import { beforeEach, describe, expect, it, vi } from "vitest";
import { fireEvent, render, screen, waitFor, within } from "@testing-library/react";
import { useState } from "react";
import { ModelPricingSection } from "./ModelPricingSection";
import type { SettingsFormState } from "./context";
const { apiMock } = vi.hoisted(() => ({
apiMock: vi.fn(),
}));
vi.mock("react-i18next", () => ({
useTranslation: () => ({ t: (_key: string, fallback: string, vars?: Record<string, unknown>) => {
if (!vars) return fallback;
return Object.entries(vars).reduce((text, [key, value]) => text.replace(`{{${key}}}`, String(value)), fallback);
} }),
}));
// Mock the API helper so fetch actions stay deterministic and do not hit the dashboard server.
vi.mock("../../../api", () => ({
api: apiMock,
}));
function Harness({ initial, addToast = vi.fn() }: { initial: SettingsFormState; addToast?: (message: string, type?: "success" | "error" | "info" | "warning") => void }) {
const [form, setForm] = useState<SettingsFormState>(initial);
return (
<ModelPricingSection
form={form}
setForm={setForm}
addToast={addToast}
projectId="proj-a"
/>
);
}
const initialForm = (): SettingsFormState => ({
modelPricingOverrides: {
"openai:gpt-4o": {
inputPer1M: 2.5,
outputPer1M: 10,
cacheReadPer1M: 1.25,
cacheWritePer1M: 2.5,
source: "manual",
},
},
} as SettingsFormState);
function openPricingTable() {
fireEvent.click(screen.getByRole("button", { name: "View pricing table" }));
return screen.getByTestId("model-pricing-table-modal");
}
describe("ModelPricingSection", () => {
beforeEach(() => {
apiMock.mockReset();
});
it("renders collapsed override summary without mounting the table shell", () => {
render(<Harness initial={initialForm()} />);
expect(screen.getByText("1 pricing override")).toBeInTheDocument();
expect(screen.queryByText("openai:gpt-4o")).not.toBeInTheDocument();
expect(screen.queryByLabelText("openai:gpt-4o input per 1M")).not.toBeInTheDocument();
expect(screen.queryByRole("table", { name: "Model pricing overrides" })).not.toBeInTheDocument();
});
it("shows empty summary copy when no overrides exist", () => {
render(<Harness initial={{} as SettingsFormState} />);
expect(screen.getByText("0 pricing overrides")).toBeInTheDocument();
expect(screen.getByText("No model pricing overrides yet. Add one manually or fetch the latest LiteLLM prices.")).toBeInTheDocument();
expect(screen.queryByLabelText("New provider:model key")).not.toBeInTheDocument();
});
it("opens and closes the pricing table popup by close button, Escape, and overlay click", () => {
render(<Harness initial={initialForm()} />);
openPricingTable();
expect(screen.getByRole("dialog", { name: "Model pricing table" })).toBeInTheDocument();
fireEvent.click(screen.getByTestId("model-pricing-close"));
expect(screen.queryByRole("dialog", { name: "Model pricing table" })).not.toBeInTheDocument();
openPricingTable();
fireEvent.keyDown(document, { key: "Escape" });
expect(screen.queryByRole("dialog", { name: "Model pricing table" })).not.toBeInTheDocument();
const overlay = openPricingTable();
fireEvent.click(overlay);
expect(screen.queryByRole("dialog", { name: "Model pricing table" })).not.toBeInTheDocument();
});
it("renders existing overrides and edits a row inside the popup", () => {
render(<Harness initial={initialForm()} />);
openPricingTable();
expect(screen.getByText("openai:gpt-4o")).toBeInTheDocument();
const inputRate = screen.getByLabelText("openai:gpt-4o input per 1M");
fireEvent.change(inputRate, { target: { value: "3.75" } });
expect(screen.getByLabelText("openai:gpt-4o input per 1M")).toHaveValue(3.75);
});
it("adds and deletes pricing rows through form state inside the popup", () => {
render(<Harness initial={{} as SettingsFormState} />);
openPricingTable();
fireEvent.change(screen.getByLabelText("New provider:model key"), { target: { value: "Anthropic:Claude-Test" } });
fireEvent.change(screen.getByLabelText("New input rate"), { target: { value: "1" } });
fireEvent.change(screen.getByLabelText("New output rate"), { target: { value: "5" } });
fireEvent.change(screen.getByLabelText("New cache read rate"), { target: { value: "0.1" } });
fireEvent.change(screen.getByLabelText("New cache write rate"), { target: { value: "1.25" } });
fireEvent.change(screen.getByLabelText("New source"), { target: { value: "manual-test" } });
fireEvent.click(screen.getByRole("button", { name: "Add row" }));
expect(screen.getByText("anthropic:claude-test")).toBeInTheDocument();
expect(screen.getByLabelText("anthropic:claude-test output per 1M")).toHaveValue(5);
expect(screen.getByText("1 pricing override")).toBeInTheDocument();
fireEvent.click(screen.getByRole("button", { name: "Delete" }));
expect(screen.queryByText("anthropic:claude-test")).not.toBeInTheDocument();
expect(screen.getByText("0 pricing overrides")).toBeInTheDocument();
expect(within(screen.getByRole("dialog", { name: "Model pricing table" })).getByText("No model pricing overrides yet. Add one manually or fetch the latest LiteLLM prices.")).toBeInTheDocument();
});
it("shows an error toast and resets loading when pricing fetch fails from the collapsed view", async () => {
const addToast = vi.fn();
let rejectFetch: (error: Error) => void = () => undefined;
apiMock.mockImplementationOnce(() => new Promise((_resolve, reject) => {
rejectFetch = reject;
}));
render(<Harness initial={{} as SettingsFormState} addToast={addToast} />);
fireEvent.click(screen.getByRole("button", { name: "Fetch latest prices" }));
expect(await screen.findByRole("button", { name: "Fetching…" })).toBeDisabled();
rejectFetch(new Error("pricing unavailable"));
await waitFor(() => expect(addToast).toHaveBeenCalledWith("pricing unavailable", "error"));
await waitFor(() => expect(screen.getByRole("button", { name: "Fetch latest prices" })).not.toBeDisabled());
expect(screen.queryByLabelText("New provider:model key")).not.toBeInTheDocument();
});
it("fetch button calls the API and refreshes fetched pricing state while keeping the table collapsed", async () => {
apiMock
.mockResolvedValueOnce({ count: 1, fetchedAt: "2026-06-22T00:00:00.000Z", source: "litellm" })
.mockResolvedValueOnce({
modelPricingFetchedAt: "2026-06-22T00:00:00.000Z",
modelPricingSource: "litellm",
modelPricingOverrides: {
"openai:gpt-test": {
inputPer1M: 1,
outputPer1M: 2,
cacheReadPer1M: 1,
cacheWritePer1M: 1,
source: "litellm/model_prices_and_context_window.json",
},
},
});
render(<Harness initial={{} as SettingsFormState} />);
fireEvent.click(screen.getByRole("button", { name: "Fetch latest prices" }));
await waitFor(() => expect(apiMock).toHaveBeenCalledWith(
"/command-center/pricing/fetch?projectId=proj-a",
{ method: "POST" },
));
await waitFor(() => expect(screen.getByText("1 pricing override")).toBeInTheDocument());
expect(screen.queryByText("openai:gpt-test")).not.toBeInTheDocument();
expect(apiMock).toHaveBeenCalledTimes(2);
expect(screen.getByText(/Prices as of/)).toBeInTheDocument();
expect(screen.getByText(/litellm/)).toBeInTheDocument();
openPricingTable();
expect(screen.getByText("openai:gpt-test")).toBeInTheDocument();
});
});