Porter la génération de tuiles dans lidar-maps et retirer l'ancienne webapp
La carte à tuiles XYZ devient la seule interface : l'API de génération (preview, generate, status, stop, file, cell), le job unique + file persistante, la délégation LIDAR_GENERATION_URL et les garde-fous (token, LIDAR_REGEN_CIDR) passent de webapp.py à mapserve.py ; l'interface gagne les boutons + Zone / ⤒ Compléter / régénération à la dalle, les options de run et la progression cadre par cadre. mapserve sert aussi l'inventaire /api/tiles et les dalles en statique versionné : le worker image complète remplace la webapp sur le 8973 du générateur, le Pi n'exécute plus que lidar-maps. index.py se réduit aux registres partagés + vignettes/sous-tuiles/inventaire (l'UI HTML/JS et les mosaïques d'overview partent avec export.py et les compose/scripts de la webapp).
This commit is contained in:
@ -207,8 +207,8 @@ def test_collect_tile_metadata_fallback_date(tmp_path):
|
||||
assert meta['generated'] is not None
|
||||
|
||||
|
||||
def test_build_index_generates_html(tmp_path):
|
||||
"""build_index génère index.html et les vignettes."""
|
||||
def test_build_index_generates_inventory(tmp_path):
|
||||
"""build_index génère l'inventaire index_tiles.json et les vignettes."""
|
||||
from lidar_pipeline.index import build_index
|
||||
|
||||
output_dir = tmp_path / "output"
|
||||
@ -221,105 +221,20 @@ def test_build_index_generates_html(tmp_path):
|
||||
|
||||
result = build_index(output_dir)
|
||||
assert result is not None
|
||||
html_path = Path(result)
|
||||
assert html_path.exists()
|
||||
assert html_path.name == "index.html"
|
||||
inv_path = Path(result)
|
||||
assert inv_path.exists()
|
||||
assert inv_path.name == "index_tiles.json"
|
||||
# L'ancienne interface HTML a été retirée : plus de coquille ni d'assets
|
||||
assert not (output_dir / "index.html").exists()
|
||||
assert not (output_dir / "assets").exists()
|
||||
|
||||
content = html_path.read_text(encoding='utf-8')
|
||||
# Vérifie la présence des éléments clés
|
||||
assert "Carte LiDAR" in content
|
||||
assert "LHD_FXX_1000_6881" in content
|
||||
assert "LHD_FXX_1001_6881" in content
|
||||
# Vérifie que le JSON intégré est valide
|
||||
assert "const TILES" in content
|
||||
# Vérifie les assets de l'interface (CSS/JS séparés)
|
||||
assets = output_dir / "assets"
|
||||
assert (assets / "app.css").read_text(encoding='utf-8').startswith('/*')
|
||||
app_js = (assets / "app.js").read_text(encoding='utf-8')
|
||||
assert "Couches" in app_js or "layers" in app_js
|
||||
# Barre de génération : sélecteur restreint au panneau (PANEL_VIZ =
|
||||
# DEFAULT_LAYERS ; valeurs = noms d'étapes --only). Les couches hors
|
||||
# panneau (svf, hillshade, wavelet, roughness) ne sont plus proposées.
|
||||
assert 'id="genViz"' in content
|
||||
assert 'value="slope"' in content
|
||||
assert 'value="pos_open"' in content
|
||||
assert 'value="aspect"' in content
|
||||
assert 'value="roughness"' not in content
|
||||
assert 'value="wavelet"' not in content
|
||||
assert 'value="svf"' not in content
|
||||
assert 'value="hillshade"' not in content
|
||||
assert 'value="aniso_open"' not in content
|
||||
assert "genViz" in app_js and "viz: selectedGenViz()" in app_js
|
||||
assert app_js.count("selectedGenViz()") >= 3 # définition + preview + generate
|
||||
# Adoption du dernier run au chargement (redémarrage du serveur) :
|
||||
# un run terminé réapparaît dans la file avec ses tuiles, sans doublon.
|
||||
assert "Dernière génération" in app_js
|
||||
assert "t.started === s.started" in app_js
|
||||
# Vidage de la file : les runs retirés sont mémorisés (par horodatage
|
||||
# serveur) pour que le rechargement ne réadopte pas la « dernière
|
||||
# génération » du serveur et ne refasse pas réapparaître les tâches finies.
|
||||
assert "dismissedRuns" in app_js
|
||||
assert "saveDismissed()" in app_js
|
||||
# Bouton « Compléter » : visualisations manquantes des dalles présentes
|
||||
# dans input/ (scope all_missing envoyé au preview et au generate).
|
||||
# « + Zone » et « Compléter » ne vivent que dans l'onglet Génération,
|
||||
# jamais dans la barre d'outils flottante de la carte.
|
||||
assert 'id="btnComplete"' in content
|
||||
map_tools = content.split('id="mapTools"', 1)[1].split('</nav>', 1)[0]
|
||||
assert 'btnGenZone' not in map_tools and 'btnComplete' not in map_tools
|
||||
tab_gen = content.split('id="tab-gen"', 1)[1].split('id="tab-tile"', 1)[0]
|
||||
assert 'id="btnGenZone"' in tab_gen and 'id="btnComplete"' in tab_gen
|
||||
assert "all_missing" in app_js and app_js.count("all_missing") >= 2
|
||||
# Dessin de zone au 1er plan : pane dédié au-dessus des tuiles (marqueurs
|
||||
# Leaflet, pane 600) pour rectangle et cellules fantômes.
|
||||
assert "createPane('genDraw')" in app_js
|
||||
assert app_js.count("pane: 'genDraw'") >= 3 # rectangle + 2 jeux de cellules
|
||||
assert 'assets/app.css' in content
|
||||
# Bouton d'actualisation des couches (détection des nouveautés sur disque) :
|
||||
# le ↻ resynchronise puis reconstruit la carte (mode webapp distante).
|
||||
assert 'id="layerRefresh"' in content
|
||||
assert "/api/layers" in app_js and "/api/sync" in app_js
|
||||
# Préconfigurations de couches (onglet Couches) : le sélecteur applique
|
||||
# des presets intégrés (couches visibles, niveaux de transparence —
|
||||
# visualisations + fond de carte OSM —, ordre de pile) et 💾/✕ gèrent
|
||||
# les presets personnels persistés en localStorage.
|
||||
assert 'id="presetSelect"' in content
|
||||
assert 'id="presetSave"' in content
|
||||
assert "BUILTIN_PRESETS" in app_js and "applyPreset" in app_js
|
||||
assert "presetDetail" in app_js and "lidarPresets_v1" in app_js
|
||||
assert "osm: { on: true, opacity: 0.3, dark: true }" in app_js # preset porte le fond OSM
|
||||
assert "p.osm" in app_js # l'application y inclut le fond
|
||||
# Partage de la vue : bouton « Partager » + lien contenant vue, couches
|
||||
# et transparences (hash #zoom,lat,lng&L=...&O=...) — le destinataire
|
||||
# reçoit l'état exact, indépendamment de son localStorage local.
|
||||
assert 'id="btnShare"' in content
|
||||
assert "buildShareHash" in app_js and "parseSharedHash" in app_js
|
||||
assert 'assets/app.js' in content
|
||||
# Autonomie hors ligne : Leaflet vendorisé localement, aucun CDN
|
||||
assert 'assets/vendor/leaflet/leaflet.js' in content
|
||||
assert 'assets/vendor/leaflet/leaflet.css' in content
|
||||
assert 'unpkg.com' not in content
|
||||
assert (assets / 'vendor' / 'leaflet' / 'leaflet.js').is_file()
|
||||
# Fin d'un run de génération : synchronisation puis rechargement (le
|
||||
# rechargement direct reste en secours si /api/sync est absent).
|
||||
assert "syncThenReload" in app_js
|
||||
# Tuiles en direct : la carte sonde /api/tiles pendant un run et fusionne
|
||||
# les tuiles terminées sans recharger (mode incrémental du pipeline).
|
||||
assert "/api/tiles" in app_js and "pollLiveTiles" in app_js
|
||||
assert "registerTile" in app_js and "mergeTilesData" in app_js
|
||||
# Garde anti-page périmée : un onglet ouvert depuis plus de 15 min
|
||||
# (JS ancien après un déploiement) se recharge au retour au premier
|
||||
# plan — l'ancienne interface n'est jamais affichée.
|
||||
assert "reloadStalePage" in app_js and "visibilitychange" in app_js
|
||||
# Export des données de carte (servi par /api/tiles)
|
||||
data = json.loads((output_dir / "index_tiles.json").read_text(encoding='utf-8'))
|
||||
# Inventaire servi par /api/tiles aux machines légères : tuiles avec coins,
|
||||
# libellés de couches, compteur.
|
||||
data = json.loads(inv_path.read_text(encoding='utf-8'))
|
||||
assert data['tiles'] and data['tiles'][0]['corners']
|
||||
assert 'aspect' in data['viz_meta']
|
||||
assert data['viz_meta']['aspect']['label']
|
||||
assert data['stats']['n_tiles'] == len(data['tiles'])
|
||||
# Version de l'interface exportée : la webapp légère détecte une image
|
||||
# périmée (assets bâchés au build, non reconstruite après évolution)
|
||||
from lidar_pipeline.index import ui_version
|
||||
assert data['stats']['ui_version'] == ui_version()
|
||||
# Vérifie les vignettes générées
|
||||
thumb_dir = output_dir / "index_thumbs"
|
||||
assert thumb_dir.is_dir()
|
||||
@ -327,27 +242,6 @@ def test_build_index_generates_html(tmp_path):
|
||||
assert len(thumbs) >= 2 # au moins hillshade pour chaque tuile
|
||||
|
||||
|
||||
def test_write_ui_assets(tmp_path):
|
||||
"""_write_ui_assets écrit app.js/app.css dans le dossier cible (bâchés image).
|
||||
|
||||
app.js a __UI_VERSION__ substitué par la version réelle (ui_version) :
|
||||
le JS compare cette version à celle du serveur de données et signale
|
||||
une webapp périmée (bandeau, cf. checkUiVersion).
|
||||
"""
|
||||
from lidar_pipeline.index import _write_ui_assets, _APP_JS, ui_version
|
||||
|
||||
dst = tmp_path / "ui"
|
||||
_write_ui_assets(dst)
|
||||
v = ui_version()
|
||||
assert v == ui_version() and len(v) == 10
|
||||
js = (dst / "app.js").read_text(encoding="utf-8")
|
||||
assert js == _APP_JS.replace("__UI_VERSION__", v)
|
||||
assert v in js and "__UI_VERSION__" not in js
|
||||
# Le placeholder et le check sont embarqués dans la source
|
||||
assert '"__UI_VERSION__"' in _APP_JS and "checkUiVersion" in _APP_JS
|
||||
assert (dst / "app.css").read_text(encoding="utf-8").startswith("/*")
|
||||
|
||||
|
||||
def test_mid_thumbnails_generated(tmp_path):
|
||||
"""Vignette intermédiaire 640 px générée et référencée dans l'index."""
|
||||
from lidar_pipeline.index import build_index
|
||||
@ -410,25 +304,6 @@ def test_panel_restricted_to_kept_layers():
|
||||
assert KEYWORD_TO_STEP.get(key, key) in steps
|
||||
|
||||
|
||||
def test_panel_selection_lists_kept_layers():
|
||||
"""Le panneau propose uniquement les couches conservées (PANEL_VIZ).
|
||||
|
||||
Même sans données sur disque, le panneau affiche les couches conservées ;
|
||||
une couche absente du panneau n'est plus proposée, même présente sur
|
||||
disque.
|
||||
"""
|
||||
from lidar_pipeline.index import _panel_selection, PANEL_VIZ
|
||||
ordered, default = _panel_selection({'aspect'})
|
||||
assert set(ordered) == set(PANEL_VIZ)
|
||||
assert default == 'aspect'
|
||||
ordered, default = _panel_selection(set())
|
||||
assert set(ordered) == set(PANEL_VIZ)
|
||||
assert default # toujours une couche par défaut
|
||||
# Une couche présente sur disque mais hors panneau est exclue
|
||||
ordered, default = _panel_selection({'aniso_open'})
|
||||
assert 'aniso_open' not in ordered
|
||||
|
||||
|
||||
def test_build_index_merges_cross_resolution_viz(tmp_path):
|
||||
"""Une couche produite seulement à 0,5 m reste visible sur la dalle 0,2 m.
|
||||
|
||||
@ -602,35 +477,6 @@ def test_build_index_empty_returns_none(tmp_path):
|
||||
assert result is None
|
||||
|
||||
|
||||
def test_build_index_embeds_valid_json(tmp_path):
|
||||
"""Le JSON embarqué dans le HTML est valide et contient les tuiles."""
|
||||
from lidar_pipeline.index import build_index
|
||||
|
||||
output_dir = tmp_path / "output"
|
||||
vis_dir = output_dir / "visualisations"
|
||||
vis_dir.mkdir(parents=True)
|
||||
_make_fake_viz_dir(vis_dir, "a", 1000, 6881, ('hillshade_multi',))
|
||||
|
||||
build_index(output_dir)
|
||||
content = (output_dir / "index.html").read_text(encoding='utf-8')
|
||||
# Extrait le JSON entre "const TILES = " et la fin de déclaration
|
||||
start = content.index("const TILES = ") + len("const TILES = ")
|
||||
# Trouve le ; de fin de déclaration
|
||||
depth = 0
|
||||
end = start
|
||||
for i, ch in enumerate(content[start:], start):
|
||||
if ch in ('{', '['):
|
||||
depth += 1
|
||||
elif ch in ('}', ']'):
|
||||
depth -= 1
|
||||
if depth == 0:
|
||||
end = i + 1
|
||||
break
|
||||
data = json.loads(content[start:end])
|
||||
assert len(data) > 0
|
||||
assert data[0]['col'] == 1000
|
||||
assert data[0]['row'] == 6881
|
||||
|
||||
|
||||
def test_attach_gps_bounds():
|
||||
"""attach_gps_bounds ajoute des bounds GPS ordonnées (France métropolitaine)."""
|
||||
@ -718,74 +564,7 @@ def test_approx_wgs84_to_l93_roundtrip():
|
||||
x2, y2 = _approx_wgs84_to_l93(lon, lat)
|
||||
assert abs(x2 - x) < 1e-6 and abs(y2 - y) < 1e-6
|
||||
|
||||
def test_overview_generated_for_large_grids(tmp_path):
|
||||
"""Grille >= 6 dalles : une mosaïque d'overview par couche + le payload."""
|
||||
from lidar_pipeline.index import build_index
|
||||
|
||||
output_dir = tmp_path / "output"
|
||||
vis_dir = output_dir / "visualisations"
|
||||
vis_dir.mkdir(parents=True)
|
||||
for col in (1000, 1001, 1002):
|
||||
for row in (6881, 6882):
|
||||
_make_fake_viz_dir(vis_dir, "t", col, row, ('hillshade_multi', 'svf'))
|
||||
|
||||
assert build_index(output_dir) is not None
|
||||
data = json.loads((output_dir / "index_tiles.json").read_text(encoding='utf-8'))
|
||||
ov = data['overview']
|
||||
assert 'hillshade_multi' in ov and 'svf' in ov
|
||||
items = ov['hillshade_multi']['items']
|
||||
assert len(items) == 1 # une seule zone géographique
|
||||
meta = items[0]
|
||||
assert (output_dir / meta['url'].split('?')[0]).is_file()
|
||||
assert meta['km_w'] == 3 and meta['km_h'] == 2
|
||||
assert meta['w'] == 3 * meta['px_per_km']
|
||||
assert meta['h'] == 2 * meta['px_per_km']
|
||||
assert len(meta['corners']) == 4
|
||||
# La coquille HTML embarque la constante pour l'interface
|
||||
html = (output_dir / "index.html").read_text(encoding='utf-8')
|
||||
assert "const OVERVIEW" in html
|
||||
assert "index_overview/hillshade_multi_" in html
|
||||
|
||||
|
||||
def test_overview_one_mosaic_per_zone(tmp_path):
|
||||
"""Zones géographiques distantes : une mosaïque PAR ZONE (pas une grille
|
||||
globale étirée sur des centaines de km aux coins introuvables)."""
|
||||
from lidar_pipeline.index import build_index
|
||||
|
||||
output_dir = tmp_path / "output"
|
||||
vis_dir = output_dir / "visualisations"
|
||||
vis_dir.mkdir(parents=True)
|
||||
# Deux clusters de 2×3 dalles, à ~400 km l'un de l'autre
|
||||
for col in (1000, 1001, 1002):
|
||||
for row in (6881, 6882):
|
||||
_make_fake_viz_dir(vis_dir, "a", col, row, ('hillshade_multi',))
|
||||
for col in (1400, 1401, 1402):
|
||||
for row in (6481, 6482):
|
||||
_make_fake_viz_dir(vis_dir, "b", col, row, ('hillshade_multi',))
|
||||
|
||||
assert build_index(output_dir) is not None
|
||||
data = json.loads((output_dir / "index_tiles.json").read_text(encoding='utf-8'))
|
||||
items = data['overview']['hillshade_multi']['items']
|
||||
assert len(items) == 2
|
||||
assert items[0]['km_w'] == 3 and items[0]['km_h'] == 2
|
||||
assert items[1]['km_w'] == 3 and items[1]['km_h'] == 2
|
||||
# Les deux mosaïques sont distinctes (coins différents)
|
||||
assert items[0]['corners'] != items[1]['corners']
|
||||
|
||||
|
||||
def test_overview_skipped_for_small_grids(tmp_path):
|
||||
"""Grille < 6 dalles : pas d'overview, les vignettes suffisent."""
|
||||
from lidar_pipeline.index import build_index
|
||||
|
||||
output_dir = tmp_path / "output"
|
||||
vis_dir = output_dir / "visualisations"
|
||||
vis_dir.mkdir(parents=True)
|
||||
_make_fake_viz_dir(vis_dir, "a", 1000, 6881, ('hillshade_multi',))
|
||||
_make_fake_viz_dir(vis_dir, "b", 1001, 6881, ('hillshade_multi',))
|
||||
|
||||
assert build_index(output_dir) is not None
|
||||
data = json.loads((output_dir / "index_tiles.json").read_text(encoding='utf-8'))
|
||||
assert data['overview'] == {}
|
||||
|
||||
|
||||
def test_subtile_corners_shared_edges_exact():
|
||||
|
||||
Reference in New Issue
Block a user