A pan cost 0.9-2.4 s. It was never the tiles - those already load in a thread - it was render_map, which rasterizes the vector ground on the paint path: 900-2342 ms for a view holding ~16k features. Two changes, in that order. The projection was doing the same trigonometry once per vertex. Every feature_to_dot call recomputed Viewport.origin_world - a sin/log pair - and a 2** for values constant across the whole frame, 107k times a frame. The mapping is affine in tile-local coordinates, so tile_transform folds it into three numbers and _draw_tile spells the arithmetic out inline: two multiplies and two adds per vertex, no call at all. That alone is 2.4x (z12 2342 -> 991 ms). The rest is genuinely per-feature Python and will not shrink to keystroke size, so the raster moved off the paint path. A paint now returns the best picture available immediately and a background task draws the real one: unchanged view serves the finished frame; a view that only gained a tile keeps the old raster, which is still correctly aligned; a view that *moved* draws the nodes alone on the new viewport, which is a few ms, so panning tracks the keys and the streets catch up. The title says "drawing..." while it does. Exactly one raster runs at a time and it is always the newest requested - a held arrow hands us a new viewport every repaint, and starting a second of thread-bound work per keypress would queue frames that are already stale on arrival and slow the keystrokes this exists to protect. On the device: pan 1205 -> 89 ms, 1156 -> 130 ms, 2449 -> 91 ms; zoom 1161 -> 142 ms; a settled view 37 ms. Also flips MESHTERM_FASTRENDER on by default (=0 still opts out), and asks whether a loop is running *before* building a coroutine, so a static render no longer leaves an un-awaited one behind. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
349 lines
14 KiB
Python
349 lines
14 KiB
Python
"""The P2 performance contracts: what must stay cheap, and what must stay off the boot path.
|
|
|
|
These are behaviour tests, not benchmarks — a wall-clock assertion would be flaky on CI and
|
|
would say nothing about *why* a regression happened. Each test instead pins the structural
|
|
property the optimisation rests on: the database opens in the mode that makes small writes
|
|
cheap, the frame skips the passes that cannot change its pixels, and the startup import graph
|
|
stays clear of the three subtrees that were dragging a fifth of a second each onto every run.
|
|
|
|
The numbers behind these choices were measured on the PicoCalc (Luckfox Lyra, ~1 GHz
|
|
Cortex-A7, SD card) and are recorded in the plan's Measurements appendix.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import subprocess
|
|
import sys
|
|
from pathlib import Path
|
|
|
|
from prompt_toolkit.data_structures import Size
|
|
from prompt_toolkit.output import DummyOutput
|
|
from rich.text import Text
|
|
|
|
from meshterm.persistence import db
|
|
from meshterm.platforms import PICOCALC, REGULAR, set_platform
|
|
from meshterm.ui.tui.frame import compose_base
|
|
from meshterm.ui.tui.screen import Screen
|
|
from meshterm.ui.tui.session import _has_wide_glyph
|
|
from meshterm.ui.tui.spinner import Spinner
|
|
|
|
|
|
# --- the database's write path ------------------------------------------------------
|
|
|
|
|
|
def test_connect_opens_in_wal_mode(tmp_path: Path) -> None:
|
|
"""WAL is what turns each tiny insert into an append instead of a journal rewrite."""
|
|
conn = db.connect(tmp_path / "wal.db")
|
|
assert conn.execute("PRAGMA journal_mode").fetchone()[0].lower() == "wal"
|
|
|
|
|
|
def test_relaxed_sync_is_only_taken_together_with_wal(tmp_path: Path) -> None:
|
|
"""``synchronous=NORMAL`` is safe under WAL and corrupting without it — never split.
|
|
|
|
Under WAL, NORMAL risks losing the last transactions to a power cut; under the rollback
|
|
journal it risks the file itself. The PicoCalc runs on a battery pack, so the two
|
|
settings travel together or not at all.
|
|
"""
|
|
conn = db.connect(tmp_path / "sync.db")
|
|
journal = conn.execute("PRAGMA journal_mode").fetchone()[0].lower()
|
|
synchronous = conn.execute("PRAGMA synchronous").fetchone()[0]
|
|
if journal == "wal":
|
|
assert synchronous == 1 # NORMAL
|
|
else: # pragma: no cover - only on a filesystem that refuses WAL
|
|
assert synchronous == 2 # FULL, the safe default left untouched
|
|
|
|
|
|
def test_temp_store_is_memory(tmp_path: Path) -> None:
|
|
"""Sorts and temporary b-trees stay in RAM rather than landing on the SD card."""
|
|
conn = db.connect(tmp_path / "temp.db")
|
|
assert conn.execute("PRAGMA temp_store").fetchone()[0] == 2 # MEMORY
|
|
|
|
|
|
def test_tuning_survives_a_reopen(tmp_path: Path) -> None:
|
|
"""WAL is persistent, so a second session inherits it — and with it the relaxed sync."""
|
|
path = tmp_path / "reopen.db"
|
|
db.connect(path).close()
|
|
conn = db.connect(path)
|
|
assert conn.execute("PRAGMA journal_mode").fetchone()[0].lower() == "wal"
|
|
assert conn.execute("PRAGMA synchronous").fetchone()[0] == 1
|
|
|
|
|
|
# --- what must not be imported at startup -------------------------------------------
|
|
|
|
#: Subtrees that cost a fifth of a second *each* to import on the Lyra and are needed by no
|
|
#: startup path: PyCryptodome probes the CPU's crypto features (reached only through
|
|
#: ``core.channels``, whose constants the boot path wanted), and the map screen drags the
|
|
#: basemap's tile fetcher — ``urllib.request`` → ``http.client`` → ``ssl`` — behind it.
|
|
_MUST_STAY_LAZY = ("Crypto", "urllib.request", "http.client", "meshterm.ui.map_screen")
|
|
|
|
|
|
def test_startup_does_not_import_the_heavy_optional_subtrees() -> None:
|
|
"""``import meshterm.cli`` stays clear of crypto, the network stack, and the map.
|
|
|
|
Run in a subprocess because the assertion is about a *fresh* interpreter's import graph:
|
|
by the time this suite is running, half the codebase is already in ``sys.modules``.
|
|
"""
|
|
probe = (
|
|
"import sys, meshterm.cli;"
|
|
"print(','.join(sorted(m for m in sys.modules"
|
|
f" if any(m == h or m.startswith(h + '.') for h in {_MUST_STAY_LAZY!r}))))"
|
|
)
|
|
out = subprocess.run(
|
|
[sys.executable, "-c", probe], capture_output=True, text=True, check=True
|
|
).stdout.strip()
|
|
assert out == "", f"these should not load at startup: {out}"
|
|
|
|
|
|
def test_the_deferred_crypto_still_decrypts() -> None:
|
|
"""Deferring the import must not have cost the function its names."""
|
|
from meshterm.core.channels import decrypt_channel_text, identify_channel
|
|
|
|
# No known channel matches, but reaching the "no match" answer means HMAC/AES resolved.
|
|
assert identify_channel("ab", "cdef", "00" * 16, [("#public", b"\x01" * 16)]) is None
|
|
assert decrypt_channel_text("ab", "cdef", "00" * 16, [("#public", b"\x01" * 16)]) is None
|
|
|
|
|
|
def test_map_default_fraction_is_the_same_constant_from_either_module() -> None:
|
|
"""The map screen re-exports the geo constant, so existing importers still see one value."""
|
|
from meshterm.core.geo import DEFAULT_VIEW_FRACTION as from_geo
|
|
from meshterm.ui.map_screen import DEFAULT_VIEW_FRACTION as from_screen
|
|
|
|
assert from_geo == from_screen
|
|
|
|
|
|
def test_widgets_does_not_drag_the_spatial_modules() -> None:
|
|
"""``ui.widgets`` reads its mark constants from ``ui.marks``, not the heavy hosts.
|
|
|
|
The P2 sweep measured ~60 ms of Lyra import time in ``widgets`` pulling
|
|
``map_render``/``mapcanvas``/``pathgraph`` for constants alone; the constants moved
|
|
to the dependency-free ``ui.marks`` in P3. Same subprocess trick as the startup
|
|
guard: the claim is about a fresh interpreter's import graph.
|
|
"""
|
|
heavy = ("meshterm.ui.map_render", "meshterm.ui.mapcanvas", "meshterm.ui.pathgraph")
|
|
probe = (
|
|
"import sys, meshterm.ui.widgets;"
|
|
f"print(','.join(sorted(m for m in sys.modules if m in {heavy!r})))"
|
|
)
|
|
out = subprocess.run(
|
|
[sys.executable, "-c", probe], capture_output=True, text=True, check=True
|
|
).stdout.strip()
|
|
assert out == "", f"widgets should not drag these anymore: {out}"
|
|
|
|
|
|
# --- per-frame work the platform can skip -------------------------------------------
|
|
|
|
|
|
class _Body(Screen):
|
|
"""A minimal screen, just enough for ``compose_base`` to frame something."""
|
|
|
|
title = "Perf"
|
|
|
|
def render_body(self, width: int) -> list[str]: # noqa: D102 - inherited docstring
|
|
return ["body"]
|
|
|
|
|
|
def _glow_calls(cols: int = 60, rows: int = 20) -> int:
|
|
"""Compose one frame, counting how many times the corner-glow pass ran."""
|
|
import meshterm.ui.tui.frame as frame_mod
|
|
|
|
original = frame_mod.apply_corner_glow
|
|
calls = 0
|
|
|
|
def counting(lines: list[str]) -> list[str]:
|
|
nonlocal calls
|
|
calls += 1
|
|
return original(lines)
|
|
|
|
frame_mod.apply_corner_glow = counting
|
|
try:
|
|
compose_base(Text("hdr"), _Body(), "Esc back", cols, rows)
|
|
finally:
|
|
frame_mod.apply_corner_glow = original
|
|
return calls
|
|
|
|
|
|
def test_glow_runs_on_regular() -> None:
|
|
"""The desktop frame keeps its lit corners."""
|
|
assert _glow_calls() == 1
|
|
|
|
|
|
def test_glow_is_skipped_where_effects_are_off() -> None:
|
|
"""The pass only recolours truecolor foregrounds, so on PicoCalc it is pure scan cost."""
|
|
set_platform(PICOCALC)
|
|
assert _glow_calls() == 0
|
|
|
|
|
|
def test_wide_glyph_scan_short_circuits_without_emoji() -> None:
|
|
"""A no-emoji console font has no wide glyph, so the per-frame scan can be skipped."""
|
|
waving = "hi \U0001f44b"
|
|
assert _has_wide_glyph(waving) is True
|
|
set_platform(PICOCALC)
|
|
assert _has_wide_glyph(waving) is False
|
|
|
|
|
|
def test_spinner_cycle_follows_the_platform() -> None:
|
|
"""Braille where effects are on, the plain LINE cycle where they aren't."""
|
|
assert Spinner().frames == Spinner.BRAILLE
|
|
set_platform(PICOCALC)
|
|
assert Spinner().frames == Spinner.LINE
|
|
|
|
|
|
def test_an_explicit_spinner_cycle_still_wins() -> None:
|
|
"""The platform supplies a *default*; a caller that names its frames keeps them."""
|
|
set_platform(PICOCALC)
|
|
assert Spinner("ab").frames == "ab"
|
|
|
|
|
|
def test_battery_gauge_holds_still_where_effects_are_off() -> None:
|
|
"""No charging sweep and no low-battery blink: the gauge draws its resting frame."""
|
|
from meshterm.services.battery_service import BatteryReading
|
|
from meshterm.ui.menu import _battery_segment
|
|
|
|
class _Ctx:
|
|
class battery: # noqa: N801 - a stand-in for the service, not a real class name
|
|
@staticmethod
|
|
def reading() -> BatteryReading:
|
|
return BatteryReading(millivolts=3300, percent=5, charging=True)
|
|
|
|
ctx = _Ctx()
|
|
set_platform(PICOCALC)
|
|
# Frame 0 of a charging sweep is the empty cell; any later frame differs. Two calls at
|
|
# different wall-clock instants must agree, which they only can if the clock isn't read.
|
|
first = _battery_segment(ctx).plain # type: ignore[arg-type]
|
|
second = _battery_segment(ctx).plain # type: ignore[arg-type]
|
|
assert first == second
|
|
set_platform(REGULAR)
|
|
assert _battery_segment(ctx).plain.endswith("5%") # type: ignore[arg-type]
|
|
|
|
|
|
def test_idle_and_spin_cadences_are_slower_where_a_frame_is_dear() -> None:
|
|
"""PicoCalc composes a frame in ~74 ms, so both animation clocks have to give.
|
|
|
|
At the desktop's rates a 1 Hz idle tick alone would spend 7-11% of the core doing
|
|
nothing, and a 0.12 s spinner would ask for more frames per second than the hardware
|
|
can physically compose. Neither is a free-floating preference: both must stay strictly
|
|
slower than regular's.
|
|
"""
|
|
assert REGULAR.tick_s == 1.0
|
|
assert REGULAR.spinner_tick_s == 0.12
|
|
assert PICOCALC.tick_s > REGULAR.tick_s
|
|
# A frame costs ~110 ms at the taller 53x40 geometry; a spin cadence under that would
|
|
# queue repaints faster than they can finish.
|
|
assert PICOCALC.spinner_tick_s > 0.12
|
|
|
|
|
|
def test_spinner_interval_reads_the_active_platform() -> None:
|
|
"""One source for the spin cadence, re-read per tick so set_platform is never missed."""
|
|
from meshterm.ui.tui.spinner import spinner_interval
|
|
|
|
assert spinner_interval() == REGULAR.spinner_tick_s
|
|
set_platform(PICOCALC)
|
|
assert spinner_interval() == PICOCALC.spinner_tick_s
|
|
|
|
|
|
# --- the direct row-diff renderer ---------------------------------------------------
|
|
|
|
|
|
class _CapturingOutput(DummyOutput):
|
|
"""A DummyOutput that records what the renderer wrote, at a fixed console size."""
|
|
|
|
def __init__(self, rows: int = 26, columns: int = 53) -> None:
|
|
super().__init__()
|
|
self.written: list[str] = []
|
|
self._size = Size(rows=rows, columns=columns)
|
|
|
|
def get_size(self) -> Size:
|
|
return self._size
|
|
|
|
def write_raw(self, data: str) -> None:
|
|
self.written.append(data)
|
|
|
|
def cursor_goto(self, row: int = 0, column: int = 0) -> None:
|
|
self.written.append(f"<goto {row},{column}>")
|
|
|
|
def erase_screen(self) -> None:
|
|
self.written.append("<erase-screen>")
|
|
|
|
@property
|
|
def stream(self) -> str:
|
|
return "".join(self.written)
|
|
|
|
|
|
def _fast_renderer(frames: list[str]) -> tuple["FastRenderer", _CapturingOutput]:
|
|
"""A FastRenderer fed a scripted sequence of composed frames."""
|
|
from prompt_toolkit.styles import Style
|
|
|
|
from meshterm.ui.tui.fastrender import FastRenderer
|
|
|
|
out = _CapturingOutput()
|
|
pending = list(frames)
|
|
renderer = FastRenderer(
|
|
Style([]), out, full_screen=True, frame_source=lambda: pending.pop(0)
|
|
)
|
|
return renderer, out
|
|
|
|
|
|
def test_fastrender_is_on_with_an_escape_hatch(monkeypatch) -> None: # noqa: ANN001
|
|
"""The row writer is the default paint; only an explicit 0 hands it back to pt."""
|
|
from meshterm.ui.tui import fastrender
|
|
|
|
monkeypatch.delenv("MESHTERM_FASTRENDER", raising=False)
|
|
assert fastrender.enabled()
|
|
monkeypatch.setenv("MESHTERM_FASTRENDER", "0")
|
|
assert not fastrender.enabled()
|
|
|
|
|
|
def test_fastrender_rewrites_only_the_rows_that_changed() -> None:
|
|
"""The whole point: a keystroke that moves one row must not repaint the screen."""
|
|
rows_a = [f"row {i:02d}" for i in range(26)]
|
|
rows_b = list(rows_a)
|
|
rows_b[7] = "row 07 SELECTED"
|
|
renderer, out = _fast_renderer(["\n".join(rows_a), "\n".join(rows_b)])
|
|
|
|
renderer.render(None, None) # first paint: everything
|
|
out.written.clear()
|
|
renderer.render(None, None) # second: one row moved
|
|
|
|
stream = out.stream
|
|
assert "row 07 SELECTED" in stream
|
|
# Every *other* row's text stayed off the wire.
|
|
assert "row 06" not in stream
|
|
assert "row 08" not in stream
|
|
assert "\x1b[8;1H" in stream # addressed row 8 (1-based) directly
|
|
|
|
|
|
def test_fastrender_erases_before_it_draws_so_a_full_row_keeps_its_last_cell() -> None:
|
|
"""Erase-to-end *after* a full-width row wipes the character just written.
|
|
|
|
A row that exactly fills the console leaves the cursor in the last column with wrap
|
|
pending; an erase there clears that cell, and the row's final glyph goes missing (it
|
|
did, on the PicoCalc's 53 columns). So the erase has to lead, never follow.
|
|
"""
|
|
full = "x" * 53
|
|
renderer, out = _fast_renderer(["\n".join([full] * 26), "\n".join([full] * 26)])
|
|
renderer.render(None, None)
|
|
stream = out.stream
|
|
assert "\x1b[K" in stream
|
|
# In every row the clear precedes that row's text, and no clear trails it.
|
|
for chunk in stream.split("\x1b[0m")[1:]:
|
|
assert chunk.startswith("\x1b[K"), chunk[:40]
|
|
assert not chunk.rstrip().endswith("\x1b[K"), chunk[-40:]
|
|
|
|
|
|
def test_fastrender_hands_dialogs_back_to_prompt_toolkit(monkeypatch) -> None: # noqa: ANN001
|
|
"""A ``None`` frame means a float is up, which pt lays out — it must own that paint."""
|
|
from prompt_toolkit.renderer import Renderer
|
|
from prompt_toolkit.styles import Style
|
|
|
|
from meshterm.ui.tui.fastrender import FastRenderer
|
|
|
|
calls: list[bool] = []
|
|
monkeypatch.setattr(Renderer, "render", lambda *a, **k: calls.append(True))
|
|
renderer = FastRenderer(
|
|
Style([]), _CapturingOutput(), full_screen=True, frame_source=lambda: None
|
|
)
|
|
renderer.render(None, None)
|
|
assert calls == [True]
|
|
assert renderer.slow_paints == 1
|
|
assert renderer.fast_paints == 0
|