7df9ebf22c
Sync labels / sync (push) Failing after 1m9s
Publish Container Image / Build and publish container image (push) Has been cancelled
Deploy Website / Deploy to GitHub Pages (push) Has been cancelled
Deploy Website / Build website and demo (push) Has been cancelled
Deploy viewer / Deploy viewer proxy to Cloudflare Workers (push) Has been cancelled
Deploy tiles / Deploy planetary tile proxy to Cloudflare Workers (push) Has been cancelled
Deploy collab / Deploy collaboration relay to Cloudflare Workers (push) Has been cancelled
CI / Dependency audit (push) Has been cancelled
CI / E2E smoke (Playwright) (push) Has been cancelled
CI / Validate CITATION.cff (push) Has been cancelled
CI / Build and test (push) Has been cancelled
543 lines
18 KiB
Python
543 lines
18 KiB
Python
"""Tests for Map helpers that do not require a running widget/server."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import pytest
|
|
|
|
import geolibre.geolibre as gmod
|
|
from geolibre.geolibre import Map
|
|
|
|
|
|
@pytest.fixture
|
|
def m(monkeypatch):
|
|
"""A Map instance with the static server stubbed out (no bundle needed)."""
|
|
monkeypatch.setattr(gmod, "serve_app", lambda *_a, **_k: "http://127.0.0.1:0/")
|
|
monkeypatch.setattr(gmod, "app_port", lambda: 0)
|
|
return Map()
|
|
|
|
|
|
def _last_layer(widget):
|
|
return widget.project["layers"][-1]
|
|
|
|
|
|
def test_remote_mode_explicit():
|
|
assert Map._resolve_remote_mode(True) == "remote"
|
|
assert Map._resolve_remote_mode(False) == ""
|
|
|
|
|
|
def test_remote_mode_auto_local(monkeypatch):
|
|
monkeypatch.delenv("JUPYTERHUB_SERVICE_PREFIX", raising=False)
|
|
assert Map._resolve_remote_mode("auto") == ""
|
|
|
|
|
|
def test_remote_mode_auto_jupyterhub(monkeypatch):
|
|
monkeypatch.setenv("JUPYTERHUB_SERVICE_PREFIX", "/user/alice/")
|
|
assert Map._resolve_remote_mode("auto") == "remote"
|
|
|
|
|
|
def test_remote_mode_invalid():
|
|
with pytest.raises(ValueError):
|
|
Map._resolve_remote_mode("bogus")
|
|
|
|
|
|
def test_remote_mode_colab_forces_direct(monkeypatch):
|
|
# Colab uses its own port proxy (front-end), which needs the localhost
|
|
# server; an explicit server_proxy=True must not switch it to the remote
|
|
# path.
|
|
monkeypatch.setattr(Map, "_running_on_colab", staticmethod(lambda: True))
|
|
assert Map._resolve_remote_mode(True) == ""
|
|
|
|
|
|
def test_remote_mode_non_colab_uses_remote(monkeypatch):
|
|
monkeypatch.setattr(Map, "_running_on_colab", staticmethod(lambda: False))
|
|
assert Map._resolve_remote_mode(True) == "remote"
|
|
|
|
|
|
def test_add_wms_appends_record_and_bumps_seq(m):
|
|
seq = m._seq
|
|
layer_id = m.add_wms("https://e/wms", "a,b")
|
|
layer = _last_layer(m)
|
|
assert layer["id"] == layer_id
|
|
assert layer["type"] == "wms"
|
|
assert m._seq == seq + 1
|
|
|
|
|
|
def test_add_wmts(m):
|
|
m.add_wmts("https://t/{z}/{y}/{x}.png")
|
|
assert _last_layer(m)["type"] == "wmts"
|
|
|
|
|
|
def test_add_raster_is_cog(m):
|
|
m.add_raster("https://e/dem.tif", bands=[1, 2, 3])
|
|
layer = _last_layer(m)
|
|
assert layer["type"] == "cog"
|
|
assert layer["metadata"]["rasterState"]["bands"] == [1, 2, 3]
|
|
|
|
|
|
def test_add_vector_url_uses_control(m):
|
|
m.add_vector("https://e/data.fgb", data_format="flatgeobuf")
|
|
layer = _last_layer(m)
|
|
assert layer["type"] == "geojson"
|
|
assert layer["metadata"]["sourceKind"] == "maplibre-gl-vector"
|
|
|
|
|
|
def test_add_geoparquet_sets_format(m):
|
|
m.add_geoparquet("https://e/d.parquet")
|
|
assert _last_layer(m)["metadata"]["vectorState"]["format"] == "parquet"
|
|
|
|
|
|
def test_add_flatgeobuf_sets_format(m):
|
|
m.add_flatgeobuf("https://e/d.fgb")
|
|
assert _last_layer(m)["metadata"]["vectorState"]["format"] == "flatgeobuf"
|
|
|
|
|
|
def test_add_vector_tiles(m):
|
|
m.add_vector_tiles("https://e/tiles.json", source_layer="x")
|
|
layer = _last_layer(m)
|
|
assert layer["type"] == "vector-tiles"
|
|
assert layer["source"]["sourceLayer"] == "x"
|
|
|
|
|
|
def test_add_pmtiles(m):
|
|
m.add_pmtiles("https://e/x.pmtiles", source_layers=["roads"])
|
|
layer = _last_layer(m)
|
|
assert layer["type"] == "pmtiles"
|
|
assert layer["metadata"]["sourceLayers"] == ["roads"]
|
|
|
|
|
|
def test_add_3d_tiles(m):
|
|
m.add_3d_tiles("https://e/tileset.json", altitude_offset=5)
|
|
layer = _last_layer(m)
|
|
assert layer["type"] == "3d-tiles"
|
|
assert layer["source"]["altitudeOffset"] == 5
|
|
|
|
|
|
def test_add_video_wraps_single_url(m):
|
|
m.add_video("https://e/a.mp4", [[0, 0], [1, 0], [1, 1], [0, 1]])
|
|
assert _last_layer(m)["source"]["urls"] == ["https://e/a.mp4"]
|
|
|
|
|
|
def test_add_wfs_inlines_geojson(monkeypatch, m):
|
|
fake_fc = {
|
|
"type": "FeatureCollection",
|
|
"features": [{"type": "Feature", "properties": {}, "geometry": None}],
|
|
}
|
|
monkeypatch.setattr(gmod._project, "load_featurecollection", lambda _url: fake_fc)
|
|
m.add_wfs("https://e/wfs", "topp:states")
|
|
layer = _last_layer(m)
|
|
assert layer["type"] == "geojson"
|
|
assert layer["geojson"] == fake_fc
|
|
assert layer["metadata"]["service"] == "wfs"
|
|
assert layer["metadata"]["sourceKind"] == "wfs-getfeature"
|
|
assert layer["metadata"]["typeName"] == "topp:states"
|
|
assert layer["metadata"]["featureCount"] == 1
|
|
# Protocol fields are persisted on the source for round-trip editing.
|
|
assert layer["source"]["service"] == "wfs"
|
|
assert layer["source"]["typeName"] == "topp:states"
|
|
assert layer["source"]["version"] == "2.0.0"
|
|
assert layer["source"]["outputFormat"] == "application/json"
|
|
|
|
|
|
def test_add_vector_local_file_inlined(monkeypatch, m):
|
|
fake_fc = {"type": "FeatureCollection", "features": []}
|
|
captured = {}
|
|
|
|
def fake_read(path, data_format=None):
|
|
captured["path"] = path
|
|
captured["data_format"] = data_format
|
|
return fake_fc
|
|
|
|
monkeypatch.setattr(gmod, "_read_local_vector", fake_read)
|
|
# add_geoparquet routes a local path with the parquet hint threaded through.
|
|
m.add_geoparquet("/data/cities.parquet")
|
|
layer = _last_layer(m)
|
|
assert layer["type"] == "geojson"
|
|
assert layer["geojson"] == fake_fc
|
|
assert captured["data_format"] == "parquet"
|
|
|
|
|
|
def test_add_vector_local_file_warns_on_ignored_kwargs(monkeypatch, m):
|
|
monkeypatch.setattr(
|
|
gmod, "_read_local_vector", lambda _p, data_format=None: {"type": "x"}
|
|
)
|
|
with pytest.warns(UserWarning, match="ignored for local files"):
|
|
m.add_vector("/data/parcels.shp", source_layer="layer0")
|
|
|
|
|
|
def test_add_vector_geo_interface_inlined(m):
|
|
class Fake:
|
|
__geo_interface__ = {"type": "FeatureCollection", "features": []}
|
|
|
|
m.add_vector(Fake(), name="GDF")
|
|
layer = _last_layer(m)
|
|
assert layer["type"] == "geojson"
|
|
assert layer["name"] == "GDF"
|
|
|
|
|
|
def test_add_vector_geo_interface_warns_on_ignored_kwargs(m):
|
|
class Fake:
|
|
__geo_interface__ = {"type": "FeatureCollection", "features": []}
|
|
|
|
with pytest.warns(UserWarning, match="__geo_interface__ objects"):
|
|
m.add_vector(Fake(), render_mode="tiles")
|
|
|
|
|
|
# -- local raster ---------------------------------------------------------
|
|
|
|
|
|
def test_add_raster_local_path_served(monkeypatch, m):
|
|
monkeypatch.setattr(
|
|
gmod, "register_local_file", lambda path: f"http://127.0.0.1:0/served/{path}"
|
|
)
|
|
m.add_raster("/data/dem.tif", colormap="terrain")
|
|
layer = _last_layer(m)
|
|
assert layer["type"] == "cog"
|
|
assert layer["source"]["url"] == "http://127.0.0.1:0/served//data/dem.tif"
|
|
assert layer["metadata"]["rasterState"]["colormap"] == "terrain"
|
|
|
|
|
|
def test_add_raster_url_not_served(monkeypatch, m):
|
|
called = {"n": 0}
|
|
|
|
def boom(_path):
|
|
called["n"] += 1
|
|
raise AssertionError("URL rasters must not be routed to the file server")
|
|
|
|
monkeypatch.setattr(gmod, "register_local_file", boom)
|
|
m.add_raster("https://e/dem.tif")
|
|
assert called["n"] == 0
|
|
assert _last_layer(m)["source"]["url"] == "https://e/dem.tif"
|
|
|
|
|
|
# -- markers --------------------------------------------------------------
|
|
|
|
|
|
def test_add_marker_single_point(m):
|
|
m.add_marker(-100, 40, properties={"name": "Center"}, fillColor="#ff0000")
|
|
layer = _last_layer(m)
|
|
assert layer["type"] == "geojson"
|
|
feature = layer["geojson"]["features"][0]
|
|
assert feature["geometry"]["coordinates"] == [-100.0, 40.0]
|
|
assert feature["properties"]["name"] == "Center"
|
|
assert layer["style"]["fillColor"] == "#ff0000"
|
|
|
|
|
|
def test_add_markers_from_pairs(m):
|
|
m.add_markers([(-100, 40), (-90, 35)])
|
|
features = _last_layer(m)["geojson"]["features"]
|
|
assert [f["geometry"]["coordinates"] for f in features] == [
|
|
[-100.0, 40.0],
|
|
[-90.0, 35.0],
|
|
]
|
|
|
|
|
|
def test_add_markers_from_dicts_keeps_properties(m):
|
|
m.add_markers([{"lon": -100, "lat": 40, "pop": 5}, {"x": -90, "y": 35}])
|
|
features = _last_layer(m)["geojson"]["features"]
|
|
assert features[0]["properties"] == {"pop": 5}
|
|
assert features[1]["geometry"]["coordinates"] == [-90.0, 35.0]
|
|
|
|
|
|
def test_add_markers_rejects_bad_pair(m):
|
|
with pytest.raises(ValueError, match="lng, lat"):
|
|
m.add_markers([(-100, 40, 1)])
|
|
|
|
|
|
def test_add_markers_rejects_dict_missing_coords(m):
|
|
with pytest.raises(ValueError, match="longitude"):
|
|
m.add_markers([{"pop": 5}])
|
|
|
|
|
|
def test_add_markers_rejects_non_point_geojson(m):
|
|
polygon_fc = {
|
|
"type": "FeatureCollection",
|
|
"features": [
|
|
{
|
|
"type": "Feature",
|
|
"properties": {},
|
|
"geometry": {"type": "Polygon", "coordinates": [[[0, 0], [1, 0], [1, 1], [0, 0]]]},
|
|
}
|
|
],
|
|
}
|
|
with pytest.raises(ValueError, match="Point/MultiPoint"):
|
|
m.add_markers(polygon_fc)
|
|
|
|
|
|
def test_add_markers_from_geojson(m):
|
|
fc = {
|
|
"type": "FeatureCollection",
|
|
"features": [
|
|
{"type": "Feature", "properties": {}, "geometry": {"type": "Point", "coordinates": [1, 2]}}
|
|
],
|
|
}
|
|
m.add_markers(fc)
|
|
assert _last_layer(m)["geojson"]["features"][0]["geometry"]["coordinates"] == [1, 2]
|
|
|
|
|
|
def test_add_circle_markers_sets_radius(m):
|
|
m.add_circle_markers([(0, 0)], radius=12)
|
|
assert _last_layer(m)["style"]["circleRadius"] == 12.0
|
|
|
|
|
|
def test_add_marker_cluster_enables_clustering(m):
|
|
m.add_marker_cluster([(0, 0), (1, 1)], cluster_radius=80, cluster_max_zoom=10)
|
|
style = _last_layer(m)["style"]
|
|
assert style["pointRenderer"] == "cluster"
|
|
assert style["clusterRadius"] == 80
|
|
assert style["clusterMaxZoom"] == 10
|
|
|
|
|
|
# -- choropleth -----------------------------------------------------------
|
|
|
|
|
|
def _choropleth_fc():
|
|
return {
|
|
"type": "FeatureCollection",
|
|
"features": [
|
|
{"type": "Feature", "properties": {"pop": v}, "geometry": None}
|
|
for v in (0, 10, 20, 30, 40)
|
|
],
|
|
}
|
|
|
|
|
|
def test_add_choropleth_builds_graduated_style(m):
|
|
m.add_choropleth(_choropleth_fc(), "pop", class_count=5, colormap="blues")
|
|
style = _last_layer(m)["style"]
|
|
assert style["vectorStyleMode"] == "graduated"
|
|
assert style["vectorStyleProperty"] == "pop"
|
|
assert style["vectorStyleColorRamp"] == "blues"
|
|
assert len(style["vectorStyleStops"]) == 5
|
|
assert style["vectorStyleStops"][0]["value"] == 0.0
|
|
assert style["vectorStyleStops"][-1]["value"] == 40.0
|
|
|
|
|
|
def test_add_choropleth_missing_column_raises(m):
|
|
with pytest.raises(ValueError, match="not found"):
|
|
m.add_choropleth(_choropleth_fc(), "missing")
|
|
|
|
|
|
def test_add_choropleth_non_numeric_column_raises(m):
|
|
fc = {
|
|
"type": "FeatureCollection",
|
|
"features": [
|
|
{"type": "Feature", "properties": {"name": label}, "geometry": None}
|
|
for label in ("alpha", "beta", "gamma")
|
|
],
|
|
}
|
|
with pytest.raises(ValueError, match="numeric value"):
|
|
m.add_choropleth(fc, "name")
|
|
|
|
|
|
def test_add_choropleth_style_override_wins(m):
|
|
m.add_choropleth(_choropleth_fc(), "pop", strokeColor="#000000")
|
|
assert _last_layer(m)["style"]["strokeColor"] == "#000000"
|
|
|
|
|
|
def test_add_data_without_column_is_plain_geojson(m):
|
|
m.add_data(_choropleth_fc())
|
|
assert _last_layer(m)["style"]["vectorStyleMode"] == "single"
|
|
|
|
|
|
def test_add_data_with_column_is_choropleth(m):
|
|
m.add_data(_choropleth_fc(), column="pop")
|
|
assert _last_layer(m)["style"]["vectorStyleMode"] == "graduated"
|
|
|
|
|
|
# -- split map / legend / colorbar -------------------------------------------
|
|
|
|
|
|
def _plugins(widget):
|
|
return widget.project.get("plugins", {})
|
|
|
|
|
|
def test_split_map_activates_swipe_plugin(m):
|
|
a = m.add_geojson(_choropleth_fc(), name="A")
|
|
b = m.add_geojson(_choropleth_fc(), name="B")
|
|
m.split_map(a, b, position=40, control_position="bottom-right")
|
|
plugins = _plugins(m)
|
|
assert "maplibre-gl-swipe" in plugins["activePluginIds"]
|
|
assert plugins["mapControlPositions"]["maplibre-gl-swipe"] == "bottom-right"
|
|
swipe = plugins["settings"]["maplibre-gl-swipe"]
|
|
assert swipe["leftLayers"] == [a]
|
|
assert swipe["rightLayers"] == [b]
|
|
assert swipe["position"] == 40
|
|
assert swipe["active"] is True
|
|
|
|
|
|
def test_plugins_block_seeds_default_active(m):
|
|
# A fresh plugins block must keep the app's default plugins active, else
|
|
# restoreProjectState would tear down the layer control / deck.gl overlay.
|
|
m.split_map()
|
|
active = _plugins(m)["activePluginIds"]
|
|
for plugin_id in (
|
|
"maplibre-layer-control",
|
|
"maplibre-deckgl-viz",
|
|
"maplibre-atmosphere-effects",
|
|
):
|
|
assert plugin_id in active
|
|
|
|
|
|
def test_split_map_accepts_layer_objects_and_lists(m):
|
|
a = m.add_geojson(_choropleth_fc(), name="A")
|
|
layer = m.get_layer(a)
|
|
m.split_map(layer, ["__basemap__"])
|
|
swipe = _plugins(m)["settings"]["maplibre-gl-swipe"]
|
|
assert swipe["leftLayers"] == [a]
|
|
assert swipe["rightLayers"] == ["__basemap__"]
|
|
|
|
|
|
def test_split_map_clamps_position(m):
|
|
m.split_map(position=999)
|
|
assert _plugins(m)["settings"]["maplibre-gl-swipe"]["position"] == 100
|
|
|
|
|
|
def test_split_map_rejects_bad_orientation(m):
|
|
with pytest.raises(ValueError, match="orientation"):
|
|
m.split_map(orientation="diagonal")
|
|
|
|
|
|
def test_split_map_rejects_bad_layer_reference(m):
|
|
with pytest.raises(ValueError, match="layer id"):
|
|
m.split_map([123])
|
|
|
|
|
|
def test_split_map_rejects_bare_non_iterable(m):
|
|
# A bare non-iterable must raise the documented ValueError, not TypeError.
|
|
with pytest.raises(ValueError, match="layer id"):
|
|
m.split_map(123)
|
|
|
|
|
|
def test_existing_plugins_block_is_not_reseeded(m):
|
|
# A project that already carries a plugins block reflects deliberate choices,
|
|
# so adding a control must not inject the default-active ids into it.
|
|
project = m.to_project()
|
|
project["plugins"] = {
|
|
"manifestUrls": [],
|
|
"activePluginIds": ["maplibre-layer-control"],
|
|
"mapControlPositions": {},
|
|
"settings": {},
|
|
}
|
|
m.load_project(project)
|
|
m.add_legend(legend_dict={"a": "#111"})
|
|
assert _plugins(m)["activePluginIds"] == ["maplibre-layer-control"]
|
|
|
|
|
|
def _components(widget):
|
|
return _plugins(widget)["settings"]["maplibre-gl-components"]
|
|
|
|
|
|
def test_add_legend_from_dict(m):
|
|
m.add_legend("Cover", legend_dict={"Water": "#0000ff", "Land": "#00ff00"})
|
|
# The Components plugin restores from settings alone; it is not added to
|
|
# activePluginIds (that would mount the full Components toolbar).
|
|
assert "maplibre-gl-components" not in _plugins(m)["activePluginIds"]
|
|
legend = _components(m)["legend"]
|
|
assert legend["visible"] is True
|
|
assert legend["title"] == "Cover"
|
|
assert legend["hasLegend"] is True
|
|
assert legend["selectedLegendIndex"] == 0
|
|
assert legend["legends"][0]["items"] == [
|
|
{"label": "Water", "color": "#0000ff", "shape": "square"},
|
|
{"label": "Land", "color": "#00ff00", "shape": "square"},
|
|
]
|
|
|
|
|
|
def test_add_legend_from_labels_and_colors(m):
|
|
m.add_legend(labels=["a", "b"], colors=["#111", "#222"], shape="circle")
|
|
items = _components(m)["legend"]["legends"][0]["items"]
|
|
assert [i["label"] for i in items] == ["a", "b"]
|
|
assert all(i["shape"] == "circle" for i in items)
|
|
|
|
|
|
def test_add_legend_builtin_nlcd(m):
|
|
m.add_legend(builtin="nlcd")
|
|
legend = _components(m)["legend"]
|
|
assert legend["title"] == "NLCD Land Cover"
|
|
labels = [i["label"] for i in legend["legends"][0]["items"]]
|
|
assert "Open Water" in labels
|
|
|
|
|
|
def test_add_legend_builtin_esa_alias(m):
|
|
m.add_legend(builtin="esa")
|
|
assert _components(m)["legend"]["title"] == "ESA WorldCover"
|
|
|
|
|
|
def test_add_legend_unknown_builtin_raises(m):
|
|
with pytest.raises(ValueError, match="Unknown built-in legend"):
|
|
m.add_legend(builtin="nope")
|
|
|
|
|
|
def test_add_legend_requires_entries(m):
|
|
with pytest.raises(ValueError, match="Provide legend entries"):
|
|
m.add_legend("Empty")
|
|
|
|
|
|
def test_add_legend_mismatched_labels_colors(m):
|
|
with pytest.raises(ValueError, match="same length"):
|
|
m.add_legend(labels=["a", "b"], colors=["#111"])
|
|
|
|
|
|
def test_add_legend_rejects_combined_sources(m):
|
|
# The three entry sources are mutually exclusive.
|
|
with pytest.raises(ValueError, match="exactly one of"):
|
|
m.add_legend(builtin="nlcd", legend_dict={"a": "#111"})
|
|
|
|
|
|
def test_add_legend_appends_multiple(m):
|
|
m.add_legend(legend_dict={"a": "#111"})
|
|
m.add_legend(legend_dict={"b": "#222"}, position="top-right")
|
|
legend = _components(m)["legend"]
|
|
assert len(legend["legends"]) == 2
|
|
assert legend["selectedLegendIndex"] == 1
|
|
assert legend["legends"][1]["legendPosition"] == "top-right"
|
|
|
|
|
|
def test_add_colorbar_named(m):
|
|
m.add_colorbar(colormap="plasma", vmin=0, vmax=255, label="Elevation", units="m")
|
|
colorbar = _components(m)["colorbar"]
|
|
assert colorbar["visible"] is True
|
|
assert colorbar["mode"] == "named"
|
|
assert colorbar["colormap"] == "plasma"
|
|
assert colorbar["vmin"] == 0
|
|
assert colorbar["vmax"] == 255
|
|
assert colorbar["colorbars"][0]["label"] == "Elevation"
|
|
|
|
|
|
def test_add_colorbar_custom_colors(m):
|
|
m.add_colorbar(colors=["#000000", "#ffffff"])
|
|
colorbar = _components(m)["colorbar"]
|
|
assert colorbar["mode"] == "custom"
|
|
assert colorbar["customColors"] == "#000000, #ffffff"
|
|
|
|
|
|
def test_add_colorbar_empty_custom_colors_raises(m):
|
|
with pytest.raises(ValueError, match="non-empty"):
|
|
m.add_colorbar(colors=[])
|
|
|
|
|
|
def test_add_colorbar_bad_position_raises(m):
|
|
with pytest.raises(ValueError, match="position"):
|
|
m.add_colorbar(position="middle")
|
|
|
|
|
|
def test_add_colorbar_rejects_inverted_range(m):
|
|
with pytest.raises(ValueError, match="must be less than"):
|
|
m.add_colorbar(vmin=100, vmax=0)
|
|
|
|
|
|
def test_add_colormap_is_colorbar_alias(m):
|
|
m.add_colormap("inferno", vmin=1, vmax=9, units="K")
|
|
colorbar = _components(m)["colorbar"]
|
|
assert colorbar["colormap"] == "inferno"
|
|
assert colorbar["vmin"] == 1
|
|
assert colorbar["vmax"] == 9
|
|
|
|
|
|
def test_legend_and_colorbar_coexist(m):
|
|
m.add_legend(legend_dict={"a": "#111"})
|
|
m.add_colorbar(colormap="viridis")
|
|
components = _components(m)
|
|
# Adding a colorbar must not drop the existing legend, and vice versa.
|
|
assert "legend" in components
|
|
assert "colorbar" in components
|