diff --git a/CHANGELOG.md b/CHANGELOG.md index 0a892c7..e137950 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -38,8 +38,8 @@ one caveat SemVer makes for a leading zero: while the major version is still `0` `channels scope INDEX [REGION]` reads or sets it, `channels list` gains a `SCOPE` column, and `chat send --channel N --scope REGION` (or `--scope '*'` for unscoped) overrides it for one message. Scoped sending needs firmware 1.10; `*` needs 1.16. -- **Packets say which region a flood was scoped to.** The packet viewer's route row, the - message paths title, and a new `SCOPE` lane in the live feed name the region a scoped +- **Packets say which region a flood was scoped to.** A `scope` row in the packet viewer, + the message paths title, and a new `SCOPE` lane in the live feed name the region a scoped flood was sent into, or its code when no region known here matches it. `monitor` gains the same `SCOPE` column and a `scope` field in `--json`. A scoped packet is kept with what is needed to name its region later, so one heard today is named once a repeater or diff --git a/meshterm/ui/packet_viewer.py b/meshterm/ui/packet_viewer.py index 60c0be5..48ef884 100644 --- a/meshterm/ui/packet_viewer.py +++ b/meshterm/ui/packet_viewer.py @@ -940,7 +940,10 @@ class PacketViewer(Screen): rows.extend(self._addressing_rows(raw)) route = raw.get("route_typename") if route: - rows.append(("route", self._route_text(route, self._entry_scope(entry)))) + scope = self._entry_scope(entry) + rows.append(("route", self._route_text(route, scope))) + if scope is not None: + rows.append(("scope", self._scope_text(scope))) rows.append(("via", self._via_path(entry))) # The figure the chain above encodes but never states, hung under it as the app's # own hop atom — the same one the node page's routes and the trace scenarios carry @@ -995,27 +998,39 @@ class PacketViewer(Screen): @staticmethod def _route_text(route: str, scope: Scope | None) -> Text: - """The ``route`` row: how the frame was routed, and for a flood, where to. + """The ``route`` row: how the frame was routed — ``flood`` or ``direct``. - A flood reads ``flood`` and then its scope as a chained atom - (:func:`~meshterm.ui.widgets.scope_text`) — ``flood · scope harbour``, - ``flood · unknown scope 3fa1``, ``flood · unscoped`` — rather than the library's - ``tc flood``, which named the wire mechanism (transport codes) and left the - reader to know that it meant *scoped*, and to which region nobody could say. - A scoped and an unscoped flood are one routing; the scope is what differs, so it - is the scope that is spelled out. + A scoped flood reads ``flood``, not the library's ``tc flood``: that named the wire + mechanism (transport codes) and left the reader to know it meant *scoped*. A scoped + and an unscoped flood are one routing; where they differ is the region, and that + has a row of its own underneath (:meth:`_scope_text`). - A direct frame keeps its route word alone. A repeater never region-filters a - direct packet, so it has no scope, and a ``TC_DIRECT`` frame's codes are the - "to nowhere" pair a contact share uses, not a region — a word there would claim a - meaning the frame does not carry. + A direct frame keeps its route word alone. A ``TC_DIRECT`` frame's codes are the + "to nowhere" pair a contact share uses, not a region, so it has no scope row. """ if scope is None: return Text(route.replace("_", " ").lower()) - text = Text("flood") - text.append(" · ", style="muted") - text.append_text(scope_text(scope)) - return text + return Text("flood") + + @staticmethod + def _scope_text(scope: Scope) -> Text: + """The ``scope`` row: the region a flood was sent into, stated on its own line. + + Its own row, labelled, rather than an atom trailing the route: it is what a reader + opened the card from the feed's SCOPE lane to see, and a card has the room to say + it in full where the lane had ten cells. The label says *scope*, so the value is + the region alone (``harbour``, in the ``scope`` style); a region no name known here + reproduces reads ``unknown region · code 3fa1``, the code kept so two packets can be + seen to share it; a plain flood reads ``unscoped``. + """ + if scope.state == "scoped" and scope.region: + return scope_text(scope, bare=True) + if scope.scoped: + text = Text("unknown region", style="muted") + if scope.code: + text.append(f" · code {scope.code}", style="muted") + return text + return Text("unscoped", style="muted") def _addressing_rows(self, raw: dict) -> list[tuple[str, RenderableType]]: """Who a frame was for, who it says it was from, and the token it carries. diff --git a/tests/test_scope_display.py b/tests/test_scope_display.py index 01d7c58..b9b3889 100644 --- a/tests/test_scope_display.py +++ b/tests/test_scope_display.py @@ -86,44 +86,48 @@ def _col(line: str, needle: str) -> int: # -- the packet viewer ------------------------------------------------------------------- -def _route_row(raw: dict, scope_of=None) -> str: # noqa: ANN001 - """The viewer's ``route`` row for one frame, as plain text.""" +def _card_row(raw: dict, label: str, scope_of=None) -> str | None: # noqa: ANN001 + """The value of the viewer's ``label`` row for one frame, as plain text (``None``: 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) ) - return next(line for line in body if line.startswith("route")).rstrip() + 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 carries a ``scope`` row reading ``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: - """``tc flood`` said *how* and never *where*; the row now says flood, then the scope.""" - assert _route_row(_scoped("harbour"), _knows("harbour")).split(None, 1)[1] == ( - "flood · scope harbour" - ) + """``tc flood`` said *how* and never *where*: route says flood, its own row 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 no known name reproduces keeps its code; a plain flood reads unscoped.""" - assert _route_row(_scoped("elsewhere"), _knows("harbour")).endswith( - f"flood · unknown scope {_code('elsewhere')}" + assert _card_row(_scoped("elsewhere"), "scope", _knows("harbour")) == ( + f"unknown region · code {_code('elsewhere')}" ) - assert _route_row(_FLOOD, _knows("harbour")).endswith("flood · unscoped") + assert _card_row(_FLOOD, "scope", _knows("harbour")) == "unscoped" def test_the_viewer_without_a_store_still_tells_scoped_from_unscoped() -> None: """No names at hand is less of the truth, never a wrong one: the code stands in.""" - assert _route_row(_scoped("harbour")).endswith(f"flood · unknown scope {_code('harbour')}") - assert _route_row(_FLOOD).endswith("flood · unscoped") + 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: - """A repeater never region-filters a direct packet, so its route row is the word alone.""" - assert _route_row(_DIRECT, _knows("harbour")).split() == ["route", "direct"] - assert _route_row({**_DIRECT, "route_typename": "TC_DIRECT"}).split() == [ - "route", - "tc", - "direct", - ] + """A repeater never region-filters a direct packet: its route word alone, 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: @@ -139,7 +143,7 @@ def test_the_viewer_keeps_the_scope_body_out_of_the_raw_dump(tmp_path: Path) -> scope_of=_knows("harbour"), ).render_body(80) ) - assert "scope harbour" in body # a replayed frame resolves exactly as a live one + assert _has_scope_row(body, "harbour") # a replayed frame resolves exactly as a live one assert "scope_body" not in body @@ -148,9 +152,9 @@ def test_the_viewer_renames_a_scope_the_moment_its_region_is_learned(tmp_path: P 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 "scope harbour" not in _plain(viewer.render_body(80)) + assert not _has_scope_row(_plain(viewer.render_body(80)), "harbour") store.learn("harbour", "typed") - assert "scope harbour" in _plain(viewer.render_body(80)) + assert _has_scope_row(_plain(viewer.render_body(80)), "harbour") # -- the live feed ----------------------------------------------------------------------- @@ -220,7 +224,7 @@ def test_the_feed_hands_its_scope_reader_to_the_viewer() -> None: screen._session.run_screen = lambda viewer: opened.append(viewer) # noqa: SLF001 screen._session.run_detached = lambda coro: None # noqa: SLF001 screen.handle("enter") - assert "flood · scope harbour" in _plain(opened[0].render_body(80)) + assert _has_scope_row(_plain(opened[0].render_body(80)), "harbour") # -- message paths -----------------------------------------------------------------------