fix: synchronize dynamic GeoServer layer lifecycle
Optional sources became dynamic while dependent labels and controller state remained static, allowing stale requests to restore removed layers. Register dependent layers through the same lifecycle and invalidate target request generations before release.
This commit is contained in:
@@ -58,6 +58,155 @@ describe("WorkbenchMapController", () => {
|
||||
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 = pointCollection;
|
||||
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],
|
||||
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 };
|
||||
|
||||
Reference in New Issue
Block a user