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