# SPDX-License-Identifier: Apache-2.0 """Node detail screen tests: the mini-map, the tabbed page, and its route and topology helpers. The screen only renders data and shows routes, so these tests run it with no terminal. The tests build the display data by hand, in the same way as :func:`~meshterm.ui.node_detail_screen.open_node_detail`. They render the screen and send keys to it against a fake session. The tests of the trace screen and of the time machine screen use the same method. The helpers that use the topology run against a real evidence graph in memory. """ from __future__ import annotations from types import SimpleNamespace from rich.text import Text from meshterm.core.geo import haversine_km from meshterm.core.models import Contact, utcnow from meshterm.services.topology import build_topology from meshterm.services.trace_runner import ( make_name_key_resolver, make_node_resolver, make_node_type_resolver, ) from meshterm.ui.map_render import MapMarker from meshterm.ui.minimap import MiniMap from meshterm.ui.node_detail_screen import ( NodeDetailScreen, _Action, _bearing, _range_text, _Route, _route_line, _routes_view, _RoutesView, _signal_row, _Tab, ) from meshterm.ui.pathgraph import DST_NODE, SRC_NODE from meshterm.ui.pathline import CRACK_HEAD, CRACK_TAIL, SELF_GLYPH, PathHop, PathLine from meshterm.ui.tui.screen import CANCEL, ListWindow from meshterm.ui.widgets import highlighted_hash, route_graph_style, tab_strip from tests.conftest import plain as _plain # the only helper that strips and joins the screen text US = "aaaaaaaaaaaa" HUB = Contact( name="Hub", public_key="3d63c6429436" + "0" * 52, key_prefix="3d63c6429436", node_type=2 ) FAR = Contact(name="Far", public_key="f2c24f54551e" + "0" * 52, key_prefix="f2c24f54551e") class _FakeSession: def __init__(self) -> None: self.repaints = 0 def invalidate(self) -> None: self.repaints += 1 class _OfflineSource: """A tile source with no basemap, so the mini-map draws markers on a blank grid.""" available = False def load_tile(self, z: int, x: int, y: int): # noqa: ANN201 return None # --- the mini-map --------------------------------------------------------------- def test_minimap_offline_renders_markers_on_a_blank_grid() -> None: """With no basemap the preview still fills its box and draws the marker of the node.""" mini = MiniMap( _FakeSession(), _OfflineSource(), 14, center_lat=45.5, center_lon=-73.6, zoom=13, markers=[MapMarker(label="Hub", lat=45.5, lon=-73.6, is_repeater=True, key="3d63aa")], ) lines = mini.render(40, 6) assert len(lines) == 6 # the canvas fills exactly the box of rows that the test requested assert mini.pending == 0 and mini.has_basemap is False # offline: no downloads are planned assert any(ch != " " for ch in _plain(lines)) # the marker drew something def test_minimap_clamps_zoom_to_the_source_ceiling() -> None: """A zoom above the maximum of the source (plus overzoom) is clamped. It does not grow.""" mini = MiniMap( _FakeSession(), _OfflineSource(), 10, center_lat=0.0, center_lon=0.0, zoom=99, markers=[], ) assert mini._zoom == 12 # max_tile_zoom (10) + overzoom (2) # --- geographic helpers --------------------------------------------------------- def test_distance_and_bearing_are_sane() -> None: """The range in km and an 8-point compass bearing between two nearby points are correct.""" # Approximately 1.11 km due north (0.01° latitude), so the bearing is N. assert 1.0 < haversine_km(45.5, -73.6, 45.51, -73.6) < 1.2 assert _bearing(45.5, -73.6, 45.51, -73.6) == "N" assert _bearing(45.5, -73.6, 45.5, -73.59) == "E" # due east def test_range_text_omits_bearing_without_our_own_fix() -> None: """The location value has the range and the bearing only when our node has a position.""" placed = _plain([_range_text(45.5, -73.6, 45.4, -73.5).plain]) assert "45.5000, -73.6000" in placed and "km" in placed bare = _range_text(45.5, -73.6, None, None) assert "km" not in bare.plain and "45.5000" in bare.plain def test_signal_row_summarizes_snr_and_rssi() -> None: """The signal row has the median SNR, the best SNR, and the last RSSI. If there is no measurement, the row is absent. """ row = _signal_row(SimpleNamespace(median_snr=3.2, best_snr=7.0, last_rssi=-92.0)) assert row is not None assert "median" in row.plain and "best" in row.plain and "RSSI -92" in row.plain # A node with no measurement (or our node) gives no row. assert _signal_row(SimpleNamespace(median_snr=None, best_snr=None, last_rssi=None)) is None assert _signal_row(None) is None # --- the tab strip -------------------------------------------------------------- def test_tab_strip_boxes_every_tab_on_top_and_lights_the_active_one() -> None: """Each tab has a box across its top. Only the corners and fill of the active tab are lit.""" group = tab_strip(["Map", "Routes"], 1, width=20) top, mid, bot = group.renderables assert mid.plain == "│ Map │ Routes │" # both tabs have a box, with one shared vertical assert top.plain == "╭───────╭──────────╮" # the left corner of the active tab opens (it wins) assert len(top.plain) == len(mid.plain) == len(bot.plain) == 20 # no fill is necessary yet def test_tab_strip_bottom_is_a_continuous_rule_notched_at_the_active_tab() -> None: """The bottom border is flat under the inactive tabs and opens only under the active tab.""" group = tab_strip(["Map", "Routes"], 1, width=20) top, mid, bot = group.renderables # A flat rule is under "Map" (it has no corner of its own). Then the notch turns up # (west and north, ╯) into "Routes". It is blank across the width of "Routes" (open into # the page). Then it goes down again (north and east, ╰) and continues. assert bot.plain == "────────╯ ╰" # It is one continuous line: each cell has the grey of the border, the colour of the # active tab. The colours are not mixed. assert {r.style for r in bot.spans} == {"border"} _, _, bot_first = tab_strip(["Map", "Routes"], 0, width=20).renderables assert bot_first.plain == "╯ ╰───────────" # the notch is now under the first tab def test_tab_strip_unselected_tabs_read_darker_than_muted() -> None: """The outlines of the inactive tabs use the dim ``faint`` shade, not the ``muted`` shade.""" top, mid, _ = tab_strip(["Map", "Routes"], 1, width=20).renderables # The own left corner and fill of "Map" (columns 0-7). Its right corner at column 8 is # shared with the active "Routes" tab and has the accent, so the test does not use it. map_styles = {s.style for s in top.spans if s.start < 8} | { s.style for s in mid.spans if s.start < 8 } assert map_styles == {"faint"} def test_tab_strip_labels_hold_position_across_the_active_index() -> None: """If the user selects a different tab, only the corners change light. No label moves.""" mid_first = tab_strip(["Map", "Routes"], 0, width=20).renderables[1] mid_second = tab_strip(["Map", "Routes"], 1, width=20).renderables[1] assert mid_first.plain == mid_second.plain == "│ Map │ Routes │" top_first = tab_strip(["Map", "Routes"], 0, width=20).renderables[0] assert top_first.plain == "╭───────╮──────────╮" # the first tab keeps the plain leading corner def test_tab_strip_rule_fills_out_to_the_render_width() -> None: """The bottom rule of the tab strip runs the full render width. Unlike the tabs, the left margin does not indent it. The margin is part of the one continuous line. """ top, _, bot = tab_strip(["Map", "Routes"], 1, width=25).renderables assert top.plain.startswith(" ╭") # the tab boxes have an indent... assert bot.plain == "──────────╯ ╰───" # ...but the rule goes straight through assert len(bot.plain) == 25 def test_tab_strip_skips_the_margin_when_there_is_no_room() -> None: """The left margin of two columns is there only when the width has space for it.""" top, _, _ = tab_strip(["Map", "Routes"], 1, width=20).renderables assert top.plain.startswith("╭") # width 20 is the natural width of the strip: no space def test_tab_strip_collapses_a_lone_tab_to_a_plain_heading() -> None: """A single tab renders as the plain accent heading, with no box.""" (lone,) = tab_strip(["Routes"], 0, width=40).renderables assert lone.plain == "── Routes ──" # --- the folded-in route line --------------------------------------------------- def test_route_line_names_its_hops() -> None: """A route line names each hop that it can place. The path line goes contact → relays → us, which is the same direction as on the Message paths screen. The tag is at the end of the context line. """ path, context = _route_line( "Far", FAR.public_key, ("3d63c6429436",), "device", None, 0, resolve=make_node_resolver([HUB]), node_known=True, self_name="Us", hash_bytes=1, ) line = path.plain assert line.startswith("Far") # the contact is at the left, with its name assert "Hub" in line # the relay shows as the contact that it is, not as ``3d`` assert "3d" not in line and "f2" not in line # no hop repeats its hash after its name assert line.rstrip().endswith(SELF_GLYPH) # our node is at the right, as the ★ assert "Us" not in line # …in one cell, not as a name that each row repeats # The context starts with the length of the route, then the tag of the firmware route. # Both come from the path line. assert context.plain == "1 hop · device route" def test_route_line_greys_hops_nobody_can_name() -> None: """A hop that nobody can name shows its hash, in the grey of an unknown node. A hop with a name has the hue that comes from its key. A page node with no name looks the same as each other node with no name, as the graph draws it. """ from meshterm.ui.theme import node_style path, _context = _route_line( "f2c24f54551e", FAR.public_key, ("3d63c6429436", "abcd1234ef56"), "", None, 0, resolve=make_node_resolver([HUB]), node_known=False, self_name="Us", hash_bytes=1, ) styles = {path.plain[s.start : s.end]: str(s.style) for s in path.spans} assert styles.get("ab") == "node.unknown" # no contact names it, so its hash is grey assert styles.get("f2") == "node.unknown" # the page node with no name has a grey hash hued = { path.plain[s.start : s.end] for s in path.spans if str(s.style) == node_style("3d63c6429436") } assert "Hub" in hued # the Hub is a contact with a name: its name has its own hue def test_route_line_hash_width_follows_our_path_hash_mode() -> None: """A hash lane has the width that the path hash mode of the device sets. Only the hops that have no name to show use hash cells, and they use the cells at that width. Here the width is three bytes, not the default of one byte. """ path, _context = _route_line( "f2c24f54551e", FAR.public_key, ("3d63c6429436", "abcd1234ef56"), "", None, 0, resolve=make_node_resolver([HUB]), node_known=False, self_name="Us", hash_bytes=3, ) line = path.plain assert "f2c24f" in line # the page node with no name, at 3 bytes assert "abcd12" in line # the relay with no name, at 3 bytes assert "Hub" in line and "3d63c6" not in line # the hop with a name does not use them def test_route_line_marks_the_best_route_and_its_context() -> None: """The best route has ★ best, and the context row ends with its weakest SNR and sample count.""" _path, context = _route_line( "Far", FAR.public_key, ("3d63c6429436",), "best", 6.5, 4, resolve=make_node_resolver([HUB]), node_known=True, self_name="Us", hash_bytes=1, ) line = context.plain assert "★ best" in line and "weakest" in line and "6.5" in line and "4×" in line def test_route_line_direct_route_has_no_relay() -> None: """A zero-hop route is contact → us, with nothing between them.""" path, _context = _route_line( "Far", FAR.public_key, (), "best", None, 0, resolve=make_node_resolver([HUB]), node_known=True, self_name="Us", hash_bytes=1, ) assert path.plain == f"Far → {SELF_GLYPH}" # --- the routes view (list + graph callbacks) ----------------------------------- def _topo_with_route(): # noqa: ANN202 """A graph where Far is reached through the Hub, from evidence of repeated traces.""" from meshterm.persistence.repository import TracedPath walks = [ TracedPath(when=utcnow(), hops=[("3d", 12.0), ("f2", -5.0), ("3d", -5.5), (None, 12.0)]) for _ in range(4) ] return build_topology( self_id=US + "0" * 52, contacts=[HUB, FAR], trace_paths=walks, packet_paths=[], neighbour_links=[], ) def _view(topo, suggested, device_route, target, node_label, contacts, name_key=None): # noqa: ANN001 """Build a routes view in the same way as :func:`open_node_detail`, from the given evidence.""" return _routes_view( topo, topo.scenarios(target, device_route=device_route), suggested, device_route, target, target, 1, resolve=make_node_resolver(contacts), type_of=make_node_type_resolver(contacts), key_of=make_name_key_resolver(contacts), style=route_graph_style, self_name="Us", node_label=node_label, name_key=name_key or (target + "0" * 52), node_known=True, hash_bytes=1, ) def test_routes_view_draws_evidence_and_notes_its_absence() -> None: """With route evidence the view has routes to select. Without it, a muted note replaces them.""" topo = _topo_with_route() view = _view( topo, topo.suggested("f2c24f54551e"), ("3d63c6429436",), "f2c24f54551e", "Far", [HUB, FAR] ) assert view.routes and view.glyph_of is not None # a graph, not a note assert view.legend is True # the Hub is a repeater, so the type legend is necessary empty = build_topology( self_id=US + "0" * 52, contacts=[FAR], trace_paths=[], packet_paths=[], neighbour_links=[] ) note = _routes_view( empty, empty.scenarios("f2c24f54551e"), None, None, "f2c24f54551e", "f2c24f54551e", 1, resolve=make_node_resolver([FAR]), type_of=make_node_type_resolver([FAR]), key_of=make_name_key_resolver([FAR]), style=route_graph_style, self_name="Us", node_label="Far", name_key=FAR.public_key, node_known=True, hash_bytes=1, ) assert not note.routes and "no route observed" in note.note def test_routes_view_puts_the_contact_on_the_left_and_us_on_the_right() -> None: """The graph goes node → us (the inbound direction): the contact left, our star right.""" topo = _topo_with_route() view = _view( topo, topo.suggested("f2c24f54551e"), ("3d63c6429436",), "f2c24f54551e", "Far", [HUB, FAR] ) assert view.label_of(SRC_NODE) == "Far" # the left end is the target contact assert view.glyph_of(DST_NODE)[0] == "★" # the right end is the star of our node assert view.label_of(DST_NODE) == "Us" # …with the label for our node def test_routes_view_tags_every_relay_with_its_hash_byte_alone() -> None: """In the graph, a relay has only its first hash byte as its label. Only the two ends have names, also when a contact can name the relay. Thus a label is never longer than two cells, and it does not go across the lanes that are next to it. This is the same rule as the graph of the Message paths screen. """ topo = _topo_with_route() view = _view( topo, topo.suggested("f2c24f54551e"), ("3d63c6429436",), "f2c24f54551e", "Far", [HUB, FAR] ) assert view.label_of("3d63c6429436") == "3d" # the Hub is a known contact: the label is ``3d`` unnamed = _view(topo, None, ("abcd1234ef56",), "f2c24f54551e", "Far", [FAR]) assert unnamed.label_of("abcd1234ef56") == "ab" # a relay with no name has the same label def test_routes_view_target_wears_its_node_type_glyph() -> None: """The left end shows the target's own map mark (a repeater ▲), not a plain dot.""" from meshterm.persistence.repository import TracedPath leaf = Contact(name="Leaf", public_key="27d4396a2967" + "0" * 52, key_prefix="27d4396a2967") # Reach the Hub (a repeater) through the Leaf, so the Hub is the left end in the drawing. walks = [ TracedPath(when=utcnow(), hops=[("27d4", 8.0), ("3d", 6.0), ("27d4", 6.0), (None, 8.0)]) for _ in range(3) ] topo = build_topology( self_id=US + "0" * 52, contacts=[HUB, leaf], trace_paths=walks, packet_paths=[], neighbour_links=[], ) view = _view( topo, topo.suggested("3d63c6429436"), None, "3d63c6429436", "Hub", [HUB, leaf], name_key=HUB.public_key, ) assert view.glyph_of(SRC_NODE)[0] == "▲" # the repeater that is the target keeps its glyph def test_routes_view_draws_routes_inbound_reversing_the_hop_order() -> None: """A drawn route goes node → us: the outbound hops (from us) reverse to inbound order.""" r1 = Contact(name="R1", public_key="111111111111" + "0" * 52, key_prefix="111111111111") r2 = Contact(name="R2", public_key="222222222222" + "0" * 52, key_prefix="222222222222") topo = build_topology( self_id=US + "0" * 52, contacts=[r1, r2, FAR], trace_paths=[], packet_paths=[], neighbour_links=[], ) route = ("111111111111", "222222222222") # us → r1 → r2 → target, outbound order view = _view(topo, None, route, "f2c24f54551e", "Far", [r1, r2, FAR]) # The drawing goes contact → us, so the relay nearest the target is first and the relay # nearest us is last. assert view.routes[0].draw == ("222222222222", "111111111111") def test_routes_view_draws_a_direct_line_for_a_bare_neighbour() -> None: """A node that was only heard directly still has its zero-hop line, not a muted note.""" from meshterm.persistence.repository import PacketPath # One packet that we heard straight from Far to us: a direct link, with no relays and no route. topo = build_topology( self_id=US + "0" * 52, contacts=[FAR], trace_paths=[], packet_paths=[PacketPath(when=utcnow(), origin="f2c24f54551e", hops=[], snr=6.0)], neighbour_links=[], ) view = _view(topo, topo.suggested("f2c24f54551e"), None, "f2c24f54551e", "Far", [FAR]) assert view.routes and view.routes[0].draw == () # a straight line from end to end def _topo_two_alternatives(): # noqa: ANN202 """A graph that reaches Far two ways: a strong route through Hub and a weak one through Alt.""" from meshterm.persistence.repository import TracedPath alt = Contact(name="Alt", public_key="a1a1a1a1a1a1" + "0" * 52, key_prefix="a1a1a1a1a1a1") now = utcnow() strong = [ TracedPath(when=now, hops=[("3d", 12.0), ("f2", 10.0), ("3d", 10.0), (None, 12.0)]) for _ in range(8) ] weak = [TracedPath(when=now, hops=[("a1", -14.0), ("f2", -14.0), ("a1", -14.0), (None, -14.0)])] topo = build_topology( self_id=US + "0" * 52, contacts=[HUB, alt, FAR], trace_paths=strong + weak, packet_paths=[], neighbour_links=[], ) return topo def test_good_alternatives_keeps_observed_routes_and_drops_outliers() -> None: """The grey alternatives are the observed routes that are good enough to trust. Outliers and the bare direct and device families do not get a lane. """ from meshterm.ui.node_detail_screen import _good_alternatives topo = _topo_two_alternatives() scenarios = topo.scenarios("f2c24f54551e") kept = {s.hops for s in _good_alternatives(topo, scenarios, "f2c24f54551e")} assert ("3d63c6429436",) in kept # the strong observed route stays assert ("a1a1a1a1a1a1",) not in kept # the much weaker route is removed as an outlier assert () not in kept # the bare direct family is never a grey lane def test_route_freshness_drops_a_route_with_a_long_quiet_hop() -> None: """A route is stale, and MeshTerm removes it, when its stalest hop is past the horizon.""" from datetime import timedelta from meshterm.persistence.repository import TracedPath from meshterm.ui.node_detail_screen import _route_is_fresh now = utcnow() hops = [("3d", 12.0), ("f2", -5.0), ("3d", -5.5), (None, 12.0)] fresh = build_topology( self_id=US + "0" * 52, contacts=[HUB, FAR], trace_paths=[TracedPath(when=now, hops=hops)], packet_paths=[], neighbour_links=[], ) assert _route_is_fresh(fresh, ("3d63c6429436",), "f2c24f54551e", now) is True stale = build_topology( self_id=US + "0" * 52, contacts=[HUB, FAR], trace_paths=[TracedPath(when=now - timedelta(days=60), hops=hops)], packet_paths=[], neighbour_links=[], ) assert _route_is_fresh(stale, ("3d63c6429436",), "f2c24f54551e", now) is False def test_contract_bidir_clusters_folds_a_knot_but_keeps_the_rows() -> None: """A bidirectional knot of 3 or more nodes contracts to one super-node in the graph. The rows do not change. Each route row and its trace spec keep the name of each member, in order. """ from meshterm.ui.node_detail_screen import _contract_bidir_clusters routes = [ _Route(draw=("aa", "bb", "cc"), spec="s1", path=Text("A B C"), context=Text("")), _Route(draw=("cc", "bb", "aa"), spec="s2", path=Text("C B A"), context=Text("")), ] new, clusters = _contract_bidir_clusters(routes, lambda _n: 2) # all repeaters ((cid, cluster),) = clusters.items() assert cluster.label == "3 repeaters" and cluster.glyph == "▲" assert [r.draw for r in new] == [(cid,), (cid,)] # the members are one cluster stop assert [r.spec for r in new] == ["s1", "s2"] # traces still use the real path assert [r.path.plain for r in new] == ["A B C", "C B A"] # the list keeps the full order def test_contract_leaves_a_two_node_pair_and_unclustered_routes_alone() -> None: """A tidy two-way pair keeps its own markers (JP likes this display). Nothing contracts.""" from meshterm.ui.node_detail_screen import _contract_bidir_clusters routes = [ _Route(draw=("aa", "bb"), spec="s1", path=Text("A B"), context=Text("")), _Route(draw=("bb", "aa"), spec="s2", path=Text("B A"), context=Text("")), ] new, clusters = _contract_bidir_clusters(routes, lambda _n: 2) assert clusters == {} assert [r.draw for r in new] == [("aa", "bb"), ("bb", "aa")] def test_contract_labels_a_mixed_cluster_generically() -> None: """A knot of mixed types has a generic label. Its members are different node types, so it cannot have one type mark. Its label is ``n nodes`` under the plain dot. """ from meshterm.ui.node_detail_screen import _contract_bidir_clusters routes = [ _Route(draw=("aa", "bb", "cc"), spec="", path=Text(""), context=Text("")), _Route(draw=("cc", "bb", "aa"), spec="", path=Text(""), context=Text("")), ] _new, clusters = _contract_bidir_clusters(routes, lambda n: {"aa": 2, "bb": 3, "cc": 4}[n[:2]]) ((_cid, cluster),) = clusters.items() assert cluster.label == "3 nodes" and cluster.glyph == "●" def test_located_accepts_real_fixes_and_rejects_junk() -> None: """The location preview needs a real position. It rejects null island and bad adverts.""" from meshterm.ui.node_detail_screen import _located assert _located(45.5, -73.6) is True assert _located(None, -73.6) is False # a coordinate is missing assert _located(0.0, 0.0) is False # null island (the value for no GPS) assert _located(-97.0, -1041.97) is False # the out-of-range advert from Homestead # --- the screen ----------------------------------------------------------------- def _header() -> Text: header = Text("▲ ", style="type.repeater") header.append("Hub", style="accent") header.append(" repeater", style="muted") return header def _screen(**over) -> NodeDetailScreen: """A node detail screen with display data that the test builds by hand. It has an Info tab and a Routes tab that shows only a note. """ kwargs = dict( title="Node — Hub", header=_header(), info_rows=[ ("key", highlighted_hash("3d63c6429436" + "0" * 52, 1)), ("heard", Text("5m ago")), ("packets", Text("42")), ], tabs=[_Tab("Info", "info"), _Tab("Routes", "routes")], minimap=None, map_caption=None, routes=_RoutesView(note="no route observed yet — trace to discover one"), info_actions=[_Action("timemachine", "⏳", "", "Time machine — 42 receptions")], trace_action=_Action("trace", "🎯", "", "Trace — auto route …"), ) kwargs.update(over) return NodeDetailScreen(**kwargs) def test_node_detail_screen_renders_its_sections() -> None: """The Info tab has the vitals and its actions. The Routes tab has the stage and Trace.""" screen = _screen() screen.note_viewport(30) body = _plain(screen.render_body(72)) assert "Hub" in body and "repeater" in body # the pinned identity header assert "│ Info │" in body and "42" in body # the vitals are in the Info tab, which has a box assert "Time machine" in body # the Info tab's own action assert "Back" not in body # no exit row anywhere: Esc leaves assert "────────" in body # the faint rule that closes the stage assert "no route observed yet" not in body # the Routes stage is only on its own tab screen.handle("tab") body = _plain(screen.render_body(72)) assert "│ Routes │" in body and "no route observed yet" in body assert "Trace" in body and "Back" not in body assert "packets" not in body # the vitals stay on the Info tab # Each rendered line fits the 72-column standard. for line in screen.render_body(72): assert len(_plain([line])) <= 72 assert len(screen.footer_hint) <= 72 def test_the_remove_row_is_tinted_and_keeps_its_tint_where_the_icon_lane_goes() -> None: """The destructive action has the ``err`` style on both platforms. On the desktop, this is the red 🗑 at the start of the row. On the PicoCalc, the whole icon lane is removed (``command_icon`` returns ``""``). If the tint went with the icon, a delete row looked the same as each other row. Thus the tint goes on the label, as :func:`~meshterm.ui.menus.marked_label` does for a menu row. """ import re from meshterm.platforms import PICOCALC_LYRA, set_platform remove = _Action("remove", "🗑", "err", "Remove contact…") screen = _screen(info_actions=[remove]) screen.note_viewport(30) line = screen._action_line(remove, selected=False, width=40) assert "🗑" in line and "Remove contact…" in _plain([line]) set_platform(PICOCALC_LYRA) screen = _screen(info_actions=[remove]) screen.note_viewport(30) folded = screen._action_line(remove, selected=False, width=40) assert "🗑" not in folded # the icon lane is gone... assert "Remove contact" in _plain([folded]) # ...and the tint of the icon is now on the words: a colour sequence starts the label. # Nothing starts the label of an action with no tint. assert re.search(r"\[[0-9;]*m *Remove contact", folded) plain_row = screen._action_line( _Action("timemachine", "⏳", "", "Time machine — 42 receptions"), False, 40 ) assert not re.search(r"\[[0-9;]*m *Time machine", plain_row) def test_the_remove_row_resolves_its_own_token() -> None: """The Enter key on the remove row gives ``open_node_detail`` the token that it deletes with.""" screen = _screen(info_actions=[_Action("remove", "🗑", "err", "Remove contact…")]) resolved: list = [] screen.resolve = lambda value: resolved.append(value) # type: ignore[method-assign] screen.note_viewport(30) screen.render_body(72) screen.handle("enter") assert resolved == ["remove"] def test_node_detail_screen_cursor_and_commit() -> None: """↑/↓ move the highlight (it stays visible). Enter resolves its key. Esc cancels.""" screen = _screen() resolved: list = [] screen.resolve = lambda value: resolved.append(value) # type: ignore[method-assign] assert screen.cursor_line() is None # the screen did not render yet screen.note_viewport(30) screen.render_body(72) # The highlight starts on the first row of the Info tab (Time machine), and the screen # always reports it. Thus the frame can keep it visible on a terminal that is too short # for the pinned layout. assert screen.cursor_line() is not None screen.render_body(72) screen.handle("enter") assert resolved == ["timemachine"] screen.handle("escape") assert resolved == ["timemachine", CANCEL] def test_the_vitals_come_back_with_edge_scroll() -> None: """On a frame that is too short for the Info tab, ↑ on the first action scrolls the vitals back. When the user moves down the actions, the identity and the vitals scroll off the top. None of them can have the highlight. Before edge scroll, the only way back to them was the Home key. Now ↑ on the first action scrolls the page one line at a time to its top. """ from meshterm.ui.tui import frame actions = [_Action(f"a{i}", "⏳", "", f"Action {i}") for i in range(4)] screen = _screen(info_actions=actions) def press(action: str) -> None: if not screen.edge_scroll(action): screen.handle(action) def view() -> list[str]: screen.note_viewport(5) visible, _above, _below = frame._visible_slice(screen, screen.render_body(60), 5) return [_plain([line]) for line in visible] view() for _ in range(3): press("down") view() for _ in range(3): press("up") view() assert "Action 0" in "\n".join(view()) # back on the first action… assert not any("Hub" in line for line in view()) # …the identity is still scrolled off for _ in range(10): press("up") # edge scroll: the page moves, not the highlight view() page = view() assert "Hub" in page[0] # the identity line is the top of the viewport again press("down") # the snap-back shows the first action again before anything moves assert any("❯" in line and "Action 0" in line for line in view()) def test_node_detail_carries_no_exit_row() -> None: """The page has no Back row. Esc leaves, and we removed the row that repeated it. The page has only its own verbs. Thus the highlight on the actions never stops on a row that does nothing. """ screen = _screen() resolved: list = [] screen.resolve = lambda value: resolved.append(value) # type: ignore[method-assign] screen.note_viewport(30) assert "Back" not in _plain(screen.render_body(72)) screen.handle("escape") assert resolved == [CANCEL] def _two_routes() -> _RoutesView: """A routes view with two routes to select, in the form that the assembly code returns.""" return _RoutesView( routes=[ _Route(draw=("3d63c6429436",), spec="3d,f2,3d", path=Text("via Hub"), context=Text("")), _Route(draw=("a1a1a1a1a1a1",), spec="a1,f2,a1", path=Text("via Alt"), context=Text("")), ], glyph_of=lambda n: ("●", "#ffffff"), label_of=lambda n: n[:2], label_rgb_of=lambda n: (200, 200, 200), ) def test_node_detail_screen_route_selection_arms_the_trace() -> None: """↑/↓ on the route rows moves the graph highlight and the spec that a trace uses.""" screen = _screen(routes=_two_routes(), tabs=[_Tab("Routes", "routes")]) # The rows that can have the highlight are route 0 and route 1. When the page lists # routes, the rows are the places where a trace starts. Thus no separate Trace action # row renders, and the tab has no other row. assert screen.selected_spec() == "3d,f2,3d" screen.handle("down") # onto route 1 assert screen._route_sel == 1 and screen.selected_spec() == "a1,f2,a1" screen.note_viewport(30) body = _plain(screen.render_body(72)) assert "via Hub …" in body and "via Alt …" in body # both rows drew, with … as the opener mark assert "Trace" not in body # the row for the auto route shows only when no routes are listed def test_node_detail_routes_stage_spends_the_caption_and_top_air_on_the_fan() -> None: """On the PicoCalc the Routes stage uses the caption row and the top air row for the fan. The fan starts directly under the tab strip and has no caption. Both rows that MeshTerm gets back go into the graph, never into a taller gap (JP, 2026-08-09). The own end padding of the graph leaves one empty canvas row above its top label. The stage draws one row over its ceiling and removes that row. Thus the removal does not cost a row. """ from meshterm.platforms import PICOCALC_LYRA, REGULAR, set_platform # A fan with so many routes that the row ceiling, not its own ideal height, sets its # size. Thus the rows that MeshTerm gets back show as lanes and not as free space. busy = _RoutesView( routes=[ _Route(draw=(h * 12,), spec=f"{h}{h},f2", path=Text(f"via {h}{h}"), context=Text("")) for h in "123456" ], glyph_of=lambda n: ("●", "#ffffff"), label_of=lambda n: n[:2], label_rgb_of=lambda n: (200, 200, 200), ) def _stage(viewport: int) -> list[str]: screen = _screen(routes=busy, tabs=[_Tab("Info", "info"), _Tab("Routes", "routes")]) screen.handle("tab") # onto Routes screen.note_viewport(viewport) lines = screen.render_body(49) def is_strip(ln): return "Routes" in _plain([ln]) and "│" in _plain([ln]) strip_idx = next(i for i, ln in enumerate(lines) if is_strip(ln)) rule_idx = next( i for i, ln in enumerate(lines) if set(_plain([ln])) == {"─"} and i > strip_idx ) return [_plain([ln]) for ln in lines[strip_idx + 2 : rule_idx]] # after the strip rule set_platform(PICOCALC_LYRA) pico = _stage(22) assert pico[0].strip(), "no blank row between the tab strip and the fan" assert not any("selected route" in line for line in pico), "the caption is dropped" set_platform(REGULAR) desktop = _stage(22) assert not desktop[0].strip() # the desktop keeps its air… assert any("node → you, as heard" in line for line in desktop) # …and its caption # Both rows go into the drawing, not into the chrome. From the same viewport, the # console gives this fan, which the ceiling limits, two more canvas rows than the desktop # stage that has a caption. drawn = [ln for ln in desktop if "node → you" not in ln] assert len(pico) == len(drawn) + 2 def _mark() -> Text: mark = Text("▲ ", style="type.repeater") mark.append("Hub", style="#ff8800") return mark def _busy_routes() -> _RoutesView: """Four routes through a shared relay: the row ceiling, not its height, sets the fan size.""" return _RoutesView( routes=[ _Route( draw=(h * 12, "f2" * 6), spec=f"{h}{h},f2", path=Text(f"via {h}{h}"), context=Text(""), ) for h in "1234" ], glyph_of=lambda n: ("●", "#ffffff"), label_of=lambda n: n[:2], label_rgb_of=lambda n: (200, 200, 200), ) def test_a_short_frame_wears_the_identity_in_the_title_bar() -> None: """On the Cardputer the title bar shows ``▲ Hub`` in the node colours. The identity line goes. The glyph replaces the word ``Node``, and the name has its own hue. The line under the bar said the same, so it said it two times (JP, 2026-10-04). A tall frame keeps both as they were. """ from meshterm.platforms import CARDPUTER_ZERO, set_platform from meshterm.ui.tui import frame set_platform(CARDPUTER_ZERO) screen = _screen(mark=_mark()) screen.note_viewport(12) # the body of the Cardputer, under the bar and over the lane body = _plain(screen.render_body(53)) assert screen.title == "▲ Hub" assert "repeater" not in body # the identity line is gone… assert body.splitlines()[0].startswith("──┤ Info ├") # …the tab strip is first on the page bar = frame._title_bar(screen, 53, False, False) start = bar.plain.index("▲") styles = {str(s.style) for s in bar.spans if s.start <= start < s.end} assert "type.repeater" in styles hub = bar.plain.index("Hub") assert "#ff8800" in {str(s.style) for s in bar.spans if s.start <= hub < s.end} tall = _screen(mark=_mark()) tall.note_viewport(30) body = _plain(tall.render_body(53)) assert tall.title == "Node — Hub" and "repeater" in body # If there is no mark to show, a short frame keeps its title and the identity line. bare = _screen() bare.note_viewport(12) assert "repeater" in _plain(bare.render_body(53)) and bare.title == "Node — Hub" def test_a_short_frame_gives_the_route_graph_the_rows_it_freed() -> None: """There is no identity line and no rule under the graph, so both rows go to the graph. Between the tab strip and the route list there is only the fan. The fan gets each row that the guaranteed list window leaves. """ from meshterm.platforms import CARDPUTER_ZERO, set_platform set_platform(CARDPUTER_ZERO) screen = _screen(mark=_mark(), routes=_busy_routes()) screen.handle("tab") # onto Routes screen.note_viewport(12) lines = [_plain([line]) for line in screen.render_body(53)] assert "Routes ├" in lines[0] first_route = next(i for i, line in enumerate(lines) if "via 11" in line) graph = lines[1:first_route] assert not any(set(line.strip()) == {"─"} for line in graph) # no rule under the fan assert len(graph) == 12 - 1 - 4 # all rows but the strip and the four rows of the list assert [line.strip() for line in lines[first_route:]] == [ "❯ via 11 …", "via 22 …", "via 33 …", "via 44 …", ] def test_node_detail_screen_context_hangs_under_the_pathline() -> None: """The context (weakest, samples, tag) has its own line, with an indent under the path line.""" routes = _RoutesView( routes=[ _Route( draw=("3d63c6429436",), spec="3d,f2,3d", path=Text("f2 3d aa"), context=Text("weakest -6.0 dB · 3× · ★ best"), ), ], glyph_of=lambda n: ("●", "#ffffff"), label_of=lambda n: n[:2], label_rgb_of=lambda n: (200, 200, 200), ) screen = _screen(routes=routes, tabs=[_Tab("Routes", "routes")]) screen.note_viewport(30) lines = screen.render_body(72) body = _plain(lines) assert "f2 3d aa …" in body # the path line, with the opener mark assert "weakest -6.0 dB" in body and "★ best" in body # the context is also drawn path_idx = next(i for i, ln in enumerate(lines) if "f2 3d aa" in _plain([ln])) assert _plain([lines[path_idx]]).startswith("❯ f2 3d aa") # the pointer starts the path line context_line = _plain([lines[path_idx + 1]]) assert context_line.startswith(" weakest") # it hangs two columns under it, with no pointer def test_node_detail_screen_context_line_absent_when_theres_nothing_to_show() -> None: """A route that has no context draws only its path line. If there is no weakest SNR, sample count, or tag, there is no empty hanging line under it. """ routes = _RoutesView( routes=[_Route(draw=(), spec="", path=Text("f2 aa"), context=Text(""))], glyph_of=lambda n: ("●", "#ffffff"), label_of=lambda n: n[:2], label_rgb_of=lambda n: (200, 200, 200), ) screen = _screen(routes=routes, tabs=[_Tab("Routes", "routes")]) screen.note_viewport(30) lines = screen.render_body(72) path_idx = next(i for i, ln in enumerate(lines) if "f2 aa" in _plain([ln])) # Nothing hangs under a route that has no context. The path line is the last item that # the page draws (the tab has no action rows). It is not a muted context line. rest = _plain(lines[path_idx + 1 :]).strip() assert not rest.startswith("weakest") and not rest def test_node_detail_screen_hscrolls_the_selected_pathline() -> None: """A path line that is too wide for the lane scrolls with ←/→, on the highlighted row only. If the highlight moves off the row, the scroll stops. A long row that is not selected has an ellipsis. """ long_path = Text("f2 " + " ".join(f"{i:02x}" for i in range(40)) + " aa") routes = _RoutesView( routes=[ _Route(draw=("3d",), spec="s0", path=long_path.copy(), context=Text("")), _Route(draw=("a1",), spec="s1", path=Text("f2 3d aa"), context=Text("")), ], glyph_of=lambda n: ("●", "#ffffff"), label_of=lambda n: n[:2], label_rgb_of=lambda n: (200, 200, 200), ) screen = _screen(routes=routes, tabs=[_Tab("Routes", "routes")]) screen.note_viewport(30) body = _plain(screen.render_body(72)) assert "f2 00 01 02" in body # with no scroll, the start of the chain shows assert "←→ scroll" in screen.footer_hint # the overflow causes the footer atom row = next(ln for ln in _plain(screen.render_body(72)).splitlines() if ln.startswith("❯")) assert row.rstrip().endswith("…") # the right edge marks the remainder, before a scroll screen.handle("right") screen.handle("right") body = _plain(screen.render_body(72)) assert "f2 00 01 02" not in body # the view moved away from the start row = next(ln for ln in body.splitlines() if ln.startswith("❯")) assert row.startswith("❯ …") # …and now a mark says that the line also continues behind screen.handle("left") body = _plain(screen.render_body(72)) assert "f2 00 01 02" not in body # one step back, but not yet at the start for _ in range(20): # run the scroll to its stop screen.handle("right") row = next(ln for ln in _plain(screen.render_body(72)).splitlines() if ln.startswith("❯")) # The clamp stops where the tail is readable: the end of the line, its last hop, is on # the screen. It is not cut behind a mark at the right edge that promises more text that # the keys cannot reach. Only the left mark is drawn. The `…` that shows that Enter # opens more is outside the lane, so a window that is full to its edge is the case where # it is not shown. assert row.rstrip().endswith("aa") and row.count("…") == 1 screen.handle("right") # …and the stop holds: nothing moves past it assert ( next(ln for ln in _plain(screen.render_body(72)).splitlines() if ln.startswith("❯")) == row ) screen.handle("down") # onto route 1: the scroll of route 0 stops, a short row cannot scroll screen.render_body(72) assert "←→ scroll" not in screen.footer_hint # the short path line of route 1 has no hint screen.handle("up") # back onto route 0 body = _plain(screen.render_body(72)) assert "f2 00 01 02" in body # the shift is reset, back at the start assert "←→ scroll" in screen.footer_hint # route 0 overflows again def test_node_detail_route_row_cracks_a_chip_path_at_both_edges() -> None: """In chips, a route row has a crack at both edges instead of an ellipsis. At each edge that the route goes past, the chip breaks on a half block. The row shows part of a route that continues, and a cracked segment says this. Three dots mean that a word is shortened. """ chips = PathLine( [PathHop(f"NODE{i:02d}", key=f"{i:02x}aa") for i in range(12)], mode="powerline" ).text() routes = _RoutesView( routes=[ _Route(draw=("3d",), spec="s0", path=chips.copy(), context=Text("")), _Route(draw=("a1",), spec="s1", path=Text("f2 3d aa"), context=Text("")), ], glyph_of=lambda n: ("●", "#ffffff"), label_of=lambda n: n[:2], label_rgb_of=lambda n: (200, 200, 200), ) screen = _screen(routes=routes, tabs=[_Tab("Routes", "routes")]) screen.note_viewport(30) def selected_row() -> str: return next(ln for ln in _plain(screen.render_body(72)).splitlines() if ln.startswith("❯")) row = selected_row() assert row.rstrip().endswith(CRACK_TAIL) and "…" not in row # cut at the edge of the lane screen.handle("right") screen.handle("right") row = selected_row() assert row.startswith("❯ " + CRACK_HEAD) # the start is now off to the left, with a crack assert row.rstrip().endswith(CRACK_TAIL) and "…" not in row def test_node_detail_route_row_spends_no_lane_cells_on_the_opens_marker() -> None: """A route that fills its lane exactly stays whole. The ``…`` does not use lane cells. The mark says what the Enter key does. It is chrome, not route. MeshTerm reserved two cells for it, and a chain that fitted had a crack on its last chip (JP, 2026-08-10). The row said that the walk continued when it had arrived. The crack was only the closing cap with half of it removed. Now the path is fitted to the whole lane, and the mark uses the cells that are left. On a full lane, no cells are left. """ avail = 72 - 2 # the lane of the row: the width less the pointer column exact = Text("x" * avail) routes = _RoutesView( routes=[_Route(draw=("3d",), spec="s0", path=exact.copy(), context=Text(""))], glyph_of=lambda n: ("●", "#ffffff"), label_of=lambda n: n[:2], label_rgb_of=lambda n: (200, 200, 200), ) screen = _screen(routes=routes, tabs=[_Tab("Routes", "routes")]) screen.note_viewport(30) row = next(ln for ln in _plain(screen.render_body(72)).splitlines() if ln.startswith("❯")) assert row == "❯ " + "x" * avail # whole, not cut, and no mark pushed in # …and if there is nothing to scroll, ←→ do nothing and the hint does not show them. assert "←→ scroll" not in screen.footer_hint # The mark needs two cells, and two cells are free here, so the screen draws it. roomy = _RoutesView( routes=[_Route(draw=("3d",), spec="s0", path=Text("x" * (avail - 2)), context=Text(""))], glyph_of=lambda n: ("●", "#ffffff"), label_of=lambda n: n[:2], label_rgb_of=lambda n: (200, 200, 200), ) screen = _screen(routes=roomy, tabs=[_Tab("Routes", "routes")]) screen.note_viewport(30) row = next(ln for ln in _plain(screen.render_body(72)).splitlines() if ln.startswith("❯")) assert row == "❯ " + "x" * (avail - 2) + " …" #: A key of 64 digits where each byte is different (``000102…1e1f``). Thus the first digits #: of a window show where the lane was cut. A shift that is wrong by one shows ``0001…`` as #: ``00102…``. _LONG_KEY = "".join(f"{i:02x}" for i in range(32)) def _key_row(screen: NodeDetailScreen, width: int) -> str: """Render the Info tab and read its key lane, as one plain line.""" screen.note_viewport(30) rows = [_plain([line]) for line in screen.render_body(width)] return next(row for row in rows if row.startswith("key")) def test_node_detail_key_lane_hscrolls_instead_of_wrapping() -> None: """The key lane has one line and slides under ←/→ instead of wrapping. The windows are aligned to bytes. A faint ``…`` mark is at each edge where the key continues. The slide clamps at both ends. """ screen = _screen(info_rows=[("key", highlighted_hash(_LONG_KEY, 1)), ("heard", Text("5m ago"))]) row = _key_row(screen, 53) assert row.startswith("key 0001") and row.endswith("…") # the head, with more at the right assert "1e1f" not in row and len(row) <= 53 # the tail is outside the lane, not wrapped assert "←→ scroll key" in screen.footer_hint # …and the overflow causes its footer atom screen.handle("right") row = _key_row(screen, 53) # The shift is four whole bytes: the window starts on byte 4 (``04``), with a mark at # both edges. assert row.startswith("key …0405") and row.endswith("…") for _ in range(6): screen.handle("right") # run to the end: the shift clamps where the tail ends row = _key_row(screen, 53) assert row.endswith("1e1f") # the last byte of the key, with no mark: this is the end assert row.startswith("key …") # …and the head is now off to the left for _ in range(6): screen.handle("left") # then the other way, and it clamps at the head assert _key_row(screen, 53).startswith("key 0001") def test_node_detail_key_lane_scroll_survives_the_cursor_and_resets_on_tab() -> None: """The key is pinned chrome, not a row that can have the highlight. If the user moves over the action rows, the key keeps its scroll. If the user leaves the tab, the scroll resets. A key that fits has no ←→ in the hint. """ screen = _screen(info_rows=[("key", highlighted_hash(_LONG_KEY, 1)), ("heard", Text("5m ago"))]) _key_row(screen, 53) screen.handle("right") screen.handle("down") # onto Back: the window stays where the user left it assert _key_row(screen, 53).startswith("key …0405") screen.handle("tab") # over to Routes… screen.render_body(53) assert "←→" not in screen.footer_hint # nothing there is too wide to scroll screen.handle("tab") # …and back: the lane starts at the head again assert _key_row(screen, 53).startswith("key 0001") # A lane that is wide enough for the whole key shows it whole, with no mark and no hint. row = _key_row(screen, 100) assert row.startswith(f"key {_LONG_KEY}") and "…" not in row assert "←→" not in screen.footer_hint screen.handle("right") # does nothing, because no text is past the edge assert _key_row(screen, 100).startswith(f"key {_LONG_KEY}") def test_node_detail_enter_on_a_route_row_opens_its_trace() -> None: """The Enter key on a route row prepares a trace on that route. No Trace row is necessary.""" screen = _screen(routes=_two_routes(), tabs=[_Tab("Routes", "routes")]) resolved: list = [] screen.resolve = lambda value: resolved.append(value) # type: ignore[method-assign] screen.handle("down") # onto route 1 screen.handle("enter") assert resolved == ["trace"] and screen.selected_spec() == "a1,f2,a1" def test_route_labels_light_through_a_coalesced_hop() -> None: """A route that has a short hop id lights the wide marker that the graph merges it into. The route evidence comes to the page with different hash widths. For example, a trace hop of 1 byte (``3d``) is on one route, and the full id of the same relay is on a sibling route. The graph merges the short id into the one wide marker. Thus the dimming of the nodes that are not on the route must check membership against the drawn ids. When it used the raw draw hops, the relay of the selected route was muted, and the white line went straight through it (the report from TSFCT SUTTON-680M). """ lit: list[str] = [] def base_rgb(node: str) -> tuple[int, int, int]: lit.append(node) # the widget calls this only for nodes on the selected route return (200, 200, 200) routes = _RoutesView( routes=[ _Route( draw=("bf61f2fb1d9e", "3d63c6429436"), spec="s0", path=Text("wide"), context=Text(""), ), _Route(draw=("bf61f2fb1d9e", "3d"), spec="s1", path=Text("short"), context=Text("")), ], glyph_of=lambda n: ("●", "#ffffff"), label_of=lambda n: n[:2], label_rgb_of=base_rgb, ) screen = _screen(routes=routes, tabs=[_Tab("Routes", "routes")]) screen.note_viewport(30) screen.handle("down") # select the short-hop route screen.render_body(72) assert "3d63c6429436" in lit # the relay that the route goes through keeps its hue def test_node_detail_route_list_windows_inside_the_page() -> None: """The route list scrolls in a list window inside the page. It does not make the page taller. If there are more routes than fit, edge markers show, and the pinned chrome stays visible. This is true for each number of routes of a busy node. """ routes = _RoutesView( routes=[ _Route( draw=(f"{i:x}{i:x}" * 6,), spec=f"s{i}", path=Text(f"route {i}"), context=Text(""), ) for i in range(12) ], glyph_of=lambda n: ("●", "#ffffff"), label_of=lambda n: n[:2], label_rgb_of=lambda n: (200, 200, 200), ) screen = _screen(routes=routes, tabs=[_Tab("Routes", "routes")]) screen.note_viewport(30) lines = screen.render_body(72) assert len(lines) <= 30 # the body fits the viewport, so nothing scrolls off body = _plain(lines) assert "↓" in body and "more" in body # the edge marker counts the routes that are hidden assert "── Routes ──" in body # the pinned chrome above the list stays assert "PgUp/PgDn scroll" in screen.footer_hint # the hint shows paging only when necessary # If the highlight moves to the last route, the window slides down to keep it visible. for _ in range(11): screen.handle("down") lines = screen.render_body(72) assert len(lines) <= 30 assert "route 11" in _plain(lines) and screen.cursor_line() is not None def test_node_detail_route_cursor_clamps_at_both_ends() -> None: """The route list window does not wrap around. ↑ on the first row and ↓ after the last row stay where they are. They do not move the window from one end to the other end. """ routes = _RoutesView( routes=[ _Route( draw=(f"{i:x}{i:x}" * 6,), spec=f"s{i}", path=Text(f"route {i}"), context=Text(""), ) for i in range(12) ], glyph_of=lambda n: ("●", "#ffffff"), label_of=lambda n: n[:2], label_rgb_of=lambda n: (200, 200, 200), ) screen = _screen(routes=routes, tabs=[_Tab("Routes", "routes")]) screen.note_viewport(30) screen.render_body(72) screen.handle("up") assert screen._row_index == 0 for _ in range(20): screen.handle("down") assert screen._row_index == 11 def test_fit_blocks_walks_wrapped_rows_into_view() -> None: """The fit for rows of different heights moves wrapped rows into the viewport. It keeps whole blocks. It uses marker lines only when rows are hidden. It moves the window down to the highlighted row. """ window = ListWindow() top, count = window.fit_blocks([2, 2, 2, 2], 5, 3) # the highlight is on the last 2-line row assert top + count == 4 and top == 2 # it slid to the tail, and the last two rows fit assert window.page == count # the capacity that the fit set is the size of a page step top, count = window.fit_blocks([1, 1], 5, 0) assert (top, count) == (0, 2) # everything fits: no window, no markers def test_node_detail_screen_tabs_switch_the_stage() -> None: """←→ (and Tab) change the tab that fills the stage. The footer shows the switch only then.""" mini = MiniMap( _FakeSession(), _OfflineSource(), 14, center_lat=45.5, center_lon=-73.6, zoom=13, markers=[MapMarker(label="Hub", lat=45.5, lon=-73.6, key="3d63aa")], ) screen = _screen( minimap=mini, map_caption=Text("Hub · centred here", style="faint"), ) screen.note_viewport(30) assert "Tab/⇧Tab switch" in screen.footer_hint # there are two tabs, so the hint has the switch # The screen starts on the Info tab. The vitals, the caption of the preview with a # position, and its braille edge scrub are all part of this tab. body = _plain(screen.render_body(72)) assert "│ Info │" in body and "centred here" in body assert screen.consume_edge_scrub() == 2 # The F-key chip names the tab that the switch goes to, so it changes with the stage. assert screen.picocalc_lyra_lane[2].label == "Routes" screen.handle("tab") # switch to the Routes tab body = _plain(screen.render_body(72)) assert "│ Routes │" in body and "no route observed yet" in body assert screen.consume_edge_scrub() == 0 # the braille preview is not visible now assert screen.picocalc_lyra_lane[2].label == "Info" def test_node_detail_preview_keeps_its_minimum_except_on_a_short_frame() -> None: """The preview shows at its minimum when the actions leave less. A short frame has none. A contact page has many action rows, so on a terminal of 24 rows they leave the preview less than its minimum. The preview keeps its minimum and the page scrolls. The 11-row viewport of the Cardputer is a short frame, and there the preview is not shown. """ mini = MiniMap( _FakeSession(), _OfflineSource(), 14, center_lat=45.5, center_lon=-73.6, zoom=13, markers=[MapMarker(label="Hub", lat=45.5, lon=-73.6, key="3d63aa")], ) actions = [_Action(f"a{i}", "⚙", "", f"Action {i}") for i in range(8)] screen = _screen( minimap=mini, map_caption=Text("Hub · centred here", style="faint"), info_actions=actions, ) screen.note_viewport(21) body = _plain(screen.render_body(72)).splitlines() caption = next(i for i, line in enumerate(body) if "centred here" in line) vitals = next(i for i, line in enumerate(body) if line.startswith("packets")) assert caption - vitals - 2 >= 5 # the rows between the blank line and the caption assert len(body) > 21 # the preview did not shrink under its minimum: the page scrolls screen.note_viewport(11) assert "centred here" not in _plain(screen.render_body(53)) def test_node_detail_single_tab_hides_the_switch_hint() -> None: """A page with only one tab does not have the ←→ tab atom in its footer.""" screen = _screen(tabs=[_Tab("Info", "info")]) # our node: no Routes tab assert "←→ tab" not in screen.footer_hint assert "↑↓ move" in screen.footer_hint and screen.footer_hint.endswith("Esc back") # There is no tab to switch to, so the lane leaves the slot empty. It does not dim it. assert screen.picocalc_lyra_lane[2] is None def test_toggling_the_lock_rewrites_the_rows_and_keeps_the_cursor_on_it() -> None: """Lock becomes Unlock on the same row, and Archive is not offered while the lock is on.""" tm = _Action("timemachine", "⏳", "", "Time machine — 42 receptions") delete = _Action("remove", "🗑", "err", "Delete contact…") screen = _screen( info_actions=[ tm, _Action("lock", "🔒", "", "Lock contact"), _Action("archive", "💾", "", "Archive contact"), delete, ] ) resolved: list = [] screen.resolve = lambda value: resolved.append(value) # type: ignore[method-assign] screen.note_viewport(30) screen.render_body(72) screen.handle("down") screen.handle("enter") assert resolved == ["lock"] screen.replace_info_actions([tm, _Action("unlock", "🔓", "", "Unlock contact"), delete]) screen.render_body(72) screen.handle("enter") assert resolved == ["lock", "unlock"] assert [a.key for a in screen._info_actions] == ["timemachine", "unlock", "remove"] def test_routes_legend_steps_aside_on_a_short_frame() -> None: """The legend is under the fan, except on a short frame such as the frame of the Cardputer. On a short frame, the rows go to the graph and the route list, as for the fan of the message paths. """ view = _RoutesView( routes=[_Route(draw=("3d" * 6,), spec="3d,f2", path=Text("via 3d"), context=Text(""))], glyph_of=lambda n: ("▲", "#ffffff"), label_of=lambda n: n[:2], label_rgb_of=lambda n: (200, 200, 200), legend=True, ) def stage(viewport: int) -> str: screen = _screen(routes=view, tabs=[_Tab("Info", "info"), _Tab("Routes", "routes")]) screen.handle("tab") # onto Routes screen.note_viewport(viewport) return _plain(screen.render_body(53)) assert "▲ repeater" in stage(22) # the body of the PicoCalc assert "▲ repeater" not in stage(11) # the body of the Cardputer def test_a_page_that_may_only_lock_shows_only_the_lock_row() -> None: """The management group of a page that a conversation opens is only Lock or Unlock. Archive, restore, and delete belong to the list that the contact is in. A lock removes nothing, so a conversation can offer it (JP, 2026-10-19). A page that manages keeps its full group, in the same order as before. """ from meshterm.ui.node_detail_screen import _management_rows def keys(**state: bool) -> list[str]: base = {"locked": False, "lockable": True, "manageable": False, "archived": False} return [row.key for row in _management_rows(**{**base, **state}, whole_key=True)] assert keys() == ["lock"] assert keys(locked=True) == ["unlock"] assert keys(lockable=False) == [] assert keys(manageable=True) == ["lock", "archive", "remove"] assert keys(manageable=True, locked=True) == ["unlock", "remove"] assert keys(manageable=True, lockable=False, archived=True) == ["restore", "remove"] async def test_a_conversation_opens_the_author_page_with_its_lock_row(monkeypatch) -> None: # noqa: ANN001 """^O in a conversation opens the page to look, with the lock as its only verb.""" import meshterm.ui.node_detail_screen as nd calls: list[tuple] = [] async def _fake(ctx, contact, *, manage=True, lock=None): # noqa: ANN001, ANN202 calls.append((contact, manage, lock)) return False monkeypatch.setattr(nd, "open_node_detail", _fake) await nd.ContactPages(None, []).show(None) # type: ignore[arg-type] assert calls == [(None, False, True)] def test_contact_pages_read_and_write_the_lock_of_a_live_contact(tmp_path) -> None: # noqa: ANN001 """The pages lock a contact with a full key, and nothing without a store or a device key.""" from meshterm.core.contact_store import ContactStore from meshterm.ui.node_detail_screen import ContactPages store = ContactStore(tmp_path / "contacts.json") dev = "cc" * 32 alice = Contact(name="Alice", public_key="a1" * 32, key_prefix="a1" * 6) prefix_only = Contact(name="Bob", public_key="", key_prefix="b2" * 6) pages = ContactPages(SimpleNamespace(contact_store=store), [alice], self_key=dev) # type: ignore[arg-type] assert pages.can_lock assert pages.lock_state(alice) is False pages.set_locked(alice, True) assert pages.lock_state(alice) is True and store.is_locked(dev, "a1" * 32) assert pages.lock_state(prefix_only) is None # An archived contact has no lock: the lock exists to keep a contact out of the archive. store.set_locked(dev, alice, False) store.archive(dev, alice, when=1_700_000_000) assert pages.lock_state(alice) is None assert ContactPages(SimpleNamespace(contact_store=store), [alice]).can_lock is False # type: ignore[arg-type] assert ContactPages(None, [alice], self_key=dev).can_lock is False # type: ignore[arg-type]