import type { Map as MapLibreMap } from "maplibre-gl"; import { describe, expect, it, vi } from "vitest"; import { FLOW_LINE_LAYER_ID, FLOW_REVERSE_LINE_LAYER_ID, clearPipeFlowCalculations, createFlowOverlayState, getPipeFlowDirection, replacePipeFlowCalculations, showFlowOverlay, updatePipeFlowCalculation } from "./flow-overlay"; describe("flow overlay", () => { it("classifies signed velocities relative to pipe geometry", () => { expect(getPipeFlowDirection(2.5)).toBe(1); expect(getPipeFlowDirection(-0.01)).toBe(-1); expect(getPipeFlowDirection(0)).toBe(0); expect(getPipeFlowDirection(Number.NaN)).toBe(0); }); it("creates native forward and reverse layers with a deterministic all-pipe mock", async () => { const { map, layers } = createMap(); const state = createFlowOverlayState(); await showFlowOverlay(map as unknown as MapLibreMap, state, true); expect(layers.get(FLOW_LINE_LAYER_ID)).toMatchObject({ source: "pipes", "source-layer": "geo_pipes_mat", paint: { "line-pattern": "workbench-network-flow-pattern-forward" } }); expect(layers.get(FLOW_REVERSE_LINE_LAYER_ID)).toMatchObject({ source: "pipes", "source-layer": "geo_pipes_mat", paint: { "line-pattern": "workbench-network-flow-pattern-reverse" } }); expect([...layers.keys()].some((id) => id.includes("arrows"))).toBe(false); expect( JSON.stringify((layers.get(FLOW_LINE_LAYER_ID) as { paint: Record }).paint["line-opacity"]) ).toContain('["%",["to-number",["id"],0],2]'); }); it("updates one pipe direction without rebuilding its vector source", () => { const { map } = createMap(); const state = createFlowOverlayState(); const first = updatePipeFlowCalculation(map as unknown as MapLibreMap, state, { pipeId: "P-1", velocity: -2.4 }); const second = updatePipeFlowCalculation(map as unknown as MapLibreMap, state, { pipeId: "P-1", velocity: 1.2 }); expect(map.setFeatureState).toHaveBeenNthCalledWith(1, { source: "pipes", sourceLayer: "geo_pipes_mat", id: "P-1" }, { flowDirection: -1 }); expect(map.setFeatureState).toHaveBeenNthCalledWith(2, expect.anything(), { flowDirection: 1 }); expect(first).toMatchObject({ total: 1, reverse: 1 }); expect(second).toMatchObject({ total: 1, forward: 1 }); expect(map.getSource).not.toHaveBeenCalled(); }); it("replaces a snapshot and clears only stale flow state keys", async () => { const { map } = createMap(); const state = createFlowOverlayState(); updatePipeFlowCalculation(map as unknown as MapLibreMap, state, { pipeId: "P-old", velocity: 1 }); map.removeFeatureState.mockClear(); const summary = await replacePipeFlowCalculations( map as unknown as MapLibreMap, state, [ { pipeId: "P-new", velocity: -3 }, { pipeId: "P-new", velocity: 4 }, { pipeId: "", velocity: 1 } ] ); expect(map.removeFeatureState).toHaveBeenCalledWith(expect.objectContaining({ id: "P-old" }), "flowDirection"); expect(map.removeFeatureState).not.toHaveBeenCalledWith(expect.anything(), "highlighted"); expect(summary).toEqual({ total: 1, forward: 1, reverse: 0, stopped: 0, invalid: 1 }); }); it("returns to the all-pipe mock after clearing explicit calculations", async () => { const { map } = createMap(); const state = createFlowOverlayState(); await showFlowOverlay(map as unknown as MapLibreMap, state, true); updatePipeFlowCalculation(map as unknown as MapLibreMap, state, { pipeId: "P-1", velocity: 1 }); await clearPipeFlowCalculations(map as unknown as MapLibreMap, state); const latestOpacity = map.setPaintProperty.mock.calls .filter(([, property]) => property === "line-opacity") .at(-1)?.[2]; expect(JSON.stringify(latestOpacity)).toContain('["%",["to-number",["id"],0],2]'); }); it("applies large snapshots in animation-frame batches", async () => { const callbacks: FrameRequestCallback[] = []; vi.stubGlobal("window", { matchMedia: () => ({ matches: false }), requestAnimationFrame: vi.fn((callback: FrameRequestCallback) => { callbacks.push(callback); return callbacks.length; }), cancelAnimationFrame: vi.fn() }); const { map } = createMap(); const state = createFlowOverlayState(); const records = Array.from({ length: 2_001 }, (_, index) => ({ pipeId: index, velocity: index % 2 === 0 ? 1 : -1 })); const pending = replacePipeFlowCalculations( map as unknown as MapLibreMap, state, records ); expect(map.setFeatureState).toHaveBeenCalledTimes(2_000); callbacks.shift()?.(16); const summary = await pending; expect(map.setFeatureState).toHaveBeenCalledTimes(2_001); expect(summary.total).toBe(2_001); vi.unstubAllGlobals(); }); }); function createMap() { const layers = new Map(); const images = new Set(); const map = { addImage: vi.fn((id: string) => { images.add(id); }), addLayer: vi.fn((layer: { id: string }) => { layers.set(layer.id, layer); return map; }), getLayer: vi.fn((id: string) => layers.get(id)), getSource: vi.fn(), hasImage: vi.fn((id: string) => images.has(id)), removeFeatureState: vi.fn(), setFeatureState: vi.fn(), setLayoutProperty: vi.fn(), setPaintProperty: vi.fn((id: string, property: string, value: unknown) => { const layer = layers.get(id) as { paint?: Record } | undefined; if (layer?.paint) layer.paint[property] = value; }), triggerRepaint: vi.fn() }; return { map, layers }; }