| import { describe, expect, it, vi, beforeEach } from 'vitest' |
| import { mount } from '@vue/test-utils' |
| import { nextTick } from 'vue' |
|
|
| import UsageTable from '../UsageTable.vue' |
|
|
| const messages: Record<string, string> = { |
| 'usage.costDetails': 'Cost Breakdown', |
| 'admin.usage.inputCost': 'Input Cost', |
| 'admin.usage.outputCost': 'Output Cost', |
| 'admin.usage.cacheCreationCost': 'Cache Creation Cost', |
| 'admin.usage.cacheReadCost': 'Cache Read Cost', |
| 'usage.inputTokenPrice': 'Input price', |
| 'usage.outputTokenPrice': 'Output price', |
| 'usage.perMillionTokens': '/ 1M tokens', |
| 'usage.serviceTier': 'Service tier', |
| 'usage.serviceTierPriority': 'Fast', |
| 'usage.serviceTierFlex': 'Flex', |
| 'usage.serviceTierStandard': 'Standard', |
| 'usage.rate': 'Rate', |
| 'usage.accountMultiplier': 'Account rate', |
| 'usage.original': 'Original', |
| 'usage.userBilled': 'User billed', |
| 'usage.accountBilled': 'Account billed', |
| 'usage.imageUnit': ' images', |
| 'usage.imageCount': 'Image count', |
| 'usage.imageBillingSize': 'Billing size', |
| 'usage.imageInputSize': 'Input size', |
| 'usage.imageOutputSize': 'Output size', |
| 'usage.imageSizeSource': 'Size source', |
| 'usage.imageSizeBreakdown': 'Size breakdown', |
| 'usage.imageSizeSourceOutput': 'Upstream output', |
| 'usage.imageSizeSourceInput': 'Request input', |
| 'usage.imageSizeSourceDefault': 'Default billing tier', |
| 'usage.imageSizeSourceLegacy': 'Legacy record', |
| 'usage.imageSizeSourceMissing': 'Not recorded', |
| 'usage.imageSizeNotRecorded': 'not recorded', |
| 'usage.imageSizeLegacyUnstandardized': 'legacy unstandardized', |
| 'usage.imageSizeUnknown': 'unknown', |
| 'usage.imageUnitPrice': 'Per-image price', |
| 'usage.imageTotalPrice': 'Image total price', |
| 'admin.usage.billingModeToken': 'Token', |
| 'admin.usage.billingModePerRequest': 'Per request', |
| 'admin.usage.billingModeImage': 'Image', |
| } |
|
|
| vi.mock('vue-i18n', async () => { |
| const actual = await vi.importActual<typeof import('vue-i18n')>('vue-i18n') |
| return { |
| ...actual, |
| useI18n: () => ({ |
| t: (key: string) => messages[key] ?? key, |
| }), |
| } |
| }) |
|
|
| const DataTableStub = { |
| props: ['data'], |
| template: ` |
| <div> |
| <div v-for="row in data" :key="row.request_id"> |
| <slot name="cell-model" :row="row" :value="row.model" /> |
| <slot name="cell-billing_mode" :row="row" /> |
| <slot name="cell-tokens" :row="row" /> |
| <slot name="cell-cost" :row="row" /> |
| </div> |
| </div> |
| `, |
| } |
|
|
| const baseImageRow = { |
| request_id: 'req-admin-image', |
| model: 'gpt-image-2', |
| actual_cost: 0.4, |
| total_cost: 0.4, |
| account_rate_multiplier: 1, |
| rate_multiplier: 1, |
| service_tier: null, |
| input_cost: 0, |
| output_cost: 0, |
| cache_creation_cost: 0, |
| cache_read_cost: 0, |
| input_tokens: 0, |
| output_tokens: 0, |
| cache_creation_tokens: 0, |
| cache_read_tokens: 0, |
| cache_creation_5m_tokens: 0, |
| cache_creation_1h_tokens: 0, |
| cache_ttl_overridden: false, |
| billing_mode: 'image', |
| image_count: 2, |
| image_size: '2K', |
| image_input_size: null, |
| image_output_size: null, |
| image_size_source: null, |
| image_size_breakdown: null, |
| } |
|
|
| describe('admin UsageTable tooltip', () => { |
| beforeEach(() => { |
| vi.spyOn(HTMLElement.prototype, 'getBoundingClientRect').mockReturnValue({ |
| x: 0, |
| y: 0, |
| top: 20, |
| left: 20, |
| right: 120, |
| bottom: 40, |
| width: 100, |
| height: 20, |
| toJSON: () => ({}), |
| } as DOMRect) |
| }) |
|
|
| it('shows service tier and billing breakdown in cost tooltip', async () => { |
| const row = { |
| request_id: 'req-admin-1', |
| actual_cost: 0.092883, |
| total_cost: 0.092883, |
| account_rate_multiplier: 1, |
| rate_multiplier: 1, |
| service_tier: 'priority', |
| input_cost: 0.020285, |
| output_cost: 0.00303, |
| cache_creation_cost: 0, |
| cache_read_cost: 0.069568, |
| input_tokens: 4057, |
| output_tokens: 101, |
| } |
|
|
| const wrapper = mount(UsageTable, { |
| props: { |
| data: [row], |
| loading: false, |
| columns: [], |
| }, |
| global: { |
| stubs: { |
| DataTable: DataTableStub, |
| EmptyState: true, |
| Icon: true, |
| Teleport: true, |
| }, |
| }, |
| }) |
|
|
| const tooltipTriggers = wrapper.findAll('.group.relative') |
| await tooltipTriggers[tooltipTriggers.length - 1].trigger('mouseenter') |
| await nextTick() |
|
|
| const text = wrapper.text() |
| expect(text).toContain('Service tier') |
| expect(text).toContain('Fast') |
| expect(text).toContain('Rate') |
| expect(text).toContain('1.00x') |
| expect(text).toContain('Account rate') |
| expect(text).toContain('User billed') |
| expect(text).toContain('Account billed') |
| expect(text).toContain('$0.092883') |
| expect(text).toContain('$5.0000 / 1M tokens') |
| expect(text).toContain('$30.0000 / 1M tokens') |
| expect(text).toContain('$0.069568') |
| }) |
|
|
| it('shows requested and upstream models separately for admin rows', () => { |
| const row = { |
| request_id: 'req-admin-model-1', |
| model: 'claude-sonnet-4', |
| upstream_model: 'claude-sonnet-4-20250514', |
| actual_cost: 0, |
| total_cost: 0, |
| account_rate_multiplier: 1, |
| rate_multiplier: 1, |
| input_cost: 0, |
| output_cost: 0, |
| cache_creation_cost: 0, |
| cache_read_cost: 0, |
| input_tokens: 0, |
| output_tokens: 0, |
| } |
|
|
| const wrapper = mount(UsageTable, { |
| props: { |
| data: [row], |
| loading: false, |
| columns: [], |
| }, |
| global: { |
| stubs: { |
| DataTable: DataTableStub, |
| EmptyState: true, |
| Icon: true, |
| Teleport: true, |
| }, |
| }, |
| }) |
|
|
| const text = wrapper.text() |
| expect(text).toContain('claude-sonnet-4') |
| expect(text).toContain('claude-sonnet-4-20250514') |
| }) |
|
|
| it.each([ |
| { |
| name: 'defaulted row', |
| row: { |
| ...baseImageRow, |
| request_id: 'req-admin-default-image', |
| image_size: '2K', |
| image_input_size: 'auto', |
| image_output_size: null, |
| image_size_source: 'default', |
| }, |
| expected: ['2K', 'Default billing tier', 'auto', 'unknown'], |
| }, |
| { |
| name: 'output-sourced row', |
| row: { |
| ...baseImageRow, |
| request_id: 'req-admin-output-image', |
| image_size: '4K', |
| image_input_size: '1024x1024', |
| image_output_size: '3840x2160', |
| image_size_source: 'output', |
| image_size_breakdown: { '4K': 1 }, |
| }, |
| expected: ['4K', 'Upstream output', '1024x1024', '3840x2160', '4K x 1'], |
| }, |
| { |
| name: 'input-sourced row', |
| row: { |
| ...baseImageRow, |
| request_id: 'req-admin-input-image', |
| image_size: '1K', |
| image_input_size: '1024x1024', |
| image_output_size: null, |
| image_size_source: 'input', |
| }, |
| expected: ['1K', 'Request input', '1024x1024', 'unknown'], |
| }, |
| { |
| name: 'legacy unstandardized row', |
| row: { |
| ...baseImageRow, |
| request_id: 'req-admin-legacy-unstandardized-image', |
| image_size: '512x512', |
| image_input_size: null, |
| image_output_size: null, |
| image_size_source: null, |
| }, |
| expected: ['legacy unstandardized: 512x512', 'Legacy record', 'unknown'], |
| }, |
| ])('shows image usage metadata for $name', async ({ row, expected }) => { |
| const wrapper = mount(UsageTable, { |
| props: { |
| data: [row], |
| loading: false, |
| columns: [], |
| }, |
| global: { |
| stubs: { |
| DataTable: DataTableStub, |
| EmptyState: true, |
| Icon: true, |
| Teleport: true, |
| }, |
| }, |
| }) |
|
|
| await wrapper.find('.group.relative').trigger('mouseenter') |
| await nextTick() |
|
|
| const text = wrapper.text() |
| expect(text).toContain('Image count') |
| expect(text).toContain('Billing size') |
| expect(text).toContain('Size source') |
| expect(text).toContain('Input size') |
| expect(text).toContain('Output size') |
| expect(text).toContain('Per-image price') |
| expect(text).toContain('Image total price') |
| for (const value of expected) { |
| expect(text).toContain(value) |
| } |
| }) |
|
|
| it('displays historical image rows with missing billing_mode as image usage without a 2K fallback', async () => { |
| const wrapper = mount(UsageTable, { |
| props: { |
| data: [ |
| { |
| ...baseImageRow, |
| request_id: 'req-admin-legacy-missing-image', |
| billing_mode: null, |
| image_size: null, |
| image_input_size: null, |
| image_output_size: null, |
| image_size_source: null, |
| image_size_breakdown: null, |
| }, |
| ], |
| loading: false, |
| columns: [], |
| }, |
| global: { |
| stubs: { |
| DataTable: DataTableStub, |
| EmptyState: true, |
| Icon: true, |
| Teleport: true, |
| }, |
| }, |
| }) |
|
|
| await wrapper.find('.group.relative').trigger('mouseenter') |
| await nextTick() |
|
|
| const text = wrapper.text() |
| expect(text).toContain('Image') |
| expect(text).toContain('Image count') |
| expect(text).toContain('Per-image price') |
| expect(text).toContain('not recorded') |
| expect(text).not.toContain('(2K)') |
| }) |
| }) |
|
|