Sélection de tuiles au clic, file d'attente et rafraîchissement fiable

- Clic sur la carte pour choisir des dalles précises, même sans données
  existantes (via /api/cell) ; les zones dessinées s'ajoutent à la
  sélection au lieu de la remplacer
- Une demande lancée pendant un run part en file d'attente côté serveur
  et démarre à la fin du travail en cours (plus de refus 409), file
  vidable depuis l'interface
- Tuiles et interface toujours fraîches : images servies sans cache
  navigateur (revalidation 304), rechargement de la carte seulement une
  fois le rebuild de l'index terminé
- serve-webapp.sh : sous-commande update retirée, documentation de
  déploiement corrigée en conséquence
This commit is contained in:
Antoine Jacquin
2026-09-16 22:37:53 +02:00
parent 26c05319fd
commit f024514427
6 changed files with 636 additions and 128 deletions

View File

@ -294,6 +294,75 @@ def test_build_command_ground_classification():
assert ("--force-classification" in cmd) == reclassify
def test_rebuild_flag_synchronous(monkeypatch):
"""Le rebuild est marqué running dès le POST (pas après coup).
Un GET /api/sync juste après le POST doit voir le rebuild en cours :
sinon il lirait le done du rebuild précédent et l'interface
rechargerait une carte périmée (bug de rafraîchissement).
"""
import threading
import time
import lidar_pipeline.webapp as webapp
import lidar_pipeline.index as index_mod
go = threading.Event()
def slow_build(out):
go.wait(2) # rebuild artificiellement long
monkeypatch.setattr(index_mod, "build_index", slow_build)
saved = dict(webapp._rebuild)
try:
d = webapp._start_rebuild()
assert d["demarré"] is True
assert d["started"] <= time.time()
# Déjà « running » au retour du POST, avant la fin du fil
assert webapp.rebuild_status()["running"] is True
assert webapp.rebuild_status()["done"] is None
go.set()
for _ in range(100):
if not webapp.rebuild_status()["running"]:
break
time.sleep(0.05)
s = webapp.rebuild_status()
assert s["running"] is False
assert s["done"] is not None and s["done"] >= d["started"]
finally:
webapp._rebuild.update(saved)
def test_static_mounts_served_no_cache(tmp_path):
"""Les montages d'images servent Cache-Control: no-cache.
Le ?v= des URLs suit la mtime de la source, pas celle du fichier servi
(vignette recalculée, cache webapp rapatrié) : sans revalidation imposée,
le cache heuristique du navigateur peut afficher l'ancien rendu.
"""
import asyncio
from starlette.routing import Mount
import lidar_pipeline.webapp as webapp
assert issubclass(webapp._OnDemandStaticFiles, webapp._NoCacheStaticFiles)
mounted = {r.path: r for r in webapp.app.routes if isinstance(r, Mount)}
for name in ("index_thumbs", "index_subtiles", "visualisations", "DTM"):
route = mounted.get(f"/{name}")
assert route is not None, f"montage /{name} absent"
assert isinstance(route.app, webapp._NoCacheStaticFiles)
# L'en-tête est bien posé sur la réponse servie
(tmp_path / "x.jpg").write_bytes(b"1")
srv = webapp._NoCacheStaticFiles(directory=str(tmp_path))
async def fetch():
scope = {"type": "http", "method": "GET", "path": "/x.jpg",
"headers": [], "query_string": b""}
return await srv.get_response("x.jpg", scope)
resp = asyncio.run(fetch())
assert resp.headers["cache-control"] == "no-cache, must-revalidate"
def test_stop_generation(monkeypatch):
"""/api/stop termine le processus en cours (SIGTERM) ou refuse (409)."""
import time
@ -333,6 +402,117 @@ def test_stop_generation(monkeypatch):
webapp._job.update(saved)
def test_point_to_cell_matches_bbox_cells():
"""point_to_cell désigne la cellule contenant le point (cohérent bbox_to_cells)."""
from lidar_pipeline.webapp import point_to_cell, bbox_to_cells
from rasterio.warp import transform as warp_transform
# Centre de la dalle 1054,6882 en L93 → WGS84
lons, lats = warp_transform('EPSG:2154', 'EPSG:4326', [1054500], [6881500])
lon, lat = lons[0], lats[0]
assert point_to_cell(lon, lat) == (1054, 6882)
# Une petite bbox autour du même point donne la même cellule, elle seule
cells = bbox_to_cells(lon - 1e-4, lat - 1e-4, lon + 1e-4, lat + 1e-4)
assert cells == [(1054, 6882)]
# Point vers le bord est de la dalle suivante
lons, lats = warp_transform('EPSG:2154', 'EPSG:4326', [1055800], [6881500])
assert point_to_cell(lons[0], lats[0]) == (1055, 6882)
def test_api_cell_returns_corners():
"""/api/cell (clic carte) renvoie la dalle L93 et ses coins GPS."""
from lidar_pipeline.webapp import cell_at_point
from rasterio.warp import transform as warp_transform
lons, lats = warp_transform('EPSG:2154', 'EPSG:4326', [1054500], [6881500])
d = cell_at_point(lat=lats[0], lng=lons[0])
assert d["col"] == 1054 and d["row"] == 6882
assert len(d["corners"]) == 4 # SW, SE, NE, NW
def test_generate_enqueues_while_running(tmp_path, monkeypatch):
"""Une demande pendant un run part en file (plus de 409, rien n'est coupé)."""
import time
import lidar_pipeline.webapp as webapp
class FakeProc:
pid = 4242
def poll(self):
return None # en cours
def wait(self, timeout=None):
return None
saved_job = dict(webapp._job)
saved_queue = list(webapp._queue)
monkeypatch.setattr(webapp, "QUEUE_FILE", tmp_path / "queue.json")
try:
webapp._job.update({"proc": FakeProc(), "started": time.time(),
"returncode": None, "cmd": ["x"], "finished": None,
"qid": None})
webapp._queue.clear()
req = webapp.GenerateRequest(tiles=[[1054, 6882]], viz=["aspect"])
d = webapp.generate(req)
assert d["demarré"] is False
assert d["en_file"] == 1 and d["tuiles"] == 1 and d["qid"] is not None
assert len(webapp._queue) == 1
assert webapp._queue[0]["req"]["tiles"] == [[1054, 6882]]
# La file survit à un « redémarrage » du serveur
webapp._queue.clear()
webapp._load_queue()
assert len(webapp._queue) == 1
# Retrait des demandes en attente (le run en cours n'est pas touché)
assert webapp.queue_clear() == {"retirées": 1}
assert webapp._queue == []
finally:
webapp._job.update(saved_job)
webapp._queue[:] = saved_queue
def test_start_next_queued_launches_after_run(tmp_path, monkeypatch):
"""Serveur libre + file non vide : la demande suivante démarre (fidélité qid)."""
import time
import lidar_pipeline.webapp as webapp
captured = {}
def fake_popen(cmd, **kwargs):
captured["cmd"] = cmd
class _P:
def wait(self):
return 0
def poll(self):
return 0
return _P()
import subprocess as _real_subprocess
class _SubprocessStub:
Popen = staticmethod(fake_popen)
run = staticmethod(_real_subprocess.run)
STDOUT = _real_subprocess.STDOUT
monkeypatch.setattr(webapp, "subprocess", _SubprocessStub)
monkeypatch.setattr(webapp, "INPUT_DIR", tmp_path)
monkeypatch.setattr(webapp, "OUTPUT_DIR", tmp_path)
monkeypatch.setattr(webapp, "LOG_FILE", tmp_path / "gen.log")
monkeypatch.setattr(webapp, "QUEUE_FILE", tmp_path / "queue.json")
saved_job = dict(webapp._job)
saved_queue = list(webapp._queue)
try:
webapp._job.update({"proc": None, "started": None, "returncode": 0,
"cmd": None, "finished": time.time(), "qid": None})
webapp._queue[:] = [{"id": 7, "queued_at": 1.0,
"req": {"tiles": [[1054, 6882]], "viz": ["aspect"]}}]
assert webapp._start_next_queued() is True
assert webapp._queue == [] # la demande est consommée
assert webapp._job["qid"] == 7 # le run porte l'identifiant de file
assert "--fetch-tiles" in captured["cmd"]
assert "1054,6882" in captured["cmd"]
finally:
webapp._job.update(saved_job)
webapp._queue[:] = saved_queue
def test_build_command_default_viz_aspect():
"""Sans choix de visualisation, la commande génère uniquement aspect."""
from lidar_pipeline.webapp import _build_command