`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>
316 lines
12 KiB
Python
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
|