"""Tests for Identify, map-control, and projection settings on Map."""
from __future__ import annotations
import json
import pathlib
import re
import pytest
import geolibre.geolibre as gmod
from geolibre.geolibre import MAP_CONTROLS, MAP_PANELS, 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 test_ui_starts_empty(m):
assert m._ui == {}
def test_set_identify_defaults_to_all_layers(m):
m.set_identify()
assert m._ui == {"identify": "all"}
def test_set_identify_resolves_name_and_handle(m):
layer_id = m.add_marker(-100, 40, name="Cities")
m.set_identify("Cities")
assert m._ui["identify"] == layer_id
m.set_identify(None)
assert m._ui["identify"] is None
m.set_identify(m.get_layer(layer_id))
assert m._ui["identify"] == layer_id
def test_set_identify_rejects_unknown_layer(m):
with pytest.raises(ValueError):
m.set_identify("missing")
assert "identify" not in m._ui
def test_set_identify_reassigns_trait_for_sync(m):
before = m._ui
m.set_identify()
assert m._ui is not before
def test_removing_identify_layer_clears_it(m):
keep = m.add_marker(-100, 40, name="Keep")
drop = m.add_marker(-90, 35, name="Drop")
m.set_identify(drop)
m.remove_layer(keep)
assert m._ui["identify"] == drop
m.remove_layer(drop)
assert m._ui["identify"] is None
def test_clear_layers_clears_layer_identify_but_keeps_all(m):
m.add_marker(-100, 40, name="A")
m.set_identify("A")
m.clear_layers()
assert m._ui["identify"] is None
m.set_identify("all")
m.clear_layers()
assert m._ui["identify"] == "all"
def test_show_and_hide_controls_accumulate(m):
m.show_control("bookmark")
m.show_control("search")
m.hide_control("globe")
assert m._ui["controls"] == {"bookmark": True, "search": True, "globe": False}
m.hide_control("bookmark")
assert m._ui["controls"]["bookmark"] is False
def test_show_control_keeps_identify(m):
m.set_identify()
m.show_control("search")
assert m._ui == {"identify": "all", "controls": {"search": True}}
def test_show_control_rejects_unknown_name(m):
with pytest.raises(ValueError, match="Unknown control"):
m.show_control("terrain")
def test_set_projection_writes_project_preferences(m):
assert m.projection == "globe"
m.set_projection("mercator")
assert m.project["preferences"]["map"]["projection"] == "mercator"
assert m.projection == "mercator"
m.set_projection("globe")
assert m.projection == "globe"
def test_set_projection_rejects_unknown(m):
with pytest.raises(ValueError):
m.set_projection("albers")
def test_load_project_drops_identify_for_a_missing_layer(m):
"""A pinned Identify target that the incoming project lacks is cleared."""
layer_id = m.add_marker(-100, 40, name="Cities")
m.set_identify("Cities")
assert m._ui["identify"] == layer_id
m.load_project({"version": m.project["version"], "name": "Other", "mapView": {}})
assert m._ui["identify"] is None
def test_load_project_keeps_identify_when_the_layer_survives(m):
"""Reloading a project that still carries the layer leaves Identify armed."""
layer_id = m.add_marker(-100, 40, name="Cities")
m.set_identify("Cities")
m.load_project(m.project)
assert m._ui["identify"] == layer_id
def test_load_project_keeps_identify_all(m):
""" "all" is not tied to any layer, so it survives a project replacement."""
m.add_marker(-100, 40, name="Cities")
m.set_identify()
m.load_project({"version": m.project["version"], "name": "Other", "mapView": {}})
assert m._ui["identify"] == "all"
def test_remove_layer_disarms_identify_before_the_project_sync(m):
"""Removing the armed layer clears Identify rather than leaving it stale."""
m.add_marker(-100, 40, name="Cities")
m.set_identify("Cities")
m.remove_layer("Cities")
assert m._ui["identify"] is None
def test_set_identify_accepts_a_list_of_layers(m):
"""A list limits Identify to those layers, resolved and de-duplicated."""
a = m.add_marker(-100, 40, name="A")
m.add_marker(-95, 38, name="B")
c = m.add_marker(-90, 35, name="C")
m.set_identify(["A", m.get_layer(c), a])
assert m._ui["identify"] == [a, c]
def test_set_identify_list_of_one_is_a_single_layer(m):
a = m.add_marker(-100, 40, name="A")
m.set_identify(["A"])
assert m._ui["identify"] == a
def test_set_identify_list_rejects_unknown_or_empty(m):
m.add_marker(-100, 40, name="A")
with pytest.raises(ValueError):
m.set_identify(["A", "missing"])
with pytest.raises(ValueError, match="empty"):
m.set_identify([])
with pytest.raises(ValueError, match="got int"):
m.set_identify(7)
assert "identify" not in m._ui
def test_removing_a_listed_layer_narrows_the_list(m):
a = m.add_marker(-100, 40, name="A")
b = m.add_marker(-95, 38, name="B")
c = m.add_marker(-90, 35, name="C")
m.set_identify([a, b, c])
m.remove_layer(b)
assert m._ui["identify"] == [a, c]
m.remove_layer(c)
assert m._ui["identify"] == a
m.clear_layers()
assert m._ui["identify"] is None
def test_to_project_and_save_project_carry_interaction(m, tmp_path):
"""Identify and controls are written as the project's interaction block."""
a = m.add_marker(-100, 40, name="A")
b = m.add_marker(-95, 38, name="B")
assert "interaction" not in m.to_project()
m.set_identify([a, b])
m.show_control("search")
m.hide_control("globe")
expected = {"identify": [a, b], "controls": {"search": True, "globe": False}}
assert m.to_project()["interaction"] == expected
assert m.to_project(keep_credentials=True)["interaction"] == expected
assert "interaction" not in m.project
out = tmp_path / "map.geolibre.json"
m.save_project(str(out))
assert json.loads(out.read_text())["interaction"] == expected
def test_to_html_carries_interaction(m):
m.add_marker(-100, 40, name="A")
m.set_identify("all")
m.show_control("bookmark")
html = m.to_html()
match = re.search(r'id="geolibre-project">(.*?)</script>', html, re.DOTALL)
assert match
project = json.loads(match.group(1))
assert project["interaction"] == {"identify": "all", "controls": {"bookmark": True}}
def test_load_project_restores_interaction(m, tmp_path):
"""A saved interaction block round-trips into _ui and out of the project."""
a = m.add_marker(-100, 40, name="A")
b = m.add_marker(-95, 38, name="B")
m.set_identify([a, b])
m.show_control("search")
out = tmp_path / "map.geolibre.json"
m.save_project(str(out))
other = Map()
other.load_project(str(out))
assert other._ui == {"identify": [a, b], "controls": {"search": True}}
assert "interaction" not in other.project
assert other.to_project()["interaction"] == m.to_project()["interaction"]
def test_load_project_filters_interaction_entries(m):
"""Unknown layers and controls in a hand-edited block are skipped."""
project = {
"version": m.project["version"],
"name": "Hand edited",
"mapView": {},
"layers": [{"id": "keep"}],
"interaction": {
"identify": ["keep", "gone"],
"controls": {"search": True, "terrain": True, "globe": "no"},
},
}
m.load_project(project)
assert m._ui == {"identify": "keep", "controls": {"search": True}}
def _ts_string_set(source: str, name: str) -> set[str]:
"""Pull the string literals out of an exported TS array constant.
Args:
source: Contents of the TypeScript module.
name: The exported constant to read.
Returns:
The quoted strings in that array.
Raises:
AssertionError: If the constant is not found.
"""
match = re.search(rf"export const {name} = \[(.*?)\]", source, re.DOTALL)
assert match, f"{name} not found in ui-controls.ts"
return set(re.findall(r'"([^"]+)"', match.group(1)))
def test_control_lists_match_the_typescript_source():
"""MAP_PANELS/MAP_CONTROLS mirror the TS lists by hand; catch the drift.
Adding a control on one side only would otherwise pass CI on both and
silently accept a name the app rejects (or the reverse).
"""
ts_path = (
pathlib.Path(__file__).resolve().parents[2]
/ "apps/geolibre-desktop/src/lib/scripting/ui-controls.ts"
)
if not ts_path.exists():
pytest.skip("frontend source not present in this checkout")
source = ts_path.read_text(encoding="utf-8")
assert _ts_string_set(source, "SCRIPTABLE_PANELS") == set(MAP_PANELS)
assert _ts_string_set(source, "SCRIPTABLE_MAP_CONTROLS") == set(MAP_CONTROLS)