mirror of
https://github.com/zvx-echo6/central.git
synced 2026-06-10 20:04:43 +02:00
Merge pull request #75 from zvx-echo6/v0_9_14_fused_fire
v0.9.14: fused FIRMS+WFIGS fire view
This commit is contained in:
commit
91f8478f80
4 changed files with 374 additions and 1 deletions
|
|
@ -20,7 +20,7 @@ logger = logging.getLogger("central.gui.routes")
|
|||
|
||||
|
||||
from fastapi import APIRouter, Depends, Form, Request
|
||||
from fastapi.responses import HTMLResponse, RedirectResponse, Response
|
||||
from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse, Response
|
||||
from central.bootstrap_config import get_settings
|
||||
from central.gui.csrf import (
|
||||
reuse_or_generate_pre_auth_csrf,
|
||||
|
|
@ -3457,6 +3457,126 @@ def _decorate_table_events(events: list[dict]) -> None:
|
|||
|
||||
|
||||
|
||||
# --- Fused fire view (v0.9.14): WFIGS perimeters + nearby FIRMS hotspots -----
|
||||
# FIRMS hotspots carry no IrwinID, so the link is spatial+temporal: a hotspot is
|
||||
# "confirmed" (part of a known fire) when it lies within FIRE_FUSE_RADIUS_M of a
|
||||
# live perimeter AND within FIRE_FUSE_WINDOW_H of it. Hotspots matching no
|
||||
# perimeter are "unconfirmed" (possible new fire ahead of an official perimeter).
|
||||
# Read-only: SELECT-only, no DML.
|
||||
FIRE_FUSE_RADIUS_M = 1000
|
||||
FIRE_FUSE_WINDOW_H = 72
|
||||
|
||||
|
||||
def _fused_bbox(params: dict) -> tuple[float, float, float, float] | None:
|
||||
"""Parse optional north/south/east/west into (west, south, east, north), or
|
||||
None if absent/degenerate/out-of-range (mirrors _parse_events_params)."""
|
||||
try:
|
||||
n = float(params["north"]); so = float(params["south"])
|
||||
e = float(params["east"]); w = float(params["west"])
|
||||
except (KeyError, TypeError, ValueError):
|
||||
return None
|
||||
if -90 <= so < n <= 90 and -180 <= w < e <= 180:
|
||||
return (w, so, e, n)
|
||||
return None
|
||||
|
||||
|
||||
def _shape_fused_fire(row) -> dict[str, Any]:
|
||||
"""Shape a confirmed-fire row (perimeter + aggregated hotspots) for JSON."""
|
||||
return {
|
||||
"id": row["id"],
|
||||
"time": row["time"].isoformat(),
|
||||
"incident_name": row["incident_name"],
|
||||
"irwin_id": row["irwin_id"],
|
||||
"acres": row["acres"],
|
||||
"cause": row["cause"],
|
||||
"geometry": row["geometry"],
|
||||
"hotspot_count": row["hotspot_count"],
|
||||
"max_frp": row["max_frp"],
|
||||
"hotspots": row["hotspots"] or [],
|
||||
}
|
||||
|
||||
|
||||
def _shape_unconfirmed_hotspot(row) -> dict[str, Any]:
|
||||
"""Shape an unconfirmed FIRMS hotspot (no nearby perimeter) for JSON."""
|
||||
return {
|
||||
"id": row["id"],
|
||||
"time": row["time"].isoformat(),
|
||||
"geometry": row["geometry"],
|
||||
"frp": row["frp"],
|
||||
"confidence": row["confidence"],
|
||||
"satellite": row["satellite"],
|
||||
}
|
||||
|
||||
|
||||
@router.get("/events/fire-fused.json")
|
||||
async def fire_fused(request: Request) -> Response:
|
||||
"""Fused fire view: each live WFIGS perimeter with its nearby/contemporaneous
|
||||
FIRMS hotspots, plus standalone ("unconfirmed") hotspots. Honors an optional
|
||||
north/south/east/west viewport bbox. Read-only."""
|
||||
pool = get_pool()
|
||||
bbox = _fused_bbox(dict(request.query_params))
|
||||
|
||||
confirmed_sql = """
|
||||
SELECT p.id, p.time,
|
||||
p.payload->'data'->'data'->'raw'->>'poly_IncidentName' AS incident_name,
|
||||
p.payload->'data'->'data'->'raw'->>'attr_IrwinID' AS irwin_id,
|
||||
(p.payload->'data'->'data'->'raw'->>'poly_GISAcres')::float AS acres,
|
||||
p.payload->'data'->'data'->'raw'->>'attr_FireCause' AS cause,
|
||||
ST_AsGeoJSON(p.geom)::jsonb AS geometry,
|
||||
count(h.id) AS hotspot_count,
|
||||
max((h.payload->'data'->'data'->>'frp')::float) AS max_frp,
|
||||
COALESCE(jsonb_agg(jsonb_build_object(
|
||||
'geometry', ST_AsGeoJSON(h.geom)::jsonb,
|
||||
'frp', h.payload->'data'->'data'->>'frp',
|
||||
'confidence', h.payload->'data'->'data'->>'confidence',
|
||||
'satellite', h.payload->'data'->'data'->>'satellite',
|
||||
'time', h.time
|
||||
) ORDER BY h.time DESC) FILTER (WHERE h.id IS NOT NULL), '[]'::jsonb) AS hotspots
|
||||
FROM events p
|
||||
LEFT JOIN events h
|
||||
ON h.adapter = 'firms' AND h.category NOT LIKE '%.removed' AND h.geom IS NOT NULL
|
||||
AND ST_DWithin(p.geom::geography, h.geom::geography, $1)
|
||||
AND h.time > p.time - ($2 * interval '1 hour')
|
||||
WHERE p.adapter = 'wfigs_perimeters' AND p.category NOT LIKE '%.removed'
|
||||
AND p.geom IS NOT NULL{p_bbox}
|
||||
GROUP BY p.id, p.time
|
||||
ORDER BY hotspot_count DESC
|
||||
"""
|
||||
unconfirmed_sql = """
|
||||
SELECT h.id, h.time,
|
||||
ST_AsGeoJSON(h.geom)::jsonb AS geometry,
|
||||
h.payload->'data'->'data'->>'frp' AS frp,
|
||||
h.payload->'data'->'data'->>'confidence' AS confidence,
|
||||
h.payload->'data'->'data'->>'satellite' AS satellite
|
||||
FROM events h
|
||||
WHERE h.adapter = 'firms' AND h.category NOT LIKE '%.removed'
|
||||
AND h.geom IS NOT NULL{h_bbox}
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM events p
|
||||
WHERE p.adapter = 'wfigs_perimeters' AND p.category NOT LIKE '%.removed'
|
||||
AND p.geom IS NOT NULL
|
||||
AND ST_DWithin(p.geom::geography, h.geom::geography, $1)
|
||||
AND h.time > p.time - ($2 * interval '1 hour')
|
||||
)
|
||||
ORDER BY h.time DESC
|
||||
"""
|
||||
args: list[Any] = [FIRE_FUSE_RADIUS_M, FIRE_FUSE_WINDOW_H]
|
||||
p_bbox = h_bbox = ""
|
||||
if bbox:
|
||||
p_bbox = "\n AND ST_Intersects(p.geom, ST_MakeEnvelope($3, $4, $5, $6, 4326))"
|
||||
h_bbox = "\n AND ST_Intersects(h.geom, ST_MakeEnvelope($3, $4, $5, $6, 4326))"
|
||||
args.extend(bbox)
|
||||
|
||||
async with pool.acquire() as conn:
|
||||
confirmed = await conn.fetch(confirmed_sql.format(p_bbox=p_bbox), *args)
|
||||
unconfirmed = await conn.fetch(unconfirmed_sql.format(h_bbox=h_bbox), *args)
|
||||
|
||||
return JSONResponse({
|
||||
"fires": [_shape_fused_fire(r) for r in confirmed],
|
||||
"unconfirmed": [_shape_unconfirmed_hotspot(r) for r in unconfirmed],
|
||||
})
|
||||
|
||||
|
||||
@router.get("/events.json")
|
||||
async def events_json(request: Request):
|
||||
"""
|
||||
|
|
|
|||
|
|
@ -29,6 +29,9 @@
|
|||
--shadow-pop: 0 4px 16px rgba(0,0,0,0.08);
|
||||
--radius: 6px;
|
||||
--radius-sm: 4px;
|
||||
/* Fused fire view (v0.9.14): retune here without touching JS. */
|
||||
--fire-confirmed: #d7261e; /* perimeter + hotspots */
|
||||
--fire-unconfirmed: #f59e0b; /* hotspots only -- possible new fire */
|
||||
}
|
||||
|
||||
/* ─── element base ──────────────────────────────────────────────────────── */
|
||||
|
|
@ -578,6 +581,9 @@ table tbody tr:hover { background: var(--bg-soft); }
|
|||
.evt-triangle { border: none; clip-path: polygon(50% 0%, 100% 100%, 0% 100%); }
|
||||
.evt-star { border: none; clip-path: polygon(50% 0%, 61% 35%, 98% 35%, 68% 57%, 79% 91%, 50% 70%, 21% 91%, 32% 57%, 2% 35%, 39% 35%); }
|
||||
.evt-marker.evt-hl { filter: drop-shadow(0 0 4px #ff3333); transform: scale(1.35); }
|
||||
/* Fused-fire centroid glyph: larger, with a soft glow to read as 'a fire'. */
|
||||
.evt-marker.fire-glyph { width: 17px; height: 17px; border-color: rgba(0,0,0,0.7);
|
||||
box-shadow: 0 0 6px 1px rgba(215,38,30,0.55); }
|
||||
|
||||
/* pagination */
|
||||
.paginator { display: flex; flex-wrap: wrap; align-items: center; gap: 4px; margin-top: 16px; font-size: 13px; }
|
||||
|
|
|
|||
|
|
@ -96,6 +96,10 @@
|
|||
<input type="checkbox" id="map-filter-toggle" {{ 'checked' if filter_state.map_filter else '' }}>
|
||||
Filter table by map view
|
||||
</label>
|
||||
<label class="map-filter-toggle tb" title="Pair WFIGS perimeters with nearby FIRMS hotspots into a single fire; lone hotspots show amber as possible new fires.">
|
||||
<input type="checkbox" id="fuse-fire-toggle" checked>
|
||||
Fuse fire layers
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
|
@ -203,6 +207,7 @@
|
|||
spiderfyOnMaxZoom: true
|
||||
}).addTo(map);
|
||||
var polyGroup = L.featureGroup().addTo(map);
|
||||
var fireFusedGroup = L.featureGroup().addTo(map); // v0.9.14 fused fire view
|
||||
var highlightedRow = null;
|
||||
var highlightedLayer = null;
|
||||
var isInitialLoad = true;
|
||||
|
|
@ -299,15 +304,112 @@
|
|||
return html;
|
||||
}
|
||||
|
||||
// --- Fused fire view (v0.9.14) -----------------------------------------
|
||||
// Fire-domain adapters whose rows are replaced by the fused renderer.
|
||||
var FIRE_ADAPTERS = { firms: 1, wfigs_perimeters: 1, wfigs_incidents: 1 };
|
||||
var fuseFireToggle = document.getElementById("fuse-fire-toggle");
|
||||
if (fuseFireToggle) fuseFireToggle.addEventListener("change", rebindEventLayers);
|
||||
function fuseFireOn() { return fuseFireToggle && fuseFireToggle.checked; }
|
||||
// Colors come from CSS vars so they can be retuned without code.
|
||||
function cssVar(name, fallback) {
|
||||
var v = getComputedStyle(document.documentElement).getPropertyValue(name).trim();
|
||||
return v || fallback;
|
||||
}
|
||||
function fireConfirmedColor() { return cssVar("--fire-confirmed", "#d7261e"); }
|
||||
function fireUnconfirmedColor() { return cssVar("--fire-unconfirmed", "#f59e0b"); }
|
||||
|
||||
function fireFusedPopup(fire) {
|
||||
var name = fire.incident_name || "(unnamed fire)";
|
||||
var n = fire.hotspot_count || 0;
|
||||
var html = "<strong>🔥 " + name + "</strong><br>Confirmed fire (perimeter + " +
|
||||
n + " hotspot" + (n === 1 ? "" : "s") + ")";
|
||||
if (fire.acres != null) html += "<br>" + Math.round(fire.acres).toLocaleString() + " acres";
|
||||
if (fire.cause) html += "<br>Cause: " + fire.cause;
|
||||
if (fire.max_frp != null) html += "<br>Max FRP: " + fire.max_frp;
|
||||
if (fire.irwin_id) html += "<br><small>" + fire.irwin_id + "</small>";
|
||||
return html;
|
||||
}
|
||||
function unconfirmedPopup(h) {
|
||||
var html = "<strong>⚠ Unconfirmed hotspot</strong><br>Satellite detection, no perimeter yet";
|
||||
if (h.satellite) html += "<br>Satellite: " + h.satellite;
|
||||
if (h.frp) html += "<br>FRP: " + h.frp;
|
||||
if (h.confidence) html += "<br>Confidence: " + h.confidence;
|
||||
var t = h.time ? new Date(h.time).toLocaleString() : "";
|
||||
if (t) html += "<br><small>" + t + "</small>";
|
||||
return html;
|
||||
}
|
||||
|
||||
function drawConfirmedFire(fire, color) {
|
||||
if (fire.geometry) {
|
||||
var poly = L.geoJSON(fire.geometry, { style: { color: color, weight: 2,
|
||||
fillColor: color, opacity: 0.9, fillOpacity: 0.25 } });
|
||||
poly.bindPopup(fireFusedPopup(fire));
|
||||
poly.addTo(fireFusedGroup);
|
||||
}
|
||||
(fire.hotspots || []).forEach(function(h) {
|
||||
if (!h.geometry) return;
|
||||
L.geoJSON(h.geometry, { pointToLayer: function(f, ll) {
|
||||
return L.circleMarker(ll, { radius: 3, color: color, weight: 1,
|
||||
fillColor: color, fillOpacity: 0.8 }); },
|
||||
style: { color: color, weight: 1, fillColor: color, fillOpacity: 0.55 }
|
||||
}).addTo(fireFusedGroup);
|
||||
});
|
||||
// Collapsed glyph at the perimeter centroid; click expands (zoom to bounds).
|
||||
var center = centroidLatLng(fire.geometry);
|
||||
if (center) {
|
||||
var glyph = L.marker(center, { icon: L.divIcon({ className: "",
|
||||
html: '<div class="evt-marker evt-square fire-glyph" style="background:' + color + '"></div>',
|
||||
iconSize: [17, 17], iconAnchor: [9, 9] }) });
|
||||
glyph.bindPopup(fireFusedPopup(fire));
|
||||
glyph.on("click", function() {
|
||||
try { var b = L.geoJSON(fire.geometry).getBounds();
|
||||
if (b.isValid()) map.fitBounds(b.pad(0.3)); } catch (e) {}
|
||||
});
|
||||
glyph.addTo(fireFusedGroup);
|
||||
}
|
||||
}
|
||||
function drawUnconfirmedHotspot(h, color) {
|
||||
if (!h.geometry) return;
|
||||
var layer = L.geoJSON(h.geometry, { pointToLayer: function(f, ll) {
|
||||
return L.circleMarker(ll, { radius: 4, color: color, weight: 1,
|
||||
fillColor: color, fillOpacity: 0.7 }); },
|
||||
style: { color: color, weight: 1, fillColor: color, fillOpacity: 0.5 } });
|
||||
layer.bindPopup(unconfirmedPopup(h));
|
||||
layer.addTo(fireFusedGroup);
|
||||
}
|
||||
function renderFusedFires() {
|
||||
fireFusedGroup.clearLayers();
|
||||
var url = "/events/fire-fused.json";
|
||||
if (mapFilterOn()) {
|
||||
var b = map.getBounds();
|
||||
url += "?north=" + Math.min(90, b.getNorth()).toFixed(4) +
|
||||
"&south=" + Math.max(-90, b.getSouth()).toFixed(4) +
|
||||
"&east=" + wrapLon(b.getEast()).toFixed(4) +
|
||||
"&west=" + wrapLon(b.getWest()).toFixed(4);
|
||||
}
|
||||
fetch(url, { headers: { "Accept": "application/json" } })
|
||||
.then(function(r) { return r.ok ? r.json() : null; })
|
||||
.then(function(data) {
|
||||
if (!data) return;
|
||||
var cc = fireConfirmedColor(), uc = fireUnconfirmedColor();
|
||||
(data.fires || []).forEach(function(fire) { drawConfirmedFire(fire, cc); });
|
||||
(data.unconfirmed || []).forEach(function(h) { drawUnconfirmedHotspot(h, uc); });
|
||||
})
|
||||
.catch(function(e) { console.error("fused fire fetch failed", e); });
|
||||
}
|
||||
|
||||
function rebindEventLayers() {
|
||||
markerCluster.clearLayers();
|
||||
polyGroup.clearLayers();
|
||||
var fused = fuseFireOn();
|
||||
if (fused) { renderFusedFires(); } else { fireFusedGroup.clearLayers(); }
|
||||
|
||||
var rows = document.querySelectorAll("#events-rows tr.event-row[data-geometry]");
|
||||
|
||||
rows.forEach(function(row) {
|
||||
var geomStr = row.dataset.geometry;
|
||||
if (!geomStr || geomStr === "") return;
|
||||
if (fused && FIRE_ADAPTERS[row.dataset.adapter || ""]) return;
|
||||
|
||||
try {
|
||||
var geom = JSON.parse(geomStr);
|
||||
|
|
|
|||
145
tests/test_fire_fused.py
Normal file
145
tests/test_fire_fused.py
Normal file
|
|
@ -0,0 +1,145 @@
|
|||
"""Fused fire view (v0.9.14): WFIGS perimeter + nearby FIRMS hotspots.
|
||||
|
||||
The spatial join itself (ST_DWithin / time window) is PostGIS and is verified on
|
||||
real data, not here -- the suite has no PostGIS test DB (mock_conn only). These
|
||||
tests cover the Python response-shaping, the bbox parse, the R/T constants, and
|
||||
that the query is parameterized with R, T, and the optional bbox.
|
||||
"""
|
||||
import json
|
||||
from datetime import datetime, timezone
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from central.gui.routes import (
|
||||
FIRE_FUSE_RADIUS_M,
|
||||
FIRE_FUSE_WINDOW_H,
|
||||
_fused_bbox,
|
||||
_shape_fused_fire,
|
||||
_shape_unconfirmed_hotspot,
|
||||
fire_fused,
|
||||
)
|
||||
|
||||
T = datetime(2026, 5, 27, 0, 0, tzinfo=timezone.utc)
|
||||
|
||||
|
||||
def test_constants():
|
||||
assert FIRE_FUSE_RADIUS_M == 1000
|
||||
assert FIRE_FUSE_WINDOW_H == 72
|
||||
|
||||
|
||||
class TestFusedBbox:
|
||||
def test_valid_returns_w_s_e_n(self):
|
||||
assert _fused_bbox({"north": "44.5", "south": "41.8",
|
||||
"east": "-111.0", "west": "-117.5"}) == (-117.5, 41.8, -111.0, 44.5)
|
||||
|
||||
def test_missing_returns_none(self):
|
||||
assert _fused_bbox({"north": "44.5"}) is None
|
||||
assert _fused_bbox({}) is None
|
||||
|
||||
def test_out_of_range_returns_none(self):
|
||||
# east<west (degenerate) and lat>90
|
||||
assert _fused_bbox({"north": "44", "south": "42", "east": "-118", "west": "-111"}) is None
|
||||
assert _fused_bbox({"north": "99", "south": "42", "east": "-111", "west": "-118"}) is None
|
||||
|
||||
def test_nonnumeric_returns_none(self):
|
||||
assert _fused_bbox({"north": "x", "south": "1", "east": "2", "west": "0"}) is None
|
||||
|
||||
|
||||
def test_shape_fused_fire():
|
||||
row = {
|
||||
"id": "perim1", "time": T, "incident_name": "Summit Creek",
|
||||
"irwin_id": "{ABC}", "acres": 1234.8, "cause": "Natural",
|
||||
"geometry": {"type": "Polygon", "coordinates": []},
|
||||
"hotspot_count": 90, "max_frp": 12.5,
|
||||
"hotspots": [{"geometry": {"type": "Polygon"}, "frp": "1.2",
|
||||
"confidence": "nominal", "satellite": "N", "time": "2026-05-26T..."}],
|
||||
}
|
||||
out = _shape_fused_fire(row)
|
||||
assert out["incident_name"] == "Summit Creek"
|
||||
assert out["hotspot_count"] == 90
|
||||
assert out["max_frp"] == 12.5
|
||||
assert out["geometry"]["type"] == "Polygon"
|
||||
assert len(out["hotspots"]) == 1
|
||||
assert out["time"] == T.isoformat()
|
||||
|
||||
|
||||
def test_shape_fused_fire_null_hotspots():
|
||||
row = {"id": "p", "time": T, "incident_name": None, "irwin_id": None,
|
||||
"acres": None, "cause": None, "geometry": None,
|
||||
"hotspot_count": 0, "max_frp": None, "hotspots": None}
|
||||
out = _shape_fused_fire(row)
|
||||
assert out["hotspots"] == []
|
||||
assert out["hotspot_count"] == 0
|
||||
|
||||
|
||||
def test_shape_unconfirmed_hotspot():
|
||||
row = {"id": "h1", "time": T, "geometry": {"type": "Polygon"},
|
||||
"frp": "0.7", "confidence": "nominal", "satellite": "N"}
|
||||
out = _shape_unconfirmed_hotspot(row)
|
||||
assert out["id"] == "h1"
|
||||
assert out["satellite"] == "N"
|
||||
assert out["geometry"]["type"] == "Polygon"
|
||||
|
||||
|
||||
def _mock_pool(conn):
|
||||
pool = MagicMock()
|
||||
pool.acquire.return_value.__aenter__ = AsyncMock(return_value=conn)
|
||||
pool.acquire.return_value.__aexit__ = AsyncMock(return_value=None)
|
||||
return pool
|
||||
|
||||
|
||||
def _confirmed_row():
|
||||
return {"id": "p1", "time": T, "incident_name": "Summit Creek", "irwin_id": "{X}",
|
||||
"acres": 1234.8, "cause": "Natural", "geometry": {"type": "Polygon"},
|
||||
"hotspot_count": 2, "max_frp": 9.9,
|
||||
"hotspots": [{"geometry": {"type": "Polygon"}, "frp": "1", "confidence": "n",
|
||||
"satellite": "N", "time": "2026-05-26T00:00:00+00:00"}]}
|
||||
|
||||
|
||||
def _unconfirmed_row():
|
||||
return {"id": "h9", "time": T, "geometry": {"type": "Polygon"},
|
||||
"frp": "2.0", "confidence": "high", "satellite": "1"}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_endpoint_returns_fires_and_unconfirmed():
|
||||
req = MagicMock()
|
||||
req.state.operator = MagicMock(id=1, username="admin")
|
||||
req.query_params = {}
|
||||
conn = AsyncMock()
|
||||
conn.fetch.side_effect = [[_confirmed_row()], [_unconfirmed_row(), _unconfirmed_row()]]
|
||||
with patch("central.gui.routes.get_pool", return_value=_mock_pool(conn)):
|
||||
resp = await fire_fused(req)
|
||||
assert resp.status_code == 200
|
||||
body = json.loads(resp.body)
|
||||
assert len(body["fires"]) == 1
|
||||
assert body["fires"][0]["incident_name"] == "Summit Creek"
|
||||
assert len(body["unconfirmed"]) == 2
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_endpoint_binds_R_T_and_no_bbox_params():
|
||||
req = MagicMock()
|
||||
req.query_params = {}
|
||||
conn = AsyncMock()
|
||||
conn.fetch.side_effect = [[], []]
|
||||
with patch("central.gui.routes.get_pool", return_value=_mock_pool(conn)):
|
||||
await fire_fused(req)
|
||||
# Both queries bound with exactly (R, T) and no bbox.
|
||||
first = conn.fetch.call_args_list[0].args
|
||||
assert first[1] == FIRE_FUSE_RADIUS_M and first[2] == FIRE_FUSE_WINDOW_H
|
||||
assert len(first) == 3 # sql, R, T
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_endpoint_appends_bbox_params_when_valid():
|
||||
req = MagicMock()
|
||||
req.query_params = {"north": "44.5", "south": "41.8", "east": "-111.0", "west": "-117.5"}
|
||||
conn = AsyncMock()
|
||||
conn.fetch.side_effect = [[], []]
|
||||
with patch("central.gui.routes.get_pool", return_value=_mock_pool(conn)):
|
||||
await fire_fused(req)
|
||||
first = conn.fetch.call_args_list[0].args
|
||||
assert first[1:] == (FIRE_FUSE_RADIUS_M, FIRE_FUSE_WINDOW_H, -117.5, 41.8, -111.0, 44.5)
|
||||
assert "ST_MakeEnvelope" in first[0]
|
||||
Loading…
Add table
Add a link
Reference in a new issue