/** * Drives `Legend.Sortable`'s onDragEnd through the shared dnd-kit mock * harness (DndContext passthrough exposing its props) against the **real** * legend store — asserting the committed `moveGroup` / `moveLayer` effects * and the cross-kind/cross-bucket guards. */ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { render, screen } from '@testing-library/react' import { dndKitCoreMock, dndKitSortableMock, lastDndContextProps, resetDndKitMocks, } from '../../../../tests/mocks/dnd-kit' vi.mock('@dnd-kit/core', () => dndKitCoreMock()) vi.mock('@dnd-kit/sortable', () => dndKitSortableMock()) import { LegendProvider } from '../../provider' import { LegendGroup } from '../legend-group/legend-group' import { LegendRow } from '../legend-row/legend-row' import { LegendItem } from '../legend-item/legend-item' import { LegendSortable } from './legend-sortable' import { clearAllLegendStores, getLegendStore, selectLayersByGroup, selectOrderedGroups, type LegendVariable, } from '../../stores' const cat: LegendVariable = { type: 'category', items: [{ label: 'A', color: '#123456' }], } const layers = [ { id: 'a', name: 'Alpha', groupId: 'g1', variables: [cat] }, { id: 'b', name: 'Beta', groupId: 'g1', variables: [cat] }, { id: 'c', name: 'Gamma', groupId: 'g2', variables: [cat] }, { id: 'x', name: 'Loose X', variables: [cat] }, { id: 'y', name: 'Loose Y', variables: [cat] }, ] const groups = [ { id: 'g1', label: 'One' }, { id: 'g2', label: 'Two' }, ] beforeEach(() => resetDndKitMocks()) afterEach(() => clearAllLegendStores()) function renderSortable(id: string, sortable = true) { const tree = ( <> {groups.map((g) => ( {layers .filter((l) => l.groupId === g.id) .map((l) => ( ))} ))} {layers .filter((l) => !l.groupId) .map((l) => ( ))} ) return render( {sortable ? {tree} : tree} , ) } const dragEnd = (activeId: string, overId: string | null) => lastDndContextProps().onDragEnd?.({ active: { id: activeId }, over: overId ? { id: overId } : null, }) describe(' opt-in', () => { it('without the wrapper there are no drag handles', () => { renderSortable('plain', false) expect(screen.queryAllByLabelText('Reorder')).toHaveLength(0) }) it('with the wrapper every group and row exposes its handle', () => { renderSortable('handles') // 2 groups + 5 rows. expect(screen.getAllByLabelText('Reorder')).toHaveLength(7) }) }) describe(' onDragEnd commits', () => { it('group over group → moveGroup', () => { renderSortable('dnd-groups') dragEnd('group:g2', 'group:g1') expect( selectOrderedGroups(getLegendStore('dnd-groups').getState()).map( (g) => g.id, ), ).toEqual(['g2', 'g1']) }) it('layer over layer in the same bucket → moveLayer', () => { renderSortable('dnd-rows') dragEnd('layer:b', 'layer:a') expect( selectLayersByGroup(getLegendStore('dnd-rows').getState(), 'g1').map( (l) => l.id, ), ).toEqual(['b', 'a']) // Ungrouped bucket too. dragEnd('layer:y', 'layer:x') expect( selectLayersByGroup(getLegendStore('dnd-rows').getState(), undefined).map( (l) => l.id, ), ).toEqual(['y', 'x']) }) it('cross-kind and cross-bucket drops are ignored (snap back)', () => { renderSortable('dnd-guards') const before = getLegendStore('dnd-guards').getState().layers const beforeGroups = getLegendStore('dnd-guards').getState().groups dragEnd('group:g1', 'layer:x') // group over a row dragEnd('layer:a', 'group:g2') // row over a group dragEnd('layer:a', 'layer:c') // row over another group's row dragEnd('layer:a', 'layer:x') // grouped row over an ungrouped one dragEnd('layer:a', null) // dropped outside expect(getLegendStore('dnd-guards').getState().layers).toBe(before) expect(getLegendStore('dnd-guards').getState().groups).toBe(beforeGroups) }) })