/**
* Render tests for BrushToggle covering the previously-uncovered branches
* in src/widgets/actions/brush-toggle/brush-toggle.tsx (0/72 baseline).
*
* Coverage focus:
* - useWidgetSelector default (defaultEnabled when no store entry)
* - Initial seed useEffect only fires when brush is undefined
* - handleToggle: brush=false → true (clears rectangles + invokes consumer
* callback) vs brush=true → false (preserves rectangles)
* - Consumer-driven clear effect (`selections === 0` branch with vs without
* rectangles/selection)
* - lastSelectionRef gating (callback fires only once per new selection)
* - Custom labels, custom Icon, custom IconButtonProps
*
* Does NOT render BrushOverlay logic — that's a separate file with its own
* 177-branch surface. BrushOverlay is included via the BrushToggle JSX, but
* its rendering happens lazily and the overlay's pointer logic isn't
* exercised here.
*/
import { describe, it, expect, beforeEach, vi } from 'vitest'
import { render, screen, fireEvent } from '@testing-library/react'
import { ThemeProvider, createTheme } from '@mui/material/styles'
import { useWidgetStore, widgetStoreActions } from '../../stores/widget-store'
import { BrushToggle } from './brush-toggle'
import type { BrushState } from './types'
const theme = createTheme({
palette: {
common: { white: '#ffffff', black: '#000000' },
secondary: { main: '#3366ff' },
// @ts-expect-error - `black` is a CARTO-specific MUI palette extension
black: { 60: 'rgba(0,0,0,0.6)' },
},
})
function renderWithTheme(ui: React.ReactElement) {
return render({ui})
}
beforeEach(() => {
useWidgetStore.getState().clearWidgets()
})
describe('BrushToggle render', () => {
it('renders with brush off (default)', () => {
renderWithTheme()
const button = screen.getByRole('button')
expect(button.getAttribute('data-active')).toBe('false')
})
it('seeds the widget store on mount when brush is undefined', () => {
renderWithTheme()
const widget = widgetStoreActions.getWidget('bt-seed')
expect(widget?.brush).toBe(true)
})
it('does NOT seed when brush is already set in the store', () => {
widgetStoreActions.setWidget('bt-noseed', { brush: false })
renderWithTheme()
const widget = widgetStoreActions.getWidget('bt-noseed')
expect(widget?.brush).toBe(false)
})
it('uses custom labels for aria-label', () => {
renderWithTheme(
,
)
expect(screen.getByRole('button', { name: 'Brush on' })).toBeDefined()
})
it('respects an explicit ariaLabel', () => {
renderWithTheme(
,
)
expect(
screen.getByRole('button', { name: 'Toggle brush selection' }),
).toBeDefined()
})
it('renders custom Icon when provided', () => {
renderWithTheme(
} />,
)
expect(screen.getByTestId('custom-icon')).toBeDefined()
})
})
describe('BrushToggle interactions', () => {
it('handleToggle: brush=false → true clears rectangles and invokes consumer callback with empty selection', () => {
const onBrushSelected = vi.fn()
widgetStoreActions.setWidget('bt-on', { brush: false })
renderWithTheme(
,
)
fireEvent.click(screen.getByRole('button'))
const w = widgetStoreActions.getWidget('bt-on')
expect(w?.brush).toBe(true)
expect(w?.brushRects).toEqual([])
expect(onBrushSelected).toHaveBeenCalledWith({
dataIndex: [],
seriesIndex: 0,
})
})
it('handleToggle: brush=true → false does NOT clear rectangles', () => {
widgetStoreActions.setWidget('bt-off', {
brush: true,
brushRects: [{ x: 1, y: 2, w: 3, h: 4 }],
})
const onBrushSelected = vi.fn()
renderWithTheme(
,
)
fireEvent.click(screen.getByRole('button'))
const w = widgetStoreActions.getWidget('bt-off')
expect(w?.brush).toBe(false)
// Rectangles preserved
expect(w?.brushRects).toHaveLength(1)
// Consumer callback not invoked on the disable transition
expect(onBrushSelected).not.toHaveBeenCalled()
})
it('works when onBrushSelected is not provided (`?.` short-circuit)', () => {
widgetStoreActions.setWidget('bt-no-cb', { brush: false })
renderWithTheme()
fireEvent.click(screen.getByRole('button'))
const w = widgetStoreActions.getWidget('bt-no-cb')
expect(w?.brush).toBe(true)
})
})
describe('BrushToggle consumer-driven clear (selections=0)', () => {
it('clears stored rectangles + selection when `selections` transitions to 0', () => {
widgetStoreActions.setWidget('bt-clear', {
brush: true,
brushRects: [{ x: 1, y: 2, w: 3, h: 4 }],
brushSelection: { dataIndex: [1, 2, 3], seriesIndex: 0 },
})
renderWithTheme()
const w = widgetStoreActions.getWidget('bt-clear')
expect(w?.brushRects).toEqual([])
expect(w?.brushSelection?.dataIndex).toEqual([])
})
it('is a no-op when there is nothing to clear', () => {
widgetStoreActions.setWidget('bt-clear-nop', {
brush: true,
brushRects: [],
brushSelection: { dataIndex: [], seriesIndex: 0 },
})
renderWithTheme()
const w = widgetStoreActions.getWidget('bt-clear-nop')
// Still empty; effect should have short-circuited
expect(w?.brushRects).toEqual([])
})
it('does nothing when `selections` is non-zero', () => {
widgetStoreActions.setWidget('bt-nonzero', {
brush: true,
brushRects: [{ x: 0, y: 0, w: 1, h: 1 }],
brushSelection: { dataIndex: [5], seriesIndex: 0 },
})
renderWithTheme()
const w = widgetStoreActions.getWidget('bt-nonzero')
expect(w?.brushRects).toHaveLength(1)
expect(w?.brushSelection?.dataIndex).toEqual([5])
})
})
describe('BrushToggle selection-callback effect', () => {
it('invokes onBrushSelected when the store selection changes', () => {
const onBrushSelected = vi.fn()
widgetStoreActions.setWidget('bt-sel', {
brush: true,
brushSelection: { dataIndex: [1, 2], seriesIndex: 0 },
})
renderWithTheme(
,
)
expect(onBrushSelected).toHaveBeenCalledWith({
dataIndex: [1, 2],
seriesIndex: 0,
})
})
it('does not double-invoke for the same selection reference', () => {
const onBrushSelected = vi.fn()
widgetStoreActions.setWidget('bt-sel-dup', {
brush: true,
brushSelection: { dataIndex: [1], seriesIndex: 0 },
})
const { rerender } = renderWithTheme(
,
)
rerender(
,
)
expect(onBrushSelected).toHaveBeenCalledTimes(1)
})
})