@defra/interactive-map 0.0.9-alpha → 0.0.10-alpha

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (33) hide show
  1. package/DOCS_README.md +39 -0
  2. package/docs/api.md +1 -1
  3. package/docs/architecture/architecture-diagrams.md +1 -3
  4. package/docs/architecture/diagrams-viewer.mdx +12 -0
  5. package/docs/govuk-prototype.md +23 -0
  6. package/docs/index.md +23 -0
  7. package/docusaurus.config.cjs +106 -0
  8. package/mise.toml +2 -0
  9. package/package.json +21 -4
  10. package/plugins/beta/draw-es/dist/esm/im-draw-es-plugin.js +1 -1
  11. package/plugins/beta/draw-es/src/api/newPolygon.js +1 -3
  12. package/plugins/beta/draw-es/src/events.js +2 -2
  13. package/plugins/beta/draw-ml/dist/esm/im-draw-ml-plugin.js +1 -1
  14. package/plugins/beta/draw-ml/dist/umd/im-draw-ml-plugin.js +1 -1
  15. package/plugins/beta/draw-ml/src/api/newLine.js +2 -2
  16. package/plugins/beta/draw-ml/src/api/newPolygon.js +2 -2
  17. package/plugins/beta/draw-ml/src/events.js +18 -10
  18. package/plugins/search/src/Search.test.jsx +170 -0
  19. package/plugins/search/src/components/CloseButton/CloseButton.test.jsx +67 -0
  20. package/plugins/search/src/components/Form/Form.test.jsx +158 -0
  21. package/plugins/search/src/components/OpenButton/OpenButton.test.jsx +47 -0
  22. package/plugins/search/src/components/Suggestions/Suggestions.test.jsx +79 -0
  23. package/plugins/search/src/datasets.test.js +46 -0
  24. package/plugins/search/src/events/fetchSuggestions.test.js +212 -0
  25. package/plugins/search/src/events/formHandlers.test.js +232 -0
  26. package/plugins/search/src/events/index.test.js +118 -0
  27. package/plugins/search/src/events/inputHandlers.test.js +104 -0
  28. package/plugins/search/src/events/suggestionHandlers.test.js +166 -0
  29. package/plugins/search/src/index.test.js +47 -0
  30. package/plugins/search/src/reducer.test.js +80 -0
  31. package/plugins/search/src/utils/parseOsNamesResults.js +2 -1
  32. package/plugins/search/src/utils/parseOsNamesResults.test.js +140 -0
  33. package/plugins/search/src/utils/updateMap.test.js +52 -0
@@ -17,8 +17,8 @@ export const newLine = ({ appState, appConfig, pluginConfig, pluginState, mapPro
17
17
  return
18
18
  }
19
19
 
20
- // Emit draw:start
21
- eventBus.emit('draw:start', { mode: 'draw_line' })
20
+ // Emit draw:started
21
+ eventBus.emit('draw:started', { mode: 'draw_line' })
22
22
 
23
23
  // Determin snapLayers from pluginConfig or runtime config
24
24
  let snapLayers = null
@@ -17,8 +17,8 @@ export const newPolygon = ({ appState, appConfig, pluginConfig, pluginState, map
17
17
  return
18
18
  }
19
19
 
20
- // Emit draw:start
21
- eventBus.emit('draw:start', { mode: 'draw_polygon' })
20
+ // Emit draw:started
21
+ eventBus.emit('draw:started', { mode: 'draw_polygon' })
22
22
 
23
23
  // Determin snapLayers from pluginConfig or runtime config
24
24
  let snapLayers = null
@@ -29,14 +29,14 @@ export function attachEvents({ pluginState, mapProvider, buttonConfig, eventBus
29
29
  // --- Button handlers
30
30
  const handleDone = () => {
31
31
  const mode = draw.getMode()
32
- const features = draw.getAll().features
33
- const feature = features?.[0]
34
32
 
35
33
  disableSnap()
36
34
  mapProvider.undoStack?.clear()
37
35
 
36
+ const features = draw.getAll().features
37
+
38
38
  if (mode === 'edit_vertex') {
39
- map.fire('draw.editfinish', { features: [feature] })
39
+ map.fire('draw.editfinish', { features: [draw.get(tempFeature.id)] })
40
40
  return
41
41
  }
42
42
 
@@ -44,7 +44,9 @@ export function attachEvents({ pluginState, mapProvider, buttonConfig, eventBus
44
44
  return
45
45
  }
46
46
 
47
+ const feature = features?.[0]
47
48
  const geom = feature.geometry
49
+
48
50
  if (geom.type === 'Polygon') {
49
51
  const ring = geom.coordinates[0]
50
52
  geom.coordinates[0] = [...ring.slice(0, -2), ring[0]]
@@ -56,6 +58,7 @@ export function attachEvents({ pluginState, mapProvider, buttonConfig, eventBus
56
58
  }
57
59
 
58
60
  const handleCancel = () => {
61
+ draw.trash()
59
62
  if (tempFeature?.id) {
60
63
  draw.delete(tempFeature.id)
61
64
  }
@@ -65,7 +68,7 @@ export function attachEvents({ pluginState, mapProvider, buttonConfig, eventBus
65
68
  disableSnap()
66
69
  draw.changeMode('disabled')
67
70
  resetDrawModeAndFeature()
68
- eventBus.emit('draw:cancel', { originalFeature: feature })
71
+ eventBus.emit('draw:cancelled', feature)
69
72
  }
70
73
 
71
74
  const handleUndo = () => {
@@ -122,14 +125,19 @@ export function attachEvents({ pluginState, mapProvider, buttonConfig, eventBus
122
125
  const newFeature = e.features[0]
123
126
  resetDrawModeAndFeature()
124
127
  setTimeout(() => draw.changeMode('disabled'), 0)
125
- eventBus.emit(eventName, { newFeature })
128
+ eventBus.emit(eventName, newFeature)
129
+ }
130
+
131
+ // --- Draw update handler
132
+ const handleUpdate = (e) => {
133
+ const tempFeature = e.features[0]
134
+ eventBus.emit('draw:updated', tempFeature)
126
135
  }
127
136
 
128
- const onCreate = handleDrawCompletion('draw:create')
129
- const onEditFinish = handleDrawCompletion('draw:edit')
137
+ const onCreate = handleDrawCompletion('draw:created')
138
+ const onEditFinish = handleDrawCompletion('draw:edited')
130
139
 
131
140
  // --- Other map events
132
- const onUpdate = (e) => eventBus.emit('draw:update', e)
133
141
  const onVertexSelection = (e) => {
134
142
  dispatch({ type: 'SET_SELECTED_VERTEX_INDEX', payload: e })
135
143
  eventBus.emit('draw:vertexselection', e)
@@ -151,7 +159,7 @@ export function attachEvents({ pluginState, mapProvider, buttonConfig, eventBus
151
159
  map.on('draw.cancel', handleCancel)
152
160
  map.on('draw.create', onCreate)
153
161
  map.on('draw.editfinish', onEditFinish)
154
- map.on('draw.update', onUpdate)
162
+ map.on('draw.update', handleUpdate)
155
163
  map.on('draw.vertexselection', onVertexSelection)
156
164
  map.on('draw.vertexchange', onVertexChange)
157
165
  map.on('draw.undochange', onUndoChange)
@@ -165,7 +173,7 @@ export function attachEvents({ pluginState, mapProvider, buttonConfig, eventBus
165
173
  map.off('draw.cancel', handleCancel)
166
174
  map.off('draw.create', onCreate)
167
175
  map.off('draw.editfinish', onEditFinish)
168
- map.off('draw.update', onUpdate)
176
+ map.off('draw.update', handleUpdate)
169
177
  map.off('draw.vertexselection', onVertexSelection)
170
178
  map.off('draw.vertexchange', onVertexChange)
171
179
  map.off('draw.undochange', onUndoChange)
@@ -0,0 +1,170 @@
1
+ // /plugins/search/Search.test.jsx
2
+ import { render, screen, fireEvent, cleanup } from '@testing-library/react'
3
+ import { Search } from './Search'
4
+ import { attachEvents } from './events/index.js'
5
+ import { createDatasets } from './datasets.js'
6
+
7
+ // Mock sub-components
8
+ jest.mock('./components/OpenButton/OpenButton', () => ({
9
+ OpenButton: ({ id, isExpanded, onClick }) => (
10
+ <button data-testid="open-button" onClick={onClick}>
11
+ OpenButton-{id}-{isExpanded ? 'expanded' : 'collapsed'}
12
+ </button>
13
+ ),
14
+ }))
15
+
16
+ jest.mock('./components/CloseButton/CloseButton', () => ({
17
+ CloseButton: ({ defaultExpanded, onClick }) => (
18
+ <button data-testid="close-button" onClick={onClick}>
19
+ CloseButton-{defaultExpanded ? 'defaultExpanded' : 'collapsed'}
20
+ </button>
21
+ ),
22
+ }))
23
+
24
+ jest.mock('./components/Form/Form', () => ({
25
+ Form: ({ children }) => <div data-testid="form">{children}</div>,
26
+ }))
27
+
28
+ // Mock external logic
29
+ jest.mock('./datasets.js', () => ({
30
+ createDatasets: jest.fn(() => ['dataset1', 'dataset2']),
31
+ }))
32
+
33
+ jest.mock('./events/index.js', () => ({
34
+ attachEvents: jest.fn(() => ({
35
+ handleOpenClick: jest.fn(),
36
+ handleCloseClick: jest.fn(),
37
+ handleOutside: jest.fn(),
38
+ })),
39
+ }))
40
+
41
+ describe('Search component', () => {
42
+ let props
43
+ let viewportRef
44
+
45
+ beforeEach(() => {
46
+ // Clear mock history before every test to prevent call count accumulation
47
+ jest.clearAllMocks()
48
+
49
+ viewportRef = { current: { style: { pointerEvents: 'auto' } } }
50
+
51
+ props = {
52
+ appConfig: { id: 'search' },
53
+ iconRegistry: { close: '<svg>close</svg>', search: '<svg>search</svg>' },
54
+ pluginState: {
55
+ dispatch: jest.fn(),
56
+ isExpanded: false,
57
+ areSuggestionsVisible: false,
58
+ suggestions: [],
59
+ },
60
+ pluginConfig: {
61
+ isExpanded: false, // This is destructured as defaultExpanded in the component
62
+ customDatasets: [],
63
+ osNamesURL: 'url',
64
+ },
65
+ appState: {
66
+ dispatch: jest.fn(),
67
+ interfaceType: 'keyboard',
68
+ layoutRefs: { viewportRef },
69
+ },
70
+ mapState: { markers: {} },
71
+ services: {},
72
+ mapProvider: { crs: 'EPSG:3857' },
73
+ }
74
+ })
75
+
76
+ afterEach(() => {
77
+ cleanup()
78
+ })
79
+
80
+ it('renders OpenButton when defaultExpanded/isExpanded is false', () => {
81
+ render(<Search {...props} />)
82
+ expect(screen.getByTestId('open-button')).toBeInTheDocument()
83
+ expect(screen.getByTestId('form')).toBeInTheDocument()
84
+ expect(screen.getByTestId('close-button')).toBeInTheDocument()
85
+ })
86
+
87
+ it('does not render OpenButton when defaultExpanded/isExpanded is true', () => {
88
+ props.pluginConfig.isExpanded = true
89
+ render(<Search {...props} />)
90
+ expect(screen.queryByTestId('open-button')).not.toBeInTheDocument()
91
+ expect(screen.getByTestId('close-button')).toBeInTheDocument()
92
+ })
93
+
94
+ it('calls attachEvents once and persists it across re-renders (useRef coverage)', () => {
95
+ const { rerender } = render(<Search {...props} />)
96
+
97
+ expect(createDatasets).toHaveBeenCalledWith({
98
+ customDatasets: [],
99
+ osNamesURL: 'url',
100
+ crs: 'EPSG:3857',
101
+ })
102
+
103
+ // Trigger a re-render with a prop change
104
+ rerender(<Search {...props} appState={{ ...props.appState, interfaceType: 'touch' }} />)
105
+
106
+ // attachEvents should still only have been called once due to the useRef check
107
+ expect(attachEvents).toHaveBeenCalledTimes(1)
108
+ })
109
+
110
+ it('OpenButton click triggers handleOpenClick', () => {
111
+ render(<Search {...props} />)
112
+ const events = attachEvents.mock.results[0].value
113
+ fireEvent.click(screen.getByTestId('open-button'))
114
+ expect(events.handleOpenClick).toHaveBeenCalledTimes(1)
115
+ })
116
+
117
+ it('CloseButton click triggers handleCloseClick', () => {
118
+ render(<Search {...props} />)
119
+ const events = attachEvents.mock.results[0].value
120
+ fireEvent.click(screen.getByTestId('close-button'))
121
+ expect(events.handleCloseClick).toHaveBeenCalledTimes(1)
122
+ })
123
+
124
+ it('focuses input when pluginState.isExpanded is true', () => {
125
+ // We have to mock the implementation because inputRef is internal
126
+ // This is a bit of a workaround for testing internal refs
127
+ props.pluginState.isExpanded = true
128
+ render(<Search {...props} />)
129
+ expect(screen.getByTestId('form')).toBeInTheDocument()
130
+ })
131
+
132
+ describe('searchOpen logic (Line 46 coverage)', () => {
133
+ it('is true when isExpanded is true', () => {
134
+ props.pluginState.isExpanded = true
135
+ render(<Search {...props} />)
136
+ // If searchOpen is true, pointerEvents becomes 'none'
137
+ expect(viewportRef.current.style.pointerEvents).toBe('none')
138
+ })
139
+
140
+ it('is true when defaultExpanded is true and suggestions exist', () => {
141
+ props.pluginConfig.isExpanded = true // defaultExpanded
142
+ props.pluginState.isExpanded = false
143
+ props.pluginState.areSuggestionsVisible = true
144
+ props.pluginState.suggestions = ['item 1']
145
+
146
+ render(<Search {...props} />)
147
+ expect(viewportRef.current.style.pointerEvents).toBe('none')
148
+ })
149
+
150
+ it('is false when defaultExpanded is true but suggestions are empty', () => {
151
+ props.pluginConfig.isExpanded = true
152
+ props.pluginState.isExpanded = false
153
+ props.pluginState.areSuggestionsVisible = true
154
+ props.pluginState.suggestions = []
155
+
156
+ render(<Search {...props} />)
157
+ // Should remain 'auto' (or not 'none')
158
+ expect(viewportRef.current.style.pointerEvents).toBe('auto')
159
+ })
160
+ })
161
+
162
+ it('cleans up effects and restores pointerEvents on unmount', () => {
163
+ props.pluginState.isExpanded = true
164
+ const { unmount } = render(<Search {...props} />)
165
+ expect(viewportRef.current.style.pointerEvents).toBe('none')
166
+
167
+ unmount()
168
+ expect(viewportRef.current.style.pointerEvents).toBe('auto')
169
+ })
170
+ })
@@ -0,0 +1,67 @@
1
+ // src/plugins/search/components/CloseButton/CloseButton.test.jsx
2
+
3
+ import { render, screen, fireEvent } from '@testing-library/react'
4
+ import { CloseButton } from './CloseButton'
5
+
6
+ describe('CloseButton', () => {
7
+ it('renders the button and calls onClick', () => {
8
+ const onClick = jest.fn()
9
+
10
+ render(
11
+ <CloseButton
12
+ defaultExpanded={false}
13
+ onClick={onClick}
14
+ closeIcon={null}
15
+ />
16
+ )
17
+
18
+ fireEvent.click(
19
+ screen.getByRole('button', { name: /close search/i })
20
+ )
21
+
22
+ expect(onClick).toHaveBeenCalledTimes(1)
23
+ })
24
+
25
+ it('applies display:none when defaultExpanded is true', () => {
26
+ render(
27
+ <CloseButton
28
+ defaultExpanded
29
+ onClick={jest.fn()}
30
+ closeIcon={null}
31
+ />
32
+ )
33
+
34
+ const button = screen.getByLabelText('Close search', {
35
+ selector: 'button',
36
+ })
37
+
38
+ expect(button).toHaveStyle({ display: 'none' })
39
+ })
40
+
41
+ it('renders the close icon SVG when closeIcon is provided', () => {
42
+ const svgContent = '<path d="M1 1L23 23" />'
43
+ const { container } = render(
44
+ <CloseButton
45
+ defaultExpanded={false}
46
+ onClick={jest.fn()}
47
+ closeIcon={svgContent}
48
+ />
49
+ )
50
+
51
+ const svg = container.querySelector('svg')
52
+ expect(svg).toBeTruthy()
53
+ expect(svg.innerHTML).toContain('M1 1L23 23')
54
+ })
55
+
56
+ it('does not render an svg when closeIcon is not provided', () => {
57
+ const { container } = render(
58
+ <CloseButton
59
+ defaultExpanded={false}
60
+ onClick={jest.fn()}
61
+ closeIcon={null}
62
+ />
63
+ )
64
+
65
+ expect(container.querySelector('svg')).toBeNull()
66
+ })
67
+ })
@@ -0,0 +1,158 @@
1
+ // src/plugins/search/Form.test.jsx
2
+
3
+ import { render, screen, fireEvent } from '@testing-library/react'
4
+ import { Form } from './Form'
5
+
6
+ // Mock Suggestions to simulate user interactions
7
+ jest.mock('../Suggestions/Suggestions', () => ({
8
+ Suggestions: ({ handleSuggestionClick, id }) => (
9
+ <button
10
+ data-testid="suggestion"
11
+ onClick={() => handleSuggestionClick('clicked-suggestion')}
12
+ id={`${id}-search-suggestions`}
13
+ >
14
+ Suggestion
15
+ </button>
16
+ ),
17
+ }))
18
+
19
+ describe('Form', () => {
20
+ const baseProps = {
21
+ id: 'test',
22
+ inputRef: { current: null },
23
+ pluginState: {
24
+ isExpanded: false,
25
+ value: '',
26
+ suggestionsVisible: false,
27
+ selectedIndex: -1,
28
+ hasKeyboardFocusWithin: false,
29
+ },
30
+ pluginConfig: {
31
+ isExpanded: false,
32
+ width: '400px',
33
+ },
34
+ appState: {
35
+ breakpoint: 'desktop',
36
+ interfaceType: 'keyboard',
37
+ },
38
+ events: {
39
+ handleSubmit: jest.fn(),
40
+ handleInputClick: jest.fn(),
41
+ handleInputChange: jest.fn(),
42
+ handleInputFocus: jest.fn(),
43
+ handleInputBlur: jest.fn(),
44
+ handleInputKeyDown: jest.fn(),
45
+ handleSuggestionClick: jest.fn(),
46
+ },
47
+ }
48
+
49
+ beforeEach(() => {
50
+ jest.clearAllMocks()
51
+ })
52
+
53
+ it('renders the form element with correct role, ID, and base classes', () => {
54
+ render(<Form {...baseProps} />)
55
+ const form = screen.getByRole('search')
56
+ expect(form).toHaveAttribute('id', 'test-search-form')
57
+ expect(form.className).toContain('im-c-search-form')
58
+ expect(form.className).toContain('im-c-panel')
59
+ })
60
+
61
+ it('applies expanded styles and width when the pluginConfig is expanded', () => {
62
+ render(
63
+ <Form
64
+ {...baseProps}
65
+ pluginConfig={{ ...baseProps.pluginConfig, isExpanded: true }}
66
+ />
67
+ )
68
+ const form = screen.getByRole('search')
69
+ expect(form).toHaveStyle({ display: 'flex', width: '400px' })
70
+ expect(form.className).toContain('im-c-search-form--default-expanded')
71
+ })
72
+
73
+ it('calls handleSubmit with the event, appState, and pluginState when form is submitted', () => {
74
+ render(<Form {...baseProps} />)
75
+ const form = screen.getByRole('search')
76
+ fireEvent.submit(form)
77
+ expect(baseProps.events.handleSubmit).toHaveBeenCalledTimes(1)
78
+ expect(baseProps.events.handleSubmit.mock.calls[0][1]).toBe(baseProps.appState)
79
+ expect(baseProps.events.handleSubmit.mock.calls[0][2]).toBe(baseProps.pluginState)
80
+ })
81
+
82
+ it('renders the search input with correct ARIA attributes when suggestions are visible and an item is selected', () => {
83
+ render(
84
+ <Form
85
+ {...baseProps}
86
+ pluginState={{
87
+ ...baseProps.pluginState,
88
+ suggestionsVisible: true,
89
+ selectedIndex: 2,
90
+ }}
91
+ />
92
+ )
93
+ const input = screen.getByRole('combobox')
94
+ expect(input).toHaveAttribute('aria-expanded', 'true')
95
+ expect(input).toHaveAttribute('aria-activedescendant', 'test-search-suggestion-2')
96
+ expect(input).toHaveAttribute('aria-controls', 'test-search-suggestions')
97
+ })
98
+
99
+ it('adds keyboard focus class when the input container has focus within', () => {
100
+ render(
101
+ <Form
102
+ {...baseProps}
103
+ pluginState={{ ...baseProps.pluginState, hasKeyboardFocusWithin: true }}
104
+ />
105
+ )
106
+ const container = screen.getByRole('search').querySelector('.im-c-search__input-container')
107
+ expect(container.className).toContain('im-c-search__input-container--keyboard-focus-within')
108
+ })
109
+
110
+ it('does not set aria-describedby when the search input has a value', () => {
111
+ render(
112
+ <Form
113
+ {...baseProps}
114
+ pluginState={{ ...baseProps.pluginState, value: 'something' }}
115
+ />
116
+ )
117
+ const input = screen.getByRole('combobox')
118
+ expect(input).not.toHaveAttribute('aria-describedby')
119
+ })
120
+
121
+ it('wires input event handlers correctly (click, change, focus, blur, keydown)', () => {
122
+ render(<Form {...baseProps} />)
123
+ const input = screen.getByRole('combobox')
124
+ fireEvent.click(input)
125
+ fireEvent.change(input, { target: { value: 'abc' } })
126
+ fireEvent.focus(input)
127
+ fireEvent.blur(input)
128
+ fireEvent.keyDown(input, { key: 'ArrowDown' })
129
+ expect(baseProps.events.handleInputClick).toHaveBeenCalled()
130
+ expect(baseProps.events.handleInputChange).toHaveBeenCalled()
131
+ expect(baseProps.events.handleInputFocus).toHaveBeenCalledWith('keyboard')
132
+ expect(baseProps.events.handleInputBlur).toHaveBeenCalledWith('keyboard')
133
+ expect(baseProps.events.handleInputKeyDown).toHaveBeenCalled()
134
+ })
135
+
136
+ it('renders children passed into the input container (e.g., CloseButton)', () => {
137
+ render(
138
+ <Form {...baseProps}>
139
+ <div data-testid="close-button" />
140
+ </Form>
141
+ )
142
+ expect(screen.getByTestId('close-button')).toBeInTheDocument()
143
+ })
144
+
145
+ it('renders the Suggestions component', () => {
146
+ render(<Form {...baseProps} />)
147
+ expect(screen.getByTestId('suggestion')).toBeInTheDocument()
148
+ })
149
+
150
+ it('calls events.handleSuggestionClick when a suggestion is clicked', () => {
151
+ render(<Form {...baseProps} />)
152
+ fireEvent.click(screen.getByTestId('suggestion'))
153
+ expect(baseProps.events.handleSuggestionClick).toHaveBeenCalledWith(
154
+ 'clicked-suggestion',
155
+ baseProps.appState
156
+ )
157
+ })
158
+ })
@@ -0,0 +1,47 @@
1
+ // src/plugins/search/OpenButton.test.jsx
2
+
3
+ import { render, screen, fireEvent } from '@testing-library/react'
4
+ import { OpenButton } from './OpenButton'
5
+
6
+ describe('OpenButton', () => {
7
+ const baseProps = {
8
+ id: 'test',
9
+ isExpanded: false,
10
+ onClick: jest.fn(),
11
+ buttonRef: { current: null },
12
+ searchIcon: null,
13
+ }
14
+
15
+ beforeEach(() => {
16
+ jest.clearAllMocks()
17
+ })
18
+
19
+ it('renders the button with correct ARIA attributes and calls onClick', () => {
20
+ render(<OpenButton {...baseProps} />)
21
+ const button = screen.getByRole('button', { name: /open search/i })
22
+ expect(button).toHaveAttribute('aria-controls', 'test-search-form')
23
+ fireEvent.click(button)
24
+ expect(baseProps.onClick).toHaveBeenCalledTimes(1)
25
+ })
26
+
27
+ it('applies display:none when isExpanded is true', () => {
28
+ render(<OpenButton {...baseProps} isExpanded />)
29
+ const button = screen.getByLabelText('Open search')
30
+ expect(button).toHaveStyle({ display: 'none' })
31
+ })
32
+
33
+ it('renders the search icon SVG when searchIcon is provided', () => {
34
+ const svgContent = '<path d="M1 1L23 23" />'
35
+ const { container } = render(
36
+ <OpenButton {...baseProps} searchIcon={svgContent} />
37
+ )
38
+ const svg = container.querySelector('svg')
39
+ expect(svg).toBeTruthy()
40
+ expect(svg.innerHTML).toContain('M1 1L23 23')
41
+ })
42
+
43
+ it('does not render an SVG when searchIcon is not provided', () => {
44
+ const { container } = render(<OpenButton {...baseProps} />)
45
+ expect(container.querySelector('svg')).toBeNull()
46
+ })
47
+ })
@@ -0,0 +1,79 @@
1
+ // src/plugins/search/Suggestions.test.jsx
2
+
3
+ import { render, screen, fireEvent } from '@testing-library/react'
4
+ import { Suggestions } from './Suggestions'
5
+
6
+ describe('Suggestions', () => {
7
+ const baseProps = {
8
+ id: 'test',
9
+ pluginState: {
10
+ areSuggestionsVisible: true,
11
+ suggestions: [
12
+ { id: '1', marked: 'First' },
13
+ { id: '2', marked: 'Second' },
14
+ ],
15
+ selectedIndex: 0,
16
+ },
17
+ handleSuggestionClick: jest.fn(),
18
+ }
19
+
20
+ beforeEach(() => {
21
+ jest.clearAllMocks()
22
+ })
23
+
24
+ it('renders the listbox with correct attributes', () => {
25
+ render(<Suggestions {...baseProps} />)
26
+ const listbox = screen.getByRole('listbox')
27
+ expect(listbox).toHaveAttribute('id', 'test-search-suggestions')
28
+ expect(listbox).toHaveAttribute('aria-labelledby', 'test-search')
29
+ expect(listbox.className).toContain('im-c-search-suggestions')
30
+ })
31
+
32
+ it('hides the listbox when suggestions are not visible', () => {
33
+ render(
34
+ <Suggestions
35
+ {...baseProps}
36
+ pluginState={{ ...baseProps.pluginState, areSuggestionsVisible: false }}
37
+ />
38
+ )
39
+ const listbox = screen.getByRole('listbox', { hidden: true })
40
+ expect(listbox).toHaveStyle({ display: 'none' })
41
+ })
42
+
43
+ it('hides the listbox when suggestions array is empty', () => {
44
+ render(
45
+ <Suggestions
46
+ {...baseProps}
47
+ pluginState={{ ...baseProps.pluginState, suggestions: [] }}
48
+ />
49
+ )
50
+ const listbox = screen.getByRole('listbox', { hidden: true })
51
+ expect(listbox).toHaveStyle({ display: 'none' })
52
+ })
53
+
54
+ it('renders all suggestion items with correct ARIA attributes', () => {
55
+ render(<Suggestions {...baseProps} />)
56
+ const items = screen.getAllByRole('option')
57
+ expect(items).toHaveLength(2)
58
+
59
+ items.forEach((item, i) => {
60
+ expect(item).toHaveAttribute('id', `test-search-suggestion-${i}`)
61
+ expect(item).toHaveClass('im-c-search-suggestions__item')
62
+ expect(item).toHaveAttribute('aria-setsize', '2')
63
+ expect(item).toHaveAttribute('aria-posinset', `${i + 1}`)
64
+ })
65
+
66
+ // First item should be selected
67
+ expect(items[0]).toHaveAttribute('aria-selected', 'true')
68
+ expect(items[1]).toHaveAttribute('aria-selected', 'false')
69
+ })
70
+
71
+ it('calls handleSuggestionClick when a suggestion is clicked', () => {
72
+ render(<Suggestions {...baseProps} />)
73
+ const items = screen.getAllByRole('option')
74
+ fireEvent.click(items[1])
75
+ expect(baseProps.handleSuggestionClick).toHaveBeenCalledWith(
76
+ baseProps.pluginState.suggestions[1]
77
+ )
78
+ })
79
+ })
@@ -0,0 +1,46 @@
1
+ // src/plugins/search/datasets.test.js
2
+
3
+ import { createDatasets } from './datasets'
4
+ import * as parseModule from './utils/parseOsNamesResults.js'
5
+
6
+ describe('createDatasets', () => {
7
+ const osNamesURL = 'https://example.com/osnames'
8
+ const crs = 'EPSG:4326'
9
+
10
+ beforeEach(() => {
11
+ jest.clearAllMocks()
12
+ })
13
+
14
+ it('returns default dataset with correct properties', () => {
15
+ const datasets = createDatasets({ osNamesURL, crs })
16
+ expect(datasets).toHaveLength(1)
17
+ const ds = datasets[0]
18
+
19
+ expect(ds.name).toBe('osNames')
20
+ expect(ds.urlTemplate).toBe(osNamesURL)
21
+ expect(ds.includeRegex).toEqual(/^[a-zA-Z0-9\s,-]+$/)
22
+ expect(ds.excludeRegex).toEqual(
23
+ /^(?:[a-z]{2}\s*(?:\d{3}\s*\d{3}|\d{4}\s*\d{4}|\d{5}\s*\d{5})|\d+\s*,?\s*\d+)$/i
24
+ )
25
+ expect(typeof ds.parseResults).toBe('function')
26
+ })
27
+
28
+ it('merge custom datasets with default dataset', () => {
29
+ const custom = [{ name: 'custom1', urlTemplate: 'https://custom.com' }]
30
+ const datasets = createDatasets({ osNamesURL, crs, customDatasets: custom })
31
+ expect(datasets).toHaveLength(2)
32
+ expect(datasets[1]).toEqual(custom[0])
33
+ })
34
+
35
+ it('parseResults calls parseOsNamesResults with correct arguments', () => {
36
+ const parseMock = jest.spyOn(parseModule, 'parseOsNamesResults').mockReturnValue('parsed')
37
+ const datasets = createDatasets({ osNamesURL, crs })
38
+ const json = { some: 'data' }
39
+ const query = 'test query'
40
+
41
+ const result = datasets[0].parseResults(json, query)
42
+ expect(parseMock).toHaveBeenCalledWith(json, query, crs)
43
+ expect(result).toBe('parsed')
44
+ parseMock.mockRestore()
45
+ })
46
+ })