The Messages list's preview draws its author as an origin chip (no colon: the point says it) and its mentions as destination chips, as the transcript does. The packet viewer's from rows close on the point and its to row opens on the notch, around the via chain that already opens and closes on both. The live feed's addressed subject is an origin chip and a destination chip facing each other across one cell of page (widgets.route_ends): a seam would claim the two are neighbours. A pair too wide for the lane cracks instead of eliding. The courier's recipient is a destination chip, in the hue of its key while it waits and muted once finished. Arrow mode reads exactly as before everywhere. A lone hop there carries no PATH_INK stamp, so a highlighted list row whitens it like any other name. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
595 lines
26 KiB
Python
595 lines
26 KiB
Python
# SPDX-License-Identifier: Apache-2.0
|
||
"""Live feed tests: the state, the rows, and the list window of the feed screen.
|
||
|
||
The tests drive the screen without a terminal. They use fake sessions and canned
|
||
observations, the same as the dashboard tests. This screen was first the feed panel of the
|
||
dashboard.
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import re
|
||
from datetime import timedelta
|
||
|
||
from rich.cells import cell_len
|
||
|
||
from meshterm.core.events import MeshEvent
|
||
from meshterm.core.models import Ack, Message, Observation, utcnow
|
||
from meshterm.ui.livefeed_screen import _ICON_LANE, LiveFeedScreen
|
||
from tests.conftest import plain as _plain # strips the ANSI, joins the text
|
||
|
||
|
||
class _FakeSession:
|
||
def __init__(self) -> None:
|
||
self.repaints = 0
|
||
#: The last screen that the feed floated over itself (the packet viewer).
|
||
self.opened = None
|
||
|
||
def invalidate(self) -> None:
|
||
self.repaints += 1
|
||
|
||
def run_screen(self, screen):
|
||
"""Record the floated screen. Do not run it, because there is no loop here."""
|
||
self.opened = screen
|
||
|
||
def run_detached(self, coro) -> None:
|
||
"""The start of a task that the session does not wait for.
|
||
|
||
``run_screen`` already did the recording.
|
||
"""
|
||
|
||
|
||
class _Fut:
|
||
"""A minimal replacement for a future, so that the screen can resolve without an event loop."""
|
||
|
||
def __init__(self) -> None:
|
||
self.value = None
|
||
self._done = False
|
||
|
||
def done(self) -> bool:
|
||
return self._done
|
||
|
||
def set_result(self, value) -> None:
|
||
self.value = value
|
||
self._done = True
|
||
|
||
|
||
#: The nodes that the resolver of the feed can name. The keys are the hash that a frame uses
|
||
#: for the address (one byte) and the longer prefixes that an advert has.
|
||
_KNOWN = {"a1b2": "Alice", "3d63": "Hub", "a1": "Alice", "3d": "Hub", "c0": "Us"}
|
||
|
||
|
||
def _screen(seed=None, **kwargs) -> LiveFeedScreen:
|
||
screen = LiveFeedScreen(
|
||
session=_FakeSession(),
|
||
resolve=lambda h: _KNOWN.get(h, ""),
|
||
seed=list(seed or []),
|
||
**kwargs,
|
||
)
|
||
screen.note_viewport(48) # the frame records this before each real paint
|
||
return screen
|
||
|
||
|
||
def _obs(node="a1b2", kind="advert", snr=5.0, rssi=-90.0, age_s=0, **extra) -> Observation:
|
||
return Observation(
|
||
node=node,
|
||
name=node,
|
||
kind=kind,
|
||
snr=snr,
|
||
rssi=rssi,
|
||
observed_at=utcnow() - timedelta(seconds=age_s),
|
||
**extra,
|
||
)
|
||
|
||
|
||
def _stripped(lines: list[str]) -> list[str]:
|
||
"""The rendered lines without the ANSI escapes, for assertions about the structure."""
|
||
return [re.sub(r"\x1b\[[0-9;]*m", "", line) for line in lines]
|
||
|
||
|
||
def _rows(screen: LiveFeedScreen, width: int) -> list[str]:
|
||
"""Only the packet rows: the body after its column header."""
|
||
return _stripped(screen.render_body(width))[1:]
|
||
|
||
|
||
def _col(line: str, needle: str) -> int:
|
||
"""The display column (in cells, not characters) where ``needle`` starts in ``line``."""
|
||
return cell_len(line[: line.index(needle)])
|
||
|
||
|
||
def test_livefeed_tool_registers_under_the_dashboard() -> None:
|
||
"""The livefeed tool is in the Watch section, directly below the dashboard."""
|
||
from meshterm.tools import load_all_tools
|
||
from meshterm.tools.base import all_tools
|
||
|
||
load_all_tools()
|
||
tools = {t.name: t for t in all_tools()}
|
||
tool = tools["livefeed"]
|
||
assert tool.title == "Live feed" and tool.category == "Watch"
|
||
assert tools["dashboard"].category == tool.category
|
||
assert tools["dashboard"].order < tool.order < tools["watchtower"].order
|
||
|
||
|
||
def test_livefeed_live_events_land_in_the_feed() -> None:
|
||
"""Each observation, message, and ack goes into the feed when it arrives."""
|
||
screen = _screen()
|
||
screen.on_event(MeshEvent.observation_event(_obs(snr=7.5)))
|
||
screen.on_event(MeshEvent.message_event(Message(text="hi", sender="a1b2")))
|
||
screen.on_event(MeshEvent.ack_event(Ack(code="01c3")))
|
||
body = _plain(screen.render_body(100))
|
||
assert "+7.5 dB" in body
|
||
assert "message" in body and "ack" in body
|
||
# The newest is first: the ack row is above the message row, and the message row is above
|
||
# the advert.
|
||
assert body.index("ack") < body.index("message") < body.index("advert")
|
||
|
||
|
||
def test_livefeed_rows_leave_the_relay_path_to_the_viewer() -> None:
|
||
"""A row shows what arrived and how well it was heard. The Enter key shows the route.
|
||
|
||
The cells that the fixed lanes left for a route were never enough to draw one. The route
|
||
was always elided to a stub. Thus the feed has no route. The row is its fixed lanes,
|
||
with the class name, and a wide terminal shows the whole row.
|
||
"""
|
||
screen = _screen()
|
||
screen.on_event(MeshEvent.observation_event(_obs(kind="packet", path="3d63,a1b2", snr=1.0)))
|
||
row = _rows(screen, 100)[0]
|
||
assert "via" not in row and "Hub" not in row # no route, not even a stub of one
|
||
assert "📦 packet" in row # the class, in words
|
||
assert "+1.0 dB" in row and "-90 dBm" in row # and the reception where it was heard
|
||
|
||
|
||
def test_livefeed_class_lane_names_the_payload_class_once() -> None:
|
||
"""The feed files a raw frame under its own class, and the subject lane does not repeat it.
|
||
|
||
``packet`` is only the name of the event family that the frame arrived in. Thus the
|
||
class lane shows the parsed payload class. This is the same label as the heading of
|
||
the card in the viewer. The subject lane is then free for the packet's own
|
||
subject. If the class has no subject, the lane shows a dash. It does not show the class
|
||
a second time.
|
||
"""
|
||
screen = _screen()
|
||
raw = {"payload_typename": "MULTIPART", "route_typename": "FLOOD"}
|
||
screen.on_event(
|
||
MeshEvent.observation_event(
|
||
_obs(node="", kind="packet", snr=1.0, path="3d63,a1b2", raw=raw)
|
||
)
|
||
)
|
||
row = _rows(screen, 100)[0]
|
||
assert "🧩 multipart" in row # the class lane, with the payload class's own icon
|
||
assert row.count("multipart") == 1 # one time, not one time for each lane
|
||
assert "packet" not in row # and never as the generic event family
|
||
assert "—" in row # the subject lane: the class has no subject
|
||
assert "?" not in row # and never the placeholder that has no use
|
||
|
||
|
||
def test_livefeed_names_a_channel_message_by_its_sender() -> None:
|
||
"""The ``Name:`` prefix of a channel message names the subject lane, not the bare channel."""
|
||
screen = _screen()
|
||
screen.on_event(
|
||
MeshEvent.message_event(Message(text="Alice: hi all", channel=3, is_channel=True))
|
||
)
|
||
body = _plain(screen.render_body(100))
|
||
assert "Alice" in body # the parsed sender is at the start of the row
|
||
assert "ch 3" in body # and the channel stays as the context at the end
|
||
|
||
|
||
def test_livefeed_unsigned_channel_post_is_filed_under_its_channel() -> None:
|
||
"""If nobody signs a channel message, it is about the channel. The row names it one time."""
|
||
screen = _screen(channel_names={3: "Alerts"})
|
||
screen.on_event(MeshEvent.message_event(Message(text="beep boop", channel=3, is_channel=True)))
|
||
row = _rows(screen, 100)[0]
|
||
assert "Alerts" in row # the channel name, not its slot number
|
||
assert row.count("Alerts") == 1 # and the note at the end does not say it again
|
||
assert "ch 3" not in row
|
||
|
||
|
||
def test_livefeed_channel_frame_is_about_its_channel() -> None:
|
||
"""The feed files a channel frame that it heard under the channel whose key confirms its MAC."""
|
||
from Crypto.Hash import HMAC, SHA256
|
||
|
||
from meshterm.core.channels import channel_hash, derive_secret
|
||
|
||
secret = derive_secret("Public")
|
||
crypted = bytes(range(16))
|
||
mac = HMAC.new(secret, digestmod=SHA256)
|
||
mac.update(crypted)
|
||
raw = {
|
||
"payload_typename": "GRP_TXT",
|
||
"chan_hash": channel_hash(secret),
|
||
"cipher_mac": mac.digest()[:2].hex(),
|
||
"crypted": crypted.hex(),
|
||
}
|
||
screen = _screen(channels=[("Public", secret)])
|
||
screen.on_event(MeshEvent.observation_event(_obs(node="", kind="packet", raw=raw)))
|
||
assert "Public" in _rows(screen, 100)[0]
|
||
|
||
# If we have no key for a channel, only the fingerprint that the frame has can name the
|
||
# channel. A name is never used, because a fingerprint alone does not prove which
|
||
# channel it is.
|
||
other = _screen(channels=[("Public", secret)])
|
||
other.on_event(
|
||
MeshEvent.observation_event(_obs(node="", kind="packet", raw={**raw, "chan_hash": "a3"}))
|
||
)
|
||
row = _rows(other, 100)[0]
|
||
assert "hash a3" in row and "Public" not in row
|
||
|
||
|
||
def test_livefeed_addressed_frame_is_about_its_two_ends() -> None:
|
||
"""A frame that names a recipient shows as sender → recipient, with the names resolved."""
|
||
screen = _screen(self_name="Us")
|
||
raw = {"payload_typename": "TEXT_MSG", "dest_hash": "c0", "src_hash": "a1"}
|
||
screen.on_event(MeshEvent.observation_event(_obs(node="", kind="packet", raw=raw)))
|
||
row = _rows(screen, 100)[0]
|
||
assert "📩 dir messg" in row
|
||
# This includes our node, when a frame is addressed to us. The row shows it as the ★ of
|
||
# the app. The user does not need a name for this endpoint, and the cells are necessary
|
||
# for the name of the other end.
|
||
assert "Alice → ★" in row
|
||
|
||
# If a pair is too wide for the lane, the cells go to the addressee. The sender changes
|
||
# to the hash that named it, so that the recipient is not the half that is cut off.
|
||
wide = _screen()
|
||
wide._resolve = lambda h: {"a1": "Alice-With-A-Long-Name", "3d": "Hilltop-Repeater"}.get(h, "")
|
||
wide.on_event(
|
||
MeshEvent.observation_event(
|
||
_obs(
|
||
node="",
|
||
kind="packet",
|
||
raw={"payload_typename": "REQ", "dest_hash": "3d", "src_hash": "a1"},
|
||
)
|
||
)
|
||
)
|
||
assert "a1 → Hilltop-Repe" in _rows(wide, 100)[0]
|
||
|
||
|
||
def test_livefeed_addressed_frame_draws_its_two_ends_as_chips(powerline) -> None:
|
||
"""With chips, the sender's point and the recipient's notch face each other across a cell.
|
||
|
||
A seam would claim that the two are neighbours, and the relays between them are not
|
||
drawn. A pair that is too wide for the lane cracks at the lane, and is not elided.
|
||
"""
|
||
from meshterm.ui.pathline import CRACK_TAIL, POWERLINE_SEP
|
||
|
||
powerline(True)
|
||
screen = _screen(self_name="Us")
|
||
raw = {"payload_typename": "TEXT_MSG", "dest_hash": "c0", "src_hash": "a1"}
|
||
screen.on_event(MeshEvent.observation_event(_obs(node="", kind="packet", raw=raw)))
|
||
assert f" Alice {POWERLINE_SEP} {POWERLINE_SEP} ★ " in _rows(screen, 100)[0]
|
||
|
||
wide = _screen()
|
||
wide._resolve = lambda h: {"a1": "Alice-With-A-Long-Name", "3d": "Hilltop-Repeater"}.get(h, "")
|
||
wide.on_event(
|
||
MeshEvent.observation_event(
|
||
_obs(
|
||
node="",
|
||
kind="packet",
|
||
raw={"payload_typename": "REQ", "dest_hash": "3d", "src_hash": "a1"},
|
||
)
|
||
)
|
||
)
|
||
row = _rows(wide, 100)[0]
|
||
assert f" a1 {POWERLINE_SEP} {POWERLINE_SEP} Hilltop" in row
|
||
assert CRACK_TAIL in row and "…" not in row
|
||
|
||
|
||
def test_livefeed_tokened_classes_are_about_their_token() -> None:
|
||
"""An ack names the message that it answers. A trace names its tag. Neither says "packet"."""
|
||
screen = _screen()
|
||
screen.on_event(
|
||
MeshEvent.observation_event(
|
||
_obs(
|
||
node="",
|
||
kind="packet",
|
||
age_s=1,
|
||
raw={"payload_typename": "TRACE", "trace_tag": "5f3c2a10"},
|
||
)
|
||
)
|
||
)
|
||
screen.on_event(
|
||
MeshEvent.observation_event(
|
||
_obs(node="", kind="packet", raw={"payload_typename": "ACK", "ack_crc": "9b71e004"})
|
||
)
|
||
)
|
||
acked, traced = _rows(screen, 100)[:2]
|
||
assert "for 9b71e004" in acked
|
||
assert "tag 5f3c2a10" in traced
|
||
|
||
|
||
def test_livefeed_column_header_sits_over_the_lanes_it_names() -> None:
|
||
"""The header names each lane, and each label is over the values under it."""
|
||
screen = _screen(seed=[_obs(node="3d63", kind="telemetry", snr=12.8, rssi=-105.0)])
|
||
header, row = _stripped(screen.render_body(90))[:2]
|
||
assert header.split() == ["TIME", "CLASS", "SUBJECT", "SCOPE", "SNR", "RSSI"]
|
||
# The test measures in display cells, not characters. The class icon is one character
|
||
# but two cells wide. A character index puts each later lane one column too early. The
|
||
# test reads the clock from the row itself. A literal needle matches only in the
|
||
# minute when it was written.
|
||
stamp = re.search(r"\d\d:\d\d:\d\d", row)
|
||
assert stamp is not None
|
||
assert _col(header, "TIME") == _col(row, stamp.group(0))
|
||
assert _col(header, "CLASS") == _col(row, "telemetry") - _ICON_LANE
|
||
assert _col(header, "SUBJECT") == _col(row, "Hub")
|
||
# The label of each reading starts where its lane starts, the same as each other label.
|
||
assert _col(header, "SNR") == _col(row, "+12.8")
|
||
assert _col(header, "RSSI") == _col(row, "-105")
|
||
# The one gap of the row separates the readings, not the width of a spare field.
|
||
assert "+12.8 dB -105 dBm" in row
|
||
|
||
|
||
def test_livefeed_column_header_is_pinned_and_carries_no_sort_cue() -> None:
|
||
"""The header does not scroll away, and it shows no sort mark. The feed has one order."""
|
||
screen = _screen(seed=[_obs(node=f"n{i}", age_s=i) for i in range(40)])
|
||
screen.note_viewport(12)
|
||
lines = _stripped(screen.render_body(100))
|
||
assert "TIME" in lines[0]
|
||
assert not any(mark in lines[0] for mark in ("▲", "▼")) # nothing here sorts
|
||
screen.handle("end") # the last row of the list
|
||
screen.handle("up") # one up from it, the oldest packet: the list window scrolls
|
||
after = _stripped(screen.render_body(100))
|
||
assert after[0] == lines[0] # the header is in the same place as before
|
||
assert "↑" in after[1] and "more" in after[1] # the rows did move
|
||
|
||
|
||
def test_livefeed_highlight_uses_the_app_wide_cursor() -> None:
|
||
"""The feed marks its row as each other list does: ``❯`` over a ``cursor``-white row."""
|
||
screen = _screen(seed=[_obs(node="n0", age_s=1), _obs(node="n1", age_s=0)])
|
||
screen.handle("down") # off the pin, onto the newest packet that is normally selected
|
||
rows = _rows(screen, 100)
|
||
assert rows[0].startswith("❯ ") and rows[1].startswith(" ")
|
||
assert "▸" not in "\n".join(rows) # the old mark that was different from the others is gone
|
||
raw = screen.render_body(100)[1]
|
||
assert raw.startswith("\x1b[") # the pointer has the cursor style, not bare text
|
||
|
||
|
||
def test_livefeed_opens_pinned_and_the_pin_rides_the_newest_packet() -> None:
|
||
"""The top stop follows the stream: the highlight moves with each arrival.
|
||
|
||
The normal state of a live feed is to watch. Thus the screen opens on the pin, drawn as
|
||
``^`` instead of ``❯``. Each packet that arrives keeps the highlight on the top row. The
|
||
highlight does not move down with the packet that it was on.
|
||
"""
|
||
screen = _screen(seed=[_obs(node="n0", age_s=1)])
|
||
assert screen._pinned and screen._selected == 0
|
||
assert _rows(screen, 100)[0].startswith("^ ") # the pin's own mark
|
||
|
||
screen.on_event(MeshEvent.observation_event(_obs(node="a1b2")))
|
||
assert screen._selected == 0, "the pin let go of the top"
|
||
rows = _rows(screen, 100)
|
||
assert rows[0].startswith("^ ") and rows[1].startswith(" ")
|
||
assert "Alice" in rows[0] # and the highlight is on the packet that arrived last
|
||
|
||
|
||
def test_livefeed_down_off_the_pin_selects_the_newest_packet_itself() -> None:
|
||
"""``↓`` off the pin does not move a row. It changes what the highlight is fixed to.
|
||
|
||
The two stops share the top line. The pin holds the position, and a selection holds the
|
||
packet. Thus the first ``↓`` goes to the same newest packet, under a plain ``❯``. Only
|
||
the second ``↓`` goes to the packet below it. ``↑`` puts the pin back.
|
||
"""
|
||
screen = _screen(seed=[_obs(node="n0", age_s=2), _obs(node="n1", age_s=1)])
|
||
screen.handle("down")
|
||
assert screen._selected == 0 and not screen._pinned, "↓ skipped past the newest packet"
|
||
assert _rows(screen, 100)[0].startswith("❯ ")
|
||
|
||
# Now the highlight is on that packet: an arrival pushes it down, as before.
|
||
screen.on_event(MeshEvent.observation_event(_obs(node="a1b2")))
|
||
assert screen._selected == 1
|
||
|
||
screen.handle("down")
|
||
assert screen._selected == 2 and not screen._pinned # the next ↓ does move
|
||
screen.handle("up")
|
||
screen.handle("up")
|
||
assert screen._selected == 0 and not screen._pinned # back on the newest packet
|
||
screen.handle("up")
|
||
assert screen._pinned # one more ↑ puts the pin back
|
||
|
||
|
||
def test_livefeed_home_resumes_following_from_deep_in_the_history() -> None:
|
||
"""Home goes to the pin, not only to the packet that is the newest at this time."""
|
||
screen = _screen(seed=[_obs(node=f"n{i}", age_s=i) for i in range(10)])
|
||
screen.handle("end")
|
||
assert screen._selected == 9 and not screen._pinned # the oldest packet
|
||
screen.handle("home")
|
||
assert screen._pinned and screen._selected == 0
|
||
screen.on_event(MeshEvent.observation_event(_obs(node="a1b2")))
|
||
assert screen._selected == 0 # the feed follows again, and does not stay on the old newest
|
||
|
||
|
||
def test_livefeed_opening_a_packet_keeps_the_pin() -> None:
|
||
"""Enter on the pin opens the viewer on the pin, so the feed and the card both follow.
|
||
|
||
JP decided this on 2026-10-10. Before, Enter removed the pin.
|
||
"""
|
||
screen = _screen(seed=[_obs(node="n0", age_s=1)])
|
||
assert screen._pinned
|
||
screen.handle("enter")
|
||
viewer = screen._session.opened
|
||
assert viewer is not None and viewer._pinned and "following" in viewer.title
|
||
assert screen._pinned, "Enter knocked the feed out of the stream"
|
||
|
||
screen.on_event(MeshEvent.observation_event(_obs(node="a1b2")))
|
||
assert screen._selected == 0, "the pin let go of the top"
|
||
assert "Alice" in _plain(viewer.render_body(72)), "the card did not follow the stream"
|
||
|
||
|
||
def test_livefeed_opening_a_selected_packet_holds_it() -> None:
|
||
"""Enter on a selected packet holds it, so an arrival during a long read cannot move it."""
|
||
screen = _screen(seed=[_obs(node="n0", age_s=1)])
|
||
screen.handle("down") # off the pin, onto the newest packet
|
||
screen.handle("enter")
|
||
viewer = screen._session.opened
|
||
assert viewer is not None and not viewer._pinned
|
||
assert not screen._pinned and screen._selected == 0
|
||
screen.on_event(MeshEvent.observation_event(_obs(node="a1b2")))
|
||
assert screen._selected == 1, "the cursor left the packet being viewed"
|
||
|
||
|
||
def test_livefeed_viewer_that_walks_up_to_the_stream_re_pins_the_feed() -> None:
|
||
"""The viewer has the second stop of this screen, and it returns the pin through it.
|
||
|
||
Enter on a selected packet opens the dialog on that packet. The user can press up at
|
||
the top of the viewer. This is the same move as up at the top of the feed. Thus the
|
||
feed follows the stream again, and it still follows when the dialog closes.
|
||
"""
|
||
screen = _screen(seed=[_obs(node="n0", age_s=2), _obs(node="n1", age_s=1)])
|
||
screen.handle("down") # off the pin, onto the newest packet
|
||
screen.handle("enter")
|
||
viewer = screen._session.opened
|
||
assert viewer is not None and not screen._pinned
|
||
|
||
viewer.handle("up")
|
||
assert viewer._pinned and "following" in viewer.title
|
||
assert screen._pinned, "the feed stayed parked while the viewer followed the stream"
|
||
|
||
# After one arrival, both stops are at the top: the highlight of the feed and the card.
|
||
screen.on_event(MeshEvent.observation_event(_obs(node="a1b2")))
|
||
assert screen._selected == 0
|
||
assert "Alice" in _plain(viewer.render_body(72))
|
||
|
||
|
||
def test_livefeed_page_keys_move_the_feed_selection() -> None:
|
||
"""PgUp/PgDn move the highlight one list window at a time. The two ends of the list clamp it."""
|
||
seed = [_obs(node=f"n{i}", age_s=i) for i in range(20)]
|
||
screen = _screen(seed=seed)
|
||
screen._feed_window.page = 5 # as if the last paint set a list window of five rows
|
||
assert screen._selected == 0 # the newest packet is highlighted from the start
|
||
screen.handle("pagedown")
|
||
# A page is five stops, and the pin is the first of them (refer to
|
||
# LiveFeedScreen._cursor). Thus a page down from the pin goes to row 4. This is the same
|
||
# five positions as each other page.
|
||
assert screen._selected == 4 # the selection moved down a page, not only the viewport
|
||
screen.handle("pageup")
|
||
assert screen._selected == 0 and screen._pinned
|
||
screen.handle("pageup")
|
||
assert screen._selected == 0 # and it stops at the pin instead of wrapping
|
||
|
||
for _ in range(5): # after pages past the oldest packet, the highlight clamps on it
|
||
screen.handle("pagedown")
|
||
assert screen._selected == 19
|
||
|
||
|
||
def test_livefeed_carries_no_exit_row() -> None:
|
||
"""The screen ends on the feed itself. Esc leaves, so no row says that.
|
||
|
||
The list window still fits the viewport when the frame removes the chrome. The two lines
|
||
of the removed exit group went to the packets, not to a gap.
|
||
"""
|
||
seed = [_obs(node=f"n{i}", age_s=i) for i in range(40)]
|
||
screen = _screen(seed=seed)
|
||
screen.note_viewport(24)
|
||
lines = _stripped(screen.render_body(100))
|
||
assert len(lines) <= 24 # the body fits, and it does not grow
|
||
assert "Back" not in " ".join(lines)
|
||
assert lines[-1].strip() # the last line is content, not a gap
|
||
assert "↓" in lines[-1] and "more" in lines[-1] # the marker that counts the rows below
|
||
|
||
# The heading and the column header stay in place while the feed scrolls under them.
|
||
screen.handle("end")
|
||
after = _stripped(screen.render_body(100))
|
||
assert "↑" in after[1] and "more" in after[1]
|
||
assert "Back" not in " ".join(after)
|
||
|
||
|
||
def test_livefeed_cursor_stops_on_the_oldest_packet() -> None:
|
||
"""↓ clamps on the oldest packet and End goes to it. There is no row after it."""
|
||
screen = _screen(seed=[_obs(node="n0", age_s=1), _obs(node="n1", age_s=0)])
|
||
screen.future = _Fut()
|
||
|
||
screen.handle("down") # off the pin, onto the newest
|
||
screen.handle("down")
|
||
assert screen._selected == 1 # the oldest packet
|
||
screen.handle("down")
|
||
assert screen._selected == 1 # and the highlight stops there
|
||
screen.handle("up")
|
||
assert screen._selected == 0
|
||
screen.handle("end")
|
||
assert screen._selected == 1 # End goes to the oldest packet
|
||
|
||
screen.handle("escape") # Esc is the way out
|
||
assert screen.future.done() and screen.future.value is None
|
||
|
||
|
||
def test_livefeed_empty_feed_draws_its_note_and_nothing_else() -> None:
|
||
"""If nothing is heard, there is no highlight. The screen shows only the note."""
|
||
screen = _screen()
|
||
screen.future = _Fut()
|
||
lines = _stripped(screen.render_body(100))
|
||
assert "nothing heard yet" in lines[0]
|
||
assert "Back" not in " ".join(lines)
|
||
assert not any(line.startswith("❯") for line in lines) # nothing to point at
|
||
screen.handle("up") # there is no place to go, and no crash
|
||
assert screen._selected is None and not screen._pinned
|
||
screen.handle("enter") # Enter opens nothing, and it does not leave by accident
|
||
assert not screen.future.done()
|
||
|
||
|
||
def test_livefeed_windows_inside_the_fixed_screen() -> None:
|
||
"""The header stays pinned. The body fits the viewport, and the feed rows scroll in it."""
|
||
seed = [_obs(node=f"n{i}", age_s=i) for i in range(40)]
|
||
screen = _screen(seed=seed)
|
||
screen.note_viewport(24)
|
||
lines = screen.render_body(100)
|
||
assert len(lines) <= 24 # the column header and the list window equal the viewport, never more
|
||
body = _plain(lines)
|
||
assert "TIME" in body # the pinned column header is there
|
||
assert "↓" in body and "more" in body # the marker counts the hidden feed rows below
|
||
|
||
|
||
#: A terminal too narrow for the fixed lanes of the feed. This is the only cause of a row
|
||
#: overflow now, because a row has no route (refer to ``_FEED_LABEL_MIN_WIDTH``).
|
||
_CRAMPED = 44
|
||
|
||
|
||
def test_livefeed_the_whole_row_fits_a_wide_screen() -> None:
|
||
"""No row overflows where the lanes fit. Then ←→ has nothing to show in the hint."""
|
||
screen = _screen(seed=[_obs(node="n0", kind="telemetry")])
|
||
for line in _stripped(screen.render_body(100)):
|
||
assert len(line.rstrip()) <= 100
|
||
assert screen._hmax == 0
|
||
assert "←→" not in screen.footer_hint
|
||
|
||
|
||
def test_livefeed_highlighted_row_scrolls_sideways_to_its_tail() -> None:
|
||
"""On a terminal too narrow for the lanes, ←→ move the highlighted row sideways to its end."""
|
||
screen = _screen(seed=[_obs()])
|
||
opening = _rows(screen, _CRAMPED)[0]
|
||
assert screen._hmax > 0 # the row does go past the right edge
|
||
assert not opening.rstrip().endswith("dBm") # thus its reception tail is not visible
|
||
|
||
for _ in range(40): # → stops at the end of the row, never past it
|
||
screen.handle("right")
|
||
scrolled = _rows(screen, _CRAMPED)[0]
|
||
assert screen._hshift == screen._hmax
|
||
# The cursor lane keeps its two cells while the row slides under it. Here it has the
|
||
# ``^`` of the pin, because a feed that just opened follows the stream.
|
||
assert scrolled.startswith("^ ")
|
||
assert scrolled.rstrip().endswith("-90 dBm") # the user can read the tail now
|
||
assert opening[2:10] not in scrolled # the time lane slid off to the left
|
||
|
||
for _ in range(40):
|
||
screen.handle("left")
|
||
assert screen._hshift == 0
|
||
assert _rows(screen, _CRAMPED)[0] == opening # back where it started
|
||
|
||
|
||
def test_livefeed_moving_the_selection_abandons_the_rows_scroll() -> None:
|
||
"""Each row scrolls on its own. A newly highlighted packet starts at its beginning."""
|
||
screen = _screen(seed=[_obs(node="n0", age_s=1), _obs(node="n1", age_s=0)])
|
||
screen.render_body(_CRAMPED)
|
||
screen.handle("right")
|
||
assert screen._hshift > 0
|
||
screen.handle("down")
|
||
assert screen._hshift == 0
|
||
|
||
|
||
def test_livefeed_advertises_line_scroll_only_where_it_acts() -> None:
|
||
"""The footer has the ←→ atom only while the highlighted row overflows."""
|
||
screen = _screen(seed=[_obs()])
|
||
screen.render_body(100)
|
||
assert "←→" not in screen.footer_hint # a row with room to spare does not scroll
|
||
|
||
screen.render_body(_CRAMPED)
|
||
assert "←→ scroll line" in screen.footer_hint
|
||
assert len(screen.footer_hint) <= 72 # the rule for screens at 72 cells, in the fullest state
|