Files
MeshTerm/tests/test_watchtower.py
T
Jean-Pierre MartineauandClaude Opus 5 61dc9dc110 Every icon-led list measures its icon column, so no label starts a column early
The terminal draws some icons in one cell (↻ ⌨ 🗑 ✓ ✗ ⚠) and most emoji in two, so
a row written icon-plus-space started its words a column left of its siblings. The
fix kept landing one screen at a time. menus.align_icons now pads every icon in a
list out to the widest one, and menu_rows applies it itself, so a list built there
cannot misalign.

Hand-built lists are brought along: the courier outbox and entry actions, the
watchtower actions and rule popover, the record card's actions, the main menu's
Quit row and the device splash's action rows. The repeater admin's regression
test, dropped by a concurrent rewrite of its file, is restored.

tests/test_icon_alignment.py sweeps every gallery screen on both platforms and
fails, naming the rows and their start cells, when a list's icon-led rows start
their words in different cells.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-11 02:02:05 -04:00

391 lines
16 KiB
Python

"""Watchtower tests: the store's persistence and the sentinel's rules.
The service is driven synchronously through :meth:`note`/:meth:`evaluate` against a
stub context (the monitor tests' approach), so no timers or hardware are involved.
"""
from __future__ import annotations
import logging
from datetime import timedelta
from pathlib import Path
from meshterm.core.models import Observation, utcnow
from meshterm.core.watch_store import ALERT_CAP, OFF, WatchStore
from meshterm.persistence.repository import Repository
from meshterm.services.watchtower import SNR_WINDOW, WatchtowerService
NODE = "3d" * 6
class _StubContext:
"""Minimal stand-in for :class:`~meshterm.context.AppContext` for rule tests."""
def __init__(self, tmp_path: Path) -> None:
self.repo = Repository(tmp_path / "wt.db")
self.watch_store = WatchStore(tmp_path / "watchtower.json")
self.log = logging.getLogger("test.watchtower")
def _service(tmp_path: Path) -> WatchtowerService:
ctx = _StubContext(tmp_path)
service = WatchtowerService(ctx)
service._known = set() # what start() seeds from the DB; empty history here
return service
def _obs(node=NODE, name="Hub", snr=None, age_s=0, kind="advert") -> Observation:
return Observation(
node=node,
name=name,
kind=kind,
snr=snr,
observed_at=utcnow() - timedelta(seconds=age_s),
)
# --- the store --------------------------------------------------------------------------
def test_store_watch_round_trips_and_persists(tmp_path: Path) -> None:
"""Watched nodes and their rules survive a fresh store instance."""
path = tmp_path / "watchtower.json"
store = WatchStore(path)
seen = utcnow() - timedelta(hours=2)
store.watch(NODE, "Hub", last_seen=seen)
store.set_silence(NODE, 24)
store.set_snr_watch(NODE, False)
again = WatchStore(path)
entry = again.watched()[NODE]
assert entry.name == "Hub" and entry.silence_hours == 24 and not entry.snr_watch
assert entry.last_heard == seen
again.unwatch(NODE)
assert not WatchStore(path).watched()
def test_store_alert_log_caps_acks_and_clears(tmp_path: Path) -> None:
"""Alerts append newest-first, cap, acknowledge, and clear."""
store = WatchStore(tmp_path / "watchtower.json")
first = store.add_alert("silence", "Hub", "quiet")
store.add_alert("snr", "Hub", "sagging")
assert [a.kind for a in store.alerts()] == ["snr", "silence"]
assert store.unacked_count() == 2
store.ack(first.ident)
assert store.unacked_count() == 1
store.ack_all()
assert store.unacked_count() == 0
store.clear_acked()
assert store.alerts() == []
for i in range(ALERT_CAP + 10):
store.add_alert("new-node", f"n{i}", "hi")
assert len(store.alerts()) == ALERT_CAP
assert store.alerts()[0].label == f"n{ALERT_CAP + 9}" # newest survives the cap
def test_store_note_heard_updates_and_refreshes_name(tmp_path: Path) -> None:
"""Hearing a watched node advances its mark and adopts a fresh name."""
store = WatchStore(tmp_path / "watchtower.json")
store.watch(NODE, NODE)
later = utcnow() + timedelta(minutes=5)
store.note_heard(NODE, when=later, name="Hilltop-Repeater")
entry = store.watched()[NODE]
assert entry.last_heard == later and entry.name == "Hilltop-Repeater"
# --- the silence rule ---------------------------------------------------------------------
def test_silence_fires_once_then_rearms_on_recovery(tmp_path: Path) -> None:
"""Quiet past the threshold alarms once; hearing again notes the recovery."""
service = _service(tmp_path)
store = service._ctx.watch_store
store.watch(NODE, "Hub", last_seen=utcnow() - timedelta(hours=13))
service.evaluate() # default threshold is 12 h — already past it
service.evaluate() # latched: no duplicate
alerts = store.alerts()
assert [a.kind for a in alerts] == ["silence"]
assert "12 h" in alerts[0].message
service.note(_obs()) # the node returns
alerts = store.alerts()
assert [a.kind for a in alerts] == ["recovered", "silence"]
assert store.watched()[NODE].silent_since is None # re-armed
service.evaluate() # freshly heard: quiet again only after another 12 h
assert len(store.alerts()) == 2
def test_silence_respects_off_and_unheard(tmp_path: Path) -> None:
"""An OFF rule never alarms, however stale the mark."""
service = _service(tmp_path)
store = service._ctx.watch_store
store.watch(NODE, "Hub", last_seen=utcnow() - timedelta(days=30))
store.set_silence(NODE, OFF)
service.evaluate()
assert store.alerts() == []
# --- the SNR rule ---------------------------------------------------------------------
def test_snr_sag_alerts_with_cooldown(tmp_path: Path) -> None:
"""A clear drop in median SNR alerts once, not on every subsequent packet."""
service = _service(tmp_path)
store = service._ctx.watch_store
store.watch(NODE, "Hub")
for _ in range(SNR_WINDOW):
service.note(_obs(snr=8.0))
for _ in range(SNR_WINDOW):
service.note(_obs(snr=-2.0))
sags = [a for a in store.alerts() if a.kind == "snr"]
assert len(sags) == 1
assert "+8.0" in sags[0].message and "-2.0" in sags[0].message
service.note(_obs(snr=-2.0)) # still sagging, but inside the cooldown
assert len([a for a in store.alerts() if a.kind == "snr"]) == 1
def test_snr_ignores_packet_rows_and_disabled_watch(tmp_path: Path) -> None:
"""Relay-measured packet SNR and an off switch both keep the rule quiet."""
service = _service(tmp_path)
store = service._ctx.watch_store
store.watch(NODE, "Hub")
store.set_snr_watch(NODE, False)
for snr in (9.0,) * SNR_WINDOW + (-9.0,) * SNR_WINDOW:
service.note(_obs(snr=snr))
assert [a for a in store.alerts() if a.kind == "snr"] == []
store.set_snr_watch(NODE, True)
for snr in (9.0,) * SNR_WINDOW + (-9.0,) * SNR_WINDOW:
service.note(_obs(snr=snr, kind="packet"))
assert [a for a in store.alerts() if a.kind == "snr"] == []
# --- the new-node rule ------------------------------------------------------------------
def test_new_node_announced_once_ever(tmp_path: Path) -> None:
"""A first-ever id alerts once; repeats and restarts stay quiet."""
service = _service(tmp_path)
store = service._ctx.watch_store
service.note(_obs(node="f7" * 6, name="Newcomer"))
service.note(_obs(node="f7" * 6, name="Newcomer"))
news = [a for a in store.alerts() if a.kind == "new-node"]
assert len(news) == 1 and news[0].label == "Newcomer"
assert store.known_contains("f7" * 6) # persisted: restarts stay quiet too
def test_new_node_rule_can_be_switched_off(tmp_path: Path) -> None:
"""With the toggle off, first sightings are remembered but never announced."""
service = _service(tmp_path)
store = service._ctx.watch_store
store.set_new_node_alerts(False)
service.note(_obs(node="c9" * 6))
assert store.alerts() == []
assert store.known_contains("c9" * 6)
# --- the screen's rows ---------------------------------------------------------------
def test_store_round_trips_node_type(tmp_path: Path) -> None:
"""A starred node's advertised type persists; legacy entries load as None."""
path = tmp_path / "watch.json"
store = WatchStore(path)
store.watch("aa" * 6, "Roof", node_type=2)
store.watch("bb" * 6, "Old-style") # no type, like a pre-field entry
reloaded = WatchStore(path)
assert reloaded.watched()["aa" * 6].node_type == 2
assert reloaded.watched()["bb" * 6].node_type is None
def test_watched_row_type_glyph_and_hued_name(tmp_path: Path) -> None:
"""A watched row leads with its type glyph and hues the name by key.
The glyph keeps its own colour and the name takes the entry's key-derived hue;
silence still reads from the trailing tail.
"""
from meshterm.core.watch_store import WatchedNode
from meshterm.ui.theme import name_style
from meshterm.ui.watchtower_screen import _watched_row
from meshterm.ui.widgets import _NODE_GLYPHS
entry = WatchedNode(key="a1" * 6, name="Roof", node_type=2, last_heard=utcnow())
row = _watched_row(entry, lambda key: None)
glyph, glyph_style = _NODE_GLYPHS[2]
assert row.plain.startswith(f"{glyph} Roof")
assert any(s.style == glyph_style and s.start == 0 for s in row.spans)
name_at = row.plain.index("Roof")
assert any(
s.style == name_style("Roof", "a1" * 6) and s.start <= name_at < s.end for s in row.spans
)
# A legacy entry (no stored type) falls back to the contact table's resolver.
legacy = WatchedNode(key="a1" * 6, name="Roof")
resolved = _watched_row(legacy, lambda key: 2)
assert resolved.plain.startswith(f"{glyph} ")
# Silence is signalled by the ⚠ tail, never the glyph colour.
quiet = WatchedNode(key="a1" * 6, name="Roof", node_type=2, silent_since=utcnow())
assert "⚠ silent" in _watched_row(quiet, lambda key: None).plain
def test_alert_row_hues_the_label_by_resolved_key(tmp_path: Path) -> None:
"""An unacked alert's label takes its key-derived hue; acked recedes to muted."""
from meshterm.core.watch_store import Alert
from meshterm.ui.theme import name_style
from meshterm.ui.watchtower_screen import _alert_lanes
def key_of(label):
return "d4" * 6 if label == "Roof" else None
alert = Alert(ident=1, when=utcnow(), kind="silence", label="Roof", message="quiet")
row = _alert_lanes(alert, key_of)
at = row.plain.index("Roof")
assert any(s.style == name_style("Roof", "d4" * 6) and s.start <= at < s.end for s in row.spans)
acked = Alert(ident=2, when=utcnow(), kind="silence", label="Roof", message="quiet", acked=True)
acked_row = _alert_lanes(acked, key_of)
at = acked_row.plain.index("Roof")
assert any(s.style == "muted" and s.start <= at < s.end for s in acked_row.spans)
def test_alert_row_leads_node_name_with_type_glyph(tmp_path: Path) -> None:
"""The node name is preceded by its shared type glyph (own colour, muted when acked)."""
from meshterm.core.watch_store import Alert
from meshterm.ui.watchtower_screen import _alert_lanes
from meshterm.ui.widgets import _DEFAULT_GLYPH, _NODE_GLYPHS
def key_of(label):
return None
def type_of(label):
return 2 if label == "Roof" else None # Roof advertises as a repeater
glyph, glyph_style = _NODE_GLYPHS[2]
alert = Alert(ident=1, when=utcnow(), kind="silence", label="Roof", message="quiet")
row = _alert_lanes(alert, key_of, type_of)
assert f"{glyph} Roof" in row.plain # glyph sits immediately left of the name
gi = row.plain.index(glyph)
assert any(s.style == glyph_style and s.start <= gi < s.end for s in row.spans)
# Unknown type (and a keyless kind like courier) falls back to the plain-node glyph.
ghost = Alert(ident=2, when=utcnow(), kind="courier", label="Ghost", message="gave up")
assert f"{_DEFAULT_GLYPH[0]} Ghost" in _alert_lanes(ghost, key_of).plain
# An acked alert mutes the glyph with the rest of its history.
acked = Alert(ident=3, when=utcnow(), kind="silence", label="Roof", message="quiet", acked=True)
acked_row = _alert_lanes(acked, key_of, type_of)
gi = acked_row.plain.index(glyph)
assert any(s.style == "muted" and s.start <= gi < s.end for s in acked_row.spans)
def test_alert_rows_pin_their_lanes_and_scroll_only_the_message() -> None:
"""←→ slide an alert's *message*; the marker, age, kind, glyph and node hold still.
The lanes in front of the ``—`` are which alert this is (JP, 2026-08-10). Reading a
long message to its end is no reason to lose them off the left edge — they are the part
that already fits, so scrolling them buys nothing and costs the row its identity.
"""
from meshterm.core.watch_store import Alert
from meshterm.ui.tui import Choice
from meshterm.ui.watchtower_screen import _alert_lanes, _menu_items
alert = Alert(
ident=1,
when=utcnow(),
kind="silence",
label="Roof",
message="nothing heard for 6 h " + "and counting " * 6,
)
items = _menu_items([alert], {}, False)
row = next(it for it in items if isinstance(it, Choice) and it.value == ("ack", alert.ident))
lanes = _alert_lanes(alert, lambda label: None)
assert row.hscroll_from == lanes.cell_len
assert row.label.plain.startswith(lanes.plain)
assert lanes.plain.endswith(" — ") # the lead-in stays with the head it introduces
assert row.label.plain[row.hscroll_from :] == alert.message # …and the run is the message
def _word_starts(items: list, words: dict) -> dict:
"""The display cell each row's first word starts in, by row value.
Cells, not characters: a two-cell ``⭐`` and a one-cell ``✓`` are both one character, so
counting characters is exactly what hid a label starting a column early.
"""
from rich.cells import cell_len
from meshterm.ui.tui import Choice
starts = {}
for item in items:
if isinstance(item, Choice) and item.value in words:
plain = item.label.plain
starts[item.value] = cell_len(plain[: plain.index(words[item.value])])
assert starts.keys() == words.keys(), "every row the test names is on the screen"
return starts
def test_watchtower_actions_start_every_label_in_the_same_cell() -> None:
"""The action rows share one icon column, across the list's section headings.
``✓`` and ``🗑`` draw one cell and ``⭐`` and ``🔔`` two, so the bulk actions started
their words a column left of *Watch a node…* and the new-node toggle. On the PicoCalc
the icons go and must take their padding with them.
"""
from rich.cells import cell_len
from meshterm.core.watch_store import Alert
from meshterm.platforms import PICOCALC, REGULAR, set_platform
from meshterm.ui.watchtower_screen import _ACK_ALL, _CLEAR, _TOGGLE_NEW, _WATCH, _menu_items
assert {cell_len("✓"), cell_len("⭐")} == {1, 2}, "a list of one icon width proves nothing"
alerts = [
Alert(ident=1, when=utcnow(), kind="silence", label="Roof", message="quiet"),
Alert(ident=2, when=utcnow(), kind="snr", label="Roof", message="sag", acked=True),
]
words = {
_ACK_ALL: "Acknowledge",
_CLEAR: "Clear",
_WATCH: "Watch",
_TOGGLE_NEW: "New-node",
}
assert set(_word_starts(_menu_items(alerts, {}, True), words).values()) == {3}
try:
set_platform(PICOCALC)
assert set(_word_starts(_menu_items(alerts, {}, True), words).values()) == {0}
finally:
set_platform(REGULAR)
def test_node_rules_start_every_label_and_value_in_the_same_cell() -> None:
"""The rule popover's words share one column, and its values another, on both platforms.
``🕒`` and ``📶`` draw two cells and ``✗`` one, so *Stop watching this node* started a
column early. The values used to be lined up with hand-typed spaces, so they are pinned
here too: whatever the icon column measures, the values stay in one column.
"""
from meshterm.core.watch_store import WatchedNode
from meshterm.platforms import PICOCALC, REGULAR, set_platform
from meshterm.ui.watchtower_screen import _rule_items
entry = WatchedNode(key="a1" * 6, name="Roof", silence_hours=12, snr_watch=True)
words = {"silence": "Silence", "snr": "SNR", "unwatch": "Stop"}
values = {"silence": "after 12 h", "snr": "on"}
try:
for platform, start in ((REGULAR, 3), (PICOCALC, 0)):
set_platform(platform)
items = _rule_items(entry)
assert set(_word_starts(items, words).values()) == {start}
assert len(set(_word_starts(items, values).values())) == 1
finally:
set_platform(REGULAR)