Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 13 additions & 4 deletions src/essence/Tools/FetchStats/FetchStatsTool.js
Original file line number Diff line number Diff line change
Expand Up @@ -37,12 +37,17 @@ const FetchStatsTool = {
MMGISInterface: null,
_api: null,
_cleanups: [],
made: false,

initialize() {
this.make(null)
},

make(targetId) {
// The guard drops the targetId the second start passes, which is
// safe only because this plugin renders nothing.
if (this.made) return
this.made = true
this.MMGISInterface = new interfaceWithMMGIS(this, targetId)
this._api =
(typeof window !== 'undefined' &&
Expand All @@ -64,9 +69,13 @@ const FetchStatsTool = {
},

destroy() {
this.made = false
this._cleanups.forEach((fn) => fn())
this._cleanups = []
this.MMGISInterface?.separateFromMMGIS()
// An analysis already awaiting the network cannot be cancelled, so
// dropping the handle is what keeps its remaining emits off the bus.
this._api = null
},

async _getAnalyzableVisibleLayers() {
Expand All @@ -92,11 +101,11 @@ const FetchStatsTool = {

const layers = await this._getAnalyzableVisibleLayers()
if (!layers.length) {
this._api.emit('analysisSkipped', { reason: 'no-eligible-layers' })
this._api?.emit('analysisSkipped', { reason: 'no-eligible-layers' })
return
}

this._api.emit('analysisProgress', { done: 0, total: layers.length })
this._api?.emit('analysisProgress', { done: 0, total: layers.length })

const body = JSON.stringify({
type: 'Feature',
Expand All @@ -110,13 +119,13 @@ const FetchStatsTool = {
const displayName = layer.display_name || layer.name
const result = await this._postStatsForLayer(layer, body)
done += 1
this._api.emit('analysisProgress', { done, total: layers.length })
this._api?.emit('analysisProgress', { done, total: layers.length })
return [displayName, result]
})
)

const analysisData = Object.fromEntries(entries)
this._api.emit('analysisReady', { analysisData })
this._api?.emit('analysisReady', { analysisData })
},

async _postStatsForLayer(layer, body) {
Expand Down
114 changes: 114 additions & 0 deletions src/essence/Tools/FetchStats/__tests__/fetchStatsLifecycle.spec.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
import { test, expect, vi, beforeEach, afterEach } from 'vitest'

import FetchStatsTool from '../FetchStatsTool'

// FetchStats renders nothing, so the classic layout reaches it through
// initialize() alone while the modern layout calls initialize() and then
// make(). One subscription either way is the point: two would answer a single
// AOI selection with two full analysis rounds.

const AOI_READY = 'plugin:aoi:analysisAOIReady'

const AOI = {
type: 'Feature',
properties: {},
geometry: {
type: 'Polygon',
coordinates: [[[-1, -1], [1, -1], [1, 1], [-1, 1], [-1, -1]]],
},
}

const ANALYSIS_LAYER = {
display_name: 'Burn severity',
variables: {
analysis: {
is_analysis_supported: true,
itemUrl: 'https://titiler.example/item',
assets: ['data'],
},
},
}

const flush = () => new Promise((resolve) => setTimeout(resolve))

let subscriptions
let handlers
let emit
let deferConfig

beforeEach(() => {
subscriptions = []
handlers = {}
emit = vi.fn()
deferConfig = null
window.mmgisAPI = {
on: (event, handler) => {
subscriptions.push(event)
handlers[event] = handler
return vi.fn()
},
forPlugin: () => ({ emit }),
request: (name) => {
if (name === 'layers:getVisible') return Promise.resolve({ uuid: true })
if (name === 'layers:getAll') return Promise.resolve(['uuid'])
// Parks the analysis run mid-flight so the test can tear the
// plugin down before any result comes back.
return new Promise((resolve) => {
deferConfig = resolve
})
},
}
})

afterEach(() => {
FetchStatsTool.destroy()
delete window.mmgisAPI
vi.unstubAllGlobals()
vi.restoreAllMocks()
})

test('the classic layout subscribes from initialize() alone', () => {
FetchStatsTool.initialize()

expect(subscriptions).toEqual([AOI_READY])
})

test('the modern layout, calling both start hooks, still subscribes once', () => {
FetchStatsTool.initialize()
FetchStatsTool.make('fetch-stats-target')

expect(subscriptions).toEqual([AOI_READY])
})

test('a reload subscribes again, once per live start', () => {
FetchStatsTool.initialize()
FetchStatsTool.destroy()
FetchStatsTool.initialize()

// destroy() clears the started flag, so the reload reaches api.on a
// second time. A plugin left flagged as started would sit deaf for the
// rest of the session.
expect(subscriptions).toEqual([AOI_READY, AOI_READY])
})

test('an analysis resolving after teardown announces nothing', async () => {
vi.stubGlobal(
'fetch',
vi.fn(async () => ({ ok: true, status: 200, json: async () => ({ b1: {} }) }))
)
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
FetchStatsTool.initialize()

handlers[AOI_READY]({ feature: AOI })
await flush()

FetchStatsTool.destroy()
deferConfig(ANALYSIS_LAYER)
await flush()

expect(emit).not.toHaveBeenCalled()
// Reaching a dropped handle through a bare .emit throws, and the
// subscription's catch would log that away instead of failing the
// assertion above.
expect(warn).not.toHaveBeenCalled()
})