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()
})
})