diff --git a/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/components/ModelRetrySettingsTab.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/components/ModelRetrySettingsTab.test.tsx new file mode 100644 index 0000000000..d0c27b9fd4 --- /dev/null +++ b/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/components/ModelRetrySettingsTab.test.tsx @@ -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(); + 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 = {}) => ({ + 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(); + + expect(screen.getByText("Global Retry Policy")).toBeInTheDocument(); + }); + + it("renders a model-specific heading when a model group is selected", () => { + render(); + + expect(screen.getByText("Retry Policy for gpt-4")).toBeInTheDocument(); + }); + + it("renders a row for every error type in the retry policy map", () => { + render(); + + 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(); + + // 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(); + + // 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( + , + ); + + // 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( + , + ); + + // 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( + , + ); + + // "(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(); + + 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(); + + 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( + , + ); + + 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( + , + ); + + 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 }); + }); +}); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/teams/components/TeamsFilters.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/teams/components/TeamsFilters.test.tsx new file mode 100644 index 0000000000..ed4e0eac49 --- /dev/null +++ b/ui/litellm-dashboard/src/app/(dashboard)/teams/components/TeamsFilters.test.tsx @@ -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[0]> = {}) => { + const defaults = { + filters: emptyFilters, + organizations: mockOrganizations, + showFilters: false, + onToggleFilters: vi.fn(), + onChange: vi.fn(), + onReset: vi.fn(), + }; + return render(); +}; + +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(); + }); +}); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/teams/components/TeamsTable/ModelsCell.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/teams/components/TeamsTable/ModelsCell.test.tsx new file mode 100644 index 0000000000..26fc985f46 --- /dev/null +++ b/ui/litellm-dashboard/src/app/(dashboard)/teams/components/TeamsTable/ModelsCell.test.tsx @@ -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(); + 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_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 from TableCell renders without HTML warnings. +const renderModelsCell = (team: Team) => + render( + + + + + + +
, + ); + +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(); + }); +}); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/teams/components/modals/DeleteTeamModal.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/teams/components/modals/DeleteTeamModal.test.tsx new file mode 100644 index 0000000000..13ed107334 --- /dev/null +++ b/ui/litellm-dashboard/src/app/(dashboard)/teams/components/modals/DeleteTeamModal.test.tsx @@ -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_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[0]> = {}) => { + const defaults = { + teams: [makeTeam()], + teamToDelete: "team-1", + onCancel: vi.fn(), + onConfirm: vi.fn(), + }; + return render(); +}; + +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(""); + }); +}); diff --git a/ui/litellm-dashboard/src/components/UsagePage/utils/value_formatters.test.ts b/ui/litellm-dashboard/src/components/UsagePage/utils/value_formatters.test.ts new file mode 100644 index 0000000000..dee726c04c --- /dev/null +++ b/ui/litellm-dashboard/src/components/UsagePage/utils/value_formatters.test.ts @@ -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"); + }); +});