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: '
+
+
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()
+ })
})