Files
MeshTerm/tests/test_watchtower.py
Jean-Pierre MartineauandClaude Opus 5.5 11b5274b31 Docstrings and comments of the tests are in ASD-STE100
Each test docstring states the behaviour that the test proves, in the
simple present. No assertion message, test id, or other string literal
changed. Four statements now agree with the code: the Trace highlight
stays on the last row, a menu count, and two others.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-06 21:36:50 -04:00

421 lines
17 KiB
Python

# SPDX-License-Identifier: Apache-2.0
"""Watchtower tests: the storage of the store and the rules of the sentinel.
The tests run the service synchronously through :meth:`note` and :meth:`evaluate`, with a
stub context (the same method as the monitor tests). Thus the tests do not use timers or
hardware.
"""
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:
"""A minimal substitute for :class:`~meshterm.context.AppContext` in the 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() # ``start()`` gets this from the DB. The history is empty 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 new 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:
"""The store adds alerts with the newest first.
It limits their number, acknowledges them, and clears them.
"""
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}" # the newest alert stays at the cap
def test_store_note_heard_updates_and_refreshes_name(tmp_path: Path) -> None:
"""When MeshTerm hears a watched node, the store moves its mark forward.
The store also takes the new 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:
"""The silence rule alarms one time when the node is quiet past the threshold.
When MeshTerm hears the node again, the rule notes the recovery and arms again.
"""
service = _service(tmp_path)
store = service._ctx.watch_store
store.watch(NODE, "Hub", last_seen=utcnow() - timedelta(hours=13))
service.evaluate() # the default threshold is 12 h, and the node is past it
service.evaluate() # the alarm is latched, so there is 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 comes back
alerts = store.alerts()
assert [a.kind for a in alerts] == ["recovered", "silence"]
assert store.watched()[NODE].silent_since is None # armed again
service.evaluate() # the node was heard just now, so it is quiet again only after 12 h more
assert len(store.alerts()) == 2
def test_silence_respects_off_and_unheard(tmp_path: Path) -> None:
"""A rule that is OFF never alarms, also when the mark is very old."""
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 the median SNR gives one alert. The next packets do not give more alerts."""
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)) # the SNR is still low, but the cooldown is active
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:
"""The SNR that a relay measured on a packet keeps the rule quiet.
A switch that is off also keeps 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:
"""An id that MeshTerm hears for the first time gives one alert.
Repeats and restarts give none.
"""
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) # the store keeps it, so restarts give no alert too
def test_new_node_rule_can_be_switched_off(tmp_path: Path) -> None:
"""If the toggle is off, the store remembers a node that is heard for the first time.
The store never announces it.
"""
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:
"""The store keeps the advertised type of a starred node. An old entry loads 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, as in an entry from before the field existed
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 starts with its type glyph, and the name has a hue from the key.
The glyph keeps its own colour. The name takes the hue that comes from the key of the
entry. The silence still shows in the tail at the end.
"""
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
)
# An old entry (with no stored type) uses the resolver of the contact table.
legacy = WatchedNode(key="a1" * 6, name="Roof")
resolved = _watched_row(legacy, lambda key: 2)
assert resolved.plain.startswith(f"{glyph} ")
# The ⚠ tail shows the silence. The colour of the glyph never shows it.
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:
"""The label of an alert that is not acknowledged takes its hue from the key.
An acknowledged alert is 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 type glyph that nodes share comes before the node name.
The glyph has its own colour, and it is muted when the alert is acknowledged.
"""
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 itself 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 # the glyph is directly to the 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)
# An unknown type, and a kind with no key such as courier, use the glyph of a plain node.
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 acknowledged 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 the message of an alert. The marker, age, kind, glyph, and node stay in place.
The lanes before the ``—`` show which alert this is (JP, 2026-08-10). The user can read
a long message to its end, and the lanes must not go off the left edge. They are the
part that already fits. If they scrolled, there would be no gain, and the row would
lose 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 that it introduces
assert row.label.plain[row.hscroll_from :] == alert.message # and the scroll run is the text
def _word_starts(items: list, words: dict) -> dict:
"""The display cell where the first word of each row starts, by row value.
The function counts cells and not characters. A ``⭐`` of two cells and a ``✓`` of one
cell are both one character. A count of characters hid a label that started one column
too 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 section headings of the list.
``✓`` and ``🗑`` use one cell, and ``⭐`` and ``🔔`` use two. Thus the bulk actions
started their words one column to the left of *Watch a node…* and the new-node toggle.
On the PicoCalc the icons are not there, and their padding must go with them.
"""
from rich.cells import cell_len
from meshterm.core.watch_store import Alert
from meshterm.platforms import PICOCALC_LYRA, 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_LYRA)
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 words of the rule dialog share one column, and its values share another.
This is true on both platforms. ``🕒`` and ``📶`` use two cells, and ``✗`` uses one.
Thus *Stop watching this node*
started one column too early. The code once aligned the values with spaces that a
person typed. Thus the test checks the values too: the values stay in one column, for
each width of the icon column.
"""
from meshterm.core.watch_store import WatchedNode
from meshterm.platforms import PICOCALC_LYRA, 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_LYRA, 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)