Files
MeshTerm/tests/test_scope_display.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

522 lines
20 KiB
Python

# SPDX-License-Identifier: Apache-2.0
"""The places where the scope of a flood is shown.
These are the packet viewer, the live feed, message paths, and the monitor.
``test_regions.py`` tests the scope itself: the maths, the store, and the history columns.
These tests are about the screens that show the scope. All these screens follow one rule.
A flood shows the region that it was sent into. Or it shows that it was unscoped, or that
it was scoped to a region that nobody here has named. A direct packet shows no scope.
"""
from __future__ import annotations
import io
import json
import re
from datetime import timedelta
from pathlib import Path
from rich.cells import cell_len
from meshterm.core.models import ChatMessage, Observation, utcnow
from meshterm.core.region_store import RegionStore
from meshterm.core.regions import (
UNSCOPED,
Scope,
frame_scope,
region_key,
scope_body,
transport_code,
)
from meshterm.persistence.repository import Repository
from meshterm.services.message_paths import (
Arrival,
channel_arrivals,
collapse,
message_scope,
)
from meshterm.tools.monitor import _live_packets
from meshterm.ui import script
from meshterm.ui.livefeed_screen import LiveFeedScreen
from meshterm.ui.message_paths_screen import MessagePathsScreen
from meshterm.ui.packet_viewer import PacketEntry, PacketViewer
from meshterm.ui.renderers import JsonRenderer, PlainRenderer
from tests.conftest import plain as _plain
from tests.test_message_paths import SECRET, _grp_txt_raw
#: A channel-text payload, chosen at random, to scope. The code uses the bytes, not the meaning.
_PAYLOAD = bytes.fromhex("a71c2d00112233445566778899aabbccddeeff")
def _scoped(region: str, payload: bytes = _PAYLOAD, **extra) -> dict:
"""The raw payload of an RX-log packet for a channel text that is flooded in ``region``."""
code = transport_code(region_key(region), scope_body(5, payload))
return {
"route_typename": "TC_FLOOD",
"payload_type": 5,
"payload_typename": "GRP_TXT",
"pkt_payload": payload,
"transport_code": code.to_bytes(2, "little").hex() + "0000",
**extra,
}
def _code(region: str) -> str:
"""The 4-hex code that a :func:`_scoped` packet has."""
return f"{transport_code(region_key(region), scope_body(5, _PAYLOAD)):04x}"
_FLOOD = {"route_typename": "FLOOD", "payload_typename": "GRP_TXT", "chan_hash": "a7"}
_DIRECT = {"route_typename": "DIRECT", "payload_typename": "TEXT_MSG", "dest_hash": "a1"}
def _knows(*names: str): # noqa: ANN202 - a scope reader over a fixed set of names
"""A ``scope_of`` that knows exactly ``names``.
It is a small version of the answer of the region store.
"""
return lambda raw: frame_scope(raw, names) if isinstance(raw, dict) else None
def _stripped(lines: list[str]) -> list[str]:
return [re.sub(r"\x1b\[[0-9;]*m", "", line) for line in lines]
def _col(line: str, needle: str) -> int:
"""The display column of ``needle``, in cells and not in characters.
The class icon is two cells.
"""
return cell_len(line[: line.index(needle)])
# -- the packet viewer -------------------------------------------------------------------
def _card_row(raw: dict, label: str, scope_of=None) -> str | None: # noqa: ANN001
"""The value of the ``label`` row of the viewer for one packet, as plain text.
The function returns ``None`` when the row is absent.
"""
entry = PacketEntry(when=utcnow(), kind="packet", path="3d", raw=raw)
body = _stripped(
PacketViewer([entry], 0, resolve=lambda h: "", scope_of=scope_of).render_body(80)
)
row = next((line for line in body if line.split(None, 1)[:1] == [label]), None)
return None if row is None else row.split(None, 1)[1].rstrip()
def _has_scope_row(text: str, value: str) -> bool:
"""Whether a rendered card has a ``scope`` row with the text ``value``."""
return re.search(rf"^scope\s+{re.escape(value)}\s*$", text, re.M) is not None
def test_the_viewer_names_the_region_a_flood_was_scoped_to() -> None:
"""The viewer names the region that a flood was scoped to.
``tc flood`` showed how the packet went, and never where. Now the route row says flood,
and a separate row says the region.
"""
assert _card_row(_scoped("harbour"), "route", _knows("harbour")) == "flood"
assert _card_row(_scoped("harbour"), "scope", _knows("harbour")) == "harbour"
def test_the_viewer_shows_an_unnamed_scopes_code_and_a_plain_flood_as_unscoped() -> None:
"""A scope that no known name gives keeps its code. A plain flood shows unscoped."""
assert _card_row(_scoped("elsewhere"), "scope", _knows("harbour")) == (
f"unknown region · code {_code('elsewhere')}"
)
assert _card_row(_FLOOD, "scope", _knows("harbour")) == "unscoped"
def test_the_viewer_without_a_store_still_tells_scoped_from_unscoped() -> None:
"""If no names are known, the viewer shows less of the truth, and never a wrong value.
The code replaces the name with the code.
"""
assert _card_row(_scoped("harbour"), "scope") == f"unknown region · code {_code('harbour')}"
assert _card_row(_FLOOD, "scope") == "unscoped"
def test_the_viewer_never_gives_a_direct_frame_a_scope() -> None:
"""The viewer gives no scope to a direct packet.
A repeater never filters a direct packet by region. The viewer shows the route word
only, and no scope row.
"""
assert _card_row(_DIRECT, "route", _knows("harbour")) == "direct"
assert _card_row(_DIRECT, "scope", _knows("harbour")) is None
tc_direct = {**_DIRECT, "route_typename": "TC_DIRECT"}
assert _card_row(tc_direct, "route") == "tc direct"
assert _card_row(tc_direct, "scope") is None
def test_the_viewer_keeps_the_scope_body_out_of_the_raw_dump(tmp_path: Path) -> None:
"""The viewer keeps the scope body out of the raw dump.
The restored HMAC input is an internal detail, and the route row already shows it.
"""
raw = _scoped("harbour")
restored = {k: v for k, v in raw.items() if k not in ("payload_type", "pkt_payload")}
restored["scope_body"] = scope_body(5, _PAYLOAD).hex()
body = _plain(
PacketViewer(
[PacketEntry(when=utcnow(), kind="packet", path="", raw=restored)],
0,
resolve=lambda h: "",
scope_of=_knows("harbour"),
).render_body(80)
)
assert _has_scope_row(body, "harbour") # a replayed packet resolves as a live packet does
assert "scope_body" not in body
def test_the_viewer_renames_a_scope_the_moment_its_region_is_learned(tmp_path: Path) -> None:
"""The viewer renames a scope when it learns its region.
The card is cached, but the cache does not hold the old card when a name arrives,
because the scope is part of the cache key.
"""
store = RegionStore(tmp_path / "regions.json")
entry = PacketEntry(when=utcnow(), kind="packet", path="", raw=_scoped("harbour"))
viewer = PacketViewer([entry], 0, resolve=lambda h: "", scope_of=store.scope_of)
assert not _has_scope_row(_plain(viewer.render_body(80)), "harbour")
store.learn("harbour", "typed")
assert _has_scope_row(_plain(viewer.render_body(80)), "harbour")
# -- the live feed -----------------------------------------------------------------------
class _Session:
def invalidate(self) -> None:
pass
def _feed(*raws: dict) -> LiveFeedScreen:
"""A live feed with one packet row for each packet, oldest first. The feed knows ``harbour``."""
screen = LiveFeedScreen(
session=_Session(),
resolve=lambda h: "",
seed=[
Observation(node="", kind="packet", snr=5.0, rssi=-90.0, observed_at=utcnow(), raw=raw)
for raw in raws
],
scope_of=_knows("harbour"),
)
screen.note_viewport(40)
return screen
def test_the_feed_scope_lane_sits_between_subject_and_readings() -> None:
"""The scope lane of the feed is between the subject and the readings.
A plain flood has a muted dash. A direct packet has no scope, so its lane is blank.
"""
screen = _feed(_DIRECT, _FLOOD, _scoped("elsewhere"), _scoped("harbour"))
header, *rows = _stripped(screen.render_body(100))
lanes = header.split()
assert lanes.index("SUBJECT") + 1 == lanes.index("SCOPE") == lanes.index("SNR") - 1
col = _col(header, "SCOPE")
assert _col(rows[0], "harbour") == col
assert _col(rows[1], f"? {_code('elsewhere')}") == col
assert _col(rows[2], " - ") + 1 == col
assert "-" not in rows[3].replace("-9", "") # the lane of the direct packet is blank
assert all(row.rstrip().endswith("dBm") for row in rows)
def test_the_feed_never_collapses_a_lane() -> None:
"""The feed never collapses a lane.
The feed draws the class name and the scope at each width. A narrow row scrolls
instead. No lane gives way: at 72 columns (a body of 68 cells) the readings go past the
edge, and the ←→ keys on the highlighted row reach them.
"""
screen = _feed(_scoped("harbour"))
for width in (68, 72, 80, 100):
header, row = _stripped(screen.render_body(width))[:2]
assert "CLASS" in header and "chan text" in row, width
assert _col(header, "SCOPE") == _col(row, "harbour"), width
screen._selected = 0 # noqa: SLF001
screen.render_body(68)
assert screen._hmax > 0 # noqa: SLF001 - the row is wider than the body, and ←→ reaches it
def test_the_picocalc_feed_names_the_class_without_an_icon() -> None:
"""The feed on the PicoCalc names the class without an icon.
On the console, the class icon is a substitute of one glyph. The name alone shows the
class.
"""
from meshterm.platforms import PICOCALC_LYRA, REGULAR, set_platform
set_platform(PICOCALC_LYRA)
try:
screen = _feed(_scoped("harbour"))
header, row = _stripped(screen.render_body(53))[:2]
assert header.split()[:2] == ["TIME", "CLASS"]
assert _col(header, "CLASS") == _col(row, "chan text")
finally:
set_platform(REGULAR)
def test_the_feed_hands_its_scope_reader_to_the_viewer() -> None:
"""The feed gives its scope reader to the viewer.
Thus Enter opens a card with the same region as the row.
"""
screen = _feed(_scoped("harbour"))
opened: list = []
screen._session.run_screen = lambda viewer: opened.append(viewer) # noqa: SLF001
screen._session.run_detached = lambda coro: None # noqa: SLF001
screen.handle("enter")
assert _has_scope_row(_plain(opened[0].render_body(80)), "harbour")
# -- message paths -----------------------------------------------------------------------
def test_a_messages_scope_is_read_once_off_its_arrivals() -> None:
"""The scope of a message is read one time from its arrivals.
Each copy has the code of the sender, so the first flooded copy gives the answer. A
reading with a name wins over a reading with no name. Any scope wins over unscoped.
Direct copies give no scope.
"""
scope_of = _knows("harbour")
when = utcnow()
def arrival(raw): # noqa: ANN001, ANN202
return Arrival(when=when, hops=(), snr=None, frame=raw)
direct, flood = arrival(_DIRECT), arrival(_FLOOD)
named, unnamed = arrival(_scoped("harbour")), arrival(_scoped("elsewhere"))
assert message_scope([direct, unnamed, named], scope_of) == Scope(
"scoped", "harbour", _code("harbour")
)
assert message_scope([flood, unnamed], scope_of).state == "unknown"
assert message_scope([direct, flood], scope_of) is UNSCOPED
assert message_scope([direct], scope_of) is None
assert message_scope([arrival(None)], scope_of) is None
def test_a_stored_scoped_message_resolves_through_its_arrivals(tmp_path: Path) -> None:
"""A stored scoped message resolves through its arrivals.
The history keeps what the resolution needs. The matcher keeps the packet, and the
collapse keeps it too.
"""
repo = Repository(tmp_path / "test.db")
run = repo.start_run("monitor", {}, None)
now = utcnow()
wire = "Alice: hi harbour"
for i, path in enumerate(("3d63", "3d63", "3d63,a1b2")):
raw = {**_grp_txt_raw(SECRET, wire), **_scoped("harbour")}
repo.record_observation(
run,
Observation(
node=None,
kind="packet",
path=path,
snr=2.0,
observed_at=now - timedelta(seconds=5 - i),
raw=raw,
),
)
message = ChatMessage(text=wire, is_channel=True, created_at=now)
arrivals = collapse(channel_arrivals(repo, message, channel_name="#general", secret=SECRET))
assert len(arrivals) == 2
store = RegionStore(tmp_path / "regions.json")
assert message_scope(arrivals, store.scope_of).state == "unknown"
store.learn("harbour", "channel")
assert message_scope(arrivals, store.scope_of).region == "harbour"
repo.close()
def _paths_screen(scope: Scope | None) -> MessagePathsScreen:
message = ChatMessage(text="hi", is_channel=True, created_at=utcnow())
return MessagePathsScreen(
message,
[Arrival(when=utcnow(), hops=("3d",), snr=1.0)],
matched=True,
resolve=lambda h: h,
prefix_bytes=1,
self_name="Homestead",
summary="heard once",
source="Alice",
scope=scope,
)
def test_the_paths_dialog_states_the_scope_once_in_its_title() -> None:
"""The paths dialog states the scope one time, in its title.
The scope is a status atom on the title, in the words of the viewer. A direct message
has no atom.
"""
assert _paths_screen(Scope("scoped", "harbour", "3fa1")).title == (
"Message paths · scope harbour"
)
assert (
_paths_screen(Scope("unknown", None, "3fa1")).title == "Message paths · unknown scope 3fa1"
)
assert _paths_screen(UNSCOPED).title == "Message paths · unscoped"
assert _paths_screen(None).title == "Message paths"
# The title does not repeat for each arrival row: the body does not repeat the scope.
body = _plain(_paths_screen(Scope("scoped", "harbour", "3fa1")).render_body(72))
assert "harbour" not in body
# -- monitor -------------------------------------------------------------------------------
def _capture(renderer_cls, rows: list[dict]) -> str: # noqa: ANN001
buffer = io.StringIO()
with renderer_cls(script.console(buffer)).stream(_live_packets()) as emit:
for row in rows:
emit(row)
return buffer.getvalue()
def _record(scope: Scope | None, name: str) -> dict:
from meshterm.ui.fields import NodeRef
return {
"observed_at": utcnow(),
"node": NodeRef(name=name, hash="d4e5f6a7"),
"kind": "packet",
"snr_db": 1.0,
"rssi_dbm": -90.0,
"position": None,
"scope": scope,
"path": [],
}
def test_monitor_streams_the_scope_before_the_name() -> None:
"""The monitor stream has the scope before the name.
The plain face has a fixed SCOPE lane, and NAME is still last. A long region goes past
its lane and pushes the row to the right. Thus the region can push only the one field
that is made to be pushed.
"""
text = _capture(
PlainRenderer,
[
_record(Scope("scoped", "harbour", "3fa1"), "Alice"),
_record(Scope("unknown", None, "3fa1"), "Bob"),
_record(UNSCOPED, "Carol"),
_record(None, "Dave"),
],
)
header, *rows = text.splitlines()
assert header.split()[-2:] == ["SCOPE", "NAME"]
assert [row.split()[-2:] for row in rows] == [
["harbour", "Alice"],
["unknown", "Bob"],
["unscoped", "Carol"],
["-", "Dave"],
]
assert (
len(
{
row.index(name)
for row, name in zip(rows, "Alice Bob Carol Dave".split(), strict=True)
}
)
== 1
)
def test_monitor_json_carries_the_scope_object_or_null() -> None:
"""The document uses the shared scope shape. A packet with no scope has ``null``."""
text = _capture(
JsonRenderer, [_record(Scope("scoped", "harbour", "3fa1"), "Alice"), _record(None, "Bob")]
)
first, second = (json.loads(line) for line in text.splitlines())
assert first["scope"] == {"state": "scoped", "region": "harbour", "code": "3fa1"}
assert second["scope"] is None
assert list(first)[-2:] == ["scope", "path"]
# -- the channel list --------------------------------------------------------------------
def _channel_header(scopes: dict[int, str]) -> str:
"""The column header of the Channels list, with a scope for the given slots."""
from meshterm.core.channel_probe import ChannelSlot
from meshterm.ui.channels import _LiveStats, _menu_items
from meshterm.ui.tui.select import SelectScreen
from tests.test_gallery import DEFAULT_PUBLIC_SECRET, _ChannelsCtx
slots = [
ChannelSlot(idx=0, name="Public", secret=DEFAULT_PUBLIC_SECRET),
ChannelSlot(idx=1, name="Ops", secret=bytes(range(16))),
]
ctx = _ChannelsCtx(muted=set(), scopes={slots[i].identity: n for i, n in scopes.items()})
title, items = _menu_items(ctx, slots, 8, _LiveStats(ctx))
return _stripped(SelectScreen(title, items).render_body(68))[0]
def test_the_channel_list_draws_scope_only_when_a_channel_has_one() -> None:
"""The channel list draws the scope lane only when a channel has a scope.
A column of blanks has no information. One scoped channel brings the lane back.
"""
assert _channel_header({}).split() == ["SLOT", "CHANNEL", "NEW", "LAST", "MSGS", "ACTIVITY"]
assert _channel_header({1: "harbour"}).split() == [
"SLOT",
"CHANNEL",
"SCOPE",
"NEW",
"LAST",
"MSGS",
"ACTIVITY",
]
# -- a decoded message's card --------------------------------------------------------------
def test_a_decoded_messages_card_reads_its_scope_off_its_logged_copies(tmp_path: Path) -> None:
"""The card of a decoded message reads its scope from its logged copies.
A 💬 row has no packet. Its card finds the flooded copy in the log and names the scope.
"""
from types import SimpleNamespace
from meshterm.persistence.repository import Repository
from meshterm.ui.livefeed_screen import message_scope_reader
text = "Alice: road closed"
raw = _grp_txt_raw(SECRET, text)
payload = bytes.fromhex(raw["chan_hash"] + raw["cipher_mac"] + raw["crypted"])
code = transport_code(region_key("harbour"), scope_body(5, payload))
raw.update(
route_typename="TC_FLOOD",
payload_type=5,
pkt_payload=payload,
transport_code=code.to_bytes(2, "little").hex() + "0000",
)
repo = Repository(tmp_path / "log.db")
when = utcnow()
repo.record_observation(
repo.start_run("monitor", {}),
Observation(node=None, kind="packet", path="3d", observed_at=when, raw=raw),
)
store = RegionStore(tmp_path / "regions.json")
ctx = SimpleNamespace(repo=repo, region_store=store)
read = message_scope_reader(ctx, {1: ("#general", SECRET)})
entry = PacketEntry(when=when, kind="message", channel=1, text=text)
assert read(entry).state == "unknown" # the copy is found, but its region has no name yet
store.learn("harbour", "typed")
assert read(entry).region == "harbour" # the name appears when the region is known
viewer = PacketViewer([entry], 0, resolve=lambda h: "", message_scope=read)
assert _has_scope_row(_plain(viewer.render_body(80)), "harbour")
other = PacketEntry(when=when, kind="message", channel=1, text="Bob: something else")
assert read(other) is None # no copy of it in the log, so no scope row and no guess
assert read(PacketEntry(when=when, kind="message", channel=9, text=text)) is None
repo.close()