"""Tests for IGN LiDAR HD tile downloads (fetch_ign).""" def test_parse_tile_specs(): """Accepts 'col,row', 'col:row' and ignores whitespace.""" from lidar_pipeline.fetch_ign import parse_tile_specs assert parse_tile_specs(["1055,6882"]) == [(1055, 6882)] assert parse_tile_specs(["1055:6883", " 651 , 6630 "]) == [(1055, 6883), (651, 6630)] def test_parse_tile_specs_rejects_invalid(): """Malformed specifications raise an explicit error.""" import pytest from lidar_pipeline.fetch_ign import parse_tile_specs with pytest.raises(ValueError): parse_tile_specs(["1055"]) with pytest.raises(ValueError): parse_tile_specs(["abc,def"]) with pytest.raises(ValueError): parse_tile_specs(["1055,6882,9999"]) def test_tile_filename_pads_coordinates(): """The tile file name uses 4-digit coordinates.""" from lidar_pipeline.fetch_ign import tile_filename assert tile_filename(1055, 6882) == "LHD_FXX_1055_6882_PTS_LAMB93_IGN69.copc.laz" assert tile_filename(651, 6630) == "LHD_FXX_0651_6630_PTS_LAMB93_IGN69.copc.laz" def test_match_feature_uses_coordonnees_nw(): """Matching uses lidarhd:coordonnees_NW (0816-6847 format).""" from lidar_pipeline.fetch_ign import match_feature features = [ {"id": "LHD_FXX_1054_6882_PTS_LAMB93_IGN69_NE", "properties": {"lidarhd:coordonnees_NW": "1054-6882"}, "assets": {"data": {"href": "https://example.org/a.copc.laz"}}}, {"id": "LHD_FXX_1055_6882_PTS_LAMB93_IGN69_NE", "properties": {"lidarhd:coordonnees_NW": "1055-6882"}, "assets": {"data": {"href": "https://example.org/b.copc.laz"}}}, ] assert match_feature(features, 1055, 6882) is features[1] assert match_feature(features, 9999, 9999) is None def test_find_tile_url_matches_and_returns_href(monkeypatch): """find_tile_url queries the STAC API and returns the data asset href.""" from lidar_pipeline import fetch_ign class FakeResponse: def __init__(self, payload): self._payload = payload.encode("utf-8") def read(self): return self._payload def __enter__(self): return self def __exit__(self, *args): return False payload = ('{"features": [{"properties": {"lidarhd:coordonnees_NW": "1055-6882"},' '"assets": {"data": {"href": "https://data.geopf.fr/x.copc.laz"}}}]}') captured = {} def fake_urlopen(req, timeout=None): captured["url"] = req.full_url return FakeResponse(payload) monkeypatch.setattr(fetch_ign.urllib.request, "urlopen", fake_urlopen) url = fetch_ign.find_tile_url(1055, 6882) assert url == "https://data.geopf.fr/x.copc.laz" assert "api.stac.teledetection.fr" in captured["url"] assert "bbox=" in captured["url"] def test_fetch_tiles_skips_existing_and_generated(tmp_path, monkeypatch): """No download if the LAZ exists or if visualisations exist.""" from lidar_pipeline import fetch_ign input_dir = tmp_path / "input" input_dir.mkdir() output_dir = tmp_path / "output" vis_dir = output_dir / "visualisations" / "LHD_FXX_1054_6882_PTS_LAMB93_IGN69" vis_dir.mkdir(parents=True) # 1054,6882: visualisations already generated # 1055,6882: LAZ already present existing = input_dir / fetch_ign.tile_filename(1055, 6882) existing.write_bytes(b"naze") def fail_download(*args, **kwargs): raise AssertionError("must not be called") monkeypatch.setattr(fetch_ign, "find_tile_url", fail_download) result = fetch_ign.fetch_tiles(input_dir, [(1054, 6882), (1055, 6882)], output_dir=output_dir) assert result == [] def _make_viz_dirs(output_dir, col, row, viz_keys, resolutions=(0.5, 0.2)): """Create a tile's visualisation directories (one per resolution). The resolution suffix is only on the DIRECTORY: files keep the bare basename (see _expected_output_path in the pipeline). """ base = f"LHD_FXX_{col:04d}_{row:04d}_PTS_LAMB93_IGN69" for res in resolutions: suffix = "" if res == 0.5 else "_r" + str(res).replace('.', 'p') vis = output_dir / "visualisations" / (base + suffix) vis.mkdir(parents=True, exist_ok=True) for key in viz_keys: (vis / f"{base}_{key}.avif").write_bytes(b"x") def test_fetch_tiles_downloads_incomplete_tile(tmp_path, monkeypatch): """An incomplete tile (missing visualisation) is downloaded, a complete one is not.""" from pathlib import Path from lidar_pipeline import fetch_ign input_dir = tmp_path / "input" input_dir.mkdir() output_dir = tmp_path / "output" _make_viz_dirs(output_dir, 1054, 6882, ["aspect"]) # slope missing _make_viz_dirs(output_dir, 1055, 6882, ["aspect", "slope"]) # complete calls = [] def fake_find_tile_url(col, row, timeout=20): calls.append((col, row)) return "https://example.org/tile.copc.laz" def fake_download(url, dest, timeout=120, chunk=1024 * 1024): Path(dest).write_bytes(b"laz") return 3 monkeypatch.setattr(fetch_ign, "find_tile_url", fake_find_tile_url) monkeypatch.setattr(fetch_ign, "download_file", fake_download) result = fetch_ign.fetch_tiles(input_dir, [(1054, 6882), (1055, 6882)], output_dir=output_dir, only_viz=["aspect", "slope"], resolutions=(0.5, 0.2)) # Only the incomplete tile is downloaded again assert calls == [(1054, 6882)] assert [p.name for p in result] == [fetch_ign.tile_filename(1054, 6882)] def test_fetch_tiles_promotes_edge_neighbor_copy(tmp_path, monkeypatch): """A tile already downloaded as a neighbour is moved to input/ without network access.""" from lidar_pipeline import fetch_ign input_dir = tmp_path / "input" input_dir.mkdir() edge_dir = input_dir / "edge_neighbors" edge_dir.mkdir() name = fetch_ign.tile_filename(1055, 6882) neighbor_copy = edge_dir / name neighbor_copy.write_bytes(b"laz-voisine") def fail(*args, **kwargs): raise AssertionError("must not be called (no network expected)") monkeypatch.setattr(fetch_ign, "find_tile_url", fail) monkeypatch.setattr(fetch_ign, "download_file", fail) result = fetch_ign.fetch_tiles(input_dir, [(1055, 6882)]) assert not neighbor_copy.exists() assert (input_dir / name).read_bytes() == b"laz-voisine" assert result == [] # promotion = "skip", like the already-present case def test_fetch_tiles_does_not_promote_when_called_on_edge_dir(tmp_path, monkeypatch): """Call with input_dir=edge_dir (_fetch_edge_neighbors case): never promotes.""" from lidar_pipeline import fetch_ign edge_dir = tmp_path / "input" / "edge_neighbors" edge_dir.mkdir(parents=True) inner_edge = edge_dir / "edge_neighbors" name = fetch_ign.tile_filename(1055, 6882) # Even if an edge_neighbors/edge_neighbors/ directory existed by # accident, it must never be moved to edge_dir/. inner_edge.mkdir() (inner_edge / name).write_bytes(b"must-not-move") calls = [] def fake_find_tile_url(col, row, timeout=20): calls.append((col, row)) return "https://example.org/tile.copc.laz" def fake_download(url, dest, timeout=120, chunk=1024 * 1024): from pathlib import Path Path(dest).write_bytes(b"laz") return 3 monkeypatch.setattr(fetch_ign, "find_tile_url", fake_find_tile_url) monkeypatch.setattr(fetch_ign, "download_file", fake_download) result = fetch_ign.fetch_tiles(edge_dir, [(1055, 6882)]) assert calls == [(1055, 6882)] # downloaded normally, not promoted assert (inner_edge / name).read_bytes() == b"must-not-move" assert (edge_dir / name).read_bytes() == b"laz" assert len(result) == 1 def test_fetch_tiles_force_downloads_complete(tmp_path, monkeypatch): """force=True downloads even a complete tile (regeneration with --force).""" from pathlib import Path from lidar_pipeline import fetch_ign input_dir = tmp_path / "input" input_dir.mkdir() output_dir = tmp_path / "output" _make_viz_dirs(output_dir, 1054, 6882, ["aspect", "slope"]) calls = [] def fake_find_tile_url(col, row, timeout=20): calls.append((col, row)) return "https://example.org/tile.copc.laz" def fake_download(url, dest, timeout=120, chunk=1024 * 1024): Path(dest).write_bytes(b"laz") return 3 monkeypatch.setattr(fetch_ign, "find_tile_url", fake_find_tile_url) monkeypatch.setattr(fetch_ign, "download_file", fake_download) result = fetch_ign.fetch_tiles(input_dir, [(1054, 6882)], output_dir=output_dir, only_viz=["aspect", "slope"], resolutions=(0.5, 0.2), force=True) assert calls == [(1054, 6882)] assert len(result) == 1