"""Tests de l'export multi-dalles (mosaïque jointive image/PDF) et de son API.""" def _make_tile_dir(tmp_path, col, row, viz_keys, resolution=0.5, tile_px=16, color=(255, 0, 0)): """Crée le dossier de visualisations d'une dalle (WebP lossless unis). Le suffixe de résolution n'est que sur le DOSSIER : les fichiers gardent le basename nu (cf. _expected_output_path dans le pipeline). """ from PIL import Image base = f"LHD_FXX_{col:04d}_{row:04d}_PTS_LAMB93_IGN69" suffix = "" if resolution == 0.5 else "_r" + str(resolution).replace(".", "p") vis = tmp_path / "visualisations" / (base + suffix) vis.mkdir(parents=True, exist_ok=True) for key in viz_keys: Image.new("RGB", (tile_px, tile_px), color).save( str(vis / f"{base}_{key}.webp"), format="WEBP", lossless=True) def _grid2x2(tmp_path): """Quatre dalles adjacentes de couleurs distinctes (row 20 au nord).""" _make_tile_dir(tmp_path, 10, 20, ["slope"], color=(255, 0, 0)) # nord-ouest _make_tile_dir(tmp_path, 11, 20, ["slope"], color=(0, 255, 0)) # nord-est _make_tile_dir(tmp_path, 10, 19, ["slope"], color=(0, 0, 255)) # sud-ouest _make_tile_dir(tmp_path, 11, 19, ["slope"], color=(255, 255, 0)) # sud-est def test_export_places_tiles_and_is_seamless(tmp_path): """Grille 2×2 : placement exact (row haute au nord) et collage bord à bord. Aucun trait ni espace entre les dalles : les pixels de part et d'autre de chaque couture appartiennent aux dalles voisines, jamais au fond blanc. """ from PIL import Image from lidar_pipeline.export import build_export _grid2x2(tmp_path) out = tmp_path / "exports" res = build_export(tmp_path / "visualisations", [(10, 19), (11, 19), (10, 20), (11, 20)], ["slope"], 0.5, "png", out) with Image.open(str(res["file"])) as img: # 2 dalles de 16 px + habillage → largeur = 32 exactement (le bandeau # légende, comme bandeau/pied, n'élargit pas l'image) assert img.width == 32 # Haut de la mosaïque : premier pixel rouge pur (dalles au-dessus des # bandeaux texte blancs, insensible à la hauteur du bandeau légende) def rgb(x, y): return img.getpixel((x, y)) mosaic_top = next(y for y in range(img.height) if rgb(4, y) == (255, 0, 0)) mid = mosaic_top + 16 # Nord : row 20 en haut (rouge à l'ouest, vert à l'est) assert rgb(4, mosaic_top + 4) == (255, 0, 0) assert rgb(28, mosaic_top + 4) == (0, 255, 0) # Sud : row 19 en bas (bleu à l'ouest, jaune à l'est) assert rgb(4, mosaic_top + 28) == (0, 0, 255) assert rgb(28, mosaic_top + 28) == (255, 255, 0) # Couture verticale (x=15|16) : couleurs des dalles, pas de blanc assert rgb(15, mosaic_top + 8) == (255, 0, 0) assert rgb(16, mosaic_top + 8) == (0, 255, 0) assert rgb(15, mid) == (0, 0, 255) assert rgb(16, mid) == (255, 255, 0) # Couture horizontale (y=+15|+16) : couleurs des dalles, pas de blanc assert rgb(4, mosaic_top + 15) == (255, 0, 0) assert rgb(4, mosaic_top + 16) == (0, 0, 255) def test_export_pdf_one_page_per_viz(tmp_path): """PDF multi-couches : une page par visualisation demandée.""" from lidar_pipeline.export import build_export _make_tile_dir(tmp_path, 10, 20, ["slope", "aspect"]) res = build_export(tmp_path / "visualisations", [(10, 20)], ["slope", "aspect"], 0.5, "pdf", tmp_path / "exports") assert res["file"].suffix == ".pdf" data = res["file"].read_bytes() assert data.startswith(b"%PDF") pages = data.count(b"/Type /Page") - data.count(b"/Type /Pages") assert pages == 2 def test_export_image_one_file_per_viz(tmp_path): """Image + plusieurs couches : un fichier par couche, chacun avec SA légende (comme le PDF fait une page par couche).""" from lidar_pipeline.export import build_export _make_tile_dir(tmp_path, 10, 20, ["slope", "aspect"]) res = build_export(tmp_path / "visualisations", [(10, 20)], ["slope", "aspect"], 0.5, "png", tmp_path / "exports") files = res["files"] assert len(files) == 2 names = [e["file"].name for e in files] assert any("_slope_" in n for n in names) assert any("_aspect_" in n for n in names) for e in files: assert e["file"].is_file() and e["file"].suffix == ".png" assert e["pages"] == 1 # Clés de compatibilité : premier fichier assert res["file"] == files[0]["file"] assert res["width"] == files[0]["width"] and res["height"] == files[0]["height"] def test_export_missing_tile_or_viz(tmp_path): """Dalle absente à la résolution demandée ou visualisation manquante.""" from lidar_pipeline.export import build_export _make_tile_dir(tmp_path, 10, 20, ["slope"]) try: build_export(tmp_path / "visualisations", [(10, 20), (12, 20)], ["slope"], 0.5, "png", tmp_path / "exports") assert False, "une ValueError était attendue" except ValueError as e: assert "12,20" in str(e) try: build_export(tmp_path / "visualisations", [(10, 20)], ["wavelet"], 0.5, "png", tmp_path / "exports") assert False, "une ValueError était attendue" except ValueError as e: assert "wavelet" in str(e) def test_export_includes_layer_legend(tmp_path): """Chaque export porte la légende de sa couche : dégradé de la colormap (extrémités exactes) dans un bandeau sous le pied, en plus de l'habillage.""" from PIL import Image from lidar_pipeline.export import build_export _make_tile_dir(tmp_path, 10, 20, ["slope"], tile_px=256) res = build_export(tmp_path / "visualisations", [(10, 20)], ["slope"], 0.5, "png", tmp_path / "exports") with Image.open(str(res["file"])) as img: # Bandeau légende présent : hauteur au-delà de mosaïque + bandeau + pied assert img.height > 256 + 90 + 84 zone = [c for c in (img.getpixel((x, y)) for y in range(256 + 90 + 84, img.height) for x in range(img.width)) if isinstance(c, tuple)] # Extrémités du dégradé inferno de la pente (0° → 30°) : #000004 → #fcffa4 assert any(abs(c[0]) <= 1 and abs(c[1]) <= 1 and abs(c[2] - 4) <= 1 for c in zone) assert any(abs(c[0] - 252) <= 1 and abs(c[1] - 255) <= 1 and abs(c[2] - 164) <= 1 for c in zone) def test_export_legend_per_pdf_page(): """PDF multi-couches : chaque page porte la légende de SA couche.""" from lidar_pipeline.export import _legend_layout from lidar_pipeline.index import VIZ_LEGENDS from PIL import Image layout_slope = _legend_layout(VIZ_LEGENDS["slope"], Image.new("RGB", (600, 600)), 600) layout_aspect = _legend_layout(VIZ_LEGENDS["aspect"], Image.new("RGB", (600, 600)), 600) # Bandeaux distincts (dégradés/textes différents) et dimensionnés assert layout_slope["height"] > 0 and layout_aspect["height"] > 0 assert layout_slope["has_bar"] and layout_aspect["has_bar"] def test_viz_legends_registry_complete(): """VIZ_LEGENDS couvre toutes les couches proposées (VIZ_LABELS).""" from lidar_pipeline.index import VIZ_LABELS, VIZ_LEGENDS assert set(VIZ_LABELS) == set(VIZ_LEGENDS) for key, info in VIZ_LEGENDS.items(): assert info["title"], key assert info["legend"], key assert info["description"], key if info.get("gradient") is not None: assert len(info["gradient"]) >= 2, key assert info["ticks"] and len(info["ticks"]) == 2, key else: # couches photo/carte : pas de dégradé, pas de bornes assert info["ticks"] is None, key def test_viz_legends_gradients_match_colormaps(): """Les dégradés de VIZ_LEGENDS restent calés sur les colormaps réelles (un cmap changé dans rendering.py doit rafraîchir le dégradé).""" import matplotlib matplotlib.use("Agg") import matplotlib.pyplot as plt from lidar_pipeline.index import VIZ_LEGENDS from lidar_pipeline.rendering import COLORMAPS, RGB_LEGENDS for key, info in VIZ_LEGENDS.items(): cmap_name = info.get("cmap") if not cmap_name: continue ref = COLORMAPS.get(key) or RGB_LEGENDS.get(key) assert ref is not None, key assert ref["cmap"] == cmap_name, key cm = plt.get_cmap(cmap_name) stops = info["gradient"] for i, hx in enumerate(stops): r, g, b = (int(hx[j:j + 2], 16) for j in (1, 3, 5)) mr, mg, mb = (round(v * 255) for v in cm(i / (len(stops) - 1))[:3]) assert abs(r - mr) <= 1 and abs(g - mg) <= 1 and abs(b - mb) <= 1, key def test_export_max_side_downsizes(tmp_path): """max_side réduit le plus grand côté de la mosaïque.""" from PIL import Image from lidar_pipeline.export import build_export _make_tile_dir(tmp_path, 10, 20, ["slope"], tile_px=64) _make_tile_dir(tmp_path, 11, 20, ["slope"], tile_px=64) res = build_export(tmp_path / "visualisations", [(10, 20), (11, 20)], ["slope"], 0.5, "png", tmp_path / "exports", max_side=64) with Image.open(str(res["file"])) as img: assert img.width == 64 # mosaïque 128 px ramenée à 64 def test_export_endpoint(tmp_path, monkeypatch): """/api/export assemble et renvoie un fichier téléchargeable.""" import lidar_pipeline.webapp as webapp _grid2x2(tmp_path) monkeypatch.setattr(webapp, "OUTPUT_DIR", tmp_path) req = webapp.ExportRequest(tiles=[[10, 20], [11, 20], [10, 19], [11, 19]], viz=["slope"], resolution=0.5, format="jpeg", max_side=0) d = webapp.export_tiles(req) files = d["fichiers"] assert len(files) == 1 f = files[0] assert f["url"].startswith("/api/export/file/") assert f["nom"].endswith(".jpg") assert (tmp_path / "exports" / f["nom"]).is_file() assert f["taille"] > 0 def test_export_endpoint_multi_viz_image(tmp_path, monkeypatch): """/api/export en image avec 2 couches → 2 fichiers téléchargeables.""" import lidar_pipeline.webapp as webapp _make_tile_dir(tmp_path, 10, 20, ["slope", "aspect"]) monkeypatch.setattr(webapp, "OUTPUT_DIR", tmp_path) req = webapp.ExportRequest(tiles=[[10, 20]], viz=["slope", "aspect"], resolution=0.5, format="jpeg", max_side=0) d = webapp.export_tiles(req) assert len(d["fichiers"]) == 2 for f in d["fichiers"]: assert f["url"].startswith("/api/export/file/") assert (tmp_path / "exports" / f["nom"]).is_file() def test_export_endpoint_rejects_bad_requests(tmp_path, monkeypatch): """/api/export valide format, visualisations et tuiles.""" from fastapi import HTTPException import lidar_pipeline.webapp as webapp _grid2x2(tmp_path) monkeypatch.setattr(webapp, "OUTPUT_DIR", tmp_path) def expect_400(req): try: webapp.export_tiles(req) assert False, "une HTTPException était attendue" except HTTPException as e: assert e.status_code == 400 expect_400(webapp.ExportRequest(tiles=[[10, 20]], viz=["slope"], resolution=0.5, format="tiff", max_side=0)) expect_400(webapp.ExportRequest(tiles=[[10, 20]], viz=[], resolution=0.5, format="png", max_side=0)) # Dalle inexistante à cette résolution expect_400(webapp.ExportRequest(tiles=[[10, 20]], viz=["slope"], resolution=0.2, format="png", max_side=0)) def test_export_file_route(tmp_path, monkeypatch): """Le fichier exporté est servi en pièce jointe ; traversée refusée.""" from fastapi import HTTPException from fastapi.responses import FileResponse import lidar_pipeline.webapp as webapp exports = tmp_path / "exports" exports.mkdir(parents=True) (exports / "ok.png").write_bytes(b"x") monkeypatch.setattr(webapp, "OUTPUT_DIR", tmp_path) resp = webapp.export_file("ok.png") assert isinstance(resp, FileResponse) for bad in ("../visualisations/secret", "nope.png", "a/b.png"): try: webapp.export_file(bad) assert False, f"une HTTPException était attendue pour {bad}" except HTTPException as e: assert e.status_code == 404