Files
next-tjwater-frontend/src/features/workbench/map/workbench-map-controller.test.ts
T
jiang c0e4231b83
Generic Container CI/CD / test-build-publish (push) Successful in 1m52s
Frontend CI/CD / build-test-publish-and-deploy (push) Successful in 1m52s
feat: expand intelligent investigation workspace
2026-10-30 17:30:19 +08:00

612 lines
25 KiB
TypeScript

import type { FeatureCollection } from "geojson";
import type { Map as MapLibreMap } from "maplibre-gl";
import { describe, expect, it, vi } from "vitest";
import {
FLOW_LINE_LAYER_ID,
createFlowOverlayState,
showFlowOverlay
} from "./flow-overlay";
import { WorkbenchMapController } from "./workbench-map-controller";
import { PRESENTATION_FEATURE_SOURCE } from "./presentation-highlight-style";
describe("WorkbenchMapController", () => {
it("tags presentation geometry with its verified source without changing manual state or cached features", async () => {
const map = createMap();
const controller = createController(map, pointCollection);
const data = await controller.queryPresentationFeatures("junctions", ["J-1"], new AbortController().signal);
expect(data.features[0].properties).toEqual({ id: "J-1", [PRESENTATION_FEATURE_SOURCE]: "junctions" });
expect(pointCollection.features[0].properties).toEqual({ id: "J-1" });
expect(map.setFeatureState).not.toHaveBeenCalled();
expect(map.easeTo).not.toHaveBeenCalled();
expect(controller.getSnapshot().targets).toEqual([]);
});
it("clears every controller overlay, preserves layer styles, and invalidates pending analysis", async () => {
const map = createMap();
map.getLayer.mockReturnValue({} as never);
const setData = vi.fn();
map.getSource.mockReturnValue({ setData });
let resolveFeatures = (_value: FeatureCollection) => {};
const pendingAnalysis = new Promise<FeatureCollection>((resolve) => { resolveFeatures = resolve; });
const controller = new WorkbenchMapController({
getMap: () => map as unknown as MapLibreMap, isReady: () => true,
getPadding: () => ({ top: 0, right: 0, bottom: 0, left: 0 }),
fetchFeatures: (source) => source === "scada" ? pendingAnalysis : Promise.resolve(pointCollection)
});
await controller.highlight({ sourceId: "junctions", featureId: "J-1" });
controller.applyLayerGroupStyle("pipes", { color: "#123456" });
const showingFlow = controller.setFlowVisible(true);
const analysis = controller.renderScadaAnalysis([{ device_id: "J-1", level: "high" }]);
controller.clearOverlays();
await showingFlow;
resolveFeatures(pointCollection);
await expect(analysis).rejects.toThrow("ACTION_SUPERSEDED");
expect(setData).toHaveBeenCalledWith({ type: "FeatureCollection", features: [] });
expect(controller.getSnapshot()).toMatchObject({
target: null, targets: [], targetResult: null, scadaAnalysis: null,
flowVisible: false, pending: false, valueLabels: {}, styledGroups: ["pipes"]
});
expect(map.removeFeatureState).toHaveBeenCalledWith(expect.objectContaining({ id: "J-1" }), "highlighted");
expect(map.setLayoutProperty).toHaveBeenCalledWith(FLOW_LINE_LAYER_ID, "visibility", "none");
expect(map.easeTo).not.toHaveBeenCalled();
});
it("queries a batch once, fits all matches, and replaces and clears every highlight", async () => {
const map = createMap();
const collection: FeatureCollection = { type: "FeatureCollection", features: [
...pointCollection.features,
{ type: "Feature", properties: { id: "J-2" }, geometry: { type: "Point", coordinates: [121, 29] } }
] };
const fetchFeatures = vi.fn(async () => collection);
const controller = new WorkbenchMapController({
getMap: () => map as unknown as MapLibreMap, isReady: () => true,
getPadding: () => ({ top: 48, right: 48, bottom: 48, left: 48 }), fetchFeatures
});
await expect(controller.locateFeatures("junctions", ["J-1", "J-2", "J-1", "missing"])).resolves.toEqual({
located_ids: ["J-1", "J-2"], missing_ids: ["missing"], fitted: true
});
expect(fetchFeatures).toHaveBeenCalledExactlyOnceWith("junctions", ["J-1", "J-2", "missing"], undefined);
expect(map.fitBounds).toHaveBeenCalledWith([[120.7, 28], [121, 29]], expect.objectContaining({ maxZoom: 18 }));
for (const id of ["J-1", "J-2"]) {
expect(map.setFeatureState).toHaveBeenCalledWith({ source: "junctions", sourceLayer: "junctions", id }, { highlighted: true });
}
expect(controller.getSnapshot().targets).toHaveLength(2);
await controller.highlight({ sourceId: "junctions", featureId: "J-1" });
for (const id of ["J-1", "J-2"]) {
expect(map.removeFeatureState).toHaveBeenCalledWith({ source: "junctions", sourceLayer: "junctions", id }, "highlighted");
}
await controller.highlight({ sourceId: "junctions", featureIds: ["J-1", "J-2"] });
map.removeFeatureState.mockClear();
controller.clearHighlight();
expect(map.removeFeatureState).toHaveBeenCalledTimes(2);
expect(controller.getSnapshot()).toMatchObject({ targets: [], target: null, targetResult: null });
});
it("keeps highlight-only and zoom-only batch behavior separate", async () => {
const map = createMap();
const controller = createController(map, lineCollection);
const target = { sourceId: "pipes" as const, featureIds: ["P-1", "missing"] };
await controller.highlight(target);
expect(map.fitBounds).not.toHaveBeenCalled();
expect(map.easeTo).not.toHaveBeenCalled();
expect(controller.getSnapshot().targetResult).toMatchObject({ featureIds: ["P-1"], missingIds: ["missing"] });
map.setFeatureState.mockClear();
await controller.zoomToFeature(target);
expect(map.fitBounds).toHaveBeenCalledOnce();
expect(map.setFeatureState).not.toHaveBeenCalled();
expect(map.removeFeatureState).not.toHaveBeenCalled();
expect(controller.getSnapshot().targets).toEqual([{ sourceId: "pipes", featureId: "P-1" }]);
});
it("cancels an in-flight batch without applying late highlights or moving the camera", async () => {
const map = createMap();
const abort = new AbortController();
let resolveFeatures = (_value: FeatureCollection) => {};
const fetchFeatures = vi.fn(() => new Promise<FeatureCollection>((resolve) => { resolveFeatures = resolve; }));
const controller = new WorkbenchMapController({
getMap: () => map as unknown as MapLibreMap, isReady: () => true,
getPadding: () => ({ top: 0, right: 0, bottom: 0, left: 0 }), fetchFeatures
});
const locating = controller.locateFeatures("junctions", ["J-1", "J-2"], abort.signal);
abort.abort();
resolveFeatures(pointCollection);
await expect(locating).rejects.toThrow();
expect(fetchFeatures).toHaveBeenCalledWith("junctions", ["J-1", "J-2"], abort.signal);
expect(map.fitBounds).not.toHaveBeenCalled();
expect(map.setFeatureState).not.toHaveBeenCalled();
expect(controller.getSnapshot()).toMatchObject({ pending: false, targets: [] });
});
it("rejects empty and oversized batches without querying or moving", async () => {
const map = createMap();
const controller = createController(map, pointCollection);
for (const ids of [[], [" "], Array.from({ length: 101 }, (_, i) => `J-${i}`)]) {
await expect(controller.locateFeatures("junctions", ids)).rejects.toThrow("INVALID_FEATURE_IDS");
}
expect(map.fitBounds).not.toHaveBeenCalled();
expect(map.setFeatureState).not.toHaveBeenCalled();
});
it("returns only resolved IDs and rejects a missing target rather than fitting the whole network", async () => {
const map = createMap();
const controller = createController(map, pointCollection);
await expect(controller.locateFeatures("junctions", ["J-1", "missing"])).resolves.toEqual({ located_ids: ["J-1"], missing_ids: ["missing"], fitted: true });
expect(map.fitBounds).toHaveBeenCalledWith(expect.anything(), expect.objectContaining({ duration: 0 }));
await expect(controller.locateFeatures("junctions", ["missing"])).rejects.toThrow("FEATURE_NOT_FOUND");
});
it("locates points with zoom 18 and keeps selected state untouched", async () => {
const map = createMap();
const controller = createController(map, pointCollection);
await controller.locateAndHighlight({ sourceId: "junctions", featureId: "J-1" });
expect(map.easeTo).toHaveBeenCalledWith(expect.objectContaining({ center: [120.7, 28], zoom: 18 }));
expect(map.setFeatureState).toHaveBeenCalledWith(expect.objectContaining({ source: "junctions", id: "J-1" }), { highlighted: true });
const clearHighlight = controller.clearHighlight;
clearHighlight();
expect(map.removeFeatureState).toHaveBeenCalledWith(expect.anything(), "highlighted");
expect(map.removeFeatureState).not.toHaveBeenCalledWith(expect.anything(), "selected");
});
it("clears a stale highlight without touching a removed source", async () => {
const map = createMap();
const controller = createController(map, pointCollection);
await controller.highlight({ sourceId: "junctions", featureId: "J-1" });
map.getSource.mockReturnValue(undefined);
controller.clearHighlight();
expect(map.removeFeatureState).not.toHaveBeenCalled();
expect(controller.getSnapshot().target).toBeNull();
});
it("ignores a resolved highlight after its source has been removed", async () => {
const map = createMap();
map.getSource.mockReturnValue(undefined);
const controller = createController(map, pointCollection);
await controller.highlight({ sourceId: "junctions", featureId: "J-1" });
expect(map.setFeatureState).not.toHaveBeenCalled();
expect(controller.getSnapshot().target).toBeNull();
expect(controller.getSnapshot().errorCode).toBe("FEATURE_NOT_FOUND");
});
it("does not move to a cached feature after its source has been removed", async () => {
const map = createMap();
const controller = createController(map, pointCollection);
const target = { sourceId: "junctions" as const, featureId: "J-1" };
await controller.locateAndHighlight(target);
map.getSource.mockReturnValue(undefined);
await controller.locateAndHighlight(target);
expect(map.easeTo).toHaveBeenCalledTimes(1);
expect(controller.getSnapshot().errorCode).toBe("FEATURE_NOT_FOUND");
});
it("drops cached features and presentation state when a dynamic source disappears", async () => {
const map = createMap();
map.getLayer.mockReturnValue({} as never);
let collection: FeatureCollection = { ...pointCollection, features: [{ ...pointCollection.features[0], properties: { id: "V-1" } }] };
const fetchFeatures = vi.fn(async () => collection);
const controller = new WorkbenchMapController({
getMap: () => map as unknown as MapLibreMap,
isReady: () => true,
getPadding: () => ({ top: 0, right: 0, bottom: 0, left: 0 }),
fetchFeatures
});
const target = { sourceId: "valves" as const, featureId: "V-1" };
await controller.locateAndHighlight(target);
controller.applyLayerGroupStyle("valves", { iconMultiplier: 1.5 });
await controller.showValueLabel({
sourceId: "valves",
property: "elevation",
precision: 1,
showValue: true,
showId: false,
textSize: 12,
textColor: "#0F172A"
});
controller.syncAvailableSources(["pipes", "junctions", "scada"]);
expect(controller.getSnapshot()).toMatchObject({
target: null,
valueLabels: {},
styledGroups: []
});
collection = {
...pointCollection,
features: [{
...pointCollection.features[0],
properties: { id: "V-1" },
geometry: { type: "Point", coordinates: [121.9, 29.1] }
}]
};
map.easeTo.mockClear();
await controller.locateAndHighlight(target);
expect(fetchFeatures).toHaveBeenCalledTimes(2);
expect(map.easeTo).toHaveBeenCalledWith(expect.objectContaining({ center: [121.9, 29.1] }));
});
it("ignores an in-flight dynamic-source result from an earlier availability generation", async () => {
const map = createMap();
let resolveFeatures = (_collection: FeatureCollection) => {};
const featureResponse = new Promise<FeatureCollection>((resolve) => {
resolveFeatures = resolve;
});
const controller = new WorkbenchMapController({
getMap: () => map as unknown as MapLibreMap,
isReady: () => true,
getPadding: () => ({ top: 0, right: 0, bottom: 0, left: 0 }),
fetchFeatures: async () => featureResponse
});
controller.syncAvailableSources(["pipes", "junctions", "valves", "scada"]);
const locating = controller.locateAndHighlight({ sourceId: "valves", featureId: "V-1" });
controller.syncAvailableSources(["pipes", "junctions", "scada"]);
controller.syncAvailableSources(["pipes", "junctions", "valves", "scada"]);
resolveFeatures(pointCollection);
await locating;
expect(map.easeTo).not.toHaveBeenCalled();
expect(map.setFeatureState).not.toHaveBeenCalled();
expect(controller.getSnapshot().target).toBeNull();
});
it("keeps a newer cached target when an older dynamic-source request resolves", async () => {
const map = createMap();
let resolveDynamicFeatures = (_collection: FeatureCollection) => {};
const dynamicFeatureResponse = new Promise<FeatureCollection>((resolve) => {
resolveDynamicFeatures = resolve;
});
const fetchFeatures = vi.fn(async (sourceId: string) =>
sourceId === "valves" ? dynamicFeatureResponse : pointCollection
);
const controller = new WorkbenchMapController({
getMap: () => map as unknown as MapLibreMap,
isReady: () => true,
getPadding: () => ({ top: 0, right: 0, bottom: 0, left: 0 }),
fetchFeatures
});
const cachedTarget = { sourceId: "junctions" as const, featureId: "J-1" };
await controller.locateAndHighlight(cachedTarget);
const locatingDynamicTarget = controller.locateAndHighlight({ sourceId: "valves", featureId: "V-1" });
await controller.locateAndHighlight(cachedTarget);
expect(controller.getSnapshot()).toMatchObject({ target: cachedTarget, pending: false });
resolveDynamicFeatures(pointCollection);
await locatingDynamicTarget;
expect(map.easeTo).toHaveBeenCalledTimes(2);
expect(controller.getSnapshot()).toMatchObject({ target: cachedTarget, pending: false });
});
it("does not restore a target after its in-flight request is cleared", async () => {
const map = createMap();
let resolveFeatures = (_collection: FeatureCollection) => {};
const featureResponse = new Promise<FeatureCollection>((resolve) => {
resolveFeatures = resolve;
});
const controller = new WorkbenchMapController({
getMap: () => map as unknown as MapLibreMap,
isReady: () => true,
getPadding: () => ({ top: 0, right: 0, bottom: 0, left: 0 }),
fetchFeatures: async () => featureResponse
});
const locating = controller.locateAndHighlight({ sourceId: "valves", featureId: "V-1" });
controller.clearHighlight();
resolveFeatures(pointCollection);
await locating;
expect(map.easeTo).not.toHaveBeenCalled();
expect(map.setFeatureState).not.toHaveBeenCalled();
expect(controller.getSnapshot()).toMatchObject({ target: null, pending: false });
});
it("reports an unavailable target when its source disappears in flight", async () => {
const map = createMap();
let resolveFeatures = (_collection: FeatureCollection) => {};
const featureResponse = new Promise<FeatureCollection>((resolve) => {
resolveFeatures = resolve;
});
const controller = new WorkbenchMapController({
getMap: () => map as unknown as MapLibreMap,
isReady: () => true,
getPadding: () => ({ top: 0, right: 0, bottom: 0, left: 0 }),
fetchFeatures: async () => featureResponse
});
controller.syncAvailableSources(["pipes", "junctions", "valves", "scada"]);
const locating = controller.locateAndHighlight({ sourceId: "valves", featureId: "V-1" });
controller.syncAvailableSources(["pipes", "junctions", "scada"]);
resolveFeatures(pointCollection);
await locating;
expect(controller.getSnapshot()).toMatchObject({
target: null,
pending: false,
errorCode: "WFS_UNAVAILABLE"
});
});
it("fits line bounds and uses responsive workbench padding", async () => {
const map = createMap();
const padding = { top: 50, right: 420, bottom: 50, left: 320 };
const controller = createController(map, lineCollection, padding);
await controller.locateAndHighlight({ sourceId: "pipes", featureId: "P-1" });
expect(map.fitBounds).toHaveBeenCalledWith([[120, 28], [121, 29]], expect.objectContaining({ padding, maxZoom: 18 }));
});
it("zooms to a feature without changing its highlight state", async () => {
const map = createMap();
const controller = createController(map, pointCollection);
await controller.zoomToFeature({ sourceId: "junctions", featureId: "J-1" });
expect(map.easeTo).toHaveBeenCalledWith(expect.objectContaining({ center: [120.7, 28], zoom: 18 }));
expect(map.setFeatureState).not.toHaveBeenCalled();
expect(controller.getSnapshot().target).toBeNull();
});
it("queries GeoServer WFS directly when no feature adapter is supplied", async () => {
const map = createMap();
const fetchMock = vi.fn(async (_input: RequestInfo | URL) => new Response(JSON.stringify(pointCollection), {
status: 200,
headers: { "Content-Type": "application/json" }
}));
vi.stubGlobal("fetch", fetchMock);
const controller = new WorkbenchMapController({
getMap: () => map as unknown as MapLibreMap,
isReady: () => true,
getPadding: () => ({ top: 0, right: 0, bottom: 0, left: 0 })
});
await controller.highlight({ sourceId: "junctions", featureId: "J-1" });
const requestUrl = new URL(String(fetchMock.mock.calls[0]?.[0]));
expect(requestUrl.pathname).toBe("/geoserver/tjwater_next/ows");
expect(requestUrl.searchParams.get("request")).toBe("GetFeature");
expect(requestUrl.searchParams.get("cql_filter")).toBe("\"id\" IN ('J-1')");
expect(map.setFeatureState).toHaveBeenCalled();
vi.unstubAllGlobals();
});
it("renders flow from the complete pipes vector source", async () => {
const map = createMap();
const layers = new Map<string, unknown>();
map.getLayer.mockImplementation((id: string) => layers.get(id));
map.addLayer.mockImplementation((layer: { id: string }) => {
layers.set(layer.id, layer);
return map as never;
});
vi.stubGlobal("window", {
matchMedia: () => ({ matches: true }),
requestAnimationFrame: vi.fn(),
cancelAnimationFrame: vi.fn()
});
await showFlowOverlay(map as unknown as MapLibreMap, createFlowOverlayState(), true);
expect(layers.get(FLOW_LINE_LAYER_ID)).toMatchObject({
source: "pipes",
"source-layer": "pipes"
});
expect([...layers.keys()].some((id) => id.includes("arrows"))).toBe(false);
vi.unstubAllGlobals();
});
it("updates signed pipe velocity and exposes direction counts", () => {
const map = createMap();
const controller = createController(map, pointCollection);
controller.updatePipeFlowCalculation({ pipeId: "P-1", velocity: -1.8 });
expect(map.setFeatureState).toHaveBeenCalledWith(
expect.objectContaining({
source: "pipes",
sourceLayer: "pipes",
id: "P-1"
}),
{ flowDirection: -1 }
);
expect(controller.getSnapshot().flowSummary).toMatchObject({
total: 1,
forward: 0,
reverse: 1,
stopped: 0
});
});
it("replaces complete pipe calculation snapshots", async () => {
const map = createMap();
const controller = createController(map, pointCollection);
await controller.replacePipeFlowCalculations([
{ pipeId: "P-1", velocity: 2 },
{ pipeId: "P-2", velocity: 0 },
{ pipeId: "", velocity: 4 }
]);
expect(controller.getSnapshot()).toMatchObject({
pending: false,
flowSummary: {
total: 2,
forward: 1,
reverse: 0,
stopped: 1,
invalid: 1
}
});
});
it("keeps MVT labels per group and supports group or global clearing", async () => {
const map = createMap();
map.getLayer.mockImplementation((id: string) => id.startsWith("workbench-value-label-") ? {} as never : undefined);
const controller = createController(map, pointCollection);
await controller.showValueLabel({
sourceId: "pipes",
property: "diameter",
precision: 0,
unit: "mm",
showValue: true,
showId: false,
textSize: 12,
textColor: "#0F172A"
});
map.setLayoutProperty.mockClear();
await controller.showValueLabel({
sourceId: "junctions",
property: "pressure",
precision: 1,
unit: "m",
showValue: true,
showId: true,
textSize: 13,
textColor: "#0F172A"
});
expect(map.setLayoutProperty).toHaveBeenCalledWith(
"workbench-value-label-junctions",
"text-field",
expect.arrayContaining(["concat"])
);
expect(map.setLayoutProperty).toHaveBeenCalledWith(
"workbench-value-label-junctions",
"visibility",
"visible"
);
expect(map.setFilter).toHaveBeenCalledWith(
"workbench-value-label-junctions",
["has", "pressure"]
);
expect(controller.getSnapshot().valueLabels.junctions).toMatchObject({
sourceId: "junctions",
property: "pressure",
showId: true
});
expect(controller.getSnapshot().valueLabels.pipes).toMatchObject({
sourceId: "pipes",
property: "diameter"
});
expect(map.setLayoutProperty).not.toHaveBeenCalledWith(
"workbench-value-label-pipes",
"visibility",
"none"
);
controller.clearValueLabel("junctions");
expect(map.setLayoutProperty).toHaveBeenCalledWith(
"workbench-value-label-junctions",
"visibility",
"none"
);
expect(controller.getSnapshot().valueLabels).toEqual({
pipes: expect.objectContaining({ property: "diameter" })
});
const clearValueLabel = controller.clearValueLabel;
clearValueLabel();
expect(map.setLayoutProperty).toHaveBeenCalledWith(
"workbench-value-label-pipes",
"visibility",
"none"
);
expect(controller.getSnapshot().valueLabels).toEqual({});
});
it("applies icon scaling to symbol-backed point groups", () => {
const map = createMap();
map.getLayer.mockReturnValue({} as never);
const controller = createController(map, pointCollection);
controller.applyLayerGroupStyle("valves", { iconMultiplier: 1.8 });
expect(map.setLayoutProperty).toHaveBeenCalledWith(
"valves-symbol",
"icon-size",
expect.arrayContaining(["*", expect.anything(), 1.8])
);
});
it("does not clear the last valid highlight when a target is missing", async () => {
const map = createMap();
let collection = pointCollection;
const controller = new WorkbenchMapController({
getMap: () => map as unknown as MapLibreMap,
isReady: () => true,
getPadding: () => ({ top: 0, right: 0, bottom: 0, left: 0 }),
fetchFeatures: async () => collection
});
await controller.highlight({ sourceId: "junctions", featureId: "J-1" });
collection = { type: "FeatureCollection", features: [] };
await controller.highlight({ sourceId: "junctions", featureId: "missing" });
expect(controller.getSnapshot().target).toEqual({ sourceId: "junctions", featureId: "J-1" });
expect(controller.getSnapshot().errorCode).toBe("FEATURE_NOT_FOUND");
expect(map.removeFeatureState).not.toHaveBeenCalled();
});
it("renders SCADA analysis through the registered GeoJSON source", async () => {
const map = createMap();
const source = { setData: vi.fn() };
map.getSource.mockReturnValue(source);
const controller = createController(map, {
type: "FeatureCollection",
features: [
{
type: "Feature",
geometry: { type: "Point", coordinates: [120.7, 28] },
properties: { device_id: "MP01" }
}
]
});
await expect(
controller.renderScadaAnalysis([{ device_id: "MP01", level: "high" }])
).resolves.toMatchObject({
rendered_ids: ["MP01"],
fitted: true
});
expect(source.setData).toHaveBeenCalled();
expect(controller.getSnapshot()).toMatchObject({
errorCode: null,
pending: false,
scadaAnalysis: {
rendered_ids: ["MP01"],
fitted: true
}
});
});
});
const pointCollection: FeatureCollection = { type: "FeatureCollection", features: [{ type: "Feature", geometry: { type: "Point", coordinates: [120.7, 28] }, properties: { id: "J-1" } }] };
const lineCollection: FeatureCollection = { type: "FeatureCollection", features: [{ type: "Feature", geometry: { type: "LineString", coordinates: [[120, 28], [121, 29]] }, properties: { id: "P-1" } }] };
function createMap() {
const images = new Set<string>();
return {
easeTo: vi.fn(), fitBounds: vi.fn(), setFeatureState: vi.fn(), removeFeatureState: vi.fn(),
getSource: vi.fn((_id: string): unknown => ({})), getLayer: vi.fn(), addLayer: vi.fn(), setPaintProperty: vi.fn(),
setLayoutProperty: vi.fn(), setFilter: vi.fn(), removeControl: vi.fn(), triggerRepaint: vi.fn(),
hasImage: vi.fn((id: string) => images.has(id)),
addImage: vi.fn((id: string) => {
images.add(id);
})
};
}
function createController(map: ReturnType<typeof createMap>, collection: FeatureCollection, padding = { top: 48, right: 48, bottom: 48, left: 48 }) {
return new WorkbenchMapController({
getMap: () => map as unknown as MapLibreMap,
isReady: () => true,
getPadding: () => padding,
fetchFeatures: vi.fn(async () => collection)
});
}