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