diff --git a/messages/en/dashboard.json b/messages/en/dashboard.json index 56bf31b22..5c7a5cb15 100644 --- a/messages/en/dashboard.json +++ b/messages/en/dashboard.json @@ -450,6 +450,11 @@ "providerRanking": "Provider Rankings", "providerCacheHitRateRanking": "Provider Cache Hit Rate", "modelRanking": "Model Rankings", + "primaryUser": "Users", + "primaryProvider": "Providers", + "primaryModel": "Models", + "secondaryCost": "Cost", + "secondaryCacheHit": "Cache Hit Rate", "dailyRanking": "Today", "weeklyRanking": "This Week", "monthlyRanking": "This Month", diff --git a/messages/ja/dashboard.json b/messages/ja/dashboard.json index 5264462ad..9f233501a 100644 --- a/messages/ja/dashboard.json +++ b/messages/ja/dashboard.json @@ -450,6 +450,11 @@ "providerRanking": "プロバイダーランキング", "providerCacheHitRateRanking": "プロバイダーキャッシュ命中率", "modelRanking": "モデルランキング", + "primaryUser": "ユーザー", + "primaryProvider": "プロバイダー", + "primaryModel": "モデル", + "secondaryCost": "コスト", + "secondaryCacheHit": "キャッシュ命中率", "dailyRanking": "本日", "weeklyRanking": "今週", "monthlyRanking": "今月", diff --git a/messages/ru/dashboard.json b/messages/ru/dashboard.json index b47b62762..499bcf76d 100644 --- a/messages/ru/dashboard.json +++ b/messages/ru/dashboard.json @@ -450,6 +450,11 @@ "providerRanking": "Рейтинг поставщиков", "providerCacheHitRateRanking": "Рейтинг по попаданиям в кэш", "modelRanking": "Рейтинг моделей", + "primaryUser": "Пользователи", + "primaryProvider": "Поставщики", + "primaryModel": "Модели", + "secondaryCost": "По стоимости", + "secondaryCacheHit": "По попаданиям в кэш", "dailyRanking": "Сегодня", "weeklyRanking": "Эта неделя", "monthlyRanking": "Этот месяц", diff --git a/messages/zh-CN/dashboard.json b/messages/zh-CN/dashboard.json index 2d34f5553..4c7abf8d3 100644 --- a/messages/zh-CN/dashboard.json +++ b/messages/zh-CN/dashboard.json @@ -450,6 +450,11 @@ "providerRanking": "供应商排行", "providerCacheHitRateRanking": "供应商缓存命中率排行", "modelRanking": "模型排行", + "primaryUser": "用户", + "primaryProvider": "供应商", + "primaryModel": "模型", + "secondaryCost": "成本榜", + "secondaryCacheHit": "缓存命中榜", "dailyRanking": "今日", "weeklyRanking": "本周", "monthlyRanking": "本月", diff --git a/messages/zh-TW/dashboard.json b/messages/zh-TW/dashboard.json index 7b4d0a413..3994bf2e2 100644 --- a/messages/zh-TW/dashboard.json +++ b/messages/zh-TW/dashboard.json @@ -450,6 +450,11 @@ "providerRanking": "供應商排名", "providerCacheHitRateRanking": "供應商快取命中率排行", "modelRanking": "模型排名", + "primaryUser": "使用者", + "primaryProvider": "供應商", + "primaryModel": "模型榜", + "secondaryCost": "成本排名", + "secondaryCacheHit": "快取命中榜", "dailyRanking": "今天", "weeklyRanking": "本週", "monthlyRanking": "當月", diff --git a/src/app/[locale]/dashboard/leaderboard/_components/leaderboard-primary-tabs.tsx b/src/app/[locale]/dashboard/leaderboard/_components/leaderboard-primary-tabs.tsx new file mode 100644 index 000000000..6a5352eba --- /dev/null +++ b/src/app/[locale]/dashboard/leaderboard/_components/leaderboard-primary-tabs.tsx @@ -0,0 +1,50 @@ +"use client"; + +import type { LeaderboardPrimaryTab } from "@/app/[locale]/dashboard/leaderboard/_components/leaderboard-tab-groups"; +import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs"; + +interface LeaderboardPrimaryTabLabels { + user: string; + provider: string; + model: string; +} + +interface LeaderboardPrimaryTabsProps { + isAdmin: boolean; + activePrimaryTab: LeaderboardPrimaryTab; + onPrimaryChange: (tab: LeaderboardPrimaryTab) => void; + labels: LeaderboardPrimaryTabLabels; +} + +export function LeaderboardPrimaryTabs({ + isAdmin, + activePrimaryTab, + onPrimaryChange, + labels, +}: LeaderboardPrimaryTabsProps) { + return ( + onPrimaryChange(value as LeaderboardPrimaryTab)} + > + + + {labels.user} + + {isAdmin ? ( + + {labels.provider} + + ) : null} + {isAdmin ? ( + + {labels.model} + + ) : null} + + + ); +} diff --git a/src/app/[locale]/dashboard/leaderboard/_components/leaderboard-secondary-tabs.tsx b/src/app/[locale]/dashboard/leaderboard/_components/leaderboard-secondary-tabs.tsx new file mode 100644 index 000000000..5b9c38f69 --- /dev/null +++ b/src/app/[locale]/dashboard/leaderboard/_components/leaderboard-secondary-tabs.tsx @@ -0,0 +1,46 @@ +"use client"; + +import type { + LeaderboardPrimaryTab, + LeaderboardSecondaryTab, +} from "@/app/[locale]/dashboard/leaderboard/_components/leaderboard-tab-groups"; +import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs"; + +interface LeaderboardSecondaryTabLabels { + cost: string; + cacheHit: string; +} + +interface LeaderboardSecondaryTabsProps { + activePrimaryTab: LeaderboardPrimaryTab; + activeSecondaryTab: LeaderboardSecondaryTab | null; + onSecondaryChange: (tab: LeaderboardSecondaryTab) => void; + labels: LeaderboardSecondaryTabLabels; +} + +export function LeaderboardSecondaryTabs({ + activePrimaryTab, + activeSecondaryTab, + onSecondaryChange, + labels, +}: LeaderboardSecondaryTabsProps) { + if (activePrimaryTab === "model") { + return null; + } + + return ( + onSecondaryChange(value as LeaderboardSecondaryTab)} + > + + + {labels.cost} + + + {labels.cacheHit} + + + + ); +} diff --git a/src/app/[locale]/dashboard/leaderboard/_components/leaderboard-tab-groups.ts b/src/app/[locale]/dashboard/leaderboard/_components/leaderboard-tab-groups.ts new file mode 100644 index 000000000..9b182904f --- /dev/null +++ b/src/app/[locale]/dashboard/leaderboard/_components/leaderboard-tab-groups.ts @@ -0,0 +1,84 @@ +export type LeaderboardLeafScope = + | "user" + | "userCacheHitRate" + | "provider" + | "providerCacheHitRate" + | "model"; + +export type LeaderboardPrimaryTab = "user" | "provider" | "model"; +export type LeaderboardPrimaryTabWithSecondary = Exclude; +export type LeaderboardSecondaryTab = "cost" | "cacheHit"; + +export function normalizeScopeFromUrl( + scope: string | null | undefined, + isAdmin: boolean +): LeaderboardLeafScope { + if (scope === "user") { + return "user"; + } + + if ( + isAdmin && + (scope === "userCacheHitRate" || + scope === "provider" || + scope === "providerCacheHitRate" || + scope === "model") + ) { + return scope; + } + + return "user"; +} + +export function getPrimaryTabFromScope(scope: LeaderboardLeafScope): LeaderboardPrimaryTab { + if (scope === "provider" || scope === "providerCacheHitRate") { + return "provider"; + } + + if (scope === "model") { + return "model"; + } + + return "user"; +} + +export function isUserFamilyScope(scope: LeaderboardLeafScope): boolean { + return getPrimaryTabFromScope(scope) === "user"; +} + +export function isProviderFamilyScope(scope: LeaderboardLeafScope): boolean { + return getPrimaryTabFromScope(scope) === "provider"; +} + +export function getSecondaryTabFromScope( + scope: LeaderboardLeafScope +): LeaderboardSecondaryTab | null { + if (scope === "model") { + return null; + } + + return scope === "userCacheHitRate" || scope === "providerCacheHitRate" ? "cacheHit" : "cost"; +} + +export function getScopeForPrimaryTab(tab: LeaderboardPrimaryTab): LeaderboardLeafScope { + if (tab === "provider") { + return "provider"; + } + + if (tab === "model") { + return "model"; + } + + return "user"; +} + +export function getScopeForSecondaryTab( + primaryTab: LeaderboardPrimaryTabWithSecondary, + secondaryTab: LeaderboardSecondaryTab +): LeaderboardLeafScope { + if (primaryTab === "provider") { + return secondaryTab === "cacheHit" ? "providerCacheHitRate" : "provider"; + } + + return secondaryTab === "cacheHit" ? "userCacheHitRate" : "user"; +} diff --git a/src/app/[locale]/dashboard/leaderboard/_components/leaderboard-view.tsx b/src/app/[locale]/dashboard/leaderboard/_components/leaderboard-view.tsx index 1f0a142bf..cecf85460 100644 --- a/src/app/[locale]/dashboard/leaderboard/_components/leaderboard-view.tsx +++ b/src/app/[locale]/dashboard/leaderboard/_components/leaderboard-view.tsx @@ -4,10 +4,23 @@ import { useSearchParams } from "next/navigation"; import { useTranslations } from "next-intl"; import { useCallback, useEffect, useState } from "react"; import { getAllUserKeyGroups, getAllUserTags } from "@/actions/users"; +import { LeaderboardPrimaryTabs } from "@/app/[locale]/dashboard/leaderboard/_components/leaderboard-primary-tabs"; +import { LeaderboardSecondaryTabs } from "@/app/[locale]/dashboard/leaderboard/_components/leaderboard-secondary-tabs"; +import { + getPrimaryTabFromScope, + getScopeForPrimaryTab, + getScopeForSecondaryTab, + getSecondaryTabFromScope, + isProviderFamilyScope, + isUserFamilyScope, + type LeaderboardPrimaryTab, + type LeaderboardLeafScope as LeaderboardScope, + type LeaderboardSecondaryTab, + normalizeScopeFromUrl, +} from "@/app/[locale]/dashboard/leaderboard/_components/leaderboard-tab-groups"; import { ProviderTypeFilter } from "@/app/[locale]/settings/providers/_components/provider-type-filter"; import { Card, CardContent } from "@/components/ui/card"; import { Skeleton } from "@/components/ui/skeleton"; -import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { TagInput } from "@/components/ui/tag-input"; import { Link } from "@/i18n/routing"; import { formatTokenAmount } from "@/lib/utils"; @@ -31,8 +44,6 @@ import { type ColumnDef, LeaderboardTable } from "./leaderboard-table"; interface LeaderboardViewProps { isAdmin: boolean; } - -type LeaderboardScope = "user" | "userCacheHitRate" | "provider" | "providerCacheHitRate" | "model"; type TotalCostFormattedFields = { totalCostFormatted?: string }; type ProviderCostFormattedFields = { // API 额外返回的展示用字段(格式化后的字符串) @@ -74,15 +85,8 @@ export function LeaderboardView({ isAdmin }: LeaderboardViewProps) { const t = useTranslations("dashboard.leaderboard"); const searchParams = useSearchParams(); - const urlScope = searchParams.get("scope") as LeaderboardScope | null; - const initialScope: LeaderboardScope = - (urlScope === "provider" || - urlScope === "providerCacheHitRate" || - urlScope === "userCacheHitRate" || - urlScope === "model") && - isAdmin - ? urlScope - : "user"; + const urlScope = searchParams.get("scope"); + const initialScope = normalizeScopeFromUrl(urlScope, isAdmin); const urlPeriod = searchParams.get("period") as LeaderboardPeriod | null; const initialPeriod: LeaderboardPeriod = urlPeriod && VALID_PERIODS.includes(urlPeriod) ? urlPeriod : "daily"; @@ -117,15 +121,7 @@ export function LeaderboardView({ isAdmin }: LeaderboardViewProps) { // 与 URL 查询参数保持同步,支持外部携带 scope/period 直达特定榜单 // biome-ignore lint/correctness/useExhaustiveDependencies: period 和 scope 仅用于比较,不应触发 effect 重新执行 useEffect(() => { - const urlScopeParam = searchParams.get("scope") as LeaderboardScope | null; - const normalizedScope: LeaderboardScope = - (urlScopeParam === "provider" || - urlScopeParam === "userCacheHitRate" || - urlScopeParam === "providerCacheHitRate" || - urlScopeParam === "model") && - isAdmin - ? urlScopeParam - : "user"; + const normalizedScope = normalizeScopeFromUrl(searchParams.get("scope"), isAdmin); if (normalizedScope !== scope) { setScope(normalizedScope); @@ -150,19 +146,16 @@ export function LeaderboardView({ isAdmin }: LeaderboardViewProps) { if (period === "custom" && dateRange) { url += `&startDate=${dateRange.startDate}&endDate=${dateRange.endDate}`; } - if ( - (scope === "providerCacheHitRate" || scope === "provider") && - providerTypeFilter !== "all" - ) { + if (isProviderFamilyScope(scope) && providerTypeFilter !== "all") { url += `&providerType=${encodeURIComponent(providerTypeFilter)}`; } if (scope === "provider") { url += "&includeModelStats=1"; } - if ((scope === "user" || scope === "userCacheHitRate") && isAdmin) { + if (isUserFamilyScope(scope) && isAdmin) { url += "&includeUserModelStats=1"; } - if (scope === "user" || scope === "userCacheHitRate") { + if (isUserFamilyScope(scope)) { if (userTagFilters.length > 0) { url += `&userTags=${encodeURIComponent(userTagFilters.join(","))}`; } @@ -204,6 +197,26 @@ export function LeaderboardView({ isAdmin }: LeaderboardViewProps) { [] ); + // 一级/二级 tab 只是叶子 scope 的 UI 投影,状态真源始终只有 scope。 + const activePrimaryTab = getPrimaryTabFromScope(scope); + const activeSecondaryTab = getSecondaryTabFromScope(scope); + const showSecondaryTabs = isAdmin && activePrimaryTab !== "model"; + const isProviderFamily = isProviderFamilyScope(scope); + const isUserFamily = isUserFamilyScope(scope); + + const handlePrimaryTabChange = (tab: LeaderboardPrimaryTab) => { + setScope(getScopeForPrimaryTab(tab)); + }; + + const handleSecondaryTabChange = (tab: LeaderboardSecondaryTab) => { + const primaryTab = getPrimaryTabFromScope(scope); + if (primaryTab === "model") { + return; + } + + setScope(getScopeForSecondaryTab(primaryTab, tab)); + }; + const skeletonColumns = scope === "user" ? 5 @@ -585,26 +598,32 @@ export function LeaderboardView({ isAdmin }: LeaderboardViewProps) { return (
{/* Scope toggle */} -
- setScope(v as LeaderboardScope)}> - - {t("tabs.userRanking")} - {isAdmin && ( - - {t("tabs.userCacheHitRateRanking")} - - )} - {isAdmin && {t("tabs.providerRanking")}} - {isAdmin && ( - - {t("tabs.providerCacheHitRateRanking")} - - )} - {isAdmin && {t("tabs.modelRanking")}} - - - - {scope === "provider" || scope === "providerCacheHitRate" ? ( +
+
+ + {showSecondaryTabs ? ( + + ) : null} +
+ + {isProviderFamily ? ( - {(scope === "user" || scope === "userCacheHitRate") && isAdmin && ( + {isUserFamily && isAdmin && (
{ + let container: HTMLDivElement | null = null; + let root: ReturnType | null = null; + + beforeEach(() => { + container = document.createElement("div"); + document.body.appendChild(container); + root = createRoot(container); + }); + + afterEach(() => { + if (root) { + act(() => root!.unmount()); + root = null; + } + if (container) { + container.remove(); + container = null; + } + }); + + it("renders user provider model tabs for admin", async () => { + const onPrimaryChange = vi.fn(); + + await act(async () => { + root!.render( + + ); + }); + + const triggers = Array.from( + container!.querySelectorAll("[data-testid^='leaderboard-primary-tab-']") + ); + expect(container!.querySelector("[data-testid='leaderboard-primary-tabs']")).not.toBeNull(); + expect(triggers.map((node) => node.dataset.testid)).toEqual([ + "leaderboard-primary-tab-user", + "leaderboard-primary-tab-provider", + "leaderboard-primary-tab-model", + ]); + + await act(async () => { + triggers[2]?.dispatchEvent(new MouseEvent("mousedown", { bubbles: true })); + triggers[2]?.dispatchEvent(new MouseEvent("click", { bubbles: true })); + }); + + expect(onPrimaryChange).toHaveBeenCalledWith("model"); + }); + + it("renders only user tab for non-admin", async () => { + await act(async () => { + root!.render( + + ); + }); + + expect(container!.querySelector("[data-testid='leaderboard-primary-tab-user']")).not.toBeNull(); + expect(container!.querySelector("[data-testid='leaderboard-primary-tab-provider']")).toBeNull(); + expect(container!.querySelector("[data-testid='leaderboard-primary-tab-model']")).toBeNull(); + }); +}); diff --git a/tests/unit/dashboard/leaderboard-secondary-tabs.test.tsx b/tests/unit/dashboard/leaderboard-secondary-tabs.test.tsx new file mode 100644 index 000000000..c5ed578cc --- /dev/null +++ b/tests/unit/dashboard/leaderboard-secondary-tabs.test.tsx @@ -0,0 +1,81 @@ +/** + * @vitest-environment happy-dom + */ +import { act } from "react"; +import { createRoot } from "react-dom/client"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { LeaderboardSecondaryTabs } from "@/app/[locale]/dashboard/leaderboard/_components/leaderboard-secondary-tabs"; + +describe("LeaderboardSecondaryTabs", () => { + let container: HTMLDivElement | null = null; + let root: ReturnType | null = null; + + beforeEach(() => { + container = document.createElement("div"); + document.body.appendChild(container); + root = createRoot(container); + }); + + afterEach(() => { + if (root) { + act(() => root!.unmount()); + root = null; + } + if (container) { + container.remove(); + container = null; + } + }); + + it("renders cost and cache-hit tabs for grouped scopes", async () => { + const onSecondaryChange = vi.fn(); + + await act(async () => { + root!.render( + + ); + }); + + const triggers = Array.from( + container!.querySelectorAll("[data-testid^='leaderboard-secondary-tab-']") + ); + expect(container!.querySelector("[data-testid='leaderboard-secondary-tabs']")).not.toBeNull(); + expect(triggers.map((node) => node.dataset.testid)).toEqual([ + "leaderboard-secondary-tab-cost", + "leaderboard-secondary-tab-cache-hit", + ]); + + await act(async () => { + triggers[1]?.dispatchEvent(new MouseEvent("mousedown", { bubbles: true })); + triggers[1]?.dispatchEvent(new MouseEvent("click", { bubbles: true })); + }); + + expect(onSecondaryChange).toHaveBeenCalledWith("cacheHit"); + }); + + it("renders no secondary tabs for model", async () => { + await act(async () => { + root!.render( + + ); + }); + + expect(container!.querySelector("[data-testid='leaderboard-secondary-tabs']")).toBeNull(); + }); +}); diff --git a/tests/unit/dashboard/leaderboard-tab-groups.test.ts b/tests/unit/dashboard/leaderboard-tab-groups.test.ts new file mode 100644 index 000000000..088a4a501 --- /dev/null +++ b/tests/unit/dashboard/leaderboard-tab-groups.test.ts @@ -0,0 +1,34 @@ +import { describe, expect, it } from "vitest"; +import { + getPrimaryTabFromScope, + getScopeForPrimaryTab, + getScopeForSecondaryTab, + getSecondaryTabFromScope, + normalizeScopeFromUrl, +} from "@/app/[locale]/dashboard/leaderboard/_components/leaderboard-tab-groups"; + +describe("leaderboard tab groups", () => { + it("maps admin leaf scopes to grouped tabs", () => { + expect(normalizeScopeFromUrl("providerCacheHitRate", true)).toBe("providerCacheHitRate"); + expect(getPrimaryTabFromScope("user")).toBe("user"); + expect(getPrimaryTabFromScope("providerCacheHitRate")).toBe("provider"); + expect(getPrimaryTabFromScope("model")).toBe("model"); + expect(getSecondaryTabFromScope("user")).toBe("cost"); + expect(getSecondaryTabFromScope("providerCacheHitRate")).toBe("cacheHit"); + expect(getSecondaryTabFromScope("model")).toBeNull(); + expect(getScopeForPrimaryTab("provider")).toBe("provider"); + expect(getScopeForPrimaryTab("model")).toBe("model"); + expect(getScopeForSecondaryTab("user", "cacheHit")).toBe("userCacheHitRate"); + expect(getScopeForSecondaryTab("provider", "cost")).toBe("provider"); + }); + + it("forces non-admin scopes back to user", () => { + expect(normalizeScopeFromUrl("provider", false)).toBe("user"); + expect(normalizeScopeFromUrl("providerCacheHitRate", false)).toBe("user"); + expect(normalizeScopeFromUrl("model", false)).toBe("user"); + expect(normalizeScopeFromUrl("userCacheHitRate", false)).toBe("user"); + expect(normalizeScopeFromUrl("user", false)).toBe("user"); + expect(normalizeScopeFromUrl("unknown", true)).toBe("user"); + expect(normalizeScopeFromUrl(null, true)).toBe("user"); + }); +}); diff --git a/tests/unit/dashboard/leaderboard-view-filter-gating.test.tsx b/tests/unit/dashboard/leaderboard-view-filter-gating.test.tsx new file mode 100644 index 000000000..461330d6e --- /dev/null +++ b/tests/unit/dashboard/leaderboard-view-filter-gating.test.tsx @@ -0,0 +1,159 @@ +/** + * @vitest-environment happy-dom + */ +import { act } from "react"; +import { createRoot } from "react-dom/client"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { LeaderboardView } from "@/app/[locale]/dashboard/leaderboard/_components/leaderboard-view"; + +const fetchMock = vi.fn(); +const { getAllUserTagsMock, getAllUserKeyGroupsMock } = vi.hoisted(() => ({ + getAllUserTagsMock: vi.fn(), + getAllUserKeyGroupsMock: vi.fn(), +})); +const searchParamsState = vi.hoisted(() => ({ + value: new URLSearchParams(), +})); +const tMock = vi.hoisted(() => vi.fn((key: string) => key)); + +vi.mock("next/navigation", () => ({ + useSearchParams: () => searchParamsState.value, +})); + +vi.mock("next-intl", () => ({ + useTranslations: () => tMock, +})); + +vi.mock("@/actions/users", () => ({ + getAllUserTags: getAllUserTagsMock, + getAllUserKeyGroups: getAllUserKeyGroupsMock, +})); + +vi.mock("@/app/[locale]/settings/providers/_components/provider-type-filter", () => ({ + ProviderTypeFilter: ({ value }: { value: string }) => ( +
{value}
+ ), +})); + +vi.mock("@/app/[locale]/dashboard/leaderboard/_components/date-range-picker", () => ({ + DateRangePicker: () =>
, +})); + +vi.mock("@/app/[locale]/dashboard/leaderboard/_components/leaderboard-table", () => ({ + LeaderboardTable: ({ data }: { data: unknown[] }) => ( +
{JSON.stringify(data)}
+ ), +})); + +vi.mock("@/components/ui/tag-input", () => ({ + TagInput: ({ ["data-testid"]: testId }: { "data-testid"?: string }) => ( +
+ ), +})); + +vi.mock("@/i18n/routing", () => ({ + Link: ({ children, href, ...props }: any) => ( + + {children} + + ), +})); + +const globalFetch = global.fetch; + +async function waitForFetchCalls(expectedCalls: number) { + for (let i = 0; i < 20; i += 1) { + if (fetchMock.mock.calls.length >= expectedCalls) { + return; + } + + await act(async () => { + await Promise.resolve(); + }); + } + + throw new Error(`fetchMock call count did not reach ${expectedCalls}`); +} + +async function flushUi() { + await act(async () => { + await Promise.resolve(); + }); +} + +describe("LeaderboardView filter gating", () => { + let container: HTMLDivElement | null = null; + let root: ReturnType | null = null; + + beforeEach(() => { + vi.clearAllMocks(); + getAllUserTagsMock.mockResolvedValue({ ok: true, data: ["vip"] }); + getAllUserKeyGroupsMock.mockResolvedValue({ ok: true, data: ["default"] }); + container = document.createElement("div"); + document.body.appendChild(container); + root = createRoot(container); + + fetchMock.mockImplementation( + async () => + ({ + ok: true, + json: async () => [], + }) as Response + ); + + global.fetch = fetchMock as typeof fetch; + }); + + afterEach(() => { + if (root) { + act(() => root!.unmount()); + root = null; + } + if (container) { + container.remove(); + container = null; + } + global.fetch = globalFetch; + }); + + it("shows provider filters for provider family and preserves provider request params", async () => { + searchParamsState.value = new URLSearchParams("scope=provider"); + + await act(async () => { + root!.render(); + }); + await waitForFetchCalls(1); + await flushUi(); + + expect(container!.querySelector("[data-testid='provider-filter']")).not.toBeNull(); + expect(fetchMock.mock.calls.at(-1)?.[0]).toContain("scope=provider"); + expect(fetchMock.mock.calls.at(-1)?.[0]).toContain("includeModelStats=1"); + + searchParamsState.value = new URLSearchParams("scope=providerCacheHitRate"); + await act(async () => { + root!.render(); + }); + await waitForFetchCalls(2); + await flushUi(); + + expect(container!.querySelector("[data-testid='provider-filter']")).not.toBeNull(); + expect(fetchMock.mock.calls.at(-1)?.[0]).toContain("scope=providerCacheHitRate"); + expect(fetchMock.mock.calls.at(-1)?.[0]).not.toContain("includeModelStats=1"); + }); + + it("hides secondary tabs and family filters for model scope", async () => { + searchParamsState.value = new URLSearchParams("scope=model"); + + await act(async () => { + root!.render(); + }); + await waitForFetchCalls(1); + await flushUi(); + + expect(container!.querySelector("[data-testid='leaderboard-secondary-tabs']")).toBeNull(); + expect(container!.querySelector("[data-testid='provider-filter']")).toBeNull(); + expect(container!.querySelector("[data-testid='leaderboard-user-tag-filter']")).toBeNull(); + expect(container!.querySelector("[data-testid='leaderboard-user-group-filter']")).toBeNull(); + expect(fetchMock.mock.calls.at(-1)?.[0]).toContain("scope=model"); + }); +}); diff --git a/tests/unit/dashboard/leaderboard-view-tab-grouping.test.tsx b/tests/unit/dashboard/leaderboard-view-tab-grouping.test.tsx new file mode 100644 index 000000000..3451fed9e --- /dev/null +++ b/tests/unit/dashboard/leaderboard-view-tab-grouping.test.tsx @@ -0,0 +1,235 @@ +/** + * @vitest-environment happy-dom + */ +import { act } from "react"; +import { createRoot } from "react-dom/client"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { LeaderboardView } from "@/app/[locale]/dashboard/leaderboard/_components/leaderboard-view"; + +const fetchMock = vi.fn(); +const { getAllUserTagsMock, getAllUserKeyGroupsMock } = vi.hoisted(() => ({ + getAllUserTagsMock: vi.fn(), + getAllUserKeyGroupsMock: vi.fn(), +})); +const searchParamsState = vi.hoisted(() => ({ + value: new URLSearchParams(), +})); +const tMock = vi.hoisted(() => vi.fn((key: string) => key)); + +vi.mock("next/navigation", () => ({ + useSearchParams: () => searchParamsState.value, +})); + +vi.mock("next-intl", () => ({ + useTranslations: () => tMock, +})); + +vi.mock("@/actions/users", () => ({ + getAllUserTags: getAllUserTagsMock, + getAllUserKeyGroups: getAllUserKeyGroupsMock, +})); + +vi.mock("@/app/[locale]/settings/providers/_components/provider-type-filter", () => ({ + ProviderTypeFilter: ({ value }: { value: string }) => ( +
{value}
+ ), +})); + +vi.mock("@/app/[locale]/dashboard/leaderboard/_components/date-range-picker", () => ({ + DateRangePicker: () =>
, +})); + +vi.mock("@/app/[locale]/dashboard/leaderboard/_components/leaderboard-table", () => ({ + LeaderboardTable: ({ data }: { data: unknown[] }) => ( +
{JSON.stringify(data)}
+ ), +})); + +vi.mock("@/components/ui/tag-input", () => ({ + TagInput: ({ ["data-testid"]: testId }: { "data-testid"?: string }) => ( +
+ ), +})); + +vi.mock("@/i18n/routing", () => ({ + Link: ({ children, href, ...props }: any) => ( + + {children} + + ), +})); + +const globalFetch = global.fetch; + +async function waitForFetchCalls(expectedCalls: number) { + for (let i = 0; i < 20; i += 1) { + if (fetchMock.mock.calls.length >= expectedCalls) { + return; + } + + await act(async () => { + await Promise.resolve(); + }); + } + + throw new Error(`fetchMock call count did not reach ${expectedCalls}`); +} + +async function flushUi() { + await act(async () => { + await Promise.resolve(); + }); +} + +function getRequestedScopes() { + return fetchMock.mock.calls.map((call) => { + const url = new URL(String(call[0]), "http://localhost"); + return url.searchParams.get("scope"); + }); +} + +describe("LeaderboardView grouped tabs", () => { + let container: HTMLDivElement | null = null; + let root: ReturnType | null = null; + + beforeEach(() => { + vi.clearAllMocks(); + getAllUserTagsMock.mockResolvedValue({ ok: true, data: ["vip"] }); + getAllUserKeyGroupsMock.mockResolvedValue({ ok: true, data: ["default"] }); + container = document.createElement("div"); + document.body.appendChild(container); + root = createRoot(container); + + fetchMock.mockImplementation( + async () => + ({ + ok: true, + json: async () => [], + }) as Response + ); + + global.fetch = fetchMock as typeof fetch; + }); + + afterEach(() => { + if (root) { + act(() => root!.unmount()); + root = null; + } + if (container) { + container.remove(); + container = null; + } + global.fetch = globalFetch; + }); + + it("deep-links admin users to the user cost leaderboard", async () => { + searchParamsState.value = new URLSearchParams("scope=user"); + + await act(async () => { + root!.render(); + }); + await waitForFetchCalls(1); + await flushUi(); + + expect( + container!.querySelector("[data-testid='leaderboard-primary-tab-user'][data-state='active']") + ).not.toBeNull(); + expect( + container!.querySelector( + "[data-testid='leaderboard-secondary-tab-cost'][data-state='active']" + ) + ).not.toBeNull(); + expect(getRequestedScopes()).toContain("user"); + }); + + it("deep-links admin users to the cache-hit secondary tab", async () => { + searchParamsState.value = new URLSearchParams("scope=userCacheHitRate"); + + await act(async () => { + root!.render(); + }); + await waitForFetchCalls(1); + await flushUi(); + + expect( + container!.querySelector("[data-testid='leaderboard-primary-tab-user'][data-state='active']") + ).not.toBeNull(); + expect( + container!.querySelector( + "[data-testid='leaderboard-secondary-tab-cache-hit'][data-state='active']" + ) + ).not.toBeNull(); + expect( + fetchMock.mock.calls.some((call) => String(call[0]).includes("scope=userCacheHitRate")) + ).toBe(true); + }); + + it("switching primary tab resets grouped scopes to cost leaf", async () => { + searchParamsState.value = new URLSearchParams("scope=userCacheHitRate"); + + await act(async () => { + root!.render(); + }); + await waitForFetchCalls(1); + await flushUi(); + + const providerTab = container!.querySelector( + "[data-testid='leaderboard-primary-tab-provider']" + ) as HTMLElement | null; + expect(providerTab).not.toBeNull(); + + await act(async () => { + providerTab!.dispatchEvent(new MouseEvent("mousedown", { bubbles: true })); + providerTab!.dispatchEvent(new MouseEvent("click", { bubbles: true })); + }); + await waitForFetchCalls(2); + await flushUi(); + + const requestedUrls = fetchMock.mock.calls.map((call) => String(call[0])); + expect(requestedUrls.at(-1)).toContain("scope=provider"); + expect(requestedUrls.at(-1)).not.toContain("providerCacheHitRate"); + expect( + container!.querySelector( + "[data-testid='leaderboard-secondary-tab-cost'][data-state='active']" + ) + ).not.toBeNull(); + }); + + it("renders no secondary tabs for model", async () => { + searchParamsState.value = new URLSearchParams("scope=model"); + + await act(async () => { + root!.render(); + }); + await waitForFetchCalls(1); + await flushUi(); + + expect( + container!.querySelector("[data-testid='leaderboard-primary-tab-model'][data-state='active']") + ).not.toBeNull(); + expect(container!.querySelector("[data-testid='leaderboard-secondary-tabs']")).toBeNull(); + expect(getRequestedScopes()).toContain("model"); + }); + + it("falls back non-admin users to the user cost leaderboard", async () => { + searchParamsState.value = new URLSearchParams("scope=providerCacheHitRate"); + + await act(async () => { + root!.render(); + }); + await waitForFetchCalls(1); + await flushUi(); + + expect( + container!.querySelector("[data-testid='leaderboard-primary-tab-user'][data-state='active']") + ).not.toBeNull(); + expect(container!.querySelector("[data-testid='leaderboard-primary-tab-provider']")).toBeNull(); + expect(container!.querySelector("[data-testid='leaderboard-primary-tab-model']")).toBeNull(); + expect(container!.querySelector("[data-testid='leaderboard-secondary-tabs']")).toBeNull(); + expect(getRequestedScopes()).toContain("user"); + expect(getRequestedScopes()).not.toContain("userCacheHitRate"); + expect(getRequestedScopes()).not.toContain("provider"); + expect(getRequestedScopes()).not.toContain("providerCacheHitRate"); + }); +}); diff --git a/tests/unit/dashboard/leaderboard-view-user-cache-hit-rate.test.tsx b/tests/unit/dashboard/leaderboard-view-user-cache-hit-rate.test.tsx index 2ac1fa7ca..90d9f72dc 100644 --- a/tests/unit/dashboard/leaderboard-view-user-cache-hit-rate.test.tsx +++ b/tests/unit/dashboard/leaderboard-view-user-cache-hit-rate.test.tsx @@ -123,6 +123,14 @@ describe("LeaderboardView user cache hit rate scope", () => { (url) => url.includes("scope=userCacheHitRate") && url.includes("includeUserModelStats=1") ) ).toBe(true); + expect( + container!.querySelector("[data-testid='leaderboard-primary-tab-user'][data-state='active']") + ).not.toBeNull(); + expect( + container!.querySelector( + "[data-testid='leaderboard-secondary-tab-cache-hit'][data-state='active']" + ) + ).not.toBeNull(); expect(container!.textContent).toContain("cache-user"); expect(container!.textContent).toContain("50.0%"); });