import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest' import { flushPromises, mount } from '@vue/test-utils' import UsageView from '../UsageView.vue' const { list, getStats, getSnapshotV2, getById, getModelStats, listErrorLogs } = vi.hoisted(() => { vi.stubGlobal('localStorage', { getItem: vi.fn(() => null), setItem: vi.fn(), removeItem: vi.fn(), }) return { list: vi.fn(), getStats: vi.fn(), getSnapshotV2: vi.fn(), getById: vi.fn(), getModelStats: vi.fn(), listErrorLogs: vi.fn(), } }) const messages: Record = { 'admin.dashboard.timeRange': 'Time Range', 'admin.dashboard.day': 'Day', 'admin.dashboard.hour': 'Hour', 'admin.usage.failedToLoadUser': 'Failed to load user', } const formatLocalDate = (date: Date): string => { const year = date.getFullYear() const month = String(date.getMonth() + 1).padStart(2, '0') const day = String(date.getDate()).padStart(2, '0') return `${year}-${month}-${day}` } vi.mock('@/api/admin', () => ({ adminAPI: { usage: { list, getStats, }, dashboard: { getSnapshotV2, getModelStats, }, users: { getById, }, }, })) vi.mock('@/api/admin/usage', () => ({ adminUsageAPI: { list: vi.fn(), }, })) vi.mock('@/api/admin/ops', () => ({ listErrorLogs, })) vi.mock('@/stores/app', () => ({ useAppStore: () => ({ showError: vi.fn(), showWarning: vi.fn(), showSuccess: vi.fn(), showInfo: vi.fn(), }), })) vi.mock('@/utils/format', () => ({ formatReasoningEffort: (value: string | null | undefined) => value ?? '-', })) vi.mock('vue-i18n', async () => { const actual = await vi.importActual('vue-i18n') return { ...actual, useI18n: () => ({ t: (key: string) => messages[key] ?? key, }), } }) vi.mock('vue-router', () => ({ useRoute: () => ({ query: {} }) })) const AppLayoutStub = { template: '
' } const UsageFiltersStub = { template: '
' } const UsageTableStub = { emits: ['userClick'], template: '
', } const ModelDistributionChartStub = { props: ['metric'], emits: ['update:metric'], template: `
{{ metric }}
`, } const GroupDistributionChartStub = { props: ['metric'], emits: ['update:metric'], template: `
{{ metric }}
`, } describe('admin UsageView distribution metric toggles', () => { beforeEach(() => { vi.useFakeTimers() list.mockReset() getStats.mockReset() getSnapshotV2.mockReset() getById.mockReset() getModelStats.mockReset() list.mockResolvedValue({ items: [], total: 0, pages: 0, }) getStats.mockResolvedValue({ total_requests: 0, total_input_tokens: 0, total_output_tokens: 0, total_cache_tokens: 0, total_tokens: 0, total_cost: 0, total_actual_cost: 0, average_duration_ms: 0, }) getSnapshotV2.mockResolvedValue({ trend: [], models: [], groups: [], }) getModelStats.mockResolvedValue({ models: [] }) }) afterEach(() => { vi.useRealTimers() }) it('keeps previous model stats visible during refresh until new data arrives', async () => { // 首次加载返回 A getModelStats.mockResolvedValueOnce({ models: [{ model: 'A', total_tokens: 10 }] }) const wrapper = mount(UsageView, { global: { stubs: { AppLayout: AppLayoutStub, UsageStatsCards: true, UsageFilters: UsageFiltersStub, UsageTable: true, UsageExportProgress: true, UsageCleanupDialog: true, UserBalanceHistoryModal: true, AuditLogModal: true, Pagination: true, Select: true, DateRangePicker: true, Icon: true, TokenUsageTrend: true, ModelDistributionChart: ModelDistributionChartStub, GroupDistributionChart: GroupDistributionChartStub, EndpointDistributionChart: true, } }, }) vi.advanceTimersByTime(120) await flushPromises() expect((wrapper.vm as any).requestedModelStats).toEqual([{ model: 'A', total_tokens: 10 }]) // 刷新:让第二次 getModelStats 处于 pending,断言旧数据 A 仍在(不被清空成 []) let resolveSecond: (v: any) => void = () => {} getModelStats.mockReturnValueOnce(new Promise((res) => { resolveSecond = res })) ;(wrapper.vm as any).refreshData() await flushPromises() expect((wrapper.vm as any).requestedModelStats).toEqual([{ model: 'A', total_tokens: 10 }]) // 新数据到达后替换为 B resolveSecond({ models: [{ model: 'B', total_tokens: 20 }] }) await flushPromises() expect((wrapper.vm as any).requestedModelStats).toEqual([{ model: 'B', total_tokens: 20 }]) }) it('keeps model and group metric toggles independent without refetching chart data', async () => { const wrapper = mount(UsageView, { global: { stubs: { AppLayout: AppLayoutStub, UsageStatsCards: true, UsageFilters: UsageFiltersStub, UsageTable: true, UsageExportProgress: true, UsageCleanupDialog: true, UserBalanceHistoryModal: true, Pagination: true, Select: true, DateRangePicker: true, Icon: true, TokenUsageTrend: true, ModelDistributionChart: ModelDistributionChartStub, GroupDistributionChart: GroupDistributionChartStub, }, }, }) vi.advanceTimersByTime(120) await flushPromises() expect(getSnapshotV2).toHaveBeenCalledTimes(1) const now = new Date() const yesterday = new Date(now.getTime() - 24 * 60 * 60 * 1000) expect(getSnapshotV2).toHaveBeenCalledWith(expect.objectContaining({ start_date: formatLocalDate(yesterday), end_date: formatLocalDate(now), granularity: 'hour' })) const modelChart = wrapper.find('[data-test="model-chart"]') const groupChart = wrapper.find('[data-test="group-chart"]') expect(modelChart.find('.metric').text()).toBe('tokens') expect(groupChart.find('.metric').text()).toBe('tokens') await modelChart.find('.switch-metric').trigger('click') await flushPromises() expect(modelChart.find('.metric').text()).toBe('actual_cost') expect(groupChart.find('.metric').text()).toBe('tokens') expect(getSnapshotV2).toHaveBeenCalledTimes(1) await groupChart.find('.switch-metric').trigger('click') await flushPromises() expect(modelChart.find('.metric').text()).toBe('actual_cost') expect(groupChart.find('.metric').text()).toBe('actual_cost') expect(getSnapshotV2).toHaveBeenCalledTimes(1) }) }) describe('admin UsageView handleUserClick', () => { beforeEach(() => { vi.useFakeTimers() list.mockReset() getStats.mockReset() getSnapshotV2.mockReset() getById.mockReset() list.mockResolvedValue({ items: [], total: 0, pages: 0 }) getStats.mockResolvedValue({ total_requests: 0, total_input_tokens: 0, total_output_tokens: 0, total_cache_tokens: 0, total_tokens: 0, total_cost: 0, total_actual_cost: 0, average_duration_ms: 0, }) getSnapshotV2.mockResolvedValue({ trend: [], models: [], groups: [] }) }) afterEach(() => { vi.useRealTimers() }) it('opens user via include_deleted when clicking a usage row user', async () => { getById.mockResolvedValue({ id: 2, email: 'd@test.com', deleted_at: '2026-05-28T00:00:00Z' }) const wrapper = mount(UsageView, { global: { stubs: { AppLayout: AppLayoutStub, UsageStatsCards: true, UsageFilters: UsageFiltersStub, UsageTable: UsageTableStub, UsageExportProgress: true, UsageCleanupDialog: true, UserBalanceHistoryModal: true, AuditLogModal: true, Pagination: true, Select: true, DateRangePicker: true, Icon: true, TokenUsageTrend: true, ModelDistributionChart: true, GroupDistributionChart: true, EndpointDistributionChart: true, }, }, }) vi.advanceTimersByTime(120) await flushPromises() await wrapper.find('[data-test="usage-table"] .user-click').trigger('click') await flushPromises() expect(getById).toHaveBeenCalledWith(2, true) }) }) describe('admin UsageView errors tab filter forwarding', () => { beforeEach(() => { vi.useFakeTimers() list.mockReset() getStats.mockReset() getSnapshotV2.mockReset() getModelStats.mockReset() listErrorLogs.mockReset() list.mockResolvedValue({ items: [], total: 0, pages: 0 }) getStats.mockResolvedValue({ total_requests: 0, total_input_tokens: 0, total_output_tokens: 0, total_cache_tokens: 0, total_tokens: 0, total_cost: 0, total_actual_cost: 0, average_duration_ms: 0, }) getSnapshotV2.mockResolvedValue({ trend: [], models: [], groups: [] }) getModelStats.mockResolvedValue({ models: [] }) listErrorLogs.mockResolvedValue({ items: [], total: 0, pages: 0 }) }) afterEach(() => { vi.useRealTimers() }) it('forwards model/account_id/group_id to listErrorLogs on the errors tab', async () => { const wrapper = mount(UsageView, { global: { stubs: { AppLayout: AppLayoutStub, UsageStatsCards: true, UsageFilters: UsageFiltersStub, UsageTable: true, UsageExportProgress: true, UsageCleanupDialog: true, UserBalanceHistoryModal: true, AuditLogModal: true, Pagination: true, Select: true, DateRangePicker: true, Icon: true, TokenUsageTrend: true, ModelDistributionChart: true, GroupDistributionChart: true, EndpointDistributionChart: true, OpsErrorLogTable: true, OpsErrorDetailModal: true, } }, }) vi.advanceTimersByTime(120) await flushPromises() // 模拟用户在过滤器里选择了模型/账户/分组 const vm = wrapper.vm as any vm.filters.model = 'gpt-5.3-codex' vm.filters.account_id = 7 vm.filters.group_id = 3 await flushPromises() // 切换到「错误请求」标签(第二个 .tab 按钮)触发 loadAdminErrors const tabs = wrapper.findAll('button.tab') await tabs[1].trigger('click') await flushPromises() expect(listErrorLogs).toHaveBeenCalledWith(expect.objectContaining({ view: 'all', model: 'gpt-5.3-codex', account_id: 7, group_id: 3, })) }) })