Every node-type mark is white now: ● ▲ ■ ◉ were pink, violet, white, and orange, and the shape already says the type. Each keeps its own type.* style, so one can take a colour back in one place; the unknown ○ keeps its grey. On the map a crowd in one cell used to glow toward white, which a white marker cannot do, so that signal is gone. The border grey goes down to #7c7c7c, which JP picked on a ladder. That sits almost on faint, so the title bar's live clip arrow is now lit like the title instead of taking the border's grey. On the PicoCalc the border is the dark grey slot. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
3230 lines
135 KiB
Python
3230 lines
135 KiB
Python
# SPDX-License-Identifier: Apache-2.0
|
||
"""Tests for the node map: MVT decoding, projection, the braille canvas, and the tool.
|
||
|
||
The tests use a real cached tile fixture (``tests/fixtures/tile_14_4843_5861.mvt``,
|
||
central Montréal) for the vector-tile decoder and the geometry. Thus the tests check the
|
||
parsing from end to end without the network. The projection, the canvas, and the
|
||
compositor are pure. The tool runs against the :class:`MockDevice` simulator with the
|
||
basemap off (the tests have no network).
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import asyncio
|
||
import io
|
||
import os
|
||
import re
|
||
import urllib.error
|
||
import urllib.request
|
||
from pathlib import Path
|
||
from time import monotonic
|
||
from typing import TYPE_CHECKING
|
||
|
||
import pytest
|
||
from rich.cells import cell_len
|
||
from rich.console import Console
|
||
|
||
from meshterm.core.geo import Viewport, haversine_km, lonlat_to_world, world_to_lonlat
|
||
from meshterm.core.models import (
|
||
NODE_TYPE_REPEATER,
|
||
Observation,
|
||
utcnow,
|
||
)
|
||
from meshterm.core.mvt import GEOM_LINE, GEOM_POLYGON, Layer, decode_tile
|
||
from meshterm.services.basemap import TILE_RETRY_SECONDS as _TILE_RETRY
|
||
from meshterm.tools.map import MapTool
|
||
from meshterm.ui.map_screen import _PAN_DIRS, _PAN_STEP
|
||
from meshterm.ui.mapcanvas import MapCanvas, parse_hex
|
||
from tests.conftest import plain as _plain # the only strip-and-join screen reader
|
||
|
||
if TYPE_CHECKING:
|
||
from meshterm.ui.map_screen import MapScreen
|
||
|
||
_FIXTURE = Path(__file__).parent / "fixtures" / "tile_14_4843_5861.mvt"
|
||
|
||
# -- MVT decoding -------------------------------------------------------------
|
||
|
||
|
||
def test_decode_tile_reads_expected_layers() -> None:
|
||
"""The real fixture decodes into the OpenMapTiles layers that the map uses."""
|
||
layers = {layer.name: layer for layer in decode_tile(_FIXTURE.read_bytes())}
|
||
for expected in ("transportation", "transportation_name", "water", "waterway", "place"):
|
||
assert expected in layers, expected
|
||
assert layers["transportation"].extent == 4096
|
||
assert layers["transportation"].features # the decoder decoded roads
|
||
|
||
|
||
def test_decode_tile_resolves_names_and_geometry() -> None:
|
||
"""Street and place features have resolved names and geometry that is not trivial."""
|
||
layers = {layer.name: layer for layer in decode_tile(_FIXTURE.read_bytes())}
|
||
streets = [f.name for f in layers["transportation_name"].features if f.name]
|
||
assert any("Rue" in s or "Avenue" in s or "Av." in s for s in streets)
|
||
|
||
places = {f.name: f.get("class") for f in layers["place"].features if f.name}
|
||
assert "Montréal" in places and places["Montréal"] == "city"
|
||
|
||
# A road is a line with at least two points. Water is a polygon.
|
||
assert any(
|
||
f.geom_type == GEOM_LINE and len(f.rings[0]) >= 2 for f in layers["transportation"].features
|
||
)
|
||
assert any(f.geom_type == GEOM_POLYGON for f in layers["water"].features)
|
||
|
||
|
||
def test_decode_tile_handles_empty_input() -> None:
|
||
"""An empty byte string decodes to no layers. It does not raise."""
|
||
assert decode_tile(b"") == []
|
||
|
||
|
||
def test_decode_tile_filter_keeps_the_whole_tile_visible() -> None:
|
||
"""A narrowed decode still names each layer. It only skips their features.
|
||
|
||
The tile source decides if a cache entry is real by checking if it parsed into layers.
|
||
Thus a narrowed decode must not make a tile look as if it has nothing.
|
||
"""
|
||
raw = _FIXTURE.read_bytes()
|
||
full = decode_tile(raw)
|
||
narrow = decode_tile(raw, layers={"water"})
|
||
|
||
assert [layer.name for layer in narrow] == [layer.name for layer in full]
|
||
assert all(layer.extent == 4096 for layer in narrow)
|
||
picked = {layer.name: layer for layer in narrow}
|
||
assert picked["water"].features # the decoder decoded the layer that the test asked for
|
||
assert not picked["building"].features # the decoder skipped the others, but kept the names
|
||
|
||
|
||
def test_decode_tile_filter_is_invisible_to_the_renderer() -> None:
|
||
"""A decode narrowed to ``DRAWN_LAYERS`` draws the same map as a full decode.
|
||
|
||
This is the guard against drift. Suppose ``_draw_tile`` starts to look up a layer that
|
||
is not in ``DRAWN_LAYERS``. Then that layer arrives with no features, and the rendered
|
||
output is different here.
|
||
"""
|
||
from meshterm.ui.map_render import DRAWN_LAYERS, MapMarker, render_map
|
||
|
||
raw = _FIXTURE.read_bytes()
|
||
vp = Viewport(45.5019, -73.5674, 14, 180, 120)
|
||
markers = [MapMarker("Yagi", 45.5019, -73.5674, is_repeater=True)]
|
||
|
||
def drawn(layers):
|
||
return render_map(vp, {(14, 4843, 5861): layers}, markers)
|
||
|
||
assert drawn(decode_tile(raw, layers=DRAWN_LAYERS)) == drawn(decode_tile(raw))
|
||
|
||
|
||
def test_decode_tile_filter_skips_the_layers_the_map_never_draws() -> None:
|
||
"""The layers that the decoder does not decode are the layers that made a tile expensive."""
|
||
from meshterm.ui.map_render import DRAWN_LAYERS
|
||
|
||
drawn = decode_tile(_FIXTURE.read_bytes(), layers=DRAWN_LAYERS)
|
||
narrow = {layer.name: layer for layer in drawn}
|
||
for skipped in ("housenumber", "poi", "mountain_peak"):
|
||
assert skipped in narrow, f"{skipped} should still be named"
|
||
assert not narrow[skipped].features, f"{skipped} should not have been decoded"
|
||
|
||
|
||
# -- buildings ----------------------------------------------------------------
|
||
|
||
|
||
def _building_dots(zoom: int) -> int:
|
||
"""The lit cells in a render of only the buildings of the fixture, at ``zoom``."""
|
||
from meshterm.ui.map_render import render_map
|
||
|
||
layers = decode_tile(_FIXTURE.read_bytes(), layers={"building"})
|
||
vp = Viewport(45.4995, -73.5690, zoom, 53 * 2, 26 * 4)
|
||
out = _plain(render_map(vp, {(14, 4843, 5861): layers}, []))
|
||
return sum(1 for ch in out if ch not in " \n")
|
||
|
||
|
||
def test_buildings_wait_for_the_zoom_that_can_hold_them() -> None:
|
||
"""Below the gate, a footprint is approximately two dots wide, so the layer is a haze."""
|
||
from meshterm.ui.map_render import _BUILDING_MIN_ZOOM
|
||
|
||
assert _building_dots(_BUILDING_MIN_ZOOM) > 100
|
||
assert _building_dots(_BUILDING_MIN_ZOOM - 1) == 0
|
||
|
||
|
||
def test_building_fill_is_stippled_so_the_streets_survive_it() -> None:
|
||
"""A braille dot is one bit, so a solid fill would erase the street grid under it.
|
||
|
||
The stipple makes a building a shade, not an eraser. Each cell that the building
|
||
touches keeps free dots, so a road can be drawn through the cell.
|
||
"""
|
||
from meshterm.ui.map_render import _BUILDING_MIN_ZOOM, render_map
|
||
|
||
layers = decode_tile(_FIXTURE.read_bytes(), layers={"building"})
|
||
vp = Viewport(45.4995, -73.5690, _BUILDING_MIN_ZOOM, 53 * 2, 26 * 4)
|
||
out = _plain(render_map(vp, {(14, 4843, 5861): layers}, []))
|
||
|
||
assert "⣿" not in out, "a fully lit cell means the fill left a road nowhere to go"
|
||
|
||
|
||
def test_offscreen_buildings_do_not_change_the_frame() -> None:
|
||
"""The bbox reject is an optimisation, so it must not change the output.
|
||
|
||
A downtown tile has thousands of footprints, and a view at a high zoom holds a few
|
||
dozen. The renderer rejects each footprint on its own tile-local bounds before it
|
||
projects a point of the footprint. If the test adds a footprint that the view cannot
|
||
reach, the render must be identical byte for byte.
|
||
"""
|
||
from meshterm.core.mvt import Feature
|
||
from meshterm.ui.map_render import _BUILDING_MIN_ZOOM, render_map
|
||
|
||
vp = Viewport(45.4995, -73.5690, _BUILDING_MIN_ZOOM, 53 * 2, 26 * 4)
|
||
cx, cy = _tile_local_of_view_centre(vp, 14, 4843, 5861)
|
||
here = [
|
||
(cx - 40, cy - 40),
|
||
(cx + 40, cy - 40),
|
||
(cx + 40, cy + 40),
|
||
(cx - 40, cy + 40),
|
||
(cx - 40, cy - 40),
|
||
]
|
||
far = [(10, 10), (90, 10), (90, 90), (10, 90), (10, 10)]
|
||
|
||
def frame(rings):
|
||
layer = Layer(
|
||
name="building",
|
||
extent=4096,
|
||
features=[Feature(geom_type=GEOM_POLYGON, rings=rings, tags={})],
|
||
)
|
||
return render_map(vp, {(14, 4843, 5861): [layer]}, [])
|
||
|
||
assert _plain(frame([here])).strip(), "the in-view footprint should draw something"
|
||
assert frame([here]) == frame([here, far])
|
||
|
||
|
||
def test_offscreen_rings_do_not_change_the_frame() -> None:
|
||
"""Each layer skips the rings that its view cannot reach. The output does not change.
|
||
|
||
A view at a low zoom sees a thin part of many tiles. Their coastlines and land cover
|
||
have tens of thousands of points. To project each point was half of a z7 frame on the
|
||
PicoCalc. Three things must render the same, byte for byte, as their absence: a hole far
|
||
off the display in a lake that covers the view, and a road and a river that do not come
|
||
near the view. The hole is the most important, because it shares an even-odd fill with
|
||
the lake around it.
|
||
"""
|
||
from meshterm.core.mvt import Feature
|
||
from meshterm.ui.map_render import render_map
|
||
|
||
# At z16, a z14 tile is 1024 dots wide, and one dot is four tile units. The view of
|
||
# 106x104 dots spans 424x416 units around its centre. Thus a point 400 units out is
|
||
# beyond the view on each side.
|
||
vp = Viewport(45.4995, -73.5690, 16, 53 * 2, 26 * 4)
|
||
cx, cy = _tile_local_of_view_centre(vp, 14, 4843, 5861)
|
||
|
||
def square(x0, y0, x1, y1):
|
||
return [(x0, y0), (x1, y0), (x1, y1), (x0, y1), (x0, y0)]
|
||
|
||
lake = square(cx - 600, cy - 600, cx + 600, cy + 600)
|
||
island = square(cx - 20, cy - 20, cx + 20, cy + 20) # a hole that the view shows
|
||
far_hole = square(cx - 590, cy - 590, cx - 500, cy - 500)
|
||
far_road = [(cx - 590, cy + 300), (cx - 400, cy + 590)]
|
||
far_river = [(cx + 450, cy - 590), (cx + 590, cy + 590)]
|
||
|
||
def frame(water_rings, roads, rivers):
|
||
layers = [
|
||
Layer("water", 4096, [Feature(GEOM_POLYGON, water_rings, {})]),
|
||
Layer("transportation", 4096, [Feature(GEOM_LINE, roads, {"class": "primary"})]),
|
||
Layer("waterway", 4096, [Feature(GEOM_LINE, rivers, {"class": "river"})]),
|
||
]
|
||
return render_map(vp, {(14, 4843, 5861): layers}, [])
|
||
|
||
near_road = [(cx - 300, cy), (cx + 300, cy)]
|
||
near_river = [(cx, cy - 300), (cx, cy + 300)]
|
||
bare = frame([lake, island], [near_road], [near_river])
|
||
assert _plain(bare).strip(), "the lake, the road and the river should all draw"
|
||
assert bare == frame([lake, island, far_hole], [near_road, far_road], [near_river, far_river])
|
||
|
||
|
||
def test_polygon_fill_lights_exactly_the_dots_a_scan_of_every_edge_would() -> None:
|
||
"""The renderer files each edge under the rows that it crosses. This must not move a dot.
|
||
|
||
The filing is a speed-up. The reference is the old fill, where each scanline asks each
|
||
edge if the edge crosses the line. This costs rows times edges. A coastline at z7 made
|
||
this 6.8 s of a frame on the PicoCalc. The answer of the old fill is the specification.
|
||
"""
|
||
import random
|
||
from itertools import pairwise
|
||
|
||
def reference(canvas: MapCanvas, rings, stipple: int) -> None:
|
||
edges = [
|
||
(x0, y0, x1, y1) for ring in rings for (x0, y0), (x1, y1) in pairwise(ring) if y0 != y1
|
||
]
|
||
for y in range(0, canvas.dot_h, stipple):
|
||
yc = y + 0.5
|
||
xs = sorted(
|
||
x0 + (yc - y0) * (x1 - x0) / (y1 - y0)
|
||
for x0, y0, x1, y1 in edges
|
||
if y0 <= yc < y1 or y1 <= yc < y0
|
||
)
|
||
for i in range(0, len(xs) - 1, 2):
|
||
lo = max(0, int(round(xs[i])))
|
||
hi = min(canvas.dot_w - 1, int(round(xs[i + 1])))
|
||
if stipple > 1:
|
||
lo += -lo % stipple
|
||
for x in range(lo, hi + 1, stipple):
|
||
canvas.plot(x, y, (1, 2, 3), 5)
|
||
|
||
def coordinate(rnd: random.Random) -> float:
|
||
# Most points are anywhere, also far off the canvas. Some points are exactly on the
|
||
# middle of a row or on the edge of a dot. An off-by-one error shows there.
|
||
if rnd.random() < 0.5:
|
||
return rnd.uniform(-30.0, 110.0)
|
||
return rnd.randint(-5, 90) + rnd.choice((0.0, 0.5, -0.5))
|
||
|
||
rnd = random.Random(7)
|
||
for _ in range(500):
|
||
w, h = rnd.randint(1, 40), rnd.randint(1, 20)
|
||
rings = []
|
||
for _ in range(rnd.randint(1, 3)):
|
||
ring = [(coordinate(rnd), coordinate(rnd)) for _ in range(rnd.randint(2, 10))]
|
||
rings.append(ring + ring[:1] if rnd.random() < 0.7 else ring)
|
||
stipple = rnd.choice((1, 1, 2, 3))
|
||
fast, slow = MapCanvas(w, h), MapCanvas(w, h)
|
||
fast.fill_polygon(rings, (1, 2, 3), 5, stipple=stipple)
|
||
reference(slow, rings, stipple)
|
||
assert fast._bits == slow._bits, (w, h, stipple, rings)
|
||
|
||
|
||
# -- projection / viewport ----------------------------------------------------
|
||
|
||
|
||
def test_world_projection_round_trips() -> None:
|
||
"""A conversion from lon/lat to world pixels and back gives the original coordinate."""
|
||
for lat, lon, z in [(45.5, -73.6, 14), (0.0, 0.0, 3), (-33.9, 151.2, 12)]:
|
||
wx, wy = lonlat_to_world(lat, lon, z)
|
||
rlat, rlon = world_to_lonlat(wx, wy, z)
|
||
assert rlat == pytest.approx(lat, abs=1e-6)
|
||
assert rlon == pytest.approx(lon, abs=1e-6)
|
||
|
||
|
||
def test_haversine_known_distance() -> None:
|
||
"""One degree of longitude at the equator is approximately 111 km. Identical points are 0."""
|
||
assert haversine_km(0.0, 0.0, 0.0, 1.0) == pytest.approx(111.19, abs=0.5)
|
||
assert haversine_km(45.0, -73.0, 45.0, -73.0) == 0.0
|
||
|
||
|
||
def test_viewport_center_projects_to_the_middle() -> None:
|
||
"""The centre of the viewport is at the middle dot, and north is up."""
|
||
vp = Viewport(45.5, -73.6, 13, 200, 120)
|
||
cx, cy = vp.lonlat_to_dot(45.5, -73.6)
|
||
assert cx == pytest.approx(100, abs=0.5) and cy == pytest.approx(60, abs=0.5)
|
||
# A point north of the centre projects above it (smaller y).
|
||
_, ny = vp.lonlat_to_dot(45.6, -73.6)
|
||
assert ny < cy
|
||
|
||
|
||
def test_viewport_fit_frames_all_points() -> None:
|
||
"""A fitted viewport has its centre on the points, and it has a zoom that shows them all."""
|
||
pts = [(45.5019, -73.5674), (45.4768, -73.5990)]
|
||
vp = Viewport.fit(pts, 200, 120, max_zoom=14)
|
||
assert 8 <= vp.zoom <= 14
|
||
for lat, lon in pts:
|
||
x, y = vp.lonlat_to_dot(lat, lon)
|
||
assert 0 <= x < vp.dot_w and 0 <= y < vp.dot_h # each point is on the canvas
|
||
|
||
|
||
def test_viewport_fit_fraction_ignores_outliers() -> None:
|
||
"""If the view frames half the nodes, it zooms to the dense core. Far outliers fall off."""
|
||
# A tight downtown group of five nodes (approximately 50 m wide) and one distant outlier.
|
||
core = [
|
||
(45.5000, -73.5600),
|
||
(45.5003, -73.5602),
|
||
(45.4998, -73.5598),
|
||
(45.5001, -73.5599),
|
||
(45.4999, -73.5601),
|
||
]
|
||
outlier = (46.80, -71.20)
|
||
pts = core + [outlier]
|
||
|
||
full = Viewport.fit(pts, 200, 120, max_zoom=16)
|
||
half = Viewport.fit(pts, 200, 120, max_zoom=16, fraction=0.5)
|
||
|
||
# The frame of the core only has a higher zoom than the frame that must hold the outlier.
|
||
assert half.zoom > full.zoom
|
||
# Each node of the group stays on the canvas. The outlier is off the canvas.
|
||
for lat, lon in core:
|
||
x, y = half.lonlat_to_dot(lat, lon)
|
||
assert 0 <= x < half.dot_w and 0 <= y < half.dot_h
|
||
ox, oy = half.lonlat_to_dot(*outlier)
|
||
assert not (0 <= ox < half.dot_w and 0 <= oy < half.dot_h)
|
||
|
||
|
||
def test_viewport_fit_fraction_keeps_small_sets_whole() -> None:
|
||
"""With only two nodes, there is no core to isolate. The view frames both nodes."""
|
||
pts = [(45.5019, -73.5674), (45.4768, -73.5990)]
|
||
half = Viewport.fit(pts, 200, 120, max_zoom=14, fraction=0.5)
|
||
full = Viewport.fit(pts, 200, 120, max_zoom=14)
|
||
assert (half.center_lat, half.center_lon, half.zoom) == (
|
||
full.center_lat,
|
||
full.center_lon,
|
||
full.zoom,
|
||
)
|
||
|
||
|
||
def test_viewport_zoom_and_pan() -> None:
|
||
"""Zooming and panning return new viewports. The zoom stays between its limits."""
|
||
vp = Viewport(45.5, -73.6, 10, 200, 120)
|
||
assert vp.zoomed(2).zoom == 12
|
||
assert vp.zoomed(50, max_zoom=16).zoom == 16 # clamped
|
||
assert vp.zoomed(-50, min_zoom=3).zoom == 3
|
||
east = vp.panned(0.5, 0)
|
||
assert east.center_lon > vp.center_lon # a pan to the east increases the longitude
|
||
north = vp.panned(0, -0.5)
|
||
assert north.center_lat > vp.center_lat # a pan to the north increases the latitude
|
||
|
||
|
||
def test_viewport_tiles_cover_the_view() -> None:
|
||
"""The viewport reports the few tiles that overlap it at the tile zoom."""
|
||
vp = Viewport(45.5019, -73.5674, 14, 200, 120)
|
||
tiles = vp.tiles(14)
|
||
assert tiles and all(z == 14 for z, _, _ in tiles)
|
||
# The fixture tile for this centre must be one of them.
|
||
assert (14, 4843, 5861) in tiles
|
||
|
||
|
||
# -- braille canvas -----------------------------------------------------------
|
||
|
||
|
||
def test_canvas_plots_dots_as_braille() -> None:
|
||
"""A plotted dot renders as a braille glyph in the top-left cell."""
|
||
canvas = MapCanvas(4, 2)
|
||
canvas.plot(0, 0, (255, 0, 0), 1)
|
||
out = _plain(canvas.to_ansi_lines())
|
||
assert out.splitlines()[0][0] == "⠁" # braille dot-1
|
||
|
||
|
||
def test_canvas_priority_decides_cell_colour() -> None:
|
||
"""If dots from two features share a cell, the higher priority sets the colour of the cell."""
|
||
canvas = MapCanvas(1, 1)
|
||
canvas.plot(0, 0, (10, 20, 30), 1) # low priority
|
||
canvas.plot(1, 0, (200, 100, 50), 9) # high priority, same cell
|
||
ansi = "".join(canvas.to_ansi_lines())
|
||
assert "38;2;200;100;50" in ansi # the colour of the high priority wins
|
||
assert "38;2;10;20;30" not in ansi
|
||
|
||
|
||
def test_canvas_marker_and_label_do_not_overprint() -> None:
|
||
"""A marker keeps its glyph and puts its label in a cell next to it."""
|
||
canvas = MapCanvas(20, 3)
|
||
canvas.marker(4, 4, "★", (255, 255, 255))
|
||
assert canvas.marker_label(4, 4, "me", (255, 255, 255))
|
||
out = _plain(canvas.to_ansi_lines())
|
||
assert "★" in out and "me" in out
|
||
row = out.splitlines()[1]
|
||
assert "★me" not in row # a gap is between the marker and its label
|
||
|
||
|
||
def test_canvas_label_collision_is_avoided() -> None:
|
||
"""The canvas skips a checked label if the label overlaps a label that the canvas placed."""
|
||
canvas = MapCanvas(20, 1)
|
||
assert canvas.place_label(10, 0, "First", (200, 200, 200))
|
||
assert not canvas.place_label(10, 0, "Second", (200, 200, 200)) # it overlaps: skipped
|
||
|
||
|
||
def test_canvas_labels_keep_a_vertical_gap() -> None:
|
||
"""The canvas skips a checked label if the label is directly above or below existing text."""
|
||
canvas = MapCanvas(20, 3)
|
||
assert canvas.place_label(20, 4, "Row1", (200, 200, 200)) # dot y=4, cell row 1
|
||
# Directly below (cell row 2) with columns that overlap: the canvas rejects it, because
|
||
# there is no gap.
|
||
assert not canvas.place_label(20, 8, "Row2", (200, 200, 200))
|
||
# In the same row, but away from the horizontal span, a label fits.
|
||
assert canvas.place_label(2, 4, "Far", (200, 200, 200))
|
||
|
||
|
||
def _dot_colors(lines: list[str]) -> set[tuple[int, int, int]]:
|
||
"""Each truecolour of a braille run (the colours of the ground, not of the text)."""
|
||
return {
|
||
(int(r), int(g), int(b))
|
||
for r, g, b in re.findall(r"38;2;(\d+);(\d+);(\d+)m(?:\x1b\[1m)?[⠀-⣿]", "".join(lines))
|
||
}
|
||
|
||
|
||
def test_canvas_paste_raster_offsets_fades_and_yields_the_cell() -> None:
|
||
"""A pasted raster goes where the index tables of the caller put it, dimmed, underneath.
|
||
|
||
``-1`` is a cell with no source: the ground that a pan just moved onto. The pasted cell
|
||
keeps the empty-cell priority, so anything drawn afterwards wins the cell.
|
||
"""
|
||
from meshterm.ui.mapcanvas import MapCanvas
|
||
|
||
src = MapCanvas(3, 1)
|
||
src.plot(0, 0, (200, 100, 50), 5) # cell 0
|
||
src.plot(4, 0, (60, 120, 240), 5) # cell 2
|
||
|
||
canvas = MapCanvas(3, 1)
|
||
canvas.paste_raster(src.raster(), [(-1, 0), (0, 0), (2, 0)], [(0, 0)], fade=0.5)
|
||
out = "".join(canvas.to_ansi_lines())
|
||
assert _plain([out]) == " ⠁⠁" # shifted one cell right, and the gap has no source
|
||
assert _dot_colors([out]) == {(100, 50, 25), (30, 60, 120)}
|
||
|
||
canvas.plot(2, 0, (10, 20, 30), 0) # a real feature that the test draws after takes the cell
|
||
assert _dot_colors(["".join(canvas.to_ansi_lines())]) >= {(10, 20, 30)}
|
||
|
||
|
||
def test_canvas_paste_raster_magnifies_by_the_dot_not_by_the_glyph() -> None:
|
||
"""A stand-in at a higher zoom shows each cell its own part of the source, enlarged.
|
||
|
||
If the renderer stamps the whole source glyph into each cell of the block that it grew
|
||
into, the result is double vision. The same 2x4 pattern shows side by side and one
|
||
above the other. This reads as a fault, not as a coarse preview (JP, 2026-08-18).
|
||
"""
|
||
from meshterm.ui.mapcanvas import MapCanvas
|
||
|
||
src = MapCanvas(1, 1)
|
||
src.plot(0, 0, (200, 100, 50), 5) # the top-left dot of the cell, and nothing else
|
||
|
||
canvas = MapCanvas(2, 2)
|
||
cols = [(0, 0), (0, 1)]
|
||
canvas.paste_raster(src.raster(), cols, cols, magnify=2)
|
||
rendered = [_plain([line]) for line in canvas.to_ansi_lines()]
|
||
|
||
# A source cell is 2x4 dots. At 2x, it becomes the block of 2x2 cells that is drawn
|
||
# here. Its one top-left dot becomes a block of 2x2 dots: the top half of the top-left
|
||
# cell only. The bug is the glyph repeated four times.
|
||
assert rendered == ["⠛ ", " "], rendered
|
||
|
||
|
||
def test_parse_hex() -> None:
|
||
"""Hex colours parse to RGB triples, with or without the hash at the start."""
|
||
assert parse_hex("#38bdf8") == (0x38, 0xBD, 0xF8)
|
||
assert parse_hex("ffffff") == (255, 255, 255)
|
||
|
||
|
||
# -- compositor over a real tile ----------------------------------------------
|
||
|
||
|
||
def test_render_map_draws_basemap_streets_and_labels() -> None:
|
||
"""A render of the real fixture gives braille geometry and a place label."""
|
||
from meshterm.ui.map_render import MapMarker, render_map
|
||
|
||
vp = Viewport(45.5019, -73.5674, 14, 180, 120)
|
||
layers = decode_tile(_FIXTURE.read_bytes())
|
||
tiles = {(14, 4843, 5861): layers}
|
||
markers = [MapMarker("Yagi", 45.5019, -73.5674, is_repeater=True)]
|
||
out = _plain(render_map(vp, tiles, markers))
|
||
assert any(0x2800 <= ord(ch) <= 0x28FF for ch in out) # the render has braille
|
||
assert "▲" in out and "Yagi" in out # the repeater marker and its label
|
||
assert "Montréal" in out # a place label from the tile
|
||
|
||
|
||
def test_render_map_prioritises_repeater_glyph() -> None:
|
||
"""A repeater and a node at the same place show the marker of the repeater on top."""
|
||
from meshterm.ui.map_render import MapMarker, render_map
|
||
|
||
vp = Viewport(45.50, -73.57, 14, 60, 40)
|
||
markers = [
|
||
MapMarker("node", 45.50, -73.57),
|
||
MapMarker("rptr", 45.50, -73.57, is_repeater=True),
|
||
]
|
||
out = _plain(render_map(vp, {}, markers))
|
||
assert "▲" in out and "●" not in out
|
||
|
||
|
||
def _street_labels_placed(cols: int, rows: int, zoom: int) -> int:
|
||
"""The number of street names that reach the canvas at this size and zoom."""
|
||
from meshterm.ui.map_render import DRAWN_LAYERS, _draw_tile, _Frame
|
||
from meshterm.ui.mapcanvas import MapCanvas
|
||
|
||
layers = decode_tile(_FIXTURE.read_bytes(), layers=DRAWN_LAYERS)
|
||
vp = Viewport(45.5019, -73.5674, zoom, cols * 2, (rows - 4) * 4)
|
||
canvas = MapCanvas(vp.dot_w // 2, vp.dot_h // 4)
|
||
frame = _Frame(canvas=canvas, viewport=vp)
|
||
_draw_tile(frame, layers, 14, 4843, 5861)
|
||
|
||
placed = 0
|
||
for label in sorted(frame.labels, key=lambda lab: lab.rank):
|
||
if vp.zoom < label.min_zoom:
|
||
continue
|
||
for ax, ay in ((label.x, label.y), *label.alts):
|
||
if canvas.place_label(ax, ay, label.text, label.color, bold=label.bold):
|
||
placed += label.min_zoom == 15 # the street-label gate identifies street names
|
||
break
|
||
return placed
|
||
|
||
|
||
def test_street_labels_get_denser_as_you_zoom_in() -> None:
|
||
"""If the user zooms in to a street, its name must not become less likely to appear.
|
||
|
||
The renderer once anchored a label to the midpoint of the feature. This fixed the label
|
||
to one geographic point. Thus at a higher zoom, that point was less often on the
|
||
display. The 265 named streets of the fixture gave 2 labels at zoom 16 and 10 at zoom 15.
|
||
"""
|
||
assert _street_labels_placed(53, 26, 16) >= 5
|
||
assert _street_labels_placed(53, 26, 15) >= 10
|
||
# A wider terminal shows more of the same ground, so it can name more streets, but not fewer.
|
||
assert _street_labels_placed(72, 24, 16) >= _street_labels_placed(53, 26, 16)
|
||
|
||
|
||
def test_street_labels_are_not_built_below_the_zoom_that_can_place_them() -> None:
|
||
"""The label is the expensive part of a named road, and below the gate the app discards it.
|
||
|
||
``_compose`` never places a label below its own ``min_zoom``. But ``_draw_tile`` once
|
||
built the label anyway. It clipped the line segment by segment, measured each piece
|
||
that stayed, and sorted the pieces, 270 times for a central tile. This took 16.8 ms of
|
||
a 46 ms frame on the desktop. On the PicoCalc it took more than a third of a second, at
|
||
the two zooms where the map most often is.
|
||
|
||
The gate must not change the picture. What the gate removes is what the app threw away
|
||
a moment later, so the frame on each side of the gate is the same frame.
|
||
"""
|
||
from meshterm.ui.map_render import (
|
||
_STREET_LABEL_MIN_ZOOM,
|
||
DRAWN_LAYERS,
|
||
MapMarker,
|
||
render_ground,
|
||
)
|
||
|
||
layers = decode_tile(_FIXTURE.read_bytes(), layers=DRAWN_LAYERS)
|
||
tiles = {(14, 4843, 5861): layers}
|
||
markers = [MapMarker("Yagi", 45.5019, -73.5674)]
|
||
below = _STREET_LABEL_MIN_ZOOM - 1
|
||
|
||
assert _street_labels_queued(below, tiles) == 0, "street labels built below the gate"
|
||
assert _street_labels_queued(_STREET_LABEL_MIN_ZOOM, tiles) > 0, (
|
||
"the gate swallowed the labels at the zoom that wants them"
|
||
)
|
||
# Overzoom: the display is past the maximum zoom of the source, so the ground comes from
|
||
# a z14 tile. But the user is still zoomed in, and still wants the names.
|
||
assert _street_labels_queued(_STREET_LABEL_MIN_ZOOM + 2, tiles) > 0, (
|
||
"an overzoomed view lost its street names to the tile's zoom"
|
||
)
|
||
|
||
# Also, the picture does not change on either side of the gate, with the coarse pass.
|
||
for zoom in (below - 2, below, _STREET_LABEL_MIN_ZOOM, _STREET_LABEL_MIN_ZOOM + 2):
|
||
vp = Viewport(45.5019, -73.5674, zoom, 53 * 2, 26 * 4)
|
||
for coarse in (False, True):
|
||
lines, ghost = render_ground(vp, tiles, markers, coarse=coarse)
|
||
assert lines, f"nothing drawn at z{zoom}"
|
||
# The ghost is the braille layer only. A label did not reach it before.
|
||
assert ghost.raster.cell_w == vp.dot_w // 2
|
||
|
||
|
||
def _street_labels_queued(zoom: int, tiles) -> int:
|
||
"""The number of street-name candidates that a frame at ``zoom`` builds before it places any."""
|
||
from meshterm.ui.map_render import _STREET_LABEL, _draw_tile, _Frame
|
||
from meshterm.ui.mapcanvas import MapCanvas
|
||
|
||
vp = Viewport(45.5019, -73.5674, zoom, 53 * 2, 26 * 4)
|
||
frame = _Frame(canvas=MapCanvas(vp.dot_w // 2, vp.dot_h // 4), viewport=vp)
|
||
for (z, x, y), layers in tiles.items():
|
||
_draw_tile(frame, layers, z, x, y)
|
||
return sum(1 for label in frame.labels if label.rank == _STREET_LABEL[2])
|
||
|
||
|
||
def _tile_local_of_view_centre(vp: Viewport, tz: int, tx: int, ty: int, extent: int = 4096):
|
||
"""The tile-local point at the middle of this viewport (the inverse of feature_to_dot)."""
|
||
from meshterm.core.geo import TILE_PX
|
||
|
||
ox, oy = vp.origin_world
|
||
scale = 2.0 ** (vp.zoom - tz)
|
||
lx = extent * ((vp.dot_w / 2 + ox) / (TILE_PX * scale) - tx)
|
||
ly = extent * ((vp.dot_h / 2 + oy) / (TILE_PX * scale) - ty)
|
||
return lx, ly
|
||
|
||
|
||
def test_street_name_is_drawn_only_once() -> None:
|
||
"""OSM splits a long street into several features, but the map names the street once."""
|
||
from meshterm.ui.map_render import DRAWN_LAYERS, MapMarker, render_map
|
||
|
||
layers = decode_tile(_FIXTURE.read_bytes(), layers=DRAWN_LAYERS)
|
||
vp = Viewport(45.5019, -73.5674, 16, 53 * 2, 22 * 4)
|
||
out = _plain(
|
||
render_map(
|
||
vp, {(14, 4843, 5861): layers}, [MapMarker("Hub", 45.5040, -73.5700, is_repeater=True)]
|
||
)
|
||
)
|
||
|
||
# René-Lévesque arrives as several segments. The map once drew it two times on one screen.
|
||
assert out.count("René-Lévesque") <= 1, "a street was named more than once"
|
||
|
||
|
||
def test_line_label_anchors_on_the_visible_stretch() -> None:
|
||
"""A street that crosses the view gets a name, also when both its ends are off the display."""
|
||
from meshterm.ui.map_render import _add_line_label, _Frame
|
||
from meshterm.ui.mapcanvas import MapCanvas
|
||
|
||
vp = Viewport(45.5019, -73.5674, 14, 120, 80)
|
||
frame = _Frame(canvas=MapCanvas(60, 20), viewport=vp)
|
||
_, cy = _tile_local_of_view_centre(vp, 14, 4843, 5861)
|
||
# A line across the whole tile at the latitude of the view. Its midpoint is far away, but
|
||
# it crosses the canvas, so the clip must find it.
|
||
_add_line_label(
|
||
frame, [[(0, cy), (4096, cy)]], 4096, 14, 4843, 5861, "Rue Long", ("#9f9f9f", False, 7)
|
||
)
|
||
|
||
assert len(frame.labels) == 1
|
||
label = frame.labels[0]
|
||
assert 0 <= label.x <= vp.dot_w and 0 <= label.y <= vp.dot_h
|
||
|
||
|
||
def test_line_label_off_screen_is_not_queued() -> None:
|
||
"""A feature with nothing on the display uses no label slot."""
|
||
from meshterm.ui.map_render import _add_line_label, _Frame
|
||
from meshterm.ui.mapcanvas import MapCanvas
|
||
|
||
vp = Viewport(45.5019, -73.5674, 16, 120, 80)
|
||
frame = _Frame(canvas=MapCanvas(60, 20), viewport=vp)
|
||
# A short line in the far corner of the tile, far outside a window at zoom 16.
|
||
_add_line_label(
|
||
frame, [[(0, 0), (8, 8)]], 4096, 14, 4843, 5861, "Nowhere", ("#9f9f9f", False, 7)
|
||
)
|
||
|
||
assert frame.labels == []
|
||
|
||
|
||
def test_line_label_offers_alternates_along_the_visible_run() -> None:
|
||
"""If the first choice is crowded, the label moves along the street. It does not disappear."""
|
||
from meshterm.ui.map_render import _add_line_label, _Frame
|
||
from meshterm.ui.mapcanvas import MapCanvas
|
||
|
||
vp = Viewport(45.5019, -73.5674, 14, 120, 80)
|
||
frame = _Frame(canvas=MapCanvas(60, 20), viewport=vp)
|
||
cx, cy = _tile_local_of_view_centre(vp, 14, 4843, 5861)
|
||
# A zig-zag across the view gives several visible pieces to choose from.
|
||
ring = [(cx - 120 + i * 40, cy - 40 + (i % 2) * 80) for i in range(8)]
|
||
_add_line_label(frame, [ring], 4096, 14, 4843, 5861, "Rue Zig", ("#9f9f9f", False, 7))
|
||
|
||
assert frame.labels and frame.labels[0].alts, "no fallback anchors offered"
|
||
assert all(a != (frame.labels[0].x, frame.labels[0].y) for a in frame.labels[0].alts)
|
||
|
||
|
||
def test_render_map_drops_crowded_labels_favouring_repeaters() -> None:
|
||
"""If the labels do not all fit, the label of the repeater wins and a crowded node loses."""
|
||
from meshterm.ui.map_render import MapMarker, render_map
|
||
|
||
# Three nodes at one place: only the two sides (left and right) can hold a label. Thus
|
||
# one of the three must show as a bare marker, and it must not be the repeater.
|
||
vp = Viewport(45.50, -73.57, 14, 40, 8)
|
||
markers = [
|
||
MapMarker("NODEONE", 45.50, -73.57),
|
||
MapMarker("NODETWO", 45.50, -73.57),
|
||
MapMarker("REPEATER", 45.50, -73.57, is_repeater=True),
|
||
]
|
||
out = _plain(render_map(vp, {}, markers))
|
||
assert "▲" in out # the glyph of the repeater is on top
|
||
assert "REPEATER" in out # and it keeps its label (placed first)
|
||
# Only one of the two other nodes can have a label. The other is a bare marker.
|
||
assert ("NODEONE" in out) != ("NODETWO" in out)
|
||
|
||
|
||
def _glyph_color(lines: list[str], glyph: str) -> tuple[int, int, int]:
|
||
"""Get the truecolour ``(r, g, b)`` of the render of the given glyph."""
|
||
m = re.search(r"38;2;(\d+);(\d+);(\d+)m(?:\x1b\[1m)?" + re.escape(glyph), "".join(lines))
|
||
assert m, f"glyph {glyph!r} not found with a colour"
|
||
return int(m.group(1)), int(m.group(2)), int(m.group(3))
|
||
|
||
|
||
def test_render_map_draws_a_crowd_as_one_white_marker() -> None:
|
||
"""A marker is white, and a crowd in one cell draws the same as one node.
|
||
|
||
A crowd once made its glyph brighter. Each marker is white now, so JP removed that
|
||
signal (2026-10-14).
|
||
"""
|
||
from meshterm.ui.map_render import MapMarker, render_map
|
||
from meshterm.ui.marks import NODE_MARK
|
||
from meshterm.ui.theme import mark_rgb
|
||
|
||
vp = Viewport(45.50, -73.57, 14, 60, 40)
|
||
|
||
lone = render_map(vp, {}, [MapMarker("solo", 45.50, -73.57)])
|
||
assert _glyph_color(lone, "●") == mark_rgb(NODE_MARK[1]) == (255, 255, 255)
|
||
|
||
crowd = [MapMarker(f"n{i}", 45.50, -73.57) for i in range(8)]
|
||
assert _glyph_color(render_map(vp, {}, crowd), "●") == (255, 255, 255)
|
||
|
||
|
||
def test_render_map_works_without_basemap() -> None:
|
||
"""With no tiles, the nodes still render on a blank grid (the offline fallback)."""
|
||
from meshterm.ui.map_render import MapMarker, render_map
|
||
|
||
vp = Viewport.fit([(45.5, -73.6), (45.4, -73.5)], 120, 80, max_zoom=14)
|
||
markers = [
|
||
MapMarker("A", 45.5, -73.6, is_self=True),
|
||
MapMarker("B", 45.4, -73.5, is_repeater=True),
|
||
]
|
||
out = _plain(render_map(vp, {}, markers))
|
||
assert "★" in out and "▲" in out
|
||
|
||
|
||
# -- the ghost ground ---------------------------------------------------------
|
||
|
||
|
||
def _dot_rows(lines: list[str]) -> list[str]:
|
||
"""Each rendered row, with only its braille dots (labels and markers are blank)."""
|
||
return [
|
||
"".join(ch if 0x2800 <= ord(ch) <= 0x28FF else " " for ch in row)
|
||
for row in _plain(lines).split("\n")
|
||
]
|
||
|
||
|
||
def _ghosted(zoom: int = 14, *, dlon: float = 0.0, dlat: float = 0.0, dz: int = 0):
|
||
"""Draw the fixture, then paint a moved view that uses the ground of that frame."""
|
||
from meshterm.ui.map_render import MapMarker, render_ground, render_map
|
||
|
||
vp = Viewport(45.5019, -73.5674, zoom, 106, 104)
|
||
tiles = {(14, 4843, 5861): decode_tile(_FIXTURE.read_bytes())}
|
||
markers = [MapMarker("Yagi", 45.5019, -73.5674, is_repeater=True)]
|
||
lines, ghost = render_ground(vp, tiles, markers)
|
||
moved = Viewport(vp.center_lat + dlat, vp.center_lon + dlon, zoom + dz, 106, 104)
|
||
return lines, render_map(moved, {}, markers, ghost=ghost)
|
||
|
||
|
||
def test_a_panned_view_stands_on_the_ground_it_just_had() -> None:
|
||
"""A pan slides the streets of the last frame across. It does not blank the map to black.
|
||
|
||
The rasterizing takes too long to run on a key press, so the paint that answers a pan
|
||
has no ground of its own. Without the ghost, the app drew the markers on an empty
|
||
canvas. The map went black between each step and flashed back when the frame arrived.
|
||
"""
|
||
from meshterm.ui.map_render import MapMarker, render_map
|
||
|
||
vp = Viewport(45.5019, -73.5674, 14, 106, 104)
|
||
markers = [MapMarker("Yagi", 45.5019, -73.5674, is_repeater=True)]
|
||
bare = sum(1 for ch in _plain(render_map(vp, {}, markers)) if ch.strip())
|
||
|
||
drawn, moved = _ghosted(dlon=0.004)
|
||
lit = sum(1 for ch in _plain(moved) if ch.strip())
|
||
assert lit > bare * 10, "the panned paint is as empty as one with no ghost at all"
|
||
assert lit < sum(1 for ch in _plain(drawn) if ch.strip()), "nothing was left behind"
|
||
|
||
|
||
def test_the_ghost_ground_lands_where_the_pan_put_it() -> None:
|
||
"""The reused dots move by the same number of cells as the view, within one cell."""
|
||
drawn, moved = _ghosted(dlon=0.004)
|
||
# 0.004° of longitude at zoom 14: dots per degree = 256 * 2^14 / 360.
|
||
shift = round(0.004 * 256 * (2**14) / 360 / 2) # in cells (2 dots wide)
|
||
|
||
before, after = _dot_rows(drawn), _dot_rows(moved)
|
||
assert len(before) == len(after)
|
||
matched = sum(
|
||
1
|
||
for old, new in zip(before, after, strict=True)
|
||
if old[shift:].rstrip() and new.rstrip() == old[shift:].rstrip()
|
||
)
|
||
assert matched > len(before) // 2, "the ground did not slide with the view"
|
||
|
||
|
||
def test_the_ghost_ground_carries_no_stale_text() -> None:
|
||
"""Only the dots move with the ghost. A label on old ground would name the wrong place."""
|
||
drawn, moved = _ghosted(dlon=0.004)
|
||
assert "Montréal" in _plain(drawn)
|
||
assert "Montréal" not in _plain(moved)
|
||
assert "Yagi" in _plain(moved) # the markers are drawn again where they are now
|
||
|
||
|
||
def test_the_ghost_ground_dims_while_its_replacement_is_drawn() -> None:
|
||
"""Reused ground reads as provisional: it is old, and it does not reach the leading edge."""
|
||
from meshterm.ui.map_render import _GHOST_FADE
|
||
|
||
assert 0 < _GHOST_FADE < 1 # the desktop default. The 16-slot console sets it to 1.0
|
||
drawn, moved = _ghosted(dlon=0.004)
|
||
faded = {tuple(round(c * _GHOST_FADE) for c in rgb) for rgb in _dot_colors(drawn)}
|
||
ghost = _dot_colors(moved)
|
||
assert ghost and ghost <= faded, "the reused ground is not the ground we drew"
|
||
assert not ghost & _dot_colors(drawn), "it came through at full strength"
|
||
|
||
|
||
def test_the_ghost_ground_is_dropped_once_it_says_nothing() -> None:
|
||
"""A view that has left the old frame gets an empty canvas, because it has no ground."""
|
||
_, far = _ghosted(dlon=4.0) # panned far from the ground that the app had
|
||
assert not any(row.strip() for row in _dot_rows(far))
|
||
_, deep = _ghosted(dz=3) # zoomed past what a stand-in of coarse cells can show
|
||
assert not any(row.strip() for row in _dot_rows(deep))
|
||
_, near = _ghosted(dz=1) # one step is still a preview that the user can read
|
||
assert any(row.strip() for row in _dot_rows(near))
|
||
|
||
|
||
# -- basemap source (offline behaviour) ---------------------------------------
|
||
|
||
|
||
def test_basemap_user_agent_is_contactable() -> None:
|
||
"""The tile user agent names a repository that exists and the version that runs.
|
||
|
||
OpenFreeMap issues no API key. Thus this string is all that a tile operator has to tell
|
||
one client from another, and to contact the client that uses their bandwidth. It once
|
||
read ``MeshTerm/0.1 (+https://github.com/; mesh node map)``. Nobody can visit that
|
||
host, and the version number was already wrong. Traffic of this type gets a project
|
||
blocked, not contacted by email.
|
||
"""
|
||
from meshterm import __version__
|
||
from meshterm.services.basemap import _USER_AGENT
|
||
|
||
assert _USER_AGENT == (
|
||
f"MeshTerm/{__version__} (+https://github.com/jpmartineau/MeshTerm; mesh node map)"
|
||
)
|
||
assert "https://github.com/;" not in _USER_AGENT, "the stub URL is back"
|
||
assert "mesh node map" in _USER_AGENT, "the operator can no longer tell what the traffic is"
|
||
|
||
|
||
def test_basemap_sends_its_user_agent_on_every_fetch(tmp_path: Path) -> None:
|
||
"""The string is not decoration. It is in the header of the request that leaves."""
|
||
from meshterm.services import basemap as basemap_mod
|
||
|
||
src = _offline_source(tmp_path / "cache")
|
||
seen: list[urllib.request.Request] = []
|
||
|
||
def _urlopen(req: urllib.request.Request, timeout: float | None = None, context=None):
|
||
seen.append(req)
|
||
raise urllib.error.URLError("no network in tests")
|
||
|
||
original = urllib.request.urlopen
|
||
urllib.request.urlopen = _urlopen # type: ignore[assignment]
|
||
try:
|
||
assert src._http_get("http://tiles.invalid/x") == basemap_mod._Response(False, b"")
|
||
finally:
|
||
urllib.request.urlopen = original # type: ignore[assignment]
|
||
|
||
assert seen, "no request was ever built"
|
||
assert seen[0].get_header("User-agent") == basemap_mod._USER_AGENT
|
||
|
||
|
||
def test_basemap_keeps_the_default_tls_context_when_it_works() -> None:
|
||
"""A platform trust store that works stays as it is. The app passes the default of urllib."""
|
||
from meshterm.services import basemap as basemap_mod
|
||
|
||
basemap_mod._tls_context.cache_clear()
|
||
try:
|
||
assert basemap_mod._tls_context() is None
|
||
finally:
|
||
basemap_mod._tls_context.cache_clear()
|
||
|
||
|
||
def test_basemap_finds_a_ca_bundle_when_the_default_store_is_empty(
|
||
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
|
||
) -> None:
|
||
"""If the default store is empty, the app uses the OS CA bundle. It does not fail each fetch.
|
||
|
||
This is the macOS bug. A frozen build asks OpenSSL for a trust store at a file path that
|
||
is fixed in the build of the interpreter. That path can be absent on a machine that
|
||
never installed that Python, and a PyInstaller bundle goes to such machines. Then the
|
||
fetches failed with ``CERTIFICATE_VERIFY_FAILED``. A refused fetch looks the same as
|
||
empty terrain, so the map drew no ground under the nodes and gave no message. Windows
|
||
hid the bug, because its default context reads the OS certificate store.
|
||
"""
|
||
import ssl
|
||
|
||
from meshterm.services import basemap as basemap_mod
|
||
|
||
# The file only has to exist. The test records the load below and does not parse the
|
||
# file. Thus the test checks which path the app chooses, and it does not need a PEM file.
|
||
bundle = tmp_path / "cert.pem"
|
||
bundle.write_text("# stand-in for the OS bundle\n", encoding="utf-8")
|
||
|
||
loaded: list[str] = []
|
||
|
||
class _EmptyStore(ssl.SSLContext):
|
||
def get_ca_certs(self, binary_form: bool = False): # type: ignore[override]
|
||
return []
|
||
|
||
def load_verify_locations(self, cafile=None, capath=None, cadata=None): # type: ignore[override]
|
||
loaded.append(cafile or "")
|
||
|
||
monkeypatch.setattr(
|
||
ssl, "create_default_context", lambda *a, **k: _EmptyStore(ssl.PROTOCOL_TLS_CLIENT)
|
||
)
|
||
monkeypatch.setattr(basemap_mod, "_CA_BUNDLES", (str(tmp_path / "nope.pem"), str(bundle)))
|
||
|
||
basemap_mod._tls_context.cache_clear()
|
||
try:
|
||
ctx = basemap_mod._tls_context()
|
||
assert ctx is not None, "no context built despite a readable bundle"
|
||
assert loaded == [str(bundle)] # skipped the missing path, used the path that exists
|
||
assert ctx.verify_mode == ssl.CERT_REQUIRED # the app does not turn off verification
|
||
finally:
|
||
basemap_mod._tls_context.cache_clear()
|
||
|
||
|
||
def test_basemap_never_disables_verification(monkeypatch: pytest.MonkeyPatch) -> None:
|
||
"""With no bundle in any place, the fetch still verifies. It fails and does not trust."""
|
||
import ssl
|
||
|
||
from meshterm.services import basemap as basemap_mod
|
||
|
||
class _EmptyStore(ssl.SSLContext):
|
||
def get_ca_certs(self, binary_form: bool = False): # type: ignore[override]
|
||
return []
|
||
|
||
monkeypatch.setattr(
|
||
ssl, "create_default_context", lambda *a, **k: _EmptyStore(ssl.PROTOCOL_TLS_CLIENT)
|
||
)
|
||
monkeypatch.setattr(basemap_mod, "_CA_BUNDLES", ("/nonexistent/ca.pem",))
|
||
|
||
basemap_mod._tls_context.cache_clear()
|
||
try:
|
||
assert basemap_mod._tls_context() is None # the default of urllib, which still verifies
|
||
finally:
|
||
basemap_mod._tls_context.cache_clear()
|
||
|
||
|
||
def test_basemap_user_agent_needs_no_installed_distribution() -> None:
|
||
"""The import of the module must not depend on package metadata on the disk.
|
||
|
||
MeshTerm often runs straight from a checkout, as often as from a wheel. The venv of
|
||
this repository and the deploy on the PicoCalc are both checkouts. If the app read the
|
||
version through ``importlib.metadata``, it would raise :class:`PackageNotFoundError` on
|
||
the tile path in the setups where the developers work. Thus the version comes from the
|
||
package. This test checks that. When the metadata lookup always fails, the module still
|
||
imports and still has a real version.
|
||
"""
|
||
import importlib
|
||
import importlib.metadata
|
||
|
||
from meshterm import __version__
|
||
from meshterm.services import basemap as basemap_mod
|
||
|
||
def _raise(name: str) -> str:
|
||
raise importlib.metadata.PackageNotFoundError(name)
|
||
|
||
original = importlib.metadata.version
|
||
importlib.metadata.version = _raise # type: ignore[assignment]
|
||
try:
|
||
reloaded = importlib.reload(basemap_mod)
|
||
assert f"MeshTerm/{__version__}" in reloaded._USER_AGENT
|
||
finally:
|
||
importlib.metadata.version = original # type: ignore[assignment]
|
||
importlib.reload(basemap_mod)
|
||
|
||
|
||
def test_basemap_source_offline_is_graceful(tmp_path: Path) -> None:
|
||
"""A source that is not reachable reports unavailable and returns no tiles. It never raises."""
|
||
from meshterm.services.basemap import BasemapSource
|
||
|
||
src = BasemapSource(tmp_path / "cache", tilejson_url="http://127.0.0.1:1/none", timeout=0.2)
|
||
assert src.available is False
|
||
assert src.max_zoom == 14 # a good default when the app cannot get the TileJSON
|
||
assert src.load_tile(14, 4843, 5861) is None
|
||
|
||
|
||
def test_basemap_source_reads_cached_tile(tmp_path: Path) -> None:
|
||
"""A tile that is already on the disk is decoded without the network."""
|
||
from meshterm.services.basemap import BasemapSource
|
||
|
||
cache = tmp_path / "cache"
|
||
dest = cache / "tiles" / "14" / "4843"
|
||
dest.mkdir(parents=True)
|
||
(dest / "5861.pbf").write_bytes(_FIXTURE.read_bytes())
|
||
src = BasemapSource(cache, tilejson_url="http://127.0.0.1:1/none", timeout=0.2)
|
||
layers = src.load_tile(14, 4843, 5861)
|
||
assert layers is not None
|
||
assert any(layer.name == "transportation" for layer in layers)
|
||
|
||
|
||
def _offline_source(cache: Path):
|
||
"""A source that cannot reach anything. Each fetch gets no answer, not an empty tile."""
|
||
from meshterm.services.basemap import BasemapSource
|
||
|
||
return BasemapSource(cache, tilejson_url="http://127.0.0.1:1/none", timeout=0.2)
|
||
|
||
|
||
def test_basemap_source_never_caches_an_unanswered_fetch(tmp_path: Path) -> None:
|
||
"""A timeout writes nothing. A blank square now must not become a blank square for ever."""
|
||
from meshterm.services import basemap as basemap_mod
|
||
|
||
cache = tmp_path / "cache"
|
||
assert _offline_source(cache).load_tile(14, 4843, 5861) is None
|
||
assert list(cache.rglob("*.pbf")) == []
|
||
|
||
# The same, but the source is resolved and the fetch fails. This is the weak link that
|
||
# the PicoCalc uses. The tile stays unknown, so the next pan asks again.
|
||
src = _offline_source(cache)
|
||
src._template = "http://tiles.invalid/{z}/{x}/{y}.pbf" # resolved: the fetch fails
|
||
calls: list[str] = []
|
||
|
||
def _get(url: str):
|
||
calls.append(url)
|
||
return basemap_mod._Response(False, b"")
|
||
|
||
src._http_get = _get # type: ignore[method-assign]
|
||
assert src.load_tile(14, 4843, 5861) is None
|
||
assert src.load_tile(14, 4843, 5861) is None
|
||
assert len(calls) == 2 # tried again, not treated as empty
|
||
assert list(cache.rglob("*.pbf")) == []
|
||
|
||
|
||
def test_basemap_source_asks_again_after_a_failed_resolve(tmp_path: Path) -> None:
|
||
"""Offline at open is a short event, not a final state.
|
||
|
||
The Wi-Fi of the PicoCalc starts after boot. The source once kept the first failure.
|
||
Thus an app that started with the network of the device drew each tile of each session
|
||
from the cache only (JP, 2026-08-18).
|
||
"""
|
||
from meshterm.services import basemap as basemap_mod
|
||
|
||
src = _offline_source(tmp_path / "cache")
|
||
assert src.available is False
|
||
assert src.max_zoom == 14 # the default, because the source resolved nothing
|
||
|
||
doc = b'{"tiles": ["http://tiles.invalid/{z}/{x}/{y}.pbf"], "maxzoom": 14}'
|
||
src._http_get = lambda url: basemap_mod._Response(True, doc) # type: ignore[method-assign]
|
||
src._resolve_after = 0.0 # the cooldown: the test skips it, and does not wait for it
|
||
assert src.max_zoom == 14
|
||
assert src.available is True, "the source never asked again"
|
||
|
||
|
||
def test_basemap_source_holds_a_failed_resolve_for_its_cooldown(tmp_path: Path) -> None:
|
||
"""To ask again is not to ask all the time. A map that is really offline is not a retry loop."""
|
||
from meshterm.services import basemap as basemap_mod
|
||
|
||
src = _offline_source(tmp_path / "cache")
|
||
calls: list[str] = []
|
||
src._http_get = lambda url: ( # type: ignore[method-assign]
|
||
calls.append(url),
|
||
basemap_mod._Response(False, b""),
|
||
)[1]
|
||
for _ in range(5):
|
||
assert src.available is False
|
||
assert calls == [] # the failed resolve of the constructor still stands
|
||
|
||
|
||
def test_basemap_source_tells_an_empty_answer_from_silence(tmp_path: Path) -> None:
|
||
"""The two ``None`` results of ``load_tile`` are different. A caller that caches needs this."""
|
||
from meshterm.services import basemap as basemap_mod
|
||
|
||
src = _offline_source(tmp_path / "cache")
|
||
src._template = "http://tiles.invalid/{z}/{x}/{y}.pbf"
|
||
src._http_get = lambda url: basemap_mod._Response(False, b"") # type: ignore[method-assign]
|
||
assert src.load_tile(14, 4843, 5861) is None
|
||
assert src.answered_empty(14, 4843, 5861) is False # no answer: ask again
|
||
|
||
src._http_get = lambda url: basemap_mod._Response(True, b"") # type: ignore[method-assign]
|
||
assert src.load_tile(14, 1, 1) is None
|
||
assert src.answered_empty(14, 1, 1) is True # the source said so, and that is final
|
||
|
||
|
||
def test_basemap_source_prunes_a_blank_cached_tile(tmp_path: Path) -> None:
|
||
"""A zero-byte entry (the failure marker of an older build) is removed, not drawn."""
|
||
cache = tmp_path / "cache"
|
||
tile = cache / "tiles" / "14" / "4843" / "5861.pbf"
|
||
tile.parent.mkdir(parents=True)
|
||
tile.write_bytes(b"")
|
||
assert _offline_source(cache).load_tile(14, 4843, 5861) is None
|
||
assert not tile.exists() # pruned, so a session with a network gets it again
|
||
|
||
|
||
def test_basemap_source_prunes_a_corrupt_cached_tile(tmp_path: Path) -> None:
|
||
"""Bytes that a write cut short do not decode, so the source prunes them."""
|
||
cache = tmp_path / "cache"
|
||
tile = cache / "tiles" / "14" / "4843" / "5861.pbf"
|
||
tile.parent.mkdir(parents=True)
|
||
tile.write_bytes(_FIXTURE.read_bytes()[:200])
|
||
assert _offline_source(cache).load_tile(14, 4843, 5861) is None
|
||
assert not tile.exists()
|
||
|
||
|
||
def test_basemap_source_caches_only_tiles_with_content(tmp_path: Path) -> None:
|
||
"""The disk cache holds geometry that decodes. It never holds an empty or unparseable answer."""
|
||
from meshterm.services import basemap as basemap_mod
|
||
|
||
cache = tmp_path / "cache"
|
||
src = _offline_source(cache)
|
||
src._template = "http://tiles.invalid/{z}/{x}/{y}.pbf" # skip the TileJSON resolution
|
||
src._resolved = True
|
||
|
||
bodies = iter([b"", b"not a vector tile at all", _FIXTURE.read_bytes()])
|
||
src._http_get = lambda url: basemap_mod._Response(True, next(bodies)) # type: ignore[method-assign]
|
||
|
||
assert src.load_tile(14, 1, 1) is None # answered "empty"
|
||
assert src.load_tile(14, 2, 2) is None # answered with junk
|
||
assert list(cache.rglob("*.pbf")) == []
|
||
layers = src.load_tile(14, 4843, 5861) # answered with a real tile
|
||
assert layers is not None
|
||
assert (cache / "tiles" / "14" / "4843" / "5861.pbf").exists()
|
||
|
||
|
||
def test_basemap_source_keeps_a_tile_whose_layers_are_all_undrawn(tmp_path: Path) -> None:
|
||
"""A narrowed decode must not make a good cached tile look blank and get pruned.
|
||
|
||
A source that decodes a layer that the tile does not have sees no features at all. If
|
||
the source read this as "nothing here", it would delete the entry and download it again
|
||
in each session. Then the cache would pay for a decision about the rendering.
|
||
"""
|
||
from meshterm.services.basemap import BasemapSource
|
||
|
||
cache = tmp_path / "cache"
|
||
tile = cache / "tiles" / "14" / "4843" / "5861.pbf"
|
||
tile.parent.mkdir(parents=True)
|
||
tile.write_bytes(_FIXTURE.read_bytes())
|
||
|
||
src = BasemapSource(
|
||
cache,
|
||
tilejson_url="http://127.0.0.1:1/none",
|
||
timeout=0.2,
|
||
layers=frozenset({"a_layer_this_tile_does_not_have"}),
|
||
)
|
||
layers = src.load_tile(14, 4843, 5861)
|
||
|
||
assert layers, "a real tile must still read as real"
|
||
assert not any(layer.features for layer in layers) # the source decoded nothing
|
||
assert tile.exists(), "the cache entry must survive"
|
||
|
||
|
||
def test_decoded_layers_survive_a_round_trip() -> None:
|
||
"""The sidecar encoding restores exactly the result of the decoder."""
|
||
from meshterm.core.mvt import dumps_layers, loads_layers
|
||
from meshterm.ui.map_render import DRAWN_LAYERS
|
||
|
||
original = decode_tile(_FIXTURE.read_bytes(), layers=DRAWN_LAYERS)
|
||
restored = loads_layers(dumps_layers(original, stamp="x"), stamp="x")
|
||
|
||
assert restored is not None
|
||
|
||
def shape(layers):
|
||
return [(layer.name, layer.extent) for layer in layers]
|
||
|
||
assert shape(restored) == shape(original)
|
||
for before, after in zip(original, restored, strict=True):
|
||
assert after.features == before.features
|
||
|
||
|
||
def test_decoded_layers_refuse_a_blob_from_another_layer_set() -> None:
|
||
"""The source must not give a narrowed blob to a caller that wants more of the tile."""
|
||
from meshterm.core.mvt import dumps_layers, loads_layers
|
||
|
||
blob = dumps_layers(decode_tile(_FIXTURE.read_bytes(), layers={"water"}), stamp="water")
|
||
assert loads_layers(blob, stamp="water") is not None
|
||
assert loads_layers(blob, stamp="water,place") is None
|
||
|
||
|
||
def test_decoded_layers_treat_damage_as_a_miss() -> None:
|
||
"""A sidecar that is truncated or foreign is a cache miss. It is not an exception."""
|
||
from meshterm.core.mvt import dumps_layers, loads_layers
|
||
|
||
blob = dumps_layers(decode_tile(_FIXTURE.read_bytes(), layers={"water"}), stamp="s")
|
||
assert loads_layers(blob[: len(blob) // 2], stamp="s") is None
|
||
assert loads_layers(b"", stamp="s") is None
|
||
assert loads_layers(b"not marshal at all", stamp="s") is None
|
||
|
||
|
||
def test_basemap_source_writes_and_reuses_a_decoded_sidecar(tmp_path: Path) -> None:
|
||
"""The second load parses nothing. Its result comes from the sidecar."""
|
||
cache = tmp_path / "cache"
|
||
tile = cache / "tiles" / "14" / "4843" / "5861.pbf"
|
||
tile.parent.mkdir(parents=True)
|
||
tile.write_bytes(_FIXTURE.read_bytes())
|
||
|
||
src = _offline_source(cache)
|
||
first = src.load_tile(14, 4843, 5861)
|
||
sidecar = cache / "decoded" / "14" / "4843" / "5861.bin"
|
||
assert first is not None
|
||
assert sidecar.exists(), "decoding should have been written down"
|
||
|
||
# Break the raw tile. If a second load still works, it can only use the sidecar.
|
||
tile.write_bytes(b"junk that cannot decode")
|
||
second = src.load_tile(14, 4843, 5861)
|
||
assert second is not None
|
||
assert [(layer.name, len(layer.features)) for layer in second] == [
|
||
(layer.name, len(layer.features)) for layer in first
|
||
]
|
||
|
||
|
||
def test_basemap_source_ignores_a_sidecar_from_another_layer_set(tmp_path: Path) -> None:
|
||
"""If the map draws more layers, the source decodes again. It does not use the narrower blob."""
|
||
from meshterm.services.basemap import BasemapSource
|
||
|
||
cache = tmp_path / "cache"
|
||
tile = cache / "tiles" / "14" / "4843" / "5861.pbf"
|
||
tile.parent.mkdir(parents=True)
|
||
tile.write_bytes(_FIXTURE.read_bytes())
|
||
|
||
def source(layers):
|
||
return BasemapSource(
|
||
cache, tilejson_url="http://127.0.0.1:1/none", timeout=0.2, layers=layers
|
||
)
|
||
|
||
narrow = source(frozenset({"water"}))
|
||
assert narrow.load_tile(14, 4843, 5861) is not None
|
||
|
||
wider = source(frozenset({"water", "place"}))
|
||
layers = {layer.name: layer for layer in wider.load_tile(14, 4843, 5861) or []}
|
||
assert layers["place"].features, "the wider set must have been decoded afresh"
|
||
|
||
|
||
def test_basemap_source_prunes_the_decoded_cache_to_its_budget(tmp_path: Path) -> None:
|
||
"""The sidecar half of the cache has a limit. The raw tiles that it comes from have none."""
|
||
from meshterm.services.basemap import BasemapSource
|
||
|
||
cache = tmp_path / "cache"
|
||
decoded = cache / "decoded" / "14" / "1"
|
||
decoded.mkdir(parents=True)
|
||
for i in range(6):
|
||
blob = decoded / f"{i}.bin"
|
||
blob.write_bytes(b"x" * 1000)
|
||
os.utime(blob, (i, i)) # oldest first
|
||
|
||
# 6 KB is present and 3 KB is the limit. The three oldest go, and the three newest stay.
|
||
src = BasemapSource(cache, tilejson_url="http://127.0.0.1:1/none", max_decoded_bytes=3000)
|
||
src._prune_decoded()
|
||
|
||
left = sorted(p.stem for p in decoded.glob("*.bin"))
|
||
assert left == ["3", "4", "5"], "the oldest sidecars go first"
|
||
|
||
|
||
def test_resident_bytes_tracks_what_a_decoded_tile_really_holds() -> None:
|
||
"""The memory budget is only as good as its scale, so the test checks the scale against RAM.
|
||
|
||
The old measure was a count of tiles. But a tile weighs between 1 MB and 4.6 MB on the
|
||
PicoCalc. The estimate must be near the value that ``tracemalloc`` shows, on the word
|
||
size of this build. If it is not, the budget that uses the estimate is a guess.
|
||
"""
|
||
import gc
|
||
import tracemalloc
|
||
|
||
from meshterm.core.mvt import dumps_layers, loads_layers, resident_bytes
|
||
from meshterm.ui.map_render import DRAWN_LAYERS
|
||
|
||
blob = dumps_layers(decode_tile(_FIXTURE.read_bytes(), layers=DRAWN_LAYERS))
|
||
gc.collect()
|
||
tracemalloc.start()
|
||
try:
|
||
layers = loads_layers(blob)
|
||
held, _ = tracemalloc.get_traced_memory()
|
||
finally:
|
||
tracemalloc.stop()
|
||
assert layers
|
||
assert 0.8 <= resident_bytes(layers) / held <= 1.4
|
||
|
||
|
||
def test_basemap_source_holds_decoded_tiles_to_a_byte_budget(tmp_path: Path) -> None:
|
||
"""Decoded tiles in RAM have a budget in bytes. The oldest go first, and the newest stays."""
|
||
from meshterm.core.mvt import resident_bytes
|
||
from meshterm.services.basemap import BasemapSource
|
||
|
||
tile = decode_tile(_FIXTURE.read_bytes())
|
||
one = resident_bytes(tile)
|
||
src = BasemapSource(
|
||
tmp_path / "cache", tilejson_url="http://127.0.0.1:1/none", memo_bytes=one * 5 // 2
|
||
)
|
||
for i in range(5):
|
||
src._remember((14, i, 0), tile)
|
||
|
||
assert [key[1] for key in src._memo] == [3, 4], "the two newest fit; the rest went oldest first"
|
||
assert src._memo_bytes == 2 * one
|
||
assert src.resident(14, 4, 0) is tile
|
||
assert src.resident(14, 0, 0) is None
|
||
|
||
# A tile that is heavier than the whole budget stays, alone, because a view asked for it.
|
||
tight = BasemapSource(tmp_path / "cache", tilejson_url="http://127.0.0.1:1/none", memo_bytes=1)
|
||
tight._remember((14, 0, 0), tile)
|
||
tight._remember((14, 1, 0), tile)
|
||
assert list(tight._memo) == [(14, 1, 0)]
|
||
|
||
|
||
def test_basemap_memory_budget_scales_with_the_machine(monkeypatch: pytest.MonkeyPatch) -> None:
|
||
"""The budget is an eighth of the RAM, within limits (the PicoCalc gets 13 MB of its 100 MB)."""
|
||
from meshterm.services import basemap
|
||
|
||
def machine(pages: int):
|
||
sizes = {"SC_PAGE_SIZE": 4096, "SC_PHYS_PAGES": pages}
|
||
monkeypatch.setattr(basemap.os, "sysconf", sizes.__getitem__, raising=False)
|
||
|
||
machine(26142) # the 104568 kB of the PicoCalc
|
||
assert basemap._memo_budget() == 4096 * 26142 // basemap._MEMO_SHARE
|
||
machine(4 * 1024 * 1024) # a 16 GB desktop
|
||
assert basemap._memo_budget() == basemap._MEMO_CEILING
|
||
machine(1024) # 4 MB: too small to hold a view at an eighth of the RAM
|
||
assert basemap._memo_budget() == basemap._MEMO_FLOOR
|
||
monkeypatch.delattr(basemap.os, "sysconf", raising=False) # Windows
|
||
assert basemap._memo_budget() == basemap._MEMO_CEILING
|
||
|
||
|
||
def test_basemap_warm_writes_a_tile_down_and_holds_nothing(tmp_path: Path) -> None:
|
||
"""A prefetch stores the decode on the disk. It leaves the RAM for where the user went."""
|
||
cache = tmp_path / "cache"
|
||
tile = cache / "tiles" / "14" / "4843" / "5861.pbf"
|
||
tile.parent.mkdir(parents=True)
|
||
tile.write_bytes(_FIXTURE.read_bytes())
|
||
|
||
src = _offline_source(cache)
|
||
src.warm(14, 4843, 5861)
|
||
assert (cache / "decoded" / "14" / "4843" / "5861.bin").exists()
|
||
assert src.resident(14, 4843, 5861) is None, "a guess was held in RAM"
|
||
|
||
# A tile that is already stored costs a second guess nothing, not even a read.
|
||
reads: list[Path] = []
|
||
src._read_cached = lambda path: reads.append(path) # type: ignore[method-assign]
|
||
src.warm(14, 4843, 5861)
|
||
assert reads == []
|
||
|
||
|
||
def test_basemap_source_remembers_a_blank_tile_for_the_session(tmp_path: Path) -> None:
|
||
"""The source does not request an answered-empty tile again in this session (or store it)."""
|
||
from meshterm.services import basemap as basemap_mod
|
||
|
||
src = _offline_source(tmp_path / "cache")
|
||
src._template = "http://tiles.invalid/{z}/{x}/{y}.pbf"
|
||
src._resolved = True
|
||
calls: list[str] = []
|
||
|
||
def _get(url: str):
|
||
calls.append(url)
|
||
return basemap_mod._Response(True, b"")
|
||
|
||
src._http_get = _get # type: ignore[method-assign]
|
||
assert src.load_tile(14, 1, 1) is None
|
||
assert src.load_tile(14, 1, 1) is None
|
||
assert len(calls) == 1
|
||
|
||
|
||
def test_basemap_http_get_separates_an_answer_from_silence(tmp_path: Path) -> None:
|
||
"""A 404 is an answer. A 500, and a body shorter than Content-Length, are not answers."""
|
||
import urllib.error
|
||
|
||
from meshterm.services.basemap import BasemapSource
|
||
|
||
src = BasemapSource(tmp_path / "cache")
|
||
|
||
class _Resp:
|
||
"""The part of an ``http.client.HTTPResponse`` that the fetcher uses."""
|
||
|
||
def __init__(self, body: bytes, declared: str | None) -> None:
|
||
self._body, self.headers = body, {"Content-Length": declared}
|
||
|
||
def read(self) -> bytes:
|
||
return self._body
|
||
|
||
def __enter__(self):
|
||
return self
|
||
|
||
def __exit__(self, *_exc) -> bool:
|
||
return False
|
||
|
||
def _fake(result):
|
||
def _urlopen(_req, timeout=None, context=None): # noqa: ANN001
|
||
if isinstance(result, Exception):
|
||
raise result
|
||
return result
|
||
|
||
return _urlopen
|
||
|
||
def _get(result) -> tuple[bool, bytes]:
|
||
import urllib.request
|
||
|
||
saved = urllib.request.urlopen
|
||
urllib.request.urlopen = _fake(result) # type: ignore[assignment]
|
||
try:
|
||
return tuple(src._http_get("http://tiles.invalid/t"))
|
||
finally:
|
||
urllib.request.urlopen = saved # type: ignore[assignment]
|
||
|
||
err = lambda code: urllib.error.HTTPError("u", code, "", {}, None) # noqa: E731
|
||
assert _get(_Resp(b"tile-bytes", "10")) == (True, b"tile-bytes")
|
||
assert _get(_Resp(b"tile-b", "10")) == (False, b"") # truncated read
|
||
assert _get(_Resp(b"anything", None)) == (True, b"anything") # no declared length
|
||
assert _get(err(404)) == (True, b"") # "no tile here": an answer
|
||
assert _get(err(500)) == (False, b"") # server fault: no answer
|
||
assert _get(err(429)) == (False, b"") # throttled: no answer
|
||
assert _get(TimeoutError("timed out")) == (False, b"")
|
||
|
||
|
||
# -- the tool -----------------------------------------------------------------
|
||
|
||
|
||
@pytest.fixture()
|
||
def ctx(tmp_path: Path):
|
||
"""An application context with a mock device and the plain (console) UI surface."""
|
||
from meshterm.context import AppContext
|
||
from meshterm.core.admin_store import AdminStore
|
||
from meshterm.core.config import Settings
|
||
from meshterm.core.device_store import DeviceStore
|
||
from meshterm.persistence.repository import Repository
|
||
from meshterm.ui.theme import MESH_THEME
|
||
|
||
settings = Settings(config_dir=tmp_path, db_path=tmp_path / "map.db")
|
||
context = AppContext(
|
||
console=Console(file=io.StringIO(), theme=MESH_THEME),
|
||
settings=settings,
|
||
repo=Repository(settings.db_path),
|
||
device_store=DeviceStore(tmp_path / "devices.json"),
|
||
admin_store=AdminStore(tmp_path / "admin.json"),
|
||
mock=True,
|
||
)
|
||
yield context
|
||
context.repo.close()
|
||
|
||
|
||
def test_usable_fix_rejects_out_of_range_and_null_island() -> None:
|
||
"""A fix is usable only in the valid lat/lon range and away from the 0/0 no-GPS sentinel."""
|
||
from meshterm.core.geo import usable_fix
|
||
|
||
assert usable_fix(45.5, -73.6) is True
|
||
assert usable_fix(90.0, 180.0) is True and usable_fix(-90.0, -180.0) is True # extremes
|
||
assert usable_fix(0.0, 0.0) is False # null island (a companion with no GPS reports this)
|
||
assert usable_fix(-97.0, -1041.97) is False # out of range (a real advert that we heard)
|
||
assert usable_fix(45.0, 181.0) is False and usable_fix(-91.0, -73.0) is False
|
||
|
||
|
||
async def test_gather_markers_drops_out_of_range_fix(ctx) -> None:
|
||
"""The map does not plot a node with invalid coordinates. It plots the node with a real fix.
|
||
|
||
Some firmware reports a fix that is out of range (a companion advertised ``lat -97,
|
||
lon -1042``). If the map projects it, the view goes off the world, and the map is an
|
||
all-black frame of nothing. The map treats it as no fix, the same as the 0/0 null island.
|
||
"""
|
||
from meshterm.services.markers import gather_markers
|
||
|
||
run_id = ctx.repo.start_run("monitor", {})
|
||
ctx.repo.record_observation(
|
||
run_id,
|
||
Observation(
|
||
node="beefbeefbeef",
|
||
name="BadFix",
|
||
node_type=NODE_TYPE_REPEATER,
|
||
lat=-97.0,
|
||
lon=-1041.97,
|
||
observed_at=utcnow(),
|
||
),
|
||
)
|
||
ctx.repo.record_observation(
|
||
run_id,
|
||
Observation(
|
||
node="cafecafecafe",
|
||
name="GoodFix",
|
||
node_type=NODE_TYPE_REPEATER,
|
||
lat=45.5,
|
||
lon=-73.6,
|
||
observed_at=utcnow(),
|
||
),
|
||
)
|
||
labels = {m.label for m in await gather_markers(ctx)}
|
||
assert "GoodFix" in labels
|
||
assert "BadFix" not in labels
|
||
|
||
|
||
def test_repository_round_trips_map_view(ctx) -> None:
|
||
"""The repository stores the map viewport and reads it back. At the start, it is absent."""
|
||
assert ctx.repo.get_map_view() is None
|
||
ctx.repo.set_map_view(45.51, -73.57, 13)
|
||
lat, lon, zoom = ctx.repo.get_map_view()
|
||
assert (lat, lon) == pytest.approx((45.51, -73.57))
|
||
assert zoom == 13
|
||
# A later save replaces the one stored view.
|
||
ctx.repo.set_map_view(40.0, -74.0, 9)
|
||
assert ctx.repo.get_map_view() == pytest.approx((40.0, -74.0, 9))
|
||
|
||
|
||
async def test_map_tool_reports_nothing_to_plot(ctx, monkeypatch) -> None:
|
||
"""If no contact and no history has a position, the tool returns without an error."""
|
||
|
||
async def _no_contacts(_ctx):
|
||
return []
|
||
|
||
monkeypatch.setattr("meshterm.services.markers._contacts", _no_contacts)
|
||
result = await MapTool().run(ctx, {"static": True, "basemap": False})
|
||
assert result.summary == {"located": 0}
|
||
|
||
|
||
async def test_gather_markers_drops_null_island_fixes(ctx, monkeypatch) -> None:
|
||
"""The map does not plot a node that advertises a 0/0 fix (no GPS lock) at null island.
|
||
|
||
Suppose the user frames such a marker (filters to it and presses Enter). The map would
|
||
go to the empty mid-Atlantic, a screen of solid water fill. Thus the map treats a fix
|
||
where both values are near zero as no fix, as it already does for our own node.
|
||
"""
|
||
from meshterm.core.models import Contact
|
||
from meshterm.services.markers import gather_markers
|
||
|
||
async def _contacts(_ctx):
|
||
return [
|
||
Contact(name="Real", public_key="aa" * 32, lat=45.50, lon=-73.57),
|
||
Contact(name="NoFix", public_key="bb" * 32, lat=0.0, lon=0.0),
|
||
]
|
||
|
||
monkeypatch.setattr("meshterm.services.markers._contacts", _contacts)
|
||
labels = [m.label for m in await gather_markers(ctx)]
|
||
assert "Real" in labels
|
||
assert "NoFix" not in labels # the map removes the null-island node
|
||
|
||
|
||
class _StubSession:
|
||
"""Minimal stand-in for :class:`TuiSession`, to drive :class:`MapScreen`."""
|
||
|
||
def __init__(self, cols: int, rows: int) -> None:
|
||
self._cols, self._rows = cols, rows
|
||
self.invalidated = 0
|
||
|
||
def base_body_size(self) -> tuple[int, int]:
|
||
return self._cols, self._rows
|
||
|
||
def invalidate(self) -> None:
|
||
self.invalidated += 1
|
||
|
||
|
||
class _StubSource:
|
||
"""A tile source that is always offline, so the screen renders only nodes (no network)."""
|
||
|
||
available = False
|
||
max_zoom = 14
|
||
|
||
def load_tile(self, z: int, x: int, y: int):
|
||
return None
|
||
|
||
def answered_empty(self, z: int, x: int, y: int) -> bool:
|
||
return False # offline means no answer, not that the source says "nothing there"
|
||
|
||
def resident(self, z: int, x: int, y: int):
|
||
return None # the source holds nothing in RAM
|
||
|
||
def warm(self, z: int, x: int, y: int) -> None:
|
||
return None
|
||
|
||
|
||
def _loaded_tile() -> list[Layer]:
|
||
"""A stand-in for a decoded tile. It is true in a boolean test, which the budget needs."""
|
||
return [Layer(name="water", extent=4096)]
|
||
|
||
|
||
def _map_screen_with_tiles(count: int) -> MapScreen:
|
||
"""A rendered map screen with the pan history of ``count`` tiles."""
|
||
from meshterm.ui.map_render import MapMarker
|
||
from meshterm.ui.map_screen import MapScreen
|
||
|
||
screen = MapScreen(_StubSession(80, 24), [MapMarker("A", 45.5, -73.6)], _StubSource(), 14)
|
||
screen.render_body(80)
|
||
for i in range(count):
|
||
screen._tiles[(14, 9000 + i, 9000 + i)] = _loaded_tile()
|
||
return screen
|
||
|
||
|
||
def test_map_holds_only_the_tiles_its_view_shows() -> None:
|
||
"""After a long pan, the screen does not keep decoded ground that it does not show.
|
||
|
||
A decoded tile is 1 MB to 4.6 MB on the PicoCalc, which has approximately 100 MB in
|
||
total. The source keeps the history that a pan back needs, with a budget in bytes. If
|
||
the screen also kept a tile, the tile would be outside that budget. This is how a fast
|
||
map once made the device swap until it stopped.
|
||
"""
|
||
screen = _map_screen_with_tiles(200)
|
||
for _ in range(12):
|
||
screen.handle("right")
|
||
screen.render_body(80)
|
||
|
||
visible = set(screen._viewport.tiles(14))
|
||
held = {k for k, layers in screen._tiles.items() if layers}
|
||
assert held <= visible, f"{len(held - visible)} decoded tiles held off screen"
|
||
|
||
|
||
def test_map_tile_cache_never_drops_what_is_on_screen() -> None:
|
||
"""The tiles that the view needs stay after the trim. The eviction starts at the far end."""
|
||
from meshterm.ui.map_render import MapMarker
|
||
from meshterm.ui.map_screen import MapScreen
|
||
|
||
screen = MapScreen(_StubSession(80, 24), [MapMarker("A", 45.5, -73.6)], _StubSource(), 14)
|
||
screen.render_body(80)
|
||
visible = list(screen._viewport.tiles(14))
|
||
for t in visible:
|
||
screen._tiles[t] = _loaded_tile()
|
||
for i in range(200): # the history of a long pan arrives after them
|
||
screen._tiles[(14, 9000 + i, 9000 + i)] = _loaded_tile()
|
||
|
||
screen.render_body(80)
|
||
|
||
for t in visible:
|
||
assert screen._tiles.get(t), f"{t} is on screen and must not have been evicted"
|
||
|
||
|
||
def test_map_tile_cache_keeps_the_absent_tile_markers() -> None:
|
||
"""A ``None`` is the memory that the screen asked. The cache never evicts it.
|
||
|
||
It uses a slot. If the cache removed one, it would gain nothing, and the next paint
|
||
would request the tile again.
|
||
"""
|
||
screen = _map_screen_with_tiles(200)
|
||
for i in range(30):
|
||
screen._tiles[(14, 100 + i, 100)] = None
|
||
|
||
screen.render_body(80)
|
||
|
||
absent = [k for k, v in screen._tiles.items() if v is None]
|
||
assert len(absent) == 30, "absent-tile markers were evicted along with the geometry"
|
||
|
||
|
||
class _CapturingSession(_StubSession):
|
||
"""Drives :func:`open_map` headless, so that a test can watch what it does.
|
||
|
||
It captures the pushed screen, renders it one time, and then plays a fixed key sequence.
|
||
This is enough to assert where the map opened, and what the map stored (or did not
|
||
store) when the view moved.
|
||
"""
|
||
|
||
def __init__(self, cols: int, rows: int, keys: tuple[str, ...] = ()) -> None:
|
||
super().__init__(cols, rows)
|
||
self.keys = keys
|
||
self.screen = None
|
||
self.repainted = False
|
||
|
||
async def run_screen(self, screen): # noqa: ANN001, ANN201
|
||
self.screen = screen
|
||
screen.render_body(self._cols)
|
||
for key in self.keys:
|
||
screen.handle(key)
|
||
return None
|
||
|
||
def request_full_repaint(self) -> None:
|
||
self.repainted = True
|
||
|
||
|
||
def test_map_screen_renders_pans_zooms_and_resets() -> None:
|
||
"""The interactive screen fills its body. The wasd keys, zoom, and reset move the viewport."""
|
||
from meshterm.core.geo import Viewport
|
||
from meshterm.ui.map_render import MapMarker
|
||
from meshterm.ui.map_screen import MapScreen
|
||
|
||
session = _StubSession(80, 24)
|
||
markers = [
|
||
MapMarker("A", 45.50, -73.60, is_repeater=True),
|
||
MapMarker("B", 45.40, -73.50),
|
||
]
|
||
screen = MapScreen(session, markers, _StubSource(), 14)
|
||
|
||
lines = screen.render_body(80)
|
||
assert len(lines) == 24 # fills the body height that the session reports
|
||
start = screen._viewport
|
||
|
||
screen.handle("right") # pan east: the longitude increases
|
||
assert screen._viewport.center_lon > start.center_lon
|
||
screen.handle("up") # pan north: the latitude increases
|
||
assert screen._viewport.center_lat > start.center_lat
|
||
|
||
z = screen._viewport.zoom
|
||
screen.handle("pageup")
|
||
assert screen._viewport.zoom == z + 1
|
||
screen.handle("pagedown")
|
||
assert screen._viewport.zoom == z
|
||
|
||
screen.handle("home") # reset fits the view to the nodes again
|
||
refit = Viewport.fit([(m.lat, m.lon) for m in markers], start.dot_w, start.dot_h, max_zoom=14)
|
||
assert screen._viewport.zoom == refit.zoom
|
||
assert screen._viewport.center_lat == pytest.approx(refit.center_lat)
|
||
|
||
# A letter does not pan or zoom. It goes to the find filter.
|
||
view = screen._viewport
|
||
screen.handle("text", "d")
|
||
assert screen._viewport is view and screen._filter == "d"
|
||
screen.handle("backspace")
|
||
|
||
# Offline source: the nodes still render, and both markers are present.
|
||
assert "▲" in _plain(screen.render_body(80)) and "●" in _plain(screen.render_body(80))
|
||
# The state of the basemap is a status atom. It is in the `·` chain of the title (in the
|
||
# place of the scale). Thus the footer is a line of keys only, within its 72-cell budget.
|
||
assert screen.title.endswith("· offline")
|
||
assert "offline" not in screen.footer_hint
|
||
assert cell_len(screen.footer_hint) <= 72
|
||
|
||
|
||
def test_map_screen_shift_pans_by_a_single_cell() -> None:
|
||
"""Shift with an arrow makes a fine pan, by one character cell."""
|
||
from meshterm.core.geo import lonlat_to_world, world_to_lonlat
|
||
from meshterm.ui.map_render import MapMarker
|
||
from meshterm.ui.map_screen import MapScreen
|
||
|
||
markers = [MapMarker("A", 45.50, -73.60), MapMarker("B", 45.40, -73.50)]
|
||
screen = MapScreen(_StubSession(80, 24), markers, _StubSource(), 14)
|
||
screen.render_body(80)
|
||
start = screen._viewport
|
||
|
||
# A coarse pan to the east (30% of the view) moves much more than a fine pan.
|
||
screen.handle("right")
|
||
coarse = screen._viewport.center_lon - start.center_lon
|
||
|
||
screen._viewport = start
|
||
screen.handle("shift_right")
|
||
fine = screen._viewport.center_lon - start.center_lon
|
||
assert 0 < fine < coarse
|
||
|
||
# The fine step is one cell (2 dots) to the east at the current zoom.
|
||
cx, cy = lonlat_to_world(start.center_lat, start.center_lon, start.zoom)
|
||
_, expected_lon = world_to_lonlat(cx + 2, cy, start.zoom)
|
||
assert screen._viewport.center_lon == pytest.approx(expected_lon)
|
||
|
||
|
||
def test_map_fkey_lane_names_its_three_destinations_and_the_zoom() -> None:
|
||
"""The map uses the navigation keys for other actions, so its lane labels them. No dead key."""
|
||
from meshterm.ui.map_render import MapMarker
|
||
from meshterm.ui.map_screen import MapScreen
|
||
from meshterm.ui.tui.fkeys import PICOCALC_LYRA_DECK
|
||
|
||
screen = MapScreen(_StubSession(80, 24), [MapMarker("A", 45.5, -73.6)], _StubSource(), 14)
|
||
lane = screen.picocalc_lyra_lane
|
||
|
||
# PgUp and PgDn zoom here, and Home frames the view again. The shared paging labels would
|
||
# be false. The "end" key has no action, so no jump is on the Shift bank of the zoom
|
||
# rocker. The zoom pair rises to the right, as each directional pair on the right does.
|
||
assert [pair.label if pair else None for pair in lane] == [
|
||
"Region",
|
||
"You",
|
||
"Frame",
|
||
"Zoom -",
|
||
"Zoom +",
|
||
]
|
||
assert (
|
||
PICOCALC_LYRA_DECK.action_for(lane, 1) == "home"
|
||
and PICOCALC_LYRA_DECK.action_for(lane, 5) == "pageup"
|
||
)
|
||
assert (
|
||
PICOCALC_LYRA_DECK.action_for(lane, 2) == "locate"
|
||
and PICOCALC_LYRA_DECK.action_for(lane, 3) == "frame"
|
||
)
|
||
# Two slots have a Shift half on their own axis: You + goes to our node, zoomed in, and
|
||
# Clear removes the find query. The other three Shift keys have no action.
|
||
assert (
|
||
PICOCALC_LYRA_DECK.action_for(lane, 7) == "locate_zoom"
|
||
and PICOCALC_LYRA_DECK.action_for(lane, 8) == "clear_find"
|
||
)
|
||
assert all(PICOCALC_LYRA_DECK.action_for(lane, n) is None for n in (6, 9, 10))
|
||
# Region and the zoom always act. The other two act only where they go to a place. Each
|
||
# Shift half depends on its own axis (no marker of our node, no query typed).
|
||
assert [pair.enabled for pair in lane] == [True, False, False, True, True]
|
||
assert lane[1].opp_enabled is False and lane[2].opp_enabled is False
|
||
|
||
|
||
def test_map_locate_recenters_on_our_own_node_at_the_current_zoom() -> None:
|
||
"""``You`` moves the view to us and changes nothing else, not even the zoom."""
|
||
from meshterm.ui.map_render import MapMarker
|
||
from meshterm.ui.map_screen import MapScreen
|
||
|
||
markers = [
|
||
MapMarker("Hilltop-Repeater", 45.53, -73.71, is_repeater=True),
|
||
MapMarker("Homestead", 45.40, -73.50, is_self=True),
|
||
]
|
||
screen = MapScreen(_StubSession(80, 24), markers, _StubSource(), 14)
|
||
screen.render_body(80)
|
||
screen.handle("pageup") # zoom in one step, so the new centre has a zoom to keep
|
||
zoom = screen._viewport.zoom
|
||
screen.handle("left") # and pan away from our node
|
||
|
||
assert screen.picocalc_lyra_lane[1].enabled is True # we are on the map, so You can act
|
||
screen.handle("locate")
|
||
assert screen._viewport.center_lat == pytest.approx(45.40)
|
||
assert screen._viewport.center_lon == pytest.approx(-73.50)
|
||
assert screen._viewport.zoom == zoom
|
||
|
||
# If our node has no position on the map, there is no place to go. The chip is dim, and
|
||
# the key does nothing.
|
||
away = MapScreen(_StubSession(80, 24), markers[:1], _StubSource(), 14)
|
||
away.render_body(80)
|
||
before = away._viewport
|
||
away.handle("locate")
|
||
assert away.picocalc_lyra_lane[1].enabled is False and away._viewport is before
|
||
|
||
|
||
def test_map_echoes_the_find_query_in_the_body_only_where_the_footer_is_gone() -> None:
|
||
"""The PicoCalc draws no hint line, so the query that the hint has moves into the body."""
|
||
from meshterm.platforms import PICOCALC_LYRA, REGULAR, set_platform
|
||
from meshterm.ui.attribution import CREDIT_SHORT
|
||
from meshterm.ui.map_render import MapMarker
|
||
from meshterm.ui.map_screen import MapScreen
|
||
|
||
markers = [MapMarker("Hilltop-Repeater", 45.53, -73.71, is_repeater=True)]
|
||
|
||
try:
|
||
set_platform(REGULAR)
|
||
desktop = MapScreen(_StubSession(72, 20), markers, _StubSource(), 14)
|
||
assert len(desktop.render_body(72)) == 20
|
||
for ch in "hil":
|
||
desktop.handle("text", ch)
|
||
# No change: the footer shows the query, so the canvas keeps the whole body.
|
||
assert "/hil" not in _plain(desktop.render_body(72))
|
||
assert len(desktop.render_body(72)) == 20
|
||
assert "find: hil" in desktop.footer_hint
|
||
|
||
set_platform(PICOCALC_LYRA)
|
||
device = MapScreen(_StubSession(53, 23), markers, _StubSource(), 14)
|
||
assert len(device.render_body(53)) == 23
|
||
for ch in "hil":
|
||
device.handle("text", ch)
|
||
lines = device.render_body(53)
|
||
# The echo goes over the last row of the canvas, directly above the F-key lane. Thus
|
||
# the ground does not move: the body height, the viewport, and the top anchor stay
|
||
# the same. The basemap credit is at the other end of the same row. (It is chrome on
|
||
# the drawing, not a body line of its own.) The echo takes the space that remains.
|
||
assert _plain(lines[-1]).strip().startswith("/hil")
|
||
assert _plain(lines[-1]).rstrip().endswith(CREDIT_SHORT)
|
||
assert "/hil" not in _plain(lines[0])
|
||
assert len(lines) == 23
|
||
# If the user clears the find, the row goes back to the canvas, again with no reflow.
|
||
for _ in "hil":
|
||
device.handle("backspace")
|
||
assert len(device.render_body(53)) == 23
|
||
assert "/" not in _plain(device.render_body(53))
|
||
finally:
|
||
set_platform(REGULAR)
|
||
|
||
|
||
def test_map_shifted_vertical_arrows_survive_the_console_keymap(monkeypatch) -> None:
|
||
"""A vertical arrow with Shift still works when the console keymap flattens it.
|
||
|
||
Shift+↑ arrives as PgUp on the PicoCalc console. When the Shift key is down, the key
|
||
must make a fine pan and not a zoom. A plain PgUp (the zoom of the F-key lane) stays a zoom.
|
||
"""
|
||
from meshterm.services import modifier_watch
|
||
from meshterm.ui.map_render import MapMarker
|
||
from meshterm.ui.map_screen import MapScreen
|
||
|
||
screen = MapScreen(_StubSession(80, 24), [MapMarker("A", 45.5, -73.6)], _StubSource(), 14)
|
||
screen.render_body(80)
|
||
zoom = screen._viewport.zoom
|
||
lat = screen._viewport.center_lat
|
||
|
||
monkeypatch.setattr(modifier_watch, "_shift_down", True)
|
||
screen.handle("pageup") # the Shift+↑ of the keymap, which the watcher repairs
|
||
assert screen._viewport.zoom == zoom # no zoom
|
||
assert screen._viewport.center_lat > lat # …the view moved a little to the north instead
|
||
|
||
monkeypatch.setattr(modifier_watch, "_shift_down", False)
|
||
screen.handle("pageup") # a real zoom key press (the action of the F5 chip)
|
||
assert screen._viewport.zoom == zoom + 1
|
||
|
||
|
||
def test_map_frame_chip_lights_only_with_matches_to_frame() -> None:
|
||
"""``Frame`` is the Enter of the find with another name. With no query, it is dim and inert."""
|
||
from meshterm.ui.map_render import MapMarker
|
||
from meshterm.ui.map_screen import MapScreen
|
||
|
||
markers = [
|
||
MapMarker("Hilltop-Repeater", 45.53, -73.71, is_repeater=True),
|
||
MapMarker("Alice", 45.40, -73.50),
|
||
]
|
||
screen = MapScreen(_StubSession(80, 24), markers, _StubSource(), 14)
|
||
screen.render_body(80)
|
||
before = screen._viewport
|
||
screen.handle("frame") # no query typed: nothing to frame, so the view does not move
|
||
assert screen.picocalc_lyra_lane[2].enabled is False and screen._viewport is before
|
||
|
||
for ch in "ali":
|
||
screen.handle("text", ch)
|
||
assert screen.picocalc_lyra_lane[2].enabled is True
|
||
screen.handle("frame")
|
||
assert screen._viewport.center_lat == pytest.approx(45.40)
|
||
|
||
# A query that matches nothing has no extent either. The chip goes back to dim.
|
||
for ch in "zzz":
|
||
screen.handle("text", ch)
|
||
assert screen._matches() == [] and screen.picocalc_lyra_lane[2].enabled is False
|
||
|
||
|
||
def test_map_screen_find_filters_frames_and_clears() -> None:
|
||
"""Typing builds the query. ^Enter frames the matches. Enter and Esc clear it. Esc leaves."""
|
||
from meshterm.ui.map_render import MapMarker
|
||
from meshterm.ui.map_screen import MapScreen
|
||
|
||
markers = [
|
||
MapMarker("Hilltop-Repeater", 45.53, -73.71, is_repeater=True),
|
||
MapMarker("Alice", 45.40, -73.50),
|
||
MapMarker("Yagi-North", 45.60, -73.65),
|
||
]
|
||
screen = MapScreen(_StubSession(80, 24), markers, _StubSource(), 14)
|
||
screen.render_body(80)
|
||
|
||
for ch in "hi":
|
||
screen.handle("text", ch)
|
||
assert screen._filter == "hi"
|
||
assert [m.label for m in screen._matches()] == ["Hilltop-Repeater"]
|
||
screen.render_body(80)
|
||
assert "1 of 3 match" in screen.title
|
||
assert "find: hi" in screen.footer_hint
|
||
|
||
# Only the match keeps a label. The others become dim glyphs without labels.
|
||
body = _plain(screen.render_body(80))
|
||
assert "Hilltop-Repeater" in body
|
||
assert "Alice" not in body and "Yagi-North" not in body
|
||
|
||
# ^Enter frames the matches. The view centres on the repeater, and the query stays.
|
||
screen.handle("ctrl_enter")
|
||
assert screen._viewport.center_lat == pytest.approx(45.53, abs=0.05)
|
||
assert screen._viewport.center_lon == pytest.approx(-73.71, abs=0.05)
|
||
assert screen._filter == "hi"
|
||
|
||
# A plain Enter is the other way out. The query goes, and the view stays where the
|
||
# frame put it.
|
||
centre = (screen._viewport.center_lat, screen._viewport.center_lon)
|
||
screen.handle("enter")
|
||
assert screen._filter == ""
|
||
assert (screen._viewport.center_lat, screen._viewport.center_lon) == centre
|
||
|
||
# Backspace edits the query. Esc first clears the filter, and then it closes the screen.
|
||
for ch in "hi":
|
||
screen.handle("text", ch)
|
||
screen.handle("backspace")
|
||
assert screen._filter == "h"
|
||
|
||
async def drive() -> object:
|
||
screen.future = asyncio.get_running_loop().create_future()
|
||
screen.handle("escape")
|
||
assert screen._filter == "" and not screen.future.done()
|
||
screen.handle("escape")
|
||
return await screen.future
|
||
|
||
assert asyncio.run(drive()) is None
|
||
|
||
|
||
def test_map_screen_frame_zooms_in_on_a_single_match() -> None:
|
||
"""^Enter on one filtered node zooms in close on it, not to the neutral default of the fit."""
|
||
from meshterm.ui.map_render import MapMarker
|
||
from meshterm.ui.map_screen import _FIND_ZOOM, MapScreen
|
||
|
||
markers = [
|
||
MapMarker("Hilltop-Repeater", 45.53, -73.71, is_repeater=True),
|
||
MapMarker("Alice", 45.40, -73.50),
|
||
]
|
||
screen = MapScreen(_StubSession(80, 24), markers, _StubSource(), 19) # source max 19
|
||
screen.render_body(80)
|
||
for ch in "hil":
|
||
screen.handle("text", ch)
|
||
assert [m.label for m in screen._matches()] == ["Hilltop-Repeater"]
|
||
screen.handle("ctrl_enter")
|
||
assert screen._viewport.center_lat == pytest.approx(45.53, abs=0.01)
|
||
assert screen._viewport.center_lon == pytest.approx(-73.71, abs=0.01)
|
||
assert screen._viewport.zoom == _FIND_ZOOM # zoomed in, not the default 14 of the fit
|
||
|
||
|
||
def test_map_screen_restores_and_persists_view() -> None:
|
||
"""The screen reopens on a saved view and reports each change of the centre or the zoom."""
|
||
from meshterm.ui.map_render import MapMarker
|
||
from meshterm.ui.map_screen import MapScreen
|
||
|
||
saved: list[tuple[float, float, int]] = []
|
||
markers = [MapMarker("A", 45.50, -73.60), MapMarker("B", 45.40, -73.50)]
|
||
screen = MapScreen(
|
||
_StubSession(80, 24),
|
||
markers,
|
||
_StubSource(),
|
||
14,
|
||
saved_view=(46.80, -71.20, 12),
|
||
on_view_change=lambda vp: saved.append((vp.center_lat, vp.center_lon, vp.zoom)),
|
||
)
|
||
|
||
screen.render_body(80)
|
||
# The screen restores the saved centre and zoom. It does not fit the markers.
|
||
assert screen._viewport.zoom == 12
|
||
assert screen._viewport.center_lat == pytest.approx(46.80)
|
||
assert screen._viewport.center_lon == pytest.approx(-71.20)
|
||
assert saved == [] # a reopen with no change does not write the stored view again
|
||
|
||
screen.handle("right") # a pan to the east stores the moved view
|
||
assert saved and saved[-1][2] == 12 # the zoom did not change
|
||
assert saved[-1][1] > -71.20 # the centre moved east
|
||
|
||
screen.handle("pageup") # a zoom in also stores the view
|
||
assert saved[-1][2] == 13
|
||
|
||
|
||
async def test_open_map_focus_centres_on_the_node_without_clobbering_the_saved_view(
|
||
ctx, monkeypatch
|
||
) -> None:
|
||
"""A focused map centres on its node and does not overwrite the saved view.
|
||
|
||
The user can open the full map from the detail page of a node. The map centres there at
|
||
the zoom of the inline preview. It is a short look, so it never overwrites the stored
|
||
global view ("where you left the map"), also when the user pans and zooms the look.
|
||
"""
|
||
from meshterm.core.geo import clamp_lat
|
||
from meshterm.ui.map_render import MapMarker
|
||
from meshterm.ui.map_screen import open_map
|
||
from meshterm.ui.surface import TuiUi
|
||
|
||
monkeypatch.setattr("meshterm.ui.map_screen.basemap_source", lambda _c: _StubSource())
|
||
ctx.repo.set_map_view(10.0, 20.0, 8) # where the user left the global map
|
||
|
||
session = _CapturingSession(80, 24, keys=("right", "pageup")) # pan and zoom the look
|
||
ctx.ui = TuiUi(session)
|
||
await open_map(
|
||
ctx,
|
||
[MapMarker("Hub", 45.5, -73.6, is_repeater=True)],
|
||
focus=(45.5, -73.6),
|
||
find="Hub",
|
||
)
|
||
|
||
# The map opened on the node, at the zoom of the detail preview (min(13, max_zoom)).
|
||
assert session.screen._saved_view == (clamp_lat(45.5), -73.6, 13)
|
||
assert session.screen._on_view_change is None # a focused look has no storage
|
||
assert session.screen._filter == "Hub" # the find starts with the focused node
|
||
# The pan and the zoom of the look did not change the global "where you left the map" view.
|
||
assert ctx.repo.get_map_view() == pytest.approx((10.0, 20.0, 8))
|
||
assert session.repainted # cleaned the terminal on the way out, as the plain open does
|
||
|
||
|
||
async def test_open_map_without_focus_restores_and_persists_the_global_view(
|
||
ctx, monkeypatch
|
||
) -> None:
|
||
"""With no focus, the full map restores and stores the global view.
|
||
|
||
It reopens where the user left it, and each pan is stored back to that view.
|
||
"""
|
||
from meshterm.ui.map_render import MapMarker
|
||
from meshterm.ui.map_screen import open_map
|
||
from meshterm.ui.surface import TuiUi
|
||
|
||
monkeypatch.setattr("meshterm.ui.map_screen.basemap_source", lambda _c: _StubSource())
|
||
ctx.repo.set_map_view(46.80, -71.20, 12) # the saved view for the reopen
|
||
|
||
session = _CapturingSession(80, 24, keys=("right",)) # pan east
|
||
ctx.ui = TuiUi(session)
|
||
await open_map(ctx, [MapMarker("Hub", 46.8, -71.2)])
|
||
|
||
assert session.screen._viewport.zoom == 12 # reopened on the saved view, not on a fit to a node
|
||
lat, lon, zoom = ctx.repo.get_map_view()
|
||
assert zoom == 12 and lon > -71.20 # the pan to the east was stored in the global view
|
||
|
||
|
||
def test_map_screen_seeded_find_behaves_like_a_typed_one() -> None:
|
||
"""A find that the app starts matches, titles, and clears as if the user typed it."""
|
||
from meshterm.ui.map_render import MapMarker
|
||
from meshterm.ui.map_screen import MapScreen
|
||
|
||
markers = [MapMarker("Hub", 45.5, -73.6), MapMarker("Far", 45.6, -73.5)]
|
||
screen = MapScreen(_StubSession(80, 24), markers, _StubSource(), 14, find="Hub")
|
||
assert [m.label for m in screen._matches()] == ["Hub"] # only the node of the seed lights
|
||
assert "find: Hub" in screen.footer_hint # the query shows, and the user can edit it
|
||
screen.handle("escape") # the first Esc clears the find (not the map)
|
||
assert screen._filter == "" and len(screen._matches()) == 2
|
||
|
||
|
||
def test_map_screen_scrubs_right_edge_after_move() -> None:
|
||
"""A move asks the session to scrub the right edge one time, to clear any braille smear."""
|
||
from meshterm.ui.map_render import MapMarker
|
||
from meshterm.ui.map_screen import MapScreen
|
||
|
||
markers = [MapMarker("A", 45.5, -73.6)]
|
||
braille = MapScreen(_StubSession(80, 24), markers, _StubSource(), 14)
|
||
braille.render_body(80)
|
||
braille.handle("right") # a move flags the edge for a scrub
|
||
assert braille.consume_edge_scrub() == 1 # the border column: the map is at the edge
|
||
assert braille.consume_edge_scrub() == 0 # consumed: no repeat without another move
|
||
|
||
|
||
def test_map_screen_escape_dismisses() -> None:
|
||
"""Esc resolves the future of the screen with None (and goes back to the menu)."""
|
||
from meshterm.ui.map_render import MapMarker
|
||
from meshterm.ui.map_screen import MapScreen
|
||
|
||
screen = MapScreen(_StubSession(80, 24), [MapMarker("A", 45.5, -73.6)], _StubSource(), 14)
|
||
|
||
async def drive() -> object:
|
||
screen.future = asyncio.get_running_loop().create_future()
|
||
screen.handle("escape")
|
||
return await screen.future
|
||
|
||
assert asyncio.run(drive()) is None
|
||
|
||
|
||
def test_map_observation_round_trips_node_type(ctx) -> None:
|
||
"""The advert type of a stored observation stays through storage and aggregation."""
|
||
run_id = ctx.repo.start_run("monitor", {})
|
||
ctx.repo.record_observation(
|
||
run_id,
|
||
Observation(
|
||
node="a1", name="Yagi", node_type=NODE_TYPE_REPEATER, snr=6.0, lat=45.5, lon=-73.5
|
||
),
|
||
)
|
||
node = next(n for n in ctx.repo.heard_nodes() if n.node == "a1")
|
||
assert node.is_repeater and node.node_type == NODE_TYPE_REPEATER
|
||
|
||
|
||
def test_render_map_labels_take_the_name_hue_ours_white() -> None:
|
||
"""A map label takes the hue of its node, and our label takes the white of you.
|
||
|
||
Our label is white, and the ★ glyph keeps the colour of our star. A label with no key gets
|
||
the muted grey.
|
||
"""
|
||
from meshterm.ui.map_render import MapMarker, render_map
|
||
from meshterm.ui.marks import SELF_MARK
|
||
from meshterm.ui.theme import mark_rgb
|
||
from meshterm.ui.widgets import name_rgb
|
||
|
||
vp = Viewport.fit([(45.5, -73.6), (45.4, -73.5)], 120, 80, max_zoom=14)
|
||
markers = [
|
||
MapMarker("US", 45.5, -73.6, is_self=True, key="cc" * 32),
|
||
MapMarker("KEYED", 45.4, -73.5, key="d4" * 32),
|
||
MapMarker("BARE", 45.45, -73.55),
|
||
]
|
||
lines = render_map(vp, {}, markers)
|
||
assert _glyph_color(lines, "★") == mark_rgb(SELF_MARK[1]) # the colour of our star
|
||
assert _glyph_color(lines, "US") == (255, 255, 255) # ...the label is the white of you
|
||
assert _glyph_color(lines, "KEYED") == name_rgb("KEYED", "d4" * 32)
|
||
assert _glyph_color(lines, "BARE") == (162, 162, 162) # no key: the muted grey
|
||
|
||
|
||
def test_render_map_find_matches_still_label_white() -> None:
|
||
"""An active find filter still makes the matching labels full white."""
|
||
from meshterm.ui.map_render import MapMarker, render_map
|
||
|
||
vp = Viewport.fit([(45.5, -73.6), (45.4, -73.5)], 120, 80, max_zoom=14)
|
||
markers = [MapMarker("TARGET", 45.5, -73.6, key="d4" * 32)]
|
||
lines = render_map(vp, {}, markers, find="targ")
|
||
assert _glyph_color(lines, "TARGET") == (255, 255, 255)
|
||
|
||
|
||
# --- the map asks again for a tile that had no answer ---------------------------------
|
||
|
||
|
||
def _map_over(source) -> MapScreen: # noqa: ANN001
|
||
"""A map screen that fetches from ``source``, with an event loop that can take its work."""
|
||
from meshterm.ui.map_render import MapMarker
|
||
from meshterm.ui.map_screen import MapScreen
|
||
|
||
return MapScreen(_StubSession(80, 24), [MapMarker("A", 45.5, -73.6)], source, 14)
|
||
|
||
|
||
async def _drawn(screen) -> None: # noqa: ANN001
|
||
"""Paint until the tiles of the view have answered and its raster has arrived.
|
||
|
||
The function uses real sleeps, not ``sleep(0)``, because a tile load goes through a
|
||
thread. The screen does not ask for a raster while a tile of the view is on its way.
|
||
Thus a test of the raster must let the loads finish first. (The stub sources answer at
|
||
once, with no data.)
|
||
"""
|
||
for _ in range(400):
|
||
screen.render_body(80)
|
||
if (
|
||
not screen._pending
|
||
and screen._drawing is None
|
||
and screen._frame_key == screen._ground_key(screen._viewport)
|
||
):
|
||
return
|
||
await asyncio.sleep(0.005)
|
||
raise AssertionError("the view's ground never landed")
|
||
|
||
|
||
class _SilentSource(_StubSource):
|
||
"""A source that never answers. This is a weak Wi-Fi link, not an empty planet."""
|
||
|
||
def __init__(self) -> None:
|
||
self.asked: list[tuple[int, int, int]] = []
|
||
|
||
def load_tile(self, z: int, x: int, y: int):
|
||
self.asked.append((z, x, y))
|
||
return None
|
||
|
||
|
||
class _EmptySource(_SilentSource):
|
||
"""A source that answers, and the answer is that those tiles have nothing."""
|
||
|
||
def answered_empty(self, z: int, x: int, y: int) -> bool:
|
||
return True
|
||
|
||
|
||
def test_map_asks_again_for_a_tile_it_got_no_answer_about(monkeypatch) -> None: # noqa: ANN001
|
||
"""One short Wi-Fi failure must not black out that ground until the app restarts.
|
||
|
||
On the PicoCalc, a missing z14 tile is the whole picture at the two highest zooms. At
|
||
these zooms, one tile covers the whole screen many times (JP, 2026-08-18).
|
||
"""
|
||
from meshterm.ui import map_screen as ms
|
||
|
||
source = _SilentSource()
|
||
screen = _map_over(source)
|
||
|
||
async def drive() -> None:
|
||
screen.render_body(80)
|
||
while screen._pending:
|
||
await asyncio.sleep(0)
|
||
first = len(source.asked)
|
||
assert first, "no tile was ever asked for"
|
||
|
||
screen.render_body(80) # still in the cooldown
|
||
await asyncio.sleep(0)
|
||
assert len(source.asked) == first, "the source is being hammered"
|
||
|
||
monkeypatch.setattr(ms, "monotonic", lambda: monotonic() + _TILE_RETRY + 1)
|
||
screen.render_body(80)
|
||
while screen._pending:
|
||
await asyncio.sleep(0)
|
||
assert len(source.asked) > first, "the unanswered tile was written off for good"
|
||
|
||
asyncio.run(drive())
|
||
|
||
|
||
def test_map_takes_the_sources_word_that_a_tile_is_empty(monkeypatch) -> None: # noqa: ANN001
|
||
"""An answer is final. The map must not request ocean tiles again for ever."""
|
||
from meshterm.ui import map_screen as ms
|
||
|
||
source = _EmptySource()
|
||
screen = _map_over(source)
|
||
|
||
async def drive() -> None:
|
||
screen.render_body(80)
|
||
while screen._pending:
|
||
await asyncio.sleep(0)
|
||
asked = len(source.asked)
|
||
|
||
monkeypatch.setattr(ms, "monotonic", lambda: monotonic() + _TILE_RETRY * 10)
|
||
screen.render_body(80)
|
||
await asyncio.sleep(0)
|
||
assert len(source.asked) == asked, "a settled answer was asked about again"
|
||
|
||
asyncio.run(drive())
|
||
|
||
|
||
def test_map_fetches_before_the_source_has_resolved(monkeypatch) -> None: # noqa: ANN001
|
||
"""``available`` means "not yet", not "do not try". A fetch is what resolves it."""
|
||
source = _SilentSource()
|
||
assert source.available is False # nothing used the network at open time
|
||
screen = _map_over(source)
|
||
|
||
async def drive() -> None:
|
||
screen.render_body(80)
|
||
while screen._pending:
|
||
await asyncio.sleep(0)
|
||
assert source.asked, "the map wrote the source off instead of asking it"
|
||
|
||
asyncio.run(drive())
|
||
|
||
|
||
# --- the map expects the next move ---------------------------------------------------
|
||
|
||
|
||
class _CountingSource(_StubSource):
|
||
"""A source that answers at once and remembers each request that it got."""
|
||
|
||
available = True
|
||
|
||
def __init__(self) -> None:
|
||
self.asked: list[tuple[int, int, int]] = []
|
||
|
||
def load_tile(self, z: int, x: int, y: int):
|
||
self.asked.append((z, x, y))
|
||
return _loaded_tile()
|
||
|
||
def warm(self, z: int, x: int, y: int) -> None:
|
||
self.asked.append((z, x, y)) # a guess is also a request, but it holds nothing
|
||
|
||
|
||
def _settled_map(source, zoom: int = 13):
|
||
"""A map screen with its view drawn and each visible tile loaded."""
|
||
from meshterm.ui.map_render import MapMarker
|
||
from meshterm.ui.map_screen import MapScreen
|
||
|
||
screen = MapScreen(
|
||
_StubSession(80, 24),
|
||
[MapMarker("A", 45.5, -73.6)],
|
||
source,
|
||
14,
|
||
saved_view=(45.5, -73.6, zoom),
|
||
)
|
||
return screen
|
||
|
||
|
||
async def _quiet(screen, source) -> list:
|
||
"""Paint until the prefetcher has nothing more to do, and report what it requested.
|
||
|
||
The function uses real sleeps, not ``sleep(0)``. The raster and each tile load go
|
||
through ``asyncio.to_thread``, and a thread does not finish in one turn of the loop.
|
||
"""
|
||
for _ in range(200):
|
||
screen.render_body(80)
|
||
screen._settled_at = 0.0 # the settle wait has its own test, so do not wait for it
|
||
await asyncio.sleep(0.005)
|
||
if (
|
||
not screen._pending
|
||
and not screen._speculating
|
||
and screen._drawing is None
|
||
and screen._next_speculation(screen._viewport) is None
|
||
):
|
||
break
|
||
visible = set(screen._viewport.tiles(14))
|
||
return [t for t in source.asked if t not in visible]
|
||
|
||
|
||
def test_map_fetches_ahead_of_the_way_it_is_being_panned() -> None:
|
||
"""Two steps in one direction are a heading, and the map fetches the ground ahead of it."""
|
||
source = _CountingSource()
|
||
screen = _settled_map(source)
|
||
|
||
async def drive() -> list:
|
||
await _quiet(screen, source)
|
||
source.asked.clear()
|
||
screen._speculated.clear()
|
||
for _ in range(2): # two steps east: a heading, not a small move
|
||
screen.handle("right")
|
||
return await _quiet(screen, source)
|
||
|
||
ahead = asyncio.run(drive())
|
||
vp = screen._viewport
|
||
east = set(vp.panned(_PAN_STEP, 0).tiles(14)) - set(vp.tiles(14))
|
||
west = set(vp.panned(-_PAN_STEP, 0).tiles(14)) - set(vp.tiles(14))
|
||
assert east, "the fixture leaves no tile to the east to fetch"
|
||
assert east <= set(ahead), "the ground ahead of the pan was not fetched"
|
||
if west & set(ahead): # a hedge is permitted, but it comes after the heading
|
||
assert ahead.index(next(iter(east))) < ahead.index(next(iter(west & set(ahead))))
|
||
|
||
|
||
def test_map_fetches_the_step_out_it_has_no_tiles_for() -> None:
|
||
"""A zoom out is the one move whose tiles were not on the display before."""
|
||
source = _CountingSource()
|
||
screen = _settled_map(source)
|
||
|
||
async def drive() -> list:
|
||
return await _quiet(screen, source)
|
||
|
||
ahead = asyncio.run(drive())
|
||
out = set(screen._viewport.zoomed(-1).tiles(14))
|
||
assert out <= set(ahead), "the view one step out was not anticipated"
|
||
|
||
|
||
def test_map_does_not_fetch_ahead_while_the_view_is_still_arriving() -> None:
|
||
"""A speculative fetch must not get in the way of the view that the user looks at."""
|
||
source = _CountingSource()
|
||
screen = _settled_map(source)
|
||
|
||
async def drive() -> None:
|
||
screen.render_body(80) # the visible tiles are pending, and nothing is drawn
|
||
assert screen._pending, "the fixture settled too early to test this"
|
||
screen._ensure_prefetch(screen._viewport)
|
||
assert not screen._speculating, "fetched ahead while the visible tiles were in flight"
|
||
|
||
screen._pending.clear()
|
||
screen._drawing = ("busy",)
|
||
screen._ensure_prefetch(screen._viewport)
|
||
assert not screen._speculating, "fetched ahead while the ground was being drawn"
|
||
|
||
asyncio.run(drive())
|
||
|
||
|
||
def test_map_waits_for_the_view_to_settle_before_it_guesses() -> None:
|
||
"""A raster that arrives between two key presses of a pan is a gap, not a pause.
|
||
|
||
If a fetch starts there, a tile decode goes in front of the next frame. This took
|
||
approximately 110 ms on the handheld, and the prefetcher must never cost that.
|
||
"""
|
||
source = _CountingSource()
|
||
screen = _settled_map(source)
|
||
|
||
async def drive() -> None:
|
||
screen.render_body(80) # builds the viewport
|
||
screen._pending.clear() # ...and now the view has arrived and is drawn
|
||
screen._drawing = screen._wanted = None
|
||
|
||
screen._ensure_prefetch(screen._viewport) # marks the view as new
|
||
assert not screen._speculating, "guessed while the view was still moving"
|
||
|
||
screen._settled_at = 0.0 # ...and now the view has been still for some time
|
||
screen._ensure_prefetch(screen._viewport)
|
||
assert screen._speculating, "never got round to guessing at all"
|
||
|
||
asyncio.run(drive())
|
||
|
||
|
||
def test_map_forgets_its_heading_when_the_reader_reframes() -> None:
|
||
"""A zoom, or a jump to home, is a look around. It is not a continued travel."""
|
||
screen = _settled_map(_CountingSource())
|
||
screen.render_body(80)
|
||
screen.handle("right")
|
||
screen.handle("right") # two coarse steps east
|
||
assert screen._heading == "right" and screen._momentum == 2
|
||
|
||
screen.handle("pagedown") # a zoom out
|
||
assert screen._heading is None and screen._momentum == 0
|
||
plan = screen._prefetch_plan(screen._viewport)
|
||
vp = screen._viewport
|
||
for direction, (dx, dy) in _PAN_DIRS.items():
|
||
stepped = set(vp.panned(dx * _PAN_STEP, dy * _PAN_STEP).tiles(14))
|
||
assert stepped - set(vp.tiles(14)) <= set(plan) or not (stepped - set(vp.tiles(14))), (
|
||
f"{direction} was not hedged once the heading was gone"
|
||
)
|
||
|
||
|
||
def test_map_does_not_guess_in_the_paint_that_queues_a_raster() -> None:
|
||
"""The map reads the pacing rule at the end of a paint, not from the answer of the last paint.
|
||
|
||
A key press in the find does not move the view. Thus this is still the settled view
|
||
that the prefetcher can use for a guess. But the paint that the key press causes queues
|
||
the raster that the user waits for. If the map asks one step earlier, from inside
|
||
``_ensure_tiles``, the prefetcher sees the quiet of the previous paint. Then it starts
|
||
a tile decode at the same time as the raster.
|
||
"""
|
||
source = _CountingSource()
|
||
screen = _settled_map(source)
|
||
|
||
async def drive() -> None:
|
||
await _quiet(screen, source)
|
||
screen._speculated.clear()
|
||
screen._settled_at = 0.0 # settled, quiet, and ready to guess
|
||
|
||
screen.handle("text", "a")
|
||
screen.render_body(80)
|
||
assert screen._drawing is not None, "the fixture drew nothing to be in the way of"
|
||
assert not screen._speculating, "guessed alongside the raster the reader waits for"
|
||
|
||
asyncio.run(drive())
|
||
|
||
|
||
def test_map_leaves_a_cooling_tile_alone_when_it_guesses() -> None:
|
||
"""A guess must not touch a source that has just gone quiet."""
|
||
screen = _settled_map(_CountingSource())
|
||
screen.render_body(80) # builds the viewport
|
||
vp = screen._viewport
|
||
plan = screen._prefetch_plan(vp)
|
||
assert plan, "the fixture had nothing to guess at"
|
||
|
||
screen._unanswered = {t: monotonic() + 20.0 for t in plan}
|
||
assert screen._next_speculation(vp) is None, "guessed at a tile serving out its silence"
|
||
|
||
screen._unanswered.clear() # the cooldown ends, and the plan is valid again
|
||
assert screen._next_speculation(vp) in plan
|
||
|
||
|
||
def test_map_keeps_no_speculated_tile_of_its_own() -> None:
|
||
"""A prefetch warms the cache of the source. The screen holds only what it draws."""
|
||
source = _CountingSource()
|
||
screen = _settled_map(source)
|
||
|
||
async def drive() -> None:
|
||
await _quiet(screen, source)
|
||
|
||
asyncio.run(drive())
|
||
visible = set(screen._viewport.tiles(14))
|
||
assert set(screen._tiles) <= visible, "speculated tiles landed in the view's working set"
|
||
assert screen._speculated, "nothing was speculated at all"
|
||
|
||
|
||
def test_map_takes_a_tile_already_in_memory_without_a_round_trip() -> None:
|
||
"""Ground that the user pans back to is in the first raster, not one thread step later."""
|
||
|
||
class _HeldSource(_CountingSource):
|
||
def resident(self, z: int, x: int, y: int):
|
||
return _loaded_tile()
|
||
|
||
source = _HeldSource()
|
||
|
||
async def drive():
|
||
screen = _settled_map(source)
|
||
screen.render_body(80)
|
||
return screen
|
||
|
||
screen = asyncio.run(drive())
|
||
visible = screen._viewport.tiles(14)
|
||
assert all(screen._tiles.get(t) for t in visible), "a held tile was not taken"
|
||
assert not screen._pending and not source.asked, "a held tile was sent to a thread"
|
||
|
||
|
||
def test_map_drops_a_tile_the_view_left_before_its_turn() -> None:
|
||
"""A fast pan asks for each view that it passes. Only the view where it stops is worth loading.
|
||
|
||
To load the others was a backlog. On the PicoCalc, each decode took one second or more.
|
||
The map stayed behind the keys for a long time after the keys stopped.
|
||
"""
|
||
source = _CountingSource()
|
||
|
||
async def drive():
|
||
screen = _settled_map(source)
|
||
screen.render_body(80) # asks for the tiles of the opening view
|
||
opening = set(screen._pending)
|
||
for _ in range(8): # gone, before any load has its turn
|
||
screen.handle("right")
|
||
await asyncio.sleep(0.05)
|
||
return screen, opening
|
||
|
||
screen, opening = asyncio.run(drive())
|
||
left_behind = opening - set(screen._viewport.tiles(14))
|
||
assert left_behind, "the pan never left the opening view"
|
||
assert not left_behind & set(source.asked), "a tile the view had left was loaded anyway"
|
||
assert not left_behind & screen._pending, "a dropped tile is still counted as on its way"
|
||
|
||
|
||
# --- the coarse first pass -----------------------------------------------------------
|
||
|
||
|
||
def test_coarse_ground_keeps_the_shape_and_drops_the_detail() -> None:
|
||
"""The quick pass draws water, parks, and through roads. It draws no buildings and no text."""
|
||
from meshterm.ui.map_render import MapMarker, render_ground
|
||
|
||
vp = Viewport(45.5019, -73.5674, 15, 160, 96)
|
||
tiles = {(14, 4843, 5861): decode_tile(_FIXTURE.read_bytes())}
|
||
markers = [MapMarker("Yagi", 45.5019, -73.5674)]
|
||
|
||
coarse, _ = render_ground(vp, tiles, markers, coarse=True)
|
||
full, _ = render_ground(vp, tiles, markers)
|
||
|
||
def ink(lines):
|
||
return sum(1 for ch in _plain(lines) if ch.strip())
|
||
|
||
assert ink(coarse), "the coarse pass drew nothing at all"
|
||
assert ink(coarse) < ink(full), "the coarse pass drew as much as the full one"
|
||
# The label of our marker goes over each paint. The text of the basemap does not.
|
||
assert "Yagi" in _plain(coarse)
|
||
assert not (_street_names(full) & _street_names(coarse)), "text came with the quick pass"
|
||
|
||
|
||
def _street_names(lines: list[str]) -> set:
|
||
"""The words in a frame that are basemap text, not the label of a node marker."""
|
||
return {w for w in re.findall(r"[A-Za-zÀ-ÿ]{4,}", _plain(lines))} - {"Yagi"}
|
||
|
||
|
||
def test_map_draws_a_coarse_frame_before_the_finished_one(monkeypatch) -> None: # noqa: ANN001
|
||
"""A view with no ground under it must not stay black for the time of a whole raster.
|
||
|
||
A coarse pan step is 30% of the screen, so four key presses leave nothing of the last
|
||
frame to project again. A full raster takes most of a second on the PicoCalc (JP,
|
||
2026-08-18).
|
||
"""
|
||
from meshterm.ui import map_screen as ms
|
||
|
||
passes: list = []
|
||
monkeypatch.setattr(
|
||
ms,
|
||
"render_ground",
|
||
lambda vp, tiles, m, **k: passes.append(k.get("coarse", False)) or (["x"], None),
|
||
)
|
||
screen = _map_over(_StubSource())
|
||
|
||
async def drive() -> None:
|
||
screen.render_body(80) # builds the viewport, and asks for its first draw
|
||
while screen._drawing is not None:
|
||
await asyncio.sleep(0)
|
||
passes.clear()
|
||
await screen._draw_ground(("k",), screen._viewport, {}, [], "", True)
|
||
|
||
asyncio.run(drive())
|
||
assert passes == [True, False], "the coarse pass did not come first, or at all"
|
||
assert screen._frame_coarse is False, "the frame was left owing its detail"
|
||
|
||
|
||
def test_map_abandons_the_finished_pass_for_a_view_that_moved(monkeypatch) -> None: # noqa: ANN001
|
||
"""A held key takes effect between the passes, so the map must not finish an old frame."""
|
||
from meshterm.ui import map_screen as ms
|
||
|
||
screen = _map_over(_StubSource())
|
||
passes: list = []
|
||
|
||
def render(vp, tiles, m, **k):
|
||
coarse = k.get("coarse", False)
|
||
passes.append(coarse)
|
||
if coarse and len(passes) == 1:
|
||
# The user pans further while the coarse pass is drawn.
|
||
screen._wanted = (("newer",), screen._viewport, [], "")
|
||
return ["x"], None
|
||
|
||
monkeypatch.setattr(ms, "render_ground", render)
|
||
|
||
async def drive() -> tuple:
|
||
screen.render_body(80)
|
||
while screen._drawing is not None:
|
||
await asyncio.sleep(0)
|
||
passes.clear()
|
||
screen._drawing = ("k",)
|
||
await screen._draw_ground(("k",), screen._viewport, {}, [], "", True)
|
||
# Read the values here, and not after the loop closes. The draw of the newer view
|
||
# is its own task by now, and what it asks for is not part of this test. The test
|
||
# asks only what the abandoned key got.
|
||
return list(passes), screen._frame_coarse
|
||
|
||
stale, rough_on_screen = asyncio.run(drive())
|
||
assert stale == [True], "a finished pass was drawn for a view already left behind"
|
||
assert rough_on_screen is True, "the abandoned view left no rough picture on screen"
|
||
|
||
|
||
def test_map_skips_the_coarse_pass_over_a_picture_already_drawn(monkeypatch) -> None: # noqa: ANN001
|
||
"""A tile that arrives must refine the frame. It must not make the frame coarser."""
|
||
from meshterm.ui import map_screen as ms
|
||
|
||
screen = _map_over(_StubSource())
|
||
coarse_asked: list = []
|
||
monkeypatch.setattr(
|
||
ms,
|
||
"render_ground",
|
||
lambda vp, tiles, m, **k: coarse_asked.append(k.get("coarse", False)) or (["x"], None),
|
||
)
|
||
|
||
async def drive() -> None:
|
||
await _drawn(screen)
|
||
coarse_asked.clear()
|
||
# A tile arrives: the same view, with more detail. The frame on the display is still
|
||
# aligned.
|
||
screen._tiles[screen._viewport.tiles(14)[0]] = _loaded_tile()
|
||
screen.render_body(80)
|
||
while screen._drawing is not None:
|
||
await asyncio.sleep(0)
|
||
|
||
asyncio.run(drive())
|
||
assert coarse_asked == [False], "a drawn frame was replaced by a coarser one"
|
||
|
||
|
||
def test_the_rough_pass_is_a_switch_the_map_reads(monkeypatch) -> None: # noqa: ANN001
|
||
"""Both passes work. A switch sets whether a moved view gets the first pass.
|
||
|
||
If the switch is off, each view waits for the whole picture. It uses the ground of the
|
||
last frame until the picture arrives. The mechanism is in
|
||
:meth:`MapScreen._draw_ground` in both cases. The switch decides only the request. For
|
||
this reason the app reads the switch where it requests the raster, and not in the
|
||
renderer.
|
||
"""
|
||
from meshterm.ui import map_screen as ms
|
||
|
||
def first_draw() -> list:
|
||
"""The passes that a view with nothing drawn under it requests."""
|
||
asked: list = []
|
||
monkeypatch.setattr(
|
||
ms,
|
||
"render_ground",
|
||
lambda vp, tiles, m, **k: asked.append(k.get("coarse", False)) or (["x"], None),
|
||
)
|
||
screen = _map_over(_StubSource())
|
||
|
||
asyncio.run(_drawn(screen))
|
||
return asked
|
||
|
||
monkeypatch.setattr(ms, "_COARSE_PREVIEW", True)
|
||
assert first_draw() == [True, False], "the rough pass did not come first when asked for"
|
||
|
||
monkeypatch.setattr(ms, "_COARSE_PREVIEW", False)
|
||
assert first_draw() == [False], "a rough pass was drawn with the switch off"
|
||
|
||
|
||
def test_map_keeps_its_detail_while_a_find_is_typed(monkeypatch) -> None: # noqa: ANN001
|
||
"""If the user types a node name, the view does not move, so the streets must keep their detail.
|
||
|
||
A find query changes which markers are bright (the overlay). The ground under them is
|
||
the same ground. The app already drew it, and it projects it again at zero offset as the
|
||
stand-in. The app once decided on the rough pass from the whole picture and not from the
|
||
view. Then each letter replaced the streets with the coarse pass and drew them again.
|
||
"""
|
||
from meshterm.ui import map_screen as ms
|
||
|
||
screen = _map_over(_StubSource())
|
||
coarse_asked: list = []
|
||
monkeypatch.setattr(
|
||
ms,
|
||
"render_ground",
|
||
lambda vp, tiles, m, **k: coarse_asked.append(k.get("coarse", False)) or (["x"], None),
|
||
)
|
||
|
||
async def drive() -> None:
|
||
await _drawn(screen)
|
||
coarse_asked.clear()
|
||
screen.handle("text", "a") # one letter of a find query, and the view does not move
|
||
screen.render_body(80)
|
||
while screen._drawing is not None:
|
||
await asyncio.sleep(0)
|
||
|
||
asyncio.run(drive())
|
||
assert coarse_asked == [False], "a find keystroke coarsened the ground under it"
|
||
|
||
|
||
# --- the ground raster runs off the paint path ---------------------------------------
|
||
|
||
|
||
def _async_map(monkeypatch, drawn: list):
|
||
"""A map screen with a live event loop. The test captures its rasters and does not run them.
|
||
|
||
Its tile loads answer when the screen asks, with no data. If the test captured a load,
|
||
the load would be in progress for ever, and the screen does not ask for a raster while
|
||
a load is in progress.
|
||
"""
|
||
from meshterm.ui import map_screen as ms
|
||
from meshterm.ui.map_render import MapMarker
|
||
|
||
monkeypatch.setattr(ms, "_loop_running", lambda: True)
|
||
started: list[tuple] = []
|
||
monkeypatch.setattr(
|
||
ms.asyncio, "ensure_future", lambda coro: (coro.close(), started.append(coro))[0]
|
||
)
|
||
|
||
def answered_at_once(self, t): # noqa: ANN001 - MapScreen._load, without the thread
|
||
self._pending.discard(t)
|
||
self._unanswered[t] = monotonic() + _TILE_RETRY
|
||
return asyncio.sleep(0) # a coroutine that the captured ensure_future closes
|
||
|
||
monkeypatch.setattr(ms.MapScreen, "_load", answered_at_once)
|
||
screen = ms.MapScreen(_StubSession(80, 24), [MapMarker("A", 45.5, -73.6)], _StubSource(), 14)
|
||
drawn.append(started)
|
||
return screen
|
||
|
||
|
||
def test_map_paints_without_waiting_for_the_ground(monkeypatch) -> None: # noqa: ANN001
|
||
"""A pan must answer the key press at once. The rasterizing takes approximately 1 s there."""
|
||
from meshterm.ui import map_screen as ms
|
||
|
||
calls: list[tuple] = []
|
||
real = ms.render_map
|
||
monkeypatch.setattr(
|
||
ms,
|
||
"render_map",
|
||
lambda vp, tiles, m, **k: calls.append(tiles) or real(vp, tiles, m, **k),
|
||
)
|
||
started: list = []
|
||
screen = _async_map(monkeypatch, started)
|
||
|
||
screen.render_body(80)
|
||
# The code on the paint path drew no tiles. The ground is the job of another task.
|
||
assert calls, "the paint drew nothing at all"
|
||
assert all(tiles == {} for tiles in calls), calls
|
||
assert screen._drawing is not None, "no background raster was scheduled"
|
||
|
||
|
||
def _rasters(started: list) -> list:
|
||
"""Only the coroutines that rasterize the ground. A pan also schedules its tile fetches."""
|
||
return [c for c in started[0] if "_draw_ground" in c.__qualname__]
|
||
|
||
|
||
def test_map_keeps_one_raster_in_flight_while_panning(monkeypatch) -> None: # noqa: ANN001
|
||
"""A held arrow key must not queue a second of thread work for each key press."""
|
||
started: list = []
|
||
screen = _async_map(monkeypatch, started)
|
||
screen.render_body(80)
|
||
scheduled = len(_rasters(started))
|
||
|
||
for _ in range(8):
|
||
screen.handle("right")
|
||
screen.render_body(80)
|
||
|
||
assert len(_rasters(started)) == scheduled, "a second raster started before the first finished"
|
||
assert screen._wanted is not None, "the newest view was not remembered for next"
|
||
|
||
|
||
def test_map_holds_an_aligned_frame_while_a_tile_lands(monkeypatch) -> None: # noqa: ANN001
|
||
"""A tile that arrives must not blank the streets that are still in the correct place.
|
||
|
||
The view did not move, so the finished raster is still aligned. It only lacks the
|
||
detail of the new tile. If the map draws again from nothing, the ground disappears for
|
||
the second that the app needs to draw the same picture again.
|
||
"""
|
||
started: list = []
|
||
screen = _async_map(monkeypatch, started)
|
||
screen.render_body(80)
|
||
# Act as if the scheduled raster finished.
|
||
screen._frame = ["ground"] * 20
|
||
screen._frame_key = screen._ground_key(screen._viewport)
|
||
screen._drawing = None
|
||
# All the rows but the bottom row are the frame exactly. The bottom row also has the
|
||
# basemap credit. The app stamps it at paint time. It is not part of a raster that the
|
||
# app uses for many paints (refer to meshterm/ui/attribution.py).
|
||
painted = screen.render_body(80)
|
||
assert painted[:-1] == ["ground"] * 19
|
||
assert _plain(painted[-1]).startswith("ground")
|
||
|
||
in_view = screen._viewport.tiles(14)[0]
|
||
screen._tiles[in_view] = _loaded_tile() # a tile arrives, and the view does not move
|
||
assert screen.render_body(80)[:-1] == ["ground"] * 19, "dropped an aligned frame"
|
||
assert screen._drawing is not None, "did not redraw for the new tile"
|
||
|
||
|
||
def test_map_stands_on_its_ghost_until_the_view_tiles_are_in(monkeypatch) -> None: # noqa: ANN001
|
||
"""A zoom shows the ghost, then the map. There is no black screen between them.
|
||
|
||
The app once drew a raster while the tiles of the new zoom were still loading. This
|
||
painted their ground black. It was the frame of the view, so it replaced the ghost. The
|
||
user saw the ghost, then black, then the streets (JP, 2026-10-04). Also, the raster was
|
||
wasted, because the tiles that arrive ask for another one.
|
||
"""
|
||
from meshterm.ui import map_screen as ms
|
||
from meshterm.ui.map_render import MapMarker
|
||
|
||
monkeypatch.setattr(ms, "_loop_running", lambda: True)
|
||
started: list = []
|
||
monkeypatch.setattr(
|
||
ms.asyncio, "ensure_future", lambda coro: (coro.close(), started.append(coro))[0]
|
||
)
|
||
stood_on: list = []
|
||
monkeypatch.setattr(
|
||
ms, "render_map", lambda vp, tiles, m, **k: stood_on.append(k.get("ghost")) or [""]
|
||
)
|
||
screen = ms.MapScreen(_StubSession(80, 24), [MapMarker("A", 45.5, -73.6)], _StubSource(), 14)
|
||
screen._ghost = ghost = object() # the ground that the last view left
|
||
|
||
def rasters() -> list:
|
||
return [c for c in started if "_draw_ground" in c.__qualname__]
|
||
|
||
screen.handle("pageup") # zoom in: a tile zoom whose tiles are not loaded
|
||
screen.render_body(80)
|
||
assert screen._pending, "the fixture's view asked for no tiles"
|
||
assert not rasters(), "a raster was drawn before the view's tiles were in"
|
||
assert stood_on == [ghost], "the paint did not stand on the ghost"
|
||
|
||
for t in list(screen._pending): # the tiles arrive
|
||
screen._pending.discard(t)
|
||
screen._tiles[t] = _loaded_tile()
|
||
screen.render_body(80)
|
||
assert len(rasters()) == 1, "the tiles landed and their raster was not drawn"
|
||
|
||
|
||
def test_map_drops_a_stale_frame_once_the_view_moves(monkeypatch) -> None: # noqa: ANN001
|
||
"""Streets that the map drew for another place are false for the place that the user sees."""
|
||
started: list = []
|
||
screen = _async_map(monkeypatch, started)
|
||
screen.render_body(80)
|
||
screen._frame = ["ground"] * 20
|
||
screen._frame_key = screen._ground_key(screen._viewport)
|
||
screen._drawing = None
|
||
|
||
screen.handle("right")
|
||
assert screen.render_body(80) != ["ground"] * 20, "kept a misaligned frame"
|
||
|
||
|
||
async def test_map_pans_on_the_ground_its_last_raster_left(monkeypatch) -> None: # noqa: ANN001
|
||
"""The map removes a frame that is not aligned, but it keeps the ground and projects it again.
|
||
|
||
This is the only ground that the map has until the next raster arrives. A pan that
|
||
answers with an empty canvas blanks the map to black between each key press.
|
||
"""
|
||
from meshterm.ui import map_screen as ms
|
||
|
||
started: list = []
|
||
screen = _async_map(monkeypatch, started)
|
||
screen.render_body(80)
|
||
|
||
await screen._draw_ground(
|
||
screen._ground_key(screen._viewport),
|
||
screen._viewport,
|
||
{},
|
||
screen._markers,
|
||
"",
|
||
False,
|
||
)
|
||
assert screen._ghost is not None, "the finished raster left no ground behind"
|
||
assert screen._ghost.viewport == screen._viewport
|
||
|
||
handed: list = []
|
||
real = ms.render_map
|
||
monkeypatch.setattr(
|
||
ms,
|
||
"render_map",
|
||
lambda vp, tiles, m, **k: handed.append(k.get("ghost")) or real(vp, tiles, m, **k),
|
||
)
|
||
screen.handle("right")
|
||
screen.render_body(80)
|
||
assert handed and handed[-1] is screen._ghost, "the pan painted on an empty canvas"
|
||
|
||
|
||
def test_each_road_class_keeps_its_own_colour_through_the_per_tile_style_memo() -> None:
|
||
"""The app resolves the palette of the tile one time for each class, not for each feature.
|
||
|
||
A frame called ``mark_rgb`` approximately 30 000 times and ``Feature.get``
|
||
approximately 32 000 times. Both answered the same few questions. A memo for each tile
|
||
replaces them. This is correct only if each class still resolves to the same colour as
|
||
before. A motorway must not get the grey of the footway because it was drawn second.
|
||
"""
|
||
from meshterm.core.mvt import GEOM_LINE, Feature, Layer
|
||
from meshterm.ui.map_render import (
|
||
_RAIL,
|
||
_ROAD_DEFAULT,
|
||
_ROAD_STYLE,
|
||
_draw_tile,
|
||
_Frame,
|
||
)
|
||
from meshterm.ui.mapcanvas import MapCanvas
|
||
from meshterm.ui.theme import mark_rgb
|
||
|
||
classes = ["motorway", "footway", "residential", "rail", "no_such_class", None]
|
||
features = [
|
||
Feature(
|
||
geom_type=GEOM_LINE,
|
||
# One short horizontal line for each class, each on its own row of the tile.
|
||
rings=[[(200, 500 + i * 500), (3900, 500 + i * 500)]],
|
||
tags={} if cls is None else {"class": cls},
|
||
)
|
||
for i, cls in enumerate(classes)
|
||
]
|
||
# Wide enough that the whole tile is on the canvas, so the map draws each class.
|
||
vp = Viewport(45.5019, -73.5674, 14, 512, 512)
|
||
canvas = MapCanvas(vp.dot_w // 2, vp.dot_h // 4)
|
||
frame = _Frame(canvas=canvas, viewport=vp)
|
||
_draw_tile(
|
||
frame,
|
||
[Layer(name="transportation", extent=4096, features=features)],
|
||
14,
|
||
4843,
|
||
5861,
|
||
)
|
||
|
||
painted = {colour for row in canvas._color for colour in row if colour}
|
||
for cls in classes:
|
||
if cls == "rail":
|
||
expected = _RAIL
|
||
else:
|
||
expected = _ROAD_STYLE.get(cls or "", _ROAD_DEFAULT)
|
||
assert mark_rgb(expected[0]) in painted, f"{cls} lost its own colour"
|
||
|
||
|
||
def test_the_map_registers_no_cli_command() -> None:
|
||
"""The map is only in the menu. A picture has no scripted face.
|
||
|
||
Its answer is a drawing in which the colour shows the difference between nodes. The
|
||
scripted CLI does not have colour (refer to :mod:`meshterm.ui.script`). Thus the tool
|
||
registers nothing, and it does not print a block of braille that no program can parse.
|
||
The nodes with a position stay available to scripts through ``meshterm contacts``.
|
||
"""
|
||
import typer
|
||
|
||
app = typer.Typer()
|
||
MapTool().register_cli(app)
|
||
assert app.registered_commands == []
|
||
|
||
|
||
# -- the basemap credit -------------------------------------------------------
|
||
#
|
||
# These tests check the Attribution Guideline of the OpenStreetMap Foundation (quoted in
|
||
# meshterm/ui/attribution.py). The credit is in a corner of the map. It names
|
||
# "OpenStreetMap" and not an abbreviation. It is visible without an interaction. It can
|
||
# collapse after the user uses the map, to a form that is still a complete OSM attribution.
|
||
#
|
||
# The frame decides which corner, and the frames of the two platforms are different. A
|
||
# bordered frame puts the credit in its bottom border rule and does not change the
|
||
# drawing. A borderless frame has no rule, so it stamps the last row of the drawing. Thus
|
||
# most of these tests run two times, one time for each platform, and they assert on the
|
||
# surface that the platform marks.
|
||
|
||
|
||
def _credited_map(cols: int = 80) -> MapScreen:
|
||
"""A map screen with the offline source. The user just arrived and did not touch it."""
|
||
from meshterm.ui.map_render import MapMarker
|
||
from meshterm.ui.map_screen import MapScreen
|
||
|
||
return MapScreen(_StubSession(cols, 24), [MapMarker("A", 45.5, -73.6)], _StubSource(), 14)
|
||
|
||
|
||
def _last_row(screen: MapScreen, cols: int = 80) -> str:
|
||
"""The bottom row of a rendered map body, as plain text."""
|
||
return _plain(screen.render_body(cols)).splitlines()[-1]
|
||
|
||
|
||
#: The bottom corners of a panel, in the two shapes that Rich draws. They are rounded where
|
||
#: the console can show them. They are square where Rich judges that the console is an old
|
||
#: Windows console, and a captured pytest run on Windows is such a console. A test for one
|
||
#: shape passes on one OS only.
|
||
_BOTTOM_CORNERS = ("╰╯", "└┘")
|
||
|
||
|
||
def _bottom_rule(screen: MapScreen, cols: int = 80, rows: int = 24) -> str:
|
||
"""The bottom border rule of the composed frame, the line that has the caption."""
|
||
from rich.text import Text
|
||
|
||
from meshterm.ui.tui import frame
|
||
|
||
screen.note_viewport(max(1, rows - 4))
|
||
composed = frame.compose_base(Text(""), screen, screen.footer_hint, cols, rows)
|
||
return next(
|
||
ln
|
||
for ln in reversed([Text.from_ansi(x).plain for x in composed.split("\n")])
|
||
if any(pair[0] in ln for pair in _BOTTOM_CORNERS)
|
||
)
|
||
|
||
|
||
def _mark(screen: MapScreen, cols: int = 80) -> str:
|
||
"""Where this platform puts the credit: the bottom rule, or the last row of the drawing."""
|
||
return _bottom_rule(screen, cols) if screen.bottom_caption else _last_row(screen, cols)
|
||
|
||
|
||
def _both_platforms():
|
||
"""The two platforms. The generator sets REGULAR again at the end. The credit differs."""
|
||
from meshterm.platforms import PICOCALC_LYRA, REGULAR, set_platform
|
||
|
||
try:
|
||
for platform in (REGULAR, PICOCALC_LYRA):
|
||
set_platform(platform)
|
||
yield platform
|
||
finally:
|
||
set_platform(REGULAR)
|
||
|
||
|
||
def test_credit_names_openstreetmap_in_full_never_an_abbreviation() -> None:
|
||
"""OSMF: "Attribution must be to 'OpenStreetMap'". Thus neither form can abbreviate it."""
|
||
from meshterm.ui import attribution
|
||
|
||
for text in (attribution.CREDIT_FULL, attribution.CREDIT_SHORT):
|
||
assert "OpenStreetMap" in text
|
||
assert not re.search(r"\bOSM\b", text), text
|
||
# The short form is one of the two older forms that OSMF names as acceptable, so the
|
||
# collapse never removes the OpenStreetMap credit itself.
|
||
assert attribution.CREDIT_SHORT in ("© OpenStreetMap", "© OpenStreetMap contributors")
|
||
# The line that OpenFreeMap requires, without the half that it calls optional.
|
||
assert "OpenMapTiles" in attribution.CREDIT_FULL
|
||
assert "Data from OpenStreetMap" in attribution.CREDIT_FULL
|
||
|
||
|
||
def test_an_untouched_map_shows_the_whole_credit_on_every_platform() -> None:
|
||
"""The user must not need to interact with the map to see the attribution."""
|
||
from meshterm.ui import attribution
|
||
|
||
for platform in _both_platforms():
|
||
mark = _mark(_credited_map(platform.readable_cols), platform.readable_cols)
|
||
assert attribution.CREDIT_FULL in mark, platform.name
|
||
|
||
|
||
def test_a_bordered_frame_sets_the_credit_in_its_bottom_rule_and_spares_the_drawing() -> None:
|
||
"""JP, 2026-09-13: "that way it's not in the map". The rule has the credit, the map does not."""
|
||
from meshterm.platforms import REGULAR, set_platform
|
||
from meshterm.ui import attribution
|
||
|
||
set_platform(REGULAR)
|
||
screen = _credited_map(72)
|
||
# The credit is at the right, with one rule cell before the corner, as a title is in the
|
||
# top rule: "╰──… © OpenMapTiles · Data from OpenStreetMap ─╯".
|
||
rule = _bottom_rule(screen, 72)
|
||
assert rule[0] + rule[-1] in _BOTTOM_CORNERS, rule
|
||
assert rule[:-1].endswith(f" {attribution.CREDIT_FULL} ─"), rule
|
||
assert cell_len(rule) == 72
|
||
# Also, none of it is in the drawing: the rows of the map are only picture.
|
||
assert "OpenStreetMap" not in _plain(screen.render_body(72))
|
||
|
||
|
||
def test_a_frame_with_no_bottom_rule_stamps_the_drawing_instead() -> None:
|
||
"""The PicoCalc has a title bar and an F-key lane. It has no rule for a caption."""
|
||
from meshterm.platforms import PICOCALC_LYRA, REGULAR, set_platform
|
||
from meshterm.ui import attribution
|
||
|
||
try:
|
||
set_platform(PICOCALC_LYRA)
|
||
screen = _credited_map(53)
|
||
assert screen.bottom_caption == "" # the only place for the credit is the map
|
||
assert _last_row(screen, 53).endswith(attribution.CREDIT_FULL)
|
||
finally:
|
||
set_platform(REGULAR)
|
||
|
||
|
||
def test_the_credit_collapses_once_the_map_is_used_and_stays_collapsed() -> None:
|
||
"""After the user uses the map, the credit collapses and stays collapsed.
|
||
|
||
OSMF permits a collapse "automatically on map interaction such as panning … zooming".
|
||
"""
|
||
from meshterm.ui import attribution
|
||
|
||
for platform in _both_platforms():
|
||
cols = platform.readable_cols
|
||
for action in ("right", "pageup", "home", "text"):
|
||
screen = _credited_map(cols)
|
||
screen.render_body(cols) # the arrival paint that the key press answers
|
||
assert attribution.CREDIT_FULL in _mark(screen, cols), (platform.name, action)
|
||
screen.handle(action, "a" if action == "text" else "")
|
||
mark = _mark(screen, cols)
|
||
assert attribution.CREDIT_SHORT in mark, (platform.name, action)
|
||
assert attribution.CREDIT_FULL not in mark, (platform.name, action)
|
||
# A paint never brings the whole line back. This includes the composition memo of
|
||
# the frame, which must use the caption as a key to find the change.
|
||
assert attribution.CREDIT_FULL not in _mark(screen, cols), (platform.name, action)
|
||
|
||
|
||
def test_a_reopened_map_arrives_with_the_whole_credit_again() -> None:
|
||
"""The collapse is the state of a visit, not of a session. A new map starts as new."""
|
||
from meshterm.ui import attribution
|
||
|
||
used = _credited_map()
|
||
used.render_body(80) # keys do nothing until the map has a viewport to move
|
||
used.handle("right")
|
||
assert attribution.CREDIT_SHORT in _mark(used)
|
||
assert attribution.CREDIT_FULL in _mark(_credited_map())
|
||
|
||
|
||
def test_the_credit_costs_no_chrome_row_and_no_footer_character() -> None:
|
||
"""The credit is in the frame, so the body keeps its height and the hint keeps its wording."""
|
||
for platform in _both_platforms():
|
||
cols = platform.readable_cols
|
||
screen = _credited_map(cols)
|
||
assert len(screen.render_body(cols)) == len(_credited_map(cols).render_body(cols))
|
||
assert "OpenStreetMap" not in screen.footer_hint
|
||
screen.render_body(cols)
|
||
assert "OpenStreetMap" not in screen.title
|
||
|
||
|
||
def test_the_bottom_rule_keeps_its_clip_arrows_when_a_caption_shares_it() -> None:
|
||
"""The rule keeps both parts: the arrows are first, the caption follows, and both move right."""
|
||
from meshterm.ui.tui.frame import _panel_box
|
||
|
||
panel = _panel_box("Map", ["body"], True, True, "accent", "© OpenStreetMap")
|
||
assert "↑↓ more · © OpenStreetMap" in (panel.subtitle or "")
|
||
assert panel.subtitle_align == "right"
|
||
# With no caption, the arrows keep the centre that they always had.
|
||
assert _panel_box("Map", ["body"], False, True, "accent", "").subtitle_align == "center"
|
||
|
||
|
||
def test_the_find_query_and_the_credit_share_the_row_with_the_credit_kept_whole() -> None:
|
||
"""Where the F-key lane replaces the hint, the echo gives its right end to the credit."""
|
||
from meshterm.platforms import PICOCALC_LYRA, REGULAR, set_platform
|
||
from meshterm.ui import attribution
|
||
|
||
try:
|
||
set_platform(PICOCALC_LYRA)
|
||
screen = _credited_map(53)
|
||
screen.render_body(53) # keys do nothing until the map has a viewport to move
|
||
screen.handle("right")
|
||
for ch in "Hilltop-Repeater-With-A-Very-Long-Name":
|
||
screen.handle("text", ch)
|
||
row = _last_row(screen, 53)
|
||
assert row.lstrip().startswith("/Hilltop") # the query, cut to the space that remains
|
||
assert row.endswith(attribution.CREDIT_SHORT) # the credit, whole
|
||
assert cell_len(row) <= 53
|
||
finally:
|
||
set_platform(REGULAR)
|
||
|
||
|
||
def test_the_location_preview_credits_the_basemap_in_its_short_form() -> None:
|
||
"""The preview is also a produced work, and it is not interactive, so its mark must not change.
|
||
|
||
The app puts the preview in the body of another screen. It has no frame of its own with
|
||
a caption. Thus it stamps the credit on both platforms, unlike the full-screen map.
|
||
"""
|
||
from meshterm.ui import attribution
|
||
from meshterm.ui.map_render import MapMarker
|
||
from meshterm.ui.minimap import MiniMap
|
||
|
||
for platform in _both_platforms():
|
||
cols = platform.readable_cols
|
||
preview = MiniMap(
|
||
_StubSession(cols, 24),
|
||
_StubSource(),
|
||
14,
|
||
center_lat=45.5,
|
||
center_lon=-73.6,
|
||
zoom=12,
|
||
markers=[MapMarker("A", 45.5, -73.6)],
|
||
)
|
||
rows = preview.render(cols, 6)
|
||
assert len(rows) == 6, platform.name # the credit does not use a row of its own
|
||
last = _plain(rows).splitlines()[-1]
|
||
assert last.endswith(attribution.CREDIT_SHORT), platform.name
|
||
assert attribution.CREDIT_FULL not in last, platform.name
|
||
|
||
|
||
def test_the_credit_glyphs_are_in_the_picocalc_console_font() -> None:
|
||
"""The handheld draws the mark, so its glyphs must be in the 512-glyph font."""
|
||
from meshterm.ui import attribution
|
||
from meshterm.ui.fontset import FONT_CODEPOINTS
|
||
|
||
for text in (attribution.CREDIT_FULL, attribution.CREDIT_SHORT):
|
||
missing = {ch for ch in text if ord(ch) not in FONT_CODEPOINTS}
|
||
assert not missing, f"{text!r} needs glyphs the console font lacks: {missing!r}"
|
||
assert cell_len(text) == len(text), f"{text!r} draws wider than it measures"
|
||
|
||
|
||
def test_a_row_too_narrow_for_the_remnant_takes_no_credit_rather_than_a_cut_one() -> None:
|
||
"""A row that is too narrow for the short form gets no credit. A cut credit would be untrue.
|
||
|
||
The About page still has the credit.
|
||
"""
|
||
from meshterm.ui import attribution
|
||
|
||
rows = ["x" * 8]
|
||
assert attribution.stamp(rows, 8, full=True) == rows
|
||
# A row with room for the short form, but not for the whole line, uses the short form.
|
||
stamped = _plain(attribution.stamp(["x" * 20], 20, full=True)).splitlines()[-1]
|
||
assert stamped.endswith(attribution.CREDIT_SHORT)
|
||
|
||
|
||
def test_stamping_leaves_the_callers_rows_untouched() -> None:
|
||
"""The map uses one cached raster for many paints, so the stamping must not change it."""
|
||
from meshterm.ui import attribution
|
||
|
||
rows = ["street " * 10, "more ground here"]
|
||
before = list(rows)
|
||
attribution.stamp(rows, 70, full=True)
|
||
assert rows == before
|
||
|
||
|
||
def test_the_locator_finds_a_node_by_its_key_or_its_key_prefix() -> None:
|
||
"""A key prefix finds the node with the full key, and a full key finds a key prefix."""
|
||
from meshterm.core.models import MapMarker
|
||
from meshterm.ui.map_screen import Locator
|
||
|
||
full = MapMarker(label="Alice", lat=45.5, lon=-73.6, key="A1B2C3D4" + "0" * 56)
|
||
short = MapMarker(label="Bob", lat=45.4, lon=-73.5, key="b2c3d4e5f6a7")
|
||
us = MapMarker(label="Base", lat=45.3, lon=-73.4, is_self=True, key="ee" * 32)
|
||
locator = Locator(None, [full, short, us]) # type: ignore[arg-type]
|
||
|
||
assert locator.find("a1b2c3d4") is full # the four bytes that sign a room post
|
||
assert locator.find("0xA1B2C3D4" + "0" * 56) is full
|
||
assert locator.find("b2c3d4e5f6a7" + "9" * 52) is short # stored history knows six bytes
|
||
assert locator.find("c3c3c3c3") is None
|
||
assert locator.find(None) is None and locator.find("") is None
|
||
assert locator.you() is us
|
||
assert Locator(None, [full]).you() is None # type: ignore[arg-type]
|