@defra/interactive-map 0.0.8-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 (104) hide show
  1. package/DOCS_README.md +39 -0
  2. package/dist/esm/im-core.js +1 -0
  3. package/dist/esm/im-shell.js +1 -0
  4. package/dist/esm/index.js +1 -2
  5. package/dist/umd/im-core.js +1 -1
  6. package/dist/umd/index.js +1 -1
  7. package/docs/api/button-definition.md +104 -3
  8. package/docs/api.md +22 -2
  9. package/docs/architecture/architecture-diagrams.md +1 -3
  10. package/docs/architecture/diagrams-viewer.mdx +12 -0
  11. package/docs/getting-started.md +78 -8
  12. package/docs/govuk-prototype.md +23 -0
  13. package/docs/index.md +23 -0
  14. package/docusaurus.config.cjs +106 -0
  15. package/mise.toml +2 -0
  16. package/package.json +51 -27
  17. package/plugins/beta/datasets/dist/css/index.css +50 -1
  18. package/plugins/beta/datasets/dist/esm/im-datasets-plugin.js +1 -0
  19. package/plugins/beta/datasets/dist/esm/index.js +1 -2
  20. package/plugins/beta/draw-es/dist/esm/im-draw-es-plugin.js +1 -0
  21. package/plugins/beta/draw-es/dist/esm/index.js +1 -2
  22. package/plugins/beta/draw-es/src/api/newPolygon.js +1 -3
  23. package/plugins/beta/draw-es/src/events.js +2 -2
  24. package/plugins/beta/draw-ml/dist/esm/im-draw-ml-plugin.js +1 -0
  25. package/plugins/beta/draw-ml/dist/esm/index.js +1 -2
  26. package/plugins/beta/draw-ml/dist/umd/im-draw-ml-plugin.js +1 -1
  27. package/plugins/beta/draw-ml/src/api/newLine.js +2 -2
  28. package/plugins/beta/draw-ml/src/api/newPolygon.js +2 -2
  29. package/plugins/beta/draw-ml/src/events.js +18 -10
  30. package/plugins/beta/frame/dist/css/index.css +11 -1
  31. package/plugins/beta/frame/dist/esm/im-frame-plugin.js +1 -0
  32. package/plugins/beta/frame/dist/esm/index.js +1 -2
  33. package/plugins/beta/map-styles/dist/css/index.css +79 -1
  34. package/plugins/beta/map-styles/dist/esm/im-map-styles-plugin.js +1 -0
  35. package/plugins/beta/map-styles/dist/esm/index.js +1 -2
  36. package/plugins/beta/scale-bar/dist/esm/im-scale-bar-plugin.js +1 -0
  37. package/plugins/beta/scale-bar/dist/esm/index.js +1 -2
  38. package/plugins/beta/use-location/dist/esm/im-use-location-plugin.js +1 -0
  39. package/plugins/beta/use-location/dist/esm/index.js +1 -2
  40. package/plugins/beta/use-location/dist/umd/index.js +1 -1
  41. package/plugins/interact/dist/esm/im-interact-plugin.js +1 -0
  42. package/plugins/interact/dist/esm/index.js +1 -2
  43. package/plugins/search/dist/esm/im-search-plugin.js +1 -0
  44. package/plugins/search/dist/esm/index.js +1 -2
  45. package/plugins/search/src/Search.test.jsx +170 -0
  46. package/plugins/search/src/components/CloseButton/CloseButton.test.jsx +67 -0
  47. package/plugins/search/src/components/Form/Form.test.jsx +158 -0
  48. package/plugins/search/src/components/OpenButton/OpenButton.test.jsx +47 -0
  49. package/plugins/search/src/components/Suggestions/Suggestions.module.scss +1 -1
  50. package/plugins/search/src/components/Suggestions/Suggestions.test.jsx +79 -0
  51. package/plugins/search/src/datasets.test.js +46 -0
  52. package/plugins/search/src/events/fetchSuggestions.test.js +212 -0
  53. package/plugins/search/src/events/formHandlers.test.js +232 -0
  54. package/plugins/search/src/events/index.test.js +118 -0
  55. package/plugins/search/src/events/inputHandlers.test.js +104 -0
  56. package/plugins/search/src/events/suggestionHandlers.test.js +166 -0
  57. package/plugins/search/src/index.test.js +47 -0
  58. package/plugins/search/src/reducer.test.js +80 -0
  59. package/plugins/search/src/search.scss +1 -1
  60. package/plugins/search/src/utils/parseOsNamesResults.js +2 -1
  61. package/plugins/search/src/utils/parseOsNamesResults.test.js +140 -0
  62. package/plugins/search/src/utils/updateMap.test.js +52 -0
  63. package/providers/beta/esri/dist/css/index.css +30 -1
  64. package/providers/beta/esri/dist/esm/im-esri-provider.js +1 -0
  65. package/providers/beta/esri/dist/esm/index.js +1 -2
  66. package/providers/beta/open-names/dist/esm/im-reverse-geocode.js +1 -0
  67. package/providers/beta/open-names/dist/esm/index.js +1 -2
  68. package/providers/beta/open-names/src/utils/mapToLocationModel.test.js +61 -0
  69. package/providers/maplibre/dist/esm/im-maplibre-provider.js +1 -0
  70. package/providers/maplibre/dist/esm/index.js +1 -2
  71. package/providers/maplibre/dist/umd/im-maplibre-provider.js +1 -1
  72. package/providers/maplibre/src/appEvents.test.js +44 -0
  73. package/providers/maplibre/src/index.test.js +60 -0
  74. package/providers/maplibre/src/mapEvents.test.js +115 -0
  75. package/providers/maplibre/src/maplibreProvider.test.js +205 -0
  76. package/providers/maplibre/src/utils/calculateLinearTextSize.test.js +31 -0
  77. package/providers/maplibre/src/utils/detectWebgl.test.js +63 -0
  78. package/providers/maplibre/src/utils/highlightFeatures.test.js +126 -0
  79. package/providers/maplibre/src/utils/labels.js +1 -3
  80. package/providers/maplibre/src/utils/labels.test.js +231 -0
  81. package/providers/maplibre/src/utils/maplibreFixes.test.js +66 -0
  82. package/providers/maplibre/src/utils/queryFeatures.test.js +60 -0
  83. package/providers/maplibre/src/utils/spatial.js +5 -4
  84. package/providers/maplibre/src/utils/spatial.test.js +96 -0
  85. package/rollup.esm.mjs +288 -0
  86. package/src/App/store/appActionsMap.js +1 -1
  87. package/src/InteractiveMap/InteractiveMap.js +3 -2
  88. package/webpack.dev.mjs +9 -1
  89. package/webpack.prod.mjs +8 -1
  90. package/webpack.umd.mjs +1 -2
  91. package/dist/esm/index.js.LICENSE.txt +0 -1
  92. package/plugins/beta/datasets/dist/esm/index.js.LICENSE.txt +0 -1
  93. package/plugins/beta/draw-es/dist/esm/index.js.LICENSE.txt +0 -1
  94. package/plugins/beta/draw-ml/dist/esm/index.js.LICENSE.txt +0 -1
  95. package/plugins/beta/frame/dist/esm/index.js.LICENSE.txt +0 -1
  96. package/plugins/beta/map-styles/dist/esm/index.js.LICENSE.txt +0 -1
  97. package/plugins/beta/scale-bar/dist/esm/index.js.LICENSE.txt +0 -1
  98. package/plugins/beta/use-location/dist/esm/index.js.LICENSE.txt +0 -1
  99. package/plugins/interact/dist/esm/index.js.LICENSE.txt +0 -1
  100. package/plugins/search/dist/esm/index.js.LICENSE.txt +0 -1
  101. package/providers/beta/esri/dist/esm/index.js.LICENSE.txt +0 -1
  102. package/providers/beta/open-names/dist/esm/index.js.LICENSE.txt +0 -1
  103. package/providers/maplibre/dist/esm/index.js.LICENSE.txt +0 -6
  104. package/webpack.esm.mjs +0 -154
@@ -0,0 +1,118 @@
1
+ /**
2
+ * @jest-environment jsdom
3
+ */
4
+ import { attachEvents } from './index.js'
5
+ import { fetchSuggestions } from './fetchSuggestions.js'
6
+ import { createFormHandlers } from './formHandlers.js'
7
+ import { createInputHandlers } from './inputHandlers.js'
8
+ import { createSuggestionHandlers } from './suggestionHandlers.js'
9
+ import { debounce } from '../../../../src/utils/debounce.js'
10
+
11
+ jest.mock('./fetchSuggestions.js')
12
+ jest.mock('./formHandlers.js')
13
+ jest.mock('./inputHandlers.js')
14
+ jest.mock('./suggestionHandlers.js')
15
+ jest.mock('../../../../src/utils/debounce.js')
16
+
17
+ describe('attachEvents', () => {
18
+ let dispatch
19
+ let services
20
+ let searchContainerRef
21
+ let args
22
+
23
+ beforeEach(() => {
24
+ dispatch = jest.fn()
25
+
26
+ services = {
27
+ eventBus: { emit: jest.fn() }
28
+ }
29
+
30
+ searchContainerRef = {
31
+ current: {
32
+ contains: jest.fn()
33
+ }
34
+ }
35
+
36
+ // Mock debounce to return the function immediately
37
+ debounce.mockImplementation(fn => fn)
38
+
39
+ createFormHandlers.mockReturnValue({ formHandler: jest.fn() })
40
+ createInputHandlers.mockReturnValue({ inputHandler: jest.fn() })
41
+ createSuggestionHandlers.mockReturnValue({ suggestionHandler: jest.fn() })
42
+
43
+ args = {
44
+ dispatch,
45
+ services,
46
+ searchContainerRef,
47
+ datasets: [],
48
+ transformRequest: jest.fn()
49
+ }
50
+
51
+ jest.clearAllMocks()
52
+ })
53
+
54
+ test('composes handlers from all handler factories', () => {
55
+ const handlers = attachEvents(args)
56
+
57
+ expect(createFormHandlers).toHaveBeenCalledWith(args)
58
+ expect(createSuggestionHandlers).toHaveBeenCalledWith(args)
59
+
60
+ expect(createInputHandlers).toHaveBeenCalledWith(
61
+ expect.objectContaining({
62
+ debouncedFetchSuggestions: expect.any(Function)
63
+ })
64
+ )
65
+
66
+ expect(handlers.formHandler).toBeDefined()
67
+ expect(handlers.inputHandler).toBeDefined()
68
+ expect(handlers.suggestionHandler).toBeDefined()
69
+ })
70
+
71
+ test('debouncedFetchSuggestions calls fetchSuggestions with correct args', () => {
72
+ const handlers = attachEvents(args)
73
+
74
+ // grab the debounced function passed to input handlers
75
+ const { debouncedFetchSuggestions } =
76
+ createInputHandlers.mock.calls[0][0]
77
+
78
+ debouncedFetchSuggestions('query')
79
+
80
+ expect(fetchSuggestions).toHaveBeenCalledWith(
81
+ 'query',
82
+ args.datasets,
83
+ dispatch,
84
+ args.transformRequest
85
+ )
86
+
87
+ expect(debounce).toHaveBeenCalledWith(
88
+ expect.any(Function),
89
+ 350
90
+ )
91
+ })
92
+
93
+ test('handleOutside does nothing when click is inside container', () => {
94
+ searchContainerRef.current.contains.mockReturnValue(true)
95
+
96
+ const handlers = attachEvents(args)
97
+
98
+ handlers.handleOutside({ target: 'inside' })
99
+
100
+ expect(dispatch).not.toHaveBeenCalled()
101
+ expect(services.eventBus.emit).not.toHaveBeenCalled()
102
+ })
103
+
104
+ test('handleOutside collapses search when click is outside container', () => {
105
+ searchContainerRef.current.contains.mockReturnValue(false)
106
+
107
+ const handlers = attachEvents(args)
108
+
109
+ handlers.handleOutside({ target: 'outside' })
110
+
111
+ expect(dispatch).toHaveBeenCalledWith({
112
+ type: 'TOGGLE_EXPANDED',
113
+ payload: false
114
+ })
115
+
116
+ expect(services.eventBus.emit).toHaveBeenCalledWith('search:close')
117
+ })
118
+ })
@@ -0,0 +1,104 @@
1
+ /**
2
+ * @jest-environment jsdom
3
+ */
4
+ import { createInputHandlers } from './inputHandlers.js'
5
+ import { DEFAULTS } from '../defaults.js'
6
+
7
+ describe('createInputHandlers', () => {
8
+ let dispatch
9
+ let debouncedFetchSuggestions
10
+ let handlers
11
+
12
+ beforeEach(() => {
13
+ dispatch = jest.fn()
14
+
15
+ debouncedFetchSuggestions = jest.fn()
16
+ debouncedFetchSuggestions.cancel = jest.fn()
17
+
18
+ handlers = createInputHandlers({
19
+ dispatch,
20
+ debouncedFetchSuggestions
21
+ })
22
+ })
23
+
24
+ test('handleInputClick shows suggestions', () => {
25
+ handlers.handleInputClick()
26
+
27
+ expect(dispatch).toHaveBeenCalledWith({
28
+ type: 'SHOW_SUGGESTIONS'
29
+ })
30
+ })
31
+
32
+ test('handleInputFocus sets keyboard focus when interface is keyboard', () => {
33
+ handlers.handleInputFocus('keyboard')
34
+
35
+ expect(dispatch).toHaveBeenCalledWith({
36
+ type: 'SET_KEYBOARD_FOCUS_WITHIN',
37
+ payload: true
38
+ })
39
+ })
40
+
41
+ test('handleInputFocus clears keyboard focus when interface is not keyboard', () => {
42
+ handlers.handleInputFocus('mouse')
43
+
44
+ expect(dispatch).toHaveBeenCalledWith({
45
+ type: 'SET_KEYBOARD_FOCUS_WITHIN',
46
+ payload: false
47
+ })
48
+ })
49
+
50
+ test('handleInputBlur dispatches blur event', () => {
51
+ handlers.handleInputBlur('keyboard')
52
+
53
+ expect(dispatch).toHaveBeenCalledWith({
54
+ type: 'INPUT_BLUR',
55
+ payload: 'keyboard'
56
+ })
57
+ })
58
+
59
+ test('handleInputChange below min length cancels debounce and hides suggestions', () => {
60
+ const value = 'a'.repeat(DEFAULTS.minSearchLength - 1)
61
+
62
+ handlers.handleInputChange({
63
+ target: { value }
64
+ })
65
+
66
+ expect(dispatch).toHaveBeenCalledWith({
67
+ type: 'SET_VALUE',
68
+ payload: value
69
+ })
70
+
71
+ expect(debouncedFetchSuggestions.cancel).toHaveBeenCalled()
72
+
73
+ expect(dispatch).toHaveBeenCalledWith({
74
+ type: 'UPDATE_SUGGESTIONS',
75
+ payload: []
76
+ })
77
+
78
+ expect(dispatch).toHaveBeenCalledWith({
79
+ type: 'HIDE_SUGGESTIONS'
80
+ })
81
+
82
+ expect(debouncedFetchSuggestions).not.toHaveBeenCalled()
83
+ })
84
+
85
+ test('handleInputChange at or above min length shows suggestions and fetches', () => {
86
+ const value = 'a'.repeat(DEFAULTS.minSearchLength)
87
+
88
+ handlers.handleInputChange({
89
+ target: { value }
90
+ })
91
+
92
+ expect(dispatch).toHaveBeenCalledWith({
93
+ type: 'SET_VALUE',
94
+ payload: value
95
+ })
96
+
97
+ expect(dispatch).toHaveBeenCalledWith({
98
+ type: 'SHOW_SUGGESTIONS'
99
+ })
100
+
101
+ expect(debouncedFetchSuggestions).toHaveBeenCalledWith(value)
102
+ expect(debouncedFetchSuggestions.cancel).not.toHaveBeenCalled()
103
+ })
104
+ })
@@ -0,0 +1,166 @@
1
+ /**
2
+ * @jest-environment jsdom
3
+ */
4
+ import { createSuggestionHandlers } from './suggestionHandlers.js'
5
+ import { updateMap } from '../utils/updateMap.js'
6
+
7
+ jest.mock('../utils/updateMap.js')
8
+
9
+ describe('createSuggestionHandlers', () => {
10
+ let dispatch
11
+ let services
12
+ let handlers
13
+
14
+ beforeEach(() => {
15
+ dispatch = jest.fn()
16
+
17
+ services = {
18
+ eventBus: { emit: jest.fn() },
19
+ announce: jest.fn()
20
+ }
21
+
22
+ handlers = createSuggestionHandlers({
23
+ dispatch,
24
+ services,
25
+ mapProvider: 'map',
26
+ markers: 'markers',
27
+ showMarker: true,
28
+ markerColor: 'blue'
29
+ })
30
+
31
+ jest.clearAllMocks()
32
+ })
33
+
34
+ // ---------- Suggestion click ----------
35
+
36
+ test('handleSuggestionClick (desktop)', () => {
37
+ const suggestion = { text: 'Paris', bounds: 'b', point: 'p' }
38
+
39
+ handlers.handleSuggestionClick(suggestion, { breakpoint: 'desktop' })
40
+
41
+ expect(dispatch).toHaveBeenCalledWith({ type: 'SET_VALUE', payload: 'Paris' })
42
+ expect(dispatch).toHaveBeenCalledWith({ type: 'HIDE_SUGGESTIONS' })
43
+ expect(dispatch).toHaveBeenCalledWith({ type: 'SET_SELECTED', payload: -1 })
44
+ expect(updateMap).toHaveBeenCalledWith(expect.objectContaining({ bounds: 'b', point: 'p' }))
45
+ expect(services.eventBus.emit).toHaveBeenCalledWith(
46
+ 'search:match',
47
+ expect.objectContaining({ query: 'Paris' })
48
+ )
49
+ })
50
+
51
+ test('handleSuggestionClick (mobile closes search)', () => {
52
+ const suggestion = { text: 'Berlin', bounds: 'b', point: 'p' }
53
+
54
+ handlers.handleSuggestionClick(suggestion, { breakpoint: 'mobile' })
55
+
56
+ expect(dispatch).toHaveBeenCalledWith({ type: 'TOGGLE_EXPANDED', payload: false })
57
+ expect(services.eventBus.emit).toHaveBeenCalledWith('search:close')
58
+ })
59
+
60
+ // ---------- ArrowDown ----------
61
+
62
+ test('ArrowDown selects next suggestion', () => {
63
+ const e = { key: 'ArrowDown', preventDefault: jest.fn() }
64
+
65
+ handlers.handleInputKeyDown(e, {
66
+ suggestions: [{ text: 'A' }, { text: 'B' }],
67
+ selectedIndex: 0
68
+ })
69
+
70
+ expect(e.preventDefault).toHaveBeenCalled()
71
+ expect(services.announce).toHaveBeenCalledWith('B. 2 of 2 is highlighted')
72
+ expect(dispatch).toHaveBeenCalledWith({ type: 'SET_SELECTED', payload: 1 })
73
+ expect(dispatch).toHaveBeenCalledWith({ type: 'SET_KEYBOARD_FOCUS_WITHIN', payload: false })
74
+ })
75
+
76
+ test('ArrowDown does nothing when no suggestions', () => {
77
+ const e = { key: 'ArrowDown', preventDefault: jest.fn() }
78
+
79
+ handlers.handleInputKeyDown(e, { suggestions: [], selectedIndex: 0 })
80
+
81
+ expect(e.preventDefault).not.toHaveBeenCalled()
82
+ expect(dispatch).not.toHaveBeenCalled()
83
+ expect(services.announce).not.toHaveBeenCalled()
84
+ })
85
+
86
+ test('ArrowDown does nothing when at last suggestion', () => {
87
+ const e = { key: 'ArrowDown', preventDefault: jest.fn() }
88
+
89
+ handlers.handleInputKeyDown(e, {
90
+ suggestions: [{ text: 'A' }, { text: 'B' }],
91
+ selectedIndex: 1 // last index
92
+ })
93
+
94
+ expect(e.preventDefault).toHaveBeenCalled()
95
+ expect(dispatch).not.toHaveBeenCalled()
96
+ expect(services.announce).not.toHaveBeenCalled()
97
+ })
98
+
99
+ // ---------- ArrowUp ----------
100
+
101
+ test('ArrowUp moves selection up and resets to -1', () => {
102
+ const e = { key: 'ArrowUp', preventDefault: jest.fn() }
103
+
104
+ handlers.handleInputKeyDown(e, {
105
+ suggestions: [{ text: 'A' }, { text: 'B' }],
106
+ selectedIndex: 0
107
+ })
108
+
109
+ expect(e.preventDefault).toHaveBeenCalled()
110
+ expect(services.announce).toHaveBeenCalledWith('2 suggestions available')
111
+ expect(dispatch).toHaveBeenCalledWith({ type: 'SET_SELECTED', payload: -1 })
112
+ expect(dispatch).toHaveBeenCalledWith({ type: 'SET_KEYBOARD_FOCUS_WITHIN', payload: true })
113
+ })
114
+
115
+ test('ArrowUp moves selection up normally when selectedIndex > 0', () => {
116
+ const e = { key: 'ArrowUp', preventDefault: jest.fn() }
117
+
118
+ handlers.handleInputKeyDown(e, {
119
+ suggestions: [{ text: 'A' }, { text: 'B' }, { text: 'C' }],
120
+ selectedIndex: 2
121
+ })
122
+
123
+ expect(e.preventDefault).toHaveBeenCalled()
124
+ expect(services.announce).toHaveBeenCalledWith('B. 2 of 3 is highlighted')
125
+ expect(dispatch).toHaveBeenCalledWith({ type: 'SET_SELECTED', payload: 1 })
126
+ expect(dispatch).toHaveBeenCalledWith({ type: 'SET_KEYBOARD_FOCUS_WITHIN', payload: false })
127
+ })
128
+
129
+ test('ArrowUp does nothing when no suggestions', () => {
130
+ const e = { key: 'ArrowUp', preventDefault: jest.fn() }
131
+
132
+ handlers.handleInputKeyDown(e, { suggestions: [], selectedIndex: 0 })
133
+
134
+ expect(e.preventDefault).not.toHaveBeenCalled()
135
+ expect(dispatch).not.toHaveBeenCalled()
136
+ expect(services.announce).not.toHaveBeenCalled()
137
+ })
138
+
139
+ // ---------- Escape & default ----------
140
+
141
+ test('Escape hides suggestions and clears selection', () => {
142
+ const e = { key: 'Escape', preventDefault: jest.fn() }
143
+
144
+ handlers.handleInputKeyDown(e, {
145
+ suggestions: [{ text: 'A' }],
146
+ selectedIndex: 0
147
+ })
148
+
149
+ expect(e.preventDefault).toHaveBeenCalled()
150
+ expect(dispatch).toHaveBeenCalledWith({ type: 'HIDE_SUGGESTIONS' })
151
+ expect(dispatch).toHaveBeenCalledWith({ type: 'SET_SELECTED', payload: -1 })
152
+ })
153
+
154
+ test('Other keys do nothing', () => {
155
+ const e = { key: 'Enter', preventDefault: jest.fn() }
156
+
157
+ handlers.handleInputKeyDown(e, {
158
+ suggestions: [{ text: 'A' }],
159
+ selectedIndex: 0
160
+ })
161
+
162
+ expect(e.preventDefault).not.toHaveBeenCalled()
163
+ expect(dispatch).not.toHaveBeenCalled()
164
+ expect(services.announce).not.toHaveBeenCalled()
165
+ })
166
+ })
@@ -0,0 +1,47 @@
1
+ // /plugins/search/index.test.js
2
+
3
+ // Mock SCSS import so Jest can run without parsing errors
4
+ jest.mock('./search.scss', () => {})
5
+
6
+ import createPlugin from './index'
7
+
8
+ describe('createPlugin', () => {
9
+ beforeEach(() => {
10
+ jest.resetModules()
11
+ })
12
+
13
+ it('returns default plugin structure with showMarker and id', async () => {
14
+ const plugin = createPlugin()
15
+ expect(plugin.showMarker).toBe(true)
16
+ expect(plugin.id).toBe('search')
17
+ expect(typeof plugin.load).toBe('function')
18
+ })
19
+
20
+ it('overrides manifest when isExpanded is true', () => {
21
+ const plugin = createPlugin({ isExpanded: true })
22
+ expect(plugin.isExpanded).toBe(true)
23
+ expect(plugin.manifest).toEqual({
24
+ controls: [{ id: 'search', mobile: { slot: 'banner' } }]
25
+ })
26
+ })
27
+
28
+ it('spreads custom options correctly', () => {
29
+ const custom = { foo: 'bar', isExpanded: false }
30
+ const plugin = createPlugin(custom)
31
+ expect(plugin.foo).toBe('bar')
32
+ expect(plugin.showMarker).toBe(true)
33
+ expect(plugin.id).toBe('search')
34
+ })
35
+
36
+ it('load function dynamically imports the manifest and returns it', async () => {
37
+ // Mock the dynamic import for './manifest.js'
38
+ const manifestMock = { data: 'test-manifest' }
39
+ jest.mock('./manifest.js', () => ({
40
+ manifest: manifestMock
41
+ }), { virtual: true })
42
+
43
+ const plugin = createPlugin()
44
+ const result = await plugin.load()
45
+ expect(result).toEqual(manifestMock)
46
+ })
47
+ })
@@ -0,0 +1,80 @@
1
+ // /plugins/search/reducer.test.js
2
+
3
+ import { initialState, actions } from './reducer'
4
+
5
+ describe('search state actions', () => {
6
+ it('TOGGLE_EXPANDED sets isExpanded and areSuggestionsVisible', () => {
7
+ const state = { ...initialState }
8
+ const newState = actions.TOGGLE_EXPANDED(state, true)
9
+ expect(newState.isExpanded).toBe(true)
10
+ expect(newState.areSuggestionsVisible).toBe(true)
11
+
12
+ const collapsed = actions.TOGGLE_EXPANDED(state, false)
13
+ expect(collapsed.isExpanded).toBe(false)
14
+ expect(collapsed.areSuggestionsVisible).toBe(false)
15
+ })
16
+
17
+ it('SET_KEYBOARD_FOCUS_WITHIN sets focus and shows suggestions', () => {
18
+ const state = { ...initialState }
19
+ const newState = actions.SET_KEYBOARD_FOCUS_WITHIN(state, true)
20
+ expect(newState.hasKeyboardFocusWithin).toBe(true)
21
+ expect(newState.areSuggestionsVisible).toBe(true)
22
+
23
+ const removedFocus = actions.SET_KEYBOARD_FOCUS_WITHIN(state, false)
24
+ expect(removedFocus.hasKeyboardFocusWithin).toBe(false)
25
+ expect(removedFocus.areSuggestionsVisible).toBe(true) // always true
26
+ })
27
+
28
+ it('INPUT_BLUR removes focus and updates areSuggestionsVisible correctly', () => {
29
+ const state = { ...initialState, areSuggestionsVisible: true, hasKeyboardFocusWithin: true }
30
+
31
+ // Non-keyboard blur
32
+ const newStateMouse = actions.INPUT_BLUR(state, 'mouse')
33
+ expect(newStateMouse.hasKeyboardFocusWithin).toBe(false)
34
+ expect(newStateMouse.areSuggestionsVisible).toBe(true) // still true
35
+ expect(newStateMouse.selectedIndex).toBe(-1)
36
+
37
+ // Keyboard blur hides suggestions
38
+ const newStateKeyboard = actions.INPUT_BLUR(state, 'keyboard')
39
+ expect(newStateKeyboard.hasKeyboardFocusWithin).toBe(false)
40
+ expect(newStateKeyboard.areSuggestionsVisible).toBe(false) // now false
41
+ expect(newStateKeyboard.selectedIndex).toBe(-1)
42
+ })
43
+
44
+ it('SET_VALUE updates the input value', () => {
45
+ const state = { ...initialState }
46
+ const newState = actions.SET_VALUE(state, 'test')
47
+ expect(newState.value).toBe('test')
48
+ })
49
+
50
+ it('UPDATE_SUGGESTIONS updates the suggestions array', () => {
51
+ const state = { ...initialState }
52
+ const suggestions = [{ id: 1 }, { id: 2 }]
53
+ const newState = actions.UPDATE_SUGGESTIONS(state, suggestions)
54
+ expect(newState.suggestions).toEqual(suggestions)
55
+ })
56
+
57
+ it('SHOW_SUGGESTIONS sets areSuggestionsVisible to true', () => {
58
+ const state = { ...initialState, areSuggestionsVisible: false }
59
+ const newState = actions.SHOW_SUGGESTIONS(state)
60
+ expect(newState.areSuggestionsVisible).toBe(true)
61
+ })
62
+
63
+ it('HIDE_SUGGESTIONS sets areSuggestionsVisible to false', () => {
64
+ const state = { ...initialState, areSuggestionsVisible: true }
65
+ const newState = actions.HIDE_SUGGESTIONS(state)
66
+ expect(newState.areSuggestionsVisible).toBe(false)
67
+ })
68
+
69
+ it('SET_SELECTED updates selectedIndex and visibility', () => {
70
+ const state = { ...initialState, areSuggestionsVisible: false }
71
+
72
+ const selected = actions.SET_SELECTED(state, 1)
73
+ expect(selected.selectedIndex).toBe(1)
74
+ expect(selected.areSuggestionsVisible).toBe(true)
75
+
76
+ const deselected = actions.SET_SELECTED(state, -1)
77
+ expect(deselected.selectedIndex).toBe(-1)
78
+ expect(deselected.areSuggestionsVisible).toBe(false)
79
+ })
80
+ })
@@ -1,4 +1,4 @@
1
- @use '/src/scss/tools/index' as tools;
1
+ @use '../../../src/scss/tools/index' as tools;
2
2
 
3
3
 
4
4
  // Components, using 'CSS in Component' pattern
@@ -105,5 +105,6 @@ const parseOsNamesResults = (json, query, crs) => {
105
105
  }
106
106
 
107
107
  export {
108
- parseOsNamesResults
108
+ parseOsNamesResults,
109
+ point
109
110
  }
@@ -0,0 +1,140 @@
1
+ /**
2
+ * @jest-environment jsdom
3
+ */
4
+ import { parseOsNamesResults, point } from './parseOsNamesResults.js'
5
+ import OsGridRef from 'geodesy/osgridref.js'
6
+
7
+ // Mock OsGridRef so we can control toLatLon outputs
8
+ jest.mock('geodesy/osgridref.js', () => {
9
+ return jest.fn().mockImplementation((x, y) => ({
10
+ x,
11
+ y,
12
+ toLatLon: () => ({ lat: y / 1e5, lon: x / 1e5 }) // deterministic lat/lon
13
+ }))
14
+ })
15
+
16
+ describe('osNamesUtils', () => {
17
+ const sampleEntry = {
18
+ GAZETTEER_ENTRY: {
19
+ ID: 1,
20
+ NAME1: 'London',
21
+ COUNTY_UNITARY: 'Greater London',
22
+ DISTRICT_BOROUGH: 'Camden',
23
+ POSTCODE_DISTRICT: 'WC1',
24
+ LOCAL_TYPE: 'Town',
25
+ MBR_XMIN: 1000,
26
+ MBR_YMIN: 2000,
27
+ MBR_XMAX: 3000,
28
+ MBR_YMAX: 4000,
29
+ GEOMETRY_X: 1500,
30
+ GEOMETRY_Y: 2500
31
+ }
32
+ }
33
+
34
+ test('returns empty array for null/invalid/error results', () => {
35
+ expect(parseOsNamesResults(null, 'x', 'EPSG:27700')).toEqual([])
36
+ expect(parseOsNamesResults({ error: true }, 'x', 'EPSG:27700')).toEqual([])
37
+ expect(parseOsNamesResults({ header: { totalresults: 0 } }, 'x', 'EPSG:27700')).toEqual([])
38
+ })
39
+
40
+ test('removes tenuous results when query does not match', () => {
41
+ const results = [
42
+ { GAZETTEER_ENTRY: { ...sampleEntry.GAZETTEER_ENTRY, NAME1: 'Bristol', ID: 2 } }
43
+ ]
44
+ const json = { results }
45
+ const output = parseOsNamesResults(json, 'London', 'EPSG:27700')
46
+ expect(output).toHaveLength(0)
47
+ })
48
+
49
+ test('removes duplicate IDs', () => {
50
+ const dup = { ...sampleEntry, GAZETTEER_ENTRY: { ...sampleEntry.GAZETTEER_ENTRY } }
51
+ const json = { results: [sampleEntry, dup] }
52
+ const output = parseOsNamesResults(json, 'London', 'EPSG:27700')
53
+ expect(output).toHaveLength(1)
54
+ })
55
+
56
+ test('limits results to MAX_RESULTS', () => {
57
+ const manyResults = Array.from({ length: 10 }, (_, i) => ({
58
+ GAZETTEER_ENTRY: { ...sampleEntry.GAZETTEER_ENTRY, ID: i }
59
+ }))
60
+ const json = { results: manyResults }
61
+ const output = parseOsNamesResults(json, 'London', 'EPSG:27700')
62
+ expect(output).toHaveLength(8)
63
+ })
64
+
65
+ test('bounds returns raw OSGB values for EPSG:27700', () => {
66
+ const json = { results: [sampleEntry] }
67
+ const output = parseOsNamesResults(json, 'London', 'EPSG:27700')
68
+ expect(output[0].bounds).toEqual([
69
+ sampleEntry.GAZETTEER_ENTRY.MBR_XMIN,
70
+ sampleEntry.GAZETTEER_ENTRY.MBR_YMIN,
71
+ sampleEntry.GAZETTEER_ENTRY.MBR_XMAX,
72
+ sampleEntry.GAZETTEER_ENTRY.MBR_YMAX
73
+ ])
74
+ expect(output[0].point).toEqual([
75
+ sampleEntry.GAZETTEER_ENTRY.GEOMETRY_X,
76
+ sampleEntry.GAZETTEER_ENTRY.GEOMETRY_Y
77
+ ])
78
+ })
79
+
80
+ test('bounds converts to WGS84 for EPSG:4326', () => {
81
+ const json = { results: [sampleEntry] }
82
+ const output = parseOsNamesResults(json, 'London', 'EPSG:4326')
83
+ const expectedBounds = [
84
+ Math.round(1000 / 1e5 * 1e6) / 1e6,
85
+ Math.round(2000 / 1e5 * 1e6) / 1e6,
86
+ Math.round(3000 / 1e5 * 1e6) / 1e6,
87
+ Math.round(4000 / 1e5 * 1e6) / 1e6
88
+ ]
89
+ expect(output[0].bounds).toEqual(expectedBounds)
90
+ const expectedPoint = [
91
+ Math.round(1500 / 1e5 * 1e6) / 1e6,
92
+ Math.round(2500 / 1e5 * 1e6) / 1e6
93
+ ]
94
+ expect(output[0].point).toEqual(expectedPoint)
95
+ })
96
+
97
+ test('label generates marked text', () => {
98
+ const json = { results: [sampleEntry] }
99
+ const output = parseOsNamesResults(json, 'London', 'EPSG:27700')
100
+ expect(output[0].text).toContain('London')
101
+ expect(output[0].marked).toContain('<mark>')
102
+ })
103
+
104
+ test('handles MBR_XMIN null by using buffered GEOMETRY_X/Y', () => {
105
+ const entry = {
106
+ GAZETTEER_ENTRY: { ...sampleEntry.GAZETTEER_ENTRY, MBR_XMIN: null, MBR_YMIN: null }
107
+ }
108
+ const json = { results: [entry] }
109
+ const output = parseOsNamesResults(json, 'London', 'EPSG:27700')
110
+ expect(output[0].bounds).toEqual([
111
+ entry.GAZETTEER_ENTRY.GEOMETRY_X - 500,
112
+ entry.GAZETTEER_ENTRY.GEOMETRY_Y - 500,
113
+ entry.GAZETTEER_ENTRY.GEOMETRY_X + 500,
114
+ entry.GAZETTEER_ENTRY.GEOMETRY_Y + 500
115
+ ])
116
+ })
117
+
118
+ test('label falls back to DISTRICT_BOROUGH when COUNTY_UNITARY is absent', () => {
119
+ const entry = { GAZETTEER_ENTRY: { ...sampleEntry.GAZETTEER_ENTRY, COUNTY_UNITARY: null } }
120
+ const output = parseOsNamesResults({ results: [entry] }, 'London', 'EPSG:27700')
121
+ expect(output[0].text).toContain(sampleEntry.GAZETTEER_ENTRY.DISTRICT_BOROUGH)
122
+ })
123
+
124
+ test('label omits qualifier for City type', () => {
125
+ const entry = { GAZETTEER_ENTRY: { ...sampleEntry.GAZETTEER_ENTRY, LOCAL_TYPE: 'City' } }
126
+ const output = parseOsNamesResults({ results: [entry] }, 'London', 'EPSG:27700')
127
+ expect(output[0].text).toBe('London')
128
+ })
129
+
130
+ test('throws error for unsupported CRS', () => {
131
+ const entry = { GAZETTEER_ENTRY: { ...sampleEntry.GAZETTEER_ENTRY } }
132
+ const json = { results: [entry] }
133
+ expect(() => parseOsNamesResults(json, 'London', 'EPSG:9999')).toThrow('Unsupported CRS')
134
+ })
135
+
136
+ test('point function throws error for unsupported CRS', () => {
137
+ const coords = { GEOMETRY_X: 1500, GEOMETRY_Y: 2500 }
138
+ expect(() => point('EPSG:9999', coords)).toThrow('Unsupported CRS: EPSG:9999')
139
+ })
140
+ })