"""Tests du serveur de tuiles XYZ et de sa découverte (TileJSON, WMTS, JOSM).""" import asyncio import json import xml.etree.ElementTree as ET from lidar_pipeline.tests.test_tiles import _make_dalle, _tile_of_cell class _FakeRequest: """Requête minimale : seules l'origine et les en-têtes de proxy comptent.""" def __init__(self, host="carte.local:8975", proto=None, scheme="http"): self.headers = {"host": host} if proto: self.headers["x-forwarded-proto"] = proto self.url = type("U", (), {"scheme": scheme, "hostname": "carte.local", "port": 8975})() def _setup(tmp_path, monkeypatch, layers=("aspect", "slope"), panel=None): """Dalle factice ; panel=None lève la restriction PANEL_VIZ pour que les couches de test (aspect, slope) soient servies.""" import lidar_pipeline.mapserve as mapserve from lidar_pipeline import index, tiles monkeypatch.setattr(index, "PANEL_VIZ", panel) _make_dalle(tmp_path, 1054, 6882, list(layers)) monkeypatch.setattr(mapserve, "OUTPUT_DIR", tmp_path) tiles.source_index(tmp_path, force=True) return mapserve def test_tile_png_is_256_rgba(tmp_path, monkeypatch): """La tuile canonique est un PNG RGBA de 256 px, avec en-têtes CORS.""" import io from PIL import Image mapserve = _setup(tmp_path, monkeypatch) z = 15 x, y = _tile_of_cell(1054, 6882, z) resp = asyncio.run(mapserve.tile("aspect", z, x, f"{y}.png")) assert resp.media_type == "image/png" assert resp.headers["Access-Control-Allow-Origin"] == "*" assert resp.headers["X-Tile-Empty"] == "0" img = Image.open(io.BytesIO(resp.body)) assert img.size == (256, 256) and img.mode in ("RGBA", "P") def test_tile_2x_is_512_webp(tmp_path, monkeypatch): """Le palier @2x sert du WebP 512 px (interface interne).""" import io from PIL import Image mapserve = _setup(tmp_path, monkeypatch) z = 15 x, y = _tile_of_cell(1054, 6882, z) resp = asyncio.run(mapserve.tile("aspect", z, x, f"{y}@2x.webp")) assert resp.media_type == "image/webp" assert Image.open(io.BytesIO(resp.body)).size == (512, 512) def test_tile_outside_data_is_transparent(tmp_path, monkeypatch): """Hors données : tuile transparente (et pas une erreur) pour JOSM/iD.""" import io from PIL import Image mapserve = _setup(tmp_path, monkeypatch) resp = asyncio.run(mapserve.tile("aspect", 15, 1, "1.png")) assert resp.headers["X-Tile-Empty"] == "1" img = Image.open(io.BytesIO(resp.body)).convert("RGBA") assert img.getextrema()[3] == (0, 0) def test_tile_cache_headers(tmp_path, monkeypatch): """URL versionnée → cache immuable ; sans version → revalidation courte.""" mapserve = _setup(tmp_path, monkeypatch) z = 15 x, y = _tile_of_cell(1054, 6882, z) plain = asyncio.run(mapserve.tile("aspect", z, x, f"{y}.png")) assert "must-revalidate" in plain.headers["Cache-Control"] versioned = asyncio.run(mapserve.tile("aspect", z, x, f"{y}.png", v="123")) assert "immutable" in versioned.headers["Cache-Control"] def test_tile_rejects_bad_input(tmp_path, monkeypatch): """Nom de tuile, couche, zoom et indices sont validés (404).""" from fastapi import HTTPException import pytest mapserve = _setup(tmp_path, monkeypatch) z = 15 x, y = _tile_of_cell(1054, 6882, z) for layer, zz, xx, name in (("aspect", z, x, "abc.png"), # nom invalide ("aspect", z, x, f"{y}.tif"), # format refusé ("inconnue", z, x, f"{y}.png"), # couche absente ("../etc", z, x, f"{y}.png"), # traversée ("aspect", 2, x, f"{y}.png"), # zoom trop bas ("aspect", 25, x, f"{y}.png"), # zoom trop haut ("aspect", z, 10 ** 9, f"{y}.png")): # hors grille with pytest.raises(HTTPException) as e: asyncio.run(mapserve.tile(layer, zz, xx, name)) assert e.value.status_code == 404 def test_tile_upstream_fallback(tmp_path, monkeypatch): """Tuile absente localement : rapatriée depuis LIDAR_MAPS_URL et mise en cache.""" import lidar_pipeline.mapserve as mapserve from lidar_pipeline import tiles _setup(tmp_path, monkeypatch) monkeypatch.setattr(mapserve, "MAPS_URL", "http://amont:8975") monkeypatch.setattr(mapserve, "_UPSTREAM", {"fails": 0, "until": 0.0}) called = {} def fake_fetch(rel_path): called["path"] = rel_path return b"\x89PNG\r\n\x1a\nFAKE" monkeypatch.setattr(mapserve, "_fetch_upstream", fake_fetch) # Zone sans donnée locale → le rendu local échoue, l'amont prend le relais resp = asyncio.run(mapserve.tile("aspect", 15, 1, "1.png")) assert called["path"] == "tiles/aspect/15/1/1.png" assert resp.body == b"\x89PNG\r\n\x1a\nFAKE" assert tiles.tile_cache_path(tmp_path, "aspect", 15, 1, 1).is_file() def test_upstream_breaker(monkeypatch): """3 échecs consécutifs suspendent les tentatives amont ; un succès réarme.""" import lidar_pipeline.mapserve as mapserve monkeypatch.setattr(mapserve, "_UPSTREAM", {"fails": 0, "until": 0.0}) for _ in range(2): mapserve._upstream_mark(False) assert mapserve._upstream_offline() is False mapserve._upstream_mark(False) assert mapserve._upstream_offline() is True mapserve._upstream_mark(True) assert mapserve._upstream_offline() is False def test_tilejson_is_valid(tmp_path, monkeypatch): """TileJSON 3.0.0 : champs obligatoires, URL XYZ absolue, attribution.""" mapserve = _setup(tmp_path, monkeypatch) data = json.loads(mapserve.tilejson("aspect", _FakeRequest()).body) assert data["tilejson"] == "3.0.0" assert data["scheme"] == "xyz" assert data["tiles"] == ["http://carte.local:8975/tiles/aspect/{z}/{x}/{y}.png"] assert data["minzoom"] < data["maxzoom"] <= 19 assert len(data["bounds"]) == 4 and data["bounds"][0] < data["bounds"][2] assert "IGN" in data["attribution"] def test_tilejson_unknown_layer(tmp_path, monkeypatch): """Couche absente du disque → 404.""" from fastapi import HTTPException import pytest mapserve = _setup(tmp_path, monkeypatch) with pytest.raises(HTTPException) as e: mapserve.tilejson("wavelet", _FakeRequest()) assert e.value.status_code == 404 def test_base_url_honours_proxy(tmp_path, monkeypatch): """Derrière un proxy TLS, les URL publiées sont en https.""" mapserve = _setup(tmp_path, monkeypatch) data = json.loads(mapserve.tilejson( "aspect", _FakeRequest(host="lidar.example.fr", proto="https")).body) assert data["tiles"][0].startswith("https://lidar.example.fr/tiles/") def test_josm_imagery_xml(tmp_path, monkeypatch): """Fichier d'imagerie JOSM : une entrée TMS par couche, URL en {zoom}/{x}/{y}.""" mapserve = _setup(tmp_path, monkeypatch) xml = mapserve.josm_imagery(_FakeRequest()).body.decode("utf-8") root = ET.fromstring(xml) ns = {"j": "http://josm.openstreetmap.de/maps-1.0"} entries = root.findall("j:entry", ns) assert {e.find("j:id", ns).text for e in entries} == { "lidar-hd-aspect", "lidar-hd-slope"} for entry in entries: assert entry.find("j:type", ns).text == "tms" assert "{zoom}/{x}/{y}.png" in entry.find("j:url", ns).text assert entry.find("j:max-zoom", ns).text == "19" assert entry.find("j:bounds", ns) is not None def test_wmts_capabilities(tmp_path, monkeypatch): """Capacités WMTS : couches, grille GoogleMapsCompatible, gabarit REST.""" mapserve = _setup(tmp_path, monkeypatch) xml = mapserve.wmts_capabilities(_FakeRequest()).body.decode("utf-8") root = ET.fromstring(xml) ns = {"w": "http://www.opengis.net/wmts/1.0", "ows": "http://www.opengis.net/ows/1.1"} ids = [l.find("ows:Identifier", ns).text for l in root.findall(".//w:Layer", ns)] assert set(ids) == {"aspect", "slope"} # `.//w:TileMatrixSet` attraperait d'abord le renvoi de # TileMatrixSetLink (simple texte) : on vise la définition. tms = root.find("w:Contents/w:TileMatrixSet", ns) assert tms.find("ows:Identifier", ns).text == "GoogleMapsCompatible" levels = [m.find("ows:Identifier", ns).text for m in tms.findall("w:TileMatrix", ns)] assert levels[0] == "5" and levels[-1] == "19" template = root.find(".//w:ResourceURL", ns).get("template") assert template.endswith("/{TileMatrix}/{TileCol}/{TileRow}.png") def test_map_meta(tmp_path, monkeypatch): """/api/map/meta décrit les couches et le contrat de tuilage de l'interface.""" mapserve = _setup(tmp_path, monkeypatch, layers=("relief_oriente", "aspect", "slope")) meta = mapserve.map_meta() assert {l["key"] for l in meta["layers"]} == {"relief_oriente", "aspect", "slope"} assert all(l["label"] for l in meta["layers"]) assert meta["tile_url"] == "tiles/{layer}/{z}/{x}/{y}@2x.avif" assert meta["even_levels"] is True assert meta["tile_size"] == 512 and meta["zoom_offset"] == -1 assert meta["max_native_zoom"] == 18 assert meta["bounds"] and meta["stamp"] > 0 assert meta["default_layers"] == ["relief_oriente"] def test_only_panel_layers_are_served(tmp_path, monkeypatch): """Seules les couches de PANEL_VIZ sont affichées et servies ; la génération depuis la carte ne produit qu'elles.""" from fastapi import HTTPException mapserve = _setup(tmp_path, monkeypatch, layers=("relief_oriente", "aspect", "slope"), panel=("relief_oriente",)) meta = mapserve.map_meta() assert [l["key"] for l in meta["layers"]] == ["relief_oriente"] with pytest.raises(HTTPException): asyncio.run(mapserve.tile("aspect", 15, 1, "1.png")) assert mapserve._panel_viz_steps() == ["relief_oriente"] def test_map_tile_info(tmp_path, monkeypatch): """/api/map/tile identifie la dalle sous un point et lit ses métadonnées.""" mapserve = _setup(tmp_path, monkeypatch) dtm = tmp_path / "DTM" dtm.mkdir(parents=True, exist_ok=True) (dtm / "LHD_FXX_1054_6882_PTS_LAMB93_IGN69_dtm_method.txt").write_text( "ign", encoding="utf-8") from lidar_pipeline.tiles import _transformer lon, lat = _transformer("EPSG:2154", "EPSG:4326").transform(1054500, 6881500) data = mapserve.map_tile_info(lat=lat, lng=lon) assert data["found"] is True assert (data["col"], data["row"]) == (1054, 6882) assert data["method"] == "ign" assert data["layers"] == ["aspect", "slope"] # Point hors zone : réponse explicite, sans erreur assert mapserve.map_tile_info(lat=45.0, lng=1.0)["found"] is False def test_healthz_and_assets(tmp_path, monkeypatch): """Sonde de santé et assets de l'interface (revalidés, jamais immuables).""" from fastapi import HTTPException import pytest mapserve = _setup(tmp_path, monkeypatch) assert mapserve.healthz()["layers"] == 2 resp = mapserve.assets("app.js") assert resp.headers["Cache-Control"] == "no-cache, must-revalidate" with pytest.raises(HTTPException): mapserve.assets("introuvable.js") def test_ui_offers_reorder_and_blend_modes(): """Pile de couches : réordonnancement au doigt ET modes de fusion. La superposition ne se réduit pas à de la transparence : chaque couche porte un mix-blend-mode, cantonné à la pile LiDAR par un conteneur isolé (sinon « Produit » assombrirait aussi le fond de carte). """ from lidar_pipeline.mapui import _MAP_CSS, _MAP_HTML, _MAP_JS # Modes proposés for mode in ("multiply", "screen", "overlay", "soft-light", "difference", "luminosity"): assert f"'{mode}'" in _MAP_JS, mode # Réordonnancement : glisser-déposer ET boutons (seuls utilisables au doigt) assert "function moveLayer(" in _MAP_JS assert "moveLayer(key, 1)" in _MAP_JS and "moveLayer(key, -1)" in _MAP_JS assert "dragstart" in _MAP_JS and "layer-move" in _MAP_CSS # Pile isolée + fusion par couche et fusion de la pile sur le fond assert "createPane('lidarStack')" in _MAP_JS assert "isolation = 'isolate'" in _MAP_JS assert "map.createPane(paneName, stack)" in _MAP_JS assert "pane.style.mixBlendMode" in _MAP_JS assert "STATE.stackBlend" in _MAP_JS and 'id="stackBlend"' in _MAP_HTML # Le lien partagé transporte la fusion (4e champ, rétrocompatible) assert "(STATE.blend[k] || 'normal')" in _MAP_JS assert "const b = validBlend(f[3]);" in _MAP_JS def test_defaults_roundtrip(tmp_path, monkeypatch): """L'état réglé depuis la carte devient la configuration servie à tous.""" import lidar_pipeline.mapserve as mapserve _setup(tmp_path, monkeypatch) monkeypatch.setattr(mapserve, "DEFAULTS_FILE", tmp_path / ".map-defaults.json") # Sans enregistrement : réglages du registre (index.py) meta = mapserve.map_meta() assert meta["defaults_saved"] is False assert meta["default_order"] is None assert meta["default_stack_blend"] == "normal" req = mapserve.DefaultsRequest( order=["slope", "aspect"], on=["aspect"], opacity={"aspect": 0.6, "slope": 1.0}, blend={"aspect": "multiply", "slope": "normal"}, base={"on": True, "opacity": 0.4, "dark": False}, stack_blend="soft-light") assert mapserve.set_defaults(req)["enregistré"] is True meta = mapserve.map_meta() assert meta["defaults_saved"] is True assert meta["default_order"] == ["slope", "aspect"] assert meta["default_layers"] == ["aspect"] assert meta["default_opacity"]["aspect"] == 0.6 assert meta["default_blend"]["aspect"] == "multiply" assert meta["default_base"] == {"on": True, "opacity": 0.4, "dark": False} assert meta["default_stack_blend"] == "soft-light" # Persisté sur disque : survit au redémarrage du conteneur assert (tmp_path / ".map-defaults.json").is_file() assert mapserve.get_defaults()["defaults"]["on"] == ["aspect"] # Retrait : retour aux réglages du registre assert mapserve.clear_defaults()["supprimé"] is True assert mapserve.map_meta()["defaults_saved"] is False def test_defaults_obsolete_layers_fall_back(tmp_path, monkeypatch): """Défauts figés sur des couches qui ne sont plus servies : registre. Sinon un nouveau navigateur arrive sur une carte sans aucune couche LiDAR allumée (cas de la prod après le passage au seul relief orienté). """ import json import lidar_pipeline.mapserve as mapserve _setup(tmp_path, monkeypatch, layers=("relief_oriente",)) defaults = tmp_path / ".map-defaults.json" monkeypatch.setattr(mapserve, "DEFAULTS_FILE", defaults) defaults.write_text(json.dumps({ "order": ["aspect", "positive_openness"], "on": ["aspect", "positive_openness"], "opacity": {}, "blend": {}, "base": {"on": True, "opacity": 0.85, "dark": True}, "stack_blend": "normal"}), encoding="utf-8") meta = mapserve.map_meta() assert meta["default_layers"] == ["relief_oriente"] # Tout éteint volontairement (couches connues) : respecté defaults.write_text(json.dumps({ "order": ["relief_oriente"], "on": [], "opacity": {}, "blend": {}, "base": {}, "stack_blend": "normal"}), encoding="utf-8") assert mapserve.map_meta()["default_layers"] == [] def test_defaults_sanitised(tmp_path, monkeypatch): """Couches inconnues écartées, opacités bornées, fusions validées.""" import lidar_pipeline.mapserve as mapserve _setup(tmp_path, monkeypatch) monkeypatch.setattr(mapserve, "DEFAULTS_FILE", tmp_path / ".map-defaults.json") data = mapserve.set_defaults(mapserve.DefaultsRequest( order=["aspect", "inexistante"], on=["aspect", "inexistante"], opacity={"aspect": 5, "slope": "x", "inexistante": 0.5}, blend={"aspect": "vaudou", "slope": "screen"}, base={"opacity": -3}, stack_blend="vaudou"))["defaults"] assert "inexistante" not in data["order"] and "inexistante" not in data["on"] # Les couches présentes mais non citées complètent l'ordre assert set(data["order"]) == {"aspect", "slope"} assert data["opacity"]["aspect"] == 1.0 # borné à 1 assert data["opacity"]["slope"] == 1.0 # valeur illisible → opaque assert data["blend"] == {"slope": "screen"} # mode inconnu écarté assert data["base"]["opacity"] == 0.0 # borné à 0 assert data["stack_blend"] == "normal" # repli def test_ui_applies_server_defaults(): """L'interface part des défauts du serveur et sait les (re)définir.""" from lidar_pipeline.mapui import _MAP_HTML, _MAP_JS assert 'id="btnDefault"' in _MAP_HTML and 'id="btnReset"' in _MAP_HTML assert "api/map/defaults" in _MAP_JS assert "meta.default_order" in _MAP_JS assert "meta.default_base" in _MAP_JS assert "validBlend(meta.default_stack_blend)" in _MAP_JS # Réinitialiser oublie l'état local avant de reprendre celui du serveur assert "localStorage.removeItem(LS_KEY)" in _MAP_JS def test_ui_reorder_shows_destination(): """Réorganiser doit être lisible : repère de chute, fantôme, arrivée signalée. Sans repère, on lâche une couche sans savoir où elle atterrit — la pile n'est pas un détail, elle décide de ce qu'on voit. """ from lidar_pipeline.mapui import _MAP_CSS, _MAP_HTML, _MAP_JS # Barre d'insertion selon la moitié survolée, effacée entre deux survols assert ".layer-row.drop-before" in _MAP_CSS and ".layer-row.drop-after" in _MAP_CSS assert "clearDropMarks" in _MAP_JS assert "box.height / 2" in _MAP_JS assert "row.classList.add(before ? 'drop-before' : 'drop-after')" in _MAP_JS # Ligne déplacée estompée pendant le glisser assert ".layer-row.dragging" in _MAP_CSS and "classList.add('dragging')" in _MAP_JS # Insertion relative (avant/après la ligne visée), pas un simple échange assert "function reorderRelative(" in _MAP_JS # Arrivée mise en évidence puis ramenée dans le champ de vision assert "just-moved" in _MAP_CSS and "@keyframes moved" in _MAP_CSS assert "scrollIntoView" in _MAP_JS assert "renderPanel(key)" in _MAP_JS and "renderPanel(src)" in _MAP_JS # Extrémités de la pile nommées assert 'id="edgeTop"' in _MAP_HTML and 'id="edgeBottom"' in _MAP_HTML assert "Haut de pile" in _MAP_HTML and "Bas de pile" in _MAP_HTML def test_ui_uses_standard_tilelayer(): """L'interface s'appuie sur le LOD natif de Leaflet, pas sur un palier maison.""" from lidar_pipeline.mapui import _MAP_JS, render_html, ui_version assert "L.TileLayer.extend(" in _MAP_JS # TileLayer Leaflet, LOD natif assert "tileSize: META.tile_size" in _MAP_JS assert "zoomOffset: META.zoom_offset" in _MAP_JS assert "maxNativeZoom: META.max_native_zoom" in _MAP_JS # Plus aucune mécanique de l'ancienne carte for banned in ("quadMatrix3d", "FULL_CAP", "ovTick", "layoutTiles"): assert banned not in _MAP_JS html = render_html() assert ui_version() in html and "__UI_VERSION__" not in html def test_write_map_assets(tmp_path): """Les assets écrits portent la version et embarquent Leaflet vendorisé.""" from lidar_pipeline.mapui import _MAP_JS, ui_version, write_map_assets write_map_assets(tmp_path / "ui") js = (tmp_path / "ui" / "app.js").read_text(encoding="utf-8") assert js == _MAP_JS.replace("__UI_VERSION__", ui_version()) assert (tmp_path / "ui" / "app.css").read_text(encoding="utf-8").startswith("/*") assert (tmp_path / "ui" / "vendor" / "leaflet" / "leaflet.js").is_file() def test_warm_endpoint(tmp_path, monkeypatch): """/api/map/warm lance le pré-calcul en tâche de fond puis rend compte.""" import time mapserve = _setup(tmp_path, monkeypatch) monkeypatch.setattr(mapserve, "_warm", {"running": False, "done": None, "result": None}) req = mapserve.WarmRequest(layers=["aspect"], z_min=12, z_max=12) assert mapserve.map_warm(req)["couches"] == ["aspect"] for _ in range(200): if not mapserve._warm["running"]: break time.sleep(0.05) assert mapserve.map_warm_status()["result"]["rendues"] >= 1 def test_tile_locks_are_refcounted(tmp_path, monkeypatch): """Verrou par tuile : partagé pendant le rendu, retiré une fois libéré. Sans compteur, le registre grossit sans fin (des millions de tuiles) ou un verrou disparaît alors qu'un autre fil l'attend — deux rendus concurrents de la même tuile. """ mapserve = _setup(tmp_path, monkeypatch) key = ("aspect", 15, 1, 1, 1, "png") a = mapserve._acquire_lock(key) b = mapserve._acquire_lock(key) assert a is b and a[1] == 2 mapserve._release_lock(key, a) assert key in mapserve._locks mapserve._release_lock(key, b) assert key not in mapserve._locks def test_concurrent_requests_render_once(tmp_path, monkeypatch): """Deux requêtes simultanées sur la même tuile ne la rendent qu'une fois.""" import threading import lidar_pipeline.mapserve as mapserve _setup(tmp_path, monkeypatch) calls = [] real = mapserve.tiles_mod.get_tile def slow_get_tile(*args, **kwargs): calls.append(args) threading.Event().wait(0.05) return real(*args, **kwargs) monkeypatch.setattr(mapserve.tiles_mod, "get_tile", slow_get_tile) z = 15 x, y = _tile_of_cell(1054, 6882, z) results = [] threads = [threading.Thread( target=lambda: results.append(mapserve._tile_bytes("aspect", z, x, y, 1, "png"))) for _ in range(4)] for t in threads: t.start() for t in threads: t.join() assert all(r == results[0] for r in results) # Les appels suivants lisent le cache disque : un seul rendu effectif assert len(calls) == 4 # chaque requête interroge get_tile… assert mapserve._locks == {} # …mais sérialisées, et les verrous libérés def test_tile_bytes_survives_render_error(tmp_path, monkeypatch): """Un rendu qui échoue rend une tuile vide sans laisser de verrou fuité.""" import lidar_pipeline.mapserve as mapserve _setup(tmp_path, monkeypatch) def boom(*a, **k): raise RuntimeError("source corrompue") monkeypatch.setattr(mapserve.tiles_mod, "get_tile", boom) z = 15 x, y = _tile_of_cell(1054, 6882, z) assert mapserve._tile_bytes("aspect", z, x, y, 1, "png") is None assert mapserve._locks == {} def test_render_concurrency_is_capped(tmp_path, monkeypatch): """Le plafond de rendus simultanés est tenu (petit serveur protégé). Sans lui, autant de rendus que de requêtes en vol : chacun décode une dalle de plusieurs dizaines de Mo — mémoire et CPU d'un Raspberry Pi saturés. """ import threading import lidar_pipeline.mapserve as mapserve _setup(tmp_path, monkeypatch) # Plafond volontairement bas pour le test monkeypatch.setattr(mapserve, "_render_sem", threading.Semaphore(2)) state = {"now": 0, "peak": 0} guard = threading.Lock() real = mapserve.tiles_mod.get_tile def slow(*args, **kwargs): with guard: state["now"] += 1 state["peak"] = max(state["peak"], state["now"]) try: threading.Event().wait(0.05) return real(*args, **kwargs) finally: with guard: state["now"] -= 1 monkeypatch.setattr(mapserve.tiles_mod, "get_tile", slow) z = 15 x, y = _tile_of_cell(1054, 6882, z) # Tuiles DIFFÉRENTES : le verrou par tuile ne masque pas le sémaphore threads = [threading.Thread(target=mapserve._tile_bytes, args=("aspect", z, x + i, y, 1, "png")) for i in range(8)] for t in threads: t.start() for t in threads: t.join() assert state["peak"] <= 2, state["peak"] assert mapserve._locks == {} def test_worker_limits_are_configurable(monkeypatch): """Les plafonds viennent de l'environnement, avec un défaut modeste.""" import importlib import lidar_pipeline.mapserve as mapserve import lidar_pipeline.tiles as tiles # Défauts : 2 rendus, 2 téléchargements — taillés pour un petit serveur assert mapserve.TILE_WORKERS == 2 assert tiles.FETCH_WORKERS == 2 monkeypatch.setenv("LIDAR_TILE_WORKERS", "6") monkeypatch.setenv("LIDAR_TILE_FETCH_WORKERS", "3") try: importlib.reload(tiles) importlib.reload(mapserve) assert mapserve.TILE_WORKERS == 6 assert tiles.FETCH_WORKERS == 3 # Valeur absurde : jamais zéro fil (blocage complet du service) monkeypatch.setenv("LIDAR_TILE_WORKERS", "0") importlib.reload(mapserve) assert mapserve.TILE_WORKERS == 1 finally: monkeypatch.delenv("LIDAR_TILE_WORKERS", raising=False) monkeypatch.delenv("LIDAR_TILE_FETCH_WORKERS", raising=False) importlib.reload(tiles) importlib.reload(mapserve) def _reset_bg(mapserve): mapserve._bg["queue"].clear() mapserve._bg["queued"].clear() mapserve._bg["snapshot"] = None mapserve._bg["stats"].update(rendues=0, a_jour=0, vides=0, scans=0, dernier_scan=None, ajouts_dernier_scan=0) def test_tile_cache_only_never_renders(tmp_path, monkeypatch): """Mode cache seule : tuile manquante = transparente non mémorisable.""" from lidar_pipeline import tiles as _t monkeypatch.setattr(_t, "TILE_EVEN_LEVELS", False) # règle historique : tout est stocké monkeypatch.setattr(_t, "TILE_CACHE_MAX_Z", 99) mapserve = _setup(tmp_path, monkeypatch, layers=("slope",)) from lidar_pipeline import tiles monkeypatch.setenv("LIDAR_TILE_CACHE_ONLY", "1") z = 15 x, y = _tile_of_cell(1054, 6882, z) resp = asyncio.run(mapserve.tile("slope", z, x, f"{y}.png")) assert resp.headers["X-Tile-Pending"] == "1" assert resp.headers["X-Tile-Empty"] == "1" assert resp.headers["Cache-Control"] == "no-store" assert not tiles.tile_cache_path(tmp_path, "slope", z, x, y).exists() # Une fois rendue par ailleurs : servie depuis le cache, mémorisable tiles.get_tile(tmp_path, "slope", z, x, y) resp = asyncio.run(mapserve.tile("slope", z, x, f"{y}.png", v="s")) assert resp.headers["X-Tile-Empty"] == "0" assert "X-Tile-Pending" not in resp.headers assert "immutable" in resp.headers["Cache-Control"] def test_background_scan_detects_and_fills(tmp_path, monkeypatch): """La maintenance détecte les dalles, remplit la pyramide, puis se tait.""" import lidar_pipeline.mapserve as mapserve from lidar_pipeline import tiles _make_dalle(tmp_path, 1054, 6882, ["slope"]) monkeypatch.setattr(mapserve, "OUTPUT_DIR", tmp_path) monkeypatch.setenv("LIDAR_TILE_BACKGROUND_MAX_Z", "14") monkeypatch.setenv("LIDAR_TILE_BACKGROUND_SCALE", "1") monkeypatch.setenv("LIDAR_TILE_BACKGROUND_FMT", "png") monkeypatch.setenv("LIDAR_TILE_BACKGROUND_PAUSE", "0") _reset_bg(mapserve) try: # Premier scan : tout est nouveau, bas niveaux en tête de file assert mapserve.background_scan(tmp_path) > 0 zs = [t[1] for t in mapserve._bg["queue"]] assert zs == sorted(zs) and max(zs) <= 14 processed = 0 while mapserve._bg["queue"] and processed < 1000: mapserve._bg_process_one() processed += 1 assert not mapserve._bg["queue"] assert mapserve._bg["stats"]["rendues"] > 0 x, y = _tile_of_cell(1054, 6882, 14) assert tiles.tile_cache_path( tmp_path, "slope", 14, x, y, 1, "png").exists() # Rien n'a changé : plus aucun ajout assert mapserve.background_scan(tmp_path) == 0 # Une dalle nouvelle (worker) : sa pyramide repart seule en file _make_dalle(tmp_path, 1055, 6882, ["slope"]) assert mapserve.background_scan(tmp_path) > 0 finally: _reset_bg(mapserve) # --------------------------------------------------------------------------- # Génération de tuiles (portée de la webapp historique) # --------------------------------------------------------------------------- import types from fastapi import HTTPException import pytest class _ClientRequest: """Requête minimale avec IP de connexion (restriction LIDAR_REGEN_CIDR).""" def __init__(self, ip, xff=None): self.client = types.SimpleNamespace(host=ip) self.headers = {"x-forwarded-for": xff} if xff else {} def _gen_setup(tmp_path, monkeypatch): import lidar_pipeline.mapserve as mapserve _make_dalle(tmp_path, 1054, 6882, ["aspect", "slope"]) monkeypatch.setattr(mapserve, "OUTPUT_DIR", tmp_path) monkeypatch.setattr(mapserve, "INPUT_DIR", tmp_path / "input") monkeypatch.setattr(mapserve, "GENERATION_URL", "") monkeypatch.setattr(mapserve, "LOG_FILE", tmp_path / ".generation.log") monkeypatch.setattr(mapserve, "JOB_FILE", tmp_path / ".generation.job.json") monkeypatch.setattr(mapserve, "QUEUE_FILE", tmp_path / ".generation.queue.json") mapserve._queue.clear() mapserve._pipeline_import_cache.clear() return mapserve def test_bbox_to_cells_covers_1km(): """Une bbox d'1 km² WGS84 sélectionne la cellule L93 correspondante.""" import lidar_pipeline.mapserve as mapserve from lidar_pipeline.tiles import _transformer x, y = 1054500, 6881500 w, n = _transformer("EPSG:2154", "EPSG:4326").transform(x - 200, y + 200) e, s = _transformer("EPSG:2154", "EPSG:4326").transform(x + 200, y - 200) cells = mapserve.bbox_to_cells(w, s, e, n) assert (1054, 6882) in cells # Cellule (col, row) : X ∈ [col, col+1] km, Y ∈ [row-1, row] km assert all(1053 <= c <= 1055 and 6881 <= r <= 6883 for c, r in cells) def test_preview_bbox_lists_cells_with_corners(tmp_path, monkeypatch): """Le preview retourne les cellules à traiter avec leurs coins GPS.""" mapserve = _gen_setup(tmp_path, monkeypatch) from lidar_pipeline.tiles import _transformer x, y = 1054500, 6881500 w, n = _transformer("EPSG:2154", "EPSG:4326").transform(x - 100, y + 100) e, s = _transformer("EPSG:2154", "EPSG:4326").transform(x + 100, y - 100) req = mapserve.PreviewRequest(bbox=[w, s, e, n], viz=["aspect"]) d = mapserve.preview(req) assert d["capped"] is False cells = {(c["col"], c["row"]) for c in d["cells"]} assert (1054, 6882) in cells first = d["cells"][0] assert first["corners"] and len(first["corners"]) == 4 def test_preview_rejects_unknown_viz(tmp_path, monkeypatch): mapserve = _gen_setup(tmp_path, monkeypatch) req = mapserve.PreviewRequest(bbox=[0.0, 44.0, 0.01, 44.01], viz=["inconnue"]) with pytest.raises(HTTPException) as e: mapserve.preview(req) assert e.value.status_code == 400 def test_preview_all_missing_without_laz(tmp_path, monkeypatch): """Passe globale sans dalle dans input/ : liste vide (rien à compléter). L'erreur 400 « aucune tuile à compléter » ne concerne que le lancement (/api/generate → _resolve_request) : le preview reste informatif. """ mapserve = _gen_setup(tmp_path, monkeypatch) (tmp_path / "input").mkdir() req = mapserve.PreviewRequest(all_missing=True, viz=["aspect"]) d = mapserve.preview(req) assert d["count"] == 0 and d["cells"] == [] def test_generate_validates_request(tmp_path, monkeypatch): mapserve = _gen_setup(tmp_path, monkeypatch) for kwargs in ({"tiles": []}, {"tiles": [[1054]]}, {"tiles": [[c, 6882] for c in range(500)]}): req = mapserve.GenerateRequest(**kwargs) with pytest.raises(HTTPException) as e: mapserve._resolve_request(req) assert e.value.status_code == 400 # Tri nord → sud (row décroissante) puis col croissante req = mapserve.GenerateRequest(tiles=[[1055, 6882], [1054, 6883], [1054, 6882]]) tiles, _viz = mapserve._resolve_request(req) assert tiles == [(1054, 6883), (1054, 6882), (1055, 6882)] def test_generate_launches_and_queues(tmp_path, monkeypatch): mapserve = _gen_setup(tmp_path, monkeypatch) launched = [] monkeypatch.setattr(mapserve, "_launch_job", lambda tiles, viz, req, qid=None: launched.append((tiles, qid)) or ["fake", "cmd"]) req = mapserve.GenerateRequest(tiles=[[1054, 6882]], viz=["aspect"]) d = mapserve.generate(req) assert d["demarré"] is True and d["tuiles"] == 1 # Run en cours : la demande part en file (jamais de coupure du travail) import threading with mapserve._job_lock: mapserve._job["proc"] = types.SimpleNamespace( poll=lambda: None, pid=4242, terminate=lambda: None, wait=lambda: None) d = mapserve.generate(req) assert d["demarré"] is False and d["en_file"] == 1 assert mapserve.generate(req)["en_file"] == 2 assert len(mapserve._queue_summary()) == 2 d = mapserve.queue_clear() assert d["retirées"] == 2 with mapserve._job_lock: mapserve._job["proc"] = None mapserve._queue.clear() def test_status_reports_rights_and_availability(tmp_path, monkeypatch): """/api/status annonce available (pipeline local) et regen_allowed (IP).""" mapserve = _gen_setup(tmp_path, monkeypatch) monkeypatch.setattr(mapserve, "_pipeline_import_cache", {"ok": True}) d = mapserve.generation_status(_ClientRequest("192.168.3.10")) assert d["available"] is True and d["regen_allowed"] is True assert d["running"] is False and d["tiles"] == [] # Client hors réseau local : génération interdite (boutons masqués) d = mapserve.generation_status(_ClientRequest("203.0.113.7")) assert d["regen_allowed"] is False # X-Forwarded-For honoré seulement depuis un pair de confiance d = mapserve.generation_status( _ClientRequest("172.25.0.1", xff="203.0.113.7")) assert d["regen_allowed"] is False d = mapserve.generation_status( _ClientRequest("172.25.0.1", xff="192.168.3.10")) assert d["regen_allowed"] is True # Image légère sans pipeline ni LIDAR_GENERATION_URL : indisponible monkeypatch.setattr(mapserve, "_pipeline_import_cache", {"ok": False}) d = mapserve.generation_status(_ClientRequest("192.168.3.10")) assert d["available"] is False def test_status_proxies_to_worker(tmp_path, monkeypatch): """Webapp légère : /api/status relaie l'état du worker, 503 hors ligne.""" mapserve = _gen_setup(tmp_path, monkeypatch) monkeypatch.setattr(mapserve, "GENERATION_URL", "http://worker:8973") monkeypatch.setattr(mapserve, "_pipeline_import_cache", {"ok": False}) def fake_proxy(method, path, payload=None, timeout=30): assert (method, path) == ("GET", "/api/status") return {"running": True, "tiles": []} monkeypatch.setattr(mapserve, "_proxy_generation", fake_proxy) d = mapserve.generation_status(_ClientRequest("192.168.3.10")) assert d["distant"] is True and d["running"] is True assert d["available"] is True and d["regen_allowed"] is True def offline(method, path, payload=None, timeout=30): raise HTTPException(503, "machine de traitement injoignable") monkeypatch.setattr(mapserve, "_proxy_generation", offline) d = mapserve.generation_status(_ClientRequest("192.168.3.10")) assert d["worker_offline"] is True and d["running"] is None def test_generate_requires_lan(tmp_path, monkeypatch): """Un client hors LIDAR_REGEN_CIDR ne peut pas lancer de génération.""" mapserve = _gen_setup(tmp_path, monkeypatch) with pytest.raises(HTTPException) as e: mapserve._require_lan_for_generation(_ClientRequest("203.0.113.7")) assert e.value.status_code == 403 def test_cell_at_point(tmp_path, monkeypatch): """/api/cell donne la dalle sous un point (sélection manuelle).""" mapserve = _gen_setup(tmp_path, monkeypatch) from lidar_pipeline.tiles import _transformer lon, lat = _transformer("EPSG:2154", "EPSG:4326").transform(1054500, 6881500) d = mapserve.cell_at_point(lat=lat, lng=lon) assert (d["col"], d["row"]) == (1054, 6882) assert d["corners"] and len(d["corners"]) == 4 def test_tiles_inventory_endpoint(tmp_path, monkeypatch): """/api/tiles sert index_tiles.json (machines légères LIDAR_SOURCE_URL).""" mapserve = _gen_setup(tmp_path, monkeypatch) payload = {"tiles": [{"col": 1054, "row": 6882, "viz": {"aspect": {"full": "visualisations/x.avif?v=1"}}}], "viz_meta": {}, "stats": {"n_tiles": 1}} (tmp_path / "index_tiles.json").write_text( __import__("json").dumps(payload), encoding="utf-8") d = mapserve.tiles_inventory() assert d["tiles"] == payload["tiles"] and d["stamp"] > 0 # ?stamp inchangé → réponse allégée light = mapserve.tiles_inventory(stamp=d["stamp"]) assert light["tiles"] is None def test_generation_always_ign_with_edge_buffer(tmp_path, monkeypatch): """Classification IGN (sol) et raccord des bords de 100 m imposés, quel que soit ce que le client envoie.""" mapserve = _gen_setup(tmp_path, monkeypatch) cmd = mapserve._build_command([(1054, 6882)]) i = cmd.index("--edge-buffer") assert cmd[i + 1] == "100" assert cmd[cmd.index("--ground-classification") + 1] == "ign" assert cmd[cmd.index("--ign-classes") + 1] == "sol" req = mapserve.GenerateRequest(tiles=[[1054, 6882]], ground_class="csf", edge_buffer=False) assert not hasattr(req, "ground_class") or getattr(req, "ground_class", None) != "csf" def test_ui_offers_generation_controls(): """L'interface embarque les contrôles de génération (+ Zone, options, run).""" from lidar_pipeline.mapui import _MAP_CSS, _MAP_HTML, _MAP_JS assert 'id="btnZone"' in _MAP_HTML and 'id="btnComplete"' in _MAP_HTML assert 'id="gencard"' in _MAP_HTML and 'id="genGo"' in _MAP_HTML assert 'id="genStop"' in _MAP_HTML and 'id="genQueueClear"' in _MAP_HTML assert 'id="genViz"' in _MAP_HTML # Classification et raccord des bords imposés : plus de réglage for gone in ('id="genClass"', 'id="genIgnClasses"', 'id="genReclass"', 'id="genEdge"'): assert gone not in _MAP_HTML # Boutons masqués par défaut : révélés seulement si /api/status autorise assert "GEN.available && GEN.allowed" in _MAP_JS # Dessin de zone + cadres de run + sondage d'état assert "genSetDrawing" in _MAP_JS and "pane: 'genDraw'" in _MAP_JS assert "gen-frame-running" in _MAP_CSS and "genDrawFrames" in _MAP_JS assert "api/status" in _MAP_JS and "api/preview" in _MAP_JS assert "api/generate" in _MAP_JS and "api/stop" in _MAP_JS assert "all_missing: true" in _MAP_JS # Fin de run : rafraîchissement des couches + relance de la maintenance assert "genAfterRun" in _MAP_JS and "api/map/background" in _MAP_JS def test_cache_only_fetches_missing_tile_upstream(tmp_path, monkeypatch): """Cache seule + LIDAR_MAPS_URL : une tuile absente est rapatriee du serveur amont (aucun rendu local : les zooms au-dela de la maintenance s'affichent). """ from lidar_pipeline import tiles as _t monkeypatch.setattr(_t, "TILE_EVEN_LEVELS", False) # règle historique : tout est stocké monkeypatch.setattr(_t, "TILE_CACHE_MAX_Z", 99) import lidar_pipeline.mapserve as mapserve from lidar_pipeline import tiles _setup(tmp_path, monkeypatch) monkeypatch.setenv("LIDAR_TILE_CACHE_ONLY", "1") monkeypatch.setattr(mapserve, "MAPS_URL", "http://amont:8975") monkeypatch.setattr(mapserve, "_UPSTREAM", {"fails": 0, "until": 0.0}) calls = [] PNG = bytes([0x89, 0x50, 0x4E, 0x47, 13, 10, 0x1A, 10]) + b'TUILE-AMONT' def fake_fetch(rel_path): calls.append(rel_path) return PNG monkeypatch.setattr(mapserve, "_fetch_upstream", fake_fetch) try: z = 17 # jamais couvert par la maintenance (max_z 16 par defaut) x, y = _tile_of_cell(1054, 6882, z) resp = asyncio.run(mapserve.tile("aspect", z, x, f"{y}@2x.webp")) assert calls == [f"tiles/aspect/{z}/{x}/{y}@2x.webp"] assert resp.body == PNG assert resp.headers.get("X-Tile-Pending") != "1" # servie, plus en attente # Mise en cache : la seconde demande ne repasse pas par l'amont asyncio.run(mapserve.tile("aspect", z, x, f"{y}@2x.webp")) assert len(calls) == 1 assert tiles.tile_cache_path(tmp_path, "aspect", z, x, y, 2, "webp").is_file() finally: monkeypatch.delenv("LIDAR_TILE_CACHE_ONLY") def test_tile_selection_always_answers(tmp_path, monkeypatch): """La sélection d'une dalle répond toujours, sans appel réseau : dalle absente comprise, avec nom, emprise L93 et cadre de sélection.""" mapserve = _setup(tmp_path, monkeypatch, layers=("relief_oriente",)) def boom(*a, **k): raise AssertionError("aucun appel IGN dans /api/map/tile") monkeypatch.setattr("lidar_pipeline.fetch_ign.tile_ign_metadata", boom) d = mapserve.map_tile_info(lat=48.0, lng=2.0) # loin de toute dalle assert d["found"] is False and d["name"].startswith("LHD_FXX_") assert len(d["corners"]) == 4 and d["l93_bbox"][2] - d["l93_bbox"][0] == 1000 def test_ign_metadata_cached_and_resilient(tmp_path, monkeypatch): """Fiche IGN : un seul appel au catalogue (cache disque) ; catalogue injoignable = message, jamais d'exception.""" mapserve = _setup(tmp_path, monkeypatch) calls = [] def fake(col, row, timeout=10): calls.append((col, row)) return {"found": True, "download_url": "https://data.geopf.fr/x.copc.laz", "acquisition_start": "2022-03-05T16:20:51Z"} monkeypatch.setattr("lidar_pipeline.fetch_ign.tile_ign_metadata", fake) a = mapserve.map_tile_ign(col=1004, row=6832) b = mapserve.map_tile_ign(col=1004, row=6832) assert a["download_url"].endswith(".copc.laz") and b == a and len(calls) == 1 def down(col, row, timeout=10): raise OSError("réseau coupé") monkeypatch.setattr("lidar_pipeline.fetch_ign.tile_ign_metadata", down) assert mapserve.map_tile_ign(col=1, row=2)["found"] is False def test_ui_tile_card_and_wind_rose(): from lidar_pipeline.mapui import _MAP_HTML, _MAP_JS assert 'id="rose"' in _MAP_HTML and 'id="roseCanvas"' in _MAP_HTML assert "api/map/ign?col=" in _MAP_JS and "Télécharger le nuage LiDAR" in _MAP_JS assert "compass + 90" in _MAP_JS # même repère que le rendu (aspect = boussole + 90°) def test_fresh_tiles_jump_the_maintenance_queue(monkeypatch): """Tuiles d'une dalle tout juste rendue : en tête de file, devant l'arriéré.""" import lidar_pipeline.mapserve as mapserve monkeypatch.setitem(mapserve._bg, "queue", __import__("collections").deque()) monkeypatch.setitem(mapserve._bg, "queued", set()) backlog = [("relief_oriente", 16, i, 0) for i in range(5)] mapserve._bg_enqueue(backlog) fresh = [("relief_oriente", 12, 9, 9), ("relief_oriente", 16, 3, 0)] mapserve._bg_enqueue(fresh, front=True) q = list(mapserve._bg["queue"]) assert q[:2] == fresh and len(q) == 6 and q.count(("relief_oriente", 16, 3, 0)) == 1 def test_cache_only_renders_unstored_levels(tmp_path, monkeypatch): """Cache seule (Pi) : un niveau non stocké est rendu à la volée — jamais une tuile transparente « en attente » qui n'arriverait jamais.""" import lidar_pipeline.mapserve as mapserve from lidar_pipeline import tiles monkeypatch.setattr(tiles, "TILE_EVEN_LEVELS", True) monkeypatch.setattr(tiles, "TILE_CACHE_MAX_Z", 16) _setup(tmp_path, monkeypatch) monkeypatch.setenv("LIDAR_TILE_CACHE_ONLY", "1") z = 17 x, y = _tile_of_cell(1054, 6882, z) resp = asyncio.run(mapserve.tile("aspect", z, x, f"{y}@2x.avif")) assert resp.headers.get("X-Tile-Pending") != "1" and resp.headers["X-Tile-Empty"] == "0" assert not tiles.tile_cache_path(tmp_path, "aspect", z, x, y, 2, "avif").exists() def test_background_scan_only_queues_stored_levels(tmp_path, monkeypatch): import lidar_pipeline.mapserve as mapserve from lidar_pipeline import tiles monkeypatch.setattr(tiles, "TILE_EVEN_LEVELS", True) monkeypatch.setattr(tiles, "TILE_CACHE_MAX_Z", 16) _setup(tmp_path, monkeypatch) monkeypatch.setitem(mapserve._bg, "queue", __import__("collections").deque()) monkeypatch.setitem(mapserve._bg, "queued", set()) monkeypatch.setitem(mapserve._bg, "snapshot", None) mapserve.background_scan(tmp_path) zs = {z for (_l, z, _x, _y) in mapserve._bg["queue"]} assert zs and all(tiles.zoom_cached(z, 2) for z in zs) assert zs <= {5, 7, 9, 11, 13, 15} def test_remote_fed_map_keeps_sources_locally(tmp_path, monkeypatch): """Carte alimentée par l'amont : les sources nécessaires sont rapatriées et gardées sans attendre de visite ; une source manquée (amont éteint) est reprise au scan suivant ; la dalle entière (doublon) n'est pas prise.""" import lidar_pipeline.mapserve as mapserve from lidar_pipeline import tiles S = tiles._Source thumb = S(tmp_path / "t.webp", (0, 0, 1000, 1000), 3.9, url="http://amont/t") quads = [S(tmp_path / f"q{i}.avif", (0, 0, 500, 500), 0.2, url=f"http://amont/q{i}") for i in range(4)] full = S(tmp_path / "d.avif", (0, 0, 1000, 1000), 0.2, url="http://amont/d") assert tiles.sources_to_keep([[thumb], quads, [full]]) == [thumb] + quads monkeypatch.setattr(tiles, "REMOTE_SOURCE_URL", "http://amont") monkeypatch.setattr(mapserve, "OUTPUT_DIR", tmp_path) monkeypatch.setattr(tiles, "source_index", lambda *a, **k: {"relief_oriente": {(1, 2): [[thumb], quads, [full]]}}) for key, val in (("queue", __import__("collections").deque()), ("queued", set()), ("snapshot", None), ("prefetch", __import__("collections").deque())): monkeypatch.setitem(mapserve._bg, key, val) online = {"up": False} def fake_fetch(url, dest): if not online["up"]: return False dest.write_bytes(b"x"); return True monkeypatch.setattr(tiles, "_fetch_source", fake_fetch) mapserve.background_scan(tmp_path) while mapserve._bg["prefetch"]: mapserve._bg_process_one() assert not any(s_.path.exists() for s_ in [thumb] + quads) # amont éteint online["up"] = True mapserve.background_scan(tmp_path) # dalle inchangée : reprise quand même while mapserve._bg["prefetch"]: mapserve._bg_process_one() assert all(s_.path.exists() for s_ in [thumb] + quads) and not full.path.exists() def _bg_reset(monkeypatch, mapserve): import collections for key, val in (("queue", collections.deque()), ("queued", set()), ("snapshot", None), ("prefetch", collections.deque()), ("stats", {"rendues": 0, "a_jour": 0, "vides": 0, "scans": 0, "dernier_scan": None, "ajouts_dernier_scan": 0})): monkeypatch.setitem(mapserve._bg, key, val) def test_background_downloads_tiles_from_upstream(tmp_path, monkeypatch): """Maintenance de fond + LIDAR_MAPS_URL : les tuiles de la pyramide sont TÉLÉCHARGÉES en tâche de fond (pas seulement à l'affichage), sans rendu local — la carte se remplit même sans visite et reste servie ensuite sans l'amont.""" import lidar_pipeline.mapserve as mapserve from lidar_pipeline import tiles _setup(tmp_path, monkeypatch) _bg_reset(monkeypatch, mapserve) monkeypatch.setattr(mapserve, "MAPS_URL", "http://amont:8975") AVIF = b"\x00\x00\x00\x20ftypavif-TUILE-AMONT" calls = [] monkeypatch.setattr(mapserve, "_fetch_upstream", lambda rel: calls.append(rel) or AVIF) def no_render(*a, **k): raise AssertionError("rendu local inutile : la tuile vient de l'amont") monkeypatch.setattr(tiles, "get_tile", no_render) z = 13 x, y = _tile_of_cell(1054, 6882, z) mapserve._bg_enqueue([("aspect", z, x, y)]) assert mapserve._bg_process_one() is True assert calls == [f"tiles/aspect/{z}/{x}/{y}@2x.avif"] cache = tiles.tile_cache_path(tmp_path, "aspect", z, x, y, 2, "avif") assert cache.read_bytes() == AVIF assert mapserve._bg["stats"]["telechargees"] == 1 # Fraîche : servie en cache seule sans amont ni rendu data, state = tiles.cached_tile(tmp_path, "aspect", z, x, y, 2, "avif") assert state == "fresh" and data == AVIF def test_background_renders_locally_when_upstream_down(tmp_path, monkeypatch): """Amont éteint : la maintenance rend la tuile localement (autonomie).""" import lidar_pipeline.mapserve as mapserve from lidar_pipeline import tiles _setup(tmp_path, monkeypatch) _bg_reset(monkeypatch, mapserve) monkeypatch.setattr(mapserve, "MAPS_URL", "http://amont:8975") monkeypatch.setattr(mapserve, "_fetch_upstream", lambda rel: None) z = 13 x, y = _tile_of_cell(1054, 6882, z) mapserve._bg_enqueue([("aspect", z, x, y)]) assert mapserve._bg_process_one() is True assert tiles.tile_cache_path(tmp_path, "aspect", z, x, y, 2, "avif").is_file() assert mapserve._bg["stats"]["rendues"] == 1 def test_upstream_breaker_rearms_after_expiry(monkeypatch): """La suspension expirée, de nouveaux échecs la relancent (sinon chaque tuile repaie le délai réseau d'un amont éteint, indéfiniment).""" import lidar_pipeline.mapserve as mapserve monkeypatch.setattr(mapserve, "_UPSTREAM", {"fails": 0, "until": 0.0}) for _ in range(3): mapserve._upstream_mark(False) assert mapserve._upstream_offline() is True mapserve._UPSTREAM["until"] = 1.0 # suspension expirée assert mapserve._upstream_offline() is False mapserve._upstream_mark(False) # nouvel échec : resuspendu assert mapserve._upstream_offline() is True def test_empty_tile_skips_upstream_when_inventory_is_upstream(tmp_path, monkeypatch): """Inventaire venu de l'amont (LIDAR_SOURCE_URL) et aucune dalle sous la tuile : l'amont n'en sait pas plus — pas de requête (20 s par tuile vide quand il est éteint).""" import lidar_pipeline.mapserve as mapserve from lidar_pipeline import tiles _setup(tmp_path, monkeypatch) monkeypatch.setattr(mapserve, "MAPS_URL", "http://amont:8975") monkeypatch.setattr(tiles, "REMOTE_SOURCE_URL", "http://amont:8973") monkeypatch.setattr(tiles, "_contributing", lambda *a, **k: []) calls = [] monkeypatch.setattr(mapserve, "_fetch_upstream", lambda rel: calls.append(rel)) resp = asyncio.run(mapserve.tile("aspect", 15, 1, "1.png")) assert calls == [] assert resp.headers["X-Tile-Empty"] == "1" def test_cache_only_serves_stale_tile_while_pending(tmp_path, monkeypatch): """Cache seule, tuile périmée (dalle régénérée) et amont muet : l'ancienne tuile reste affichée en attendant la nouvelle — pas un trou transparent.""" import os import lidar_pipeline.mapserve as mapserve from lidar_pipeline import tiles _setup(tmp_path, monkeypatch) monkeypatch.setenv("LIDAR_TILE_CACHE_ONLY", "1") monkeypatch.setattr(mapserve, "MAPS_URL", "") z = 13 x, y = _tile_of_cell(1054, 6882, z) cache = tiles.tile_cache_path(tmp_path, "aspect", z, x, y, 2, "avif") cache.parent.mkdir(parents=True, exist_ok=True) cache.write_bytes(b"ANCIENNE-TUILE") os.utime(cache, (1_000_000, 1_000_000)) # plus vieille que la dalle assert tiles.cached_tile(tmp_path, "aspect", z, x, y, 2, "avif")[1] == "pending" resp = asyncio.run(mapserve.tile("aspect", z, x, f"{y}@2x.avif")) assert resp.body == b"ANCIENNE-TUILE" assert resp.headers["Cache-Control"] == "no-store" # redemandée plus tard assert resp.headers["X-Tile-Pending"] == "1" assert resp.headers["X-Tile-Empty"] == "0" def test_background_tiles_before_sources(tmp_path, monkeypatch): """Tuiles d'abord (légères, visibles tout de suite), sources ensuite : des milliers de sources à rapatrier ne retardent pas la pyramide.""" import lidar_pipeline.mapserve as mapserve from lidar_pipeline import tiles _setup(tmp_path, monkeypatch) _bg_reset(monkeypatch, mapserve) monkeypatch.setattr(mapserve, "MAPS_URL", "http://amont:8975") monkeypatch.setattr(mapserve, "_fetch_upstream", lambda rel: b"TUILE") fetched = [] class Src: def ensure(self): fetched.append(1) return True mapserve._bg["prefetch"].append(Src()) z = 13 x, y = _tile_of_cell(1054, 6882, z) mapserve._bg_enqueue([("aspect", z, x, y)]) assert mapserve._bg_process_one() is True assert fetched == [] and mapserve._bg["stats"]["telechargees"] == 1 assert mapserve._bg_process_one() is True assert fetched == [1] def test_zoom_animation_transforms_only_on_all_tile_layers(): """Pendant l'animation de zoom, AUCUNE couche ne crée de niveau : un niveau neuf n'est pas composé par Chrome (animé sur le thread principal, gelé ~90 ms par le chargement) — le fond sautait quand le relief orienté, qui garde son niveau pair, glissait normalement : désynchronisés.""" import re from lidar_pipeline.mapui import _MAP_JS assert "_animateZoom(e) { this._setView(e.center, e.zoom, true, true); }" in _MAP_JS # « zoom » part aussi au début de l'animation (Leaflet 1.9) : même garde assert "if (this._map && this._map._animatingZoom) {" in _MAP_JS # Sans quoi _setView recrée un niveau malgré noUpdate (tileZoomChanged) assert _MAP_JS.count("updateWhenZooming: false") >= 2 # Le fond OSM ET les couches LiDAR héritent du comportement assert re.search(r"const base = new ZoomAnimTileLayer\(", _MAP_JS) assert "const EvenLevelTileLayer = ZoomAnimTileLayer.extend(" in _MAP_JS assert "L.tileLayer(" not in _MAP_JS.replace("L.tileLayer` standard", "")