From b0b1474ef097f05ec22b4b1c0f6b608dcce0992e Mon Sep 17 00:00:00 2001 From: Carson Davis Date: Thu, 10 Sep 2026 16:34:38 -0500 Subject: [PATCH 1/2] Move AOI's analyze and cancel card onto the core popup service AOI asks core for its card with plain data through its own handle and acts on how the card closed: Analyze runs the analysis, Cancel, the X, Escape and an empty-map click clear the selection, and a card that code replaced or retracted leaves the selection alone. Starting a drawing parks the selection and takes the card down; cancelling before a vertex puts both back. The tool's own tooltip component and its styles are gone, and the centroid and bounds helpers handle lines and points. --- .../Tools/AOI/AOIComponent/AOIComponent.scss | 72 +-- src/essence/Tools/AOI/AOITool.js | 287 ++++++---- src/essence/Tools/AOI/AOITooltip.tsx | 42 -- src/essence/Tools/AOI/aoiHelpers.ts | 89 +-- tests/unit/AOITool.spec.js | 540 ++++++++++++++++++ tests/unit/aoiDrawKeys.spec.js | 69 ++- tests/unit/aoiSelectionCamera.spec.js | 186 ++++-- 7 files changed, 978 insertions(+), 307 deletions(-) delete mode 100644 src/essence/Tools/AOI/AOITooltip.tsx create mode 100644 tests/unit/AOITool.spec.js diff --git a/src/essence/Tools/AOI/AOIComponent/AOIComponent.scss b/src/essence/Tools/AOI/AOIComponent/AOIComponent.scss index 9d602deae..073f03944 100644 --- a/src/essence/Tools/AOI/AOIComponent/AOIComponent.scss +++ b/src/essence/Tools/AOI/AOIComponent/AOIComponent.scss @@ -1,4 +1,4 @@ -// AOI plugin — scoped under the .aoi-tool / .aoi-tooltip roots only. +// AOI plugin — scoped under the .aoi-tool root only. // // Every value resolves against the global design tokens exported by // src/styles/_theme-export.scss as :root --theme-* custom properties; the @@ -16,8 +16,7 @@ // picture — hover, focus, disabled — use the accent tint the rest of the map // controls use. -.aoi-tool, -.aoi-tooltip { +.aoi-tool { box-sizing: border-box; color: var(--theme-color-ink, #17171b); font-family: var(--theme-font-body, 'Public Sans', system-ui, sans-serif); @@ -25,8 +24,7 @@ background: var(--theme-color-white, #ffffff); } -.aoi-tool *, -.aoi-tooltip * { +.aoi-tool * { box-sizing: border-box; } @@ -35,8 +33,7 @@ // matched declaration beats one inherited from the list — however specific the // list's own selector is — so every list in the tool would render in that pale // gray. Restoring `inherit` hands the cascade back to the list. -.aoi-tool li, -.aoi-tooltip li { +.aoi-tool li { color: var(--theme-color-base-dark, #58585b); } @@ -44,8 +41,7 @@ // focusable control in the plugin restores one here. .aoi-tool button:focus-visible, .aoi-tool input:focus-visible, -.aoi-tool [tabindex]:focus-visible, -.aoi-tooltip button:focus-visible { +.aoi-tool [tabindex]:focus-visible { outline: var(--theme-border-width-md, 2px) solid var(--theme-color-primary, #1c67e3); outline-offset: -2px; } @@ -619,61 +615,3 @@ line-height: 1.6; } -.aoi-tooltip { - position: absolute; - transform: translate(-50%, calc(-100% - var(--theme-spacing-105, 12px))); - min-width: 220px; - padding: var(--theme-spacing-105, 12px); - border-radius: var(--theme-radius-sm, 2px); - box-shadow: 0 4px 8px var(--theme-color-shadow, rgba(0, 0, 0, 0.15)); - pointer-events: auto; - z-index: 1000; -} - -.aoi-tooltip__label { - margin: 0 0 var(--theme-spacing-1, 8px); - font-size: var(--theme-font-size-2xs, 14px); - font-weight: var(--theme-font-weight-semibold, 600); -} - -.aoi-tooltip__actions { - display: flex; - gap: var(--theme-spacing-1, 8px); -} - -.aoi-tooltip__primary, -.aoi-tooltip__secondary { - flex: 1 1 auto; - height: 32px; - border: 0; - border-radius: var(--theme-radius-sm, 2px); - cursor: pointer; - font-family: inherit; - font-size: var(--theme-font-size-2xs, 14px); - font-weight: var(--theme-font-weight-semibold, 600); - transition: background-color 0.12s ease, color 0.12s ease; -} - -.aoi-tooltip__primary { - background: var(--theme-color-primary, #1c67e3); - color: var(--theme-color-white, #ffffff); -} - -.aoi-tooltip__primary:hover:not(:disabled) { - background: var(--theme-color-primary-dark, #0b3d91); -} - -.aoi-tooltip__primary:disabled { - opacity: 0.5; - cursor: not-allowed; -} - -.aoi-tooltip__secondary { - background: var(--theme-color-base-lightest, #f6f6f6); - color: var(--theme-color-ink, #17171b); -} - -.aoi-tooltip__secondary:hover { - background: var(--theme-color-primary-lightest, #eaf0fd); - color: var(--theme-color-primary, #1c67e3); -} diff --git a/src/essence/Tools/AOI/AOITool.js b/src/essence/Tools/AOI/AOITool.js index f627313d5..cec11210a 100644 --- a/src/essence/Tools/AOI/AOITool.js +++ b/src/essence/Tools/AOI/AOITool.js @@ -1,7 +1,7 @@ /** * AOI plugin — MMGIS wrapper. * - * Pluggable contract (see specs/012-aoi-plugin/plan.md and PLUGIN-DEVELOPMENT-GUIDE.md): + * Pluggable contract: * * pluginId: 'aoi' — derived from this plugin's binding at build time. The * tool controller mints the plugin-scoped bus handle from it and injects it @@ -12,16 +12,15 @@ * - areaDrawn { feature, source: 'search'|'draw'|'upload'|'inspect' } * - analysisAOIReady { feature } — consumed by the FetchStats plugin * - drawingCleared {} - * - drawingCancelled {} * * Provides (auto-prefixed plugin:aoi:): - * - getCurrentSelection -> { feature, source } | null + * - getCurrentSelection -> { feature, source, label } | null * * Listens to: - * - tool:change (core) * - map:drawstart / drawvertex / * drawcomplete / drawcancel (engine bus) * - map:featureClick (inspect-mode boundary clicks, filtered by layerId) + * - map:moveend (one-shot, while a selection waits for the camera) * - plugin:fetchstats:analysisProgress { done, total } * - plugin:fetchstats:analysisReady { analysisData } * - plugin:fetchstats:analysisSkipped { reason } @@ -30,17 +29,16 @@ * - map:createLayer / map:removeLayer * - map:getBounds / map:fitBounds * - map:enableDrawing / map:disableDrawing / map:finishDrawing - * - map:addOverlay / map:removeOverlay + * - map:showPopup (resolves with how the popup closed) / map:hidePopup * - plugins:setState - - * AOIComponent.tsx and AOITooltip.tsx must stay MMGIS-agnostic. + * + * AOIComponent.tsx must stay MMGIS-agnostic. */ import React from 'react' import { createRoot } from 'react-dom/client' import AOIComponent from './AOIComponent' -import AOITooltip from './AOITooltip' import { mmgisSetPluginState } from '../_shared/adapters/mmgisAPI' import { buildSearchIndex, @@ -51,7 +49,7 @@ import { featureCentroid, featureBounds, selectionFitBounds, - selectionTooltipAnchor, + selectionPopupAnchor, } from './aoiHelpers' import { loadBoundaries } from './aoiBoundaryLoader' @@ -60,7 +58,6 @@ const DEFAULT_DRAW_SHAPES = ['polygon', 'rectangle', 'circle'] const VALID_DRAW_SHAPES = new Set(['point', 'linestring', 'polygon', 'rectangle', 'circle']) const SELECTION_LAYER_ID = 'aoi:selection' const INSPECT_BOUNDARIES_LAYER_ID = 'aoi:inspect-boundaries' -const TOOLTIP_OVERLAY_ID = 'aoi:tooltip' // ── Draw-session keys ────────────────────────────────────────────────────────── // Components with these roles handle Escape themselves — a dialog, menu, @@ -141,6 +138,11 @@ const AOITool = { _cleanups: [], _analysisErrorTimeout: null, _drawKeyHandler: null, + // Cancels the deferred popup show while the camera is still moving. + _pendingPopup: null, + // The selection a drawing session took the card away from, held until the + // session either replaces it or is backed out of. + _suspendedAOI: null, // ── Lifecycle ────────────────────────────────────────────────────────────── @@ -177,14 +179,14 @@ const AOITool = { this._setState({ searchLoading: false, searchDisabled: true }) }) - const api = window.mmgisAPI - if (api?.on) { + // Subscriptions are on the global bus; only requests need the handle. + const bus = window.mmgisAPI + if (bus?.on) { const subscribe = (event, handler) => { - const off = api.on(event, handler) + const off = bus.on(event, handler) this._cleanups.push(typeof off === 'function' ? off : () => { }) } - subscribe('tool:change', () => this._clearSelection()) - subscribe('map:drawstart', () => this._onDrawStart()) + subscribe('map:drawstart', (e) => this._onDrawStart(e)) subscribe('map:drawvertex', (e) => this._onDrawVertex(e)) subscribe('map:drawcomplete', (e) => this._onDrawComplete(e)) subscribe('map:drawcancel', () => this._onDrawCancelEvent()) @@ -228,13 +230,18 @@ const AOITool = { this._analysisErrorTimeout = null } + this._cancelPendingPopup() + // Nothing of the selection outlives the tool, so the cancel below has + // no card to put back. + this._suspendedAOI = null + // Fire-and-forget: cancel any active drawing session via the bus. this._removeDrawKeys() - window.mmgisAPI?.request?.('map:disableDrawing').catch(() => { }) + this.api?.request('map:disableDrawing').catch(() => { }) this._removeSelectionLayer() this._hideInspectBoundaries() - this._hideTooltip() + this._hidePopup() if (this._reactRoot) { this._reactRoot.unmount() @@ -363,7 +370,7 @@ const AOITool = { if (prev === 'draw') { // Cancel any in-flight drawing session when leaving Draw mode. // The bus handler is a no-op if no session is active. - window.mmgisAPI?.request?.('map:disableDrawing').catch(() => { }) + this.api?.request('map:disableDrawing').catch(() => { }) } this._setState({ mode: nextMode }) @@ -398,13 +405,31 @@ const AOITool = { _onDrawShapeChange(shape) { this._setState({ drawShape: shape, drawVerticesCount: 0 }) - window.mmgisAPI?.request?.('map:enableDrawing', { shape }) + // The engines end any live session without a cancel of its own before + // starting the new one, so a swap reaches the handlers below as a + // `drawstart` alone and the suspended selection stays suspended. + this.api?.request('map:enableDrawing', { shape }) .catch((err) => console.warn('[AOI] enableDrawing failed', err)) }, - _onDrawStart() { + /** + * Arming a session is not the same act as replacing the selection, so the + * selection stays until a vertex actually lands. Its card cannot: it would + * sit over the map for the whole session, taking the Escape and Enter the + * session needs and offering to analyze an area being replaced. + */ + _onDrawStart(e) { + this._cancelPendingPopup() + this._suspendedAOI = this._state.currentAOI + this._hidePopup() this._installDrawKeys() - this._setState({ isDrawing: true, drawVerticesCount: 0 }) + this._setState({ + isDrawing: true, + // The shape the engine actually started: its word on which session + // is live outranks the one the panel asked for. + drawShape: e?.shape ?? this._state.drawShape, + drawVerticesCount: 0, + }) }, /** @@ -429,9 +454,9 @@ const AOITool = { target?.closest?.(KEY_OWNING_ROLE_SELECTOR) ) return if (evt.key === 'Escape') { - window.mmgisAPI?.request?.('map:disableDrawing').catch(() => { }) + this.api?.request('map:disableDrawing').catch(() => { }) } else if (evt.key === 'Enter') { - window.mmgisAPI?.request?.('map:finishDrawing').catch(() => { }) + this.api?.request('map:finishDrawing').catch(() => { }) } } document.addEventListener('keydown', this._drawKeyHandler) @@ -444,6 +469,11 @@ const AOITool = { }, _onDrawVertex(e) { + // The first vertex is where the replacement begins, so this is where + // the previous selection goes: every shape reports its first committed + // vertex — a polygon's first click, a rectangle's first corner, a + // circle's centre, the point itself — before it can complete. + this._dropSuspendedSelection() const count = Array.isArray(e?.vertices) ? e.vertices.length : 0 this._setState({ drawVerticesCount: count }) }, @@ -452,7 +482,13 @@ const AOITool = { this._removeDrawKeys() this._setState({ isDrawing: false, drawShape: null, drawVerticesCount: 0 }) const feature = e?.feature - if (!feature) return + if (!feature) { + // Defence against a payload with no feature: there is nothing to + // select, so the previous selection is left as it was found. + this._restoreSuspendedSelection() + return + } + this._suspendedAOI = null const label = feature.properties?.shape ? `Drawn ${feature.properties.shape}` : 'Drawn area' @@ -462,6 +498,32 @@ const AOITool = { _onDrawCancelEvent() { this._removeDrawKeys() this._setState({ isDrawing: false, drawShape: null, drawVerticesCount: 0 }) + this._restoreSuspendedSelection() + }, + + /** Let go of the selection a session suspended: it is being replaced. */ + _dropSuspendedSelection() { + if (!this._suspendedAOI) return + this._suspendedAOI = null + this._clearSelection() + }, + + /** + * Put back the card of a selection a session suspended — only the card; the + * selection never left the map, and the camera may not be framing it. + */ + _restoreSuspendedSelection() { + const aoi = this._suspendedAOI + this._suspendedAOI = null + if (!aoi || this._state.currentAOI !== aoi) return + this.api?.request('map:getBounds') + .catch(() => null) + .then((view) => { + // The read is a hop: across it the selection can be replaced + // or cleared, and a fresh session can arm. + if (this._state.currentAOI !== aoi || this._state.isDrawing) return + this._showSelectionPopup(aoi.feature, aoi.label, view) + }) }, // ── Inspect mode ─────────────────────────────────────────────────────────── @@ -469,7 +531,7 @@ const AOITool = { _showInspectBoundaries() { const entries = this._state.searchAllEntries if (!entries.length) return - const api = window.mmgisAPI + const api = this.api if (!api?.request) return // Sort largest-area first so big polygons (e.g. "United States") render @@ -498,7 +560,7 @@ const AOITool = { }, _hideInspectBoundaries() { - window.mmgisAPI?.request?.('map:removeLayer', { id: INSPECT_BOUNDARIES_LAYER_ID }) + this.api?.request('map:removeLayer', { id: INSPECT_BOUNDARIES_LAYER_ID }) .catch(() => { }) }, @@ -569,10 +631,15 @@ const AOITool = { // ── Selection lifecycle ──────────────────────────────────────────────────── _applySelection(feature, source, label) { + this._cancelPendingPopup() this._removeSelectionLayer() - const api = window.mmgisAPI - api?.request?.('map:createLayer', { + // Requests go through AOI's handle, which stamps them with AOI's + // address; the bus events are subscribed on the global, which is where + // `on`/`off` live. + const api = this.api + const bus = window.mmgisAPI + api?.request('map:createLayer', { id: SELECTION_LAYER_ID, type: 'vector', geojson: { type: 'FeatureCollection', features: [feature] }, @@ -581,73 +648,83 @@ const AOITool = { }).catch((err) => console.warn('[AOI] failed to add selection layer', err)) this._state.currentAOI = { feature, source, label } - this.api?.emit('areaDrawn', { feature, source }) + api?.emit('areaDrawn', { feature, source }) - const c = featureCentroid(feature) - // `view` keeps the tooltip on-screen when the camera does not move; omit - // it once the camera has been fitted to the selection. - const showTooltip = (view) => { - if (c) { - this._showTooltip({ - label, - latlng: selectionTooltipAnchor({ lat: c[1], lng: c[0] }, view), - analyzeEnabled: true, - }) - } - } + const showPopup = (view) => this._showSelectionPopup(feature, label, view) const bbox = featureBounds(feature) - if (bbox && api?.request && api?.on && api?.off) { + if (bbox && api?.request && bus?.on && bus?.off) { + // Pending from here on, before the camera is even read: a teardown + // or a superseding selection during that async hop must drop this + // popup. `disarm` is filled in only if the show waits on the camera. + let disarm = null + const cancel = () => disarm?.() + this._pendingPopup = cancel + + // Pass a view to `showPopup` only when the camera never moved. Once + // fitBounds has framed the selection, its centroid is on-screen and + // needs no fallback anchor. + const settled = (unmovedView) => { + // Only the still-current show may fire: moveend, the fallback + // timer and a rejected fitBounds arbitrate to one popup. + if (this._pendingPopup !== cancel) return + this._cancelPendingPopup() + showPopup(unmovedView) + } + // Leave the camera alone unless the selection extends beyond the // current view; then fit its extent minimally (selectionFitBounds). api.request('map:getBounds') .catch(() => null) .then((view) => { + if (this._pendingPopup !== cancel) return const fit = selectionFitBounds(bbox, view) if (!fit) { - showTooltip(view) + // The camera stays put, so no moveend is coming: + // open the popup now. + settled(view) return } - // Defer the tooltip until the fitBounds animation settles so it - // mounts at the final centroid pixel instead of flickering through - // intermediate positions during the camera move. - let fallback - // Pass a view here only when the camera never moved. Once - // fitBounds has framed the selection, its centroid is - // on-screen and needs no fallback anchor. - const settle = (unmovedView) => { - api.off('map:moveend', oneShot) - clearTimeout(fallback) - showTooltip(unmovedView) + // Subscribe before the fit: `request` runs its provider + // synchronously, so a transitionless fit emits `moveend` + // inside the call. + const oneShot = () => settled() + const timer = setTimeout(oneShot, 1500) + bus.on('map:moveend', oneShot) + disarm = () => { + clearTimeout(timer) + bus.off('map:moveend', oneShot) } - // `map:moveend` hands its listener a view state - // ({ longitude, latitude, zoom }), not a ViewBounds. This - // wrapper drops that payload so `settle` is called with no - // view at all. - const oneShot = () => settle() - api.on('map:moveend', oneShot) - // Safety net: if no moveend fires (e.g. an engine that - // skips the event on a programmatic fit), show the tooltip - // after a short timeout anyway. - fallback = setTimeout(oneShot, 1500) api.request('map:fitBounds', fit).catch((err) => { console.warn('[AOI] fitBounds failed', err) - settle(view) + // The fit never happened, so the view read above is + // still the one on screen: anchor against it. + settled(view) }) }) - .catch((err) => + .catch((err) => { console.warn('[AOI] selection camera step failed', err) - ) + // Nothing can open this popup any more, so release the + // pending slot — but only while it is still this chain's; + // a superseding selection owns its own show. + if (this._pendingPopup === cancel) this._cancelPendingPopup() + }) } else { - showTooltip() + showPopup() } }, + /** Drop a popup that is still waiting for the camera to settle. */ + _cancelPendingPopup() { + if (!this._pendingPopup) return + this._pendingPopup() + this._pendingPopup = null + }, + _clearSelection() { if (!this._state.currentAOI) return this._removeSelectionLayer() - this._hideTooltip() this._state.currentAOI = null this.api?.emit('drawingCleared', {}) this._render() @@ -655,55 +732,61 @@ const AOITool = { _removeSelectionLayer() { // removeLayer is idempotent; no need for a hasLayer pre-check. - window.mmgisAPI?.request?.('map:removeLayer', { id: SELECTION_LAYER_ID }) + this.api?.request('map:removeLayer', { id: SELECTION_LAYER_ID }) .catch(() => { }) }, - // ── Tooltip overlay ──────────────────────────────────────────────────────── + // ── Popup ────────────────────────────────────────────────────────────────── /** - * Show the analyze/cancel tooltip anchored to a feature centroid. - * Core's `map:addOverlay` owns the DOM and repositions on view change. + * Show the card at the feature centroid — or, when `view` is given and the + * centroid is off it, at the view's centre (see {@link selectionPopupAnchor}). */ - _showTooltip({ label, latlng, analyzeEnabled }) { - const api = window.mmgisAPI - if (!api?.request) return - api.request('map:addOverlay', { - id: TOOLTIP_OVERLAY_ID, - latlng, - mount: (node) => { - const tooltipRoot = createRoot(node) - tooltipRoot.render( - React.createElement(AOITooltip, { - label, - position: { x: 0, y: 0 }, - analyzeEnabled, - onAnalyze: () => this._onAnalyze(), - onCancel: () => this._onCancel(), - }) - ) - return () => tooltipRoot.unmount() - }, - }).catch((err) => console.warn('[AOI] addOverlay failed', err)) + _showSelectionPopup(feature, label, view) { + const c = featureCentroid(feature) + if (!c) return + this._showPopup(label, selectionPopupAnchor({ lat: c[1], lng: c[0] }, view)) }, - _hideTooltip() { - window.mmgisAPI?.request?.('map:removeOverlay', { id: TOOLTIP_OVERLAY_ID }) - .catch(() => { }) + /** Core owns the card; the request is data only and answers with how it closed. */ + _showPopup(label, latlng) { + this.api + ?.request('map:showPopup', { + latlng, + title: label, + secondaryAction: { label: 'Cancel' }, + primaryAction: { label: 'Analyze area' }, + }) + // Two-arg `then`, so the rejection handler covers the request only + // and a throw out of the outcome branches is not reported as a + // failure to show the popup. + .then( + ({ action } = {}) => { + // Dismissals abandon the selection; 'closed' means AOI or + // core took the card away. + if (action === 'primary') this._onAnalyze() + else if (action === 'secondary' || action === 'dismiss') { + this._clearSelection() + } + }, + (err) => console.warn('[AOI] showPopup failed', err) + ) + }, + + _hidePopup() { + this.api?.request('map:hidePopup').catch(() => { }) }, // ── Analysis hand-off ────────────────────────────────────────────────────── + /** + * The popup's result carries no data, so the feature is attached here: + * `analysisAOIReady` is what reaches the FetchStats and Chart plugins. + */ _onAnalyze() { const aoi = this._state.currentAOI if (!aoi) return this.api?.emit('analysisAOIReady', { feature: aoi.feature }) - this._hideTooltip() - }, - - _onCancel() { - this.api?.emit('drawingCancelled', {}) - this._clearSelection() }, } diff --git a/src/essence/Tools/AOI/AOITooltip.tsx b/src/essence/Tools/AOI/AOITooltip.tsx deleted file mode 100644 index 5c2fa8150..000000000 --- a/src/essence/Tools/AOI/AOITooltip.tsx +++ /dev/null @@ -1,42 +0,0 @@ -import React from 'react' -import './AOIComponent/AOIComponent.scss' - -export interface AOITooltipProps { - label: string - position: { x: number; y: number } - analyzeEnabled: boolean - onAnalyze: () => void - onCancel: () => void -} - -export function AOITooltip(props: AOITooltipProps) { - return ( -
-

{props.label}

-
- - -
-
- ) -} - -export default AOITooltip diff --git a/src/essence/Tools/AOI/aoiHelpers.ts b/src/essence/Tools/AOI/aoiHelpers.ts index d50852703..0870b3c87 100644 --- a/src/essence/Tools/AOI/aoiHelpers.ts +++ b/src/essence/Tools/AOI/aoiHelpers.ts @@ -197,53 +197,64 @@ function textOf(el: Element, tag: string): string | null { return node?.textContent?.trim() || null } -export function featureCentroid(f: Feature): [number, number] | null { - const g = f.geometry +/** + * A geometry as a list of vertex lists — rings for the polygons, the line + * itself for the linestrings, a one-vertex list for a point. The centroid and + * the bounds both read a selection through this, so the two agree on which + * geometries a selection can be made of. + */ +function vertexParts(g: Geometry | null | undefined): number[][][] | null { if (!g) return null - if (g.type === 'Point') { - const c = g.coordinates as [number, number] - return [c[0], c[1]] - } - if (g.type === 'Polygon' || g.type === 'MultiPolygon') { - const rings: number[][][] = - g.type === 'Polygon' - ? (g.coordinates as number[][][]) - : ((g.coordinates as number[][][][]).flat() as number[][][]) - let sx = 0 - let sy = 0 - let n = 0 - for (const ring of rings) { - const last = ring.length - 1 - const stopAt = - ring.length > 1 && - ring[0][0] === ring[last][0] && - ring[0][1] === ring[last][1] - ? last - : ring.length - for (let i = 0; i < stopAt; i++) { - sx += ring[i][0] - sy += ring[i][1] - n++ - } + if (g.type === 'Point') return [[g.coordinates as number[]]] + if (g.type === 'Polygon') return g.coordinates as number[][][] + if (g.type === 'MultiPolygon') + return (g.coordinates as number[][][][]).flat() as number[][][] + if (g.type === 'LineString') return [g.coordinates as number[][]] + if (g.type === 'MultiLineString') return g.coordinates as number[][][] + return null +} + +/** The unweighted mean of a selection's vertices. */ +export function featureCentroid(f: Feature): [number, number] | null { + const parts = vertexParts(f.geometry) + if (!parts) return null + + let sx = 0 + let sy = 0 + let n = 0 + for (const part of parts) { + // A closed ring repeats its first vertex as its last; count it once. + const last = part.length - 1 + const stopAt = + part.length > 1 && + part[0][0] === part[last][0] && + part[0][1] === part[last][1] + ? last + : part.length + for (let i = 0; i < stopAt; i++) { + sx += part[i][0] + sy += part[i][1] + n++ } - return n > 0 ? [sx / n, sy / n] : null } - return null + return n > 0 ? [sx / n, sy / n] : null } +/** + * The west/south/east/north envelope of a selection's vertices. A point, and a + * line with no width or no height, give a degenerate box — too thin for + * {@link selectionFitBounds} to frame, which leaves the camera where it is and + * the popup anchored against the view it can still see. + */ export function featureBounds(f: Feature): [number, number, number, number] | null { - const g = f.geometry - if (!g || (g.type !== 'Polygon' && g.type !== 'MultiPolygon')) return null - const rings: number[][][] = - g.type === 'Polygon' - ? (g.coordinates as number[][][]) - : ((g.coordinates as number[][][][]).flat() as number[][][]) + const parts = vertexParts(f.geometry) + if (!parts) return null let w = Infinity let s = Infinity let e = -Infinity let n = -Infinity - for (const ring of rings) { - for (const [x, y] of ring) { + for (const part of parts) { + for (const [x, y] of part) { if (x < w) w = x if (y < s) s = y if (x > e) e = x @@ -347,7 +358,7 @@ export function selectionFitBounds( } /** - * Pick where to anchor the selection tooltip. The tooltip holds the only + * Pick where to anchor the selection's popup card. The card holds the only * Analyze and Cancel buttons a selection has, so mounting it off-screen * strands the selection. * @@ -360,7 +371,7 @@ export function selectionFitBounds( * Pass no view once the camera has been fitted to the selection — the centroid * is on-screen by then. */ -export function selectionTooltipAnchor( +export function selectionPopupAnchor( centroid: { lat: number; lng: number }, view?: ViewBounds | null ): { lat: number; lng: number } { diff --git a/tests/unit/AOITool.spec.js b/tests/unit/AOITool.spec.js new file mode 100644 index 000000000..9610fde92 --- /dev/null +++ b/tests/unit/AOITool.spec.js @@ -0,0 +1,540 @@ +import { describe, test, expect, vi, beforeEach, afterEach } from 'vitest' + +// The panel component pulls in @trussworks/react-uswds and a SCSS entry point; +// nothing here renders it. +vi.mock('../../src/essence/Tools/AOI/AOIComponent', () => ({ default: () => null })) +vi.mock('react-dom/client', () => ({ + createRoot: () => ({ render() { }, unmount() { } }), +})) + +import AOITool from '../../src/essence/Tools/AOI/AOITool' + +const polygon = (ring) => ({ + type: 'Feature', + properties: {}, + geometry: { type: 'Polygon', coordinates: [ring] }, +}) + +// Centroid (5, 5), bounds [0, 0, 10, 10]. +const SQUARE = polygon([[0, 0], [0, 10], [10, 10], [10, 0], [0, 0]]) +// Centroid (25, 25), so a superseding selection is distinguishable. +const FAR_SQUARE = polygon([[20, 20], [20, 30], [30, 30], [30, 20], [20, 20]]) + +// The view `map:getBounds` reports. It holds neither square, so every +// selection here overflows it and the popup waits on a camera move — the +// path this file is about. +const VIEW = { + southWest: { lat: -40, lng: -40 }, + northEast: { lat: -30, lng: -30 }, +} + +/** + * Stand-ins for the global bus the plugin subscribes on (`window.mmgisAPI`) and + * the handle the controller injects as `AOITool.api`. Requests and emits are + * recorded; the popup impls model core's one-slot contract, every show being + * answered on its own promise with how its popup closed. + */ +function makeFakeApi() { + const listeners = new Map() + const requests = [] + const emits = [] + const provided = new Map() + const requestImpl = new Map() + + let openPopup = null + const settleOpen = (action) => { + if (!openPopup) return + const { resolve } = openPopup + openPopup = null + resolve({ action }) + } + + const api = { + on(event, handler) { + if (!listeners.has(event)) listeners.set(event, new Set()) + listeners.get(event).add(handler) + return () => api.off(event, handler) + }, + off(event, handler) { + const set = listeners.get(event) + if (set) set.delete(handler) + }, + emit(event, data) { + emits.push({ event, data }) + // Snapshot: a handler may unsubscribe itself while dispatching. + Array.from(listeners.get(event) || []).forEach((h) => h(data)) + }, + // The provider runs inside the call, before the promise is handed + // back, as core's does. It is handed the caller's address the way core + // hands it over, in a context argument; a bare bus request has none. + request(name, payload, options) { + const caller = options?.caller ?? null + requests.push({ name, payload, caller }) + const impl = requestImpl.get(name) + try { + return Promise.resolve(impl ? impl(payload, { caller }) : true) + } catch (err) { + return Promise.reject(err) + } + }, + provide(name, handler) { + provided.set(name, handler) + return () => provided.delete(name) + }, + + // A plugin's bus handle, as `mintHandle` builds it: emits and provides + // are prefixed with the plugin's address, requests keep their full name + // and are stamped with it. + handleFor(address) { + const prefix = `plugin:${address}:` + return { + address, + on: (event, handler) => api.on(event, handler), + emit: (event, data) => api.emit(prefix + event, data), + provide: (name, handler) => api.provide(prefix + name, handler), + request: (name, data) => api.request(name, data, { caller: address }), + release: () => { }, + } + }, + + // Test-only accessors. + requestImpl, + listenerCount: (event) => listeners.get(event)?.size || 0, + namesOf: (name) => requests.filter((r) => r.name === name), + emitsOf: (event) => emits.filter((e) => e.event === event), + getSelection: () => provided.get('plugin:aoi:getCurrentSelection')?.(), + /** Close the open popup the way core would, answering its request. */ + closePopup: (action) => settleOpen(action), + hasOpenPopup: () => openPopup !== null, + reset() { + requests.length = 0 + emits.length = 0 + }, + } + + requestImpl.set('map:getBounds', () => VIEW) + // Showing takes the slot and records whose popup it now is. + requestImpl.set('map:showPopup', (payload, { caller }) => { + settleOpen('closed') + return new Promise((resolve) => { + openPopup = { payload, resolve, owner: caller } + }) + }) + // Hiding reaches only the caller's own popup, as core's does. + requestImpl.set('map:hidePopup', (payload, { caller }) => { + if (!openPopup || openPopup.owner !== caller) return false + settleOpen('closed') + return true + }) + + return api +} + +let api + +/** + * Let queued microtasks (bus request promises) run. A selection chains several + * of them — reading the camera, then deciding the fit — so one tick is not + * enough to reach the state a test is about to assert on. + */ +const flush = async () => { + for (let i = 0; i < 5; i++) await vi.advanceTimersByTimeAsync(0) +} + +/** Make a selection and let its deferred popup open. */ +async function selectAndOpen(feature, label) { + AOITool._applySelection(feature, 'search', label) + // The camera is read before the popup is armed to wait on `map:moveend`. + await flush() + api.emit('map:moveend') + await flush() +} + +beforeEach(async () => { + vi.useFakeTimers() + const container = document.createElement('div') + container.id = 'toolPanel' + document.body.appendChild(container) + + api = makeFakeApi() + window.mmgisAPI = api + // The controller mints this handle and injects it before make() runs. + AOITool.api = api.handleFor('aoi') + + AOITool.make('toolPanel') + await flush() + api.reset() +}) + +afterEach(() => { + AOITool.destroy() + const container = document.getElementById('toolPanel') + if (container) container.remove() + delete AOITool.api + delete window.mmgisAPI + vi.useRealTimers() +}) + +describe('AOITool popup requests', () => { + test('asks core for the analyze/cancel popup at the feature centroid, once the camera settles', async () => { + // A label holding markup goes to core as it was written: core renders + // a title as text, so escaping one here would put the escapes + // themselves on the card. + AOITool._applySelection(SQUARE, 'search', 'Smith & Sons') + await flush() + expect(api.namesOf('map:showPopup')).toHaveLength(0) + + api.emit('map:moveend') + + const shows = api.namesOf('map:showPopup') + expect(shows).toHaveLength(1) + const payload = shows[0].payload + expect(payload.latlng).toEqual({ lat: 5, lng: 5 }) + expect(payload.title).toBe('Smith & Sons') + // Labels only: the outcome comes back on the request's promise, so the + // plugin names no events for core to broadcast — and no body at all, + // the card being the title over its two buttons. + expect(payload.primaryAction).toEqual({ label: 'Analyze area' }) + expect(payload.secondaryAction).toEqual({ label: 'Cancel' }) + expect('html' in payload).toBe(false) + + // The request must survive a postMessage boundary: data only, no + // functions crossing into core. + expect(JSON.parse(JSON.stringify(payload))).toEqual(payload) + }) + + test('lets the next selection replace the open card, keeping the new selection', async () => { + await selectAndOpen(SQUARE, 'Alabama') + expect(api.hasOpenPopup()).toBe(true) + api.reset() + + // The replacement answers the first request with 'closed', which must + // not be read as the user abandoning the selection just made. Nothing + // is retracted on the way: only a click on empty map dismisses a card, + // and the click that picks a boundary is a click on a feature. + await selectAndOpen(FAR_SQUARE, 'Alaska') + + expect(api.namesOf('map:hidePopup')).toHaveLength(0) + const shows = api.namesOf('map:showPopup') + expect(shows).toHaveLength(1) + expect(shows[0].payload.title).toBe('Alaska') + expect(api.emitsOf('plugin:aoi:drawingCleared')).toHaveLength(0) + expect(api.getSelection()).toMatchObject({ feature: FAR_SQUARE }) + }) + + test('catches the moveend a transitionless fit emits inside its own request', async () => { + // An engine with no transition to run ends the camera move inside the + // `map:fitBounds` call itself, so the plugin has to be listening + // before it asks for the fit — a listener added afterwards hears + // nothing and leaves the card to the 1.5s fallback timer. + api.requestImpl.set('map:fitBounds', () => { + api.emit('map:moveend', { longitude: 5, latitude: 5, zoom: 4 }) + return true + }) + + AOITool._applySelection(SQUARE, 'search', 'Alabama') + // `flush` advances the clock by nothing, so the fallback timer cannot + // be what opened this. + await flush() + + const shows = api.namesOf('map:showPopup') + expect(shows).toHaveLength(1) + expect(shows[0].payload.latlng).toEqual({ lat: 5, lng: 5 }) + expect(api.listenerCount('map:moveend')).toBe(0) + }) +}) + +describe('AOITool popup outcomes', () => { + test('a primary press hands the selected feature to the analysis consumers', async () => { + await selectAndOpen(SQUARE, 'Alabama') + api.reset() + + api.closePopup('primary') + await flush() + + const ready = api.emitsOf('plugin:aoi:analysisAOIReady') + expect(ready).toHaveLength(1) + expect(ready[0].data).toEqual({ feature: SQUARE }) + // Analyzing keeps the selection; only cancelling clears it. + expect(api.getSelection()).toMatchObject({ feature: SQUARE, source: 'search' }) + }) + + test.each(['secondary', 'dismiss'])( + 'a %s close clears the selection and its highlight', + async (action) => { + await selectAndOpen(SQUARE, 'Alabama') + api.reset() + + api.closePopup(action) + await flush() + + expect(api.namesOf('map:removeLayer').map((r) => r.payload)).toContainEqual({ + id: 'aoi:selection', + }) + expect(api.emitsOf('plugin:aoi:drawingCleared')).toHaveLength(1) + expect(api.getSelection()).toBeNull() + } + ) + + test('a popup that closed on its own leaves the selection alone', async () => { + await selectAndOpen(SQUARE, 'Alabama') + api.reset() + + api.closePopup('closed') + await flush() + + expect(api.emitsOf('plugin:aoi:drawingCleared')).toHaveLength(0) + expect(api.emitsOf('plugin:aoi:analysisAOIReady')).toHaveLength(0) + expect(api.getSelection()).toMatchObject({ feature: SQUARE }) + }) + + test('a rejected popup request is reported and keeps the selection', async () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => { }) + api.requestImpl.set('map:showPopup', () => { + throw new Error('invalid request') + }) + + AOITool._applySelection(SQUARE, 'search', 'Alabama') + await flush() + api.emit('map:moveend') + await flush() + + // Named, because a selection warns from four other places: any of + // them would satisfy a bare "warned about something". + expect(warn).toHaveBeenCalledWith('[AOI] showPopup failed', expect.any(Error)) + expect(api.getSelection()).toMatchObject({ feature: SQUARE }) + }) +}) + +describe('AOITool popup lifecycle', () => { + // Closing the tool and unloading it both reach the plugin through + // `destroy()`, and that is the whole of the teardown contract: nothing of + // the selection outlives the tool. Hiding the tool does not reach it — the + // instance is kept and the card stays up. + test('destroy clears the selection, its highlight and the popup', async () => { + await selectAndOpen(SQUARE, 'Alabama') + expect(api.hasOpenPopup()).toBe(true) + api.reset() + + AOITool.destroy() + await flush() + + expect(api.namesOf('map:hidePopup')).toHaveLength(1) + expect(api.hasOpenPopup()).toBe(false) + expect( + api.namesOf('map:removeLayer').map((r) => r.payload.id) + ).toContain('aoi:selection') + expect(AOITool._state.currentAOI).toBeNull() + expect(api.listenerCount('map:featureClick')).toBe(0) + }) + + test('destroy disarms a show already waiting on the camera', async () => { + AOITool._applySelection(SQUARE, 'search', 'Alabama') + // Far enough in that the show is armed: the camera has been read and + // the fit asked for, so a `map:moveend` listener and the fallback timer + // are both standing. + await flush() + expect(api.listenerCount('map:moveend')).toBe(1) + api.reset() + + AOITool.destroy() + + expect(api.listenerCount('map:moveend')).toBe(0) + api.emit('map:moveend') + await vi.advanceTimersByTimeAsync(2000) + expect(api.namesOf('map:showPopup')).toHaveLength(0) + }) + + test('a superseding selection disarms the show already waiting on the camera', async () => { + AOITool._applySelection(SQUARE, 'search', 'Alabama') + await flush() + expect(api.listenerCount('map:moveend')).toBe(1) + + AOITool._applySelection(FAR_SQUARE, 'search', 'Alaska') + await flush() + // The superseded show let go of its listener and its timer; only the + // current one is armed. + expect(api.listenerCount('map:moveend')).toBe(1) + + api.emit('map:moveend') + await vi.advanceTimersByTimeAsync(2000) + + const shows = api.namesOf('map:showPopup') + expect(shows).toHaveLength(1) + expect(shows[0].payload.title).toBe('Alaska') + }) + + test('a camera step that fails leaves nothing pending', async () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => { }) + // A view missing a corner: deciding the fit throws on it, so the chain + // ends after the show was already marked pending and before anything + // was armed to settle it. + api.requestImpl.set('map:getBounds', () => ({ + northEast: { lat: -30, lng: -30 }, + })) + + AOITool._applySelection(SQUARE, 'search', 'Alabama') + await flush() + + expect(warn).toHaveBeenCalled() + // No popup can open from here, so claiming one is pending would be a + // lie — and would suppress the next selection's show. + expect(AOITool._pendingPopup).toBeNull() + api.emit('map:moveend') + await vi.advanceTimersByTimeAsync(2000) + expect(api.namesOf('map:showPopup')).toHaveLength(0) + }) + +}) + +// Picking a shape arms a session; it does not choose an area. What these pin +// is where along a session the previous selection is actually given up, and +// what the user is left with when the session ends without a drawing. +describe('AOITool drawing sessions', () => { + const VERTEX = { shape: 'polygon', vertices: [{ lat: 1, lng: 1 }] } + + test('arming a session retracts the card and keeps the selection', async () => { + await selectAndOpen(SQUARE, 'Alabama') + expect(api.hasOpenPopup()).toBe(true) + api.reset() + + api.emit('map:drawstart', { shape: 'polygon' }) + await flush() + + // The card cannot stay: it would sit over the map for the whole + // session, holding the Escape and Enter the session needs and offering + // to analyze the area being replaced. + expect(api.hasOpenPopup()).toBe(false) + expect(api.namesOf('map:hidePopup')).toHaveLength(1) + // The selection can, and must — nothing has replaced it yet, and a + // selection dropped here has no undo. + expect(api.getSelection()).toMatchObject({ feature: SQUARE }) + expect(api.emitsOf('plugin:aoi:drawingCleared')).toHaveLength(0) + }) + + test('backing out before any vertex puts the card back', async () => { + await selectAndOpen(SQUARE, 'Alabama') + // The camera was fitted to the selection, so its centroid is in view + // and the card goes back on it. + api.requestImpl.set('map:getBounds', () => ({ + southWest: { lat: 0, lng: 0 }, + northEast: { lat: 10, lng: 10 }, + })) + api.emit('map:drawstart', { shape: 'polygon' }) + await flush() + api.reset() + + api.emit('map:drawcancel', { shape: 'polygon' }) + await flush() + + const shows = api.namesOf('map:showPopup') + expect(shows).toHaveLength(1) + expect(shows[0].payload.title).toBe('Alabama') + expect(shows[0].payload.latlng).toEqual({ lat: 5, lng: 5 }) + expect(api.hasOpenPopup()).toBe(true) + expect(api.getSelection()).toMatchObject({ feature: SQUARE }) + }) + + test('re-arming across the camera read leaves the card down', async () => { + await selectAndOpen(SQUARE, 'Alabama') + api.emit('map:drawstart', { shape: 'polygon' }) + await flush() + api.reset() + + // Backing out reads the camera before putting the card back, and the + // user picks another shape across that hop. The card belongs to the + // selection the new session is about to replace, so it stays down. + api.emit('map:drawcancel', { shape: 'polygon' }) + api.emit('map:drawstart', { shape: 'rectangle' }) + await flush() + + expect(api.namesOf('map:showPopup')).toHaveLength(0) + expect(api.hasOpenPopup()).toBe(false) + expect(api.getSelection()).toMatchObject({ feature: SQUARE }) + }) + + test('the first vertex is where the previous selection goes', async () => { + await selectAndOpen(SQUARE, 'Alabama') + api.emit('map:drawstart', { shape: 'polygon' }) + await flush() + api.reset() + + api.emit('map:drawvertex', VERTEX) + await flush() + + expect(api.getSelection()).toBeNull() + expect(api.emitsOf('plugin:aoi:drawingCleared')).toHaveLength(1) + expect( + api.namesOf('map:removeLayer').map((r) => r.payload.id) + ).toContain('aoi:selection') + + // Backing out from here has nothing left to put back. + api.reset() + api.emit('map:drawcancel', { shape: 'polygon' }) + await flush() + expect(api.namesOf('map:showPopup')).toHaveLength(0) + }) + + test('a finished drawing gets its own card', async () => { + await selectAndOpen(SQUARE, 'Alabama') + api.emit('map:drawstart', { shape: 'polygon' }) + api.emit('map:drawvertex', VERTEX) + await flush() + api.reset() + + api.emit('map:drawcomplete', { feature: FAR_SQUARE }) + await flush() + api.emit('map:moveend') + await flush() + + const shows = api.namesOf('map:showPopup') + expect(shows).toHaveLength(1) + expect(shows[0].payload.title).toBe('Drawn area') + expect(shows[0].payload.latlng).toEqual({ lat: 25, lng: 25 }) + expect(api.getSelection()).toMatchObject({ feature: FAR_SQUARE, source: 'draw' }) + }) + + // Both engines end the live session inside `enableDrawing` before starting + // the new one, and end it without a cancel of its own, so a shape switch + // reaches the plugin as a `drawstart` alone. + test('switching shape mid-session keeps the session and its new shape', async () => { + api.requestImpl.set('map:enableDrawing', ({ shape }) => { + api.emit('map:drawstart', { shape }) + return true + }) + await selectAndOpen(SQUARE, 'Alabama') + + AOITool._onDrawShapeChange('polygon') + await flush() + expect(AOITool._state).toMatchObject({ isDrawing: true, drawShape: 'polygon' }) + api.reset() + + AOITool._onDrawShapeChange('rectangle') + await flush() + + // A live session on the new shape: the panel needs both, or it falls + // back to the shape picker while a rectangle session is running. + expect(AOITool._state).toMatchObject({ isDrawing: true, drawShape: 'rectangle' }) + // Nothing was backed out of, so the suspended card stays suspended + // instead of coming back only to be retracted again. + expect(api.namesOf('map:showPopup')).toHaveLength(0) + expect(api.getSelection()).toMatchObject({ feature: SQUARE }) + }) +}) + +// One popup slot, and core decides whose popup a `map:hidePopup` reaches. AOI +// asks whenever it is done with a popup and lets core sort out whether there +// is one of its own to close — so what these pin is the outcome, not whether +// AOI worked out for itself that it should stay quiet. +describe('AOITool popup ownership', () => { + // The stamp is the whole mechanism, and it comes from asking through the + // injected handle: a popup opened on the bare bus carries no address, and + // would be one AOI could never retract. + test('asks for its popup through its own handle', async () => { + await selectAndOpen(SQUARE, 'Alabama') + AOITool.destroy() + + expect(api.namesOf('map:showPopup')[0].caller).toBe('aoi') + expect(api.namesOf('map:hidePopup')[0].caller).toBe('aoi') + }) +}) diff --git a/tests/unit/aoiDrawKeys.spec.js b/tests/unit/aoiDrawKeys.spec.js index 184ed9565..0905819c5 100644 --- a/tests/unit/aoiDrawKeys.spec.js +++ b/tests/unit/aoiDrawKeys.spec.js @@ -28,22 +28,50 @@ function appendTo(body, tag, attributes = {}) { return el } +/** + * Start a session the way `map:drawstart` does, and drop the bookkeeping that + * comes with it — starting one retracts the previous selection's card — so + * `requests` holds only what the keys asked for. + */ +function startSession() { + AOITool._onDrawStart() + requests.length = 0 +} + beforeEach(() => { requests = [] finishSucceeds = true window.mmgisAPI = { request: (name) => { requests.push(name) - return Promise.resolve( - name === 'map:finishDrawing' ? finishSucceeds : true - ) + if (name === 'map:finishDrawing') { + return Promise.resolve(finishSucceeds) + } + // There is no camera behind these specs, and "no view" is an + // answer the popup anchoring understands — `true` is not. + if (name === 'map:getBounds') return Promise.resolve(null) + return Promise.resolve(true) }, } + // These specs drive the session directly rather than through `make()`, so + // hand the tool the handle the controller would have injected — every + // request it makes goes through that. + AOITool.api = { + on: () => () => { }, + emit: () => { }, + provide: () => () => { }, + request: (name, data) => window.mmgisAPI.request(name, data), + release: () => { }, + } }) afterEach(() => { AOITool._removeDrawKeys() AOITool._state.isDrawing = false + // The module is shared across these specs, so a selection one of them + // completed must not be one the next one's session suspends. + AOITool._state.currentAOI = null + AOITool._suspendedAOI = null document.body.innerHTML = '' delete AOITool.api delete window.mmgisAPI @@ -51,13 +79,13 @@ afterEach(() => { test.describe('AOI draw-session keys', () => { test('Escape cancels the drawing from anywhere on the page', () => { - AOITool._onDrawStart() + startSession() press('Escape') expect(requests).toEqual(['map:disableDrawing']) }) test('Enter finishes the drawing from anywhere on the page', () => { - AOITool._onDrawStart() + startSession() press('Enter') expect(requests).toEqual(['map:finishDrawing']) }) @@ -67,7 +95,7 @@ test.describe('AOI draw-session keys', () => { // terra-draw hears are the two halves of one press, and must not read as // two finishes. test('both keys work with the map element focused', () => { - AOITool._onDrawStart() + startSession() const canvas = appendTo(document.body, 'canvas') press('Enter', canvas) release('Enter', canvas) @@ -79,7 +107,7 @@ test.describe('AOI draw-session keys', () => { // Clicking a panel control moves focus off the map, which is exactly where // terra-draw stops hearing anything. test('both keys work with a panel control focused', () => { - AOITool._onDrawStart() + startSession() const button = appendTo(document.body, 'button') press('Enter', button) press('Escape', button) @@ -87,7 +115,7 @@ test.describe('AOI draw-session keys', () => { }) test('leaves the keys to whatever field they were typed in', () => { - AOITool._onDrawStart() + startSession() // jsdom parses contenteditable but never sets isContentEditable, so // stand in for the flag a browser would have raised here. const editable = appendTo(document.body, 'div', { contenteditable: 'true' }) @@ -108,7 +136,7 @@ test.describe('AOI draw-session keys', () => { // must survive an Escape aimed at one of those — including one aimed at a // control nested inside it. test('leaves the keys to a component that closes on Escape', () => { - AOITool._onDrawStart() + startSession() for (const role of ['dialog', 'menu', 'listbox', 'combobox']) { const owner = appendTo(document.body, 'div', { role }) const nested = appendTo(owner, 'button') @@ -120,7 +148,7 @@ test.describe('AOI draw-session keys', () => { }) test('ignores a key held down long enough to repeat', () => { - AOITool._onDrawStart() + startSession() press('Enter', document.body, { repeat: true }) press('Escape', document.body, { repeat: true }) expect(requests).toEqual([]) @@ -131,7 +159,7 @@ test.describe('AOI draw-session keys', () => { // stays in drawing state with the keys still live. test('keeps drawing when the shape has too few vertices to finish', async () => { finishSucceeds = false - AOITool._onDrawStart() + startSession() press('Enter') expect(await window.mmgisAPI.request('map:finishDrawing')).toBe(false) expect(AOITool._state.isDrawing).toBe(true) @@ -144,7 +172,7 @@ test.describe('AOI draw-session keys', () => { }) test('stops listening once the drawing completes', () => { - AOITool._onDrawStart() + startSession() AOITool._onDrawComplete({ feature: { type: 'Feature', @@ -162,7 +190,7 @@ test.describe('AOI draw-session keys', () => { }) test('stops listening once the drawing is cancelled', () => { - AOITool._onDrawStart() + startSession() AOITool._onDrawCancelEvent() requests = [] press('Escape') @@ -170,7 +198,7 @@ test.describe('AOI draw-session keys', () => { }) test('stops listening when the tool goes away', () => { - AOITool._onDrawStart() + startSession() AOITool.destroy() requests = [] press('Escape') @@ -178,8 +206,8 @@ test.describe('AOI draw-session keys', () => { }) test('installs a single listener however often a session starts', () => { - AOITool._onDrawStart() - AOITool._onDrawStart() + startSession() + startSession() press('Escape') expect(requests).toEqual(['map:disableDrawing']) }) @@ -193,18 +221,11 @@ test.describe('AOI draw-session keys', () => { handlers[event] = handler return () => delete handlers[event] } - // The controller injects the plugin-scoped handle before make() runs. - AOITool.api = { - on: () => () => { }, - emit: () => { }, - provide: () => () => { }, - request: () => Promise.resolve(null), - release: () => { }, - } appendTo(document.body, 'div', { id: 'toolPanel' }) AOITool.make('toolPanel') handlers['map:drawstart']({ shape: 'polygon' }) + requests.length = 0 press('Escape') expect(requests).toEqual(['map:disableDrawing']) diff --git a/tests/unit/aoiSelectionCamera.spec.js b/tests/unit/aoiSelectionCamera.spec.js index 2d6d2d761..054fa9fdf 100644 --- a/tests/unit/aoiSelectionCamera.spec.js +++ b/tests/unit/aoiSelectionCamera.spec.js @@ -1,7 +1,9 @@ import { test, expect, vi, beforeEach, afterEach } from 'vitest' import { + featureBounds, + featureCentroid, selectionFitBounds, - selectionTooltipAnchor, + selectionPopupAnchor, } from '../../src/essence/Tools/AOI/aoiHelpers.ts' import AOITool from '../../src/essence/Tools/AOI/AOITool.js' @@ -29,6 +31,12 @@ const squareFeature = (w, s, e, n) => ({ }, }) +const geometry = (type, coordinates) => ({ + type: 'Feature', + properties: {}, + geometry: { type, coordinates }, +}) + test.describe('selectionFitBounds', () => { test('returns null when the selection is fully inside the view', () => { expect(selectionFitBounds([-98, 32, -92, 38], view)).toBeNull() @@ -113,26 +121,75 @@ test.describe('selectionFitBounds', () => { }) }) -test.describe('selectionTooltipAnchor', () => { +test.describe('featureCentroid', () => { + test('averages a polygon ring, counting its repeated closing vertex once', () => { + expect(featureCentroid(squareFeature(0, 0, 10, 20))).toEqual([5, 10]) + }) + + // `drawShapes: linestring` is a supported mission config, and the popup + // card holds the only Analyze and Cancel a selection has — no centroid, no + // card, and the drawn line cannot be analyzed or cleared. + test('averages a linestring, which is what a drawn line selects on', () => { + expect( + featureCentroid(geometry('LineString', [[0, 0], [10, 4], [20, 8]])) + ).toEqual([10, 4]) + }) + + test('has no centroid for a geometry with no vertices to average', () => { + expect(featureCentroid(geometry('LineString', []))).toBeNull() + expect(featureCentroid({ type: 'Feature', properties: {} })).toBeNull() + }) +}) + +test.describe('featureBounds', () => { + test('envelopes a polygon ring', () => { + expect(featureBounds(squareFeature(0, 0, 10, 20))).toEqual([0, 0, 10, 20]) + }) + + // The bounds decide whether the camera is asked to frame the selection and + // whether the anchor gets a view to fall back on, so every geometry the + // centroid supports has to reach that path too. + test('envelopes the lines and the point, as the centroid does', () => { + expect( + featureBounds(geometry('LineString', [[0, 0], [10, 4], [20, 8]])) + ).toEqual([0, 0, 20, 8]) + expect( + featureBounds( + geometry('MultiLineString', [ + [[0, 0], [2, 0]], + [[-1, 4], [2, 6]], + ]) + ) + ).toEqual([-1, 0, 2, 6]) + expect(featureBounds(geometry('Point', [3, 7]))).toEqual([3, 7, 3, 7]) + }) + + test('has no bounds for a geometry with no vertices', () => { + expect(featureBounds(geometry('LineString', []))).toBeNull() + expect(featureBounds({ type: 'Feature', properties: {} })).toBeNull() + }) +}) + +test.describe('selectionPopupAnchor', () => { const centroid = { lat: 35, lng: -95 } test('keeps a centroid that is inside the view', () => { - expect(selectionTooltipAnchor(centroid, view)).toEqual(centroid) + expect(selectionPopupAnchor(centroid, view)).toEqual(centroid) }) test('keeps the centroid when no view is supplied (camera was fitted)', () => { - expect(selectionTooltipAnchor(centroid)).toEqual(centroid) + expect(selectionPopupAnchor(centroid)).toEqual(centroid) }) test('falls back to the view centre for an off-screen centroid (Alaska)', () => { - expect(selectionTooltipAnchor({ lat: 58.4, lng: -139.3 }, view)).toEqual({ + expect(selectionPopupAnchor({ lat: 58.4, lng: -139.3 }, view)).toEqual({ lat: 35, lng: -95, }) }) test('falls back for a centroid off-screen in latitude only', () => { - expect(selectionTooltipAnchor({ lat: 5, lng: -95 }, view)).toEqual({ + expect(selectionPopupAnchor({ lat: 5, lng: -95 }, view)).toEqual({ lat: 35, lng: -95, }) @@ -143,7 +200,7 @@ test.describe('selectionTooltipAnchor', () => { southWest: { lat: 30, lng: 0 }, northEast: { lat: 40, lng: 400 }, } - expect(selectionTooltipAnchor({ lat: 80, lng: 5 }, overwide)).toEqual({ + expect(selectionPopupAnchor({ lat: 80, lng: 5 }, overwide)).toEqual({ lat: 35, lng: 200, }) @@ -154,7 +211,7 @@ test.describe('selectionTooltipAnchor', () => { southWest: { lat: 30, lng: 170 }, northEast: { lat: 40, lng: -170 }, } - expect(selectionTooltipAnchor({ lat: 58.4, lng: -139.3 }, wrapped)).toEqual({ + expect(selectionPopupAnchor({ lat: 58.4, lng: -139.3 }, wrapped)).toEqual({ lat: 35, lng: 180, }) @@ -165,8 +222,12 @@ test.describe('AOITool._applySelection camera behavior', () => { const mockApi = (currentView) => { const calls = [] window.mmgisAPI = { - request: vi.fn((name, payload) => { - calls.push({ name, payload }) + // The handle below adds a third argument naming who asked, the + // way the real one stamps a request with its plugin's address. + // Recording it unwrapped is what keeps `calls` reading as a plain + // caller. + request: vi.fn((name, payload, options) => { + calls.push({ name, payload, caller: options?.caller }) return Promise.resolve( name === 'map:getBounds' ? currentView : undefined ) @@ -174,6 +235,18 @@ test.describe('AOITool._applySelection camera behavior', () => { on: vi.fn(), off: vi.fn(), } + // These specs drive `_applySelection` on its own rather than through + // `make()`, so hand the tool the handle the controller would have + // injected. Every request goes through it, which is what stamps the + // request with the plugin's address. + AOITool.api = { + on: (event, handler) => window.mmgisAPI.on(event, handler), + emit: () => { }, + provide: () => () => { }, + request: (name, data) => + window.mmgisAPI.request(name, data, { caller: 'aoi' }), + release: () => { }, + } return calls } const names = (calls) => calls.map((c) => c.name) @@ -189,18 +262,19 @@ test.describe('AOITool._applySelection camera behavior', () => { vi.clearAllTimers() vi.useRealTimers() delete window.mmgisAPI + delete AOITool.api AOITool._state.currentAOI = null }) - test('makes no camera call and mounts the tooltip promptly when the selection is in view', async () => { + test('makes no camera call and opens the popup promptly when the selection is in view', async () => { const calls = mockApi(view) AOITool._applySelection(squareFeature(-98, 32, -92, 38), 'draw', 'In view') await flush() expect(names(calls)).not.toContain('map:fitBounds') - const overlay = calls.find((c) => c.name === 'map:addOverlay') + const popup = calls.find((c) => c.name === 'map:showPopup') // Anchored at the selection's own centroid — the view never moved, but // the centroid was already on-screen. - expect(overlay?.payload.latlng).toEqual({ lat: 35, lng: -95 }) + expect(popup?.payload.latlng).toEqual({ lat: 35, lng: -95 }) expect(window.mmgisAPI.on).not.toHaveBeenCalledWith( 'map:moveend', expect.anything() @@ -222,18 +296,19 @@ test.describe('AOITool._applySelection camera behavior', () => { { lat: 32, lng: -98 }, { lat: 38, lng: -80 }, ], - }) + }), + { caller: 'aoi' } ) - // The tooltip waits for the camera; this mock never fires moveend, so + // The popup waits for the camera; this mock never fires moveend, so // the fallback timer mounts it — at the centroid, now framed. - expect(names(calls)).not.toContain('map:addOverlay') + expect(names(calls)).not.toContain('map:showPopup') vi.advanceTimersByTime(1600) await flush() - const overlay = calls.find((c) => c.name === 'map:addOverlay') - expect(overlay?.payload.latlng).toEqual({ lat: 35, lng: -89 }) + const popup = calls.find((c) => c.name === 'map:showPopup') + expect(popup?.payload.latlng).toEqual({ lat: 35, lng: -89 }) }) - test('mounts the tooltip on moveend and unhooks the handler', async () => { + test('opens the popup on moveend and unhooks the handler', async () => { const calls = mockApi(view) AOITool._applySelection( squareFeature(-98, 32, -80, 38), @@ -243,27 +318,29 @@ test.describe('AOITool._applySelection camera behavior', () => { await flush() const [event, onMoveend] = window.mmgisAPI.on.mock.calls[0] expect(event).toBe('map:moveend') - expect(names(calls)).not.toContain('map:addOverlay') + expect(names(calls)).not.toContain('map:showPopup') // Map_ re-emits moveend with the engine's view state, so the handler - // must drop that payload rather than pass it on as a ViewBounds. - onMoveend({ longitude: -89, latitude: 35, zoom: 6 }) + // must drop that payload rather than pass it on as a ViewBounds. The + // numbers in it are nowhere near the selection's centroid, so a + // handler that read the card's anchor off them would be caught here. + onMoveend({ longitude: 12, latitude: -4, zoom: 6 }) // At the centroid: the camera has framed the selection, so no anchor // fallback applies. expect( - calls.find((c) => c.name === 'map:addOverlay')?.payload.latlng + calls.find((c) => c.name === 'map:showPopup')?.payload.latlng ).toEqual({ lat: 35, lng: -89 }) expect(window.mmgisAPI.off).toHaveBeenCalledWith( 'map:moveend', onMoveend ) - // The fallback timer is disarmed, so it cannot mount a second tooltip. + // The fallback timer is disarmed, so it cannot open a second popup. vi.advanceTimersByTime(1600) await flush() - expect(calls.filter((c) => c.name === 'map:addOverlay')).toHaveLength(1) + expect(calls.filter((c) => c.name === 'map:showPopup')).toHaveLength(1) }) - test('anchors the tooltip inside the view when fitBounds is rejected', async () => { + test('anchors the popup inside the view when fitBounds is rejected', async () => { const calls = mockApi(view) const request = window.mmgisAPI.request window.mmgisAPI.request = vi.fn((name, payload) => @@ -278,12 +355,55 @@ test.describe('AOITool._applySelection camera behavior', () => { ) await flush() // The camera never moved, so the off-screen centroid (-89) would strand - // the tooltip; it falls back to the centre of the unchanged view. - const overlay = calls.find((c) => c.name === 'map:addOverlay') - expect(overlay?.payload.latlng).toEqual({ lat: 35, lng: -95 }) + // the popup; it falls back to the centre of the unchanged view. + const popup = calls.find((c) => c.name === 'map:showPopup') + expect(popup?.payload.latlng).toEqual({ lat: 35, lng: -95 }) + }) + + test('drops a fitBounds rejection that lands after a superseding selection', async () => { + const calls = mockApi(view) + const request = window.mmgisAPI.request + // Hold each fit open, so the first selection's rejection can be made to + // arrive after the second selection has already replaced it. + const rejectFit = [] + window.mmgisAPI.request = vi.fn((name, payload, options) => { + if (name !== 'map:fitBounds') return request(name, payload, options) + calls.push({ name, payload, caller: options?.caller }) + return new Promise((_resolve, reject) => rejectFit.push(reject)) + }) + + AOITool._applySelection(squareFeature(-98, 32, -80, 38), 'search', 'First') + await flush() + AOITool._applySelection(squareFeature(-98, 20, -80, 45), 'search', 'Second') + await flush() + + const [, secondMoveend] = window.mmgisAPI.on.mock.calls[1] + + rejectFit[0](new Error('nope')) + await flush() + + // The stale show stays shut: its selection is gone from the map and + // from `currentAOI`, so its popup would offer to analyze an area the + // user has already replaced. + expect(names(calls)).not.toContain('map:showPopup') + // And it leaves the current selection's show armed. Disarming that one + // is the worse half of the failure: the popup the user is waiting for + // never opens at all. + expect(window.mmgisAPI.off).not.toHaveBeenCalledWith( + 'map:moveend', + secondMoveend + ) + + // A view state nowhere near the selection's centroid, so a handler + // that forwarded it as the anchor is caught here. + secondMoveend({ longitude: 12, latitude: -4, zoom: 6 }) + const popups = calls.filter((c) => c.name === 'map:showPopup') + expect(popups).toHaveLength(1) + expect(popups[0].payload.title).toBe('Second') + expect(popups[0].payload.latlng).toEqual({ lat: 32.5, lng: -89 }) }) - test('anchors the tooltip inside the view for an unframeable selection (Alaska)', async () => { + test('anchors the popup inside the view for an unframeable selection (Alaska)', async () => { const calls = mockApi(view) // A ring straddling ±180, as Alaska's MultiPolygon does. AOITool._applySelection( @@ -293,8 +413,8 @@ test.describe('AOITool._applySelection camera behavior', () => { ) await flush() expect(names(calls)).not.toContain('map:fitBounds') - const overlay = calls.find((c) => c.name === 'map:addOverlay') - expect(overlay).toBeDefined() - expect(overlay.payload.latlng).toEqual({ lat: 35, lng: -95 }) + const popup = calls.find((c) => c.name === 'map:showPopup') + expect(popup).toBeDefined() + expect(popup.payload.latlng).toEqual({ lat: 35, lng: -95 }) }) }) From a140b0061d22f41d6f592f577346c7d2f8fecff7 Mon Sep 17 00:00:00 2001 From: Carson Davis Date: Thu, 10 Sep 2026 21:00:00 -0500 Subject: [PATCH 2/2] Subscribe through AOI's handle, keep a mid-session selection, and drop a private assertion --- src/essence/Tools/AOI/AOITool.js | 73 +++++++++++++-------------- tests/unit/AOITool.spec.js | 17 ++++--- tests/unit/aoiDrawKeys.spec.js | 7 +-- tests/unit/aoiSelectionCamera.spec.js | 37 +++++++------- 4 files changed, 67 insertions(+), 67 deletions(-) diff --git a/src/essence/Tools/AOI/AOITool.js b/src/essence/Tools/AOI/AOITool.js index cec11210a..c2d644a84 100644 --- a/src/essence/Tools/AOI/AOITool.js +++ b/src/essence/Tools/AOI/AOITool.js @@ -179,37 +179,35 @@ const AOITool = { this._setState({ searchLoading: false, searchDisabled: true }) }) - // Subscriptions are on the global bus; only requests need the handle. - const bus = window.mmgisAPI - if (bus?.on) { - const subscribe = (event, handler) => { - const off = bus.on(event, handler) - this._cleanups.push(typeof off === 'function' ? off : () => { }) - } - subscribe('map:drawstart', (e) => this._onDrawStart(e)) - subscribe('map:drawvertex', (e) => this._onDrawVertex(e)) - subscribe('map:drawcomplete', (e) => this._onDrawComplete(e)) - subscribe('map:drawcancel', () => this._onDrawCancelEvent()) - subscribe('map:featureClick', (info) => this._onMapFeatureClick(info)) - subscribe('plugin:fetchstats:analysisProgress', ({ done, total }) => { - if (done === 0) { - this._setState({ - analysisStatus: 'running', - analysisLabel: this._state.currentAOI?.label || 'Area of interest', - analysisDone: 0, - analysisTotal: total, - }) - } else { - this._setState({ analysisDone: done }) - } - }) - subscribe('plugin:fetchstats:analysisReady', () => { - this._setState({ analysisStatus: 'idle' }) - }) - subscribe('plugin:fetchstats:analysisSkipped', ({ reason } = {}) => { - this._showAnalysisError(this._messageForSkipReason(reason)) - }) + // Subscriptions go through the handle as well: it hands back a + // disposer for each one, and destroy() drains them from `_cleanups`. + const subscribe = (event, handler) => { + const off = this.api?.on(event, handler) + this._cleanups.push(typeof off === 'function' ? off : () => { }) } + subscribe('map:drawstart', (e) => this._onDrawStart(e)) + subscribe('map:drawvertex', (e) => this._onDrawVertex(e)) + subscribe('map:drawcomplete', (e) => this._onDrawComplete(e)) + subscribe('map:drawcancel', () => this._onDrawCancelEvent()) + subscribe('map:featureClick', (info) => this._onMapFeatureClick(info)) + subscribe('plugin:fetchstats:analysisProgress', ({ done, total }) => { + if (done === 0) { + this._setState({ + analysisStatus: 'running', + analysisLabel: this._state.currentAOI?.label || 'Area of interest', + analysisDone: 0, + analysisTotal: total, + }) + } else { + this._setState({ analysisDone: done }) + } + }) + subscribe('plugin:fetchstats:analysisReady', () => { + this._setState({ analysisStatus: 'idle' }) + }) + subscribe('plugin:fetchstats:analysisSkipped', ({ reason } = {}) => { + this._showAnalysisError(this._messageForSkipReason(reason)) + }) this._render() this.made = true @@ -633,12 +631,13 @@ const AOITool = { _applySelection(feature, source, label) { this._cancelPendingPopup() this._removeSelectionLayer() + // This feature is the current selection from here on, so a session has + // no suspended selection left for its next vertex to drop. + this._suspendedAOI = null - // Requests go through AOI's handle, which stamps them with AOI's - // address; the bus events are subscribed on the global, which is where - // `on`/`off` live. + // Everything goes through AOI's handle: it stamps each request with + // AOI's address and hands back a disposer for each subscription. const api = this.api - const bus = window.mmgisAPI api?.request('map:createLayer', { id: SELECTION_LAYER_ID, type: 'vector', @@ -653,7 +652,7 @@ const AOITool = { const showPopup = (view) => this._showSelectionPopup(feature, label, view) const bbox = featureBounds(feature) - if (bbox && api?.request && bus?.on && bus?.off) { + if (bbox && api?.request && api?.on) { // Pending from here on, before the camera is even read: a teardown // or a superseding selection during that async hop must drop this // popup. `disarm` is filled in only if the show waits on the camera. @@ -690,10 +689,10 @@ const AOITool = { // inside the call. const oneShot = () => settled() const timer = setTimeout(oneShot, 1500) - bus.on('map:moveend', oneShot) + const offMoveend = api.on('map:moveend', oneShot) disarm = () => { clearTimeout(timer) - bus.off('map:moveend', oneShot) + offMoveend?.() } api.request('map:fitBounds', fit).catch((err) => { diff --git a/tests/unit/AOITool.spec.js b/tests/unit/AOITool.spec.js index 9610fde92..f6da29216 100644 --- a/tests/unit/AOITool.spec.js +++ b/tests/unit/AOITool.spec.js @@ -29,10 +29,11 @@ const VIEW = { } /** - * Stand-ins for the global bus the plugin subscribes on (`window.mmgisAPI`) and - * the handle the controller injects as `AOITool.api`. Requests and emits are - * recorded; the popup impls model core's one-slot contract, every show being - * answered on its own promise with how its popup closed. + * Stand-ins for the global bus (`window.mmgisAPI`) and the handle the + * controller injects as `AOITool.api` — which is what the plugin subscribes, + * requests and emits through. Calls are recorded; the popup impls model core's + * one-slot contract, every show being answered on its own promise with how its + * popup closed. */ function makeFakeApi() { const listeners = new Map() @@ -84,7 +85,8 @@ function makeFakeApi() { // A plugin's bus handle, as `mintHandle` builds it: emits and provides // are prefixed with the plugin's address, requests keep their full name - // and are stamped with it. + // and are stamped with it, and `on` hands back the disposer for the + // subscription it made. handleFor(address) { const prefix = `plugin:${address}:` return { @@ -377,9 +379,8 @@ describe('AOITool popup lifecycle', () => { await flush() expect(warn).toHaveBeenCalled() - // No popup can open from here, so claiming one is pending would be a - // lie — and would suppress the next selection's show. - expect(AOITool._pendingPopup).toBeNull() + // Nothing was armed to open this selection's popup, so neither a + // moveend nor the fallback timer can produce one. api.emit('map:moveend') await vi.advanceTimersByTimeAsync(2000) expect(api.namesOf('map:showPopup')).toHaveLength(0) diff --git a/tests/unit/aoiDrawKeys.spec.js b/tests/unit/aoiDrawKeys.spec.js index 0905819c5..37cd5b248 100644 --- a/tests/unit/aoiDrawKeys.spec.js +++ b/tests/unit/aoiDrawKeys.spec.js @@ -57,6 +57,7 @@ beforeEach(() => { // hand the tool the handle the controller would have injected — every // request it makes goes through that. AOITool.api = { + // As `mintHandle` does: subscribing hands back the disposer for it. on: () => () => { }, emit: () => { }, provide: () => () => { }, @@ -213,11 +214,11 @@ test.describe('AOI draw-session keys', () => { }) // The keys are only ever armed by the engine's drawstart reaching the - // plugin, so drive the session the way the bus does: through the tool the - // panel actually makes. + // plugin, so drive the session the way the bus does: through the handle + // the tool the panel actually makes subscribes on. test('a drawstart delivered over the bus arms the keys', () => { const handlers = {} - window.mmgisAPI.on = (event, handler) => { + AOITool.api.on = (event, handler) => { handlers[event] = handler return () => delete handlers[event] } diff --git a/tests/unit/aoiSelectionCamera.spec.js b/tests/unit/aoiSelectionCamera.spec.js index 054fa9fdf..35bba7ea3 100644 --- a/tests/unit/aoiSelectionCamera.spec.js +++ b/tests/unit/aoiSelectionCamera.spec.js @@ -219,8 +219,13 @@ test.describe('selectionPopupAnchor', () => { }) test.describe('AOITool._applySelection camera behavior', () => { + // Every subscription the tool made through its handle, in order, each with + // the disposer the handle handed back — unhooking is calling that disposer, + // so that is what these specs watch. + let subs = [] const mockApi = (currentView) => { const calls = [] + subs = [] window.mmgisAPI = { // The handle below adds a third argument naming who asked, the // way the real one stamps a request with its plugin's address. @@ -232,15 +237,18 @@ test.describe('AOITool._applySelection camera behavior', () => { name === 'map:getBounds' ? currentView : undefined ) }), - on: vi.fn(), - off: vi.fn(), } // These specs drive `_applySelection` on its own rather than through // `make()`, so hand the tool the handle the controller would have - // injected. Every request goes through it, which is what stamps the - // request with the plugin's address. + // injected. Every request and subscription goes through it: that is + // what stamps a request with the plugin's address, and what makes a + // subscription hand back its own disposer. AOITool.api = { - on: (event, handler) => window.mmgisAPI.on(event, handler), + on: (event, handler) => { + const off = vi.fn() + subs.push({ event, handler, off }) + return off + }, emit: () => { }, provide: () => () => { }, request: (name, data) => @@ -275,10 +283,7 @@ test.describe('AOITool._applySelection camera behavior', () => { // Anchored at the selection's own centroid — the view never moved, but // the centroid was already on-screen. expect(popup?.payload.latlng).toEqual({ lat: 35, lng: -95 }) - expect(window.mmgisAPI.on).not.toHaveBeenCalledWith( - 'map:moveend', - expect.anything() - ) + expect(subs).toHaveLength(0) }) test('forwards the selection extent to map:fitBounds when it overflows the view', async () => { @@ -316,7 +321,7 @@ test.describe('AOITool._applySelection camera behavior', () => { 'Beyond view' ) await flush() - const [event, onMoveend] = window.mmgisAPI.on.mock.calls[0] + const { event, handler: onMoveend, off: offMoveend } = subs[0] expect(event).toBe('map:moveend') expect(names(calls)).not.toContain('map:showPopup') @@ -330,10 +335,7 @@ test.describe('AOITool._applySelection camera behavior', () => { expect( calls.find((c) => c.name === 'map:showPopup')?.payload.latlng ).toEqual({ lat: 35, lng: -89 }) - expect(window.mmgisAPI.off).toHaveBeenCalledWith( - 'map:moveend', - onMoveend - ) + expect(offMoveend).toHaveBeenCalled() // The fallback timer is disarmed, so it cannot open a second popup. vi.advanceTimersByTime(1600) await flush() @@ -377,7 +379,7 @@ test.describe('AOITool._applySelection camera behavior', () => { AOITool._applySelection(squareFeature(-98, 20, -80, 45), 'search', 'Second') await flush() - const [, secondMoveend] = window.mmgisAPI.on.mock.calls[1] + const { handler: secondMoveend, off: secondOff } = subs[1] rejectFit[0](new Error('nope')) await flush() @@ -389,10 +391,7 @@ test.describe('AOITool._applySelection camera behavior', () => { // And it leaves the current selection's show armed. Disarming that one // is the worse half of the failure: the popup the user is waiting for // never opens at all. - expect(window.mmgisAPI.off).not.toHaveBeenCalledWith( - 'map:moveend', - secondMoveend - ) + expect(secondOff).not.toHaveBeenCalled() // A view state nowhere near the selection's centroid, so a handler // that forwarded it as the anchor is caught here.