import { createElement } from 'react' import { afterEach, describe, expect, it } from 'vitest' import { createLegendStore, clearAllLegendStores, } from './legend-store-registry' import { selectAllGroupsCollapsed, selectGroupVisibility, selectLayersByGroup, selectOrderedGroups, selectVisibleLayersGrouped, } from './selectors' import type { LegendLayerInput, LegendVariable } from './types' const cat: LegendVariable = { type: 'category', items: [{ label: 'A', color: '#000' }], } const layers: LegendLayerInput[] = [ { id: 'a', name: 'A', variables: [cat] }, { id: 'b', name: 'B', variables: [cat] }, { id: 'c', name: 'C', variables: [cat], groupId: 'g1' }, { id: 'd', name: 'D', variables: [cat], groupId: 'g1' }, ] afterEach(() => clearAllLegendStores()) describe('legend store', () => { it('seeds interactive defaults (visible/opacity/collapsed/order)', () => { const store = createLegendStore('s', { layers }) const a = store.getState().layers.a! expect(a.visible).toBe(true) expect(a.opacity).toBe(1) expect(a.collapsed).toBe(false) expect(a.order).toBe(0) expect(store.getState().layers.b!.order).toBe(1) }) it('setVisibility / setCollapsed / setSectionValue mutate the layer', () => { const store = createLegendStore('s', { layers: [ { id: 'a', name: 'A', variables: [cat], sections: [{ id: 's1', options: ['x', 'pop'], active: 'x' }], }, ], }) const s = store.getState() s.setVisibility('a', false) s.setCollapsed('a', true) s.setSectionValue('a', 's1', 'pop') const a = store.getState().layers.a! expect(a.visible).toBe(false) expect(a.collapsed).toBe(true) expect(a.sections![0]!.active).toBe('pop') }) it('setSectionValue validates the section and option', () => { const sections = [ { id: 's1', title: 'Global', options: ['x', 'y'], active: 'x' }, { id: 's2', title: 'Vars', options: ['m', 'n'], active: 'm' }, ] const store = createLegendStore('s', { layers: [{ id: 'a', name: 'A', variables: [cat], sections }], }) const before = store.getState().layers store.getState().setSectionValue('a', 'nope', 'y') // unknown section store.getState().setSectionValue('a', 's1', 'zzz') // option not offered store.getState().setSectionValue('zz', 's1', 'y') // unknown layer expect(store.getState().layers).toBe(before) // all no-ops // Sections keep independent actives. store.getState().setSectionValue('a', 's2', 'n') const after = store.getState().layers.a!.sections! expect(after[0]!.active).toBe('x') expect(after[1]!.active).toBe('n') }) it('_sync preserves user-picked section actives (structure follows props)', () => { const sections = [ { id: 's1', options: ['x', 'y'], active: 'x' }, { id: 's2', options: ['m', 'n'], active: 'm' }, ] const input = [{ id: 'a', name: 'A', variables: [cat], sections }] const store = createLegendStore('s', { layers: input }) store.getState().setSectionValue('a', 's1', 'y') // Re-sync with a NEW sections reference (renamed layer): the user's pick // survives because s1 still exists and 'y' is still offered. store.getState()._sync( [ { id: 'a', name: 'A renamed', variables: [cat], sections: sections.map((x) => ({ ...x })), }, ], [], ) let after = store.getState().layers.a! expect(after.name).toBe('A renamed') expect(after.sections![0]!.active).toBe('y') // If the pick is no longer among the options, the incoming active wins. store.getState()._sync( [ { id: 'a', name: 'A', variables: [cat], sections: [{ id: 's1', options: ['x', 'z'], active: 'z' }], }, ], [], ) after = store.getState().layers.a! expect(after.sections![0]!.active).toBe('z') }) it('setOpacity clamps to 0–1', () => { const store = createLegendStore('s', { layers }) store.getState().setOpacity('a', 5) expect(store.getState().layers.a!.opacity).toBe(1) store.getState().setOpacity('a', -2) expect(store.getState().layers.a!.opacity).toBe(0) }) it('group setters cascade visibility/collapse to members', () => { const store = createLegendStore('s', { layers, groups: [ { id: 'g1', label: 'One' }, { id: 'g2', label: 'Two' }, ], }) // Group visibility cascades to the member layers (c, d). store.getState().setGroupVisibility('g1', false) store.getState().setGroupCollapsed('g1', true) expect(store.getState().layers.c!.visible).toBe(false) expect(store.getState().layers.d!.visible).toBe(false) expect(selectGroupVisibility(store.getState(), 'g1')).toBe('hidden') expect(store.getState().groups.g1!.collapsed).toBe(true) }) it('a group with no visible member starts collapsed', () => { const store = createLegendStore('s', { layers: layers.map((l) => l.groupId === 'g1' ? { ...l, visible: false } : l, ), groups: [ { id: 'g1', label: 'One' }, { id: 'g2', label: 'Empty' }, ], }) expect(store.getState().groups.g1!.collapsed).toBe(true) // No members at all also counts as "nothing enabled". expect(store.getState().groups.g2!.collapsed).toBe(true) }) it('a group with at least one visible member starts expanded', () => { const store = createLegendStore('s', { layers, groups: [{ id: 'g1', label: 'One' }], }) expect(store.getState().groups.g1!.collapsed).toBe(false) }) it('selectGroupVisibility derives the tri-state from member layers', () => { const store = createLegendStore('s', { layers, groups: [{ id: 'g1', label: 'One' }], }) expect(selectGroupVisibility(store.getState(), 'g1')).toBe('visible') store.getState().setVisibility('c', false) expect(selectGroupVisibility(store.getState(), 'g1')).toBe('mixed') store.getState().setVisibility('d', false) expect(selectGroupVisibility(store.getState(), 'g1')).toBe('hidden') // Empty / unknown groups read as visible. expect(selectGroupVisibility(store.getState(), 'nope')).toBe('visible') }) it('hiding a layer individually never collapses its group', () => { const store = createLegendStore('s', { layers, groups: [{ id: 'g1', label: 'One' }], }) store.getState().setVisibility('c', false) store.getState().setVisibility('d', false) // group now derived-hidden expect(store.getState().groups.g1!.collapsed).toBe(false) }) it('group cascade preserves each member layer collapse snapshot', () => { const store = createLegendStore('s', { layers, groups: [{ id: 'g1', label: 'One' }], }) store.getState().setCollapsed('c', true) // manual collapse on one member store.getState().setGroupVisibility('g1', false) store.getState().setGroupVisibility('g1', true) expect(store.getState().layers.c!.collapsed).toBe(true) // persisted expect(store.getState().layers.d!.collapsed).toBe(false) // restored default }) it('hiding a mixed group leaves already-hidden members untouched', () => { const store = createLegendStore('s', { layers, groups: [{ id: 'g1', label: 'One' }], }) store.getState().setVisibility('c', false) // c hidden, snapshot: expanded store.getState().setGroupVisibility('g1', false) // hides d only store.getState().setGroupVisibility('g1', true) // shows both expect(store.getState().layers.c!.visible).toBe(true) expect(store.getState().layers.c!.collapsed).toBe(false) // own snapshot expect(store.getState().layers.d!.visible).toBe(true) }) it('setGroupVisibility no-ops at the target state and on empty groups', () => { const store = createLegendStore('s', { layers, groups: [ { id: 'g1', label: 'One' }, { id: 'empty', label: 'Empty' }, ], }) const before = store.getState() store.getState().setGroupVisibility('g1', true) // already all visible store.getState().setGroupVisibility('empty', false) // no members expect(store.getState().layers).toBe(before.layers) expect(store.getState().groups).toBe(before.groups) store.getState().setGroupVisibility('g1', false) const hidden = store.getState() store.getState().setGroupVisibility('g1', false) // double-hide expect(store.getState().layers).toBe(hidden.layers) }) it('_sync preserves interactive state of known layers and drops vanished ones', () => { const store = createLegendStore('s', { layers }) store.getState().setVisibility('a', false) store.getState().setOpacity('a', 0.3) // Re-sync with updated name for "a" and without "b". store.getState()._sync( [ { id: 'a', name: 'A renamed', variables: [cat] }, { id: 'c', name: 'C', variables: [cat], groupId: 'g1' }, ], [], ) const a = store.getState().layers.a! expect(a.name).toBe('A renamed') // static field follows props expect(a.visible).toBe(false) // interactive state preserved expect(a.opacity).toBe(0.3) expect(store.getState().layers.b).toBeUndefined() // vanished layer dropped }) it('normalizes duplicate sibling orders to dense indexes', () => { const store = createLegendStore('s', { layers: [ { id: 'x', name: 'X', variables: [cat], order: 5 }, { id: 'y', name: 'Y', variables: [cat], order: 5 }, { id: 'z', name: 'Z', variables: [cat], order: 6 }, ], }) // Ties broken by input position → dense 0,1,2. expect(store.getState().layers.x!.order).toBe(0) expect(store.getState().layers.y!.order).toBe(1) expect(store.getState().layers.z!.order).toBe(2) expect( selectLayersByGroup(store.getState(), undefined).map((l) => l.id), ).toEqual(['x', 'y', 'z']) }) it('_sync with identical input is a no-op preserving object identity', () => { const store = createLegendStore('s', { layers }) const before = store.getState() store.getState()._sync(layers, []) const after = store.getState() expect(after.layers).toBe(before.layers) // record identity kept expect(after.layers.a).toBe(before.layers.a) // entity identity kept }) it('visibility drives collapse: hide → collapsed, show → expanded', () => { const store = createLegendStore('s', { layers }) store.getState().setVisibility('a', false) expect(store.getState().layers.a!.collapsed).toBe(true) store.getState().setVisibility('a', true) expect(store.getState().layers.a!.collapsed).toBe(false) }) it('manual collapse persists across hide/show', () => { const store = createLegendStore('s', { layers }) store.getState().setCollapsed('a', true) // manual collapse store.getState().setVisibility('a', false) expect(store.getState().layers.a!.collapsed).toBe(true) store.getState().setVisibility('a', true) expect(store.getState().layers.a!.collapsed).toBe(true) // persisted }) it('manual expand persists: hide collapses, show restores expanded', () => { const store = createLegendStore('s', { layers }) store.getState().setCollapsed('a', true) store.getState().setCollapsed('a', false) // manual expand store.getState().setVisibility('a', false) expect(store.getState().layers.a!.collapsed).toBe(true) // hidden = collapsed store.getState().setVisibility('a', true) expect(store.getState().layers.a!.collapsed).toBe(false) // restored }) it('manual toggle while hidden wins on the next show', () => { const store = createLegendStore('s', { layers }) store.getState().setVisibility('a', false) // snapshot: expanded store.getState().setCollapsed('a', true) // manual choice while hidden store.getState().setVisibility('a', true) expect(store.getState().layers.a!.collapsed).toBe(true) }) it('double-hide is a no-op and does not corrupt the snapshot', () => { const store = createLegendStore('s', { layers }) store.getState().setVisibility('a', false) store.getState().setVisibility('a', false) // idempotent store.getState().setVisibility('a', true) expect(store.getState().layers.a!.collapsed).toBe(false) // pre-hide state }) it('group visibility drives group collapse with the same persistence', () => { const store = createLegendStore('s', { layers, groups: [{ id: 'g1', label: 'One' }], }) store.getState().setGroupVisibility('g1', false) expect(store.getState().groups.g1!.collapsed).toBe(true) store.getState().setGroupVisibility('g1', true) expect(store.getState().groups.g1!.collapsed).toBe(false) store.getState().setGroupCollapsed('g1', true) // manual store.getState().setGroupVisibility('g1', false) store.getState().setGroupVisibility('g1', true) expect(store.getState().groups.g1!.collapsed).toBe(true) // persisted }) it('_sync treats a re-created group icon of the same type/props as unchanged', () => { const store = createLegendStore('s', { layers, groups: [ { id: 'g1', label: 'One', icon: createElement('span', null, 'icon') }, ], }) const before = store.getState().groups // New element instance each call (as a consumer re-rendering inline // would produce), but same type and shallow-equal props. store .getState() ._sync(layers, [ { id: 'g1', label: 'One', icon: createElement('span', null, 'icon') }, ]) expect(store.getState().groups).toBe(before) // no sync at all }) it('_sync detects a real icon change (different type/props)', () => { const store = createLegendStore('s', { layers, groups: [ { id: 'g1', label: 'One', icon: createElement('span', null, 'icon') }, ], }) const newIcon = createElement('div', null, 'icon') store.getState()._sync(layers, [{ id: 'g1', label: 'One', icon: newIcon }]) expect(store.getState().groups.g1!.icon).toBe(newIcon) }) it('_sync detects a same-type/same-props icon with a different key as changed', () => { const store = createLegendStore('s', { groups: [ { id: 'g1', label: 'One', icon: createElement('span', { key: 'a' }, 'icon'), }, ], }) const newIcon = createElement('span', { key: 'b' }, 'icon') store.getState()._sync([], [{ id: 'g1', label: 'One', icon: newIcon }]) expect(store.getState().groups.g1!.icon).toBe(newIcon) }) it('_sync preserves the collapse snapshot of a hidden layer', () => { const store = createLegendStore('s', { layers }) store.getState().setCollapsed('a', true) // manual collapse store.getState().setVisibility('a', false) store.getState()._sync(layers, []) // Provider re-sync while hidden store.getState().setVisibility('a', true) expect(store.getState().layers.a!.collapsed).toBe(true) // snapshot survived }) it('_sync replaces only the entities that changed', () => { const store = createLegendStore('s', { layers }) const before = store.getState() // Unchanged layers reuse the fixture inputs (stable `variables` array // references — the documented contract for identity-preserving syncs). store .getState() ._sync( [ { ...layers[0]!, name: 'A renamed' }, layers[1]!, layers[2]!, layers[3]!, ], [], ) const after = store.getState() expect(after.layers).not.toBe(before.layers) expect(after.layers.a).not.toBe(before.layers.a) // renamed → new object expect(after.layers.b).toBe(before.layers.b) // untouched → same object }) // ─── Imperative add/remove/update ────────────────────────────────────────── it('setLegendLayer adds a new layer with seeded defaults + appended order', () => { const store = createLegendStore('s', { layers }) store.getState().setLegendLayer({ id: 'e', name: 'E', variables: [cat] }) // ungrouped const e = store.getState().layers.e! expect(e.visible).toBe(true) expect(e.opacity).toBe(1) expect(e.collapsed).toBe(false) // Ungrouped bucket was a,b (orders 0,1) → new layer appends at 2. expect(e.order).toBe(2) // Untouched layers keep their identity. expect(store.getState().layers.a).toBeDefined() }) it('setLegendLayer appends within the target group bucket', () => { const store = createLegendStore('s', { layers }) store .getState() .setLegendLayer({ id: 'e', name: 'E', variables: [cat], groupId: 'g1' }) expect( selectLayersByGroup(store.getState(), 'g1').map((l) => l.id), ).toEqual(['c', 'd', 'e']) }) it('setLegendLayer updates an existing layer, preserving interactive state', () => { const store = createLegendStore('s', { layers: [ { id: 'a', name: 'A', variables: [cat], sections: [ { id: 's1', title: 'By', options: ['x', 'y'], active: 'x' }, ], }, ], }) store.getState().setVisibility('a', false) store.getState().setSectionValue('a', 's1', 'y') store.getState().setLegendLayer({ id: 'a', name: 'A renamed', variables: [cat], sections: [{ id: 's1', title: 'By', options: ['x', 'y'], active: 'x' }], }) const a = store.getState().layers.a! expect(a.name).toBe('A renamed') // static field follows input expect(a.visible).toBe(false) // interactive state preserved expect(a.sections![0]!.active).toBe('y') // user pick preserved (mergeSections) }) it('setLegendLayer with identical input is a no-op (identity preserved)', () => { const store = createLegendStore('s', { layers }) const before = store.getState() store.getState().setLegendLayer(layers[0]!) // same id, same content expect(store.getState().layers).toBe(before.layers) }) it('removeLegendLayer drops the layer and re-densifies sibling order', () => { const store = createLegendStore('s', { layers }) store.getState().removeLegendLayer('a') expect(store.getState().layers.a).toBeUndefined() // b was order 1 → re-densified to 0. expect(store.getState().layers.b!.order).toBe(0) const before = store.getState() store.getState().removeLegendLayer('nope') // unknown → no-op expect(store.getState().layers).toBe(before.layers) }) it('setLegendGroup adds/updates a group; removeLegendGroup un-groups members', () => { const store = createLegendStore('s', { layers, groups: [{ id: 'g1', label: 'One' }], }) store.getState().setLegendGroup({ id: 'g2', label: 'Two' }) expect(selectOrderedGroups(store.getState()).map((g) => g.id)).toEqual([ 'g1', 'g2', ]) store.getState().setLegendGroup({ id: 'g1', label: 'One renamed' }) expect(store.getState().groups.g1!.label).toBe('One renamed') // Removing g1 keeps its members (c, d) as ungrouped rows. store.getState().removeLegendGroup('g1') expect(store.getState().groups.g1).toBeUndefined() expect(store.getState().layers.c!.groupId).toBeUndefined() expect(store.getState().layers.d!.groupId).toBeUndefined() expect( selectLayersByGroup(store.getState(), undefined).map((l) => l.id), ).toContain('c') }) }) describe('moveLayer / moveGroup', () => { // a, b ungrouped; c, d, e in g1; f in g2. const moveLayers: LegendLayerInput[] = [ { id: 'a', name: 'A', variables: [cat] }, { id: 'b', name: 'B', variables: [cat] }, { id: 'c', name: 'C', variables: [cat], groupId: 'g1' }, { id: 'd', name: 'D', variables: [cat], groupId: 'g1' }, { id: 'e', name: 'E', variables: [cat], groupId: 'g1' }, { id: 'f', name: 'F', variables: [cat], groupId: 'g2' }, ] const moveGroups = [ { id: 'g1', label: 'G1' }, { id: 'g2', label: 'G2' }, { id: 'g3', label: 'G3' }, ] const orderedIds = ( s: ReturnType['getState']>, groupId: string | undefined, ) => selectLayersByGroup(s, groupId).map((l) => l.id) it('reorders within the bucket with dense orders', () => { const store = createLegendStore('s', { layers: moveLayers, groups: moveGroups, }) store.getState().moveLayer('e', 0) expect(orderedIds(store.getState(), 'g1')).toEqual(['e', 'c', 'd']) expect( selectLayersByGroup(store.getState(), 'g1').map((l) => l.order), ).toEqual([0, 1, 2]) // Other buckets untouched. expect(orderedIds(store.getState(), undefined)).toEqual(['a', 'b']) expect(orderedIds(store.getState(), 'g2')).toEqual(['f']) }) it('moves ungrouped layers within their own bucket', () => { const store = createLegendStore('s', { layers: moveLayers, groups: moveGroups, }) store.getState().moveLayer('b', 0) expect(orderedIds(store.getState(), undefined)).toEqual(['b', 'a']) }) it('clamps the target index and no-ops on unknown ids / same position', () => { const store = createLegendStore('s', { layers: moveLayers, groups: moveGroups, }) store.getState().moveLayer('c', 99) // clamped to the end of g1 expect(orderedIds(store.getState(), 'g1')).toEqual(['d', 'e', 'c']) const before = store.getState().layers store.getState().moveLayer('nope', 0) store.getState().moveLayer('c', 2) // already there expect(store.getState().layers).toBe(before) }) it('moveGroup reorders the group list and survives a resync', () => { const store = createLegendStore('s', { layers: moveLayers, groups: moveGroups, }) store.getState().moveGroup('g3', 0) expect(selectOrderedGroups(store.getState()).map((g) => g.id)).toEqual([ 'g3', 'g1', 'g2', ]) // A provider resync with the original inputs preserves the user's order. store.getState()._sync(moveLayers, moveGroups) expect(selectOrderedGroups(store.getState()).map((g) => g.id)).toEqual([ 'g3', 'g1', 'g2', ]) const before = store.getState().groups store.getState().moveGroup('nope', 0) expect(store.getState().groups).toBe(before) }) }) describe('summary selectors', () => { // a, b ungrouped; c, d in g1 — plus a second group for cross-group checks. const groupedLayers: LegendLayerInput[] = [ ...layers, { id: 'e', name: 'E', variables: [cat], groupId: 'g2' }, ] const groups = [ { id: 'g1', label: 'G1' }, { id: 'g2', label: 'G2' }, ] it('selectVisibleLayersGrouped lists only visible layers, ungrouped last', () => { const store = createLegendStore('s', { layers: groupedLayers, groups }) store.getState().setVisibility('d', false) store.getState().setVisibility('e', false) // g2 fully hidden → dropped const sections = selectVisibleLayersGrouped(store.getState()) expect(sections.map((x) => x.group?.id)).toEqual(['g1', undefined]) expect(sections[0]!.layers.map((l) => l.id)).toEqual(['c']) expect(sections[1]!.layers.map((l) => l.id)).toEqual(['a', 'b']) }) it('selectAllGroupsCollapsed flips only when every group is collapsed', () => { const store = createLegendStore('s', { layers: groupedLayers, groups }) expect(selectAllGroupsCollapsed(store.getState())).toBe(false) store.getState().setGroupCollapsed('g1', true) expect(selectAllGroupsCollapsed(store.getState())).toBe(false) store.getState().setGroupCollapsed('g2', true) expect(selectAllGroupsCollapsed(store.getState())).toBe(true) }) })