Peupler le cache de la webapp à la visualisation, sans rsync

Les images (visualisations, vignettes, sous-tuiles) sont rapatriées depuis
la machine de traitement par HTTP au moment où elles sont affichées, puis
servies depuis le disque : une image absente est téléchargée, une image
périmée ( ?v= plus récent que sa mtime, tuile régénérée) est rafraîchie.
/api/tiles fusionne l'index du worker (cache 60 s) avec le cache local, si
bien que les tuiles non cachées apparaissent quand même sur la carte.
Worker hors ligne : disjoncteur 2 min après 3 échecs, index local seul,
/api/status répond running=null — aucune erreur cliente.
This commit is contained in:
Antoine Jacquin
2026-09-12 19:49:14 +02:00
parent 68272cabf0
commit bde2025714
2 changed files with 357 additions and 14 deletions

View File

@ -293,6 +293,139 @@ def test_tiles_endpoint_stamp_diffing(tmp_path, monkeypatch):
assert webapp.tiles_data(stamp=full["stamp"] - 10)["tiles"] is not None
def test_tiles_endpoint_merges_remote_index(tmp_path, monkeypatch):
"""L'index du worker prime ; le cache local complète les positions perdues."""
import json
import lidar_pipeline.webapp as webapp
monkeypatch.setattr(webapp, "OUTPUT_DIR", tmp_path)
monkeypatch.setattr(webapp, "GENERATION_URL", "http://distant:8973")
(tmp_path / "index_tiles.json").write_text(json.dumps({
"tiles": [
{"col": 1054, "row": 6882, "resolution": 0.2, "viz": {}}, # commun
{"col": 1050, "row": 6882, "resolution": 0.2, "viz": {}}, # local seul
],
"viz_meta": {"local": {"label": "Locale"}},
"stats": {},
}), encoding="utf-8")
import os
remote_stamp = os.stat(tmp_path / "index_tiles.json").st_mtime + 1000
monkeypatch.setattr(webapp, "_remote_tiles_data", lambda: {
"stamp": remote_stamp,
"tiles": [
# 1054 régénéré côté worker : sa version (viz remplie) doit primer
{"col": 1054, "row": 6882, "resolution": 0.2, "viz": {"slope": {}}},
{"col": 1055, "row": 6882, "resolution": 0.2, "viz": {}}, # distant seul
],
"viz_meta": {"slope": {"label": "Pente"}},
})
d = webapp.tiles_data()
entries = {(t["col"], json.dumps(t["viz"], sort_keys=True)) for t in d["tiles"]}
assert (1054, '{"slope": {}}') in entries # version distante retenue
assert (1055, '{}') in entries # tuile non cachée visible
assert (1050, '{}') in entries # historique local conservé
assert d["viz_meta"]["slope"]["label"] == "Pente" # distant prime
assert d["viz_meta"]["local"]["label"] == "Locale" # local complété
assert d["stamp"] == remote_stamp # max des deux stamps
def test_tiles_endpoint_worker_offline_serves_local(tmp_path, monkeypatch):
"""Worker injoignable : index local servi seul, sans erreur."""
import json
import lidar_pipeline.webapp as webapp
monkeypatch.setattr(webapp, "OUTPUT_DIR", tmp_path)
monkeypatch.setattr(webapp, "GENERATION_URL", "http://distant:8973")
(tmp_path / "index_tiles.json").write_text(
json.dumps({"tiles": [{"col": 1054}], "viz_meta": {}, "stats": {}}),
encoding="utf-8")
monkeypatch.setattr(webapp, "_remote_tiles_data", lambda: None)
d = webapp.tiles_data()
assert d["tiles"][0]["col"] == 1054
assert d["stamp"] is not None
def test_worker_offline_breaker(monkeypatch):
"""3 échecs consécutifs → tentatives suspendues ; un succès réarme."""
import lidar_pipeline.webapp as webapp
monkeypatch.setattr(webapp, "_WORKER_HEALTH", {"fails": 0, "until": 0.0})
webapp._worker_mark(False)
webapp._worker_mark(False)
assert webapp._worker_offline() is False # pas encore armé
webapp._worker_mark(False)
assert webapp._worker_offline() is True # disjoncteur armé
until = webapp._WORKER_HEALTH["until"]
webapp._worker_mark(False)
assert webapp._WORKER_HEALTH["until"] == until # fenêtre non étendue
webapp._worker_mark(True)
assert webapp._worker_offline() is False # réarmé immédiatement
def test_safe_rel_path():
"""Seuls les chemins images des préfixes autorisés sont rapatrieables."""
from lidar_pipeline.webapp import _safe_rel_path
assert _safe_rel_path("/visualisations/dalle/img.avif") == \
"visualisations/dalle/img.avif"
assert _safe_rel_path("/index_thumbs/a_b.jpg") == "index_thumbs/a_b.jpg"
assert _safe_rel_path("/index_subtiles/x_0_1.avif") == "index_subtiles/x_0_1.avif"
assert _safe_rel_path("/DTM/x.tif") is None # préfixe interdit
assert _safe_rel_path("/assets/app.js") is None # préfixe interdit
assert _safe_rel_path("/visualisations/../x.avif") is None # traversal
assert _safe_rel_path("/visualisations/a%20b.avif") is None # caractères
def test_fetch_remote_to_cache(tmp_path, monkeypatch):
"""Rapatriement atomique depuis le worker ; échec sans fichier ni erreur."""
import io
import lidar_pipeline.webapp as webapp
monkeypatch.setattr(webapp, "OUTPUT_DIR", tmp_path)
monkeypatch.setattr(webapp, "GENERATION_URL", "http://distant:8973")
monkeypatch.setattr(webapp, "_WORKER_HEALTH", {"fails": 0, "until": 0.0})
class _Resp(io.BytesIO):
def __enter__(self):
return self
def __exit__(self, *a):
return False
captured = {}
def fake_urlopen(req, timeout=None):
captured["url"] = req.full_url
return _Resp(b"IMAGEDATA")
monkeypatch.setattr(webapp.urllib.request, "urlopen", fake_urlopen)
assert webapp._fetch_remote_to_cache("visualisations/dalle/img.avif") is True
p = tmp_path / "visualisations" / "dalle" / "img.avif"
assert p.read_bytes() == b"IMAGEDATA"
assert captured["url"] == "http://distant:8973/visualisations/dalle/img.avif"
assert not list(tmp_path.rglob("*.part")) # pas de résidu temporaire
assert webapp._WORKER_HEALTH["fails"] == 0 # succès → disjoncteur sain
def boom(req, timeout=None):
raise OSError("injoignable")
monkeypatch.setattr(webapp.urllib.request, "urlopen", boom)
assert webapp._fetch_remote_to_cache("visualisations/dalle/x.avif") is False
assert not (tmp_path / "visualisations" / "dalle" / "x.avif").exists()
assert webapp._WORKER_HEALTH["fails"] == 1
def test_status_worker_offline_no_error(monkeypatch):
"""Worker hors ligne : running=null (ignoré par l'UI), pas de 503."""
from fastapi import HTTPException
import lidar_pipeline.webapp as webapp
monkeypatch.setattr(webapp, "GENERATION_URL", "http://distant:8973")
def unreachable(method, path, payload=None, timeout=30):
raise HTTPException(503, "machine de traitement injoignable")
monkeypatch.setattr(webapp, "_proxy_api", unreachable)
d = webapp.status(_FakeRequest(host="192.168.1.7"))
assert d["running"] is None
assert d["worker_offline"] is True
assert d["regen_allowed"] is True
def test_viz_step_names_match_pipeline():
"""Les noms acceptés par l'API sont exactement les étapes VIZ_STEPS du pipeline."""
from lidar_pipeline.webapp import _viz_step_names