Files
MeshTerm/tests/test_livefeed.py
T
Jean-Pierre MartineauandClaude Opus 5.5 f07bb9e1dd The ends of a route are origin and destination chips in four more places
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>
2026-10-10 17:45:24 -04:00

595 lines
26 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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