@@ -50,7 +50,7 @@ afterEach(() => {
5050it . each ( [
5151 [ 'configured' , 'https://api.example.test:9443/prefix' ] ,
5252 [ 'default' , undefined ]
53- ] ) ( 'loads the built Windy module with %s API base, shows stale horizons and tile errors, normalizes clicks , cancels stale requests, and expires a pending series' , async ( _name , configuredBase ) => {
53+ ] ) ( 'loads the built Windy module with %s API base, routes clicks without closing its pane, shows stale horizons and tile errors, cancels stale requests, and expires a pending series' , async ( _name , configuredBase ) => {
5454 const pluginDir = resolve ( import . meta. dirname , '..' ) ;
5555 const vite = resolve ( pluginDir , 'node_modules/vite/bin/vite.js' ) ;
5656 const base = configuredBase ?? 'http://localhost:8787' ;
@@ -101,7 +101,22 @@ it.each([
101101 sampleSignal = init ?. signal ;
102102 return new Promise ( resolve => { resolveSample = resolve ; } ) ;
103103 } ) ;
104- let click : ( ( event : { latlng : { lat : number ; lng : number } } ) => void ) | undefined ;
104+ const clickListeners = new Map < string , Set < ( point : WindySingleclickPoint ) => void > > ( ) ;
105+ const singleclick = {
106+ on : vi . fn ( ( name : string , listener : ( point : WindySingleclickPoint ) => void ) => {
107+ const listeners = clickListeners . get ( name ) ?? new Set < ( point : WindySingleclickPoint ) => void > ( ) ;
108+ listeners . add ( listener ) ;
109+ clickListeners . set ( name , listeners ) ;
110+ } ) ,
111+ off : vi . fn ( ( name : string , listener : ( point : WindySingleclickPoint ) => void ) => {
112+ const listeners = clickListeners . get ( name ) ;
113+ listeners ?. delete ( listener ) ;
114+ if ( listeners ?. size === 0 ) clickListeners . delete ( name ) ;
115+ } ) ,
116+ emit ( name : string , point : WindySingleclickPoint ) : void {
117+ for ( const listener of clickListeners . get ( name ) ?? [ ] ) listener ( point ) ;
118+ }
119+ } ;
105120 const tileListeners = new Map < string , ( event : { tile : object } ) => void > ( ) ;
106121 const source = {
107122 on : vi . fn ( ( name : string , listener : ( event : { tile : object } ) => void ) => tileListeners . set ( name , listener ) ) ,
@@ -111,7 +126,7 @@ it.each([
111126 url : '' , setUrl ( url : string ) { this . url = url ; } , setOpacity : vi . fn ( ) ,
112127 source : ( ) => source
113128 } ;
114- const map = { on : vi . fn ( ( _name , listener ) => { click = listener ; } ) , off : vi . fn ( ) , addLayer : vi . fn ( ) , removeLayer : vi . fn ( ) } ;
129+ const map = { on : vi . fn ( ) , off : vi . fn ( ) , addLayer : vi . fn ( ) , removeLayer : vi . fn ( ) } ;
115130 const store = { get : ( ) => hostTime , set : vi . fn ( ) , on : vi . fn ( ) , off : vi . fn ( ) } ;
116131 let destroyApp : ( ( ) => void ) | undefined ;
117132 const broadcast = { emit : vi . fn ( ( event : string , name : string ) => {
@@ -121,7 +136,7 @@ it.each([
121136 vi . stubGlobal ( 'document' , document ) ;
122137 vi . stubGlobal ( 'Option' , FakeOption ) ;
123138 vi . stubGlobal ( 'fetch' , fetchImpl ) ;
124- vi . stubGlobal ( 'W' , { map : { map } , store, broadcast } ) ;
139+ vi . stubGlobal ( 'W' , { map : { map } , singleclick : { singleclick } , store, broadcast } ) ;
125140 const protocols = new Map < string , unknown > ( ) ;
126141 vi . stubGlobal ( 'L' , {
127142 tileLayer : ( url : string ) => { layer . url = url ; return layer ; } ,
@@ -133,6 +148,16 @@ it.each([
133148 expect ( loaded . __pluginConfig . name ) . toBe ( 'windy-plugin-av-layer' ) ;
134149 expect ( typeof loaded . default ) . toBe ( 'function' ) ;
135150 const metadata = JSON . parse ( readFileSync ( resolve ( pluginDir , 'dist/plugin.json' ) , 'utf8' ) ) ;
151+ const openWeatherPicker = vi . fn ( ( ) => destroyApp ?.( ) ) ;
152+ const click = ( point : WindySingleclickPoint ) : void => {
153+ // Windy routes claimed clicks to the open plugin; otherwise its weather
154+ // picker opens and dismisses a conflicting native pane.
155+ if ( metadata . listenToSingleclick && clickListeners . has ( metadata . name ) ) {
156+ singleclick . emit ( metadata . name , point ) ;
157+ } else {
158+ openWeatherPicker ( ) ;
159+ }
160+ } ;
136161 const installPackage = JSON . parse ( readFileSync ( resolve ( pluginDir , 'dist/package.json' ) , 'utf8' ) ) ;
137162 expect ( metadata ) . toEqual ( loaded . __pluginConfig ) ;
138163 expect ( installPackage ) . toEqual ( { name : metadata . name , version : metadata . version , type : 'module' , main : 'plugin.min.js' } ) ;
@@ -144,11 +169,15 @@ it.each([
144169 expect ( target . children [ 1 ] . className . split ( ' ' ) ) . toContain ( 'plugin__content' ) ;
145170 expect ( fetchImpl ) . not . toHaveBeenCalled ( ) ;
146171 expect ( map . on ) . not . toHaveBeenCalled ( ) ;
172+ expect ( singleclick . on ) . not . toHaveBeenCalled ( ) ;
147173 destroyApp = ( ) => app . $destroy ( ) ;
148174 expect ( typeof app . onopen ) . toBe ( 'function' ) ;
149175 expect ( typeof app . $destroy ) . toBe ( 'function' ) ;
150176 app . onopen ( ) ;
151177 await vi . waitFor ( ( ) => expect ( map . addLayer ) . toHaveBeenCalledTimes ( 1 ) ) ;
178+ expect ( singleclick . on ) . toHaveBeenCalledWith ( metadata . name , expect . any ( Function ) ) ;
179+ expect ( clickListeners . get ( metadata . name ) ?. size ) . toBe ( 1 ) ;
180+ expect ( map . on ) . not . toHaveBeenCalled ( ) ;
152181 expect ( fetchImpl . mock . calls [ 0 ] [ 0 ] ) . toBe ( `${ base } /v1/models/gfs/latest?expand=manifest` ) ;
153182 expect ( layer . url ) . toContain ( '/tiles/gfs/b1/precip-rate/f006/' ) ;
154183 expect ( protocols . size ) . toBe ( 1 ) ;
@@ -175,18 +204,22 @@ it.each([
175204 selects [ 2 ] . dispatch ( 'change' ) ;
176205 expect ( store . set ) . toHaveBeenCalledWith ( 'timestamp' , Date . parse ( '2026-10-30T03:00:00.000Z' ) ) ;
177206
178- click ?.( { latlng : { lat : 1 , lng : 190 } } ) ;
207+ click ( { lat : 1 , lon : 190 } ) ;
208+ expect ( openWeatherPicker ) . not . toHaveBeenCalled ( ) ;
209+ expect ( target . children ) . toHaveLength ( 2 ) ;
210+ expect ( map . removeLayer ) . not . toHaveBeenCalled ( ) ;
179211 const button = elements . find ( element => element . textContent === 'Sample clicked point' ) ! ;
180212 button . dispatch ( 'click' ) ;
181213 expect ( sampleSignal ?. aborted ) . toBe ( false ) ;
214+ expect ( fetchImpl . mock . calls [ 1 ] [ 0 ] ) . toContain ( 'lat=1' ) ;
182215 expect ( fetchImpl . mock . calls [ 1 ] [ 0 ] ) . toContain ( 'lon=-170' ) ;
183216 resolveSample ( new Response ( JSON . stringify ( { model : 'gfs' , buildId : 'b1' , lat : 1 , lon : - 170 , method : manifest . method , source : manifest . source , coverage : manifest . coverage , expiresAt, frames : frames . map ( frame => ( { ...frame , layers : { 'precip-rate' : { value : 1 , status : 'valid' , support : 0 , interpolated : false } } } ) ) } ) ) ) ;
184217 const series = elements . find ( element => element . className === 'av-series' ) ! ;
185218 await vi . waitFor ( ( ) => expect ( series . children ) . toHaveLength ( 1 ) ) ;
186219
187220 button . dispatch ( 'click' ) ;
188221 const firstSignal = sampleSignal ;
189- click ?. ( { latlng : { lat : 2 , lng : 3 } } ) ;
222+ click ( { lat : 2 , lon : 3 } ) ;
190223 expect ( firstSignal ?. aborted ) . toBe ( true ) ;
191224 expect ( series . children ) . toHaveLength ( 0 ) ;
192225
@@ -205,11 +238,13 @@ it.each([
205238 await Promise . resolve ( ) ;
206239 expect ( series . children ) . toHaveLength ( 0 ) ;
207240 app . $destroy ( ) ;
208- expect ( map . off ) . toHaveBeenCalledTimes ( 1 ) ;
241+ expect ( singleclick . off ) . toHaveBeenCalledTimes ( 1 ) ;
242+ expect ( clickListeners . size ) . toBe ( 0 ) ;
243+ expect ( map . off ) . not . toHaveBeenCalled ( ) ;
209244 expect ( target . children ) . toHaveLength ( 0 ) ;
210245
211246 // A live view must close before opening the menu, releasing its
212- // pending point request, time subscription, map listener and raster protocol.
247+ // pending point request, time subscription, click listener and raster protocol.
213248 clock . mockReturnValue ( now ) ;
214249 const previousElementCount = elements . length ;
215250 const reopened = new loaded . default ( { target, anchor : target } ) ;
@@ -224,7 +259,8 @@ it.each([
224259 expect ( target . children ) . toEqual ( [ mobileHeader , root ] ) ;
225260 expect ( target . children ) . toContain ( root ) ;
226261 expect ( document . body . children ) . not . toContain ( root ) ;
227- click ?.( { latlng : { lat : 3 , lng : 4 } } ) ;
262+ expect ( clickListeners . get ( metadata . name ) ?. size ) . toBe ( 1 ) ;
263+ click ( { lat : 3 , lon : 4 } ) ;
228264 reopenedElements . find ( element => element . textContent === 'Sample clicked point' ) ! . dispatch ( 'click' ) ;
229265 expect ( sampleSignal ?. aborted ) . toBe ( false ) ;
230266 expect ( protocols . size ) . toBe ( 1 ) ;
@@ -234,7 +270,11 @@ it.each([
234270 [ 'rqstOpen' , 'menu' ]
235271 ] ) ;
236272 expect ( sampleSignal ?. aborted ) . toBe ( true ) ;
237- expect ( map . off ) . toHaveBeenCalledTimes ( 2 ) ;
273+ expect ( singleclick . off ) . toHaveBeenCalledTimes ( 2 ) ;
274+ expect ( clickListeners . size ) . toBe ( 0 ) ;
275+ expect ( map . on ) . not . toHaveBeenCalled ( ) ;
276+ expect ( map . off ) . not . toHaveBeenCalled ( ) ;
277+ expect ( openWeatherPicker ) . not . toHaveBeenCalled ( ) ;
238278 expect ( store . off ) . toHaveBeenCalledTimes ( 2 ) ;
239279 expect ( map . removeLayer ) . toHaveBeenCalledTimes ( 2 ) ;
240280 expect ( tileListeners . size ) . toBe ( 0 ) ;
0 commit comments