import { afterEach, describe, it, expect, vi } from 'vitest' import { fireEvent, render, screen } from '@testing-library/react' import { LegendProvider } from '../../provider' import { LegendGroup } from './legend-group' import { LegendRow } from '../legend-row/legend-row' import { LegendActions } from '../legend-actions/legend-actions' import { LegendVisibilityToggle } from '../legend-visibility-toggle/legend-visibility-toggle' import { LegendGroupMenu } from '../legend-group-menu/legend-group-menu' import { LegendShowOnlyGroup, LegendShowAllGroups, LegendCollapseAllGroups, LegendZoomToGroup, } from '../legend-group-menu/legend-group-menu-items' import { LegendShowAllLayers } from '../legend-row-menu/legend-row-menu-items' import { clearAllLegendStores, getLegendStore, type LegendGroupInput, type LegendLayerInput, type LegendVariable, } from '../../stores' const cat: LegendVariable = { type: 'category', items: [{ label: 'A', color: '#123456' }], } const groups: LegendGroupInput[] = [{ id: 'g1', label: 'Demographics' }] const layers: LegendLayerInput[] = [ { id: 'a', name: 'Alpha', variables: [cat], groupId: 'g1', order: 1 }, { id: 'b', name: 'Beta', variables: [cat], groupId: 'g1', order: 0 }, ] afterEach(() => clearAllLegendStores()) /** Module-shaped composition: group eye in the header, explicit member rows. */ function renderGroup(id: string) { return render( {['b', 'a'].map((layerId) => ( ))} , ) } describe(' composition', () => { it('renders the label from the store, Actions in the header, rows in the body', () => { const { container } = renderGroup('g-comp') expect(screen.getByText('Demographics')).toBeTruthy() // The group eye landed in the group fade slot of the header… const fade = container.querySelector('.PsLegend-groupFade')! expect(fade.contains(screen.getByLabelText('Hide group'))).toBe(true) // …and both member rows render in the body. expect(screen.getByText('Alpha')).toBeTruthy() expect(screen.getByText('Beta')).toBeTruthy() }) it('the group eye cascades visibility to every member layer', () => { renderGroup('g-vis') fireEvent.click(screen.getByLabelText('Hide group')) let state = getLegendStore('g-vis').getState() expect(state.layers.a!.visible).toBe(false) expect(state.layers.b!.visible).toBe(false) fireEvent.click(screen.getByLabelText('Show group')) state = getLegendStore('g-vis').getState() expect(state.layers.a!.visible).toBe(true) expect(state.layers.b!.visible).toBe(true) }) it('clicking the title row toggles group collapse (accordion header)', () => { renderGroup('g-col') const header = screen.getByRole('button', { name: 'Demographics' }) expect(header.getAttribute('aria-expanded')).toBe('true') fireEvent.click(header) expect(getLegendStore('g-col').getState().groups.g1!.collapsed).toBe(true) expect(header.getAttribute('aria-expanded')).toBe('false') // Keyboard parity: Enter re-expands. fireEvent.keyDown(header, { key: 'Enter' }) expect(getLegendStore('g-col').getState().groups.g1!.collapsed).toBe(false) }) it('shows the indeterminate icon (mixed) when only some layers are hidden', () => { renderGroup('g-mixed') // Hide one member row via its own (row-context) eye. fireEvent.click(screen.getAllByLabelText('Hide layer')[0]!) // Mixed: the group eye still offers "Hide group", is pinned active, and // renders the indeterminate glyph. const eye = screen.getByLabelText('Hide group') expect(eye.className).toContain('active') expect( eye.querySelector('[data-testid="VisibilityIndeterminateIcon"]'), ).toBeTruthy() // Clicking on mixed hides the remaining visible member. fireEvent.click(eye) const state = getLegendStore('g-mixed').getState() expect(state.layers.a!.visible).toBe(false) expect(state.layers.b!.visible).toBe(false) expect(screen.getByLabelText('Show group')).toBeTruthy() }) it('marks the visibility toggle active while the group is hidden', () => { renderGroup('g-active') fireEvent.click(screen.getByLabelText('Hide group')) expect(screen.getByLabelText('Show group').className).toContain('active') }) it('hiding the group collapses it, but the title row stays usable', () => { renderGroup('g-hide') fireEvent.click(screen.getByLabelText('Hide group')) expect(getLegendStore('g-hide').getState().groups.g1!.collapsed).toBe(true) // Groups are never force-collapsed by visibility — a hidden group can be // peeked into to re-show layers one by one. const header = screen.getByRole('button', { name: 'Demographics' }) fireEvent.click(header) expect(getLegendStore('g-hide').getState().groups.g1!.collapsed).toBe(false) }) it('renders no icon when the group has no icon configured', () => { renderGroup('g-icon-none') expect(screen.queryByTestId('LayersOutlinedIcon')).toBeNull() expect(screen.queryByTestId('my-icon')).toBeNull() }) it('renders the custom icon when provided', () => { render( , }, ]} > , ) expect(screen.getByTestId('my-icon')).toBeTruthy() }) }) describe(' + built-in items', () => { function renderWithMenu(id: string) { return render( , ) } const zoomSpy = vi.fn() it('Show only this group hides other groups and ungrouped layers', () => { renderWithMenu('gm-only') fireEvent.click(screen.getByLabelText('Group options')) fireEvent.click(screen.getByText('Show only this group')) const state = getLegendStore('gm-only').getState() expect(state.layers.a!.visible).toBe(true) expect(state.layers.solo!.visible).toBe(false) // Menu closed after the pick. expect(screen.queryByRole('menuitem')).toBeNull() fireEvent.click(screen.getByLabelText('Group options')) fireEvent.click(screen.getByText('Show all groups')) expect(getLegendStore('gm-only').getState().layers.solo!.visible).toBe(true) }) it('Collapse all groups toggles and flips its label', () => { renderWithMenu('gm-collapse') fireEvent.click(screen.getByLabelText('Group options')) fireEvent.click(screen.getByText('Collapse all groups')) const state = getLegendStore('gm-collapse').getState() expect(state.groups.g1!.collapsed).toBe(true) expect(state.groups.g2!.collapsed).toBe(true) // Reopen: the item now offers Expand. fireEvent.click(screen.getByLabelText('Group options')) fireEvent.click(screen.getByText('Expand all groups')) expect(getLegendStore('gm-collapse').getState().groups.g1!.collapsed).toBe( false, ) }) it('Zoom to group fires the composed handler with the group id', async () => { renderWithMenu('gm-zoom') fireEvent.click(screen.getByLabelText('Group options')) fireEvent.click(screen.getByText('Zoom to group')) expect(zoomSpy).toHaveBeenCalledWith('g1') await vi.waitFor(() => { expect(screen.queryByRole('menuitem')).toBeNull() }) }) it('Zoom to group keeps the menu open with loading feedback while pending', async () => { let resolveZoom!: () => void const pendingZoom = vi.fn( () => new Promise((resolve) => { resolveZoom = resolve }), ) render( , ) fireEvent.click(screen.getByLabelText('Group options')) fireEvent.click(screen.getByText('Zoom to group')) expect(pendingZoom).toHaveBeenCalledWith('g1') 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('Show all layers composed directly in the group menu targets only that group', () => { render( , ) getLegendStore('gm-showall-group').getState().setVisibility('a', false) getLegendStore('gm-showall-group').getState().setVisibility('b', false) getLegendStore('gm-showall-group').getState().setVisibility('solo', false) fireEvent.click(screen.getByLabelText('Group options')) fireEvent.click(screen.getByText('Show all layers')) const state = getLegendStore('gm-showall-group').getState() expect(state.layers.a!.visible).toBe(true) expect(state.layers.b!.visible).toBe(true) // Ungrouped sibling untouched — this instance only affects g1. expect(state.layers.solo!.visible).toBe(false) }) }) describe('panel-wide group actions with no groups', () => { it('ShowAllGroups / CollapseAllGroups render null when the legend has no groups', () => { render( , ) expect(screen.queryByText('Show all groups')).toBeNull() expect(screen.queryByText('Collapse all groups')).toBeNull() }) })