Skip to content

Commit ddd7ab0

Browse files
committed
fix(plugins): keep the plugin query tests off the plugin registry
The read-only query API added in #1784 is exercised through `createAppAPI`, which lives in `usePlugins.ts`. That module imports the whole built-in plugin registry, so loading it in a Node test pulled in MapCanvas, CesiumCanvas, and every `maplibre-*` plugin: 39 browser-only modules, none of them meaningfully exercised, all of them newly counted by the coverage reporter. Coverage is reported only over files a test actually imports, so those 39 files landed in the denominator at 1-30% function coverage and dropped the total from 72.90% to 60.36%, under the 63% floor. CI has been red on `main` since, and the report is measuring module reachability rather than how well the code is tested. Move the six query methods and `readPluginSelection` into `lib/plugin-layer-queries.ts`, which needs only the store, and have `createAppAPI` spread them in. The test imports that module directly and no longer stubs `maplibre-gl`, `window`, `sessionStorage`, or `localStorage` to get off the ground. Same reasoning, and the same shape, as `geo-editor-geometry.ts` in `@geolibre/plugins`, which is already kept free of the Geoman/MapLibre runtime so it can be unit-tested under Node. `SKETCHES_SOURCE_KIND` gets a subpath export alongside the five already in that package, so the test can share the constant instead of repeating the string literal as it did before. The wiring into the plugin-facing API is now a typed spread rather than something this test asserts; `tsc` covers it on every build. Coverage returns to 82.84% lines / 84.45% branches / 72.55% functions (baseline before #1784: 82.89 / 84.46 / 72.90), with the counted file set back from 444 to 406. The one addition is the extracted module itself, at 100% lines and 95.45% functions. All 8 tests still pass.
1 parent 6093aa8 commit ddd7ab0

4 files changed

Lines changed: 118 additions & 130 deletions

File tree

apps/geolibre-desktop/src/hooks/usePlugins.ts

Lines changed: 2 additions & 51 deletions
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,6 @@ import {
2626
setEffectsSettings,
2727
type EffectsSettings,
2828
maplibreEarthdataGisPlugin,
29-
SKETCHES_SOURCE_KIND,
3029
setEarthdataCogSaver,
3130
maplibreEnviroAtlasPlugin,
3231
maplibreEsriWaybackPlugin,
@@ -94,7 +93,6 @@ import type {
9493
GeoLibreExternalNativeLayerRegistration,
9594
GeoLibreFileDialogOptions,
9695
GeoLibreMapControlPosition,
97-
GeoLibreSelection,
9896
GeoLibreTileLayerOptions,
9997
GeoLibreWmsLayerOptions,
10098
GeoLibreZarrLayerOptions,
@@ -132,6 +130,7 @@ import { partitionProjectPluginManifestUrls } from "../lib/plugin-trust";
132130
import { setTimeSliderOpenedByBinding, shouldCloseTimeSliderDock } from "../lib/time-slider-dock";
133131
import { createWmsTileUrl, normalizeWmsVersion } from "../components/layout/add-data/helpers";
134132
import { createExternalNativeStoreLayer } from "../lib/external-native-layer";
133+
import { createPluginLayerQueries } from "../lib/plugin-layer-queries";
135134
import { mergeStringLists } from "../lib/string-lists";
136135
import {
137136
browserSaveFallsBackToDownload,
@@ -831,21 +830,6 @@ export function useTimeSliderAutoClose(mapControllerRef: RefObject<MapController
831830
}, [mapControllerRef]);
832831
}
833832

834-
function readPluginSelection(): GeoLibreSelection {
835-
const state = useAppStore.getState();
836-
const layer = state.layers.find((item) => item.id === state.selectedLayerId);
837-
if (!layer || state.selectedFeatureIds.length === 0) {
838-
return { layerId: state.selectedLayerId, features: [] };
839-
}
840-
const selected = new Set(state.selectedFeatureIds);
841-
const features = structuredClone(
842-
(layer.geojson?.features ?? []).filter((feature, index) =>
843-
selected.has(String(feature.id ?? index)),
844-
),
845-
);
846-
return { layerId: state.selectedLayerId, features };
847-
}
848-
849833
export function createAppAPI(mapControllerRef?: RefObject<MapController | null>) {
850834
const store = useAppStore.getState();
851835
// Captured so methods that delegate to plugin helpers taking the AppAPI
@@ -857,40 +841,7 @@ export function createAppAPI(mapControllerRef?: RefObject<MapController | null>)
857841
const id = store.addGeoJsonLayer(name, data, sourcePath);
858842
return id;
859843
},
860-
listLayers: () =>
861-
useAppStore.getState().layers.map(({ id, name, type, visible, opacity }) => ({
862-
id,
863-
name,
864-
type,
865-
visible,
866-
opacity,
867-
})),
868-
getLayerFeatures: (layerId: string) => {
869-
const layer = useAppStore.getState().layers.find((item) => item.id === layerId);
870-
if (!layer) throw new Error(`No layer with id "${layerId}"`);
871-
return structuredClone(layer.geojson?.features ?? []);
872-
},
873-
getSelectedFeatures: () => readPluginSelection().features,
874-
getSelectedLayerId: () => useAppStore.getState().selectedLayerId,
875-
getDrawnFeatures: () =>
876-
structuredClone(
877-
useAppStore
878-
.getState()
879-
.layers.flatMap((layer) =>
880-
layer.metadata.sourceKind === SKETCHES_SOURCE_KIND
881-
? (layer.geojson?.features ?? [])
882-
: [],
883-
),
884-
),
885-
onSelectionChange: (callback: (selection: GeoLibreSelection) => void) =>
886-
useAppStore.subscribe((state, previous) => {
887-
if (
888-
state.selectedLayerId !== previous.selectedLayerId ||
889-
state.selectedFeatureIds !== previous.selectedFeatureIds
890-
) {
891-
callback(readPluginSelection());
892-
}
893-
}),
844+
...createPluginLayerQueries(),
894845
addTileLayer: (name: string, url: string, options?: GeoLibreTileLayerOptions) =>
895846
store.addTileLayer(
896847
name,
Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
import { useAppStore } from "@geolibre/core";
2+
import { SKETCHES_SOURCE_KIND } from "@geolibre/plugins/geo-editor-geometry";
3+
import type { GeoLibreSelection } from "@geolibre/plugins";
4+
5+
/**
6+
* The read-only half of the external plugin API: everything a plugin may ask
7+
* about the layers currently on the map, and nothing that writes to them.
8+
*
9+
* Kept out of `usePlugins.ts` on purpose. That module imports the whole
10+
* built-in plugin registry (and through it MapCanvas, CesiumCanvas, and every
11+
* `maplibre-*` plugin), so a unit test reaching these queries through
12+
* `createAppAPI` had to stub `maplibre-gl`, `window`, and `localStorage` just
13+
* to load the module, and dragged 39 browser-only files into the coverage
14+
* report along the way. These functions need only the store, so they live
15+
* here and `createAppAPI` spreads them in. Same reasoning as
16+
* `geo-editor-geometry.ts` in `@geolibre/plugins`.
17+
*
18+
* Every accessor hands back a `structuredClone`, so a plugin holding a
19+
* returned feature cannot reach into store state and mutate it.
20+
*/
21+
22+
/** The current selection as plugins see it: the layer id and its selected features. */
23+
export function readPluginSelection(): GeoLibreSelection {
24+
const state = useAppStore.getState();
25+
const layer = state.layers.find((item) => item.id === state.selectedLayerId);
26+
if (!layer || state.selectedFeatureIds.length === 0) {
27+
return { layerId: state.selectedLayerId, features: [] };
28+
}
29+
const selected = new Set(state.selectedFeatureIds);
30+
const features = structuredClone(
31+
(layer.geojson?.features ?? []).filter((feature, index) =>
32+
selected.has(String(feature.id ?? index)),
33+
),
34+
);
35+
return { layerId: state.selectedLayerId, features };
36+
}
37+
38+
/**
39+
* Build the read-only query methods that `createAppAPI` exposes to plugins.
40+
* Reads the store on every call rather than closing over a snapshot, so a
41+
* plugin holding the API sees the map as it is now.
42+
*/
43+
export function createPluginLayerQueries() {
44+
return {
45+
listLayers: () =>
46+
useAppStore.getState().layers.map(({ id, name, type, visible, opacity }) => ({
47+
id,
48+
name,
49+
type,
50+
visible,
51+
opacity,
52+
})),
53+
getLayerFeatures: (layerId: string) => {
54+
const layer = useAppStore.getState().layers.find((item) => item.id === layerId);
55+
if (!layer) throw new Error(`No layer with id "${layerId}"`);
56+
return structuredClone(layer.geojson?.features ?? []);
57+
},
58+
getSelectedFeatures: () => readPluginSelection().features,
59+
getSelectedLayerId: () => useAppStore.getState().selectedLayerId,
60+
getDrawnFeatures: () =>
61+
structuredClone(
62+
useAppStore
63+
.getState()
64+
.layers.flatMap((layer) =>
65+
layer.metadata.sourceKind === SKETCHES_SOURCE_KIND
66+
? (layer.geojson?.features ?? [])
67+
: [],
68+
),
69+
),
70+
onSelectionChange: (callback: (selection: GeoLibreSelection) => void) =>
71+
useAppStore.subscribe((state, previous) => {
72+
if (
73+
state.selectedLayerId !== previous.selectedLayerId ||
74+
state.selectedFeatureIds !== previous.selectedFeatureIds
75+
) {
76+
callback(readPluginSelection());
77+
}
78+
}),
79+
};
80+
}

packages/plugins/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
"exports": {
99
".": "./src/index.ts",
1010
"./cog-spectral-profile": "./src/plugins/cog-spectral-profile.ts",
11+
"./geo-editor-geometry": "./src/plugins/geo-editor-geometry.ts",
1112
"./local-netcdf": "./src/plugins/local-netcdf.ts",
1213
"./maplibre-graticule": "./src/plugins/maplibre-graticule.ts",
1314
"./raster-symbology": "./src/plugins/raster-symbology.ts",

tests/plugin-query-api.test.ts

Lines changed: 35 additions & 79 deletions
Original file line numberDiff line numberDiff line change
@@ -1,61 +1,17 @@
11
import assert from "node:assert/strict";
2-
import { createRequire, registerHooks } from "node:module";
3-
import { before, beforeEach, describe, it } from "node:test";
2+
import { beforeEach, describe, it } from "node:test";
3+
import { useAppStore } from "@geolibre/core";
4+
import { SKETCHES_SOURCE_KIND } from "@geolibre/plugins/geo-editor-geometry";
45
import type { GeoLibreSelection } from "@geolibre/plugins";
6+
import { createPluginLayerQueries } from "../apps/geolibre-desktop/src/lib/plugin-layer-queries";
57

6-
(globalThis as typeof globalThis & { window: typeof globalThis }).window = globalThis;
7-
(globalThis as typeof globalThis & { location: { search: string } }).location = { search: "" };
8-
const emptyStorage = { getItem: () => null };
9-
(globalThis as typeof globalThis & { sessionStorage: typeof emptyStorage }).sessionStorage =
10-
emptyStorage;
11-
(globalThis as typeof globalThis & { localStorage: typeof emptyStorage }).localStorage =
12-
emptyStorage;
13-
const maplibreGl = createRequire(`${process.cwd()}/package.json`)("maplibre-gl") as Record<
14-
string,
15-
unknown
16-
>;
17-
(globalThis as typeof globalThis & { __maplibreGl: Record<string, unknown> }).__maplibreGl =
18-
maplibreGl;
19-
const maplibreGlModuleSource = [
20-
"export default globalThis.__maplibreGl;",
21-
...Object.keys(maplibreGl)
22-
.filter((name) => name !== "default" && /^[A-Za-z_$][A-Za-z0-9_$]*$/.test(name))
23-
.map((name) => `export const ${name} = globalThis.__maplibreGl[${JSON.stringify(name)}];`),
24-
].join("\n");
25-
registerHooks({
26-
resolve(specifier, context, nextResolve) {
27-
if (specifier === "maplibre-gl") {
28-
return { url: "test:maplibre-gl", shortCircuit: true };
29-
}
30-
return nextResolve(specifier, context);
31-
},
32-
load(url, context, nextLoad) {
33-
if (url === "test:maplibre-gl") {
34-
return { format: "module", source: maplibreGlModuleSource, shortCircuit: true };
35-
}
36-
if (url.endsWith(".css")) {
37-
return { format: "module", source: "", shortCircuit: true };
38-
}
39-
if (url === "virtual:bundled-plugins") {
40-
return {
41-
format: "module",
42-
source: "export const bundledPluginManifestPaths = [];",
43-
shortCircuit: true,
44-
};
45-
}
46-
return nextLoad(url, context);
47-
},
48-
});
49-
const SKETCHES_SOURCE_KIND = "geoeditor-sketches";
50-
let useAppStore: typeof import("@geolibre/core").useAppStore;
51-
let createAppAPI: typeof import("../apps/geolibre-desktop/src/hooks/usePlugins").createAppAPI;
52-
53-
before(async () => {
54-
[{ useAppStore }, { createAppAPI }] = await Promise.all([
55-
import("@geolibre/core"),
56-
import("../apps/geolibre-desktop/src/hooks/usePlugins"),
57-
]);
58-
});
8+
// These exercise `createPluginLayerQueries`, which `createAppAPI` spreads into
9+
// the object it hands plugins, rather than reaching through `createAppAPI`
10+
// itself. Loading `usePlugins.ts` pulls in the whole built-in plugin registry
11+
// (and MapCanvas, CesiumCanvas, and every `maplibre-*` plugin with it), which
12+
// forced this file to stub `maplibre-gl`, `window`, and `localStorage` just to
13+
// import it, and put 39 browser-only modules into the coverage report. The
14+
// wiring itself is a typed spread, so `npm run build` is what holds it.
5915

6016
describe("external plugin query API", () => {
6117
beforeEach(() => {
@@ -74,18 +30,18 @@ describe("external plugin query API", () => {
7430
store.selectLayer(layerId);
7531
store.selectFeatures(["A", "B"]);
7632

77-
const app = createAppAPI();
78-
assert.equal(app.getSelectedLayerId?.(), layerId);
33+
const app = createPluginLayerQueries();
34+
assert.equal(app.getSelectedLayerId(), layerId);
7935
assert.deepEqual(
80-
app.getSelectedFeatures?.().map((feature) => feature.id),
36+
app.getSelectedFeatures().map((feature) => feature.id),
8137
["A", "B"],
8238
);
8339
});
8440

8541
it("notifies and unsubscribes selection listeners", () => {
86-
const app = createAppAPI();
42+
const app = createPluginLayerQueries();
8743
const events: unknown[] = [];
88-
const unsubscribe = app.onSelectionChange?.((selection) => events.push(selection));
44+
const unsubscribe = app.onSelectionChange((selection) => events.push(selection));
8945
assert.ok(unsubscribe);
9046
useAppStore.getState().selectFeatures(["A"]);
9147
assert.equal(events.length, 1);
@@ -109,8 +65,8 @@ describe("external plugin query API", () => {
10965
});
11066
const before = JSON.stringify(useAppStore.getState());
11167

112-
const app = createAppAPI();
113-
assert.deepEqual(app.listLayers?.(), [
68+
const app = createPluginLayerQueries();
69+
assert.deepEqual(app.listLayers(), [
11470
{
11571
id: layerId,
11672
name: "Catchments",
@@ -119,17 +75,17 @@ describe("external plugin query API", () => {
11975
opacity: 1,
12076
},
12177
]);
122-
assert.deepEqual(app.getLayerFeatures?.(layerId), [
78+
assert.deepEqual(app.getLayerFeatures(layerId), [
12379
{
12480
type: "Feature",
12581
id: "A",
12682
properties: { NAME: "Upper Basin", AREA_KM2: 12.5 },
12783
geometry: { type: "Point", coordinates: [101.7, 3.1] },
12884
},
12985
]);
130-
app.getSelectedFeatures?.();
131-
app.getSelectedLayerId?.();
132-
app.getDrawnFeatures?.();
86+
app.getSelectedFeatures();
87+
app.getSelectedLayerId();
88+
app.getDrawnFeatures();
13389

13490
assert.equal(JSON.stringify(useAppStore.getState()), before);
13591
});
@@ -152,19 +108,19 @@ describe("external plugin query API", () => {
152108
});
153109
store.selectLayer(layerId);
154110

155-
const app = createAppAPI();
111+
const app = createPluginLayerQueries();
156112
let callbackSelection: GeoLibreSelection | undefined;
157-
const unsubscribe = app.onSelectionChange?.((selection) => {
113+
const unsubscribe = app.onSelectionChange((selection) => {
158114
callbackSelection = selection;
159115
});
160116
assert.ok(unsubscribe);
161117
store.selectFeatures(["A"]);
162118
assert.ok(callbackSelection);
163119

164120
const returnedFeatures = [
165-
app.getLayerFeatures?.(layerId)[0],
166-
app.getSelectedFeatures?.()[0],
167-
app.getDrawnFeatures?.()[0],
121+
app.getLayerFeatures(layerId)[0],
122+
app.getSelectedFeatures()[0],
123+
app.getDrawnFeatures()[0],
168124
callbackSelection.features[0],
169125
];
170126
for (const feature of returnedFeatures) {
@@ -184,8 +140,8 @@ describe("external plugin query API", () => {
184140
});
185141

186142
it("throws when a requested layer does not exist", () => {
187-
const app = createAppAPI();
188-
assert.throws(() => app.getLayerFeatures?.("missing-layer"), {
143+
const app = createPluginLayerQueries();
144+
assert.throws(() => app.getLayerFeatures("missing-layer"), {
189145
message: 'No layer with id "missing-layer"',
190146
});
191147
});
@@ -203,8 +159,8 @@ describe("external plugin query API", () => {
203159
store.selectFeatures(["1"]);
204160

205161
assert.deepEqual(
206-
createAppAPI()
207-
.getSelectedFeatures?.()
162+
createPluginLayerQueries()
163+
.getSelectedFeatures()
208164
.map((feature) => feature.properties?.NAME),
209165
["Second"],
210166
);
@@ -218,9 +174,9 @@ describe("external plugin query API", () => {
218174
});
219175
store.selectLayer(layerId);
220176

221-
const app = createAppAPI();
222-
assert.equal(app.getSelectedLayerId?.(), layerId);
223-
assert.deepEqual(app.getSelectedFeatures?.(), []);
177+
const app = createPluginLayerQueries();
178+
assert.equal(app.getSelectedLayerId(), layerId);
179+
assert.deepEqual(app.getSelectedFeatures(), []);
224180
});
225181

226182
it("returns features from every sketch layer and excludes ordinary layers", () => {
@@ -244,7 +200,7 @@ describe("external plugin query API", () => {
244200
metadata: { sourceKind: SKETCHES_SOURCE_KIND },
245201
});
246202

247-
assert.deepEqual(createAppAPI().getDrawnFeatures?.(), [
203+
assert.deepEqual(createPluginLayerQueries().getDrawnFeatures(), [
248204
{
249205
type: "Feature",
250206
id: "drawn-1",

0 commit comments

Comments
 (0)