import { describe, test, expect, beforeEach } from 'vitest' import { render, screen, fireEvent, waitFor } from '@testing-library/react' import { RelativeData, RELATIVE_DATA_TOOL_ID, RELATIVE_DATA_CONFIG_TOOL_ID, } from './relative-data' import { useWidgetStore } from '../../stores/widget-store' import type { EchartWidgetData } from '../../echart/types' import type { RelativeDataState } from './types' describe('RelativeData', () => { const widgetId = 'test-relative-widget' const mockData: EchartWidgetData = [ [ { category: 'A', value: 25 }, { category: 'B', value: 75 }, ], ] beforeEach(() => { useWidgetStore.getState().clearWidgets() }) test('renders relative data toggle button', () => { render() const button = screen.getByRole('button') expect(button).toBeTruthy() }) test('shows relative tooltip when in absolute mode', () => { render() const button = screen.getByRole('button', { name: 'Show relative values' }) expect(button).toBeTruthy() }) test('toggles to relative mode and updates widget store with percentage values', async () => { render() const button = screen.getByRole('button') fireEvent.click(button) // Execute the pipeline with the source data await useWidgetStore.getState().executeToolPipeline(widgetId, mockData) const widget = useWidgetStore.getState().getWidget(widgetId) const data = widget?.data as EchartWidgetData // Total is 100, so 25 becomes 25% and 75 becomes 75% expect(data?.[0]?.[0]?.value).toBe(25) expect(data?.[0]?.[1]?.value).toBe(75) expect(data?.[0]?.[0]?.category).toBe('A') expect(data?.[0]?.[1]?.category).toBe('B') }) test('toggles back to absolute mode and restores original values', async () => { render() const button = screen.getByRole('button') // Toggle to relative fireEvent.click(button) await useWidgetStore.getState().executeToolPipeline(widgetId, mockData) // Toggle back to absolute fireEvent.click(button) await useWidgetStore.getState().executeToolPipeline(widgetId, mockData) const widget = useWidgetStore.getState().getWidget(widgetId) const data = widget?.data as EchartWidgetData expect(data?.[0]?.[0]?.value).toBe(25) expect(data?.[0]?.[1]?.value).toBe(75) }) test('has active state when in relative mode', () => { render() const button = screen.getByRole('button') expect(button.getAttribute('data-active')).toBe('false') fireEvent.click(button) expect(button.getAttribute('data-active')).toBe('true') }) test('starts in relative mode when defaultIsRelative is true', () => { render() const button = screen.getByRole('button') expect(button.getAttribute('data-active')).toBe('true') // Should show absolute tooltip when in relative mode expect(button.getAttribute('aria-label')).toBe('Show absolute values') }) test('initializes tool state with defaultIsRelative on mount', async () => { render() await waitFor(() => { const widget = useWidgetStore.getState().getWidget(widgetId) const tool = widget?.registeredTools?.find( (t) => t.id === RELATIVE_DATA_TOOL_ID, ) expect(tool?.enabled).toBe(true) }) }) test('initializes tool state with false when defaultIsRelative is false', async () => { render() await waitFor(() => { const widget = useWidgetStore.getState().getWidget(widgetId) const tool = widget?.registeredTools?.find( (t) => t.id === RELATIVE_DATA_TOOL_ID, ) expect(tool?.enabled).toBe(false) }) }) test('uses custom labels when provided', () => { const customLabels = { relative: 'Switch to %', absolute: 'Switch to numbers', ariaLabel: 'Toggle data mode', } render() const button = screen.getByRole('button', { name: 'Toggle data mode' }) expect(button).toBeTruthy() }) test('handles data with multiple series', async () => { const multiSeriesData: EchartWidgetData = [ [ { name: 'X', count: 10 }, { name: 'Y', count: 20 }, ], [ { name: 'X', count: 30 }, { name: 'Y', count: 40 }, ], ] render() const button = screen.getByRole('button') fireEvent.click(button) await useWidgetStore .getState() .executeToolPipeline(widgetId, multiSeriesData) const widget = useWidgetStore.getState().getWidget(widgetId) const data = widget?.data as EchartWidgetData // Total is 100, so values become 10%, 20%, 30%, 40% expect(data?.[0]?.[0]?.count).toBe(10) expect(data?.[0]?.[1]?.count).toBe(20) expect(data?.[1]?.[0]?.count).toBe(30) expect(data?.[1]?.[1]?.count).toBe(40) }) test('handles zero total gracefully', async () => { const zeroData: EchartWidgetData = [ [ { category: 'A', value: 0 }, { category: 'B', value: 0 }, ], ] render() const button = screen.getByRole('button') fireEvent.click(button) await useWidgetStore.getState().executeToolPipeline(widgetId, zeroData) const widget = useWidgetStore.getState().getWidget(widgetId) const data = widget?.data as EchartWidgetData // Should preserve original data when total is 0 expect(data?.[0]?.[0]?.value).toBe(0) expect(data?.[0]?.[1]?.value).toBe(0) }) test('preserves string values during transformation', async () => { const mixedData: EchartWidgetData = [ [ { label: 'First', value: 50, extra: 'text' }, { label: 'Second', value: 50, extra: 'more' }, ], ] render() const button = screen.getByRole('button') fireEvent.click(button) await useWidgetStore.getState().executeToolPipeline(widgetId, mixedData) const widget = useWidgetStore.getState().getWidget(widgetId) const data = widget?.data as EchartWidgetData expect(data?.[0]?.[0]?.label).toBe('First') expect(data?.[0]?.[0]?.extra).toBe('text') expect(data?.[0]?.[1]?.label).toBe('Second') expect(data?.[0]?.[1]?.extra).toBe('more') }) test('renders custom icon when provided', () => { const customIcon = % render() expect(screen.getByTestId('custom-icon')).toBeTruthy() }) test('passes IconButtonProps to the button', () => { render() const button = screen.getByRole('button') expect(button.hasAttribute('disabled')).toBeTruthy() }) test('registers both data and config tools on mount', async () => { render() await waitFor(() => { const widget = useWidgetStore.getState().getWidget(widgetId) const dataTool = widget?.registeredTools?.find( (t) => t.id === RELATIVE_DATA_TOOL_ID, ) const configTool = widget?.registeredTools?.find( (t) => t.id === RELATIVE_DATA_CONFIG_TOOL_ID, ) expect(dataTool).toBeTruthy() expect(configTool).toBeTruthy() expect(configTool?.type).toBe('config') // Data tool disabled by default (isRelative=false) expect(dataTool?.enabled).toBe(false) // Config tool always enabled — it reads isRelative from store internally expect(configTool?.enabled).toBe(true) }) }) test('data tool enabled when toggled to relative mode', async () => { render() const button = screen.getByRole('button') fireEvent.click(button) await waitFor(() => { const widget = useWidgetStore.getState().getWidget(widgetId) const dataTool = widget?.registeredTools?.find( (t) => t.id === RELATIVE_DATA_TOOL_ID, ) expect(dataTool?.enabled).toBe(true) }) }) test('stores isRelative in widget root state', () => { render() // Default: isRelative=false in store let widget = useWidgetStore.getState().getWidget(widgetId) expect((widget as RelativeDataState | undefined)?.isRelative).toBe(false) // Toggle to relative const button = screen.getByRole('button') fireEvent.click(button) widget = useWidgetStore.getState().getWidget(widgetId) expect((widget as RelativeDataState | undefined)?.isRelative).toBe(true) // Toggle back fireEvent.click(button) widget = useWidgetStore.getState().getWidget(widgetId) expect((widget as RelativeDataState | undefined)?.isRelative).toBe(false) }) test('config tool does not apply formatter when sourceData is empty', async () => { const customFormatter = (value: number) => `$${value}` useWidgetStore .getState() .setWidget(widgetId, { formatter: customFormatter, sourceData: [] }) render() const button = screen.getByRole('button') fireEvent.click(button) // Execute config pipeline — sourceData is empty, so formatter should pass through await useWidgetStore .getState() .executeConfigPipeline(widgetId, { formatter: customFormatter, max: 200 }) const widget = useWidgetStore.getState().getWidget(widgetId) expect(widget?.formatter).toBe(customFormatter) expect((widget as unknown as Record)?.max).toBe(200) }) test('config pipeline applies percentage formatter when in relative mode', async () => { useWidgetStore .getState() .setWidget(widgetId, { max: 500, sourceData: mockData }) render() const button = screen.getByRole('button') fireEvent.click(button) // Execute config pipeline await useWidgetStore .getState() .executeConfigPipeline(widgetId, { max: 500 }) const widget = useWidgetStore.getState().getWidget(widgetId) expect((widget as unknown as Record)?.max).toBe(100) expect(widget?.formatter).toBeDefined() expect(typeof widget?.formatter).toBe('function') }) test('config tool passes config through unchanged when not in relative mode', async () => { const customFormatter = (value: number) => `$${value}` useWidgetStore .getState() .setWidget(widgetId, { formatter: customFormatter, max: 200 }) render() // Execute config pipeline — config tool is enabled but isRelative=false in store, // so it passes through the config unchanged await useWidgetStore .getState() .executeConfigPipeline(widgetId, { formatter: customFormatter, max: 200 }) const widget = useWidgetStore.getState().getWidget(widgetId) expect(widget?.formatter).toBe(customFormatter) expect((widget as unknown as Record)?.max).toBe(200) }) test('restores original formatter/max when toggling back to absolute', async () => { const customFormatter = (value: number) => `$${value}` useWidgetStore.getState().setWidget(widgetId, { formatter: customFormatter, max: 200, sourceData: mockData, }) render() const button = screen.getByRole('button') // Toggle to relative — captures originalFormatter/originalMax in store fireEvent.click(button) await useWidgetStore .getState() .executeConfigPipeline(widgetId, { max: 200 }) let widget = useWidgetStore.getState().getWidget(widgetId) expect((widget as unknown as Record)?.max).toBe(100) // Toggle back to absolute — config tool restores originals from store fireEvent.click(button) await useWidgetStore .getState() .executeConfigPipeline(widgetId, { max: 200 }) widget = useWidgetStore.getState().getWidget(widgetId) expect(widget?.formatter).toBe(customFormatter) expect((widget as unknown as Record)?.max).toBe(200) }) test('removes percentage formatter when toggling back with no original formatter', async () => { // Widget has no formatter — originalFormatter will be captured as undefined useWidgetStore.getState().setWidget(widgetId, { sourceData: mockData }) render() const button = screen.getByRole('button') // Toggle to relative — captures originalFormatter=undefined fireEvent.click(button) await useWidgetStore.getState().executeConfigPipeline(widgetId, {}) let widget = useWidgetStore.getState().getWidget(widgetId) expect(widget?.formatter).toBeDefined() expect(typeof widget?.formatter).toBe('function') // Toggle back to absolute — should remove percentage formatter fireEvent.click(button) await useWidgetStore.getState().executeConfigPipeline(widgetId, {}) widget = useWidgetStore.getState().getWidget(widgetId) expect(widget?.formatter).toBeUndefined() }) test('removes percentage formatter when defaultIsRelative=true and toggling to absolute', async () => { // Widget starts with defaultIsRelative=true and no formatter useWidgetStore.getState().setWidget(widgetId, { sourceData: mockData }) render() // Pipeline runs with isRelative=true — applies percentage formatter await useWidgetStore.getState().executeConfigPipeline(widgetId, {}) let widget = useWidgetStore.getState().getWidget(widgetId) expect(widget?.formatter).toBeDefined() expect(typeof widget?.formatter).toBe('function') // Toggle to absolute (first toggle is OFF when defaultIsRelative=true) const button = screen.getByRole('button') fireEvent.click(button) await useWidgetStore.getState().executeConfigPipeline(widgetId, {}) widget = useWidgetStore.getState().getWidget(widgetId) expect(widget?.formatter).toBeUndefined() }) test('stores originalFormatter/originalMax in widget root on toggle', () => { const customFormatter = (value: number) => `$${value}` useWidgetStore .getState() .setWidget(widgetId, { formatter: customFormatter, max: 200 }) render() const button = screen.getByRole('button') fireEvent.click(button) const widget = useWidgetStore .getState() .getWidget(widgetId) expect(widget?.originalFormatter).toBe(customFormatter) expect(widget?.originalMax).toBe(200) }) test('restores original formatter/max when unmounting while in relative mode', async () => { const customFormatter = (value: number) => `$${value}` useWidgetStore .getState() .setWidget(widgetId, { formatter: customFormatter, max: 200 }) const { unmount } = render() const button = screen.getByRole('button') // Toggle to relative — captures originalFormatter/originalMax in store fireEvent.click(button) await waitFor(() => { const widget = useWidgetStore .getState() .getWidget(widgetId) expect(widget?.isRelative).toBe(true) expect(widget?.originalFormatter).toBe(customFormatter) expect(widget?.originalMax).toBe(200) }) // Unmount while still in relative mode unmount() const widget = useWidgetStore.getState().getWidget(widgetId) expect(widget?.formatter).toBe(customFormatter) expect((widget as unknown as Record)?.max).toBe(200) }) test('unregisters tools on unmount', async () => { const { unmount } = render() await waitFor(() => { const widget = useWidgetStore.getState().getWidget(widgetId) expect(widget?.registeredTools?.length).toBeGreaterThan(0) }) unmount() const widget = useWidgetStore.getState().getWidget(widgetId) const dataTool = widget?.registeredTools?.find( (t) => t.id === RELATIVE_DATA_TOOL_ID, ) const configTool = widget?.registeredTools?.find( (t) => t.id === RELATIVE_DATA_CONFIG_TOOL_ID, ) expect(dataTool).toBeUndefined() expect(configTool).toBeUndefined() }) test('recalculates relative values when store data changes externally while in relative mode', async () => { const initialData: EchartWidgetData = [ [ { category: 'A', value: 50 }, { category: 'B', value: 50 }, ], ] // Pre-populate the widget in the store with relative data useWidgetStore.getState().setWidget(widgetId, { data: initialData, }) render() let widget let data await waitFor(() => { widget = useWidgetStore.getState().getWidget(widgetId) data = widget?.data as EchartWidgetData // Initial data: 50/100 = 50%, 50/100 = 50% expect(data?.[0]?.[0]?.value).toBe(50) expect(data?.[0]?.[1]?.value).toBe(50) }) // Simulate external store update with new absolute data const newAbsoluteData: EchartWidgetData = [ [ { category: 'A', value: 25 }, { category: 'B', value: 75 }, ], ] useWidgetStore.getState().setWidget(widgetId, { data: newAbsoluteData }) // Component should detect external change and recalculate // New data: 25/100 = 25%, 75/100 = 75% widget = useWidgetStore.getState().getWidget(widgetId) data = widget?.data as EchartWidgetData expect(data?.[0]?.[0]?.value).toBe(25) expect(data?.[0]?.[1]?.value).toBe(75) }) })