locate moves from ^Y to ^L, and the room's login from ^L to ^O, under the
chord rule of 3675226. On the map and in the mesh walk, ^L still goes to
our node. In a chat and on a room's board, ^L opens the map on the author
of the selected message (our own message: our node); on Contacts, on the
highlighted node. map_screen.Locator holds the markers that
gather_markers returns when the screen opens and answers find() at each
paint, so the "^L map" hint atom and the Shift+F1 Map chip show only
where the key acts. A room keeps Log in on Shift+F1, so ^L has no chip
on a room's board yet.
The chat's hints for a selected message go through _fit_hint, which
drops ↑↓ and then ^P only when the line passes 72 cells; the room now
reuses them. LaneDeck.action_for returns None for a slot that has only
a Shift companion.
Right Ctrl with a letter chord still does nothing on the Canadian
Multilingual layout: JP chose not to repair that.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
3227 lines
134 KiB
Python
3227 lines
134 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", ("#9aa0aa", 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", ("#9aa0aa", 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", ("#9aa0aa", 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_brightens_piled_markers() -> None:
|
||
"""A cell that many nodes share renders its glyph brighter than the glyph of one node."""
|
||
from meshterm.ui.map_render import MapMarker, render_map
|
||
from meshterm.ui.marks import NODE_MARK
|
||
|
||
base = parse_hex(NODE_MARK[1])
|
||
vp = Viewport(45.50, -73.57, 14, 60, 40)
|
||
|
||
lone = render_map(vp, {}, [MapMarker("solo", 45.50, -73.57)])
|
||
assert _glyph_color(lone, "●") == base # one node keeps its base colour
|
||
|
||
crowd = [MapMarker(f"n{i}", 45.50, -73.57) for i in range(8)]
|
||
piled = _glyph_color(render_map(vp, {}, crowd), "●")
|
||
# The colour goes toward white: each channel is brighter than the base cyan.
|
||
assert all(p > b for p, b in zip(piled, base, strict=True))
|
||
assert piled != base
|
||
|
||
|
||
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 stays yellow. 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.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, "★") == parse_hex(SELF_MARK[1]) # the glyph keeps its yellow
|
||
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") == (148, 163, 184) # 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]
|