297 lines
12 KiB
Python
297 lines
12 KiB
Python
"""Tests de l'export PDF (planche d'impression terrain)."""
|
||
|
||
import pytest
|
||
|
||
|
||
def test_layout_landscape_a4_dimensions():
|
||
from lidar_pipeline.export_pdf import layout
|
||
lay = layout("A4", "paysage", 2000)
|
||
assert (lay.page_w, lay.page_h) == (297.0, 210.0) and lay.dpi == 300
|
||
assert lay.map_w > 150 and lay.map_h > 150
|
||
assert lay.panel_x >= lay.map_x + lay.map_w # bandeau à droite
|
||
assert lay.map_x + lay.map_w <= lay.page_w and lay.map_y + lay.map_h <= lay.page_h
|
||
|
||
|
||
def test_layout_portrait_a3_panel_below():
|
||
from lidar_pipeline.export_pdf import layout
|
||
lay = layout("A3", "portrait", 5000)
|
||
assert (lay.page_w, lay.page_h) == (297.0, 420.0) and lay.dpi == 250
|
||
assert lay.panel_y + lay.panel_h <= lay.map_y # bandeau en bas
|
||
|
||
|
||
@pytest.mark.parametrize("args", [("A5", "paysage", 2000), ("A4", "biais", 2000),
|
||
("A4", "paysage", 1234)])
|
||
def test_layout_rejects_invalid(args):
|
||
from lidar_pipeline.export_pdf import layout
|
||
with pytest.raises(ValueError):
|
||
layout(*args)
|
||
|
||
|
||
def test_map_bbox_matches_paper_times_scale():
|
||
from lidar_pipeline.export_pdf import layout, map_bbox
|
||
lay = layout("A4", "paysage", 2000)
|
||
b = map_bbox(652500.0, 6861500.0, lay)
|
||
assert abs((b[2] - b[0]) - lay.map_w / 1000 * 2000) < 1e-6
|
||
assert abs((b[3] - b[1]) - lay.map_h / 1000 * 2000) < 1e-6
|
||
assert abs((b[0] + b[2]) / 2 - 652500.0) < 1e-6
|
||
|
||
|
||
def test_pixel_size_and_grid_step():
|
||
from lidar_pipeline.export_pdf import grid_step, layout, pixel_size
|
||
assert pixel_size(layout("A4", "paysage", 1000)) == 0.2 # plafonné au natif
|
||
assert abs(pixel_size(layout("A3", "paysage", 10000)) - 10000 * 0.0254 / 250) < 1e-9
|
||
assert [grid_step(s) for s in (1000, 2000, 5000, 10000)] == [100, 100, 500, 1000]
|
||
|
||
|
||
def test_center_l93_rejects_non_finite():
|
||
from lidar_pipeline.export_pdf import center_l93
|
||
with pytest.raises(ValueError):
|
||
center_l93(float("nan"), 2.0)
|
||
|
||
|
||
def test_frame_roundtrip():
|
||
from lidar_pipeline.export_pdf import center_l93, frame
|
||
lat, lon = 48.85, 2.35
|
||
f = frame(lat, lon, "A4", "paysage", 2000)
|
||
cx, cy = center_l93(lat, lon)
|
||
assert abs(f["cx"] - cx) < 1e-6 and abs(f["cy"] - cy) < 1e-6
|
||
assert len(f["corners"]) == 4
|
||
nw, ne, se, sw = f["corners"]
|
||
assert nw[0] > sw[0] and ne[1] > nw[1] # [lat, lon] : nord en haut, est à droite
|
||
assert f["width_m"] > f["height_m"] > 0
|
||
|
||
|
||
def test_export_modules_import_without_numpy():
|
||
import subprocess, sys
|
||
code = ("import sys; sys.modules['numpy'] = None; "
|
||
"import lidar_pipeline.export_pdf, lidar_pipeline.tiles; print('ok')")
|
||
r = subprocess.run([sys.executable, "-c", code], capture_output=True, text=True)
|
||
assert r.returncode == 0, r.stderr
|
||
|
||
|
||
def _dalle(tmp_path, col, row, color=(200, 30, 30), px=100, layer="relief_oriente"):
|
||
from lidar_pipeline import index, tiles
|
||
from lidar_pipeline.tests.test_tiles import _make_dalle
|
||
_make_dalle(tmp_path, col, row, [layer], color=color, px=px)
|
||
tiles.source_index(tmp_path, force=True)
|
||
|
||
|
||
def test_nodata_color_matches_visualizations():
|
||
from lidar_pipeline.export_pdf import NODATA_RGB
|
||
from lidar_pipeline.visualizations import RELIEF_NODATA_RGB
|
||
assert NODATA_RGB == tuple(RELIEF_NODATA_RGB)
|
||
|
||
|
||
def test_compose_l93_full_cell(tmp_path, monkeypatch):
|
||
from lidar_pipeline import index
|
||
from lidar_pipeline.export_pdf import compose_l93
|
||
monkeypatch.setattr(index, "PANEL_VIZ", None)
|
||
_dalle(tmp_path, 1054, 6882)
|
||
img, mask, cells = compose_l93(tmp_path, (1054200.0, 6881200.0, 1054400.0, 6881300.0), 1.0)
|
||
assert img.size == (200, 100) and cells == [(1054, 6882)]
|
||
assert mask.getextrema() == (255, 255)
|
||
r, g, b = img.getpixel((100, 50))
|
||
assert r > 150 and g < 80 and b < 80
|
||
|
||
|
||
def test_compose_l93_half_outside_is_white_hatched(tmp_path, monkeypatch):
|
||
from lidar_pipeline import index
|
||
from lidar_pipeline.export_pdf import compose_l93
|
||
monkeypatch.setattr(index, "PANEL_VIZ", None)
|
||
_dalle(tmp_path, 1054, 6882)
|
||
# moitié ouest dans la dalle, moitié est hors données
|
||
img, mask, cells = compose_l93(tmp_path, (1054900.0, 6881400.0, 1055100.0, 6881500.0), 1.0)
|
||
assert mask.getpixel((50, 50)) == 255 and mask.getpixel((150, 50)) == 0
|
||
east = img.crop((110, 0, 200, 100)).convert("L").getextrema()
|
||
assert east[1] == 255 and east[0] < 255 # blanc + hachures
|
||
|
||
|
||
def test_compose_l93_nodata_pixels_masked(tmp_path, monkeypatch):
|
||
from lidar_pipeline import index
|
||
from lidar_pipeline.export_pdf import NODATA_RGB, compose_l93
|
||
monkeypatch.setattr(index, "PANEL_VIZ", None)
|
||
_dalle(tmp_path, 1054, 6882, color=NODATA_RGB)
|
||
img, mask, cells = compose_l93(tmp_path, (1054200.0, 6881200.0, 1054400.0, 6881300.0), 1.0)
|
||
assert mask.getextrema() == (0, 0)
|
||
|
||
|
||
def test_compose_l93_no_data_returns_empty_cells(tmp_path):
|
||
from lidar_pipeline.export_pdf import compose_l93
|
||
img, mask, cells = compose_l93(tmp_path, (0.0, 0.0, 100.0, 100.0), 1.0)
|
||
assert cells == [] and mask.getextrema() == (0, 0)
|
||
|
||
|
||
def test_lab_to_rgb_matches_numpy_reference():
|
||
import numpy as np
|
||
from lidar_pipeline.export_pdf import lab_to_rgb
|
||
from lidar_pipeline.visualizations import _lab_to_srgb
|
||
for L, a, b in [(64, 30, -20), (20, 0, 0), (90, -10, 40), (50, 60, 0)]:
|
||
ref = tuple(int(round(v * 255)) for v in _lab_to_srgb(
|
||
np.array(L, float), np.array(a, float), np.array(b, float)))
|
||
assert all(abs(p - q) <= 1 for p, q in zip(lab_to_rgb(L, a, b), ref))
|
||
|
||
|
||
def test_rose_color_distinct_orientations():
|
||
from lidar_pipeline.export_pdf import rose_color
|
||
colors = {rose_color(c) for c in (0, 90, 180, 270)}
|
||
assert len(colors) == 4
|
||
|
||
|
||
def test_density_color_classes():
|
||
from lidar_pipeline.export_pdf import DENSITY_CLASSES, density_color
|
||
assert density_color(0.0) == DENSITY_CLASSES[0][2]
|
||
assert density_color(50.0) == DENSITY_CLASSES[-1][2]
|
||
|
||
|
||
def test_pdf_text_replaces_unencodable():
|
||
from lidar_pipeline.export_pdf import _pdf_text
|
||
assert _pdf_text("Relief orienté — 1:2 000 ≥ 🚀") == "Relief orienté — 1:2 000 ? ?"
|
||
|
||
|
||
def _q(density, start="2023-03-15", end="2023-03-17", empty=0.1):
|
||
return {"version": 1, "ground_density": density,
|
||
"density_grid": [[density] * 20 for _ in range(20)],
|
||
"empty_fraction": empty, "acq_start": start, "acq_end": end,
|
||
"acq_source": "gps"}
|
||
|
||
|
||
def test_zone_quality_aggregates_two_cells():
|
||
from lidar_pipeline.export_pdf import zone_quality
|
||
table = {"LHD_FXX_1054_6882_PTS_LAMB93_IGN69": _q(4.0, "2023-03-15", "2023-03-15"),
|
||
"LHD_FXX_1055_6882_PTS_LAMB93_IGN69": _q(8.0, "2023-04-02", "2023-04-03", 0.3)}
|
||
bbox = (1054500.0, 6881500.0, 1055500.0, 6881600.0) # moitié de chaque dalle
|
||
z = zone_quality(bbox, table, [(1054, 6882), (1055, 6882)])
|
||
assert abs(z["density_mean"] - 6.0) < 1e-6 and z["density_min"] == 4.0
|
||
assert abs(z["empty_fraction"] - 0.2) < 1e-6
|
||
assert (z["acq_start"], z["acq_end"]) == ("2023-03-15", "2023-04-03")
|
||
assert z["missing_relief"] == [] and z["missing_quality"] == []
|
||
assert len(z["grid_cells"]) == 2 * 10 * 2 # 10 mailles en x × 2 en y par dalle
|
||
|
||
|
||
def test_zone_quality_missing_cells():
|
||
from lidar_pipeline.export_pdf import zone_quality
|
||
bbox = (1054500.0, 6881500.0, 1055500.0, 6881600.0)
|
||
z = zone_quality(bbox, {}, [(1054, 6882)])
|
||
assert z["density_mean"] is None and z["acq_start"] is None
|
||
assert z["missing_quality"] == [(1054, 6882), (1055, 6882)]
|
||
assert z["missing_relief"] == [(1055, 6882)]
|
||
|
||
|
||
def _mediabox(pdf):
|
||
import re
|
||
m = re.search(rb"/MediaBox \[\s*0 0 ([\d.]+) ([\d.]+)\s*\]", pdf)
|
||
return float(m.group(1)), float(m.group(2))
|
||
|
||
|
||
def _cell_center_wgs84(col, row):
|
||
from lidar_pipeline.tiles import _transformer
|
||
lon, lat = _transformer("EPSG:2154", "EPSG:4326").transform(col * 1000 + 500, (row - 1) * 1000 + 500)
|
||
return lat, lon
|
||
|
||
|
||
def test_build_pdf_a4_landscape(tmp_path, monkeypatch):
|
||
from lidar_pipeline import index
|
||
from lidar_pipeline.export_pdf import build_pdf
|
||
from lidar_pipeline.quality import write_quality
|
||
monkeypatch.setattr(index, "PANEL_VIZ", None)
|
||
_dalle(tmp_path, 1054, 6882, px=200)
|
||
write_quality(tmp_path, "LHD_FXX_1054_6882_PTS_LAMB93_IGN69", _q(6.5))
|
||
lat, lon = _cell_center_wgs84(1054, 6882)
|
||
pdf, name = build_pdf(tmp_path, lat, lon, "A4", "paysage", 2000,
|
||
title="Prospection 🚀 bois", compress=False)
|
||
assert pdf.startswith(b"%PDF")
|
||
w, h = _mediabox(pdf)
|
||
assert abs(w - 841.89) < 0.5 and abs(h - 595.28) < 0.5
|
||
assert name.startswith("relief_1054.") and name.endswith("_1-2000.pdf")
|
||
for s in (b"Prospection ? bois", b"1:2 000", b"Densit", b"2023-03-15", b"LiDAR HD", b"6,5 pts"):
|
||
assert s in pdf, s
|
||
|
||
|
||
def test_build_pdf_a3_portrait_size(tmp_path, monkeypatch):
|
||
from lidar_pipeline import index
|
||
from lidar_pipeline.export_pdf import build_pdf
|
||
monkeypatch.setattr(index, "PANEL_VIZ", None)
|
||
_dalle(tmp_path, 1054, 6882)
|
||
lat, lon = _cell_center_wgs84(1054, 6882)
|
||
pdf, _name = build_pdf(tmp_path, lat, lon, "A3", "portrait", 5000)
|
||
w, h = _mediabox(pdf)
|
||
assert abs(w - 841.89) < 0.5 and abs(h - 1190.55) < 0.5
|
||
|
||
|
||
def test_build_pdf_partial_zone_hatched(tmp_path, monkeypatch):
|
||
"""Zone à cheval sur le bord des données : planche produite, dalle absente listée."""
|
||
from lidar_pipeline import index
|
||
from lidar_pipeline.export_pdf import build_pdf
|
||
from lidar_pipeline.tiles import _transformer
|
||
monkeypatch.setattr(index, "PANEL_VIZ", None)
|
||
_dalle(tmp_path, 1054, 6882)
|
||
lon, lat = _transformer("EPSG:2154", "EPSG:4326").transform(1055000.0, 6881500.0)
|
||
pdf, _ = build_pdf(tmp_path, lat, lon, "A4", "paysage", 2000, compress=False)
|
||
assert pdf.startswith(b"%PDF") and b"1055_6882" in pdf
|
||
assert b"Donn" in pdf and b"manquante" in pdf
|
||
|
||
|
||
def test_build_pdf_no_data_raises(tmp_path):
|
||
import pytest
|
||
from lidar_pipeline.export_pdf import NoDataError, build_pdf
|
||
with pytest.raises(NoDataError):
|
||
build_pdf(tmp_path, 46.5, 2.5, "A4", "paysage", 2000)
|
||
|
||
|
||
def test_fmt_int():
|
||
from lidar_pipeline.export_pdf import _fmt_int
|
||
assert _fmt_int(2000) == "2 000" and _fmt_int(500) == "500"
|
||
|
||
|
||
def test_north_arrow_angle_matches_pyproj_reference():
|
||
"""L'angle de rotation reportlab doit amener la flèche (dessinée vers le
|
||
nord du quadrillage) sur le nord géographique, dans le bon sens, à l'est
|
||
et à l'ouest du méridien central (3°E)."""
|
||
import math
|
||
from lidar_pipeline.export_pdf import _north_arrow_angle, _to_wgs84
|
||
from lidar_pipeline.tiles import _transformer, wgs84_to_l93
|
||
|
||
to_l93 = _transformer("EPSG:4326", "EPSG:2154")
|
||
|
||
def _reference_angle(cx, cy):
|
||
lat0, lon0 = _to_wgs84(cx, cy)
|
||
x0, y0 = to_l93.transform(lon0, lat0)
|
||
x1, y1 = to_l93.transform(lon0, lat0 + 0.001)
|
||
# azimut (sens horaire depuis le nord du quadrillage) du nord géographique
|
||
psi = math.degrees(math.atan2(x1 - x0, y1 - y0))
|
||
return -psi # rotation reportlab (antihoraire) amenant "haut" sur ce nord
|
||
|
||
east = wgs84_to_l93(6.0, 46.0) # à l'est du méridien central
|
||
west = wgs84_to_l93(0.0, 46.0) # à l'ouest du méridien central
|
||
for cx, cy in (east, west):
|
||
assert abs(_north_arrow_angle(cx, cy) - _reference_angle(cx, cy)) < 0.05
|
||
assert _north_arrow_angle(*east) > 0
|
||
assert _north_arrow_angle(*west) < 0
|
||
|
||
|
||
def test_wrap_text_lines_fit_width():
|
||
from reportlab.pdfgen import canvas as rl_canvas
|
||
from reportlab.lib.units import mm
|
||
from io import BytesIO
|
||
from lidar_pipeline.export_pdf import _wrap_text
|
||
c = rl_canvas.Canvas(BytesIO())
|
||
text = "Donnée manquante (sans relief) : " + ", ".join(
|
||
f"{1050 + i}_6882" for i in range(12))
|
||
max_width = 60 * mm
|
||
lines = _wrap_text(c, text, "Helvetica", 5.8, max_width, sep=", ")
|
||
assert len(lines) > 1
|
||
for line in lines:
|
||
assert c.stringWidth(line, "Helvetica", 5.8) <= max_width + 1e-6
|
||
|
||
|
||
def test_fit_title_shrinks_then_elides():
|
||
from reportlab.pdfgen import canvas as rl_canvas
|
||
from reportlab.lib.units import mm
|
||
from io import BytesIO
|
||
from lidar_pipeline.export_pdf import _fit_title
|
||
c = rl_canvas.Canvas(BytesIO())
|
||
long_title = "Relief orienté - " + ", ".join(f"{1050 + i:04d}_6882" for i in range(6))
|
||
text, size = _fit_title(c, long_title, "Helvetica-Bold", 10, 60 * mm, min_size=7.0)
|
||
assert size >= 7.0
|
||
assert c.stringWidth(text, "Helvetica-Bold", size) <= 60 * mm + 1e-6
|