"""Tests for layer filters, labels, plugin state, and story map authoring.
The authoring functions are shared by :class:`geolibre.Map` and the MCP server,
so both surfaces are exercised here on top of the functions themselves.
Two kinds of drift check guard the shapes against the TypeScript app:
* the mirrored defaults and vocabularies are compared with
``packages/core/src/types.ts`` and the plugin sources; and
* a project built by these functions is pinned as
``tests/fixtures/python-authoring.geolibre.json`` at the repo root, which
``tests/python-authoring-roundtrip.test.ts`` loads through the app's own
``parseProject`` and checks nothing was dropped or rewritten.
Both skip when the repo sources are absent (an sdist or wheel install).
Regenerate the fixture with ``GEOLIBRE_REGEN_FIXTURES=1``.
"""
from __future__ import annotations
import asyncio
import json
import os
import re
from pathlib import Path
import pytest
import geolibre.geolibre as gmod
from geolibre import Map, authoring, project
REPO = Path(__file__).resolve().parents[2]
TYPES_TS = REPO / "packages" / "core" / "src" / "types.ts"
PLUGINS_SRC = REPO / "packages" / "plugins" / "src"
FIXTURE = REPO / "tests" / "fixtures" / "python-authoring.geolibre.json"
POINT_FC = {
"type": "FeatureCollection",
"features": [
{
"type": "Feature",
"properties": {"name": "A", "pop": 10},
"geometry": {"type": "Point", "coordinates": [0, 0]},
},
{
"type": "Feature",
"properties": {"name": "B", "pop": 90},
"geometry": {"type": "Point", "coordinates": [1, 1]},
},
],
}
@pytest.fixture
def proj():
"""A project with one inlined GeoJSON layer named "Cities"."""
p = project.build_empty_project("Test")
authoring.add_layer(p, project.geojson_layer("Cities", POINT_FC))
return p
def test_set_layer_filter_accepts_a_list_or_json_and_clears(proj):
"""A list and its JSON string store the same expression; None clears it."""
summary = authoring.set_layer_filter(proj, "Cities", [">=", ["get", "pop"], 50])
assert summary["filterExpression"] == [">=", ["get", "pop"], 50]
layer = authoring.find_layer(proj, "Cities")
assert layer["filterExpression"] == [">=", ["get", "pop"], 50]
authoring.set_layer_filter(proj, "Cities", '["==", ["get", "name"], "A"]')
assert layer["filterExpression"] == ["==", ["get", "name"], "A"]
authoring.set_layer_filter(proj, "Cities", None)
assert "filterExpression" not in layer
@pytest.mark.parametrize(
"expression",
[
["case", ["has", "pop"], [">", ["get", "pop"], 5], False],
["match", ["get", "k"], ["a", "b"], True, False],
["coalesce", ["boolean", ["get", "flag"]], False],
["let", "x", 5, [">", ["get", "pop"], ["var", "x"]]],
],
)
def test_set_layer_filter_accepts_boolean_branching_operators(proj, expression):
"""case/match/coalesce/let pass when every branch they can return is boolean."""
authoring.set_layer_filter(proj, "Cities", expression)
assert authoring.find_layer(proj, "Cities")["filterExpression"] == expression
@pytest.mark.parametrize(
("expression", "message"),
[
("pop", "not valid JSON"),
([], None),
({"op": "=="}, "expression array"),
([1, 2], "operator string"),
(["get", "pop"], "true/false"),
(["+", 1, 2], "true/false"),
(["case", ["has", "pop"], 1, 0], "true/false"),
(["let", "x", 5, ["var", "x"]], "true/false"),
(["match", ["get", "k"], "a", True, "nope"], "true/false"),
],
)
def test_set_layer_filter_rejects_non_boolean_expressions(proj, expression, message):
"""Expressions the app would silently drop on load are refused up front."""
if message is None:
authoring.set_layer_filter(proj, "Cities", expression)
return
with pytest.raises(ValueError, match=message):
authoring.set_layer_filter(proj, "Cities", expression)
def test_set_labels_writes_a_complete_labels_object(proj):
"""The written object carries every LabelStyle key, with options mapped."""
labels = authoring.set_labels(
proj,
"Cities",
"name",
size=14,
halo_width=2,
anchor="top",
transform="uppercase",
color_expression=["match", ["get", "name"], "A", "#ff0000", "#000000"],
)
assert set(labels) == set(project.DEFAULT_LABEL_STYLE)
assert labels["enabled"] is True
assert labels["field"] == "name"
assert labels["size"] == 14
assert labels["haloWidth"] == 2
assert labels["anchor"] == "top"
assert labels["transform"] == "uppercase"
assert json.loads(labels["colorExpression"])[0] == "match"
assert authoring.find_layer(proj, "Cities")["style"]["labels"] == labels
def test_set_labels_keeps_unspecified_settings(proj):
"""A second call restyles without restating the field."""
authoring.set_labels(proj, "Cities", "name", size=20)
labels = authoring.set_labels(proj, "Cities", color="#ff0000")
assert labels["field"] == "name"
assert labels["size"] == 20
assert labels["color"] == "#ff0000"
hidden = authoring.set_labels(proj, "Cities", enabled=False)
assert hidden["enabled"] is False
assert hidden["field"] == "name"
restyled = authoring.set_labels(proj, "Cities", size=11)
assert restyled["enabled"] is False
assert authoring.set_labels(proj, "Cities", enabled=True)["enabled"] is True
def test_set_labels_treats_a_stored_empty_labels_object_as_on(proj):
"""A hand-edited `labels: {}` does not make new labels invisible."""
authoring.find_layer(proj, "Cities")["style"]["labels"] = {}
assert authoring.set_labels(proj, "Cities", "name")["enabled"] is True
def test_deeply_nested_expressions_are_a_value_error(proj):
"""Recursion limits surface as the documented ValueError."""
deep: list = [">", ["get", "pop"], 0]
for _ in range(5000):
deep = ["coalesce", deep]
with pytest.raises(ValueError, match="nested too deeply"):
authoring.set_layer_filter(proj, "Cities", deep)
def test_set_labels_expression_overrides_field(proj):
"""A label-text expression is stored compactly and can be cleared."""
labels = authoring.set_labels(proj, "Cities", expression=["concat", ["get", "name"], "!"])
assert labels["expression"] == '["concat",["get","name"],"!"]'
cleared = authoring.set_labels(proj, "Cities", "name", expression="")
assert cleared["expression"] == ""
@pytest.mark.parametrize(
("kwargs", "message"),
[
({"anchor": "middle"}, "anchor must be one of"),
({"placement": "curve"}, "placement must be one of"),
({"size": 0}, "greater than 0"),
({"min_zoom": 30}, "between 0 and 24"),
({"min_zoom": 10, "max_zoom": 5}, "must not exceed"),
({"number_decimals": 2.5}, "whole number"),
({"allow_overlap": "yes"}, "true or false"),
({"font": "Arial"}, "unknown label option"),
({"size_expression": "nope"}, "not valid JSON"),
],
)
def test_set_labels_rejects_bad_options(proj, kwargs, message):
"""Invalid options raise with a message naming the problem."""
with pytest.raises(ValueError, match=message):
authoring.set_labels(proj, "Cities", "name", **kwargs)
def test_set_labels_needs_something_to_draw(proj):
"""Enabling labels with no field and no expression is refused."""
with pytest.raises(ValueError, match="field or an expression"):
authoring.set_labels(proj, "Cities")
def test_set_plugin_state_stores_and_activates(proj):
"""The blob, corner, and activation land where the app restores them."""
stored = authoring.set_plugin_state(
proj, "maplibre-h3-grid", {"resolution": 5}, position="top-left"
)
assert stored == {
"pluginId": "maplibre-h3-grid",
"active": True,
"position": "top-left",
"state": {"resolution": 5},
}
plugins = proj["plugins"]
assert plugins["settings"]["maplibre-h3-grid"] == {"resolution": 5}
assert plugins["mapControlPositions"]["maplibre-h3-grid"] == "top-left"
for default in project.DEFAULT_ACTIVE_PLUGIN_IDS:
assert default in plugins["activePluginIds"]
def test_set_plugin_state_without_state_keeps_settings(proj):
"""Omitting the state repositions without wiping the stored settings."""
authoring.set_plugin_state(proj, "maplibre-h3-grid", {"resolution": 5})
moved = authoring.set_plugin_state(proj, "maplibre-h3-grid", position="bottom-left")
assert moved["state"] == {"resolution": 5}
assert moved["position"] == "bottom-left"
def test_set_plugin_state_clear_removes_settings_only(proj):
"""clear=True drops the blob and never switches the plugin on."""
authoring.set_plugin_state(proj, "maplibre-h3-grid", {"resolution": 5})
authoring.set_plugin_state(proj, "maplibre-h3-grid", clear=True)
assert "maplibre-h3-grid" not in proj["plugins"]["settings"]
authoring.set_plugin_state(proj, "maplibre-olc", clear=True)
assert "maplibre-olc" not in proj["plugins"]["activePluginIds"]
with pytest.raises(ValueError, match="not both"):
authoring.set_plugin_state(proj, "maplibre-olc", {"a": 1}, clear=True)
def test_set_plugin_state_refuses_unknown_ids_unless_allowed(proj):
"""An id no built-in plugin restores is refused unless explicitly allowed."""
with pytest.raises(ValueError, match="allow_unknown"):
authoring.set_plugin_state(proj, "my-plugin", {"a": 1})
stored = authoring.set_plugin_state(proj, "my-plugin", {"a": 1}, allow_unknown=True)
assert stored["state"] == {"a": 1}
def test_set_plugin_state_rejects_non_json_and_bad_corners(proj):
"""State the app would drop on load, and unknown corners, are refused."""
with pytest.raises(ValueError, match="plain JSON"):
authoring.set_plugin_state(proj, "maplibre-h3-grid", {"x": float("nan")})
with pytest.raises(ValueError, match="position must be one of"):
authoring.set_plugin_state(proj, "maplibre-h3-grid", {}, position="middle")
def test_add_story_chapter_defaults_to_the_saved_view(proj):
"""A chapter with no camera captures the project's saved view."""
authoring.set_view(proj, center=[-84, 36], zoom=7, bearing=-30, pitch=40)
chapter = authoring.add_story_chapter(proj, "Intro")
assert chapter["location"] == {"center": [-84, 36], "zoom": 7, "pitch": 40, "bearing": 330}
assert chapter["alignment"] == "left"
assert chapter["mapAnimation"] == "flyTo"
assert "image" not in chapter
assert proj["storymap"]["chapters"] == [chapter]
assert set(proj["storymap"]) == set(project.DEFAULT_STORY_MAP)
def test_add_story_chapter_resolves_layer_names_in_opacity_changes(proj):
"""Opacity changes accept layer names and store the layer id."""
layer_id = authoring.find_layer(proj, "Cities")["id"]
chapter = authoring.add_story_chapter(
proj,
"Cities fade in",
center=[0, 0],
zoom=3,
on_enter=[{"layer": "Cities", "opacity": 1, "duration": 500}],
on_exit=[{"layer": layer_id, "opacity": 0}],
)
assert chapter["onChapterEnter"][0]["layerId"] == layer_id
assert chapter["onChapterEnter"][0]["duration"] == 500
assert chapter["onChapterExit"][0]["opacity"] == 0
assert "duration" not in chapter["onChapterExit"][0]
def test_story_chapter_ordering(proj):
"""Chapters insert at an index, move, and are removed by title or id."""
first = authoring.add_story_chapter(proj, "One", center=[0, 0], zoom=1)
authoring.add_story_chapter(proj, "Three", center=[0, 0], zoom=1)
authoring.add_story_chapter(proj, "Two", center=[0, 0], zoom=1, index=1)
def titles():
return [chapter["title"] for chapter in proj["storymap"]["chapters"]]
assert titles() == ["One", "Two", "Three"]
authoring.move_story_chapter(proj, "three", 0)
assert titles() == ["Three", "One", "Two"]
authoring.move_story_chapter(proj, 0, 99)
assert titles() == ["One", "Two", "Three"]
summary = authoring.remove_story_chapter(proj, first["id"])
assert [chapter["title"] for chapter in summary["chapters"]] == ["Two", "Three"]
authoring.remove_story_chapter(proj, -1)
assert titles() == ["Two"]
@pytest.mark.parametrize(
("kwargs", "message"),
[
({"center": [200, 0]}, "longitude"),
({"center": [0, 95]}, "latitude"),
({"zoom": 30}, "zoom"),
({"pitch": 90}, "pitch"),
({"alignment": "top"}, "alignment"),
({"map_animation": "warp"}, "map_animation"),
({"on_enter": [{"layer": "Cities", "opacity": 2}]}, "between 0 and 1"),
({"on_enter": [{"layer": "Nope"}]}, "No layer matches"),
],
)
def test_add_story_chapter_rejects_bad_values(proj, kwargs, message):
"""Values the app would clamp or drop are refused instead."""
with pytest.raises(ValueError, match=message):
authoring.add_story_chapter(proj, "Bad", **{"center": [0, 0], "zoom": 1, **kwargs})
def test_story_chapter_lookup_errors(proj):
"""Unknown and ambiguous references are explained."""
authoring.add_story_chapter(proj, "Same", center=[0, 0], zoom=1)
authoring.add_story_chapter(proj, "Same", center=[0, 0], zoom=1)
with pytest.raises(ValueError, match="2 chapters are titled"):
authoring.remove_story_chapter(proj, "Same")
with pytest.raises(ValueError, match="No chapter matches"):
authoring.remove_story_chapter(proj, "Other")
with pytest.raises(ValueError, match="No chapter at index"):
authoring.move_story_chapter(proj, 5, 0)
with pytest.raises(ValueError, match="already exists"):
chapter_id = proj["storymap"]["chapters"][0]["id"]
authoring.add_story_chapter(proj, "Dup", center=[0, 0], zoom=1, chapter_id=chapter_id)
def test_set_story_map_settings(proj):
"""Settings map to StoryMap keys and are validated."""
summary = authoring.set_story_map(
proj, title="Tour", theme="light", show_markers=True, start_slide="global"
)
assert summary["title"] == "Tour"
assert proj["storymap"]["showMarkers"] is True
assert proj["storymap"]["startSlide"] == "global"
assert proj["storymap"]["chapters"] == []
with pytest.raises(ValueError, match="theme must be one of"):
authoring.set_story_map(proj, theme="sepia")
with pytest.raises(ValueError, match="unknown story map setting"):
authoring.set_story_map(proj, colour="red")
def test_map_methods_delegate_to_authoring(monkeypatch):
"""The widget methods write the same project shapes as the functions."""
monkeypatch.setattr(gmod, "serve_app", lambda *_a, **_k: "http://127.0.0.1:0/")
monkeypatch.setattr(gmod, "app_port", lambda: 0)
m = Map()
layer_id = m.add_geojson(POINT_FC, name="Cities")
m.set_layer_filter("Cities", ["==", ["get", "name"], "A"])
labels = m.set_labels(m.get_layer(layer_id), "name", size=12)
m.set_plugin_state("maplibre-gl-graticule", {"interval": 10})
chapter = m.add_story_chapter(
"Start", center=(1, 2), zoom=4, on_enter=[{"layer": m.get_layer(layer_id)}]
)
m.add_story_chapter("Next", center=(3, 4), zoom=5)
m.move_story_chapter("Next", 0)
m.set_story_map(title="Tour")
layer = next(item for item in m.project["layers"] if item["id"] == layer_id)
assert layer["filterExpression"] == ["==", ["get", "name"], "A"]
assert layer["style"]["labels"] == labels
assert m.project["plugins"]["settings"]["maplibre-gl-graticule"] == {"interval": 10}
assert chapter["onChapterEnter"][0]["layerId"] == layer_id
assert [c["title"] for c in m.project["storymap"]["chapters"]] == ["Next", "Start"]
m.remove_story_chapter("Next")
assert [c["title"] for c in m.project["storymap"]["chapters"]] == ["Start"]
assert m.project["storymap"]["title"] == "Tour"
@pytest.fixture
def mcp_server(tmp_path):
"""An MCP server confined to a temporary workspace (needs the SDK)."""
pytest.importorskip("mcp", reason="the mcp SDK is an optional extra")
from geolibre.mcp.server import build_server
from geolibre.mcp.workspace import Workspace
return build_server(Workspace([tmp_path]))
def _call(server, tool, /, **arguments):
"""Invoke an MCP tool and return its structured result."""
result = asyncio.run(server.call_tool(tool, arguments))
if result.is_error:
raise AssertionError(f"{tool} failed: {result.content[0].text}")
return result.structured_content
def test_mcp_tools_author_filters_labels_plugins_and_stories(mcp_server, tmp_path):
"""The MCP tools write through authoring.py and save the file."""
_call(mcp_server, "create_project", path="map.geolibre.json")
_call(
mcp_server,
"add_geojson_layer",
path="map.geolibre.json",
data=json.dumps(POINT_FC),
name="Cities",
)
_call(
mcp_server,
"set_layer_filter",
path="map.geolibre.json",
layer="Cities",
expression='[">", ["get", "pop"], 20]',
)
labels = _call(
mcp_server,
"set_labels",
path="map.geolibre.json",
layer="Cities",
field="name",
size=15,
options={"transform": "uppercase"},
)["labels"]
assert labels["size"] == 15 and labels["transform"] == "uppercase"
_call(
mcp_server,
"set_plugin_state",
path="map.geolibre.json",
plugin_id="maplibre-gl-graticule",
state={"interval": 5},
)
_call(mcp_server, "set_story_map", path="map.geolibre.json", title="Tour")
added = _call(
mcp_server,
"add_story_chapter",
path="map.geolibre.json",
title="A",
center=[0, 0],
zoom=3,
on_enter=[{"layer": "Cities", "opacity": 1}],
)["chapter"]
_call(mcp_server, "add_story_chapter", path="map.geolibre.json", title="B", index=0)
moved = _call(mcp_server, "move_story_chapter", path="map.geolibre.json", chapter="A", index=0)
assert [c["title"] for c in moved["storymap"]["chapters"]] == ["A", "B"]
_call(mcp_server, "remove_story_chapter", path="map.geolibre.json", chapter=1)
saved = json.loads((tmp_path / "map.geolibre.json").read_text(encoding="utf-8"))
layer = saved["layers"][0]
assert layer["filterExpression"] == [">", ["get", "pop"], 20]
assert layer["style"]["labels"]["field"] == "name"
assert saved["plugins"]["settings"]["maplibre-gl-graticule"] == {"interval": 5}
assert saved["storymap"]["title"] == "Tour"
assert [c["id"] for c in saved["storymap"]["chapters"]] == [added["id"]]
assert "maplibre-gl-graticule" in _call(mcp_server, "list_catalog")["pluginStateIds"]
@pytest.mark.parametrize("key", ["size", "enabled", "field", "expression"])
def test_mcp_set_labels_refuses_named_options_inside_options(mcp_server, key):
"""A setting passed both ways is an error rather than a silent pick."""
from mcp.server.mcpserver.exceptions import ToolError
_call(mcp_server, "create_project", path="map.geolibre.json")
_call(
mcp_server,
"add_geojson_layer",
path="map.geolibre.json",
data=json.dumps(POINT_FC),
name="Cities",
)
with pytest.raises(ToolError, match="as arguments"):
asyncio.run(
mcp_server.call_tool(
"set_labels",
{
"path": "map.geolibre.json",
"layer": "Cities",
"field": "name",
"options": {key: 3},
},
)
)
needs_repo = pytest.mark.skipif(
not TYPES_TS.is_file(), reason="the TypeScript sources ship in the repo, not in the package"
)
def _ts_object_literal(source: str, start: str) -> str:
"""Return the brace-balanced object literal that follows ``start``.
Args:
source: TypeScript source text.
start: Text immediately before the opening brace.
Returns:
The literal's body, without the outer braces.
"""
index = source.index(start) + len(start)
index = source.index("{", index)
depth = 0
for end in range(index, len(source)):
if source[end] == "{":
depth += 1
elif source[end] == "}":
depth -= 1
if depth == 0:
return source[index + 1 : end]
raise AssertionError(f"unbalanced object literal after {start!r}")
def _ts_literal_values(body: str) -> dict[str, object]:
"""Parse ``key: value,`` lines of a flat TS object literal into Python.
Args:
body: An object literal body with one scalar property per line.
Returns:
The properties, with JSON-compatible values decoded.
"""
values: dict[str, object] = {}
for match in re.finditer(r"^\s*(\w+):\s*(.+?),?\s*$", body, re.M):
key, raw = match.groups()
raw = raw.rstrip(",")
values[key] = [] if raw == "[]" else json.loads(raw)
return values
def _ts_union(name: str) -> set[str]:
"""Return the string members of an exported TS union type in types.ts.
Args:
name: The type name.
Returns:
The quoted members.
"""
source = TYPES_TS.read_text(encoding="utf-8")
match = re.search(rf"export type {name} =(.*?);", source, re.S)
assert match, f"{name} not found in types.ts"
return set(re.findall(r'"([^"]+)"', match.group(1)))
@needs_repo
def test_label_defaults_match_types_ts():
"""DEFAULT_LABEL_STYLE mirrors DEFAULT_LAYER_STYLE.labels exactly."""
source = TYPES_TS.read_text(encoding="utf-8")
style = _ts_object_literal(source, "export const DEFAULT_LAYER_STYLE: LayerStyle =")
labels = _ts_literal_values(_ts_object_literal(style, "labels:"))
assert labels == project.DEFAULT_LABEL_STYLE
@needs_repo
def test_label_and_story_vocabularies_match_types_ts():
"""The enum sets the builders validate against are the app's own."""
assert _ts_union("LabelAnchor") == project.LABEL_ANCHORS
assert _ts_union("LabelTransform") == project.LABEL_TRANSFORMS
assert _ts_union("LabelDedupe") == project.LABEL_DEDUPE_MODES
assert _ts_union("StoryChapterAlignment") == project.STORY_ALIGNMENTS
assert _ts_union("StoryChapterAnimation") == project.STORY_ANIMATIONS
assert _ts_union("StoryInsetPosition") == project.STORY_INSET_POSITIONS
assert _ts_union("StorySlideMode") == project.STORY_SLIDE_MODES
@needs_repo
def test_story_defaults_match_types_ts():
"""DEFAULT_STORY_MAP mirrors the app's DEFAULT_STORY_MAP."""
source = TYPES_TS.read_text(encoding="utf-8")
body = _ts_object_literal(source, "export const DEFAULT_STORY_MAP: StoryMap =")
assert _ts_literal_values(body) == project.DEFAULT_STORY_MAP
@needs_repo
def test_plugin_state_ids_exist_in_the_plugin_sources():
"""Every id set_plugin_state accepts is still a plugin id in the app."""
text = "\n".join(
path.read_text(encoding="utf-8")
for path in PLUGINS_SRC.rglob("*.ts")
if ".test." not in path.name
)
missing = sorted(i for i in project.PLUGIN_STATE_IDS if f'"{i}"' not in text)
assert not missing, f"plugin ids no longer in packages/plugins/src: {missing}"
def build_round_trip_project() -> dict:
"""Build the pinned project the TypeScript round-trip test loads.
Every id is fixed so the output is deterministic.
Returns:
A project exercising each authoring function in this module.
"""
p = project.build_empty_project("Python authoring round trip", center=[-84, 36], zoom=6)
p.pop("id", None)
layer = project.geojson_layer("Cities", POINT_FC)
layer["id"] = "cities"
authoring.add_layer(p, layer)
authoring.set_layer_filter(p, "cities", ["all", [">=", ["get", "pop"], 20], ["has", "name"]])
authoring.set_labels(
p,
"cities",
"name",
size=14,
anchor="top",
offset_y=0.8,
halo_width=2,
transform="uppercase",
dedupe="unique",
number_format=True,
number_decimals=1,
number_locale="de-DE",
size_expression=["interpolate", ["linear"], ["get", "pop"], 0, 10, 100, 20],
visibility_expression=[">", ["get", "pop"], 0],
)
authoring.set_layer_metadata(
p,
"cities",
title="Cities of Tennessee",
abstract="Two sample cities.",
keywords="cities, sample",
license="CC-BY-4.0",
attribution="GeoLibre",
contact={"name": "Ada", "email": "ada@example.org", "organization": "GeoLibre"},
lineage="Hand-made for the round-trip test.",
temporal_extent=("2019-01-01", "2019-12-31T12:00:00Z"),
links=["https://example.org", {"href": "https://example.org/l", "rel": "license"}],
)
authoring.set_plugin_state(
p, "maplibre-gl-graticule", {"interval": 10, "color": "#888888"}, position="top-left"
)
authoring.set_story_map(
p,
title="A tour",
subtitle="Two stops",
byline="GeoLibre",
theme="light",
show_markers=True,
inset=True,
inset_position="top-right",
start_slide="global",
end_slide="black",
)
authoring.add_story_chapter(
p,
"Overview",
description="Every city.",
zoom=4,
bearing=-45,
chapter_id="overview",
on_enter=[{"id": "fade-in", "layer": "Cities", "opacity": 1, "duration": 800}],
)
authoring.add_story_chapter(
p,
"Close up",
center=[-83.5, 35.5],
zoom=11.5,
pitch=45,
image="https://example.com/photo.jpg",
alignment="right",
map_animation="easeTo",
rotate_animation=True,
chapter_id="close-up",
on_exit=[{"id": "fade-out", "layer": "cities", "opacity": 0.25}],
)
p["bookmarkGroups"] = [project.bookmark_folder("Neighborhoods", folder_id="hoods")]
authoring.add_bookmark(
p,
"Downtown",
center=[-83.92, 35.96],
zoom=14,
pitch=30,
bearing=-20,
folder="hoods",
visible_layers=["Cities"],
bookmark_id="downtown",
created_at=1_700_000_000_000,
)
authoring.add_bookmark(p, "Region", zoom=6, bookmark_id="region", created_at=1_700_000_000_001)
return p
@pytest.mark.skipif(
not FIXTURE.parent.is_dir(), reason="the round-trip fixture ships in the repo only"
)
def test_round_trip_fixture_is_current():
"""The pinned fixture is exactly what the authoring functions produce now.
The TypeScript side (tests/python-authoring-roundtrip.test.ts) proves the
fixture survives the app's parseProject; this proves the fixture is still
what Python writes, so the two together cover the round trip.
"""
built = build_round_trip_project()
text = json.dumps(built, indent=2) + "\n"
if os.environ.get("GEOLIBRE_REGEN_FIXTURES") == "1":
FIXTURE.write_text(text, encoding="utf-8")
assert FIXTURE.is_file(), "run with GEOLIBRE_REGEN_FIXTURES=1 to create the fixture"
assert json.loads(FIXTURE.read_text(encoding="utf-8")) == built
def test_add_bookmark_defaults_to_the_saved_view(proj):
proj["mapView"] = {"center": [-83.9, 35.9], "zoom": 9, "pitch": 10, "bearing": 350}
bookmark = authoring.add_bookmark(proj, "Home")
assert (bookmark["lng"], bookmark["lat"], bookmark["zoom"]) == (-83.9, 35.9, 9)
assert (bookmark["pitch"], bookmark["bearing"]) == (10, 350)
assert "groupId" not in bookmark and "extra" not in bookmark
assert proj["bookmarks"] == [bookmark]
assert "bookmarkGroups" not in proj
def test_add_bookmark_files_it_in_a_folder_next_to_its_members(proj):
first = authoring.add_bookmark(proj, "A", zoom=1, folder="Parks")
authoring.add_bookmark(proj, "Loose", zoom=1)
third = authoring.add_bookmark(proj, "B", zoom=1, folder="parks-missing-name-match")
fourth = authoring.add_bookmark(proj, "C", zoom=1, folder="Parks")
folders = {f["name"]: f["id"] for f in proj["bookmarkGroups"]}
assert first["groupId"] == fourth["groupId"] == folders["Parks"]
assert third["groupId"] == folders["parks-missing-name-match"]
assert [b["name"] for b in proj["bookmarks"]] == ["A", "C", "Loose", "B"]
def test_add_bookmark_resolves_visible_layers(proj):
bookmark = authoring.add_bookmark(proj, "Cities only", zoom=3, visible_layers=["cities"])
layer_id = authoring.find_layer(proj, "Cities")["id"]
assert bookmark["extra"] == {"visibleLayerIds": [layer_id]}
with pytest.raises(ValueError):
authoring.add_bookmark(proj, "Bad", zoom=3, visible_layers=["no such layer"])
@pytest.mark.parametrize(
("kwargs", "message"),
[
({"center": [200, 0]}, "longitude"),
({"center": [0, 95]}, "latitude"),
({"zoom": 30}, "zoom"),
({"pitch": 90}, "pitch"),
],
)
def test_add_bookmark_rejects_bad_values(proj, kwargs, message):
with pytest.raises(ValueError, match=message):
authoring.add_bookmark(proj, "Bad", **{"center": [0, 0], "zoom": 1, **kwargs})
assert "bookmarks" not in proj
def test_remove_bookmark_by_name_id_or_index(proj):
a = authoring.add_bookmark(proj, "Alpha", zoom=1)
authoring.add_bookmark(proj, "Beta", zoom=1)
authoring.add_bookmark(proj, "Gamma", zoom=1)
assert [b["name"] for b in authoring.remove_bookmark(proj, "beta")] == ["Alpha", "Gamma"]
authoring.remove_bookmark(proj, a["id"])
authoring.remove_bookmark(proj, 0)
assert "bookmarks" not in proj
with pytest.raises(ValueError, match="no bookmark"):
authoring.remove_bookmark(proj, "Alpha")
def test_describe_project_lists_bookmarks(proj):
assert "bookmarks" not in authoring.describe_project(proj)
authoring.add_bookmark(proj, "Home", zoom=2, folder="Mine")
assert authoring.describe_project(proj)["bookmarks"] == [
{"id": proj["bookmarks"][0]["id"], "name": "Home", "folder": "Mine"}
]
def test_map_add_bookmark_accepts_layer_handles(monkeypatch):
monkeypatch.setattr(gmod, "serve_app", lambda *_a, **_k: "http://127.0.0.1:0/")
monkeypatch.setattr(gmod, "app_port", lambda: 0)
m = Map()
layer = m.get_layer(m.add_geojson(POINT_FC, name="Cities"))
bookmark = m.add_bookmark("Here", center=(1, 2), zoom=5, visible_layers=[layer])
assert bookmark["extra"] == {"visibleLayerIds": [layer.id]}
assert m.project["bookmarks"] == [bookmark]
m.remove_bookmark("Here")
assert "bookmarks" not in m.project
def test_add_bookmark_refuses_past_the_app_limit(proj):
proj["bookmarks"] = [
project.bookmark(f"B{i}", center=[0, 0], zoom=1, bookmark_id=f"b{i}", created_at=0)
for i in range(project.MAX_BOOKMARKS)
]
with pytest.raises(ValueError, match="most the app keeps"):
authoring.add_bookmark(proj, "One too many", zoom=1, folder="New")
assert len(proj["bookmarks"]) == project.MAX_BOOKMARKS
assert "bookmarkGroups" not in proj
@pytest.mark.skipif(not (REPO / "packages").is_dir(), reason="needs the repo sources")
def test_bookmark_limit_matches_the_app():
source = (REPO / "packages" / "core" / "src" / "project.ts").read_text(encoding="utf-8")
match = re.search(r"MAX_PROJECT_BOOKMARKS = (\d+);", source)
assert match, "MAX_PROJECT_BOOKMARKS not found in project.ts"
assert int(match.group(1)) == project.MAX_BOOKMARKS