Files
MeshTerm/tests/test_header.py
T
Jean-Pierre MartineauandClaude Opus 5 63cd4e5f96 Performance: SQLite WAL, lighter startup, per-platform tick rates
PicoCalc plan phase P2, driven by measurements taken on the Lyra as a
paired A/B (git archive HEAD vs the working tree, run side by side
against the same venv).

The headline is the database. Every packet the monitor hears is one
durable commit, and on the SD card each was costing 33.78 ms of fsync.
db._tune() opens in WAL with temp_store=MEMORY: 0.59 ms per commit, a
54x cut. synchronous=NORMAL is requested only once WAL is confirmed
engaged - the pragma returns the mode it settled on rather than raising
when a filesystem refuses it - because under WAL it risks the last
transactions to a power cut, and under the rollback journal it risks the
file itself. This device runs on a battery pack.

Startup: bleak/dbus-fast were already lazy; the real finds were two
cheap *values* dragging heavy subtrees. core/connection.py imports one
constant from core/channels.py, which imported PyCryptodome at module
level (224 ms of CPU-feature probing per run) - now deferred into the
two functions that decrypt. And tools/map.py's register_cli, which runs
for every tool at boot, reached into ui/map_screen.py for a --fraction
default, dragging the basemap's urllib.request -> http.client -> ssl
behind it; DEFAULT_VIEW_FRACTION moves to core/geo.py (pure arithmetic,
already on the boot path) and is re-exported so existing importers are
untouched. 26 modules and 316 ms of import self-time gone.

Per-frame: the corner glow only ever recolours truecolor foregrounds, so
on a 16-slot console it scanned every line of every frame to hand back
the identical list - skipped outright where effects are off, along with
the battery blink and the braille spinner cycle. _has_wide_glyph answers
from the platform when no emoji can be drawn instead of scanning the
composed frame (6.5-9.1 ms per repaint on its own). The header built its
segments twice to measure the compact separator, on exactly the narrow
terminals that take that branch every frame; it now builds once and
measures arithmetically. Together: 101.2 -> 73.2 ms per frame at 53x26,
151.8 -> 110.4 at 53x40, and 72x24 unregressed at 91.7.

That finally gave numbers to set the clocks against. refresh_interval
becomes per-platform tick_s (2.0 s on picocalc: a 1 Hz idle tick alone
was 7-11% of a core doing nothing, and only the header's per-minute
pulse drifts, since screens push their own repaints via invalidate).
The 0.12 s spinner was untenable - 84% of a core at 53x26, and at 53x40
it did not fit in its own interval at all. Five duplicate
_SPINNER_INTERVAL = 0.12 constants across the UI collapse into one
platform-aware spinner_interval() beside the Spinner it paces.

The header refactor was verified equivalent over 540 renders, plain text
and style spans both. The span check earned its keep: the first cut
passed style= to the Text constructor, which makes it the object's base
style and blankets everything appended after, so the version string
would have rendered brand-under-muted. test_header.py only asserted on
.plain and never saw it; it now pins the styling too.

tests/test_performance.py pins each contract as behaviour rather than
timing, including a subprocess guard that fails if Crypto, the network
stack, or the map screen ever creep back onto the startup import graph.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-01 03:40:42 -04:00

143 lines
6.5 KiB
Python

"""Header tests: the one-line status bar's width-filling activity sparkline."""
from __future__ import annotations
from types import SimpleNamespace
from meshterm.ui.menu import _header
def _ctx(histogram=(), unread=0, alerts=0, battery=None) -> SimpleNamespace:
"""A minimal stand-in exposing only what the header reads (simulator path).
``battery`` is the poller's cached reading (a ``percent``/``charging`` object) or
``None`` for a companion with no pack — the default, so most tests see the old
battery-free header.
"""
return SimpleNamespace(
mock=True,
chat=SimpleNamespace(unread_total=lambda: unread),
monitor=SimpleNamespace(
activity_histogram=lambda: tuple(histogram),
activity_session_flags=lambda: (True,) * len(tuple(histogram)),
),
watchtower=SimpleNamespace(unacked_count=lambda: alerts),
battery=SimpleNamespace(reading=lambda: battery),
)
def test_header_shows_the_watchtower_badge() -> None:
"""Unacknowledged alerts appear as the triangle badge; none means no badge."""
assert "▲ 2" in _header(_ctx(alerts=2), {}, 80).plain
assert "▲" not in _header(_ctx(alerts=0), {}, 80).plain
def test_header_segments_style_each_run_on_its_own() -> None:
"""A segment's styles sit side by side, never layered one under the next.
The segments are built as their own ``Text`` objects and joined (see
:func:`~meshterm.ui.menu._header_segments`), which makes it easy to reach for a
constructor ``style=`` — but that becomes the object's *base* style and blankets
everything appended after it, so the version string would render brand-under-muted and
each badge's count would inherit its glyph's error red.
"""
header = _header(_ctx(unread=3, alerts=2), {}, 120)
covering = {
header.plain[span.start : span.end]: span.style
for span in header.spans
if span.style in ("brand", "err")
}
assert covering.get("MeshTerm") == "brand" # not "MeshTerm v1.2.3"
assert set(covering) == {"MeshTerm", "●", "▲"} # neither badge's count is swept in
def test_header_sparkline_fills_the_row_exactly() -> None:
"""Every cell the fixed segments leave is sparkline — no short rows, no overflow."""
for width in (60, 100):
header = _header(_ctx(histogram=(21,) * 360), {}, width)
assert header.cell_len == width
assert "⣿" in header.plain # maxed-out cells all the way to the edge
def test_header_puts_now_at_the_right_edge() -> None:
"""The current minute's traffic draws in the row's final cell, not its first."""
header = _header(_ctx(histogram=(21,) + (0,) * 359), {}, 80).plain
spark = [ch for ch in header if 0x2800 <= ord(ch) <= 0x28FF]
# The newest bucket is the final cell's *right* dot column (its left column is
# the minute before, silent bar the continuing baseline dot).
assert spark[-1] == chr(0x2800 | 0xB8 | 0x40)
assert all(ch == chr(0x2800 | 0x40 | 0x80) for ch in spark[:-1]) # older = flatline
def test_header_wider_terminal_shows_deeper_history() -> None:
"""A burst 90 minutes ago is off a narrow header but visible on a wide one.
One minute per dot column: 90 minutes back needs 45 cells of sparkline, so it
renders as a lit cell only when the row leaves that much room.
"""
histogram = tuple(0 if i != 90 else 21 for i in range(360))
def burst_cells(row: str) -> list[str]:
# Anything lit above the bottom dot row (bits beyond dots 7/8) is the burst;
# the baseline flatline and blank braille never set those bits.
return [ch for ch in row if 0x2800 <= ord(ch) <= 0x28FF and ord(ch) & 0x3F]
assert not burst_cells(_header(_ctx(histogram=histogram), {}, 60).plain)
assert burst_cells(_header(_ctx(histogram=histogram), {}, 120).plain)
def test_header_scales_to_the_window_peak() -> None:
"""The pulse scales relative to a peak, not a fixed threshold ladder.
A 16/12/8/4 ramp climbs one dot per step against the robust ceiling ``activity_peak``
computes for it (~14.8 here — a floored 90th percentile of the counts), so the four
newest minutes read heights 1,2,3,4 left→right in the row's final two cells.
"""
histogram = (16, 12, 8, 4) + (0,) * 356
header = _header(_ctx(histogram=histogram), {}, 80).plain
spark = [ch for ch in header if 0x2800 <= ord(ch) <= 0x28FF]
left = (0x00, 0x40, 0x44, 0x46, 0x47)
right = (0x00, 0x80, 0xA0, 0xB0, 0xB8)
assert spark[-2:] == [chr(0x2800 | left[1] | right[2]), chr(0x2800 | left[3] | right[4])]
def test_header_tight_row_drops_to_compact_separators() -> None:
"""When roomy separators would squeeze the pulse, they narrow to ' · '."""
roomy = _header(_ctx(), {}, 100).plain
assert " · " in roomy
tight = _header(_ctx(unread=12, alerts=3), {}, 60).plain
assert " · " not in tight and " · " in tight
# The compaction buys real sparkline room even on a badge-laden 60-column row.
spark = [ch for ch in tight if 0x2800 <= ord(ch) <= 0x28FF]
assert len(spark) >= 14
def test_header_survives_a_sliver_terminal() -> None:
"""When the fixed segments already overflow the width, the sparkline just stays out."""
header = _header(_ctx(histogram=(9,) * 360), {}, 10)
# No room left: no braille at all, nothing stretched past the edge.
assert all(not (0x2800 <= ord(ch) <= 0x28FF) for ch in header.plain)
def _spark(row: str) -> list[str]:
return [ch for ch in row if 0x2800 <= ord(ch) <= 0x28FF]
def test_header_pins_the_battery_gauge_to_the_right_edge() -> None:
"""A companion with a pack shows its charge in the corner; the pulse cedes the room."""
reading = SimpleNamespace(percent=87, charging=False)
header = _header(_ctx(histogram=(21,) * 360, battery=reading), {}, 80)
assert header.cell_len == 80 # still fills the row exactly, gauge included
assert header.plain.rstrip().endswith("87%") # the gauge sits flush right
# The pulse gave up real cells to make room (fewer sparkline cells than a bare row,
# even counting the gauge's own full-cell glyph).
with_batt = len(_spark(header.plain))
bare = len(_spark(_header(_ctx(histogram=(21,) * 360), {}, 80).plain))
assert with_batt < bare
def test_header_hides_the_gauge_without_a_battery() -> None:
"""No pack, no gauge — the pulse keeps the whole row it always had."""
header = _header(_ctx(histogram=(21,) * 360, battery=None), {}, 80)
assert "%" not in header.plain
assert header.cell_len == 80