Rich draws a panel's corners rounded, and square only where it judges the console legacy Windows, which a captured pytest run on Windows is. The credit tests looked for the square corner, so they passed here and failed on every Linux and macOS runner. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2815 lines
115 KiB
Python
2815 lines
115 KiB
Python
# SPDX-License-Identifier: Apache-2.0
|
||
"""Tests for the node map: MVT decoding, projection, the braille canvas, and the tool.
|
||
|
||
The vector-tile decoder and geometry are exercised against a real cached tile fixture
|
||
(``tests/fixtures/tile_14_4843_5861.mvt``, central Montréal) so the parsing is verified end
|
||
to end without the network; the projection, canvas, and compositor are pure; the tool runs
|
||
against the :class:`MockDevice` simulator with the basemap disabled (no network in tests).
|
||
"""
|
||
|
||
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 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 the map relies on."""
|
||
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 # roads were decoded
|
||
|
||
|
||
def test_decode_tile_resolves_names_and_geometry() -> None:
|
||
"""Street and place features carry resolved names and non-trivial geometry."""
|
||
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 rather than raising."""
|
||
assert decode_tile(b"") == []
|
||
|
||
|
||
def test_decode_tile_filter_keeps_the_whole_tile_visible() -> None:
|
||
"""A narrowed decode still names every layer — it only skips their features.
|
||
|
||
The tile source decides whether a cache entry is real by whether it parsed into
|
||
layers, so narrowing must never make a tile look like it holds 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 one we asked for was decoded
|
||
assert not picked["building"].features # the rest were skipped, not dropped
|
||
|
||
|
||
def test_decode_tile_filter_is_invisible_to_the_renderer() -> None:
|
||
"""Narrowing to ``DRAWN_LAYERS`` draws exactly the map a full decode draws.
|
||
|
||
This is the anti-drift guard: if ``_draw_tile`` grows a lookup for a layer that
|
||
isn't in ``DRAWN_LAYERS``, that layer arrives featureless and the rendered output
|
||
diverges 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 left undecoded are the ones 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:
|
||
"""Lit cells in a render of the fixture's buildings alone, 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 about two dots across, 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: a *solid* fill would erase the street grid it covers.
|
||
|
||
The stipple is what keeps a building a shade rather than an eraser — every cell it
|
||
touches keeps free dots for a road to be drawn through.
|
||
"""
|
||
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 be invisible in the output.
|
||
|
||
A downtown tile carries thousands of footprints and a zoomed-in view holds a few
|
||
dozen; each one is rejected on its own tile-local bounds before a single point of it
|
||
is projected. Adding a footprint the view cannot reach must render byte-identically.
|
||
"""
|
||
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])
|
||
|
||
|
||
# -- projection / viewport ----------------------------------------------------
|
||
|
||
|
||
def test_world_projection_round_trips() -> None:
|
||
"""lon/lat -> world pixels -> lon/lat recovers 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 ~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 viewport's centre lands at the middle dot; 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 due north projects above the centre (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 is centred on the points and zoomed so they share a tile scale."""
|
||
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 # every point is on-canvas
|
||
|
||
|
||
def test_viewport_fit_fraction_ignores_outliers() -> None:
|
||
"""Framing half the nodes zooms to the dense core, letting far outliers fall off-canvas."""
|
||
# A tight downtown cluster of five nodes (~50 m across) plus 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 core-only frame is zoomed in tighter than the frame that must hold the outlier.
|
||
assert half.zoom > full.zoom
|
||
# Every clustered node stays on-canvas; the outlier is pushed off.
|
||
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 — both are still framed."""
|
||
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; zoom clamps to its bounds."""
|
||
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 # panning east increases longitude
|
||
north = vp.panned(0, -0.5)
|
||
assert north.center_lat > vp.center_lat # panning north increases latitude
|
||
|
||
|
||
def test_viewport_tiles_cover_the_view() -> None:
|
||
"""The viewport reports the handful of tiles overlapping 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 among them.
|
||
assert (14, 4843, 5861) in tiles
|
||
|
||
|
||
# -- braille canvas -----------------------------------------------------------
|
||
|
||
|
||
def test_canvas_plots_dots_as_braille() -> None:
|
||
"""A single 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:
|
||
"""When dots from two features share a cell, the higher priority sets its colour."""
|
||
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 high-priority colour won
|
||
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 places its label in a neighbouring cell."""
|
||
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 sits between the marker and its label
|
||
|
||
|
||
def test_canvas_label_collision_is_avoided() -> None:
|
||
"""A checked label is skipped when it would overlap an already-placed one."""
|
||
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)) # overlaps → skipped
|
||
|
||
|
||
def test_canvas_labels_keep_a_vertical_gap() -> None:
|
||
"""A checked label is skipped when it would sit flush above/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 overlapping columns: rejected for lack of a gap.
|
||
assert not canvas.place_label(20, 8, "Row2", (200, 200, 200))
|
||
# Same row but clear of the horizontal span still fits.
|
||
assert canvas.place_label(2, 4, "Far", (200, 200, 200))
|
||
|
||
|
||
def _dot_colors(lines: list[str]) -> set[tuple[int, int, int]]:
|
||
"""Every truecolour a braille run was drawn in (the ground's colours, not the text's)."""
|
||
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 lands where the caller's index tables put it, dimmed, underneath.
|
||
|
||
``-1`` is a cell with no source — the ground a pan has just moved onto — and the
|
||
pasted cell keeps the empty-cell priority so anything drawn afterwards wins it.
|
||
"""
|
||
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; 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, drawn 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 zoomed-in stand-in shows each cell its own share of the source, enlarged.
|
||
|
||
Stamping the whole source glyph into every cell of the block it grew into is double
|
||
vision — the same 2x4 pattern side by side and one above the other, which reads as a
|
||
fault rather than 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 cell's top-left dot, 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, so at 2x it becomes the 2x2 block of cells drawn here,
|
||
# and its one top-left dot becomes a 2x2 block of dots: the top half of the top-left
|
||
# cell alone. The glyph repeated four times would be the bug.
|
||
assert rendered == ["⠛ ", " "], rendered
|
||
|
||
|
||
def test_parse_hex() -> None:
|
||
"""Hex colours parse to RGB triples, with or without the leading hash."""
|
||
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:
|
||
"""Rendering the real fixture yields 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) # braille was drawn
|
||
assert "▲" in out and "Yagi" in out # the repeater marker + 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 spot resolve to the repeater's marker 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:
|
||
"""How many street names actually 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 them
|
||
break
|
||
return placed
|
||
|
||
|
||
def test_street_labels_get_denser_as_you_zoom_in() -> None:
|
||
"""Zooming toward a street must not make its name less likely to appear.
|
||
|
||
Anchoring a label to the feature's own midpoint pinned it to a fixed geographic
|
||
point, so the tighter the view the less often that point was still on screen — the
|
||
fixture's 265 named streets yielded 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 sees more of the same ground, so it may name more -- never 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 expensive half of a named road is the label, and below the gate it was binned.
|
||
|
||
``_compose`` never places a label under its own ``min_zoom``; ``_draw_tile`` used to
|
||
build one anyway — clipping the line segment by segment, measuring each surviving
|
||
piece and sorting them, 270 times over for a central tile. Measured at 16.8 ms of a
|
||
46 ms frame on the desktop, so upwards of a third of a second on the PicoCalc, at the
|
||
two zooms the map most often sits at.
|
||
|
||
The gate must be invisible in the picture: what it drops is exactly what was thrown
|
||
away a moment later, so the frame either side of it 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 source's max, so the ground comes off a z14 tile —
|
||
# but the reader 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"
|
||
)
|
||
|
||
# And the picture is untouched either side of the gate, coarse pass included.
|
||
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 alone: a label never reached it anyway.
|
||
assert ghost.raster.cell_w == vp.dot_w // 2
|
||
|
||
|
||
def _street_labels_queued(zoom: int, tiles) -> int:
|
||
"""How many street-name candidates a frame at ``zoom`` builds before placing anything."""
|
||
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 that lands in the middle of this viewport (inverts 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; the map names it 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 and used to be drawn twice 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 crossing the view is named even with both its endpoints off screen."""
|
||
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 spanning the whole tile at the view's latitude: its own 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 screen costs no label slot at all."""
|
||
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, well outside a zoom-16 window.
|
||
_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:
|
||
"""A crowded first choice falls back further along the street rather than vanishing."""
|
||
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 between.
|
||
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:
|
||
"""When labels can't all fit, the repeater's wins and a crowded node's is dropped."""
|
||
from meshterm.ui.map_render import MapMarker, render_map
|
||
|
||
# Three nodes stacked on one spot: only the two sides (left/right) can hold a label,
|
||
# so one of the three must show as a bare marker — and the repeater must not be it.
|
||
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 repeater's glyph is on top
|
||
assert "REPEATER" in out # and it keeps its label (placed first)
|
||
# Only one of the two leaf nodes could fit 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]:
|
||
"""Extract the truecolour ``(r, g, b)`` the given glyph was rendered with."""
|
||
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 many nodes share renders its glyph brighter than a lone node's."""
|
||
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 # a single 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), "●")
|
||
# Washed toward white: every 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 reduced to its braille dots (labels and markers blanked out)."""
|
||
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 standing on that frame's ground."""
|
||
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:
|
||
"""Panning slides the last frame's streets across rather than blanking to black.
|
||
|
||
Rasterizing takes far too long to sit on a keypress, so the paint that answers a pan
|
||
has no ground of its own. Without the ghost it drew the markers on an empty canvas —
|
||
the map going black between every step and flashing back when the frame landed.
|
||
"""
|
||
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 exactly the cells the view moved, to 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) # → 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 come along: a label pinned to 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 redrawn where they really are
|
||
|
||
|
||
def test_the_ghost_ground_dims_while_its_replacement_is_drawn() -> None:
|
||
"""Reused ground reads as provisional — it is stale, and short of the leading edge."""
|
||
from meshterm.ui.map_render import _GHOST_FADE
|
||
|
||
assert 0 < _GHOST_FADE < 1 # the desktop default; the 16-slot console binds 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 behind gets the honest empty canvas."""
|
||
_, far = _ghosted(dlon=4.0) # panned clean off the ground we had
|
||
assert not any(row.strip() for row in _dot_rows(far))
|
||
_, deep = _ghosted(dz=3) # zoomed past what a cell-coarse stand-in can say
|
||
assert not any(row.strip() for row in _dot_rows(deep))
|
||
_, near = _ghosted(dz=1) # one step is still a readable preview
|
||
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 actually running.
|
||
|
||
OpenFreeMap issues no API key, so this string is the whole of what a tile operator has
|
||
to tell one client from another and to reach whoever is costing them bandwidth. It read
|
||
``MeshTerm/0.1 (+https://github.com/; mesh node map)`` — a host nobody can visit, and a
|
||
number that was already wrong — which is the shape of traffic that gets a project
|
||
blocked rather than emailed.
|
||
"""
|
||
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 rides 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):
|
||
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_user_agent_needs_no_installed_distribution() -> None:
|
||
"""Importing the module must not depend on package metadata being on disk.
|
||
|
||
MeshTerm is run straight from a checkout as often as from a wheel — this repo's own
|
||
venv and the PicoCalc's deploy both are — so a version read through
|
||
``importlib.metadata`` would raise :class:`PackageNotFoundError` on the tile path in
|
||
exactly the setups it is developed in. The version comes from the package instead, and
|
||
this pins that: with the metadata lookup made to fail outright, the module still
|
||
imports and still carries 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:
|
||
"""An unreachable source reports unavailable and returns no tiles, never raising."""
|
||
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 # sensible default when the TileJSON can't be fetched
|
||
assert src.load_tile(14, 4843, 5861) is None
|
||
|
||
|
||
def test_basemap_source_reads_cached_tile(tmp_path: Path) -> None:
|
||
"""A tile already on disk is decoded without any network access."""
|
||
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 can't reach anything — every fetch is silence, 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, with the source resolved and the *fetch* the thing that fails — the flaky
|
||
# link the PicoCalc lives on. 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 is what 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 # retried, not written off 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 moment, not a verdict — the PicoCalc's Wi-Fi lands after boot.
|
||
|
||
Latching the first failure meant an app started alongside the device's network drew
|
||
every tile of every session from whatever the cache already held (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 nothing was resolved
|
||
|
||
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, stepped over rather than slept through
|
||
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:
|
||
"""Asking again is not asking constantly: a genuinely offline map isn't 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 constructor's own failed resolve still stands
|
||
|
||
|
||
def test_basemap_source_tells_an_empty_answer_from_silence(tmp_path: Path) -> None:
|
||
"""The two ``None`` returns of ``load_tile``, told apart — a caching caller needs 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._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 — worth asking 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; that settles it
|
||
|
||
|
||
def test_basemap_source_prunes_a_blank_cached_tile(tmp_path: Path) -> None:
|
||
"""A zero-byte entry (an older build's failure marker) is dropped rather than 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 network re-fetches it
|
||
|
||
|
||
def test_basemap_source_prunes_a_corrupt_cached_tile(tmp_path: Path) -> None:
|
||
"""Bytes cut short mid-write don't decode, so they're pruned instead of kept blank."""
|
||
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 decodable geometry — never 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 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:
|
||
"""Narrowing the decode must not make a good cached tile look blank and get pruned.
|
||
|
||
A source told to decode a layer the tile doesn't carry sees no features at all. If
|
||
that were read as "nothing here", the entry would be deleted and re-downloaded on
|
||
every session — the cache paying for a rendering decision.
|
||
"""
|
||
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) # nothing was decoded
|
||
assert tile.exists(), "the cache entry must survive"
|
||
|
||
|
||
def test_decoded_layers_survive_a_round_trip() -> None:
|
||
"""The sidecar encoding restores exactly what the decoder produced."""
|
||
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:
|
||
"""A narrowed blob must never be served 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 truncated or foreign sidecar is a cache miss, never 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: it comes back 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: a second load that still works can only have used 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:
|
||
"""Widening what the map draws re-decodes rather than serving 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 is bounded; the raw tiles it derives from are not."""
|
||
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 present, 3 KB allowed: the three oldest go, 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_basemap_source_remembers_a_blank_tile_for_the_session(tmp_path: Path) -> None:
|
||
"""An answered-empty tile isn't re-requested this session (but isn't written down)."""
|
||
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:
|
||
"""404 is an answer; a 500, and a body short of Content-Length, are not."""
|
||
import urllib.error
|
||
|
||
from meshterm.services.basemap import BasemapSource
|
||
|
||
src = BasemapSource(tmp_path / "cache")
|
||
|
||
class _Resp:
|
||
"""The slice of an ``http.client.HTTPResponse`` the fetcher touches."""
|
||
|
||
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): # 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):
|
||
"""A mock-backed application context with 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 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 no-GPS companion reports this)
|
||
assert usable_fix(-97.0, -1041.97) is False # out of range (a real advert seen in the wild)
|
||
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:
|
||
"""A node advertising nonsense coordinates is never plotted, only the real one is.
|
||
|
||
Some firmware reports an out-of-range fix (a companion has advertised ``lat -97,
|
||
lon -1042``); projecting it flings the view off the world, leaving the map an all-black
|
||
off-world frame. It is treated as no fix at all, the same guard the 0/0 null island gets.
|
||
"""
|
||
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 saved map viewport persists and reads back; absent by default."""
|
||
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 overwrites the single 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:
|
||
"""With no located contacts and no located history the tool returns cleanly."""
|
||
|
||
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:
|
||
"""A node advertising a 0/0 fix (no GPS lock) is not plotted at null island.
|
||
|
||
Framing such a marker (filter to it, press Enter) would drop the map onto the empty
|
||
mid-Atlantic — a screen of solid water fill — so a both-near-zero fix is treated as no
|
||
fix at all, exactly as our own node's marker already is.
|
||
"""
|
||
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 null-island node is dropped
|
||
|
||
|
||
class _StubSession:
|
||
"""Minimal stand-in for :class:`TuiSession` for driving :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:
|
||
"""An always-offline tile source, so the screen renders nodes only (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 is silence, never the source saying "nothing there"
|
||
|
||
|
||
def _loaded_tile() -> list[Layer]:
|
||
"""A stand-in for a decoded tile: truthy, which is all the budget cares about."""
|
||
return [Layer(name="water", extent=4096)]
|
||
|
||
|
||
def _map_screen_with_tiles(count: int) -> MapScreen:
|
||
"""A rendered map screen carrying ``count`` tiles' worth of pan history."""
|
||
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 _budget(screen) -> int: # noqa: ANN001 - the screen's own arithmetic, mirrored
|
||
from meshterm.ui.map_screen import _MIN_TILE_CACHE, _TILE_CACHE_SCREENS
|
||
|
||
in_view = len(screen._viewport.tiles(14))
|
||
return max(_MIN_TILE_CACHE, in_view * _TILE_CACHE_SCREENS)
|
||
|
||
|
||
def test_map_tile_cache_stays_bounded_while_panning() -> None:
|
||
"""Panning far doesn't accumulate decoded tiles without limit.
|
||
|
||
One decoded tile is ~0.9 MB on the PicoCalc, which has ~100 MB in total, so an
|
||
unbounded cache turns a long pan into an out-of-memory kill.
|
||
"""
|
||
screen = _map_screen_with_tiles(200)
|
||
for _ in range(12):
|
||
screen.handle("right")
|
||
screen.render_body(80)
|
||
|
||
held = sum(1 for layers in screen._tiles.values() if layers)
|
||
assert held <= _budget(screen), f"{held} decoded tiles held, over budget"
|
||
|
||
|
||
def test_map_tile_cache_never_drops_what_is_on_screen() -> None:
|
||
"""Whatever the view needs survives the trim — 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): # a long pan's worth of history arriving 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 of having asked; it costs a slot, so it is never evicted.
|
||
|
||
Dropping one would buy nothing back and cost a re-request on the very next repaint.
|
||
"""
|
||
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 a test can watch what it does.
|
||
|
||
Captures the pushed screen, renders it once, then replays a canned key sequence —
|
||
which is enough to assert on where the map opened and what it did (or didn't)
|
||
persist as the view moves.
|
||
"""
|
||
|
||
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, and wasd/zoom/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 reported by the session
|
||
start = screen._viewport
|
||
|
||
screen.handle("right") # pan east → longitude increases
|
||
assert screen._viewport.center_lon > start.center_lon
|
||
screen.handle("up") # pan north → 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 refits to the nodes
|
||
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)
|
||
|
||
# Letters never pan or zoom — they feed the find filter instead.
|
||
view = screen._viewport
|
||
screen.handle("text", "d")
|
||
assert screen._viewport is view and screen._filter == "d"
|
||
screen.handle("backspace")
|
||
|
||
# Offline source: nodes still render and both markers are present.
|
||
assert "▲" in _plain(screen.render_body(80)) and "●" in _plain(screen.render_body(80))
|
||
# The basemap's state is a status atom, so it rides the title's `·` chain (standing in
|
||
# for the scale) and leaves the footer a pure key line inside 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:
|
||
"""Holding Shift with an arrow pans finely, 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 east pan (30% of the view) moves much further than a fine one.
|
||
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 exactly one cell (2 dots) 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 repurposes the nav keys, so its lane labels them — and offers no dead key."""
|
||
from meshterm.ui.map_render import MapMarker
|
||
from meshterm.ui.map_screen import MapScreen
|
||
from meshterm.ui.tui.fkeys import action_for
|
||
|
||
screen = MapScreen(_StubSession(80, 24), [MapMarker("A", 45.5, -73.6)], _StubSource(), 14)
|
||
lane = screen.fkey_lane
|
||
|
||
# PgUp/PgDn zoom here and Home reframes — the shared paging labels would all be lies,
|
||
# and "end" is bound to nothing at all, so no jump rides the zoom rocker's Shift bank.
|
||
# The zoom pair rises to the right, like every right-hand directional pair.
|
||
assert [pair.label if pair else None for pair in lane] == [
|
||
"Region",
|
||
"You",
|
||
"Frame",
|
||
"Zoom -",
|
||
"Zoom +",
|
||
]
|
||
assert action_for(lane, 1) == "home" and action_for(lane, 5) == "pageup"
|
||
assert action_for(lane, 2) == "locate" and action_for(lane, 3) == "frame"
|
||
# Two slots carry a Shift half along their own axis: You + homes in on us zoomed,
|
||
# Clear drops the find query. The other three Shift keys stay unbound.
|
||
assert action_for(lane, 7) == "locate_zoom" and action_for(lane, 8) == "clear_find"
|
||
assert all(action_for(lane, n) is None for n in (6, 9, 10))
|
||
# Region and the zoom always act; the other two only where they'd land somewhere —
|
||
# and each Shift half gates with its own axis (no self marker, 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 recentre has a zoom to preserve
|
||
zoom = screen._viewport.zoom
|
||
screen.handle("left") # and pan away from wherever we are
|
||
|
||
assert screen.fkey_lane[1].enabled is True # we're 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
|
||
|
||
# A mesh we aren't located in has nowhere to go: the chip dims and the key no-ops.
|
||
away = MapScreen(_StubSession(80, 24), markers[:1], _StubSource(), 14)
|
||
away.render_body(80)
|
||
before = away._viewport
|
||
away.handle("locate")
|
||
assert away.fkey_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 it carries moves into the body."""
|
||
from meshterm.platforms import PICOCALC, 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)
|
||
# Unchanged: the footer already shows it, 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)
|
||
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 overlays the canvas's *last* row — directly above the F-key lane —
|
||
# so the ground never shifts: same body height, same viewport, top anchor held.
|
||
# The basemap credit is pinned to that same row's other end (it is chrome on the
|
||
# drawing, not a body line of its own), so the echo takes what is left of it.
|
||
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
|
||
# Clearing the find hands the row back to the canvas, still without a 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:
|
||
"""Shifted vertical arrows survive the console keymap flattening them.
|
||
|
||
Shift+↑ arrives as PgUp on the PicoCalc console; with Shift physically down it
|
||
must fine-pan, not zoom — and plain PgUp (the F-lane's zoom) 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 keymap's Shift+↑, rescued by the watcher
|
||
assert screen._viewport.zoom == zoom # no zoom happened
|
||
assert screen._viewport.center_lat > lat # …the view nudged north instead
|
||
|
||
monkeypatch.setattr(modifier_watch, "_shift_down", False)
|
||
screen.handle("pageup") # a real zoom press (the F5 chip's action)
|
||
assert screen._viewport.zoom == zoom + 1
|
||
|
||
|
||
def test_map_frame_chip_lights_only_with_matches_to_frame() -> None:
|
||
"""``Frame`` is the find's Enter under another name — dim, and inert, without a query."""
|
||
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 nothing moves
|
||
assert screen.fkey_lane[2].enabled is False and screen._viewport is before
|
||
|
||
for ch in "ali":
|
||
screen.handle("text", ch)
|
||
assert screen.fkey_lane[2].enabled is True
|
||
screen.handle("frame")
|
||
assert screen._viewport.center_lat == pytest.approx(45.40)
|
||
|
||
# A query nothing matches has no extent either — the chip goes back to dim.
|
||
for ch in "zzz":
|
||
screen.handle("text", ch)
|
||
assert screen._matches() == [] and screen.fkey_lane[2].enabled is False
|
||
|
||
|
||
def test_map_screen_find_filters_frames_and_clears() -> None:
|
||
"""Typing builds the query; ^Enter frames matches; Enter/Esc drop it; Esc then 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 dim to bare context glyphs.
|
||
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 survives.
|
||
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"
|
||
|
||
# Plain Enter is the other way out: the query goes, the view stays exactly where the
|
||
# frame left 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; Esc clears the filter first and only then dismisses.
|
||
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 a lone filtered node homes in close on it, not the fit's neutral default."""
|
||
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 fit's default 14
|
||
|
||
|
||
def test_map_screen_restores_and_persists_view() -> None:
|
||
"""The screen reopens on a saved view and reports every centre/zoom change."""
|
||
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)
|
||
# Restored to the saved centre/zoom, not a fit of 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 == [] # reopening unchanged doesn't rewrite the stored view
|
||
|
||
screen.handle("right") # pan east persists the moved view
|
||
assert saved and saved[-1][2] == 12 # zoom unchanged
|
||
assert saved[-1][1] > -71.20 # centre moved east
|
||
|
||
screen.handle("pageup") # zooming in persists too
|
||
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 without clobbering the saved view.
|
||
|
||
Opening the full map from a node's detail page centres there at the inline
|
||
preview's zoom and, being a transient peek, never overwrites the persisted "where
|
||
you left the map" global view — even as the peek is panned and zoomed.
|
||
"""
|
||
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 last left the global map
|
||
|
||
session = _CapturingSession(80, 24, keys=("right", "pageup")) # pan + zoom the peek
|
||
ctx.ui = TuiUi(session)
|
||
await open_map(
|
||
ctx,
|
||
[MapMarker("Hub", 45.5, -73.6, is_repeater=True)],
|
||
focus=(45.5, -73.6),
|
||
find="Hub",
|
||
)
|
||
|
||
# Opened on the node, at the detail preview's zoom (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 peek wires no persistence
|
||
assert session.screen._filter == "Hub" # the find opens seeded to the focused node
|
||
# Panning/zooming the peek left the global 'where you left the map' view untouched.
|
||
assert ctx.repo.get_map_view() == pytest.approx((10.0, 20.0, 8))
|
||
assert session.repainted # cleaned the terminal on the way out, like the plain open
|
||
|
||
|
||
async def test_open_map_without_focus_restores_and_persists_the_global_view(
|
||
ctx, monkeypatch
|
||
) -> None:
|
||
"""With no focus, the full map restores and persists the global view.
|
||
|
||
It reopens where the user left it, and pans persist straight 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 to reopen on
|
||
|
||
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 a node fit
|
||
lat, lon, zoom = ctx.repo.get_map_view()
|
||
assert zoom == 12 and lon > -71.20 # the eastward pan persisted back to the global view
|
||
|
||
|
||
def test_map_screen_seeded_find_behaves_like_a_typed_one() -> None:
|
||
"""A seeded find matches, titles, and clears exactly as if the user had 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 seed's node lights
|
||
assert "find: Hub" in screen.footer_hint # the query shows, editable, as ever
|
||
screen.handle("escape") # 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 once, 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() == 2 # right padding + border columns
|
||
assert braille.consume_edge_scrub() == 0 # consumed — not repeated without another move
|
||
|
||
|
||
def test_map_screen_escape_dismisses() -> None:
|
||
"""Esc resolves the screen's future with None (backs out 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:
|
||
"""A stored observation's advert type survives persistence 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:
|
||
"""Map labels take the node hue, and ours takes the you white.
|
||
|
||
Our own label is white while the ★ glyph stays yellow, and a keyless label lands
|
||
on 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 goes you-white
|
||
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 keeps forcing matching labels to 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)
|
||
|
||
|
||
# --- a tile nobody answered about is asked for again ----------------------------------
|
||
|
||
|
||
def _map_over(source) -> MapScreen: # noqa: ANN001
|
||
"""A map screen fetching from ``source``, with an event loop it can hand work to."""
|
||
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)
|
||
|
||
|
||
class _SilentSource(_StubSource):
|
||
"""A source that never answers — the flaky Wi-Fi, 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 there is nothing at those tiles."""
|
||
|
||
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 Wi-Fi blip must not black out that ground until the app is restarted.
|
||
|
||
On the PicoCalc a missing z14 tile *is* the picture at the two highest zooms, where
|
||
one tile covers the whole screen many times over (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 inside 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 settles it: ocean tiles must not be re-requested 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`` says "not yet", never "don't bother" — a fetch is what resolves it."""
|
||
source = _SilentSource()
|
||
assert source.available is False # nothing has reached 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())
|
||
|
||
|
||
# --- anticipating the next move ------------------------------------------------------
|
||
|
||
|
||
class _CountingSource(_StubSource):
|
||
"""A source that answers instantly and remembers everything it was asked for."""
|
||
|
||
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 _settled_map(source, zoom: int = 13):
|
||
"""A map screen with its own view drawn and every visible tile in hand."""
|
||
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 runs dry, and report what it went and got.
|
||
|
||
Real sleeps, not ``sleep(0)``: both the raster and every tile load go through
|
||
``asyncio.to_thread``, and a thread does not finish on a bare loop turn.
|
||
"""
|
||
for _ in range(200):
|
||
screen.render_body(80)
|
||
screen._settled_at = 0.0 # the settle wait has its own test; don't sleep it out
|
||
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 one way is a heading, and ground is bought in front 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 nudge
|
||
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 allowed, 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:
|
||
"""Zooming out is the one move whose tiles were never on screen."""
|
||
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:
|
||
"""Speculation must never be the thing in the way of the view being looked at."""
|
||
source = _CountingSource()
|
||
screen = _settled_map(source)
|
||
|
||
async def drive() -> None:
|
||
screen.render_body(80) # visible tiles now pending, nothing 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 landing between two keypresses of a pan is a gap, not a pause.
|
||
|
||
Starting a fetch there puts a tile decode in front of the next frame — measured at
|
||
~110 ms on the device, which is exactly what the prefetcher must never cost.
|
||
"""
|
||
source = _CountingSource()
|
||
screen = _settled_map(source)
|
||
|
||
async def drive() -> None:
|
||
screen.render_body(80) # builds the viewport
|
||
screen._pending.clear() # ...and here it is, arrived and drawn this instant
|
||
screen._drawing = screen._wanted = None
|
||
|
||
screen._ensure_prefetch(screen._viewport) # stamps the view as newly arrived
|
||
assert not screen._speculating, "guessed while the view was still moving"
|
||
|
||
screen._settled_at = 0.0 # ...and now it has been still for a while
|
||
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 home is looking around, not travelling on."""
|
||
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 pacing rule is read at the end of a paint, not off the last one's answer.
|
||
|
||
A find keystroke moves the view nowhere, so this is still the settled view the
|
||
prefetcher is entitled to guess from — but the paint it triggers queues the raster
|
||
the reader is waiting for. Asked a step earlier, from inside ``_ensure_tiles``, the
|
||
prefetcher saw the *previous* paint's quiet and started a tile decode alongside it.
|
||
"""
|
||
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 entitled 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 source that has just gone quiet is the last one a guess should be poking."""
|
||
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 expires and the plan is a plan again
|
||
assert screen._next_speculation(vp) in plan
|
||
|
||
|
||
def test_map_keeps_no_speculated_tile_of_its_own() -> None:
|
||
"""A prefetch warms the source's cache; 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"
|
||
|
||
|
||
# --- 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 — no buildings, 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"
|
||
# Our own marker label is overlaid on every paint; the basemap's text is 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:
|
||
"""Words in a frame that are basemap text rather than a node marker's label."""
|
||
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 sit black for a whole raster.
|
||
|
||
A coarse pan step is 30% of the screen, so four keypresses leave nothing of the last
|
||
frame to reproject — and a full raster is 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 own 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
|
||
"""Between the passes is where a held key gets off: don't finish a stale 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 on while the coarse pass is being 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 here, not after the loop closes: the newer view's own draw is a task of its
|
||
# own by now, and what it goes on to ask for is that view's business, not this
|
||
# one's. The question is 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 landing must refine the frame, never coarsen it."""
|
||
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:
|
||
screen.render_body(80)
|
||
while screen._drawing is not None:
|
||
await asyncio.sleep(0)
|
||
coarse_asked.clear()
|
||
# A tile lands: same view, more detail. The frame on screen 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; whether a moved view is offered the first one is a knob.
|
||
|
||
Off, every view waits for the whole picture and stands on the last frame's ground
|
||
until it lands. The machinery is :meth:`MapScreen._draw_ground`'s either way — what
|
||
the switch decides is the *asking*, which is why it is read where the raster is
|
||
requested and not inside the renderer.
|
||
"""
|
||
from meshterm.ui import map_screen as ms
|
||
|
||
def first_draw() -> list:
|
||
"""The passes a view with nothing drawn under it asks for."""
|
||
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())
|
||
|
||
async def drive() -> None:
|
||
screen.render_body(80)
|
||
while screen._drawing is not None:
|
||
await asyncio.sleep(0)
|
||
|
||
asyncio.run(drive())
|
||
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
|
||
"""Typing a node name moves the view nowhere, so the streets must not flatten.
|
||
|
||
A find query changes which markers are bright — the *overlay* — while the ground
|
||
under them is the same ground, already drawn and reprojected at zero offset as the
|
||
stand-in. Deciding the rough pass on the whole picture rather than on the view meant
|
||
every letter replaced the streets with the coarse pass and drew them back.
|
||
"""
|
||
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:
|
||
screen.render_body(80)
|
||
while screen._drawing is not None:
|
||
await asyncio.sleep(0)
|
||
coarse_asked.clear()
|
||
screen.handle("text", "a") # one letter of a find query; 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 whose rasters are captured instead of run, with a live event loop."""
|
||
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]
|
||
)
|
||
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 keystroke now — rasterizing takes ~1 s on the PicoCalc."""
|
||
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)
|
||
# Whatever ran on the paint path drew no tiles: the ground is somebody else's job.
|
||
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:
|
||
"""Just the ground-rasterizing coroutines — 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 per keypress."""
|
||
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 arriving must not blank streets that are still in the right place.
|
||
|
||
The view has not moved, so the finished raster is still correctly aligned — it is only
|
||
missing the newcomer's detail. Redrawing from nothing would flash the ground away for
|
||
the second it takes to draw the same picture again.
|
||
"""
|
||
started: list = []
|
||
screen = _async_map(monkeypatch, started)
|
||
screen.render_body(80)
|
||
# Pretend the scheduled raster finished.
|
||
screen._frame = ["ground"] * 20
|
||
screen._frame_key = screen._ground_key(screen._viewport)
|
||
screen._drawing = None
|
||
# All but the bottom row is the frame verbatim; that one also carries the basemap
|
||
# credit, which is stamped at paint time rather than baked into a raster served
|
||
# across many paints (see 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 lands, view unmoved
|
||
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_drops_a_stale_frame_once_the_view_moves(monkeypatch) -> None: # noqa: ANN001
|
||
"""Streets drawn for somewhere else are a lie about where you are looking."""
|
||
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
|
||
"""A misaligned frame is dropped, but the ground it drew is kept and reprojected.
|
||
|
||
It is the only ground anyone has until the next raster lands, and a pan that answers
|
||
with an empty canvas blanks the map to black between every keypress.
|
||
"""
|
||
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 tile's palette is resolved once per class, not once per feature.
|
||
|
||
``mark_rgb`` was reached ~30 000 times a frame and ``Feature.get`` ~32 000 — both
|
||
answering the same handful of questions. Hoisting them behind a per-tile memo is only
|
||
sound while every class still resolves to exactly the colour it did: a motorway must
|
||
not inherit the footway's grey because it drew 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 run per 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 lands on the canvas, so every class gets drawn.
|
||
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 menu-only: a picture has no scripted face.
|
||
|
||
Its answer is a drawing whose nodes are told apart by colour, which the scripted CLI
|
||
does not have (see :mod:`meshterm.ui.script`) — so the tool registers nothing rather
|
||
than printing an unparseable block of braille. The located nodes stay scriptable
|
||
through ``meshterm contacts``.
|
||
"""
|
||
import typer
|
||
|
||
app = typer.Typer()
|
||
MapTool().register_cli(app)
|
||
assert app.registered_commands == []
|
||
|
||
|
||
# -- the basemap credit -------------------------------------------------------
|
||
#
|
||
# The OpenStreetMap Foundation's Attribution Guideline is what these pin (quoted in
|
||
# meshterm/ui/attribution.py): the credit sits in a corner of the map, names
|
||
# "OpenStreetMap" rather than an abbreviation, is visible without interacting, and may
|
||
# collapse once the map is used — to a form that is still a complete OSM attribution.
|
||
#
|
||
# Which corner is the *frame's*, and the two platforms' frames differ: a bordered one sets
|
||
# the credit into its bottom border rule and leaves the drawing alone, while the
|
||
# borderless one has no rule and stamps the drawing's own last row instead. So most of
|
||
# these run twice, once per platform, and assert on the surface that platform actually
|
||
# marks.
|
||
|
||
|
||
def _credited_map(cols: int = 80) -> MapScreen:
|
||
"""A map screen over the offline source, arrived at and not yet touched."""
|
||
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, stripped to plain text."""
|
||
return _plain(screen.render_body(cols)).splitlines()[-1]
|
||
|
||
|
||
#: A panel's bottom corners, in both shapes Rich draws them: rounded wherever the console
|
||
#: can take them, square where it judges the console a legacy Windows one — which a
|
||
#: captured pytest run on Windows is. Asserting one shape passes on one OS only.
|
||
_BOTTOM_CORNERS = ("╰╯", "└┘")
|
||
|
||
|
||
def _bottom_rule(screen: MapScreen, cols: int = 80, rows: int = 24) -> str:
|
||
"""The composed frame's bottom border rule — the line the caption is set into."""
|
||
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:
|
||
"""Wherever this platform puts the credit: the bottom rule, else the drawing's last row."""
|
||
return _bottom_rule(screen, cols) if screen.bottom_caption else _last_row(screen, cols)
|
||
|
||
|
||
def _both_platforms():
|
||
"""The two platforms, restoring REGULAR afterwards — the credit differs across them."""
|
||
from meshterm.platforms import PICOCALC, REGULAR, set_platform
|
||
|
||
try:
|
||
for platform in (REGULAR, PICOCALC):
|
||
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'" — so neither form may 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 remnant is one of the two historical forms OSMF names as acceptable outright,
|
||
# so collapsing never costs the OpenStreetMap credit itself.
|
||
assert attribution.CREDIT_SHORT in ("© OpenStreetMap", "© OpenStreetMap contributors")
|
||
# OpenFreeMap's required line, less only the half 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 attribution may not be something you have to interact with the map to see."""
|
||
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 carries it, the map doesn't."""
|
||
from meshterm.platforms import REGULAR, set_platform
|
||
from meshterm.ui import attribution
|
||
|
||
set_platform(REGULAR)
|
||
screen = _credited_map(72)
|
||
# Right-justified with exactly one rule cell before the corner, as a title sits 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
|
||
# And nothing of it reached the drawing: the map's own rows are pure 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, and no rule to set a caption into."""
|
||
from meshterm.platforms import PICOCALC, REGULAR, set_platform
|
||
from meshterm.ui import attribution
|
||
|
||
try:
|
||
set_platform(PICOCALC)
|
||
screen = _credited_map(53)
|
||
assert screen.bottom_caption == "" # nowhere to put it but 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:
|
||
"""OSMF allows 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 the reader is answering
|
||
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 repaint never brings the whole line back — including through the frame's
|
||
# own composition memo, which has to key on the caption to notice.
|
||
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 a visit's state, not a session's — a new map is arrived at anew."""
|
||
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:
|
||
"""It rides 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:
|
||
"""Neither half is dropped: the arrows lead, the caption follows, the run moves 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 they have 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 yields its right end to the credit."""
|
||
from meshterm.platforms import PICOCALC, REGULAR, set_platform
|
||
from meshterm.ui import attribution
|
||
|
||
try:
|
||
set_platform(PICOCALC)
|
||
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, cropped to what is left
|
||
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:
|
||
"""A produced work too — and a non-interactive one, so its mark must stand still.
|
||
|
||
The preview is embedded in somebody else's body, with no frame of its own to set a
|
||
caption into, so it stamps 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 takes no 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 mark is drawn on the device, 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 truncated credit would claim something untrue; the About page still carries it."""
|
||
from meshterm.ui import attribution
|
||
|
||
rows = ["x" * 8]
|
||
assert attribution.stamp(rows, 8, full=True) == rows
|
||
# And a row with room for the remnant but not the whole line falls back to it.
|
||
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 serves one cached raster across many paints; stamping may not edit it."""
|
||
from meshterm.ui import attribution
|
||
|
||
rows = ["street " * 10, "more ground here"]
|
||
before = list(rows)
|
||
attribution.stamp(rows, 70, full=True)
|
||
assert rows == before
|