From e36752b7c5d096fe8ffa7feb28ab5c5098f09ea3 Mon Sep 17 00:00:00 2001 From: Dan Leech Date: Fri, 13 Feb 2026 09:33:07 +0000 Subject: [PATCH 1/2] Draw mode amends --- demo/js/index.js | 6 +-- plugins/beta/draw-ml/src/api/newLine.js | 5 +++ plugins/beta/draw-ml/src/api/newPolygon.js | 5 +++ plugins/beta/draw-ml/src/defaults.js | 3 +- plugins/beta/draw-ml/src/events.js | 42 ++++++++++++------- plugins/beta/draw-ml/src/manifest.js | 28 ++++++++----- plugins/beta/draw-ml/src/reducer.js | 13 +++++- .../components/Actions/Actions.module.scss | 2 +- 8 files changed, 71 insertions(+), 33 deletions(-) diff --git a/demo/js/index.js b/demo/js/index.js index 417cc4d0..6aed3c5f 100755 --- a/demo/js/index.js +++ b/demo/js/index.js @@ -161,9 +161,9 @@ interactiveMap.on('map:ready', function (e) { // framePlugin.addFrame('test', { // aspectRatio: 1 // }) - interactPlugin.enable({ - debug: true - }) + // interactPlugin.enable({ + // debug: true + // }) }) interactiveMap.on('datasets:ready', function () { diff --git a/plugins/beta/draw-ml/src/api/newLine.js b/plugins/beta/draw-ml/src/api/newLine.js index f7ec04e3..679bd36f 100644 --- a/plugins/beta/draw-ml/src/api/newLine.js +++ b/plugins/beta/draw-ml/src/api/newLine.js @@ -1,5 +1,6 @@ import { getSnapInstance } from '../utils/snapHelpers.js' import { flattenStyleProperties } from '../utils/flattenStyleProperties.js' +import { DEFAULTS } from '../defaults.js' /** * Programmatically create a new line @@ -43,6 +44,10 @@ export const newLine = ({ appState, appConfig, pluginConfig, pluginState, mapPro // Update state so UI can react to snap layer availability dispatch({ type: 'SET_HAS_SNAP_LAYERS', payload: snapLayers?.length > 0 }) + // Resolve editAfterCreate from per-call options or pluginConfig (default: true) + const editAfterCreate = options.editAfterCreate ?? pluginConfig.editAfterCreate ?? DEFAULTS.editAfterCreate + dispatch({ type: 'SET_EDIT_AFTER_CREATE', payload: editAfterCreate }) + // Extract style props and flatten variants into properties const { stroke, fill, strokeWidth, properties: customProperties, ...modeOptions } = options const properties = { diff --git a/plugins/beta/draw-ml/src/api/newPolygon.js b/plugins/beta/draw-ml/src/api/newPolygon.js index fc47de78..175a22c6 100644 --- a/plugins/beta/draw-ml/src/api/newPolygon.js +++ b/plugins/beta/draw-ml/src/api/newPolygon.js @@ -1,5 +1,6 @@ import { getSnapInstance } from '../utils/snapHelpers.js' import { flattenStyleProperties } from '../utils/flattenStyleProperties.js' +import { DEFAULTS } from '../defaults.js' /** * Programmatically create a new polygon @@ -43,6 +44,10 @@ export const newPolygon = ({ appState, appConfig, pluginConfig, pluginState, map // Update state so UI can react to snap layer availability dispatch({ type: 'SET_HAS_SNAP_LAYERS', payload: snapLayers?.length > 0 }) + // Resolve editAfterCreate from per-call options or pluginConfig (default: true) + const editAfterCreate = options.editAfterCreate ?? pluginConfig.editAfterCreate ?? DEFAULTS.editAfterCreate + dispatch({ type: 'SET_EDIT_AFTER_CREATE', payload: editAfterCreate }) + // Extract style props and flatten variants into properties const { stroke, fill, strokeWidth, properties: customProperties, ...modeOptions } = options const properties = { diff --git a/plugins/beta/draw-ml/src/defaults.js b/plugins/beta/draw-ml/src/defaults.js index cac5db78..043a9c76 100644 --- a/plugins/beta/draw-ml/src/defaults.js +++ b/plugins/beta/draw-ml/src/defaults.js @@ -13,5 +13,6 @@ export const DEFAULTS = { midpoint: 'rgba(40,161,151,1)', edge: 'rgba(29,112,184,1)' }, - snapRadius: 10 + snapRadius: 10, + editAfterCreate: false } \ No newline at end of file diff --git a/plugins/beta/draw-ml/src/events.js b/plugins/beta/draw-ml/src/events.js index f1b78eda..adc4faa7 100755 --- a/plugins/beta/draw-ml/src/events.js +++ b/plugins/beta/draw-ml/src/events.js @@ -168,21 +168,33 @@ export function attachEvents ({ appState, appConfig, mapState, pluginState, mapP // Clear draw mode undo stack - editing starts fresh mapProvider.undoStack?.clear() - // Switch straight to edit vertex mode - dispatch({ type: 'SET_MODE', payload: 'edit_vertex'}) - - setTimeout(() => { - draw.changeMode('edit_vertex', { - container: appState.layoutRefs.viewportRef.current, - deleteVertexButtonId: `${appConfig.id}-draw-delete-point`, - undoButtonId: `${appConfig.id}-draw-undo`, - isPanEnabled: appState.interfaceType !== 'keyboard', - interfaceType: appState.interfaceType, - scale: { small: 1, medium: 1.5, large: 2 }[mapState.mapSize], - featureId: newFeature.id, - getSnapEnabled: () => mapProvider.snapEnabled === true - }) - }, 0) + if (pluginState.editAfterCreate) { + // Switch straight to edit vertex mode + dispatch({ type: 'SET_MODE', payload: 'edit_vertex'}) + + setTimeout(() => { + draw.changeMode('edit_vertex', { + container: appState.layoutRefs.viewportRef.current, + deleteVertexButtonId: `${appConfig.id}-draw-delete-point`, + undoButtonId: `${appConfig.id}-draw-undo`, + isPanEnabled: appState.interfaceType !== 'keyboard', + interfaceType: appState.interfaceType, + scale: { small: 1, medium: 1.5, large: 2 }[mapState.mapSize], + featureId: newFeature.id, + getSnapEnabled: () => mapProvider.snapEnabled === true + }) + }, 0) + } else { + // Switch to disabled mode - feature stays on map but not editable + dispatch({ type: 'SET_MODE', payload: null }) + dispatch({ type: 'SET_FEATURE', payload: { feature: null, tempFeature: null }}) + + setTimeout(() => { + draw.changeMode('disabled') + }, 0) + + eventBus.emit('draw:done', { newFeature }) + } } map.on('draw.create', onCreate) diff --git a/plugins/beta/draw-ml/src/manifest.js b/plugins/beta/draw-ml/src/manifest.js index 6577d528..dbe047da 100755 --- a/plugins/beta/draw-ml/src/manifest.js +++ b/plugins/beta/draw-ml/src/manifest.js @@ -28,8 +28,17 @@ export const manifest = { label: ({ pluginState }) => pluginState.action ? pluginState.action.charAt(0).toUpperCase() + pluginState.action.slice(1) : 'Done', hiddenWhen: ({ appState, pluginState }) => !pluginState.mode || appState.interfaceType !== 'mouse' && pluginState.mode !== 'edit_vertex', enableWhen: ({ pluginState }) => pluginState.action ? pluginState.actionValid : !!pluginState.tempFeature, - ...createButtonSlots(true), + excludeWhen: () => true, + ...createButtonSlots(false), variant: 'primary', + },{ + id: 'drawFinish', + label: 'Finish shape', + iconId: 'check', + variant: 'primary', + hiddenWhen: ({ pluginState }) => !['draw_polygon', 'draw_line', 'edit_vertex'].includes(pluginState.mode), + enableWhen: ({ pluginState }) => pluginState.numVertecies >= (pluginState.mode === 'draw_polygon' ? 3 : 2), + ...createButtonSlots(false) },{ id: 'drawAddPoint', label: 'Add point', @@ -44,18 +53,10 @@ export const manifest = { hiddenWhen: ({ pluginState }) => !['draw_polygon', 'draw_line', 'edit_vertex'].includes(pluginState.mode), enableWhen: ({ pluginState }) => pluginState.undoStackLength > 0, ...createButtonSlots(false) - },{ - id: 'drawFinish', - label: 'Finish shape', - iconId: 'check', - variant: 'tertiary', - hiddenWhen: ({ pluginState }) => !['draw_polygon', 'draw_line'].includes(pluginState.mode), - enableWhen: ({ pluginState }) => pluginState.numVertecies >= (pluginState.mode === 'draw_polygon' ? 3 : 2), - ...createButtonSlots(false) },{ id: 'drawDeletePoint', label: 'Delete point', - iconId: 'close', + iconId: 'delete-vertex', variant: 'tertiary', enableWhen: ({ pluginState }) => pluginState.selectedVertexIndex >= 0 && pluginState.numVertecies > (pluginState.tempFeature?.geometry?.type === 'Polygon' ? 3 : 2), hiddenWhen: ({ pluginState }) => !(['simple_select', 'edit_vertex'].includes(pluginState.mode)), @@ -71,9 +72,11 @@ export const manifest = { },{ id: 'drawCancel', label: 'Cancel', + iconId: 'close', variant: 'tertiary', hiddenWhen: ({ pluginState }) => !pluginState.mode, - ...createButtonSlots(true) + // excludeWhen: () => true, + ...createButtonSlots(false) }], keyboardShortcuts: [{ @@ -92,6 +95,9 @@ export const manifest = { },{ id: 'magnet', svgContent: '' + },{ + id: 'delete-vertex', + svgContent: '' }], api: { diff --git a/plugins/beta/draw-ml/src/reducer.js b/plugins/beta/draw-ml/src/reducer.js index 77a78029..2a0c6349 100755 --- a/plugins/beta/draw-ml/src/reducer.js +++ b/plugins/beta/draw-ml/src/reducer.js @@ -8,7 +8,8 @@ const initialState = { numVertecies: null, snap: false, hasSnapLayers: false, - undoStackLength: 0 + undoStackLength: 0, + editAfterCreate: true } const setMode = (state, payload) => { @@ -70,6 +71,13 @@ const setUndoStackLength = (state, payload) => { } } +const setEditAfterCreate = (state, payload) => { + return { + ...state, + editAfterCreate: !!payload + } +} + const actions = { SET_MODE: setMode, SET_ACTION: setAction, @@ -78,7 +86,8 @@ const actions = { TOGGLE_SNAP: toggleSnap, SET_SNAP: setSnap, SET_HAS_SNAP_LAYERS: setHasSnapLayers, - SET_UNDO_STACK_LENGTH: setUndoStackLength + SET_UNDO_STACK_LENGTH: setUndoStackLength, + SET_EDIT_AFTER_CREATE: setEditAfterCreate } export { diff --git a/src/App/components/Actions/Actions.module.scss b/src/App/components/Actions/Actions.module.scss index e2b175c0..dde1a40b 100755 --- a/src/App/components/Actions/Actions.module.scss +++ b/src/App/components/Actions/Actions.module.scss @@ -22,6 +22,6 @@ .im-o-app--tablet, .im-o-app--desktop { .im-c-actions { - min-width: 280px; + min-width: 0; } } \ No newline at end of file From 56b1b891f1c801bae50ac55fe67364e86c737b28 Mon Sep 17 00:00:00 2001 From: Dan Leech Date: Fri, 13 Feb 2026 14:56:08 +0000 Subject: [PATCH 2/2] Comsumer html rendering fix and unit tests --- demo/js/index.js | 6 +- demo/js/planning.js | 8 + plugins/beta/draw-ml/src/manifest.js | 2 +- plugins/interact/src/events.test.js | 35 +++ .../src/hooks/useInteractionHandlers.test.js | 50 ++++ providers/maplibre/src/utils/queryFeatures.js | 175 +++++++++---- src/App/components/Panel/Panel.jsx | 23 +- src/App/components/Panel/Panel.test.jsx | 18 ++ src/App/hooks/useButtonStateEvaluator.test.js | 16 ++ src/App/hooks/useMediaQueryDispatch.test.js | 48 ++++ src/App/layout/Layout.jsx | 8 +- src/App/registry/panelRegistry.test.js | 31 ++- src/App/registry/pluginRegistry.test.js | 17 ++ src/App/renderer/HtmlElementHost.jsx | 184 ++++++++++++++ src/App/renderer/HtmlElementHost.test.jsx | 231 ++++++++++++++++++ src/App/renderer/mapControls.js | 6 + src/App/renderer/mapControls.test.js | 12 +- src/App/renderer/mapPanels.js | 35 +-- src/App/renderer/mapPanels.test.js | 29 +++ src/App/renderer/pluginWrapper.test.js | 28 +++ src/App/renderer/slotHelpers.js | 60 +++++ src/App/renderer/slotHelpers.test.js | 82 +++++++ src/App/store/AppProvider.jsx | 3 + src/App/store/AppProvider.test.jsx | 40 +++ src/App/store/appActionsMap.test.js | 12 + src/InteractiveMap/InteractiveMap.js | 19 +- src/InteractiveMap/InteractiveMap.test.js | 89 +++++++ .../behaviourController.test.js | 16 ++ src/config/appConfig.test.js | 143 ++++++++--- src/services/eventBus.test.js | 25 +- src/utils/detectBreakpoint.test.js | 54 +++- src/utils/getSafeZoneInset.test.js | 18 ++ 32 files changed, 1380 insertions(+), 143 deletions(-) create mode 100644 src/App/renderer/HtmlElementHost.jsx create mode 100644 src/App/renderer/HtmlElementHost.test.jsx create mode 100644 src/App/renderer/slotHelpers.js create mode 100644 src/App/renderer/slotHelpers.test.js diff --git a/demo/js/index.js b/demo/js/index.js index 6aed3c5f..417cc4d0 100755 --- a/demo/js/index.js +++ b/demo/js/index.js @@ -161,9 +161,9 @@ interactiveMap.on('map:ready', function (e) { // framePlugin.addFrame('test', { // aspectRatio: 1 // }) - // interactPlugin.enable({ - // debug: true - // }) + interactPlugin.enable({ + debug: true + }) }) interactiveMap.on('datasets:ready', function () { diff --git a/demo/js/planning.js b/demo/js/planning.js index 2fdb62e1..c96c2f19 100755 --- a/demo/js/planning.js +++ b/demo/js/planning.js @@ -162,6 +162,14 @@ interactiveMap.on('app:ready', function (e) { tablet: { slot: 'inset', width: '260px', initiallyOpen: false, exclusive: true }, desktop: { slot: 'inset', width: '280px', initiallyOpen: false, exclusive: true } }) + interactiveMap.addPanel('banner', { + label: 'Banner', + showLabel: false, + html: '

Test banner

', + mobile: { slot: 'banner' }, + tablet: { slot: 'banner' }, + desktop: { slot: 'banner' } + }) }) interactiveMap.on('map:exit', function (e) { diff --git a/plugins/beta/draw-ml/src/manifest.js b/plugins/beta/draw-ml/src/manifest.js index dbe047da..3527373c 100755 --- a/plugins/beta/draw-ml/src/manifest.js +++ b/plugins/beta/draw-ml/src/manifest.js @@ -97,7 +97,7 @@ export const manifest = { svgContent: '' },{ id: 'delete-vertex', - svgContent: '' + svgContent: '' }], api: { diff --git a/plugins/interact/src/events.test.js b/plugins/interact/src/events.test.js index e9683c3a..573d59b0 100644 --- a/plugins/interact/src/events.test.js +++ b/plugins/interact/src/events.test.js @@ -148,4 +148,39 @@ describe('attachEvents', () => { cleanup() Object.values(params.buttonConfig).forEach(btn => expect(btn.onClick).toBeNull()) }) + + it('selectDone handles emission when no marker/coords exist', () => { + const params = createParams() + cleanup = attachEvents(params) + + // Ensure marker returns null (no coords) + params.mapState.markers.getMarker.mockReturnValue(null) + + // Set up features and bounds + params.pluginState.selectedFeatures = [{ id: 'f1' }] + params.pluginState.selectionBounds = { sw: [0, 0], ne: [1, 1] } + + params.buttonConfig.selectDone.onClick() + + expect(params.eventBus.emit).toHaveBeenCalledWith('interact:done', { + selectedFeatures: [{ id: 'f1' }], + selectionBounds: { sw: [0, 0], ne: [1, 1] } + }) + }) + + it('respects default closeOnAction when value is undefined (fallback to true)', () => { + const params = createParams() + // Explicitly set to undefined to trigger the ?? fallback + params.pluginState.closeOnAction = undefined + cleanup = attachEvents(params) + + // Test for selectDone + params.buttonConfig.selectDone.onClick() + expect(params.closeApp).toHaveBeenCalledTimes(1) + + // Test for selectCancel + params.closeApp.mockClear() + params.buttonConfig.selectCancel.onClick() + expect(params.closeApp).toHaveBeenCalledTimes(1) + }) }) diff --git a/plugins/interact/src/hooks/useInteractionHandlers.test.js b/plugins/interact/src/hooks/useInteractionHandlers.test.js index 089f20eb..098eee6c 100644 --- a/plugins/interact/src/hooks/useInteractionHandlers.test.js +++ b/plugins/interact/src/hooks/useInteractionHandlers.test.js @@ -264,3 +264,53 @@ it('emits selectionchange once when bounds exist', () => { }) ) }) + +it('skips emission when selection remains empty after being cleared', () => { + const eventBus = { emit: jest.fn() } + + // 1. First render with a feature (prev is null, emission happens) + const { rerender } = renderHook( + ({ features }) => useInteractionHandlers({ + mapState: { markers: {} }, + pluginState: { selectedFeatures: features, selectionBounds: { b: 1 } }, + services: { eventBus }, + mapProvider: {} + }), + { initialProps: { features: [{ id: 'f1' }] } } + ) + + expect(eventBus.emit).toHaveBeenCalledTimes(1) + eventBus.emit.mockClear() + + // 2. Rerender with empty selection (prev is now [{id: 'f1'}], emission happens) + rerender({ features: [] }) + expect(eventBus.emit).toHaveBeenCalledTimes(1) + eventBus.emit.mockClear() + + // 3. Rerender with empty selection AGAIN + // This triggers: prev !== null AND prev.length === 0 + rerender({ features: [] }) + + // Should skip emission because wasEmpty is true (via prev.length === 0) + // and current features.length is 0 + expect(eventBus.emit).not.toHaveBeenCalled() +}) + +/* ------------------------------------------------------------------ */ +/* Debug mode */ +/* ------------------------------------------------------------------ */ + +it('logs features when debug mode is enabled', () => { + const logSpy = jest.spyOn(console, 'log').mockImplementation(() => {}) + + const { result } = setup({ debug: true }) + + click(result) + + expect(logSpy).toHaveBeenCalledWith( + expect.stringContaining('--- Features at'), + expect.any(Array) + ) + + logSpy.mockRestore() +}) diff --git a/providers/maplibre/src/utils/queryFeatures.js b/providers/maplibre/src/utils/queryFeatures.js index e5014137..01af0350 100644 --- a/providers/maplibre/src/utils/queryFeatures.js +++ b/providers/maplibre/src/utils/queryFeatures.js @@ -1,73 +1,144 @@ /** - * Flatten geometry coordinates into a flat array of [lng, lat] pairs. - * - * @param {any} coordinates - GeoJSON coordinates. - * @param {string} type - GeoJSON geometry type. - * @returns {Array<[number, number]>} + * Calculates the squared distance from a point (p) to a line segment (v to w). */ -const flattenCoords = (coordinates, type) => { - if (type === 'Point') { - return [coordinates] - } - if (type === 'MultiPoint' || type === 'LineString') { - return coordinates - } - if (type === 'MultiLineString' || type === 'Polygon') { - return coordinates.flat() +const distToSegmentSquared = (p, v, w) => { + const l2 = (v.x - w.x) ** 2 + (v.y - w.y) ** 2 + if (l2 === 0) { + return (p.x - v.x) ** 2 + (p.y - v.y) ** 2 } - return coordinates.flat(2) // MultiPolygon + let t = ((p.x - v.x) * (w.x - v.x) + (p.y - v.y) * (w.y - v.y)) / l2 + t = Math.max(0, Math.min(1, t)) + return (p.x - (v.x + t * (w.x - v.x))) ** 2 + (p.y - (v.y + t * (w.y - v.y))) ** 2 } /** - * Calculate the minimum squared screen-pixel distance from a point to a feature's - * geometry vertices. - * - * @param {import('maplibre-gl').Map} map - MapLibre map instance (for projection). - * @param {{ x: number, y: number }} point - Screen pixel position. - * @param {Object} geometry - GeoJSON geometry object. - * @returns {number} Minimum squared pixel distance. + * Ray-casting algorithm to determine if a point is inside a polygon. */ -const screenDistance = (map, point, geometry) => { - const coords = flattenCoords(geometry.coordinates, geometry.type) - let min = Infinity - - for (const [lng, lat] of coords) { - const { x, y } = map.project([lng, lat]) - const d = (x - point.x) ** 2 + (y - point.y) ** 2 - if (d < min) { - min = d +const isPointInPolygon = (point, ring) => { + const [px, py] = point + let inside = false + for (let i = 0, j = ring.length - 1; i < ring.length; j = i++) { + const [xi, yi] = ring[i] + const [xj, yj] = ring[j] + const isAboveI = yi > py + const isAboveJ = yj > py + if (isAboveI !== isAboveJ) { + const intersectX = (xj - xi) * (py - yi) / (yj - yi) + xi + if (px < intersectX) { + if (inside === true) { + inside = false + } else { + inside = true + } + } } } + return inside +} - return min +/** + * Calculates minimum squared pixel distance to the geometry. + */ +const getMinDistToGeometry = (map, point, geometry) => { + const { coordinates: coords, type } = geometry + let minSqDist = Infinity + const getScreenPt = (lngLat) => map.project(lngLat) + + const processLine = (lineCoords) => { + for (let i = 0; i < lineCoords.length - 1; i++) { + const d2 = distToSegmentSquared(point, getScreenPt(lineCoords[i]), getScreenPt(lineCoords[i + 1])) + if (d2 < minSqDist) { + minSqDist = d2 + } + } + } + + if (type === 'Point') { + const p = getScreenPt(coords) + minSqDist = (point.x - p.x) ** 2 + (point.y - p.y) ** 2 + } else if (type === 'LineString' || type === 'MultiPoint') { + if (type === 'LineString') { + processLine(coords) + } else { + coords.forEach((pt) => { + const p = getScreenPt(pt) + const d2 = (point.x - p.x) ** 2 + (point.y - p.y) ** 2 + if (d2 < minSqDist) { + minSqDist = d2 + } + }) + } + } else if (type === 'Polygon' || type === 'MultiLineString') { + coords.forEach(processLine) + } else if (type === 'MultiPolygon') { + coords.forEach((poly) => poly.forEach(processLine)) + } + return minSqDist } /** - * Query rendered features at a screen pixel position, optionally expanding - * the query area by a pixel radius and sorting results closest-first. - * - * @param {import('maplibre-gl').Map} map - MapLibre map instance. - * @param {{ x: number, y: number }} point - Screen pixel position. - * @param {Object} [options] - * @param {number} [options.radius] - Pixel radius to expand the query area. - * @returns {any[]} Features sorted by proximity when radius is provided. + * Query features prioritizing Layer Order, then Containment for Polygons. */ export const queryFeatures = (map, point, options = {}) => { - const { radius } = options + const { radius = 10 } = options + const queryArea = [[point.x - radius, point.y - radius], [point.x + radius, point.y + radius]] + const rawFeatures = map.queryRenderedFeatures(queryArea) + if (rawFeatures.length === 0) { + return [] + } + + // Identify layer visual hierarchy + const layerStack = [] + rawFeatures.forEach(f => { + if (layerStack.includes(f.layer.id) === false) { + layerStack.push(f.layer.id) + } + }) - if (!radius) { - return map.queryRenderedFeatures(point) + // Deduplicate Bottom-Up to favor data layers over highlight layers + const seenIds = new Set() + const uniqueFeatures = [] + for (let i = rawFeatures.length - 1; i >= 0; i--) { + const f = rawFeatures[i] + const featureId = f.id !== undefined ? f.id : JSON.stringify(f.properties) + if (seenIds.has(featureId) === false) { + seenIds.add(featureId) + uniqueFeatures.push(f) + } } - const bbox = [ - [point.x - radius, point.y - radius], - [point.x + radius, point.y + radius] - ] + const clickLngLat = map.unproject(point) + const clickPt = [clickLngLat.lng, clickLngLat.lat] + + return uniqueFeatures + .map((f) => { + let score = 0 + const type = f.geometry.type + const pixelDistSq = getMinDistToGeometry(map, point, f.geometry) + + // PRIORITY 1: LAYER ORDER + const layerRank = layerStack.indexOf(f.layer.id) + score += (layerRank * 1000000) - const features = map.queryRenderedFeatures(bbox) + // PRIORITY 2: CONTAINMENT (Polygon Special Treatment) + if (type.includes('Polygon')) { + const polys = type === 'Polygon' ? [f.geometry.coordinates] : f.geometry.coordinates + const isInside = polys.some((ring) => isPointInPolygon(clickPt, ring[0])) + + if (isInside === true) { + // Massive boost for polygons if we are actually inside them + score -= 500000 + } else { + // If we are outside a polygon, it loses significantly to anything we ARE inside + score += 100000 + } + } - return features - .map(f => ({ f, d: screenDistance(map, point, f.geometry) })) - .sort((a, b) => a.d - b.d) + // PRIORITY 3: DISTANCE (Final Tie-breaker) + score += pixelDistSq + + return { f, score } + }) + .sort((a, b) => a.score - b.score) .map(({ f }) => f) -} +} \ No newline at end of file diff --git a/src/App/components/Panel/Panel.jsx b/src/App/components/Panel/Panel.jsx index 6ece2d93..cdb0eda7 100755 --- a/src/App/components/Panel/Panel.jsx +++ b/src/App/components/Panel/Panel.jsx @@ -57,7 +57,7 @@ const buildBodyProps = ({ bodyRef, panelBodyClass, isBodyScrollable, elementId } // eslint-disable-next-line camelcase, react/jsx-pascal-case // sonarjs/disable-next-line function-name -export const Panel = ({ panelId, panelConfig, props, WrappedChild, label, html, children }) => { +export const Panel = ({ panelId, panelConfig, props, WrappedChild, label, html, children, isOpen = true, rootRef }) => { const { id } = useConfig() const { dispatch, breakpoint, layoutRefs } = useApp() @@ -67,23 +67,34 @@ export const Panel = ({ panelId, panelConfig, props, WrappedChild, label, html, const { isAside, isDialog, isModal, isDismissable, shouldFocus, buttonContainerEl } = computePanelState(bpConfig, props?.triggeringElement) + // For persistent panels, gate modal behaviour on open state + const isModalActive = isModal && isOpen + const mainRef = layoutRefs.mainRef - const panelRef = useRef(null) + const internalPanelRef = useRef(null) const bodyRef = useRef(null) + const prevIsOpenRef = useRef(isOpen) const isBodyScrollable = useIsScrollable(bodyRef) + // Merge internal ref with optional external rootRef + const panelRef = rootRef || internalPanelRef + const handleClose = () => { requestAnimationFrame(() => { (props?.triggeringElement || layoutRefs.viewportRef.current).focus?.() }) dispatch({ type: 'CLOSE_PANEL', payload: panelId }) } - useModalPanelBehaviour({ mainRef, panelRef, isModal, isAside, rootEl, buttonContainerEl, handleClose }) + useModalPanelBehaviour({ mainRef, panelRef, isModal: isModalActive, isAside, rootEl, buttonContainerEl, handleClose }) useEffect(() => { - if (shouldFocus) { - panelRef.current.focus() + // Focus on initial mount (non-persistent) or when isOpen transitions to true (persistent) + const justOpened = isOpen && !prevIsOpenRef.current + prevIsOpenRef.current = isOpen + + if (shouldFocus && (justOpened || isOpen)) { + panelRef.current?.focus() } - }, []) + }, [isOpen]) const panelClass = buildPanelClassNames(bpConfig.slot, panelConfig.showLabel) const panelBodyClass = buildPanelBodyClassNames(panelConfig.showLabel, isDismissable) diff --git a/src/App/components/Panel/Panel.test.jsx b/src/App/components/Panel/Panel.test.jsx index 32c6e696..86c54164 100755 --- a/src/App/components/Panel/Panel.test.jsx +++ b/src/App/components/Panel/Panel.test.jsx @@ -4,6 +4,7 @@ import { render, screen, fireEvent } from '@testing-library/react' import { Panel } from './Panel' import { useConfig } from '../../store/configContext' import { useApp } from '../../store/appContext' +import { useIsScrollable } from '../../hooks/useIsScrollable.js' jest.mock('../../store/configContext', () => ({ useConfig: jest.fn() })) jest.mock('../../store/appContext', () => ({ useApp: jest.fn() })) @@ -61,6 +62,23 @@ describe('Panel', () => { renderPanel({ desktop: { slot: 'side', dismissable: true, initiallyOpen: true, width: '300px' } }) expect(screen.getByRole('complementary')).toHaveStyle({ width: '300px' }) }) + + it('adds scrollable attributes to body when content overflows', () => { + // 1. Force the mock to true ONLY for this test + useIsScrollable.mockReturnValue(true) + + const { container } = renderPanel() + + // 2. Target by class to avoid role collision with the parent panel + const body = container.querySelector('.im-c-panel__body') + + expect(body).toHaveAttribute('tabIndex', '0') + expect(body).toHaveAttribute('role', 'region') + expect(body).toHaveAttribute('aria-labelledby', 'app-panel-settings-label') + + // 3. IMPORTANT: Reset to false so other tests don't see two regions + useIsScrollable.mockReturnValue(false) + }) }) describe('role and aria attributes', () => { diff --git a/src/App/hooks/useButtonStateEvaluator.test.js b/src/App/hooks/useButtonStateEvaluator.test.js index 2d495a9a..4210313d 100644 --- a/src/App/hooks/useButtonStateEvaluator.test.js +++ b/src/App/hooks/useButtonStateEvaluator.test.js @@ -130,4 +130,20 @@ describe('useButtonStateEvaluator', () => { renderHook(() => useButtonStateEvaluator((fn) => fn({ pluginState: {} }))) expect(enableWhen).toHaveBeenCalled() }) + + it('covers fallback to empty array when manifest or buttons is missing', () => { + // Branch 1: Plugin exists but manifest is missing + // Branch 2: Manifest exists but buttons is missing + mockPluginRegistry.registeredPlugins = [ + { id: 'p1' }, + { id: 'p2', manifest: {} }, + { id: 'p3', manifest: { buttons: null } } + ] + + renderHook(() => useButtonStateEvaluator((fn) => fn())) + + // If the fallback (|| []) works, the code continues to the next plugin + // without throwing a "cannot read property forEach of undefined" error. + expect(mockDispatch).not.toHaveBeenCalled() + }) }) diff --git a/src/App/hooks/useMediaQueryDispatch.test.js b/src/App/hooks/useMediaQueryDispatch.test.js index 3d1778e7..4a08eec3 100644 --- a/src/App/hooks/useMediaQueryDispatch.test.js +++ b/src/App/hooks/useMediaQueryDispatch.test.js @@ -115,4 +115,52 @@ describe('useMediaQueryDispatch', () => { payload: { preferredColorScheme: 'light', prefersReducedMotion: true } }) }) + + it('sets up hybrid media query and dispatches on change', () => { + const options = { + behaviour: 'hybrid', + hybridWidth: 500, + maxMobileWidth: 768, + appColorScheme: 'light', + autoColorScheme: true + } + + renderHook(() => useMediaQueryDispatch(dispatch, options)) + + // Verify matchMedia was called with the hybrid threshold + expect(window.matchMedia).toHaveBeenCalledWith('(max-width: 500px)') + + // Verify addEventListener was called for the 2 standard queries + 1 hybrid query + expect(mockMediaQuery.addEventListener).toHaveBeenCalledTimes(3) + + // Find the hybrid change handler (the last one registered) + const hybridHandler = mockMediaQuery.addEventListener.mock.calls[2][1] + + // Simulate a media query match event + act(() => { + hybridHandler({ matches: true }) + }) + + expect(dispatch).toHaveBeenCalledWith({ + type: 'SET_HYBRID_FULLSCREEN', + payload: true + }) + }) + + it('falls back to maxMobileWidth for hybrid threshold when hybridWidth is missing', () => { + const options = { + behaviour: 'hybrid', + // hybridWidth is undefined here + maxMobileWidth: 800, + appColorScheme: 'light', + autoColorScheme: true + } + + renderHook(() => useMediaQueryDispatch(dispatch, options)) + + // This covers the right side of the ?? operator + expect(window.matchMedia).toHaveBeenCalledWith('(max-width: 800px)') + + expect(mockMediaQuery.addEventListener).toHaveBeenCalledTimes(3) + }) }) diff --git a/src/App/layout/Layout.jsx b/src/App/layout/Layout.jsx index dab0e9c6..69e0be0a 100755 --- a/src/App/layout/Layout.jsx +++ b/src/App/layout/Layout.jsx @@ -9,6 +9,7 @@ import { Logo } from '../components/Logo/Logo' import { Attributions } from '../components/Attributions/Attributions' import { layoutSlots } from '../renderer/slots' import { SlotRenderer } from '../renderer/SlotRenderer' +import { HtmlElementHost } from '../renderer/HtmlElementHost' // eslint-disable-next-line camelcase, react/jsx-pascal-case // sonarjs/disable-next-line function-name @@ -73,7 +74,7 @@ export const Layout = () => { -
+
@@ -87,7 +88,7 @@ export const Layout = () => {
-
+
@@ -95,7 +96,8 @@ export const Layout = () => {
-
+ +
diff --git a/src/App/registry/panelRegistry.test.js b/src/App/registry/panelRegistry.test.js index 0faf060a..b8278d1c 100755 --- a/src/App/registry/panelRegistry.test.js +++ b/src/App/registry/panelRegistry.test.js @@ -1,4 +1,4 @@ -import { registerPanel, addPanel, removePanel, getPanelConfig } from './panelRegistry.js' +import { createPanelRegistry, registerPanel, addPanel, removePanel, getPanelConfig } from './panelRegistry.js' import { defaultPanelConfig } from '../../config/appConfig.js' describe('panelRegistry', () => { @@ -88,4 +88,33 @@ describe('panelRegistry', () => { expect(updatedConfig.panel1).toBeUndefined() expect(updatedConfig).not.toBe(config) // Immutable }) + + describe('createPanelRegistry (Factory)', () => { + let registry + + beforeEach(() => { + registry = createPanelRegistry() + }) + + test('should manage state internally via all methods', () => { + // Test registerPanel state + registry.registerPanel({ p1: { title: 'P1' } }) + expect(registry.getPanelConfig()).toHaveProperty('p1') + expect(registry.getPanelConfig().p1.showLabel).toBe(true) + + // Test addPanel state and return value + const added = registry.addPanel('p2', { title: 'P2' }) + expect(added.title).toBe('P2') + expect(registry.getPanelConfig()).toHaveProperty('p2') + + // Test removePanel state + registry.removePanel('p1') + expect(registry.getPanelConfig()).not.toHaveProperty('p1') + expect(registry.getPanelConfig()).toHaveProperty('p2') + + // Test clear state + registry.clear() + expect(registry.getPanelConfig()).toEqual({}) + }) + }) }) diff --git a/src/App/registry/pluginRegistry.test.js b/src/App/registry/pluginRegistry.test.js index 6bb4a344..99fd42bf 100755 --- a/src/App/registry/pluginRegistry.test.js +++ b/src/App/registry/pluginRegistry.test.js @@ -103,4 +103,21 @@ describe('pluginRegistry', () => { pluginRegistry.registerPlugin(pluginB) expect(pluginRegistry.registeredPlugins).toEqual([pluginA, pluginB]) }) + + it('clears all registered plugins', () => { + const pluginA = { id: 'A', config: {}, manifest: {} } + const pluginB = { id: 'B', config: {}, manifest: {} } + + // Fill the registry + pluginRegistry.registerPlugin(pluginA) + pluginRegistry.registerPlugin(pluginB) + expect(pluginRegistry.registeredPlugins.length).toBe(2) + + // Execute clear + pluginRegistry.clear() + + // Verify it is empty + expect(pluginRegistry.registeredPlugins.length).toBe(0) + expect(pluginRegistry.registeredPlugins).toEqual([]) + }) }) diff --git a/src/App/renderer/HtmlElementHost.jsx b/src/App/renderer/HtmlElementHost.jsx new file mode 100644 index 00000000..59bc3227 --- /dev/null +++ b/src/App/renderer/HtmlElementHost.jsx @@ -0,0 +1,184 @@ +// src/App/renderer/HtmlElementHost.jsx +import React, { useRef, useEffect, useMemo } from 'react' +import { useApp } from '../store/appContext.js' +import { Panel } from '../components/Panel/Panel.jsx' +import { resolveTargetSlot, isModeAllowed, isControlVisible, isConsumerHtml } from './slotHelpers.js' +import { allowedSlots } from './slots.js' +import { stringToKebab } from '../../utils/stringToKebab.js' + +/** + * Maps slot names to their corresponding layout refs. + */ +export const getSlotRef = (slot, layoutRefs) => { + const slotRefMap = { + side: layoutRefs.sideRef, + banner: layoutRefs.bannerRef, + 'top-left': layoutRefs.topLeftColRef, + 'top-right': layoutRefs.topRightColRef, + inset: layoutRefs.insetRef, + middle: layoutRefs.middleRef, + bottom: layoutRefs.bottomRef, + actions: layoutRefs.actionsRef, + modal: layoutRefs.modalRef + } + return slotRefMap[slot] || null +} + +/** + * Manages DOM projection for a single persistent element. + * Moves the wrapper into the target slot when visible, hides it otherwise. + * Depends on breakpoint to handle conditionally rendered slot containers + * (e.g. the banner slot swaps DOM nodes between mobile and desktop). + */ +export const useDomProjection = (wrapperRef, targetSlot, isVisible, layoutRefs, breakpoint) => { + useEffect(() => { + const wrapper = wrapperRef.current + + if (isVisible) { + const slotRef = getSlotRef(targetSlot, layoutRefs) + if (slotRef?.current) { + slotRef.current.appendChild(wrapper) + wrapper.style.display = '' + } + } else { + wrapper.style.display = 'none' + } + + return () => { + wrapper.style.display = 'none' + } + }, [isVisible, targetSlot, layoutRefs, breakpoint, wrapperRef]) +} + +/** + * Persistent wrapper for a consumer HTML panel. + * The Panel component stays mounted for the lifetime of the registration. + * DOM projection moves it between slots; CSS hides it when closed. + */ +const PersistentPanel = ({ panelId, config, isOpen, openPanelProps, allowedModalPanelId, appState }) => { + const panelRootRef = useRef(null) + const { breakpoint, mode, isFullscreen, layoutRefs } = appState + + const bpConfig = config[breakpoint] + const targetSlot = bpConfig ? resolveTargetSlot(bpConfig, breakpoint) : null + + // Determine visibility using the same logic as mapPanels + const isVisible = (() => { + if (!isOpen || !bpConfig || !targetSlot) { + return false + } + const isNextToButton = `${stringToKebab(panelId)}-button` === targetSlot + if (!allowedSlots.panel.includes(targetSlot) && !isNextToButton) { + return false + } + if (!isModeAllowed(config, mode)) { + return false + } + if (config.inline === false && !isFullscreen) { + return false + } + if (bpConfig.modal && panelId !== allowedModalPanelId) { + return false + } + return true + })() + + useDomProjection(panelRootRef, targetSlot, isVisible, layoutRefs, breakpoint) + + return ( + + ) +} + +/** + * Persistent wrapper for a consumer HTML control. + * The control stays mounted for the lifetime of the registration. + */ +const PersistentControl = ({ control, appState }) => { + const wrapperRef = useRef(null) + const { breakpoint, mode, isFullscreen, layoutRefs } = appState + + const bpConfig = control[breakpoint] + const isVisible = isControlVisible(control, { breakpoint, mode, isFullscreen }) + const targetSlot = bpConfig?.slot || null + + useDomProjection(wrapperRef, targetSlot, isVisible, layoutRefs, breakpoint) + + const innerHtml = useMemo(() => ({ __html: control.html }), [control.html]) + + return ( +
+ ) +} + +/** + * Renders all consumer HTML panels and controls persistently. + * Items mount once on registration and only unmount on deregistration. + * Visibility and slot placement are handled by DOM projection, not React mount/unmount. + */ +export const HtmlElementHost = () => { + const appState = useApp() + const { panelConfig = {}, controlConfig = {}, openPanels = {}, breakpoint } = appState + + // Find consumer HTML panels + const htmlPanels = useMemo(() => + Object.entries(panelConfig).filter(([_, config]) => isConsumerHtml(config)), + [panelConfig] + ) + + // Find consumer HTML controls + const htmlControls = useMemo(() => + Object.values(controlConfig).filter(control => isConsumerHtml(control)), + [controlConfig] + ) + + // Determine which modal panel is allowed (topmost open modal) + const allowedModalPanelId = useMemo(() => { + const openPanelEntries = Object.entries(openPanels) + const modalPanels = openPanelEntries.filter(([panelId]) => { + const cfg = panelConfig[panelId]?.[breakpoint] + return cfg?.modal + }) + return modalPanels.length > 0 ? modalPanels[modalPanels.length - 1][0] : null + }, [openPanels, panelConfig, breakpoint]) + + if (!htmlPanels.length && !htmlControls.length) { + return null + } + + return ( + <> + {htmlPanels.map(([panelId, config]) => ( + + ))} + {htmlControls.map(control => ( + + ))} + + ) +} diff --git a/src/App/renderer/HtmlElementHost.test.jsx b/src/App/renderer/HtmlElementHost.test.jsx new file mode 100644 index 00000000..70ce1555 --- /dev/null +++ b/src/App/renderer/HtmlElementHost.test.jsx @@ -0,0 +1,231 @@ +import React from 'react' +import { render } from '@testing-library/react' +import { HtmlElementHost, getSlotRef } from './HtmlElementHost.jsx' +import { useApp } from '../store/appContext' + +jest.mock('../store/appContext', () => ({ useApp: jest.fn() })) +jest.mock('../store/configContext', () => ({ useConfig: jest.fn(() => ({ id: 'test' })) })) +jest.mock('../components/Panel/Panel.jsx', () => ({ + Panel: ({ panelId, html, isOpen, rootRef }) => ( +
+ {html} +
+ ) +})) +jest.mock('./slots.js', () => ({ + allowedSlots: { + panel: ['inset', 'side', 'modal', 'bottom'], + control: ['inset', 'banner', 'bottom', 'actions'] + } +})) + +/** + * Wrapper that provides real DOM slot containers as refs. + * This keeps projected nodes inside React's render tree so cleanup works. + */ +const SlotHarness = ({ layoutRefs, children }) => ( +
+
+
+
+
+
+
+ {children} +
+) + +describe('HtmlElementHost', () => { + let layoutRefs + + beforeEach(() => { + jest.clearAllMocks() + layoutRefs = { + sideRef: { current: null }, + bannerRef: { current: null }, + topLeftColRef: { current: null }, + topRightColRef: { current: null }, + insetRef: { current: null }, + middleRef: { current: null }, + bottomRef: { current: null }, + actionsRef: { current: null }, + modalRef: { current: null }, + viewportRef: { current: null }, + mainRef: { current: null } + } + }) + + const mockApp = (overrides = {}) => { + const state = { + breakpoint: 'desktop', + mode: 'view', + isFullscreen: false, + panelConfig: {}, + controlConfig: {}, + openPanels: {}, + layoutRefs, + dispatch: jest.fn(), + ...overrides + } + useApp.mockReturnValue(state) + return state + } + + const renderWithSlots = (appOverrides = {}) => { + mockApp(appOverrides) + return render( + + + + ) + } + + it('renders nothing when no consumer HTML panels or controls exist', () => { + mockApp() + const { container } = render() + expect(container.firstChild).toBeNull() + }) + + it('ignores plugin panels (with pluginId)', () => { + mockApp({ + panelConfig: { p1: { pluginId: 'plug1', html: '

Hi

', desktop: { slot: 'inset' } } }, + openPanels: { p1: { props: {} } } + }) + const { container } = render() + expect(container.firstChild).toBeNull() + }) + + it('ignores plugin controls (with pluginId)', () => { + mockApp({ + controlConfig: { c1: { id: 'c1', pluginId: 'plug1', html: '

Hi

', desktop: { slot: 'inset' } } } + }) + const { container } = render() + expect(container.firstChild).toBeNull() + }) + + it('projects open panel into correct slot', () => { + const { container } = renderWithSlots({ + panelConfig: { p1: { html: '

Hi

', label: 'Test', desktop: { slot: 'inset' } } }, + openPanels: { p1: { props: {} } } + }) + expect(container.querySelector('[data-slot="inset"] [data-testid="panel-p1"]')).toBeTruthy() + }) + + it('hides panel when closed and passes isOpen=false', () => { + const { getByTestId } = renderWithSlots({ + panelConfig: { p1: { html: '

Hi

', label: 'Test', desktop: { slot: 'inset' } } }, + openPanels: {} + }) + expect(getByTestId('panel-p1').style.display).toBe('none') + expect(getByTestId('panel-p1').dataset.open).toBe('false') + }) + + it('hides panel when mode is not allowed', () => { + const { getByTestId } = renderWithSlots({ + panelConfig: { p1: { html: '

Hi

', label: 'Test', desktop: { slot: 'inset' }, includeModes: ['edit'] } }, + openPanels: { p1: { props: {} } } + }) + expect(getByTestId('panel-p1').style.display).toBe('none') + }) + + it('hides panel with inline:false when not fullscreen', () => { + const { getByTestId } = renderWithSlots({ + panelConfig: { p1: { html: '

Hi

', label: 'Test', desktop: { slot: 'inset' }, inline: false } }, + openPanels: { p1: { props: {} } }, + isFullscreen: false + }) + expect(getByTestId('panel-p1').style.display).toBe('none') + }) + + it('resolves bottom slot to inset on desktop', () => { + const { container } = renderWithSlots({ + panelConfig: { p1: { html: '

Hi

', label: 'Test', desktop: { slot: 'bottom' } } }, + openPanels: { p1: { props: {} } } + }) + expect(container.querySelector('[data-slot="inset"] [data-testid="panel-p1"]')).toBeTruthy() + expect(container.querySelector('[data-slot="bottom"] [data-testid="panel-p1"]')).toBeNull() + }) + + it('only shows topmost modal panel', () => { + const { getByTestId, container } = renderWithSlots({ + panelConfig: { + p1: { html: '

1

', label: 'M1', desktop: { slot: 'side', modal: true } }, + p2: { html: '

2

', label: 'M2', desktop: { slot: 'side', modal: true } } + }, + openPanels: { p1: { props: {} }, p2: { props: {} } } + }) + expect(getByTestId('panel-p1').style.display).toBe('none') + expect(container.querySelector('[data-slot="modal"] [data-testid="panel-p2"]')).toBeTruthy() + }) + + it('hides panel when breakpoint config is missing', () => { + const { getByTestId } = renderWithSlots({ + panelConfig: { p1: { html: '

Hi

', label: 'Test', mobile: { slot: 'inset' } } }, + openPanels: { p1: { props: {} } }, + breakpoint: 'desktop' + }) + expect(getByTestId('panel-p1').style.display).toBe('none') + }) + + it('projects visible control into correct slot', () => { + const { container } = renderWithSlots({ + controlConfig: { c1: { id: 'c1', html: '', desktop: { slot: 'inset' } } } + }) + const control = container.querySelector('[data-slot="inset"] .im-c-control') + expect(control).toBeTruthy() + expect(control.innerHTML).toBe('') + }) + + it('hides panel when slot is not allowed', () => { + const { getByTestId } = renderWithSlots({ + panelConfig: { p1: { html: '

Hi

', label: 'Test', desktop: { slot: 'invalid' } } }, + openPanels: { p1: { props: {} } } + }) + expect(getByTestId('panel-p1').style.display).toBe('none') + }) + + it('hides control when breakpoint config is missing', () => { + const { container } = renderWithSlots({ + controlConfig: { c1: { id: 'c1', html: '

Hi

', mobile: { slot: 'inset' } } }, + breakpoint: 'desktop' + }) + const control = container.querySelector('.im-c-control') + expect(control.style.display).toBe('none') + }) + + it('uses default empty objects when appState is missing configs', () => { + useApp.mockReturnValue({}) // no panelConfig/controlConfig/openPanels/breakpoint + const { container } = render() + expect(container.firstChild).toBeNull() // still renders safely + }) + + test('getSlotRef returns null for unknown slot', () => { + expect(getSlotRef('unknown-slot', {})).toBeNull() + }) + + it('does not append child if slotRef exists but current is null', () => { + // 1. Setup refs where the slot exists in the map but the DOM node (current) is null + const incompleteRefs = { + ...layoutRefs, + sideRef: { current: null } + } + + // 2. Mock the app so the panel IS visible, but the slot it wants is the broken one + mockApp({ + panelConfig: { p1: { html: 'Hi', desktop: { slot: 'side' } } }, + openPanels: { p1: { props: {} } }, + layoutRefs: incompleteRefs + }) + + const { getByTestId, container } = render() + + const panel = getByTestId('panel-p1') + + // 3. Verify that the panel was NOT moved into a slot container + // Since sideRef.current is null, it should still be sitting in the root of the Host + expect(container.querySelector('[data-slot="side"] [data-testid="panel-p1"]')).toBeNull() + + // The panel exists in the DOM but hasn't been "projected" + expect(panel).toBeTruthy() + }) +}) diff --git a/src/App/renderer/mapControls.js b/src/App/renderer/mapControls.js index 67a19838..589356fb 100755 --- a/src/App/renderer/mapControls.js +++ b/src/App/renderer/mapControls.js @@ -2,6 +2,7 @@ import React from 'react' import { withPluginContexts } from './pluginWrapper.js' import { allowedSlots } from './slots.js' +import { isConsumerHtml } from './slotHelpers.js' /** * Map controls for a given slot and app state. @@ -12,6 +13,11 @@ export function mapControls ({ slot, appState, evaluateProp }) { return Object.values(controlConfig) .filter(control => { + // Consumer HTML controls are managed by HtmlElementHost + if (isConsumerHtml(control)) { + return false + } + const bpConfig = control[breakpoint] if (!bpConfig) { return false diff --git a/src/App/renderer/mapControls.test.js b/src/App/renderer/mapControls.test.js index bef87ef8..b32cf2a7 100755 --- a/src/App/renderer/mapControls.test.js +++ b/src/App/renderer/mapControls.test.js @@ -90,14 +90,22 @@ describe('mapControls', () => { expect(result[0].order).toBe(0) }) - it('renders HTML controls with dangerouslySetInnerHTML', () => { + it('renders plugin HTML controls with dangerouslySetInnerHTML', () => { defaultAppState.controlConfig = ({ - ctrlHtml: { id: 'ctrlHtml', desktop: { slot: 'header' }, html: '

Hi

', includeModes: ['view'] } + ctrlHtml: { id: 'ctrlHtml', pluginId: 'plugin1', desktop: { slot: 'header' }, html: '

Hi

', includeModes: ['view'] } }) const result = mapControls({ slot: 'header', appState: defaultAppState, evaluateProp: (p) => p }) expect(result[0].element.props.dangerouslySetInnerHTML).toEqual({ __html: '

Hi

' }) }) + it('filters out consumer HTML controls (handled by HtmlElementHost)', () => { + defaultAppState.controlConfig = ({ + ctrlHtml: { id: 'ctrlHtml', desktop: { slot: 'header' }, html: '

Hi

', includeModes: ['view'] } + }) + const result = mapControls({ slot: 'header', appState: defaultAppState, evaluateProp: (p) => p }) + expect(result).toEqual([]) + }) + it('handles plugin-less controls gracefully', () => { defaultAppState.controlConfig = ({ ctrl2: { id: 'ctrl2', desktop: { slot: 'header' }, render: () =>
, includeModes: ['view'] } diff --git a/src/App/renderer/mapPanels.js b/src/App/renderer/mapPanels.js index f09cd1df..2ee4f32a 100755 --- a/src/App/renderer/mapPanels.js +++ b/src/App/renderer/mapPanels.js @@ -4,35 +4,7 @@ import { stringToKebab } from '../../utils/stringToKebab.js' import { withPluginContexts } from './pluginWrapper.js' import { Panel } from '../components/Panel/Panel.jsx' import { allowedSlots } from './slots.js' - -/** - * Resolves the target slot for a panel based on its breakpoint config. - * Modal panels always render in the 'modal' slot, and the bottom slot - * is only available on mobile — tablet and desktop fall back to 'inset'. - */ -const resolveTargetSlot = (bpConfig, breakpoint) => { - if (bpConfig.modal) { - return 'modal' - } - if (bpConfig.slot === 'bottom' && ['tablet', 'desktop'].includes(breakpoint)) { - return 'inset' - } - return bpConfig.slot -} - -/** - * Checks whether the current application mode permits the panel to be shown, - * based on its includeModes and excludModes configuration. - */ -const isModeAllowed = (config, mode) => { - if (config.includeModes && !config.includeModes.includes(mode)) { - return false - } - if (config.excludeModes?.includes(mode)) { - return false - } - return true -} +import { resolveTargetSlot, isModeAllowed, isConsumerHtml } from './slotHelpers.js' /** * Determines whether a panel should be rendered in the given slot. @@ -81,6 +53,11 @@ export function mapPanels ({ slot, appState, evaluateProp }) { return null } + // Consumer HTML panels are managed by HtmlElementHost + if (isConsumerHtml(config)) { + return null + } + const bpConfig = config[breakpoint] if (!bpConfig) { return null diff --git a/src/App/renderer/mapPanels.test.js b/src/App/renderer/mapPanels.test.js index 981c0739..8d23431e 100755 --- a/src/App/renderer/mapPanels.test.js +++ b/src/App/renderer/mapPanels.test.js @@ -65,6 +65,28 @@ describe('mapPanels', () => { expect(map()).toEqual([]) }) + it('skips panel if mode is not allowed (isModeAllowed returns false)', () => { + // 1. Define config that only allows 'edit' mode + const panelConfig = { + p1: { + desktop: { slot: 'header' }, + includeModes: ['edit'] + } + } + + // 2. Mock appState with 'view' mode + const state = { + ...defaultAppState, + mode: 'view', + panelConfig, + openPanels: { p1: { props: {} } } + } + + // 3. Verify it's filtered out even though it's the right slot + const result = map(state, 'header') + expect(result).toEqual([]) + }) + it('only allows last opened modal panel', () => { defaultAppState.panelConfig = ({ p1: { desktop: { modal: true }, includeModes: ['view'] }, @@ -159,4 +181,11 @@ describe('mapPanels', () => { }) expect(map().map(p => p.id)).toEqual(['p1']) }) + + it('filters out consumer HTML panels (handled by HtmlElementHost)', () => { + defaultAppState.panelConfig = ({ + p1: { desktop: { slot: 'header' }, html: '

Hi

', includeModes: ['view'] } + }) + expect(map()).toEqual([]) + }) }) diff --git a/src/App/renderer/pluginWrapper.test.js b/src/App/renderer/pluginWrapper.test.js index af6e443d..4554a15f 100755 --- a/src/App/renderer/pluginWrapper.test.js +++ b/src/App/renderer/pluginWrapper.test.js @@ -44,4 +44,32 @@ describe('withPluginContexts', () => { expect(Wrapped2).toBe(Wrapped1) }) + + it('filters buttonConfig by pluginId', () => { + const Inner = jest.fn(() =>
Inner
) + + // Provide a buttonConfig with buttons for multiple plugins + const appStateMock = { + user: 'testUser', + buttonConfig: { + btn1: { label: 'A', pluginId: 'plugin1' }, + btn2: { label: 'B', pluginId: 'plugin2' } + } + } + + // Override the useApp hook for this test + const { useApp } = require('../store/appContext.js') + useApp.mockImplementation(() => appStateMock) + + const Wrapped = withPluginContexts(Inner, { pluginId: 'plugin1', pluginConfig: { foo: 'bar' } }) + + render() + + const props = Inner.mock.calls[0][0] + + // buttonConfig should include only buttons belonging to plugin1 + expect(props.buttonConfig).toEqual({ + btn1: { label: 'A', pluginId: 'plugin1' } + }) + }) }) diff --git a/src/App/renderer/slotHelpers.js b/src/App/renderer/slotHelpers.js new file mode 100644 index 00000000..0dbde277 --- /dev/null +++ b/src/App/renderer/slotHelpers.js @@ -0,0 +1,60 @@ +// src/App/renderer/slotHelpers.js +import { allowedSlots } from './slots.js' + +/** + * Resolves the target slot for a panel based on its breakpoint config. + * Modal panels always render in the 'modal' slot, and the bottom slot + * is only available on mobile — tablet and desktop fall back to 'inset'. + */ +export const resolveTargetSlot = (bpConfig, breakpoint) => { + if (bpConfig.modal) { + return 'modal' + } + if (bpConfig.slot === 'bottom' && ['tablet', 'desktop'].includes(breakpoint)) { + return 'inset' + } + return bpConfig.slot +} + +/** + * Checks whether the current application mode permits an item to be shown, + * based on its includeModes and excludModes configuration. + */ +export const isModeAllowed = (config, mode) => { + if (config.includeModes && !config.includeModes.includes(mode)) { + return false + } + if (config.excludeModes?.includes(mode)) { + return false + } + return true +} + +/** + * Checks whether a control should be visible based on breakpoint, + * mode, fullscreen, and slot constraints. + */ +export const isControlVisible = (control, { breakpoint, mode, isFullscreen }) => { + const bpConfig = control[breakpoint] + if (!bpConfig) { + return false + } + if (!allowedSlots.control.includes(bpConfig.slot)) { + return false + } + if (!isModeAllowed(control, mode)) { + return false + } + if (control.inline === false && !isFullscreen) { + return false + } + return true +} + +/** + * Returns true if a panel/control was added via the consumer API with static HTML + * (i.e. not a plugin component). + */ +export const isConsumerHtml = (config) => { + return typeof config.html === 'string' && !config.pluginId +} diff --git a/src/App/renderer/slotHelpers.test.js b/src/App/renderer/slotHelpers.test.js new file mode 100644 index 00000000..5978ad69 --- /dev/null +++ b/src/App/renderer/slotHelpers.test.js @@ -0,0 +1,82 @@ +import { resolveTargetSlot, isModeAllowed, isControlVisible, isConsumerHtml } from './slotHelpers.js' + +jest.mock('./slots.js', () => ({ allowedSlots: { control: ['inset', 'banner', 'actions'] } })) + +describe('resolveTargetSlot', () => { + it('returns modal for modal panels', () => { + expect(resolveTargetSlot({ modal: true, slot: 'side' }, 'desktop')).toBe('modal') + }) + + it('replaces bottom with inset on tablet and desktop', () => { + expect(resolveTargetSlot({ slot: 'bottom' }, 'tablet')).toBe('inset') + expect(resolveTargetSlot({ slot: 'bottom' }, 'desktop')).toBe('inset') + }) + + it('keeps bottom on mobile', () => { + expect(resolveTargetSlot({ slot: 'bottom' }, 'mobile')).toBe('bottom') + }) + + it('returns slot as-is otherwise', () => { + expect(resolveTargetSlot({ slot: 'side' }, 'desktop')).toBe('side') + }) +}) + +describe('isModeAllowed', () => { + it('returns true when no mode restrictions', () => { + expect(isModeAllowed({}, 'view')).toBe(true) + }) + + it('rejects when mode not in includeModes', () => { + expect(isModeAllowed({ includeModes: ['edit'] }, 'view')).toBe(false) + }) + + it('rejects when mode in excludeModes', () => { + expect(isModeAllowed({ excludeModes: ['view'] }, 'view')).toBe(false) + }) + + it('allows when mode matches includeModes', () => { + expect(isModeAllowed({ includeModes: ['view'] }, 'view')).toBe(true) + }) +}) + +describe('isControlVisible', () => { + const base = { desktop: { slot: 'inset' } } + + it('returns true for valid control', () => { + expect(isControlVisible(base, { breakpoint: 'desktop', mode: 'view', isFullscreen: false })).toBe(true) + }) + + it('returns false when breakpoint config missing', () => { + expect(isControlVisible(base, { breakpoint: 'mobile', mode: 'view', isFullscreen: false })).toBe(false) + }) + + it('returns false when slot not allowed', () => { + expect(isControlVisible({ desktop: { slot: 'invalid' } }, { breakpoint: 'desktop', mode: 'view', isFullscreen: false })).toBe(false) + }) + + it('returns false when mode not allowed', () => { + expect(isControlVisible({ ...base, includeModes: ['edit'] }, { breakpoint: 'desktop', mode: 'view', isFullscreen: false })).toBe(false) + }) + + it('returns false when inline:false and not fullscreen', () => { + expect(isControlVisible({ ...base, inline: false }, { breakpoint: 'desktop', mode: 'view', isFullscreen: false })).toBe(false) + }) + + it('returns true when inline:false and fullscreen', () => { + expect(isControlVisible({ ...base, inline: false }, { breakpoint: 'desktop', mode: 'view', isFullscreen: true })).toBe(true) + }) +}) + +describe('isConsumerHtml', () => { + it('returns true for consumer HTML config', () => { + expect(isConsumerHtml({ html: '

Hi

' })).toBe(true) + }) + + it('returns false when pluginId present', () => { + expect(isConsumerHtml({ html: '

Hi

', pluginId: 'p1' })).toBe(false) + }) + + it('returns false when no html', () => { + expect(isConsumerHtml({ render: () => {} })).toBe(false) + }) +}) diff --git a/src/App/store/AppProvider.jsx b/src/App/store/AppProvider.jsx index 6ed0d2ec..0c9bf886 100755 --- a/src/App/store/AppProvider.jsx +++ b/src/App/store/AppProvider.jsx @@ -21,9 +21,12 @@ export const AppProvider = ({ options, children }) => { topRightColRef: useRef(null), insetRef: useRef(null), rightRef: useRef(null), + middleRef: useRef(null), + bottomRef: useRef(null), footerRef: useRef(null), actionsRef: useRef(null), bannerRef: useRef(null), + modalRef: useRef(null), viewportRef: useRef(null) } diff --git a/src/App/store/AppProvider.test.jsx b/src/App/store/AppProvider.test.jsx index ebdd5dda..54fc3d29 100755 --- a/src/App/store/AppProvider.test.jsx +++ b/src/App/store/AppProvider.test.jsx @@ -4,6 +4,7 @@ import { AppProvider, AppContext } from './AppProvider.jsx' import { createMockRegistries } from '../../test-utils.js' import * as mediaHook from '../hooks/useMediaQueryDispatch.js' import * as detectInterface from '../../utils/detectInterfaceType.js' +import * as appReducerModule from './appReducer.js' jest.mock('../hooks/useMediaQueryDispatch.js') jest.mock('../../utils/detectInterfaceType.js') @@ -111,4 +112,43 @@ describe('AppProvider', () => { expect(contextValue.layoutRefs).toHaveProperty('mainRef') expect(contextValue.layoutRefs).toHaveProperty('footerRef') }) + + test('dispatch fallback uses options.panelRegistry.getPanelConfig() when state.panelConfig missing', () => { + const getPanelConfigMock = jest.fn(() => ({ panel1: {} })) + + // Mock initialState to return state without panelConfig but with panelRegistry + jest.spyOn(appReducerModule, 'initialState').mockImplementation(() => ({ + mode: 'view', + previousMode: 'edit', + openPanels: {}, + previousOpenPanels: {}, + interfaceType: 'default', + isFullscreen: false, + hasExclusiveControl: false, + panelRegistry: { getPanelConfig: getPanelConfigMock } // <-- provide it here! + })) + + const mockEventBus = { on: jest.fn(), off: jest.fn() } + const mockBreakpointDetector = { subscribe: jest.fn(() => jest.fn()) } + const mockOptions = { + ...createMockRegistries({ panelConfig: undefined }), + eventBus: mockEventBus, + breakpointDetector: mockBreakpointDetector + } + + render( + +
Child
+
+ ) + + // Trigger a dispatch via eventBus to hit the dispatch wrapper + act(() => { + mockEventBus.on.mock.calls.forEach(([event, handler]) => { + if (event === 'app:setmode') handler('newMode') + }) + }) + + expect(getPanelConfigMock).toHaveBeenCalled() + }) }) diff --git a/src/App/store/appActionsMap.test.js b/src/App/store/appActionsMap.test.js index 695e51b1..e9058cae 100755 --- a/src/App/store/appActionsMap.test.js +++ b/src/App/store/appActionsMap.test.js @@ -204,6 +204,18 @@ describe('actionsMap full coverage', () => { expect(state.buttonRegistry.addButton).toHaveBeenCalled() }) + test('ADD_BUTTON sets hidden, disabled, and pressed state when config flags are true', () => { + const payload = { + id: 'btnSpecial', + config: { isHidden: true, isDisabled: true, isPressed: true } + } + const result = actionsMap.ADD_BUTTON(state, payload) + expect(result.buttonConfig.btnSpecial).toBeDefined() + expect(result.hiddenButtons.has('btnSpecial')).toBe(true) + expect(result.disabledButtons.has('btnSpecial')).toBe(true) + expect(result.pressedButtons.has('btnSpecial')).toBe(true) + }) + // ---------------------- FALLBACK / OPTIONAL BRANCHES ---------------------- test('SET_MODE uses panelRegistry.getPanelConfig() when panelConfig missing', () => { const tmp = { ...state, panelConfig: undefined } diff --git a/src/InteractiveMap/InteractiveMap.js b/src/InteractiveMap/InteractiveMap.js index 253e9a4a..4053439c 100755 --- a/src/InteractiveMap/InteractiveMap.js +++ b/src/InteractiveMap/InteractiveMap.js @@ -110,16 +110,31 @@ export default class InteractiveMap { } } + _removeMapParamFromUrl (href, key) { + const regex = new RegExp(`[?&]${key}=[^&]*(&|$)`) + + if (!regex.test(href)) { + return href + } + + return href + .replace(regex, (_, p1) => { + return p1 === '&' ? '?' : '' + }) + .replace(/\?$/, '') + } + _handleExitClick () { if (this.config.preserveStateOnClose) { this.hideApp() } else { this.removeApp() } - // Remove the map param from the URL using regex to prevent encoding + const key = this.config.mapViewParamKey const href = location.href - const newUrl = href.replace(new RegExp(`[?&]${key}=[^&]*(&|$)`), (_, p1) => (p1 === '&' ? '?' : '')).replace(/\?$/, '') + const newUrl = this._removeMapParamFromUrl(href, key) + history.replaceState(history.state, '', newUrl) } diff --git a/src/InteractiveMap/InteractiveMap.test.js b/src/InteractiveMap/InteractiveMap.test.js index 5411dcf4..a0180d4e 100755 --- a/src/InteractiveMap/InteractiveMap.test.js +++ b/src/InteractiveMap/InteractiveMap.test.js @@ -351,6 +351,33 @@ describe('InteractiveMap Core Functionality', () => { expect(mockButtonInstance.focus).toHaveBeenCalled() }) + it('hideApp restores document title when it contains a map prefix', () => { + document.title = 'Map View: Original Page Title' + + const map = new InteractiveMap('map', { + behaviour: 'buttonFirst', + mapProvider: mapProviderMock + }) + + map._openButton = mockButtonInstance + map.hideApp() + + expect(document.title).toBe('Original Page Title') + }) + + it('hideApp works when _openButton is null', () => { + const map = new InteractiveMap('map', { + behaviour: 'buttonFirst', + mapProvider: mapProviderMock + }) + + map._openButton = null + map.hideApp() + + expect(map._isHidden).toBe(true) + expect(map.rootEl.style.display).toBe('none') + }) + it('showApp sets _isHidden false and shows element', () => { const map = new InteractiveMap('map', { behaviour: 'buttonFirst', mapProvider: mapProviderMock }) map._isHidden = true @@ -364,6 +391,59 @@ describe('InteractiveMap Core Functionality', () => { expect(mockButtonInstance.style.display).toBe('none') expect(updateDOMState).toHaveBeenCalledWith(map) }) + + it('showApp works when _openButton is null', () => { + const map = new InteractiveMap('map', { + behaviour: 'buttonFirst', + mapProvider: mapProviderMock + }) + + map._isHidden = true + map._openButton = null + map.rootEl.style.display = 'none' + + map.showApp() + + expect(map._isHidden).toBe(false) + expect(map.rootEl.style.display).toBe('') + expect(updateDOMState).toHaveBeenCalledWith(map) + }) +}) + +describe('_removeMapParamFromUrl', () => { + let map + + beforeEach(() => { + document.body.innerHTML = '
' + map = new InteractiveMap('map', { + mapProvider: { load: jest.fn() }, + mapViewParamKey: 'mv' + }) + }) + + it('removes param when followed by another param (& branch)', () => { + const href = 'https://example.com/page?mv=map&foo=1' + const result = map._removeMapParamFromUrl(href, 'mv') + expect(result).toBe('https://example.com/page?foo=1') + }) + + it('removes param when it is the last param (end-of-string branch)', () => { + const href = 'https://example.com/page?foo=1&mv=map' + const result = map._removeMapParamFromUrl(href, 'mv') + expect(result).toBe('https://example.com/page?foo=1') + }) + + it('removes lone param and trailing ?', () => { + const href = 'https://example.com/page?mv=map' + const result = map._removeMapParamFromUrl(href, 'mv') + expect(result).toBe('https://example.com/page') + }) + + it('returns href unchanged when param does not exist (no-match branch)', () => { + const href = 'https://example.com/page?foo=1' + const result = map._removeMapParamFromUrl(href, 'mv') + expect(result).toBe(href) + }) }) describe('InteractiveMap Public API Methods', () => { @@ -420,4 +500,13 @@ describe('InteractiveMap Public API Methods', () => { expect(map.eventBus.emit).toHaveBeenCalledWith('app:showpanel', 'panel2') expect(map.eventBus.emit).toHaveBeenCalledWith('app:hidepanel', 'panel3') }) + + it('delegates toggleButtonState correctly', () => { + map.toggleButtonState('btn-1', 'disabled', true) + + expect(map.eventBus.emit).toHaveBeenCalledWith( + 'app:togglebuttonstate', + { id: 'btn-1', prop: 'disabled', value: true } + ) + }) }) diff --git a/src/InteractiveMap/behaviourController.test.js b/src/InteractiveMap/behaviourController.test.js index 033af75c..ced38b79 100755 --- a/src/InteractiveMap/behaviourController.test.js +++ b/src/InteractiveMap/behaviourController.test.js @@ -209,5 +209,21 @@ describe('setupBehavior', () => { expect(mockMapInstance.hideApp).toHaveBeenCalled() }) + + it('does nothing when should not load and map has no root', () => { + mockMapInstance._root = null + mockMapInstance._isHidden = false + + // Force shouldLoadComponent === false + window.matchMedia = jest.fn().mockImplementation(() => ({ matches: true })) + queryString.getQueryParam.mockReturnValue(null) + + handleChange() + + expect(mockMapInstance.hideApp).not.toHaveBeenCalled() + expect(mockMapInstance.loadApp).not.toHaveBeenCalled() + expect(mockMapInstance.showApp).not.toHaveBeenCalled() + expect(updateDOMState).not.toHaveBeenCalled() + }) }) }) diff --git a/src/config/appConfig.test.js b/src/config/appConfig.test.js index 0c821fb5..7132a836 100755 --- a/src/config/appConfig.test.js +++ b/src/config/appConfig.test.js @@ -1,75 +1,140 @@ import { render } from '@testing-library/react' -import { defaultAppConfig } from './appConfig' +import { defaultAppConfig, defaultButtonConfig, scaleFactor, markerSvgPaths } from './appConfig' describe('defaultAppConfig', () => { - const appState = { layoutRefs: { appContainerRef: { current: document.createElement('div') } }, isFullscreen: false } + const appState = { + layoutRefs: { appContainerRef: { current: document.createElement('div') } }, + isFullscreen: false, + interfaceType: 'mouse' + } + const buttons = defaultAppConfig.buttons const fullscreenBtn = buttons.find(b => b.id === 'fullscreen') const exitBtn = buttons.find(b => b.id === 'exit') + const zoomInBtn = buttons.find(b => b.id === 'zoomIn') + const zoomOutBtn = buttons.find(b => b.id === 'zoomOut') + // --- UI RENDER TESTS --- it('renders KeyboardHelp panel', () => { const panel = defaultAppConfig.panels.find(p => p.id === 'keyboardHelp') const { container } = render(panel.render()) expect(container.querySelector('.im-c-keyboard-help')).toBeInTheDocument() }) - it('evaluates dynamic button properties', () => { - // label - expect(typeof fullscreenBtn.label).toBe('function') - expect(fullscreenBtn.label({ appState, appConfig: defaultAppConfig })).toMatch(/fullscreen/) + // --- EXIT BUTTON (Line 27 Coverage) --- + it('covers all branches of exitBtn excludeWhen', () => { + const config = { hasExitButton: true, mapViewParamKey: 'view' } + + expect(exitBtn.excludeWhen({ + appConfig: { ...config, hasExitButton: false }, + appState: { isFullscreen: true } + })).toBe(true) + + window.history.pushState({}, '', '?view=map') + expect(exitBtn.excludeWhen({ + appConfig: config, + appState: { isFullscreen: false } + })).toBe(true) + + window.history.pushState({}, '', '?wrong=param') + expect(exitBtn.excludeWhen({ + appConfig: config, + appState: { isFullscreen: true } + })).toBe(true) + + window.history.pushState({}, '', '?view=map') + expect(exitBtn.excludeWhen({ + appConfig: config, + appState: { isFullscreen: true } + })).toBe(false) + }) + + it('calls exit button onClick correctly', () => { + const servicesMock = { closeApp: jest.fn() } + exitBtn.onClick({}, { services: servicesMock }) + expect(servicesMock.closeApp).toHaveBeenCalled() + }) + + // --- FULLSCREEN BUTTON (Line 39 Coverage) --- + it('evaluates fullscreen label and icon states', () => { + const containerMock = { requestFullscreen: jest.fn() } + Object.defineProperty(document, 'fullscreenElement', { value: null, writable: true, configurable: true }) - // iconId - expect(typeof fullscreenBtn.iconId).toBe('function') - expect(fullscreenBtn.iconId({ appState, appConfig: defaultAppConfig })).toBe('maximise') + expect(fullscreenBtn.label({ appState })).toBe('Enter fullscreen') + expect(fullscreenBtn.iconId({ appState })).toBe('maximise') - // excludeWhen - expect(exitBtn.excludeWhen({ appConfig: { hasExitButton: false } })).toBe(true) - expect(fullscreenBtn.excludeWhen({ appState, appConfig: { enableFullscreen: true } })).toBe(false) + Object.defineProperty(document, 'fullscreenElement', { value: containerMock, writable: true, configurable: true }) + expect(fullscreenBtn.label({ appState })).toBe('Exit fullscreen') + expect(fullscreenBtn.iconId({ appState })).toBe('minimise') + }) + + it('covers all branches of fullscreen excludeWhen', () => { + expect(fullscreenBtn.excludeWhen({ appConfig: { enableFullscreen: false }, appState: { isFullscreen: false } })).toBe(true) + expect(fullscreenBtn.excludeWhen({ appConfig: { enableFullscreen: true }, appState: { isFullscreen: true } })).toBe(true) + expect(fullscreenBtn.excludeWhen({ appConfig: { enableFullscreen: true }, appState: { isFullscreen: false } })).toBe(false) }) it('calls fullscreen onClick correctly', () => { const containerMock = { requestFullscreen: jest.fn() } - const appStateMock = { layoutRefs: { appContainerRef: { current: containerMock } }, isFullscreen: false } - - Object.defineProperty(document, 'fullscreenElement', { value: null, writable: true }) + const appStateMock = { layoutRefs: { appContainerRef: { current: containerMock } } } document.exitFullscreen = jest.fn() - // Enter fullscreen + Object.defineProperty(document, 'fullscreenElement', { value: null, writable: true, configurable: true }) fullscreenBtn.onClick({}, { appState: appStateMock }) expect(containerMock.requestFullscreen).toHaveBeenCalled() - expect(document.exitFullscreen).not.toHaveBeenCalled() - // Exit fullscreen - Object.defineProperty(document, 'fullscreenElement', { value: containerMock }) + Object.defineProperty(document, 'fullscreenElement', { value: containerMock, writable: true, configurable: true }) fullscreenBtn.onClick({}, { appState: appStateMock }) expect(document.exitFullscreen).toHaveBeenCalled() }) - it('evaluates fullscreen button label and iconId for both fullscreen states', () => { - const containerMock = { requestFullscreen: jest.fn() } - - // Not in fullscreen - Object.defineProperty(document, 'fullscreenElement', { value: null, writable: true }) - expect(fullscreenBtn.label({ appState, appConfig: defaultAppConfig })).toBe('Enter fullscreen') - expect(fullscreenBtn.iconId({ appState, appConfig: defaultAppConfig })).toBe('maximise') + // --- ZOOM BUTTONS (Line 60 & 70 Coverage) --- + it('covers all branches of zoom excludeWhen for BOTH buttons', () => { + // We run this test for both ZoomIn and ZoomOut to ensure + // identical lines in both button configs are covered. + [zoomInBtn, zoomOutBtn].forEach((btn) => { + // Branch A: First part of OR is true (!enableZoomControls) + expect(btn.excludeWhen({ + appConfig: { enableZoomControls: false }, + appState: { interfaceType: 'mouse' } + })).toBe(true) + + // Branch B: Second part of OR is true (interfaceType === 'touch') + expect(btn.excludeWhen({ + appConfig: { enableZoomControls: true }, + appState: { interfaceType: 'touch' } + })).toBe(true) + + // Branch C: Both parts are false (Result: false) + expect(btn.excludeWhen({ + appConfig: { enableZoomControls: true }, + appState: { interfaceType: 'mouse' } + })).toBe(false) + }) + }) - // In fullscreen - Object.defineProperty(document, 'fullscreenElement', { value: containerMock, writable: true }) - expect(fullscreenBtn.label({ appState, appConfig: defaultAppConfig })).toBe('Exit fullscreen') - expect(fullscreenBtn.iconId({ appState, appConfig: defaultAppConfig })).toBe('minimise') + it('evaluates zoom enableWhen logic', () => { + expect(zoomInBtn.enableWhen({ mapState: { isAtMaxZoom: true } })).toBe(false) + expect(zoomInBtn.enableWhen({ mapState: { isAtMaxZoom: false } })).toBe(true) + expect(zoomOutBtn.enableWhen({ mapState: { isAtMinZoom: true } })).toBe(false) + expect(zoomOutBtn.enableWhen({ mapState: { isAtMinZoom: false } })).toBe(true) }) - it('calls exit button onClick correctly', () => { - const fakeEvent = {} - const servicesMock = { closeApp: jest.fn() } - const handleExitClickMock = jest.fn() + it('triggers mapProvider zoom methods on click', () => { + const mapProviderMock = { zoomIn: jest.fn(), zoomOut: jest.fn() } + const appConfigMock = { zoomDelta: 2 } - exitBtn.onClick(fakeEvent, { services: servicesMock, appConfig: { handleExitClick: handleExitClickMock } }) + zoomInBtn.onClick({}, { mapProvider: mapProviderMock, appConfig: appConfigMock }) + expect(mapProviderMock.zoomIn).toHaveBeenCalledWith(2) - // Assert that the services.closeApp() is called - expect(servicesMock.closeApp).toHaveBeenCalled() + zoomOutBtn.onClick({}, { mapProvider: mapProviderMock, appConfig: appConfigMock }) + expect(mapProviderMock.zoomOut).toHaveBeenCalledWith(2) + }) - // If your button no longer calls handleExitClick in the current logic, this should NOT be called - expect(handleExitClickMock).not.toHaveBeenCalled() + // --- SUPPLEMENTARY CONFIGS --- + it('exports supplementary configs and constants', () => { + expect(defaultButtonConfig.label).toBe('Button') + expect(scaleFactor.large).toBe(2) + expect(markerSvgPaths[0].shape).toBe('pin') }) }) diff --git a/src/services/eventBus.test.js b/src/services/eventBus.test.js index cb88a3c0..b6d42eaa 100755 --- a/src/services/eventBus.test.js +++ b/src/services/eventBus.test.js @@ -1,5 +1,5 @@ // src/services/eventBus.test.js -import eventBus from './eventBus.js' +import eventBus, { createEventBus } from './eventBus.js' describe('EventBus singleton', () => { beforeEach(() => { @@ -93,3 +93,26 @@ describe('EventBus singleton', () => { expect(eventBus.events).toEqual({}) }) }) + +describe('createEventBus factory', () => { + /** + * Test to ensure coverage for the factory function (Line 50). + * Validates that createEventBus returns a fresh, working EventBus instance. + */ + it('creates a new, independent EventBus instance', () => { + const newBus = createEventBus() + const handler = jest.fn() + + // Verify it is an instance of the same logic + expect(newBus).toHaveProperty('on') + expect(newBus).toHaveProperty('emit') + + // Verify it is independent of the singleton + newBus.on('instanceTest', handler) + eventBus.emit('instanceTest', 'data') // Emit on singleton + expect(handler).not.toHaveBeenCalled() + + newBus.emit('instanceTest', 'data') // Emit on the new instance + expect(handler).toHaveBeenCalledWith('data') + }) +}) diff --git a/src/utils/detectBreakpoint.test.js b/src/utils/detectBreakpoint.test.js index 1feababf..8929dc72 100644 --- a/src/utils/detectBreakpoint.test.js +++ b/src/utils/detectBreakpoint.test.js @@ -12,7 +12,7 @@ let mockedQueries = {} class MockResizeObserver { constructor (callback) { this.callback = callback } - observe (el) { MockResizeObserver.instance = this } + observe () { MockResizeObserver.instance = this } disconnect () { MockResizeObserver.instance = null } trigger (width, fallback) { this.callback([fallback ? { contentRect: { width } } : { borderBoxSize: [{ inlineSize: width }], contentRect: { width } }]) @@ -35,7 +35,9 @@ const mockMatchMedia = (query) => { window.matchMedia = jest.fn(mockMatchMedia) const triggerMQ = (query, matches) => { - if (mockedQueries[query]) mockedQueries[query].matches = matches + if (mockedQueries[query]) { + mockedQueries[query].matches = matches + } mediaListeners[query]?.forEach(fn => fn({ matches })) } @@ -68,6 +70,46 @@ describe('detectBreakpoint', () => { detector.destroy() }) + /** + * Test to ensure coverage for the concurrency guard (Line 75). + * Validates that if multiple breakpoint changes occur before a RAF + * frame fires, only the most recent (current) state notifies listeners. + */ + it('does not notify listeners if the breakpoint changed again before RAF fired', () => { + const listener = jest.fn() + const detector = createBreakpointDetector(cfg) + detector.subscribe(listener) + + // 1. Initial flush to clear the "mount" notification + flushRAF() + listener.mockClear() + + // 2. Trigger 'mobile'. + // This sets lastBreakpoint = 'mobile' and queues RAF #1 (type: 'mobile') + triggerMQ('(max-width: 768px)', true) + + // 3. IMMEDIATELY switch to 'desktop' before flushing. + // IMPORTANT: We must turn mobile OFF so the detector sees 'desktop'. + triggerMQ('(max-width: 768px)', false) + triggerMQ('(min-width: 1024px)', true) + + // At this point: + // - lastBreakpoint is now 'desktop' + // - There are 3 callbacks in the RAF queue: + // - RAF #1 from the mobile trigger (type: 'mobile') + // - RAF #2 from the mobile=false trigger (type: 'tablet') + // - RAF #3 from the desktop trigger (type: 'desktop') + + flushRAF() + + // RAF #1: ('mobile' === 'desktop') is false -> skip listener + // RAF #2: ('tablet' === 'desktop') is false -> skip listener + // RAF #3: ('desktop' === 'desktop') is true -> call listener + expect(listener).toHaveBeenCalledTimes(1) + expect(listener).toHaveBeenCalledWith('desktop') + expect(detector.getBreakpoint()).toBe('desktop') + }) + describe('viewport mode', () => { it.each([ ['mobile', true, false], @@ -76,8 +118,12 @@ describe('detectBreakpoint', () => { ])('detects %s', (name, mobile, desktop) => { window.matchMedia.mockImplementation(q => { const mq = mockMatchMedia(q) - if (q === '(max-width: 768px)') mq.matches = mobile - if (q === '(min-width: 1024px)') mq.matches = desktop + if (q === '(max-width: 768px)') { + mq.matches = mobile + } + if (q === '(min-width: 1024px)') { + mq.matches = desktop + } return mq }) const detector = createBreakpointDetector(cfg) diff --git a/src/utils/getSafeZoneInset.test.js b/src/utils/getSafeZoneInset.test.js index 42908ee4..3705554e 100644 --- a/src/utils/getSafeZoneInset.test.js +++ b/src/utils/getSafeZoneInset.test.js @@ -68,4 +68,22 @@ describe('getSafeZoneInset', () => { expect(result.left).toBe(80) expect(result.right).toBe(80) }) + + /** + * Test to ensure coverage for the safety guardrail (Line 29). + * Validates that the function returns undefined if React refs are + * not yet attached to DOM elements. + */ + it('returns undefined if any ref.current is null (unattached)', () => { + const unattachedRefs = { + mainRef: { current: null }, + insetRef: { current: null }, + rightRef: { current: null }, + actionsRef: { current: null }, + footerRef: { current: null } + } + + const result = getSafeZoneInset(unattachedRefs) + expect(result).toBeUndefined() + }) })