import { describe, test, expect, beforeEach } from 'vitest'
import { render, screen, fireEvent, waitFor } from '@testing-library/react'
import { StackToggle, STACK_TOGGLE_TOOL_ID } from './stack-toggle'
import { useWidgetStore } from '../../stores/widget-store'
import { DEFAULT_STACK_GROUP } from '../../echart/const'
describe('StackToggle', () => {
const widgetId = 'test-stack-widget'
// Helper to set up widget with multiple series (required for component to render)
function setupMultiSeriesWidget(overrides = {}) {
useWidgetStore.getState().setWidget(widgetId, {
option: {
series: [
{ name: 'Series 1', type: 'bar' },
{ name: 'Series 2', type: 'bar' },
],
},
...overrides,
})
}
beforeEach(() => {
useWidgetStore.getState().clearWidgets()
setupMultiSeriesWidget()
})
test('renders stack toggle button', () => {
render()
const button = screen.getByRole('button')
expect(button).toBeTruthy()
})
test('shows stacked tooltip when in unstacked mode', () => {
render()
const button = screen.getByRole('button', { name: 'Enable stacking' })
expect(button).toBeTruthy()
})
test('toggles to stacked mode and updates tool config', () => {
render()
const button = screen.getByRole('button')
fireEvent.click(button)
const widget = useWidgetStore.getState().getWidget(widgetId)
const tool = widget?.registeredTools?.find(
(t) => t.id === STACK_TOGGLE_TOOL_ID,
)
expect(tool?.enabled).toBe(true)
})
test('toggles back to unstacked mode', () => {
render()
const button = screen.getByRole('button')
// Toggle to stacked
fireEvent.click(button)
// Toggle back to unstacked
fireEvent.click(button)
const widget = useWidgetStore.getState().getWidget(widgetId)
const tool = widget?.registeredTools?.find(
(t) => t.id === STACK_TOGGLE_TOOL_ID,
)
expect(tool?.enabled).toBe(false)
})
test('has active state when in stacked 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 stacked mode when defaultIsStacked is true', () => {
render()
const button = screen.getByRole('button')
expect(button.getAttribute('data-active')).toBe('true')
// Should show unstacked tooltip when in stacked mode
expect(button.getAttribute('aria-label')).toBe('Disable stacking')
})
test('uses custom labels when provided', () => {
const customLabels = {
stacked: 'Stack bars',
unstacked: 'Unstack bars',
ariaLabel: 'Toggle bar stacking',
}
render()
const button = screen.getByRole('button', { name: 'Toggle bar stacking' })
expect(button).toBeTruthy()
})
test('initializes tool config with default values on mount', () => {
render()
const widget = useWidgetStore.getState().getWidget(widgetId)
const tool = widget?.registeredTools?.find(
(t) => t.id === STACK_TOGGLE_TOOL_ID,
)
expect(tool?.enabled).toBe(false)
})
test('initializes tool config with stacked values when defaultIsStacked is true', () => {
render()
const widget = useWidgetStore.getState().getWidget(widgetId)
const tool = widget?.registeredTools?.find(
(t) => t.id === STACK_TOGGLE_TOOL_ID,
)
expect(tool?.enabled).toBe(true)
})
test('applies stack to EChart series via config pipeline when toggling to stacked', async () => {
render()
const button = screen.getByRole('button')
fireEvent.click(button)
// Run the config pipeline to apply the stack tool's transformation
await useWidgetStore.getState().executeConfigPipeline(widgetId, {
option: {
series: [
{ name: 'Series 1', type: 'bar' },
{ name: 'Series 2', type: 'bar' },
],
},
})
const widget = useWidgetStore.getState().getWidget(widgetId)
const series = (widget as { option?: { series?: { stack?: string }[] } })
?.option?.series?.[0]
expect(series?.stack).toBe(DEFAULT_STACK_GROUP)
})
test('removes stack from EChart series via config pipeline when toggling to unstacked', async () => {
useWidgetStore.getState().setWidget(widgetId, {
option: {
series: [
{ name: 'Series 1', type: 'bar', stack: DEFAULT_STACK_GROUP },
{ name: 'Series 2', type: 'bar', stack: DEFAULT_STACK_GROUP },
],
},
})
render()
const button = screen.getByRole('button')
// First click unstacks (since series has stack, it starts stacked)
fireEvent.click(button)
// Run the config pipeline — tool is disabled when unstacked, so stack is removed
await useWidgetStore.getState().executeConfigPipeline(widgetId, {
option: {
series: [
{ name: 'Series 1', type: 'bar' },
{ name: 'Series 2', type: 'bar' },
],
},
})
const widget = useWidgetStore.getState().getWidget(widgetId)
const series = (widget as { option?: { series?: { stack?: string }[] } })
?.option?.series?.[0]
expect(series?.stack).toBeUndefined()
})
test('applies stack to multiple series via config pipeline', async () => {
useWidgetStore.getState().setWidget(widgetId, {
option: {
series: [
{ name: 'Series 1', type: 'bar' },
{ name: 'Series 2', type: 'bar' },
{ name: 'Series 3', type: 'bar' },
],
},
})
render()
const button = screen.getByRole('button')
fireEvent.click(button)
await useWidgetStore.getState().executeConfigPipeline(widgetId, {
option: {
series: [
{ name: 'Series 1', type: 'bar' },
{ name: 'Series 2', type: 'bar' },
{ name: 'Series 3', type: 'bar' },
],
},
})
const widget = useWidgetStore.getState().getWidget(widgetId)
const seriesArray = (
widget as { option?: { series?: { stack?: string }[] } }
)?.option?.series
expect(seriesArray).toHaveLength(3)
seriesArray?.forEach((s) => {
expect(s.stack).toBe(DEFAULT_STACK_GROUP)
})
})
test('preserves custom stack group via config pipeline', async () => {
const customStackGroup = 'custom-group'
useWidgetStore.getState().setWidget(widgetId, {
option: {
series: [
{ name: 'Series 1', type: 'bar', stack: customStackGroup },
{ name: 'Series 2', type: 'bar', stack: customStackGroup },
],
},
})
render()
const button = screen.getByRole('button')
// Toggle off then on
fireEvent.click(button) // unstacked
fireEvent.click(button) // stacked again
await useWidgetStore.getState().executeConfigPipeline(widgetId, {
option: {
series: [
{ name: 'Series 1', type: 'bar', stack: customStackGroup },
{ name: 'Series 2', type: 'bar', stack: customStackGroup },
],
},
})
await waitFor(() => {
const widget = useWidgetStore.getState().getWidget(widgetId)
const series = (widget as { option?: { series?: { stack?: string }[] } })
?.option?.series?.[0]
expect(series?.stack).toBe(customStackGroup)
})
})
test('detects existing stack in series and defaults to stacked mode', () => {
useWidgetStore.getState().setWidget(widgetId, {
option: {
series: [
{ name: 'Series 1', type: 'bar', stack: 'existing-stack' },
{ name: 'Series 2', type: 'bar', stack: 'existing-stack' },
],
},
})
render()
const button = screen.getByRole('button')
expect(button.getAttribute('data-active')).toBe('true')
expect(button.getAttribute('aria-label')).toBe('Disable stacking')
})
test('returns null for single series (stacking not applicable)', () => {
useWidgetStore.getState().setWidget(widgetId, {
option: {
series: { name: 'Single Series', type: 'bar' },
},
})
const { container } = render()
// Component returns null when there's only one series
expect(container.firstChild).toBeNull()
})
test('returns null when widget has no option defined', () => {
// Clear the widget set up in beforeEach and create one without option
useWidgetStore.getState().clearWidgets()
useWidgetStore.getState().setWidget(widgetId, {})
const { container } = render()
// Component returns null when there's no series data
expect(container.firstChild).toBeNull()
})
test('config tool re-applies stack when config pipeline re-runs', async () => {
// Start with stacked widget
useWidgetStore.getState().setWidget(widgetId, {
option: {
series: [
{ name: 'Series 1', type: 'bar', stack: DEFAULT_STACK_GROUP },
{ name: 'Series 2', type: 'bar', stack: DEFAULT_STACK_GROUP },
],
},
})
render()
// Simulate loader running config pipeline with base config (no stack)
await useWidgetStore.getState().executeConfigPipeline(widgetId, {
option: {
series: [
{ name: 'Series 1', type: 'bar' },
{ name: 'Series 2', type: 'bar' },
],
},
})
// Stack should be re-applied by the config tool
await waitFor(() => {
const widget = useWidgetStore.getState().getWidget(widgetId)
const series = (widget as { option?: { series?: { stack?: string }[] } })
?.option?.series
expect(series?.[0]?.stack).toBe(DEFAULT_STACK_GROUP)
expect(series?.[1]?.stack).toBe(DEFAULT_STACK_GROUP)
})
})
test('config tool does not apply stack when unstacked and pipeline re-runs', async () => {
// Start with unstacked widget
useWidgetStore.getState().setWidget(widgetId, {
option: {
series: [
{ name: 'Series 1', type: 'bar' },
{ name: 'Series 2', type: 'bar' },
],
},
})
render()
// Run config pipeline with base config
await useWidgetStore.getState().executeConfigPipeline(widgetId, {
option: {
series: [
{ name: 'Series 1', type: 'bar' },
{ name: 'Series 2', type: 'bar' },
],
},
})
// Stack should NOT be applied since isStacked is false (tool disabled)
await waitFor(() => {
const widget = useWidgetStore.getState().getWidget(widgetId)
const series = (widget as { option?: { series?: { stack?: string }[] } })
?.option?.series
expect(series?.[0]?.stack).toBeUndefined()
expect(series?.[1]?.stack).toBeUndefined()
})
})
test('preserves other option properties via config pipeline', async () => {
useWidgetStore.getState().setWidget(widgetId, {
option: {
title: { text: 'My Chart' },
xAxis: { type: 'category' },
series: [
{ name: 'Series 1', type: 'bar' },
{ name: 'Series 2', type: 'bar' },
],
},
})
render()
const button = screen.getByRole('button')
fireEvent.click(button)
await useWidgetStore.getState().executeConfigPipeline(widgetId, {
option: {
title: { text: 'My Chart' },
xAxis: { type: 'category' },
series: [
{ name: 'Series 1', type: 'bar' },
{ name: 'Series 2', type: 'bar' },
],
},
})
const widget = useWidgetStore.getState().getWidget(widgetId)
const option = (
widget as {
option?: {
title?: { text?: string }
xAxis?: { type?: string }
}
}
)?.option
expect(option?.title?.text).toBe('My Chart')
expect(option?.xAxis?.type).toBe('category')
})
test('registers config tool on mount and unregisters on unmount', () => {
const { unmount } = render()
const widget = useWidgetStore.getState().getWidget(widgetId)
const tools = widget?.registeredTools ?? []
const stackTool = tools.find((t) => t.id === STACK_TOGGLE_TOOL_ID)
expect(stackTool).toBeDefined()
expect(stackTool?.type).toBe('config')
unmount()
const widgetAfter = useWidgetStore.getState().getWidget(widgetId)
const toolsAfter = widgetAfter?.registeredTools ?? []
const stackToolAfter = toolsAfter.find((t) => t.id === STACK_TOGGLE_TOOL_ID)
expect(stackToolAfter).toBeUndefined()
})
})