mirror of
https://github.com/tiennm99/litellm.git
synced 2026-08-06 06:24:16 +00:00
[Test] UI - Add vitest unit tests for Teams, Models, and Usage components
Add v8-coverage-targeted unit tests for 5 previously untested files across the Teams, Models + Endpoints, and Usage workflows: - value_formatters.test.ts: full branch coverage of valueFormatter and valueFormatterSpend (M/k/plain formatting, zero-value edge cases) - DeleteTeamModal.test.tsx: confirmation input validation, key-count warning singular/plural, onConfirm/onCancel callbacks, input reset on cancel - TeamsFilters.test.tsx: search input binding, Filters toggle, Reset callback, additional-filters visibility, active-filter dot indicator - ModelsCell.test.tsx: empty/single/overflow model rendering, truncation at 30 chars, accordion expand/collapse, all-proxy-models badge in overflow - ModelRetrySettingsTab.test.tsx: global vs model-scope headings, defaultRetry fallback chain, setGlobalRetryPolicy/setModelGroupRetryPolicy updater functions, Save button callback Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 4.6
parent
8356dfa045
commit
1c1c84974f
+217
@@ -0,0 +1,217 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import React from "react";
|
||||
import { describe, beforeEach, expect, it, vi } from "vitest";
|
||||
import ModelRetrySettingsTab from "./ModelRetrySettingsTab";
|
||||
|
||||
// TabPanel requires a parent Tabs context in Tremor. We stub it to render children
|
||||
// directly so the component can be tested in isolation.
|
||||
vi.mock("@tremor/react", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("@tremor/react")>();
|
||||
return {
|
||||
...actual,
|
||||
TabPanel: ({ children }: { children: React.ReactNode }) => React.createElement("div", null, children),
|
||||
// Keep Select/SelectItem as the real implementation so scope-switching is testable
|
||||
};
|
||||
});
|
||||
|
||||
type GlobalRetryPolicy = { [key: string]: number };
|
||||
type ModelGroupRetryPolicy = { [key: string]: { [key: string]: number } | undefined };
|
||||
|
||||
const DEFAULT_RETRY = 0;
|
||||
|
||||
const buildProps = (overrides: Record<string, unknown> = {}) => ({
|
||||
selectedModelGroup: "global" as string | null,
|
||||
setSelectedModelGroup: vi.fn(),
|
||||
availableModelGroups: ["gpt-4", "claude-3-opus"],
|
||||
globalRetryPolicy: null as GlobalRetryPolicy | null,
|
||||
setGlobalRetryPolicy: vi.fn(),
|
||||
defaultRetry: DEFAULT_RETRY,
|
||||
modelGroupRetryPolicy: null as ModelGroupRetryPolicy | null,
|
||||
setModelGroupRetryPolicy: vi.fn(),
|
||||
handleSaveRetrySettings: vi.fn(),
|
||||
...overrides,
|
||||
});
|
||||
|
||||
describe("ModelRetrySettingsTab", () => {
|
||||
it("renders the 'Global Retry Policy' heading when selectedModelGroup is 'global'", () => {
|
||||
render(<ModelRetrySettingsTab {...buildProps()} />);
|
||||
|
||||
expect(screen.getByText("Global Retry Policy")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders a model-specific heading when a model group is selected", () => {
|
||||
render(<ModelRetrySettingsTab {...buildProps({ selectedModelGroup: "gpt-4" })} />);
|
||||
|
||||
expect(screen.getByText("Retry Policy for gpt-4")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders a row for every error type in the retry policy map", () => {
|
||||
render(<ModelRetrySettingsTab {...buildProps()} />);
|
||||
|
||||
expect(screen.getByText(/BadRequestError \(400\)/)).toBeInTheDocument();
|
||||
expect(screen.getByText(/AuthenticationError/)).toBeInTheDocument();
|
||||
expect(screen.getByText(/TimeoutError \(408\)/)).toBeInTheDocument();
|
||||
expect(screen.getByText(/RateLimitError \(429\)/)).toBeInTheDocument();
|
||||
expect(screen.getByText(/ContentPolicyViolationError \(400\)/)).toBeInTheDocument();
|
||||
expect(screen.getByText(/InternalServerError \(500\)/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("uses defaultRetry when globalRetryPolicy is null (global scope)", () => {
|
||||
render(<ModelRetrySettingsTab {...buildProps({ defaultRetry: 3 })} />);
|
||||
|
||||
// All 6 spinbutton inputs should show the defaultRetry value
|
||||
const inputs = screen.getAllByRole("spinbutton");
|
||||
inputs.forEach((input) => {
|
||||
expect(input).toHaveValue("3");
|
||||
});
|
||||
});
|
||||
|
||||
it("shows globalRetryPolicy values when they are set (global scope)", () => {
|
||||
const globalRetryPolicy: GlobalRetryPolicy = {
|
||||
RateLimitErrorRetries: 5,
|
||||
};
|
||||
render(<ModelRetrySettingsTab {...buildProps({ globalRetryPolicy, defaultRetry: 0 })} />);
|
||||
|
||||
// The RateLimitError row is the 4th entry in the map
|
||||
const inputs = screen.getAllByRole("spinbutton");
|
||||
const rateLimitInput = inputs[3]; // 0-indexed: Bad(0), Auth(1), Timeout(2), Rate(3)
|
||||
expect(rateLimitInput).toHaveValue("5");
|
||||
|
||||
// Unset entries fall back to defaultRetry (0)
|
||||
expect(inputs[0]).toHaveValue("0");
|
||||
});
|
||||
|
||||
it("falls back to globalRetryPolicy when no model-specific value is set (model scope)", () => {
|
||||
const globalRetryPolicy: GlobalRetryPolicy = {
|
||||
TimeoutErrorRetries: 7,
|
||||
};
|
||||
render(
|
||||
<ModelRetrySettingsTab
|
||||
{...buildProps({
|
||||
selectedModelGroup: "gpt-4",
|
||||
globalRetryPolicy,
|
||||
modelGroupRetryPolicy: null,
|
||||
defaultRetry: 1,
|
||||
})}
|
||||
/>,
|
||||
);
|
||||
|
||||
// The TimeoutError row is 3rd (index 2)
|
||||
const inputs = screen.getAllByRole("spinbutton");
|
||||
expect(inputs[2]).toHaveValue("7");
|
||||
|
||||
// Rows without a global value fall back to defaultRetry
|
||||
expect(inputs[0]).toHaveValue("1");
|
||||
});
|
||||
|
||||
it("prefers model-specific retry count over the global value (model scope)", () => {
|
||||
const globalRetryPolicy: GlobalRetryPolicy = {
|
||||
RateLimitErrorRetries: 3,
|
||||
};
|
||||
const modelGroupRetryPolicy: ModelGroupRetryPolicy = {
|
||||
"gpt-4": { RateLimitErrorRetries: 9 },
|
||||
};
|
||||
render(
|
||||
<ModelRetrySettingsTab
|
||||
{...buildProps({
|
||||
selectedModelGroup: "gpt-4",
|
||||
globalRetryPolicy,
|
||||
modelGroupRetryPolicy,
|
||||
defaultRetry: 0,
|
||||
})}
|
||||
/>,
|
||||
);
|
||||
|
||||
// The model-specific value (9) should win over global (3)
|
||||
const inputs = screen.getAllByRole("spinbutton");
|
||||
expect(inputs[3]).toHaveValue("9");
|
||||
});
|
||||
|
||||
it("shows the global reference value text for each row in model-specific scope", () => {
|
||||
const globalRetryPolicy: GlobalRetryPolicy = { BadRequestErrorRetries: 2 };
|
||||
render(
|
||||
<ModelRetrySettingsTab
|
||||
{...buildProps({
|
||||
selectedModelGroup: "gpt-4",
|
||||
globalRetryPolicy,
|
||||
defaultRetry: 0,
|
||||
})}
|
||||
/>,
|
||||
);
|
||||
|
||||
// "(Global: X)" annotations are shown next to each row label in model scope
|
||||
expect(screen.getByText("(Global: 2)")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("does not show global reference annotations in global scope", () => {
|
||||
render(<ModelRetrySettingsTab {...buildProps({ selectedModelGroup: "global" })} />);
|
||||
|
||||
expect(screen.queryByText(/Global:/)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("calls handleSaveRetrySettings when the Save button is clicked", async () => {
|
||||
const user = userEvent.setup();
|
||||
const handleSaveRetrySettings = vi.fn();
|
||||
render(<ModelRetrySettingsTab {...buildProps({ handleSaveRetrySettings })} />);
|
||||
|
||||
await user.click(screen.getByRole("button", { name: /save/i }));
|
||||
|
||||
expect(handleSaveRetrySettings).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("calls setGlobalRetryPolicy with an updater function when an input changes (global scope)", async () => {
|
||||
const user = userEvent.setup();
|
||||
const setGlobalRetryPolicy = vi.fn();
|
||||
render(
|
||||
<ModelRetrySettingsTab
|
||||
{...buildProps({
|
||||
selectedModelGroup: "global",
|
||||
globalRetryPolicy: { BadRequestErrorRetries: 0 },
|
||||
setGlobalRetryPolicy,
|
||||
defaultRetry: 0,
|
||||
})}
|
||||
/>,
|
||||
);
|
||||
|
||||
const inputs = screen.getAllByRole("spinbutton");
|
||||
await user.clear(inputs[0]);
|
||||
await user.type(inputs[0], "4");
|
||||
|
||||
// setGlobalRetryPolicy is called with a function updater
|
||||
expect(setGlobalRetryPolicy).toHaveBeenCalled();
|
||||
const updater = setGlobalRetryPolicy.mock.calls.at(-1)![0];
|
||||
expect(typeof updater).toBe("function");
|
||||
|
||||
// Calling the updater returns the merged policy
|
||||
const result = updater({ BadRequestErrorRetries: 0 });
|
||||
expect(result).toMatchObject({ BadRequestErrorRetries: 4 });
|
||||
});
|
||||
|
||||
it("calls setModelGroupRetryPolicy with an updater function when an input changes (model scope)", async () => {
|
||||
const user = userEvent.setup();
|
||||
const setModelGroupRetryPolicy = vi.fn();
|
||||
render(
|
||||
<ModelRetrySettingsTab
|
||||
{...buildProps({
|
||||
selectedModelGroup: "gpt-4",
|
||||
modelGroupRetryPolicy: { "gpt-4": { BadRequestErrorRetries: 0 } },
|
||||
setModelGroupRetryPolicy,
|
||||
defaultRetry: 0,
|
||||
})}
|
||||
/>,
|
||||
);
|
||||
|
||||
const inputs = screen.getAllByRole("spinbutton");
|
||||
await user.clear(inputs[0]);
|
||||
await user.type(inputs[0], "2");
|
||||
|
||||
expect(setModelGroupRetryPolicy).toHaveBeenCalled();
|
||||
const updater = setModelGroupRetryPolicy.mock.calls.at(-1)![0];
|
||||
expect(typeof updater).toBe("function");
|
||||
|
||||
// Calling the updater returns the merged model-group policy
|
||||
const result = updater({ "gpt-4": { BadRequestErrorRetries: 0 } });
|
||||
expect(result["gpt-4"]).toMatchObject({ BadRequestErrorRetries: 2 });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,151 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import React from "react";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { Organization } from "@/components/networking";
|
||||
import TeamsFilters from "./TeamsFilters";
|
||||
|
||||
type FilterState = {
|
||||
team_id: string;
|
||||
team_alias: string;
|
||||
organization_id: string;
|
||||
sort_by: string;
|
||||
sort_order: "asc" | "desc";
|
||||
};
|
||||
|
||||
const emptyFilters: FilterState = {
|
||||
team_alias: "",
|
||||
team_id: "",
|
||||
organization_id: "",
|
||||
sort_by: "",
|
||||
sort_order: "asc",
|
||||
};
|
||||
|
||||
const mockOrganizations: Organization[] = [
|
||||
{ organization_id: "org-1", organization_alias: "Acme Corp" } as Organization,
|
||||
{ organization_id: "org-2", organization_alias: "Globex" } as Organization,
|
||||
];
|
||||
|
||||
const renderFilters = (overrides: Partial<Parameters<typeof TeamsFilters>[0]> = {}) => {
|
||||
const defaults = {
|
||||
filters: emptyFilters,
|
||||
organizations: mockOrganizations,
|
||||
showFilters: false,
|
||||
onToggleFilters: vi.fn(),
|
||||
onChange: vi.fn(),
|
||||
onReset: vi.fn(),
|
||||
};
|
||||
return render(<TeamsFilters {...defaults} {...overrides} />);
|
||||
};
|
||||
|
||||
describe("TeamsFilters", () => {
|
||||
it("renders the team name search input, Filters button, and Reset Filters button", () => {
|
||||
renderFilters();
|
||||
|
||||
expect(screen.getByPlaceholderText("Search by Team Name...")).toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: /^filters$/i })).toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: /reset filters/i })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("reflects the current team_alias filter value in the search input", () => {
|
||||
renderFilters({ filters: { ...emptyFilters, team_alias: "Platform" } });
|
||||
|
||||
expect(screen.getByPlaceholderText("Search by Team Name...")).toHaveValue("Platform");
|
||||
});
|
||||
|
||||
it("calls onChange with 'team_alias' key when the search input changes", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onChange = vi.fn();
|
||||
renderFilters({ onChange });
|
||||
|
||||
await user.type(screen.getByPlaceholderText("Search by Team Name..."), "Dev");
|
||||
|
||||
expect(onChange).toHaveBeenCalledWith("team_alias", expect.stringContaining("D"));
|
||||
});
|
||||
|
||||
it("calls onToggleFilters with the inverted boolean when the Filters button is clicked", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onToggleFilters = vi.fn();
|
||||
renderFilters({ showFilters: false, onToggleFilters });
|
||||
|
||||
await user.click(screen.getByRole("button", { name: /^filters$/i }));
|
||||
|
||||
expect(onToggleFilters).toHaveBeenCalledWith(true);
|
||||
});
|
||||
|
||||
it("calls onToggleFilters(false) when filters are currently expanded", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onToggleFilters = vi.fn();
|
||||
renderFilters({ showFilters: true, onToggleFilters });
|
||||
|
||||
await user.click(screen.getByRole("button", { name: /^filters$/i }));
|
||||
|
||||
expect(onToggleFilters).toHaveBeenCalledWith(false);
|
||||
});
|
||||
|
||||
it("calls onReset when the Reset Filters button is clicked", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onReset = vi.fn();
|
||||
renderFilters({ onReset });
|
||||
|
||||
await user.click(screen.getByRole("button", { name: /reset filters/i }));
|
||||
|
||||
expect(onReset).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("does not show the Team ID input when showFilters is false", () => {
|
||||
renderFilters({ showFilters: false });
|
||||
|
||||
expect(screen.queryByPlaceholderText("Enter Team ID")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows the Team ID input when showFilters is true", () => {
|
||||
renderFilters({ showFilters: true });
|
||||
|
||||
expect(screen.getByPlaceholderText("Enter Team ID")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("calls onChange with 'team_id' key when the Team ID input changes", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onChange = vi.fn();
|
||||
renderFilters({ showFilters: true, onChange });
|
||||
|
||||
await user.type(screen.getByPlaceholderText("Enter Team ID"), "abc");
|
||||
|
||||
expect(onChange).toHaveBeenCalledWith("team_id", expect.stringContaining("a"));
|
||||
});
|
||||
|
||||
it("reflects the current team_id filter value in the Team ID input", () => {
|
||||
renderFilters({ showFilters: true, filters: { ...emptyFilters, team_id: "team-xyz" } });
|
||||
|
||||
expect(screen.getByPlaceholderText("Enter Team ID")).toHaveValue("team-xyz");
|
||||
});
|
||||
|
||||
it("shows a blue dot indicator on the Filters button when team_alias filter is active", () => {
|
||||
renderFilters({ filters: { ...emptyFilters, team_alias: "Platform" } });
|
||||
|
||||
const filtersButton = screen.getByRole("button", { name: /^filters$/i });
|
||||
expect(filtersButton.querySelector(".bg-blue-500")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows a blue dot indicator on the Filters button when team_id filter is active", () => {
|
||||
renderFilters({ filters: { ...emptyFilters, team_id: "team-123" } });
|
||||
|
||||
const filtersButton = screen.getByRole("button", { name: /^filters$/i });
|
||||
expect(filtersButton.querySelector(".bg-blue-500")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows a blue dot indicator on the Filters button when organization_id filter is active", () => {
|
||||
renderFilters({ filters: { ...emptyFilters, organization_id: "org-1" } });
|
||||
|
||||
const filtersButton = screen.getByRole("button", { name: /^filters$/i });
|
||||
expect(filtersButton.querySelector(".bg-blue-500")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("does not show the blue dot indicator when all filters are empty", () => {
|
||||
renderFilters({ filters: emptyFilters });
|
||||
|
||||
const filtersButton = screen.getByRole("button", { name: /^filters$/i });
|
||||
expect(filtersButton.querySelector(".bg-blue-500")).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
+130
@@ -0,0 +1,130 @@
|
||||
import { render, screen, fireEvent } from "@testing-library/react";
|
||||
import React from "react";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { Team } from "@/components/key_team_helpers/key_list";
|
||||
import ModelsCell from "./ModelsCell";
|
||||
|
||||
// The Icon component from @tremor/react does not forward onClick to the rendered element
|
||||
// by default in the test environment, so we stub it with a clickable button so accordion
|
||||
// interaction can be tested end-to-end.
|
||||
vi.mock("@tremor/react", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("@tremor/react")>();
|
||||
return {
|
||||
...actual,
|
||||
Icon: ({ onClick, "aria-label": ariaLabel }: { onClick?: () => void; "aria-label"?: string }) =>
|
||||
React.createElement("button", { onClick, "aria-label": ariaLabel ?? "accordion-toggle", type: "button" }),
|
||||
};
|
||||
});
|
||||
|
||||
const makeTeam = (models: string[], overrides: Partial<Team> = {}): Team => ({
|
||||
team_id: "team-1",
|
||||
team_alias: "Engineering",
|
||||
models,
|
||||
max_budget: null,
|
||||
budget_duration: null,
|
||||
tpm_limit: null,
|
||||
rpm_limit: null,
|
||||
organization_id: "org-1",
|
||||
created_at: "2024-01-01T00:00:00Z",
|
||||
keys: [],
|
||||
members_with_roles: [],
|
||||
spend: 0,
|
||||
...overrides,
|
||||
});
|
||||
|
||||
// Wrap in a table so the <td> from TableCell renders without HTML warnings.
|
||||
const renderModelsCell = (team: Team) =>
|
||||
render(
|
||||
<table>
|
||||
<tbody>
|
||||
<tr>
|
||||
<ModelsCell team={team} />
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>,
|
||||
);
|
||||
|
||||
describe("ModelsCell", () => {
|
||||
it("shows 'All Proxy Models' badge when the models array is empty", () => {
|
||||
renderModelsCell(makeTeam([]));
|
||||
|
||||
expect(screen.getByText("All Proxy Models")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows an 'All Proxy Models' badge when the model value is 'all-proxy-models'", () => {
|
||||
renderModelsCell(makeTeam(["all-proxy-models"]));
|
||||
|
||||
expect(screen.getByText("All Proxy Models")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("displays individual model badges for up to 3 models without an accordion", () => {
|
||||
renderModelsCell(makeTeam(["gpt-4", "gpt-3.5-turbo", "claude-3"]));
|
||||
|
||||
expect(screen.getByText("gpt-4")).toBeInTheDocument();
|
||||
expect(screen.getByText("gpt-3.5-turbo")).toBeInTheDocument();
|
||||
expect(screen.getByText("claude-3")).toBeInTheDocument();
|
||||
expect(screen.queryByRole("button", { name: /accordion/i })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("truncates model names longer than 30 characters with an ellipsis", () => {
|
||||
const longName = "a-very-long-model-name-exceeding-thirty-chars";
|
||||
renderModelsCell(makeTeam([longName]));
|
||||
|
||||
const badge = screen.getByText((text) => text.endsWith("..."));
|
||||
expect(badge).toBeInTheDocument();
|
||||
expect(badge.textContent!.length).toBeLessThanOrEqual(33); // 30 chars + "..."
|
||||
});
|
||||
|
||||
it("shows the first 3 models and a '+N more models' badge when there are more than 3 models", () => {
|
||||
renderModelsCell(makeTeam(["m1", "m2", "m3", "m4", "m5"]));
|
||||
|
||||
expect(screen.getByText("m1")).toBeInTheDocument();
|
||||
expect(screen.getByText("m2")).toBeInTheDocument();
|
||||
expect(screen.getByText("m3")).toBeInTheDocument();
|
||||
expect(screen.getByText("+2 more models")).toBeInTheDocument();
|
||||
expect(screen.queryByText("m4")).not.toBeInTheDocument();
|
||||
expect(screen.queryByText("m5")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("uses singular 'more model' when there is exactly 1 overflow model", () => {
|
||||
renderModelsCell(makeTeam(["m1", "m2", "m3", "m4"]));
|
||||
|
||||
expect(screen.getByText("+1 more model")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows the accordion toggle button when there are more than 3 models", () => {
|
||||
renderModelsCell(makeTeam(["m1", "m2", "m3", "m4"]));
|
||||
|
||||
expect(screen.getByRole("button", { name: /accordion/i })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("expands to show all models when the accordion toggle is clicked", () => {
|
||||
renderModelsCell(makeTeam(["m1", "m2", "m3", "m4", "m5"]));
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: /accordion/i }));
|
||||
|
||||
expect(screen.getByText("m4")).toBeInTheDocument();
|
||||
expect(screen.getByText("m5")).toBeInTheDocument();
|
||||
expect(screen.queryByText("+2 more models")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("collapses back to show the overflow badge after a second click on the toggle", () => {
|
||||
renderModelsCell(makeTeam(["m1", "m2", "m3", "m4", "m5"]));
|
||||
|
||||
const toggle = screen.getByRole("button", { name: /accordion/i });
|
||||
fireEvent.click(toggle);
|
||||
fireEvent.click(toggle);
|
||||
|
||||
expect(screen.queryByText("m4")).not.toBeInTheDocument();
|
||||
expect(screen.getByText("+2 more models")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders 'all-proxy-models' entries in the overflow section as 'All Proxy Models' badges", () => {
|
||||
renderModelsCell(makeTeam(["m1", "m2", "m3", "all-proxy-models"]));
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: /accordion/i }));
|
||||
|
||||
// There should now be an "All Proxy Models" badge in the expanded section
|
||||
expect(screen.getByText("All Proxy Models")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
+175
@@ -0,0 +1,175 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import React from "react";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { Team } from "@/components/key_team_helpers/key_list";
|
||||
import DeleteTeamModal from "./DeleteTeamModal";
|
||||
|
||||
const makeTeam = (overrides: Partial<Team> = {}): Team => ({
|
||||
team_id: "team-1",
|
||||
team_alias: "Engineering",
|
||||
models: [],
|
||||
max_budget: null,
|
||||
budget_duration: null,
|
||||
tpm_limit: null,
|
||||
rpm_limit: null,
|
||||
organization_id: "org-1",
|
||||
created_at: "2024-01-01T00:00:00Z",
|
||||
keys: [],
|
||||
members_with_roles: [],
|
||||
spend: 0,
|
||||
...overrides,
|
||||
});
|
||||
|
||||
const renderModal = (props: Partial<Parameters<typeof DeleteTeamModal>[0]> = {}) => {
|
||||
const defaults = {
|
||||
teams: [makeTeam()],
|
||||
teamToDelete: "team-1",
|
||||
onCancel: vi.fn(),
|
||||
onConfirm: vi.fn(),
|
||||
};
|
||||
return render(<DeleteTeamModal {...defaults} {...props} />);
|
||||
};
|
||||
|
||||
describe("DeleteTeamModal", () => {
|
||||
it("renders the title, team name label, and confirmation input", () => {
|
||||
renderModal();
|
||||
|
||||
expect(screen.getByText("Delete Team")).toBeInTheDocument();
|
||||
expect(screen.getByText("Engineering")).toBeInTheDocument();
|
||||
expect(screen.getByPlaceholderText("Enter team name exactly")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders Cancel and Force Delete buttons", () => {
|
||||
renderModal();
|
||||
|
||||
expect(screen.getByRole("button", { name: /^cancel$/i })).toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: /force delete/i })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("does not show the warning banner when the team has no keys", () => {
|
||||
renderModal({ teams: [makeTeam({ keys: [] })] });
|
||||
|
||||
expect(screen.queryByText(/Warning/i)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows a warning with singular 'key' when the team has exactly 1 key", () => {
|
||||
const team = makeTeam({ keys: [{ token: "tok-1" } as any] });
|
||||
renderModal({ teams: [team] });
|
||||
|
||||
expect(screen.getByText(/This team has 1 associated key\./)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows a warning with plural 'keys' when the team has multiple keys", () => {
|
||||
const team = makeTeam({
|
||||
keys: [{ token: "tok-1" } as any, { token: "tok-2" } as any, { token: "tok-3" } as any],
|
||||
});
|
||||
renderModal({ teams: [team] });
|
||||
|
||||
expect(screen.getByText(/This team has 3 associated keys\./)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("notes that associated keys will also be deleted in the warning", () => {
|
||||
const team = makeTeam({ keys: [{ token: "tok-1" } as any] });
|
||||
renderModal({ teams: [team] });
|
||||
|
||||
expect(screen.getByText(/Deleting the team will also delete all associated keys/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("Force Delete button is disabled when the input is empty", () => {
|
||||
renderModal();
|
||||
|
||||
expect(screen.getByRole("button", { name: /force delete/i })).toBeDisabled();
|
||||
});
|
||||
|
||||
it("Force Delete button remains disabled when the input does not exactly match the team name", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderModal();
|
||||
|
||||
await user.type(screen.getByPlaceholderText("Enter team name exactly"), "engineer");
|
||||
|
||||
expect(screen.getByRole("button", { name: /force delete/i })).toBeDisabled();
|
||||
});
|
||||
|
||||
it("enables Force Delete only after typing the exact team name (case-sensitive)", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderModal();
|
||||
|
||||
const input = screen.getByPlaceholderText("Enter team name exactly");
|
||||
|
||||
await user.type(input, "Engineering");
|
||||
|
||||
expect(screen.getByRole("button", { name: /force delete/i })).toBeEnabled();
|
||||
});
|
||||
|
||||
it("calls onConfirm when Force Delete is clicked with a valid input", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onConfirm = vi.fn();
|
||||
renderModal({ onConfirm });
|
||||
|
||||
await user.type(screen.getByPlaceholderText("Enter team name exactly"), "Engineering");
|
||||
await user.click(screen.getByRole("button", { name: /force delete/i }));
|
||||
|
||||
expect(onConfirm).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("does not call onConfirm when Force Delete is clicked with an invalid input", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onConfirm = vi.fn();
|
||||
renderModal({ onConfirm });
|
||||
|
||||
// Button is disabled so click has no effect
|
||||
await user.click(screen.getByRole("button", { name: /force delete/i }));
|
||||
|
||||
expect(onConfirm).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("calls onCancel when the Cancel button is clicked", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onCancel = vi.fn();
|
||||
renderModal({ onCancel });
|
||||
|
||||
await user.click(screen.getByRole("button", { name: /^cancel$/i }));
|
||||
|
||||
expect(onCancel).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("calls onCancel when the X close button is clicked", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onCancel = vi.fn();
|
||||
renderModal({ onCancel });
|
||||
|
||||
// The first button in the header is the X close button (no accessible label)
|
||||
const allButtons = screen.getAllByRole("button");
|
||||
const xButton = allButtons[0];
|
||||
await user.click(xButton);
|
||||
|
||||
expect(onCancel).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("resets the confirmation input when Cancel is clicked", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderModal();
|
||||
|
||||
const input = screen.getByPlaceholderText("Enter team name exactly");
|
||||
await user.type(input, "Engineering");
|
||||
expect(input).toHaveValue("Engineering");
|
||||
|
||||
await user.click(screen.getByRole("button", { name: /^cancel$/i }));
|
||||
|
||||
expect(input).toHaveValue("");
|
||||
});
|
||||
|
||||
it("resets the confirmation input when the X close button is clicked", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderModal();
|
||||
|
||||
const input = screen.getByPlaceholderText("Enter team name exactly");
|
||||
await user.type(input, "Engineering");
|
||||
|
||||
const allButtons = screen.getAllByRole("button");
|
||||
await user.click(allButtons[0]);
|
||||
|
||||
expect(input).toHaveValue("");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,64 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { valueFormatter, valueFormatterSpend } from "./value_formatters";
|
||||
|
||||
describe("valueFormatter", () => {
|
||||
it("formats numbers >= 1,000,000 as millions with 2 decimal places", () => {
|
||||
expect(valueFormatter(1_000_000)).toBe("1.00M");
|
||||
expect(valueFormatter(1_500_000)).toBe("1.50M");
|
||||
expect(valueFormatter(2_750_000)).toBe("2.75M");
|
||||
expect(valueFormatter(10_000_000)).toBe("10.00M");
|
||||
});
|
||||
|
||||
it("formats numbers in the thousands range as 'k' suffix", () => {
|
||||
expect(valueFormatter(1_000)).toBe("1k");
|
||||
expect(valueFormatter(5_500)).toBe("5.5k");
|
||||
expect(valueFormatter(999_999)).toBe("999.999k");
|
||||
});
|
||||
|
||||
it("returns the plain string for numbers below 1,000", () => {
|
||||
expect(valueFormatter(0)).toBe("0");
|
||||
expect(valueFormatter(1)).toBe("1");
|
||||
expect(valueFormatter(999)).toBe("999");
|
||||
expect(valueFormatter(42)).toBe("42");
|
||||
});
|
||||
|
||||
it("treats exactly 1,000,000 as millions boundary", () => {
|
||||
expect(valueFormatter(1_000_000)).toBe("1.00M");
|
||||
});
|
||||
|
||||
it("treats exactly 1,000 as thousands boundary", () => {
|
||||
expect(valueFormatter(1_000)).toBe("1k");
|
||||
});
|
||||
});
|
||||
|
||||
describe("valueFormatterSpend", () => {
|
||||
it("returns '$0' when the value is exactly zero", () => {
|
||||
expect(valueFormatterSpend(0)).toBe("$0");
|
||||
});
|
||||
|
||||
it("formats numbers >= 1,000,000 as dollar millions", () => {
|
||||
expect(valueFormatterSpend(1_000_000)).toBe("$1M");
|
||||
expect(valueFormatterSpend(2_500_000)).toBe("$2.5M");
|
||||
expect(valueFormatterSpend(10_000_000)).toBe("$10M");
|
||||
});
|
||||
|
||||
it("formats numbers >= 1,000 as dollar thousands", () => {
|
||||
expect(valueFormatterSpend(1_000)).toBe("$1k");
|
||||
expect(valueFormatterSpend(5_500)).toBe("$5.5k");
|
||||
expect(valueFormatterSpend(999_999)).toBe("$999.999k");
|
||||
});
|
||||
|
||||
it("formats numbers below 1,000 as plain dollar amounts", () => {
|
||||
expect(valueFormatterSpend(1)).toBe("$1");
|
||||
expect(valueFormatterSpend(99.99)).toBe("$99.99");
|
||||
expect(valueFormatterSpend(999)).toBe("$999");
|
||||
});
|
||||
|
||||
it("treats exactly 1,000,000 as millions boundary", () => {
|
||||
expect(valueFormatterSpend(1_000_000)).toBe("$1M");
|
||||
});
|
||||
|
||||
it("treats exactly 1,000 as thousands boundary", () => {
|
||||
expect(valueFormatterSpend(1_000)).toBe("$1k");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user