Files
MeshTerm/tests/test_watchtower.py
T
Jean-Pierre MartineauandClaude Opus 5 92d31a3191 Every file is spelled the way the formatter spells it
`ruff format .` across the repo: 153 files, no behaviour change. This is the
second gate CONTRIBUTING.md promises, and until now it reported 154 files needing
reformat — so a first contributor ran the command the guide told them to run and
could not tell their own diff from the background.

Landed on its own, with nothing else in it, because a whole-repo reformat is only
safe to skip past in blame if it is provably nothing but layout. It was checked
that way rather than assumed: every changed file was formatted in memory and
compared by full `ast.dump()`, and 152 of 153 come out structurally identical
(the one exception was a docstring opening on a quote character, fixed in the
commit before this one). The string and comment token streams were diffed
separately, so a quote flip or an edited comment could not hide inside a
re-layout — five quote-style normalisations and sixteen implicit-concatenation
joins, all value-identical, and one commented-out line gaining the space after
its `#`. `meshterm specimen` is byte-identical on both platforms, and the suite
is 1888 green, gallery gate and `test_theme16` included.

What actually changed, in order of how much of the diff it is: wrapped calls and
signatures rejoin onto one line where they fit inside the hundred columns; the
magic trailing comma expands what it is there to expand; complex slices gain
their spaces (`[len(noise) :]`); a few redundant parens go. The tables that are
laid out to be read were fenced in the previous commit and are untouched here.

The aligned trailing-comment column is gone from about ninety lanes that were not
fenced — including the `heat.*` ladder in `MESH_THEME`, which cannot be fenced
where it sits. Every comment stays on the entry it describes, so nothing there
reads wrong; it just no longer lines up.

Add this commit to `.git-blame-ignore-revs`, which follows immediately.

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

316 lines
12 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