Tuiles visibles en direct après régénération et déploiement webapp distante
- URLs images versionnées (?v=mtime) : une tuile recalculée change d'URL et force le rechargement navigateur (cache heuristique contourné), y compris en plein run via /api/tiles ; veille permanente 15 s sur la carte - simulation locale du mode deux machines (docker-compose.local-2m.yml) : worker GPU :8974 + webapp légère :8973 au cache séparé output-webapp/ - override webapp pour le Pi 5 (192.168.3.3) : volume /srv/lidar/output, rsync vers le worker, labels Traefik (proxy/websecure/myresolver) - purge 0,5 m : worker/process et politique générale passés à 0,2 m seul - intègre le travail parallèle non commité : export mosaïque multi-dalles (export.py + /api/export), sous-tuilage intégral des couches, légendes VIZ_LEGENDS, docs et tests associés (213 tests verts)
This commit is contained in:
@ -237,18 +237,17 @@ def test_build_index_generates_html(tmp_path):
|
||||
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 limité aux couches proposées dans le
|
||||
# panneau de visualisation (aspect + slope + openness + ondelette,
|
||||
# valeurs = noms d'étapes --only). La sélection est envoyée à /api/preview
|
||||
# (détection des tuiles incomplètes) comme à /api/generate.
|
||||
# Les couches hors panneau (svf, hillshade) n'y figurent pas.
|
||||
# Barre de génération : sélecteur proposant TOUTES les couches du registre
|
||||
# (valeurs = noms d'étapes --only), y compris celles absentes du panneau
|
||||
# (svf, hillshade) pour pouvoir les générer. La sélection est envoyée à
|
||||
# /api/preview (détection des tuiles incomplètes) comme à /api/generate.
|
||||
assert 'id="genViz"' in content
|
||||
assert 'value="wavelet"' in content
|
||||
assert 'value="aniso_open"' not in content
|
||||
assert 'value="pos_open"' in content
|
||||
assert 'value="slope"' in content
|
||||
assert 'value="svf"' not in content
|
||||
assert 'value="hillshade"' not in content
|
||||
assert 'value="svf"' in content
|
||||
assert 'value="hillshade"' 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) :
|
||||
@ -312,29 +311,67 @@ def test_mid_thumbnails_generated(tmp_path):
|
||||
data = json.loads((output_dir / "index_tiles.json").read_text(encoding='utf-8'))
|
||||
viz = data['tiles'][0]['viz']['hillshade_multi']
|
||||
assert 'mid' in viz
|
||||
assert (output_dir / viz['mid']).exists()
|
||||
assert viz['mid'].endswith("_mid.jpg")
|
||||
# L'URL porte un suffixe ?v= (invalidation cache) : chemin sans lui
|
||||
assert (output_dir / viz['mid'].split('?')[0]).exists()
|
||||
assert viz['mid'].split('?')[0].endswith("_mid.jpg")
|
||||
|
||||
|
||||
def test_subtiled_viz_covers_generation_choices():
|
||||
"""Les viz générables depuis la carte sont sous-tuilées (affichage fluide)."""
|
||||
from lidar_pipeline.index import _CARTO_SUBTILED_VIZ
|
||||
for key in ('aspect', 'hillshade_multi', 'slope', 'positive_openness',
|
||||
'wavelet'):
|
||||
assert key in _CARTO_SUBTILED_VIZ
|
||||
def test_subtiles_cover_all_layers(tmp_path):
|
||||
"""Toutes les couches d'une dalle 0,2 m sont sous-tuilées (plus de liste courte)."""
|
||||
from lidar_pipeline.index import _CARTO_SUBTILED_VIZ, build_index
|
||||
|
||||
assert _CARTO_SUBTILED_VIZ == ()
|
||||
|
||||
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', 'svf', 'topo'), res_suffix='_r0p2')
|
||||
|
||||
assert build_index(output_dir) is not None
|
||||
sub_dir = output_dir / "index_subtiles"
|
||||
for viz in ('hillshade_multi', 'svf', 'topo'):
|
||||
avif = sub_dir / f"LHD_FXX_1000_6881_PTS_LAMB93_IGN69_r0p2_{viz}_0_0.avif"
|
||||
assert avif.exists(), viz
|
||||
# L'index référence les sous-tuiles pour chaque couche, pas le repli dalle
|
||||
data = json.loads((output_dir / "index_tiles.json").read_text(encoding='utf-8'))
|
||||
subs = [t for t in data['tiles'] if t.get('sub_k') == 2]
|
||||
assert len(subs) == 4
|
||||
for t in subs:
|
||||
for viz in ('hillshade_multi', 'svf', 'topo'):
|
||||
assert t['viz'][viz]['full'].startswith("index_subtiles/"), viz
|
||||
|
||||
|
||||
def test_panel_restricted_to_requested_layers():
|
||||
"""Le panneau et le sélecteur ne proposent que la base aspect + les 3 couches demandées."""
|
||||
from lidar_pipeline.index import PANEL_VIZ, KEYWORD_TO_STEP
|
||||
def test_panel_offers_all_layers():
|
||||
"""Le panneau n'est plus restreint : toutes les couches connues sont proposées."""
|
||||
from lidar_pipeline.index import PANEL_VIZ, VIZ_LABELS, KEYWORD_TO_STEP
|
||||
from lidar_pipeline.pipeline import VIZ_STEPS
|
||||
assert PANEL_VIZ == ('aspect', 'slope', 'positive_openness', 'wavelet')
|
||||
# Chaque couche du panneau correspond à une étape --only valide du pipeline
|
||||
assert PANEL_VIZ is None
|
||||
# Chaque couche connue du registre correspond à une étape --only valide
|
||||
steps = {name for name, _ in VIZ_STEPS}
|
||||
for key in PANEL_VIZ:
|
||||
assert set(VIZ_LABELS)
|
||||
for key in VIZ_LABELS:
|
||||
assert KEYWORD_TO_STEP.get(key, key) in steps
|
||||
|
||||
|
||||
def test_panel_selection_lists_full_registry():
|
||||
"""Le panneau propose le registre complet, même sans données sur disque.
|
||||
|
||||
Une installation neuve (ou une couche jamais générée) doit quand même
|
||||
afficher toutes les couches connues ; les couches historiques présentes
|
||||
sur disque restent proposées (libellé = clé brute).
|
||||
"""
|
||||
from lidar_pipeline.index import _panel_selection, VIZ_LABELS
|
||||
ordered, default = _panel_selection({'aspect'})
|
||||
assert set(VIZ_LABELS) <= set(ordered)
|
||||
assert default == 'aspect'
|
||||
ordered, default = _panel_selection(set())
|
||||
assert set(VIZ_LABELS) <= set(ordered)
|
||||
assert default # toujours une couche par défaut
|
||||
ordered, default = _panel_selection({'aniso_open'})
|
||||
assert 'aniso_open' 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.
|
||||
|
||||
@ -365,8 +402,8 @@ def test_build_index_merges_cross_resolution_viz(tmp_path):
|
||||
assert (output_dir / "index_thumbs" / f"{base}_r0p2_slope.jpg").exists()
|
||||
for t in tiles:
|
||||
for v in t['viz'].values():
|
||||
assert (output_dir / v['thumb']).exists(), v['thumb']
|
||||
assert (output_dir / v['full']).exists(), v['full']
|
||||
assert (output_dir / v['thumb'].split('?')[0]).exists(), v['thumb']
|
||||
assert (output_dir / v['full'].split('?')[0]).exists(), v['full']
|
||||
|
||||
|
||||
def test_build_index_regenerates_stale_thumbnails(tmp_path):
|
||||
@ -433,6 +470,72 @@ def test_build_subtiles_regenerates_stale_crops(tmp_path):
|
||||
assert aspect_avif.stat().st_mtime == m_aspect_1 # aspect intact
|
||||
|
||||
|
||||
def test_urls_versioned_for_cache_busting(tmp_path):
|
||||
"""Les URLs images changent quand une tuile est régénérée.
|
||||
|
||||
Les images sont servies par des montages statiques sans Cache-Control :
|
||||
sans version d'URL, le navigateur garde son ancien rendu en cache même
|
||||
après recalcul. Chaque URL (vignette, intermédiaire, pleine) porte la
|
||||
mtime de sa source en ?v= — un recalcul change l'URL et force le
|
||||
rechargement, y compris en direct pendant un run (--incremental-index).
|
||||
"""
|
||||
import os
|
||||
import re
|
||||
from lidar_pipeline.index import build_index
|
||||
|
||||
output_dir = tmp_path / "output"
|
||||
vis_dir = output_dir / "visualisations"
|
||||
vis_dir.mkdir(parents=True)
|
||||
tile_dir = _make_fake_viz_dir(vis_dir, "a", 1000, 6881, ('hillshade_multi',))
|
||||
|
||||
assert build_index(output_dir) is not None
|
||||
|
||||
def read_urls():
|
||||
data = json.loads((output_dir / "index_tiles.json").read_text(encoding='utf-8'))
|
||||
viz = data['tiles'][0]['viz']['hillshade_multi']
|
||||
return viz['thumb'], viz['mid'], viz['full']
|
||||
|
||||
thumb1, mid1, full1 = read_urls()
|
||||
for url in (thumb1, mid1, full1):
|
||||
assert re.search(r"\?v=\d+$", url), url # versionnée (mtime ms)
|
||||
assert (output_dir / url.split('?')[0]).exists(), url
|
||||
|
||||
# Même source, rebuild → URLs identiques (cache navigateur conservé)
|
||||
assert build_index(output_dir) is not None
|
||||
assert read_urls() == (thumb1, mid1, full1)
|
||||
|
||||
# Régénération : mtime de la source plus récente → nouvelles URLs
|
||||
src = tile_dir / "LHD_FXX_1000_6881_PTS_LAMB93_IGN69_hillshade_multi.webp"
|
||||
m = src.stat().st_mtime
|
||||
os.utime(src, (m + 10, m + 10))
|
||||
assert build_index(output_dir) is not None
|
||||
thumb2, mid2, full2 = read_urls()
|
||||
assert (thumb2, mid2, full2) != (thumb1, mid1, full1)
|
||||
assert thumb2.split('?')[0] == thumb1.split('?')[0] # même fichier, version neuve
|
||||
|
||||
|
||||
def test_subtile_urls_versioned(tmp_path):
|
||||
"""Les URLs des sous-tuiles 0,2 m sont aussi versionnées (?v=)."""
|
||||
import re
|
||||
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',),
|
||||
res_suffix='_r0p2')
|
||||
|
||||
assert build_index(output_dir) is not None
|
||||
data = json.loads((output_dir / "index_tiles.json").read_text(encoding='utf-8'))
|
||||
subs = [t for t in data['tiles'] if t.get('sub_k') == 2]
|
||||
assert subs
|
||||
for t in subs:
|
||||
for v in t['viz'].values():
|
||||
for url in (v['thumb'], v['mid'], v['full']):
|
||||
assert re.search(r"\?v=\d+$", url), url
|
||||
assert (output_dir / url.split('?')[0]).exists(), url
|
||||
|
||||
|
||||
def test_build_index_empty_returns_none(tmp_path):
|
||||
"""Aucune tuile → build_index retourne None sans crash."""
|
||||
from lidar_pipeline.index import build_index
|
||||
|
||||
Reference in New Issue
Block a user