import { describe, expect, it, vi } from 'vitest' import type { BarSeriesOption } from 'echarts' import { adminWorkbenchChartTheme } from '@/styles/adminWorkbenchChartTheme' import { turnoverChart, turnoverNumber, volumeText, windowText, turnoverReasonsText, volumeNote } from './turnoverPresentation' import type { TurnoverCharts, TurnoverWindowMetric } from './turnoverTypes' describe('turnover honest precision and charts', () => { it('keeps unknown volume distinct from zero and labels historical volume as estimated', () => { const value = { value: null, knownSubtotal: 0, missingDimensionQuantity: 15, missingDimensionSkuCount: 2, unknownQuantitySkuCount: 1, declaredDimensionQuantity: 0, reasonCodes: [] } expect(volumeNote(value)).toContain('已知小计 0 m³') expect(volumeNote(value)).toContain('缺尺寸 2 SKU / 15 件') expect(volumeNote(value)).toContain('数量待确认 1 SKU') expect(volumeNote({ ...value, value: 0, missingDimensionSkuCount: 0, unknownQuantitySkuCount: 0 })).toBe('已知体积为0') expect(volumeNote(value, true)).toContain('按当前商品尺寸估算') }) it('explains missing samples and zero average stock without exposing internal statuses', () => { expect(turnoverReasonsText(['INSUFFICIENT'])).toBe('合格日末库存记录不足') expect(turnoverReasonsText(['ZERO_AVERAGE_STOCK'])).toContain('期间平均日末库存为0') expect(turnoverReasonsText(['INVENTORY_UNKNOWN_OR_STALE'])).toContain('已过期') }) it('keeps tiny positive values distinct from zero and unknown', () => { expect(turnoverNumber(0.000001)).toBe('<0.01') expect(turnoverNumber(0.01, 1)).toBe('<0.1') expect(turnoverNumber(0)).toBe('0'); expect(turnoverNumber(null)).toBe('—') expect(volumeText(1.25)).toBe('1.25 m³') expect(volumeText(0)).toBe('0 m³') expect(volumeText(null)).toBe('—') expect(volumeText(0.0000000001, 9)).toBe('<0.000000001 m³') }) it('does not draw partial-window totals as daily averages or zero bars', () => { const partial: TurnoverWindowMetric = { days: 15, startDate: '2026-09-28', endDateExclusive: '2026-10-13', dataStatus: 'PARTIAL', observedQuantity: 150, outboundQuantity: null, averageDailyOutboundQuantity: null, completeDayCount: 7, partialDayCount: 1, unknownDayCount: 7, reasonCodes: [] } const charts: TurnoverCharts = { ownerPhysicalTop: [], unknownOwnerCount: 0, ownerVolumeTop: [], unknownVolumeOwnerCount: 0, averageDaily: [{ ...partial, days: 7, dataStatus: 'COMPLETE', averageDailyOutboundQuantity: 10 }, partial] } const option = turnoverChart(charts, 'daily') const tooltip = option.tooltip as { valueFormatter: (value: number | null) => string } expect(tooltip.valueFormatter(2165.133333)).toBe('2,165.13 件/日') expect(tooltip.valueFormatter(0)).toBe('0 件/日') expect(tooltip.valueFormatter(null)).toBe('— 件/日') expect(JSON.stringify(option.series)).toContain('"data":[10,null]') expect(windowText(partial)).toBe('已采集 150,覆盖不足') }) it.each(['owners', 'volume', 'daily'] as const)('applies the workbench theme to %s without adding artificial bar lengths', kind => { const charts: TurnoverCharts = { ownerPhysicalTop: [], unknownOwnerCount: 0, ownerVolumeTop: [], unknownVolumeOwnerCount: 0, averageDaily: [] } const option = turnoverChart(charts, kind) expect(option.textStyle).toMatchObject({ color: adminWorkbenchChartTheme.colors.text, fontFamily: adminWorkbenchChartTheme.fontFamily }) expect(option.tooltip).toMatchObject({ backgroundColor: adminWorkbenchChartTheme.colors.surface, borderColor: adminWorkbenchChartTheme.colors.border, renderMode: 'richText' }) expect(kind === 'daily' ? option.yAxis : option.xAxis).toMatchObject({ type: 'value', min: 0, axisLabel: adminWorkbenchChartTheme.axisLabel, splitLine: adminWorkbenchChartTheme.splitLine }) const series = (option.series as BarSeriesOption[])[0]! expect(series.itemStyle).toMatchObject({ color: adminWorkbenchChartTheme.colors.primary, borderRadius: kind === 'daily' ? [6, 6, 0, 0] : [0, 6, 6, 0] }) expect(series.data).toEqual([]) expect(series).not.toHaveProperty('showBackground') expect(series).not.toHaveProperty('barMinHeight') }) it('preserves owner ordering, volume precision, zero and unknown without changing click indexes', () => { const volume = { value: null, knownSubtotal: null, missingDimensionQuantity: null, missingDimensionSkuCount: 0, unknownQuantitySkuCount: 0, declaredDimensionQuantity: 0, reasonCodes: [] } const values = ['12345.678901', '0', null, '0.000001'] const charts: TurnoverCharts = { ownerPhysicalTop: values.map((physicalQuantity, index) => ({ customerCode: `OWNER-${index}`, customerName: index === 0 ? '货主名称' : null, physicalQuantity })), ownerVolumeTop: values.map((value, index) => ({ customerCode: `OWNER-${index}`, customerName: index === 0 ? '货主名称' : null, inventoryVolumeM3: { ...volume, value } })), unknownOwnerCount: 1, unknownVolumeOwnerCount: 1, averageDaily: [], } for (const kind of ['owners', 'volume'] as const) { const option = turnoverChart(charts, kind) expect(option.yAxis).toMatchObject({ inverse: true, data: ['货主名称', 'OWNER-1', 'OWNER-2', 'OWNER-3'] }) expect((option.series as BarSeriesOption[])[0]!.data).toEqual([12345.678901, 0, null, 0.000001]) } const option = turnoverChart(charts, 'volume') expect(option.grid).toMatchObject({ right: 110 }) const label = (option.series as BarSeriesOption[])[0]!.label as { formatter: (params: { value: number | null }) => string } const tooltip = option.tooltip as { valueFormatter: (value: number | null) => string } expect(label.formatter({ value: 12345.678901 })).toBe('12,345.68 m³') expect(label.formatter({ value: 0.000001 })).toBe('<0.01 m³') expect(tooltip.valueFormatter(12345.678901)).toBe('12,345.68 m³') expect(tooltip.valueFormatter(null)).toBe('— m³') }) it('continues to honor reduced-motion preference', () => { const charts: TurnoverCharts = { ownerPhysicalTop: [], unknownOwnerCount: 0, ownerVolumeTop: [], unknownVolumeOwnerCount: 0, averageDaily: [] } const matchMedia = vi.fn(() => ({ matches: true })) vi.stubGlobal('window', { matchMedia }) try { expect(turnoverChart(charts, 'owners').animation).toBe(false) expect(matchMedia).toHaveBeenCalledWith('(prefers-reduced-motion: reduce)') matchMedia.mockReturnValue({ matches: false }) expect(turnoverChart(charts, 'owners').animation).toBe(true) } finally { vi.unstubAllGlobals() } }) })