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