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)
})
})