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:
@ -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
|
||||
|
||||
Reference in New Issue
Block a user