import { afterEach, describe, it, expect, vi } from 'vitest' import { fireEvent, render, screen } from '@testing-library/react' import { MenuItem } from '@mui/material' import { LegendProvider } from '../../provider' import { LegendRow } from './legend-row' import { LegendGroup } from '../legend-group/legend-group' import { LegendActions } from '../legend-actions/legend-actions' import { LegendVisibilityToggle } from '../legend-visibility-toggle/legend-visibility-toggle' import { LegendRowMenu } from '../legend-row-menu/legend-row-menu' import { LegendZoomTo } from '../legend-row-menu/legend-zoom-to' import { LegendShowOnlyLayer, LegendShowAllLayers, } from '../legend-row-menu/legend-row-menu-items' import { LegendOpacity } from '../legend-opacity/legend-opacity' import { LegendConfigSelect } from '../legend-config-select/legend-config-select' import { LegendItem } from '../legend-item/legend-item' import { clearAllLegendStores, getLegendStore, type LegendLayerInput, type LegendVariable, } from '../../stores' const cat: LegendVariable = { type: 'category', items: [{ label: 'A', color: '#123456' }], } afterEach(() => clearAllLegendStores()) /** * Full row composition (the module shape): eye + ⋮ menu in the header slot * (menu last → rightmost), the inline opacity bar as first body child, then * config-select + item. Menu items: built-ins first, ZoomTo (app extra) last. */ function renderRow( id: string, layers: LegendLayerInput[], callbacks: { onZoomTo?: (id: string) => void | Promise } = {}, layerId = layers[0]!.id, ) { return render( {callbacks.onZoomTo && ( )} , ) } /** The row's accordion header (title area), named by its content. */ const titleButton = (name: string | RegExp) => screen.getByRole('button', { name }) describe(' composition', () => { it('partitions Actions into the header and other children into the body', () => { const { container } = renderRow('comp1', [ { id: 'a', name: 'My layer', variables: [cat] }, ]) // Title from the store. expect(screen.getByText('My layer')).toBeTruthy() // The visibility action landed inside the header fade group… const fade = container.querySelector('.PsLegend-rowFade')! expect(fade.contains(screen.getByLabelText('Hide layer'))).toBe(true) // …the ⋮ too (it fades like the rest)… expect(fade.contains(screen.getByLabelText('Layer options'))).toBe(true) // …while the item renderer landed in the body, outside the fade group. expect(fade.contains(screen.getByText('A'))).toBe(false) }) it('toggles visibility through the store', () => { renderRow('r1', [{ id: 'a', name: 'A', variables: [cat] }]) fireEvent.click(screen.getByLabelText('Hide layer')) expect(getLegendStore('r1').getState().layers.a!.visible).toBe(false) fireEvent.click(screen.getByLabelText('Show layer')) expect(getLegendStore('r1').getState().layers.a!.visible).toBe(true) }) it('clicking the title row toggles collapse (accordion header)', () => { renderRow('r2', [{ id: 'a', name: 'A', variables: [cat] }]) const header = titleButton(/A/) expect(header.getAttribute('aria-expanded')).toBe('true') fireEvent.click(header) expect(getLegendStore('r2').getState().layers.a!.collapsed).toBe(true) expect(header.getAttribute('aria-expanded')).toBe('false') // Keyboard parity: Space re-expands. fireEvent.keyDown(header, { key: ' ' }) expect(getLegendStore('r2').getState().layers.a!.collapsed).toBe(false) }) it('offers no collapse affordance when the layer has nothing to collapse', () => { renderRow('r3', [{ id: 'a', name: 'Bodiless', variables: [] }]) expect(screen.queryByRole('button', { name: /Bodiless/ })).toBeNull() }) it('eye toggle drives collapse; a manual title-click collapse persists', () => { renderRow('r6', [{ id: 'a', name: 'A', variables: [cat] }]) // Hide → collapses, and the title button disappears while hidden. fireEvent.click(screen.getByLabelText('Hide layer')) expect(getLegendStore('r6').getState().layers.a!.collapsed).toBe(true) expect(screen.queryByRole('button', { name: /A/ })).toBeNull() fireEvent.click(screen.getByLabelText('Show layer')) expect(getLegendStore('r6').getState().layers.a!.collapsed).toBe(false) // Manual collapse first → hide → show → stays collapsed. fireEvent.click(titleButton(/A/)) fireEvent.click(screen.getByLabelText('Hide layer')) fireEvent.click(screen.getByLabelText('Show layer')) expect(getLegendStore('r6').getState().layers.a!.collapsed).toBe(true) }) it('hides the subtitle while collapsed, restores it on expand', () => { renderRow('sub1', [ { id: 'a', name: 'A', subtitle: 'Zoom 0–12', variables: [cat] }, ]) expect(screen.getByText('Zoom 0–12')).toBeTruthy() fireEvent.click(titleButton(/A/)) expect(screen.queryByText('Zoom 0–12')).toBeNull() fireEvent.click(titleButton(/A/)) expect(screen.getByText('Zoom 0–12')).toBeTruthy() }) it('hides the subtitle while the layer is hidden (force-collapsed)', () => { renderRow('sub2', [ { id: 'a', name: 'A', subtitle: 'Zoom 0–12', variables: [cat] }, ]) fireEvent.click(screen.getByLabelText('Hide layer')) expect(screen.queryByText('Zoom 0–12')).toBeNull() fireEvent.click(screen.getByLabelText('Show layer')) expect(screen.getByText('Zoom 0–12')).toBeTruthy() }) it('renders the config select when the layer declares sections', () => { renderRow('r5', [ { id: 'a', name: 'A', variables: [cat], sections: [{ id: 's1', options: ['x', 'y'], active: 'x' }], }, ]) // Closed field shows the first section's active value. expect(screen.getByRole('button', { name: 'Attribute' }).textContent).toBe( 'x', ) }) it('stacks every variable of the layer with a single layer-level note', () => { renderRow('multi', [ { id: 'a', name: 'Multi', variables: [ cat, { type: 'proportion', min: 0, max: 100, attribute: 'revenue' }, ], helperText: 'One note for the whole layer.', }, ]) // Both renderers are in the body… expect(screen.getByText('A')).toBeTruthy() // category item expect(screen.getByText('Radius range by')).toBeTruthy() // proportion header // …and the note renders exactly once, after all variables. expect(screen.getAllByText('One note for the whole layer.')).toHaveLength(1) }) }) describe(' grouped (inside Legend.Group)', () => { it('renders inside a group and collapses via its own title row', () => { render( , ) fireEvent.click(screen.getByRole('button', { name: /Alpha/ })) expect(getLegendStore('g1-row').getState().layers.a!.collapsed).toBe(true) }) }) describe(' (agnostic shell) + built-in items', () => { it('Legend.ZoomTo fires the composed handler with the layer id; the menu closes', async () => { const onZoomTo = vi.fn() renderRow('m3', [{ id: 'a', name: 'A', variables: [cat] }], { onZoomTo }) fireEvent.click(screen.getByLabelText('Layer options')) fireEvent.click(screen.getByText('Zoom to layer')) expect(onZoomTo).toHaveBeenCalledWith('a') // Sync handler settles on the next microtask → item closes the menu. await vi.waitFor(() => { expect(screen.queryByRole('menuitem')).toBeNull() }) }) it('Legend.ZoomTo keeps the menu open with loading feedback while the handler is pending', async () => { let resolveZoom!: () => void const onZoomTo = vi.fn( () => new Promise((resolve) => { resolveZoom = resolve }), ) renderRow('m3-busy', [{ id: 'a', name: 'A', variables: [cat] }], { onZoomTo, }) fireEvent.click(screen.getByLabelText('Layer options')) fireEvent.click(screen.getByText('Zoom to layer')) expect(onZoomTo).toHaveBeenCalledWith('a') // Menu stays open; item shows the in-flight label and is disabled. const item = await screen.findByRole('menuitem', { name: /Loading/ }) expect(item.getAttribute('aria-disabled')).toBe('true') expect(screen.getByRole('progressbar')).toBeDefined() resolveZoom() await vi.waitFor(() => { expect(screen.queryByRole('menuitem')).toBeNull() }) }) it('Legend.ZoomTo ignores a second click before the busy re-render', async () => { let resolveZoom!: () => void const onZoomTo = vi.fn( () => new Promise((resolve) => { resolveZoom = resolve }), ) renderRow('m3-dbl', [{ id: 'a', name: 'A', variables: [cat] }], { onZoomTo, }) fireEvent.click(screen.getByLabelText('Layer options')) const zoomItem = screen.getByText('Zoom to layer') // Two clicks in the same tick — before React applies setBusy(true). fireEvent.click(zoomItem) fireEvent.click(zoomItem) expect(onZoomTo).toHaveBeenCalledTimes(1) resolveZoom() await vi.waitFor(() => { expect(screen.queryByRole('menuitem')).toBeNull() }) }) it('Show only this layer / Show all layers dispatch the sibling actions', () => { render( , ) fireEvent.click(screen.getByLabelText('Layer options')) fireEvent.click(screen.getByText('Show only this layer')) expect(getLegendStore('m7').getState().layers.b!.visible).toBe(false) fireEvent.click(screen.getByLabelText('Layer options')) fireEvent.click(screen.getByText('Show all layers')) expect(getLegendStore('m7').getState().layers.b!.visible).toBe(true) }) it('show only/all scope to the group bucket and restore collapse snapshots', () => { render( , ) // Manual collapse on the sibling: the snapshot must survive the cycle. getLegendStore('m9').getState().setCollapsed('b', true) fireEvent.click(screen.getByLabelText('Layer options')) fireEvent.click(screen.getByText('Show only this layer')) let st = getLegendStore('m9').getState() expect(st.layers.b!.visible).toBe(false) // sibling in g1 expect(st.layers.loose!.visible).toBe(true) // other bucket untouched fireEvent.click(screen.getByLabelText('Layer options')) fireEvent.click(screen.getByText('Show all layers')) st = getLegendStore('m9').getState() expect(st.layers.b!.visible).toBe(true) expect(st.layers.b!.collapsed).toBe(true) // manual collapse restored }) it('renders any consumer MenuItem; clicks dispatch and close the menu', () => { const onCustom = vi.fn() render( Custom thing , ) fireEvent.click(screen.getByLabelText('Layer options')) fireEvent.click(screen.getByText('Custom thing')) expect(onCustom).toHaveBeenCalled() expect(screen.queryByRole('menuitem')).toBeNull() }) it('renders nothing without children', () => { render( , ) expect(screen.queryByLabelText('Layer options')).toBeNull() }) it('stays available while hidden, with only the applicable items', () => { renderRow( 'm8', [{ id: 'a', name: 'A', variables: [cat], opacityControl: true }], { onZoomTo: vi.fn() }, ) fireEvent.click(screen.getByLabelText('Hide layer')) // ⋮ still present (hover-revealed, not pinned). const trigger = screen.getByLabelText('Layer options') expect(trigger.className).not.toContain('active') fireEvent.click(trigger) // Show only/all still apply; Zoom + Opacity remove themselves. expect(screen.getByText('Show only this layer')).toBeTruthy() expect(screen.getByText('Show all layers')).toBeTruthy() expect(screen.queryByText('Zoom to layer')).toBeNull() expect(screen.queryByText('Opacity')).toBeNull() }) }) describe('hover-fade dirty pinning (.active)', () => { it('marks the visibility toggle active while the layer is hidden', () => { renderRow('f1', [{ id: 'a', name: 'A', variables: [cat] }]) fireEvent.click(screen.getByLabelText('Hide layer')) expect(screen.getByLabelText('Show layer').className).toContain('active') fireEvent.click(screen.getByLabelText('Show layer')) expect(screen.getByLabelText('Hide layer').className).not.toContain( 'active', ) }) it('marks the menu trigger active while its menu or the opacity bar is open', () => { renderRow('f2', [ { id: 'a', name: 'A', variables: [cat], opacityControl: true }, ]) const trigger = screen.getByLabelText('Layer options') expect(trigger.className).not.toContain('active') fireEvent.click(trigger) expect(trigger.className).toContain('active') // menu open // Pick Opacity: the menu closes but the ⋮ stays pinned while the bar is // open. fireEvent.click(screen.getByRole('menuitem', { name: 'Opacity' })) expect(screen.queryByRole('menuitem')).toBeNull() expect(trigger.className).toContain('active') }) }) describe(' Item + Inline', () => { const layersWithOpacity: LegendLayerInput[] = [ { id: 'a', name: 'A', variables: [cat], opacityControl: true }, ] const openBar = () => { fireEvent.click(screen.getByLabelText('Layer options')) fireEvent.click(screen.getByRole('menuitem', { name: 'Opacity' })) } it('the menu item toggles the inline bar and stays selected while open', () => { renderRow('o1', layersWithOpacity) expect(screen.queryByRole('slider')).toBeNull() openBar() expect(screen.getByRole('slider', { name: 'Opacity' })).toBeTruthy() expect(screen.getByRole('textbox', { name: 'Opacity' })).toBeTruthy() // Reopen the menu: the item reflects the open bar and toggles it off. fireEvent.click(screen.getByLabelText('Layer options')) const item = screen.getByRole('menuitem', { name: 'Opacity' }) expect(item.className).toContain('Mui-selected') fireEvent.click(item) expect(screen.queryByRole('slider')).toBeNull() }) it('removes the item without opacityControl', () => { renderRow('o2', [{ id: 'a', name: 'A', variables: [cat] }]) fireEvent.click(screen.getByLabelText('Layer options')) expect(screen.queryByRole('menuitem', { name: 'Opacity' })).toBeNull() }) it('opening on a collapsed row auto-expands it; folding keeps the state', () => { renderRow('o3', layersWithOpacity) fireEvent.click(titleButton(/A/)) // collapse openBar() // Auto-expanded so the bar is visible. expect(getLegendStore('o3').getState().layers.a!.collapsed).toBe(false) expect(screen.getByRole('slider', { name: 'Opacity' })).toBeTruthy() // Folding hides the bar with the body but keeps opacityOpen: expanding // brings it back without touching the menu. fireEvent.click(titleButton(/A/)) fireEvent.click(titleButton(/A/)) expect(screen.getByRole('slider', { name: 'Opacity' })).toBeTruthy() }) it('the ✕ closes the bar', () => { renderRow('o4', layersWithOpacity) openBar() fireEvent.click(screen.getByLabelText('Close')) expect(screen.queryByRole('slider')).toBeNull() }) it('slider release commits setOpacity (0–1) to the store', () => { renderRow('c1', layersWithOpacity) openBar() const slider = screen.getByRole('slider', { name: 'Opacity' }) // The hidden-input change path fires MUI's onChangeCommitted too. fireEvent.change(slider, { target: { value: '40' } }) expect(getLegendStore('c1').getState().layers.a!.opacity).toBe(0.4) }) it('percent input commits live and clamps to 0–100', () => { renderRow('c3', layersWithOpacity) openBar() const input = screen.getByRole('textbox', { name: 'Opacity' }) fireEvent.change(input, { target: { value: '40' } }) expect(getLegendStore('c3').getState().layers.a!.opacity).toBe(0.4) // Out-of-range input clamps (no ellipsized "1…" — the field shows 100). fireEvent.change(input, { target: { value: '250' } }) expect(getLegendStore('c3').getState().layers.a!.opacity).toBe(1) expect((input as HTMLInputElement).value).toBe('100') // Empty input doesn't commit; blur snaps back to the store value. fireEvent.change(input, { target: { value: '' } }) expect(getLegendStore('c3').getState().layers.a!.opacity).toBe(1) fireEvent.blur(input) expect((input as HTMLInputElement).value).toBe('100') }) }) describe(' dispatch', () => { it('a single section renders without its title; a pick writes the store', () => { renderRow('c2', [ { id: 'a', name: 'A', variables: [cat], sections: [ { id: 's1', title: 'Global', options: ['x', 'y'], active: 'x' }, ], }, ]) fireEvent.click(screen.getByRole('button', { name: 'Attribute' })) expect(screen.queryByText('Global')).toBeNull() // single section → no title fireEvent.click(screen.getByRole('menuitem', { name: 'y' })) expect(getLegendStore('c2').getState().layers.a!.sections![0]!.active).toBe( 'y', ) // Menu closed after the pick. expect(screen.queryByRole('menuitem')).toBeNull() }) it('multiple sections show titles and each keeps its own active', () => { renderRow('c4', [ { id: 'a', name: 'A', variables: [cat], sections: [ { id: 'global', title: 'Global', options: ['Preset 01', 'Preset 04'], active: 'Preset 04', }, { id: 'vars', title: 'Variables 1', options: ['Atributo 01', 'Atributo 02'], active: 'Atributo 01', }, ], }, ]) // Closed display = first section's active. const trigger = screen.getByRole('button', { name: 'Attribute' }) expect(trigger.textContent).toBe('Preset 04') fireEvent.click(trigger) expect(screen.getByText('Global')).toBeTruthy() expect(screen.getByText('Variables 1')).toBeTruthy() // Both sections highlight their own active simultaneously. expect( screen.getByRole('menuitem', { name: 'Preset 04' }).className, ).toContain('Mui-selected') expect( screen.getByRole('menuitem', { name: 'Atributo 01' }).className, ).toContain('Mui-selected') // Picking in the second section leaves the first untouched. fireEvent.click(screen.getByRole('menuitem', { name: 'Atributo 02' })) const sections = getLegendStore('c4').getState().layers.a!.sections! expect(sections[0]!.active).toBe('Preset 04') expect(sections[1]!.active).toBe('Atributo 02') expect(trigger.textContent).toBe('Preset 04') // display unchanged }) }) describe(' controlled mode', () => { it('renders the eye for each tri-state and fires onToggle with the next visibility', () => { const onToggle = vi.fn() const { rerender } = render( , ) // No Row/Group context needed — works standalone. const eye = screen.getByLabelText('Hide layer') expect(eye.className).not.toContain('active') fireEvent.click(eye) expect(onToggle).toHaveBeenLastCalledWith(false) rerender( , ) const mixed = screen.getByLabelText('Hide layer') expect(mixed.className).toContain('active') expect( mixed.querySelector('[data-testid="VisibilityIndeterminateIcon"]'), ).toBeTruthy() fireEvent.click(mixed) expect(onToggle).toHaveBeenLastCalledWith(false) rerender( , ) const hidden = screen.getByLabelText('Show layer') expect(hidden.className).toContain('active') fireEvent.click(hidden) expect(onToggle).toHaveBeenLastCalledWith(true) }) it('controlled props win over the row context and accept label overrides', () => { const onToggle = vi.fn() renderRow('vt2', [{ id: 'a', name: 'A', variables: [cat] }]) render( , ) // The layer is visible in the store, but the controlled props render the // hidden eye with the custom label — context is ignored. const eye = screen.getByLabelText('Show section') fireEvent.click(eye) expect(onToggle).toHaveBeenCalledWith(true) expect(getLegendStore('vt3').getState().layers.a!.visible).toBe(true) }) }) describe('labels override', () => { it('Provider labels replace the defaults end-to-end', () => { render( , ) expect(screen.getByLabelText('Ocultar capa')).toBeTruthy() fireEvent.click(screen.getByLabelText('Layer options')) // The opacity menu item carries the overridden label… fireEvent.click(screen.getByRole('menuitem', { name: 'Opacidad' })) // …and so does the inline bar's slider. expect(screen.getByRole('slider', { name: 'Opacidad' })).toBeTruthy() }) }) describe(' keepAlive', () => { it('preserves interactive state across remounts by default', () => { const ui = ( ) const { unmount } = render(ui) fireEvent.click(screen.getByLabelText('Hide layer')) expect(getLegendStore('ka1').getState().layers.a!.visible).toBe(false) unmount() render(ui) // Store survived: still hidden after remount. expect(getLegendStore('ka1').getState().layers.a!.visible).toBe(false) }) it('keepAlive=false resets state on remount', () => { const ui = ( ) const { unmount } = render(ui) fireEvent.click(screen.getByLabelText('Hide layer')) unmount() render(ui) expect(getLegendStore('ka2').getState().layers.a!.visible).toBe(true) }) })