locate moves from ^Y to ^L, and the room's login from ^L to ^O, under the
chord rule of 3675226. On the map and in the mesh walk, ^L still goes to
our node. In a chat and on a room's board, ^L opens the map on the author
of the selected message (our own message: our node); on Contacts, on the
highlighted node. map_screen.Locator holds the markers that
gather_markers returns when the screen opens and answers find() at each
paint, so the "^L map" hint atom and the Shift+F1 Map chip show only
where the key acts. A room keeps Log in on Shift+F1, so ^L has no chip
on a room's board yet.
The chat's hints for a selected message go through _fit_hint, which
drops ↑↓ and then ^P only when the line passes 72 cells; the room now
reuses them. LaneDeck.action_for returns None for a slot that has only
a Shift companion.
Right Ctrl with a letter chord still does nothing on the Canadian
Multilingual layout: JP chose not to repair that.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
1012 lines
44 KiB
Python
1012 lines
44 KiB
Python
# SPDX-License-Identifier: Apache-2.0
|
||
"""Mesh walk tests: the focus-and-walk browser over a hand-built topology.
|
||
|
||
The tests run the screen headless against a fake session. This is the same approach as in
|
||
the dashboard tests. render_body only returns lines, and handle() only changes state. Thus
|
||
the tests can check the walk, the backtrack, the find, and the split of the canvas and the
|
||
list without a terminal.
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
from datetime import timedelta
|
||
|
||
from meshterm.core.models import Contact, utcnow
|
||
from meshterm.services.topology import MeshTopology
|
||
from meshterm.ui.pathline import (
|
||
CRACK_HEAD,
|
||
CRACK_TAIL,
|
||
ELIDE_HEAD,
|
||
SELF_GLYPH,
|
||
PathLine,
|
||
)
|
||
from meshterm.ui.tui.render import render_to_ansi
|
||
from meshterm.ui.walk_screen import _HSCROLL_STEP, WalkScreen
|
||
from tests.conftest import plain as _plain # the only function that strips and joins a screen
|
||
|
||
US = "aa" * 6
|
||
Hub = Contact(name="Hilltop-Repeater", public_key="3d" * 32, node_type=2)
|
||
ALICE = Contact(name="Alice", public_key="b2" * 32, last_seen=utcnow() - timedelta(minutes=5))
|
||
|
||
|
||
class _FakeSession:
|
||
def __init__(self) -> None:
|
||
self.repaints = 0
|
||
|
||
def invalidate(self) -> None:
|
||
self.repaints += 1
|
||
|
||
|
||
def _topo(*, with_island: bool = False) -> MeshTopology:
|
||
"""Us, Hub, and Alice as a chain of two rings, with an optional separate island pair."""
|
||
topo = MeshTopology(US, contacts=[Hub, ALICE])
|
||
hub = topo.canonical(Hub.public_key)
|
||
alice = topo.canonical(ALICE.public_key)
|
||
when = utcnow()
|
||
topo.add_walk([topo.self_id, hub], snrs=[6.0], when=when, source="trace")
|
||
topo.add_walk([hub, alice], snrs=[-2.0], when=when, source="packet")
|
||
if with_island:
|
||
topo.add_walk(["c3" * 6, "d4" * 6], snrs=[1.0], when=when, source="neighbour")
|
||
return topo
|
||
|
||
|
||
def _screen(topo: MeshTopology, cell_h: int = 24) -> WalkScreen:
|
||
screen = WalkScreen(
|
||
session=_FakeSession(),
|
||
topo=topo,
|
||
contacts={
|
||
topo.canonical(Hub.public_key): Hub,
|
||
topo.canonical(ALICE.public_key): ALICE,
|
||
},
|
||
self_label="Homestead",
|
||
)
|
||
screen.note_viewport(cell_h) # the frame records this before each real paint
|
||
return screen
|
||
|
||
|
||
# --- rendering ----------------------------------------------------------------------------
|
||
|
||
|
||
def test_walk_opens_focused_on_us_with_the_link_list() -> None:
|
||
"""The default focus is our own node: the canvas, the legend, and its links below."""
|
||
screen = _screen(_topo())
|
||
body = _plain(screen.render_body(80))
|
||
assert "Homestead" in body and "this device" in body
|
||
assert "Links" in body and "strongest observed first" in body
|
||
assert "Hilltop-Repeater" in body # our one neighbour, as a row that the user can select
|
||
assert "edge = SNR" in body # the legend of the canvas
|
||
# The title is only the name of the screen. The body under it shows the focus and the
|
||
# size of the neighbourhood.
|
||
assert screen.title == "Mesh walk"
|
||
|
||
|
||
def test_walk_link_rows_carry_snr_evidence_and_onward_count() -> None:
|
||
"""A neighbour row shows the SNR, the samples, the source tags, the age, and onward links."""
|
||
screen = _screen(_topo())
|
||
body = _plain(screen.render_body(80))
|
||
row = next(line for line in body.split("\n") if "Hilltop-Repeater" in line and "❯" in line)
|
||
assert "+6.0" in row # the median SNR of the link
|
||
assert "1×" in row # samples
|
||
assert "T" in row # the tag of trace evidence
|
||
assert "⋯ 1" in row # one link onward (Hub to Alice)
|
||
assert "3d" * 6 in row # the whole id of 12 hex digits, not a truncated prefix…
|
||
assert "…" not in row # …and nothing in it is elided
|
||
|
||
|
||
def test_walk_link_row_shrinks_the_key_before_the_name() -> None:
|
||
"""A long name keeps its letters. The key gives space first, at a byte boundary.
|
||
|
||
If the row cannot hold both a long name and the full id of 12 hex digits, the key is
|
||
truncated to its lit hash and a ``…`` (an even count of whole bytes). The name does not
|
||
lose characters, and the hash prefix is never the part that is removed.
|
||
"""
|
||
long_repeater = Contact(
|
||
name="Repeater-Downtown-01", public_key="d4c3b2a1f0e9" + "00" * 26, node_type=2
|
||
)
|
||
topo = MeshTopology(US, contacts=[long_repeater])
|
||
node = topo.canonical(long_repeater.public_key)
|
||
topo.add_walk([topo.self_id, node], snrs=[4.0], when=utcnow(), source="trace")
|
||
screen = WalkScreen(
|
||
session=_FakeSession(),
|
||
topo=topo,
|
||
contacts={node: long_repeater},
|
||
self_label="Homestead",
|
||
prefix_bytes=1, # the first byte (``d4``) is the hash that addresses the node
|
||
)
|
||
screen.note_viewport(24)
|
||
row = next(
|
||
line
|
||
for line in _plain(screen.render_body(68)).split("\n")
|
||
if "Repeater-Downtown-01" in line and "❯" in line # the list row, not a label of the canvas
|
||
)
|
||
assert "Repeater-Downtown-01" in row # the name is whole, not clipped
|
||
assert "…" in row # the key is the lane that gave space
|
||
assert "d4c3" in row # its lit hash (and a byte or two more) remains
|
||
assert "d4c3b2a1f0e9" not in row # …but not the whole id, because it was truncated
|
||
|
||
|
||
def test_walk_graph_labels_names_in_full_when_the_canvas_has_room() -> None:
|
||
"""The canvas draws the name of a neighbour whole, not clipped to a short fixed limit.
|
||
|
||
The fan pulls to the west of the edge, and each label is clamped to the room that is
|
||
there. Thus a long contact name keeps its letters where the canvas can hold it.
|
||
"""
|
||
long_names = [
|
||
Contact(name="Lakesidetechnique", public_key="e8" * 32, node_type=2),
|
||
Contact(name="Repeater-Downtown-01", public_key="d4" * 32, node_type=2),
|
||
]
|
||
topo = MeshTopology(US, contacts=long_names)
|
||
when = utcnow()
|
||
for c, snr in zip(long_names, (8.0, 1.0), strict=True):
|
||
topo.add_walk(
|
||
[topo.self_id, topo.canonical(c.public_key)], snrs=[snr], when=when, source="trace"
|
||
)
|
||
screen = WalkScreen(
|
||
session=_FakeSession(),
|
||
topo=topo,
|
||
contacts={topo.canonical(c.public_key): c for c in long_names},
|
||
self_label="Homestead",
|
||
)
|
||
screen.note_viewport(24)
|
||
# The canvas rows are all the rows above the legend line.
|
||
lines = _plain(screen.render_body(80)).split("\n")
|
||
canvas = "\n".join(lines[: next(i for i, ln in enumerate(lines) if "edge = SNR" in ln)])
|
||
assert "Lakesidetechnique" in canvas # 17 characters, drawn whole: no "Lakesidetechniq…"
|
||
assert "Repeater-Downtown-01" in canvas # 20 characters, whole
|
||
|
||
|
||
def test_walk_selected_link_lights_the_route_that_reaches_it() -> None:
|
||
"""A selected link draws its edge and the approach to the focus at full strength.
|
||
|
||
Each link is stale, so an edge that is not highlighted fades to half. The edge of the
|
||
selected neighbour and the came_from → focus approach are drawn with no fade. This is
|
||
the lit route. The edge of a neighbour that is not selected stays faded.
|
||
"""
|
||
from meshterm.ui.walk_screen import _snr_rgb
|
||
|
||
stale = utcnow() - timedelta(days=10) # older than a week, so _freshness is 0.5
|
||
hub = Contact(name="Hub", public_key="3d" * 32, node_type=2)
|
||
alice = Contact(name="Alice", public_key="b2" * 32)
|
||
bob = Contact(name="Bob", public_key="c4" * 32)
|
||
topo = MeshTopology(US, contacts=[hub, alice, bob])
|
||
y = topo.canonical(hub.public_key)
|
||
topo.add_walk([topo.self_id, y], snrs=[10.0], when=stale, source="trace") # approach: green
|
||
# amber, then red
|
||
topo.add_walk([y, topo.canonical(alice.public_key)], snrs=[0.0], when=stale, source="trace")
|
||
topo.add_walk([y, topo.canonical(bob.public_key)], snrs=[-15.0], when=stale, source="trace")
|
||
screen = WalkScreen(
|
||
session=_FakeSession(),
|
||
topo=topo,
|
||
contacts={topo.canonical(c.public_key): c for c in (hub, alice, bob)},
|
||
self_label="Homestead",
|
||
)
|
||
screen.note_viewport(24)
|
||
screen.render_body(80)
|
||
screen.handle("enter") # walk to Hub: now came_from is us, the approach edge
|
||
# Put the selection on Alice (a fan neighbour, not the ⌫-back row).
|
||
while screen._rows()[screen._index] != topo.canonical(alice.public_key):
|
||
screen.handle("down")
|
||
|
||
ansi_lines = screen.render_body(80)
|
||
legend = next(i for i, ln in enumerate(ansi_lines) if "edge = SNR" in _plain([ln]))
|
||
canvas = "".join(ansi_lines[:legend])
|
||
|
||
def code(rgb: tuple[int, int, int]) -> str:
|
||
return f"38;2;{rgb[0]};{rgb[1]};{rgb[2]}m"
|
||
|
||
def scaled(rgb: tuple[int, int, int], f: float) -> tuple[int, int, int]:
|
||
return tuple(max(0, min(255, round(c * f))) for c in rgb)
|
||
|
||
assert code(_snr_rgb(0.0)) in canvas # the edge of Alice, amber at full strength
|
||
assert code(scaled(_snr_rgb(-15.0), 0.5)) in canvas # the edge of Bob stays faded…
|
||
assert code(_snr_rgb(-15.0)) not in canvas # …and never reaches full strength
|
||
|
||
|
||
def test_walk_empty_graph_renders_guidance() -> None:
|
||
"""With no evidence, the screen explains how the walk fills up."""
|
||
topo = MeshTopology(US, contacts=[])
|
||
screen = _screen(topo)
|
||
body = _plain(screen.render_body(80))
|
||
assert "no evidence to draw yet" in body
|
||
assert "trace" in body
|
||
|
||
|
||
# --- walking ------------------------------------------------------------------------------
|
||
|
||
|
||
def test_walk_enter_walks_and_grows_the_trail() -> None:
|
||
"""Enter focuses the highlighted neighbour, and the breadcrumb trail shows the walk."""
|
||
topo = _topo()
|
||
screen = _screen(topo)
|
||
screen.render_body(80)
|
||
screen.handle("enter") # walk to Hub (our only neighbour)
|
||
assert screen._focus == topo.canonical(Hub.public_key)
|
||
body = _plain(screen.render_body(80))
|
||
assert "★ › Hilltop-Repeater" in body # the trail, with our end as the star of the app
|
||
# The focus line shows name (hash). The glyph shows the type, not a kind in words.
|
||
assert "Hilltop-Repeater (3d)" in body and "1 hop out" in body
|
||
assert "Alice" in body # the onward neighbour of Hub is now a row
|
||
# The screen does not offer the node that we walked in from as a link, because ⌫ is the
|
||
# way back. Thus the list has only ways onward, and it opens on the strongest of them.
|
||
assert screen._rows() == [topo.canonical(ALICE.public_key)]
|
||
assert "⌫ back" not in body
|
||
|
||
|
||
def test_walk_backspace_steps_back_along_the_trail() -> None:
|
||
"""⌫ pops the trail one step. At the start of the trail, it does nothing."""
|
||
topo = _topo()
|
||
screen = _screen(topo)
|
||
screen.render_body(80)
|
||
screen.handle("enter")
|
||
assert len(screen._trail) == 2
|
||
screen.handle("backspace")
|
||
assert screen._trail == [topo.self_id]
|
||
screen.handle("backspace") # already home: it does nothing
|
||
assert screen._trail == [topo.self_id]
|
||
|
||
|
||
def test_walk_the_node_walked_in_from_is_not_offered_back_as_a_link() -> None:
|
||
"""The way back is ⌫, not a row. The screen does not offer a walk that undoes the last walk.
|
||
|
||
Thus the list has only ways onward. This also makes row 0 the strongest link out of
|
||
this node. Row 0 is where each reset of the highlight goes.
|
||
"""
|
||
topo = _topo()
|
||
screen = _screen(topo)
|
||
screen.render_body(80)
|
||
screen.handle("enter") # us → Hub
|
||
|
||
assert topo.self_id not in screen._rows()
|
||
assert screen._rows() == screen._fan_nodes() # the canvas draws the same set
|
||
assert screen._index == 0
|
||
strongest = max(
|
||
screen._links_of(screen._focus),
|
||
key=lambda pair: pair[1].median_snr if pair[1].median_snr is not None else -99,
|
||
)[0]
|
||
assert screen._rows()[screen._index] != topo.self_id
|
||
assert strongest in (topo.self_id, screen._rows()[0]) # us is stronger, and excluded
|
||
|
||
screen.handle("backspace") # the way back, with the key
|
||
assert screen._trail == [topo.self_id]
|
||
|
||
|
||
def test_walk_walking_to_an_earlier_node_drops_the_loop() -> None:
|
||
"""A walk back to an earlier node removes the loop.
|
||
|
||
When the walk visits a node that is already on the trail again, the stack is truncated
|
||
to the first appearance of that node. This removes the circular section that the walk
|
||
made to get back there.
|
||
"""
|
||
topo = MeshTopology(US, contacts=[Hub, ALICE])
|
||
hub = topo.canonical(Hub.public_key)
|
||
alice = topo.canonical(ALICE.public_key)
|
||
when = utcnow()
|
||
topo.add_walk([topo.self_id, hub], snrs=[6.0], when=when, source="trace")
|
||
topo.add_walk([hub, alice], snrs=[-2.0], when=when, source="packet")
|
||
topo.add_walk([topo.self_id, alice], snrs=[3.0], when=when, source="trace") # closes the loop
|
||
screen = _screen(topo)
|
||
|
||
def walk_to(node: str) -> None:
|
||
screen.render_body(80)
|
||
screen._index = screen._rows().index(node)
|
||
screen.handle("enter")
|
||
|
||
walk_to(hub) # us → Hub
|
||
walk_to(alice) # Hub → Alice
|
||
assert screen._trail == [topo.self_id, hub, alice]
|
||
# Alice → us closes the loop. It is a real link onward (it is not the node that we
|
||
# arrived from, which is Hub), so the screen offers it. When the user takes it, the
|
||
# section that the walk made to get here is removed.
|
||
walk_to(topo.self_id)
|
||
assert screen._trail == [topo.self_id]
|
||
|
||
|
||
def test_walk_trail_crops_its_head_not_its_tail_when_narrow() -> None:
|
||
"""A trail that is too long for the line is cropped at its start, as in the trophy case."""
|
||
screen = _screen(_topo())
|
||
us = screen._topo.self_id
|
||
hub = screen._topo.canonical(Hub.public_key)
|
||
alice = screen._topo.canonical(ALICE.public_key)
|
||
screen._trail = [us, hub, alice]
|
||
width = 20 # too narrow for the whole "Homestead › Hilltop-Repeater › Alice"
|
||
text = screen._trail_text(width).plain
|
||
assert text[0] in ("…", CRACK_HEAD) # cut, not elided: no whole hop paid for the mark
|
||
assert "⋯" not in text
|
||
assert text.endswith("Alice") # the focus is always kept
|
||
assert "Homestead" not in text # the start was cropped, not the tail
|
||
assert len(text) == width # a crop ends at the width, so it is already flush right
|
||
|
||
|
||
def test_walk_trail_crop_keeps_the_cells_an_elision_would_have_spent() -> None:
|
||
"""The purpose of the crop: the cells of the lane go to the walk, not to a removed hop."""
|
||
screen, width = _walked_chain(8)
|
||
cropped = screen._trail_text(width)
|
||
elided = PathLine(
|
||
[screen._trail_hop(node) for node in screen._trail], separator=" › "
|
||
).ellipsized(width, elide=ELIDE_HEAD)
|
||
assert cropped.cell_len == width # the crop fills the lane…
|
||
assert elided.cell_len < width # …where the removal of a whole hop left cells unused
|
||
# The crop keeps the tail of the hop where it ended. The elision removed the whole hop.
|
||
assert cropped.plain.startswith("…-05 ") and "-05" not in elided.plain
|
||
|
||
|
||
def test_walk_trail_sits_left_until_it_overflows() -> None:
|
||
"""A trail that fits is left-aligned. Only an elided trail goes to the right edge."""
|
||
screen = _screen(_topo())
|
||
screen._trail = [screen._topo.self_id]
|
||
assert screen._trail_text(40).plain == SELF_GLYPH # us, in one cell
|
||
|
||
|
||
def test_walk_trail_scrolled_back_to_its_head_sits_left_again() -> None:
|
||
"""The alignment follows the head, not the scroll. A visible start puts the line at the left."""
|
||
screen, width = _walked_chain(8)
|
||
steps = screen._trail_max_scroll(width) // _HSCROLL_STEP
|
||
for step in range(1, steps): # each stop before the head is still cropped
|
||
screen.handle("left")
|
||
cut = _plain([render_to_ansi(screen._trail_text(width), width, no_wrap=True)])
|
||
assert cut[0] in ("…", CRACK_HEAD) and len(cut) == width, step
|
||
|
||
screen.handle("left") # the step that makes the start of the walk visible again
|
||
assert screen._trail_scroll == steps * _HSCROLL_STEP
|
||
home = _plain([render_to_ansi(screen._trail_text(width), width, no_wrap=True)])
|
||
assert home.startswith(SELF_GLYPH) # flush left, with no padding before the head
|
||
|
||
|
||
def test_walk_trail_names_carry_their_node_hues() -> None:
|
||
"""Trail names use the hue of the key of each node (ours is the white "you" style).
|
||
|
||
The focus is bold.
|
||
"""
|
||
from meshterm.ui.theme import node_style
|
||
|
||
screen = _screen(_topo())
|
||
us = screen._topo.self_id
|
||
hub = screen._topo.canonical(Hub.public_key)
|
||
screen._trail = [us, hub]
|
||
text = screen._trail_text(80)
|
||
plain = text.plain
|
||
hub_at = plain.index("Hilltop-Repeater")
|
||
assert any(
|
||
s.start <= hub_at < s.end and "bold" in str(s.style) and node_style(hub) in str(s.style)
|
||
for s in text.spans
|
||
)
|
||
|
||
|
||
def test_walk_locate_refocuses_us_and_home_end_walk_the_list() -> None:
|
||
"""^L resets the walk to our own node from anywhere. Home and End go to the ends of the list."""
|
||
# A hub with spokes from one of our neighbours: a focus away from us, with a list to move in.
|
||
topo = MeshTopology(US, contacts=[Hub])
|
||
hub = topo.canonical(Hub.public_key)
|
||
when = utcnow()
|
||
topo.add_walk([topo.self_id, hub], snrs=[6.0], when=when, source="trace")
|
||
for i in range(5):
|
||
topo.add_walk([hub, f"{i:02x}" * 6], snrs=[5.0 - i], when=when, source="trace")
|
||
screen = _screen(topo)
|
||
screen.render_body(80)
|
||
screen.handle("enter") # us → Hub
|
||
screen.render_body(80)
|
||
rows = screen._rows()
|
||
assert len(rows) > 1 and topo.self_id not in rows
|
||
|
||
# Home and End move the highlight. They do not abandon the walk.
|
||
screen.handle("end")
|
||
assert screen._index == len(rows) - 1 and screen._trail == [topo.self_id, hub]
|
||
screen.handle("home")
|
||
assert screen._index == 0 and screen._trail == [topo.self_id, hub]
|
||
|
||
screen.handle("locate")
|
||
assert screen._trail == [topo.self_id]
|
||
|
||
|
||
def test_walk_echoes_the_find_query_above_the_matches_it_narrows() -> None:
|
||
"""The PicoCalc draws no hint line, so the query that the hint has moves into the body."""
|
||
from meshterm.platforms import PICOCALC_LYRA, REGULAR, set_platform
|
||
|
||
try:
|
||
set_platform(REGULAR)
|
||
desktop = _screen(_topo())
|
||
desktop.render_body(80)
|
||
for ch in "al":
|
||
desktop.handle("text", ch)
|
||
body = _plain(desktop.render_body(80))
|
||
assert "/al" not in body and "find: al" in desktop.footer_hint
|
||
|
||
set_platform(PICOCALC_LYRA)
|
||
device = _screen(_topo())
|
||
device.render_body(53)
|
||
for ch in "al":
|
||
device.handle("text", ch)
|
||
lines = [_plain(line) for line in device.render_body(53)]
|
||
assert len(lines) <= 24 # still inside the viewport that the test gave
|
||
# The echo is directly above the heading of the list that it narrows. The list, not
|
||
# the canvas, gives the row for it.
|
||
echo = next(i for i, line in enumerate(lines) if line.strip() == "/al")
|
||
assert lines[echo + 1].startswith("Matches")
|
||
# When the find is cleared, the row goes with it.
|
||
for _ in "al":
|
||
device.handle("backspace")
|
||
assert not any(
|
||
line.strip().startswith("/") for line in (_plain(ln) for ln in device.render_body(53))
|
||
)
|
||
finally:
|
||
set_platform(REGULAR)
|
||
|
||
|
||
def test_walk_fkey_lane_gives_you_its_own_slot_over_the_pagers_ends() -> None:
|
||
"""``You`` is a verb of this screen (F3), not an end of the list that the pager scrolls."""
|
||
from meshterm.ui.tui.fkeys import PICOCALC_LYRA_DECK
|
||
|
||
screen = _screen(_topo())
|
||
screen.render_body(80)
|
||
lane = screen.picocalc_lyra_lane
|
||
|
||
assert [pair.label if pair else None for pair in lane] == [
|
||
None,
|
||
None,
|
||
"You",
|
||
"Page ↓",
|
||
"Page ↑",
|
||
]
|
||
assert PICOCALC_LYRA_DECK.action_for(lane, 3) == "locate"
|
||
assert lane[2].opp_label == "" # nothing is the opposite of going home
|
||
# Also, the Shift bank of the pager has the same meaning as on each other screen: the
|
||
# two ends of the list.
|
||
assert lane[4].opp_label == "Top" and PICOCALC_LYRA_DECK.action_for(lane, 10) == "home"
|
||
assert lane[3].opp_label == "Bottom" and PICOCALC_LYRA_DECK.action_for(lane, 9) == "end"
|
||
|
||
|
||
def test_walk_you_dims_once_the_focus_is_already_us() -> None:
|
||
"""A dim chip means that the action exists here but is not available now.
|
||
|
||
Here there is nothing to come back from.
|
||
"""
|
||
screen = _screen(_topo())
|
||
screen.render_body(80)
|
||
assert screen.picocalc_lyra_lane[2].enabled is False # opens on us, with a trail of one
|
||
|
||
screen.handle("enter") # walked away: now there is something to come back from
|
||
assert screen.picocalc_lyra_lane[2].enabled is True
|
||
|
||
screen.handle("locate")
|
||
assert screen.picocalc_lyra_lane[2].enabled is False
|
||
screen.handle("text", "a") # a find narrows the list away from our own neighbours too
|
||
assert screen.picocalc_lyra_lane[2].enabled is True
|
||
|
||
|
||
def test_walk_the_fan_is_all_east_and_holds_no_came_from() -> None:
|
||
"""Each node that the canvas draws is a way onward, east of the focus. No node is behind it."""
|
||
topo = _topo()
|
||
screen = _screen(topo)
|
||
screen.render_body(80)
|
||
screen.handle("enter") # focus Hub, and we came from us
|
||
alice = topo.canonical(ALICE.public_key)
|
||
fx, _fy = screen._focus_pos(80, 12)
|
||
placed = screen._place_neighbours(80, 12, screen._fan_nodes(), False)
|
||
assert topo.self_id not in placed # the node that we came from is behind us, not on screen
|
||
assert list(placed) == [alice]
|
||
assert placed[alice][0] > fx # the fan is east of the focus
|
||
assert fx <= (80 * 2) // 3 # and the focus is at the left
|
||
|
||
|
||
def test_walk_fan_rim_leaves_spread_east_not_curling_back() -> None:
|
||
"""The fan is a flat arc. The top and bottom leaves also reach far to the east.
|
||
|
||
On the old circular arc, only the leaf due east reached the far side. The rim leaves
|
||
curled back toward the focus (to approximately 0.31 of the reach), and the corners were
|
||
empty. The wedge makes the horizontal reach flat, so a rim leaf is east of the midpoint
|
||
between the anchor of the fan and its tip due east. Thus the fan spreads across the width.
|
||
"""
|
||
fan = [f"{i + 0x20:02x}" * 6 for i in range(5)]
|
||
topo = MeshTopology(US, contacts=[])
|
||
when = utcnow()
|
||
for i, node in enumerate(fan):
|
||
topo.add_walk([topo.self_id, node], snrs=[5.0 - i], when=when, source="trace")
|
||
screen = _screen(topo)
|
||
ax, _ay, _name = screen._focus_anchor(80, 14)
|
||
placed = screen._place_neighbours(80, 14, fan, False)
|
||
tip = max(x for x, _y in placed.values()) # the leaf due east, the far tip of the fan
|
||
midpoint = ax + (tip - ax) / 2
|
||
assert placed[fan[0]][0] > midpoint # the top leaf reaches past halfway east…
|
||
assert placed[fan[-1]][0] > midpoint # …and so does the bottom leaf
|
||
|
||
|
||
def _hub_topo(spokes: int) -> MeshTopology:
|
||
"""Us at the centre of a hub with ``spokes`` neighbours, with decreasing strengths."""
|
||
topo = MeshTopology(US, contacts=[])
|
||
when = utcnow()
|
||
for i in range(spokes):
|
||
node = f"{i:02x}" * 6
|
||
for _ in range(spokes - i): # more samples is stronger, so the order is fixed
|
||
topo.add_walk([topo.self_id, node], snrs=[5.0], when=when, source="trace")
|
||
return topo
|
||
|
||
|
||
def test_walk_collapses_the_weak_links_into_one_ellipsis_marker() -> None:
|
||
"""Past the capacity of the area, the weaker neighbours fold into one ``…`` node."""
|
||
screen = WalkScreen(session=_FakeSession(), topo=_hub_topo(14), contacts={}, self_label="us")
|
||
screen.note_viewport(20)
|
||
body = _plain(screen.render_body(80))
|
||
assert "weaker" in body # the label of the collapsed marker is "+n weaker"
|
||
canvas_part = body.split("Links")[0]
|
||
assert "…" in canvas_part
|
||
# The list still names each neighbour. The list is where the user selects.
|
||
assert len(screen._rows()) == 14
|
||
|
||
|
||
def test_walk_fan_stays_sparse_and_collapses_the_rest() -> None:
|
||
"""The fan is sparse by design. Even a small hub collapses its weakest links."""
|
||
screen = WalkScreen(session=_FakeSession(), topo=_hub_topo(8), contacts={}, self_label="us")
|
||
screen.note_viewport(20)
|
||
canvas_part = _plain(screen.render_body(80)).split("Links")[0]
|
||
assert "…" in canvas_part and "weaker" in canvas_part # not all eight are drawn
|
||
|
||
|
||
def test_walk_labels_non_selected_nodes_to_the_right_of_their_icon() -> None:
|
||
"""The name of a fan node is directly to the right of its marker, in the reading direction."""
|
||
from meshterm.ui.mapcanvas import MapCanvas
|
||
|
||
screen = _screen(_topo())
|
||
canvas = MapCanvas(80, 12)
|
||
canvas.marker(40, 20, "●", (255, 255, 255)) # a marker with room to its east
|
||
screen._label_right(canvas, 40, 20, "Bravo", (200, 200, 200))
|
||
marker_cx = 40 >> 1
|
||
assert canvas._label_cells # the name was placed
|
||
assert all(cx > marker_cx for cx, _cy in canvas._label_cells) # each cell is east
|
||
|
||
|
||
def test_walk_selecting_a_collapsed_row_lights_the_ellipsis_with_its_name() -> None:
|
||
"""A highlight on a weak (collapsed) row shows its name at the ``…`` marker."""
|
||
screen = WalkScreen(session=_FakeSession(), topo=_hub_topo(14), contacts={}, self_label="us")
|
||
screen.note_viewport(20)
|
||
screen.render_body(80)
|
||
screen._index = len(screen._rows()) - 1 # the weakest row, which is collapsed
|
||
body = _plain(screen.render_body(80))
|
||
weakest = screen._rows()[-1][:8]
|
||
canvas_part = body.split("Links")[0]
|
||
assert weakest in canvas_part # the ellipsis marker has the label of the selection
|
||
assert "weaker" not in canvas_part # ...and replaces the "+n weaker" count
|
||
|
||
|
||
def test_walk_body_fits_the_viewport_and_windows_the_list() -> None:
|
||
"""The screen never grows past the frame. Only the link list scrolls, and it has marks."""
|
||
screen = WalkScreen(session=_FakeSession(), topo=_hub_topo(16), contacts={}, self_label="us")
|
||
screen.note_viewport(22)
|
||
lines = screen.render_body(80)
|
||
assert len(lines) <= 22 # canvas, chrome, and list window together are the viewport
|
||
body = _plain(lines)
|
||
assert "↓" in body and "more" in body # the window has a mark for the rows below
|
||
assert "Links" in body
|
||
|
||
|
||
def test_walk_pgdn_pages_the_highlight_by_the_list_window() -> None:
|
||
"""PgUp and PgDn move by the size of the list window, and the window follows the highlight."""
|
||
screen = WalkScreen(session=_FakeSession(), topo=_hub_topo(16), contacts={}, self_label="us")
|
||
screen.note_viewport(22)
|
||
screen.render_body(80)
|
||
stride = screen._list.page
|
||
assert stride >= 1
|
||
screen.handle("pagedown")
|
||
assert screen._index == min(15, stride)
|
||
for _ in range(6):
|
||
screen.handle("pagedown")
|
||
assert screen._index == 15 # clamped at the last row
|
||
body = _plain(screen.render_body(80))
|
||
assert "↑" in body and "more" in body # the rows scrolled off above have a count
|
||
|
||
|
||
# --- find ---------------------------------------------------------------------------------
|
||
|
||
|
||
def test_walk_find_lists_matches_and_teleports() -> None:
|
||
"""Typing filters all the known nodes. Enter focuses the match and restarts the trail."""
|
||
topo = _topo()
|
||
screen = _screen(topo)
|
||
screen.render_body(80)
|
||
for ch in "ali":
|
||
screen.handle("text", ch)
|
||
assert "find: ali" in screen.footer_hint
|
||
body = _plain(screen.render_body(80))
|
||
assert "Matches" in body and "Alice" in body
|
||
assert "2 hops out" in body # the match row shows how far away the node is
|
||
assert screen.title == "Mesh walk" # the title does not change. The list is the answer
|
||
|
||
screen.handle("enter")
|
||
alice = topo.canonical(ALICE.public_key)
|
||
assert screen._focus == alice
|
||
assert screen._trail == [alice] # a jump restarts the trail
|
||
assert screen._filter == ""
|
||
|
||
|
||
def test_walk_find_marks_islands() -> None:
|
||
"""A match with no path to us shows island, and its focus says why."""
|
||
screen = _screen(_topo(with_island=True))
|
||
screen.render_body(80)
|
||
for ch in "c3":
|
||
screen.handle("text", ch)
|
||
body = _plain(screen.render_body(80))
|
||
assert "island" in body
|
||
screen.handle("enter")
|
||
body = _plain(screen.render_body(80))
|
||
assert "island — no observed path to you" in body
|
||
|
||
|
||
def test_walk_esc_peels_find_then_dismisses() -> None:
|
||
"""The Esc key first clears an active find. The next Esc leaves the screen."""
|
||
import asyncio
|
||
|
||
screen = _screen(_topo())
|
||
screen.render_body(80)
|
||
screen.handle("text", "a")
|
||
|
||
async def drive() -> object:
|
||
screen.future = asyncio.get_running_loop().create_future()
|
||
screen.handle("escape") # peels the filter
|
||
assert screen._filter == "" and not screen.future.done()
|
||
screen.handle("escape") # leaves the screen
|
||
return await screen.future
|
||
|
||
assert asyncio.run(drive()) is None
|
||
|
||
|
||
def _chain(length: int) -> MeshTopology:
|
||
"""Us, hop-01, hop-02, and so on. Each name is long enough to overflow a narrow trail."""
|
||
contacts = [
|
||
Contact(name=f"Repeater-{i:02d}", public_key=f"{i:02x}" * 32) for i in range(1, length + 1)
|
||
]
|
||
topo = MeshTopology(US, contacts=contacts)
|
||
when = utcnow()
|
||
previous = topo.self_id
|
||
for contact in contacts:
|
||
node = topo.canonical(contact.public_key)
|
||
topo.add_walk([previous, node], snrs=[4.0], when=when, source="trace")
|
||
previous = node
|
||
return topo
|
||
|
||
|
||
def _walked_chain(length: int, *, width: int = 46):
|
||
"""A screen walked to the end of a chain of `length` hops, rendered at `width`."""
|
||
topo = _chain(length)
|
||
contacts = {
|
||
topo.canonical(f"{i:02x}" * 32): Contact(
|
||
name=f"Repeater-{i:02d}", public_key=f"{i:02x}" * 32
|
||
)
|
||
for i in range(1, length + 1)
|
||
}
|
||
screen = WalkScreen(
|
||
session=_FakeSession(), topo=topo, contacts=contacts, self_label="Homestead"
|
||
)
|
||
screen.note_viewport(24)
|
||
for _ in range(length):
|
||
screen.render_body(width)
|
||
rows = screen._rows()
|
||
# Always step onward, never back through the node that we came from.
|
||
screen._index = next(i for i, node in enumerate(rows) if node not in screen._trail)
|
||
screen.handle("enter")
|
||
screen.render_body(width)
|
||
return screen, width
|
||
|
||
|
||
def test_walk_trail_slides_a_window_over_the_walk_and_cracks_both_edges() -> None:
|
||
"""The ← key reads back over a long walk, and the → key returns.
|
||
|
||
Each edge where the walk goes on has a crack.
|
||
"""
|
||
screen, width = _walked_chain(8)
|
||
trail = _plain([render_to_ansi(screen._trail_text(width), width, no_wrap=True)])
|
||
|
||
# At rest the window is at the tail. The focus shows, and the start is cropped off at the
|
||
# left. The end of the walk needs no mark, because the walk really ends there.
|
||
assert "Repeater-08" in trail and "Homestead" not in trail
|
||
assert trail[0] in ("…", CRACK_HEAD) and len(trail) == width
|
||
assert trail[-1] not in ("…", CRACK_TAIL) and "⋯" not in trail
|
||
assert screen._trail_scroll == 0
|
||
|
||
screen.handle("left")
|
||
slid = _plain([render_to_ansi(screen._trail_text(width), width, no_wrap=True)])
|
||
assert screen._trail_scroll == _HSCROLL_STEP # the step of the app, in cells and not hops
|
||
# The walk moved out of view on the right, and the line says so in the same way as on the
|
||
# left: with the cut mark, never the ⋯. Nothing here is elided. The lane ran out.
|
||
assert "Repeater-08" not in slid and "Repeater-07" in slid
|
||
assert slid[-1] in ("…", CRACK_TAIL) and "⋯" not in slid
|
||
assert len(slid) == width
|
||
|
||
screen.handle("right")
|
||
assert screen._trail_scroll == 0
|
||
assert _plain([render_to_ansi(screen._trail_text(width), width, no_wrap=True)]) == trail
|
||
|
||
|
||
def test_walk_trail_scroll_stops_at_the_head_and_resets_with_the_trail() -> None:
|
||
"""The ← key stops where the start of the walk is visible. A change to the trail rewinds it."""
|
||
screen, width = _walked_chain(8)
|
||
limit = screen._trail_max_scroll(width)
|
||
assert limit > 0
|
||
|
||
for _ in range(limit + 5): # a held ← stores no extra steps to undo
|
||
screen.handle("left")
|
||
assert screen._trail_scroll == limit
|
||
head = _plain([render_to_ansi(screen._trail_text(width), width, no_wrap=True)])
|
||
assert head.lstrip().startswith(SELF_GLYPH) # scrolled far enough to see the start
|
||
|
||
screen.handle("backspace") # a step back is a change to the trail
|
||
assert screen._trail_scroll == 0
|
||
screen.render_body(width)
|
||
screen.handle("left")
|
||
screen.handle("locate") # a return home is also a change
|
||
assert screen._trail_scroll == 0
|
||
|
||
|
||
def test_walk_trail_scroll_is_inert_on_a_walk_that_fits() -> None:
|
||
"""A trail with nothing hidden has nothing to scroll, and the hint does not offer it."""
|
||
screen = _screen(_topo())
|
||
screen.render_body(80)
|
||
assert screen._trail_max_scroll(80) == 0
|
||
assert "←→ trail" not in screen.footer_hint
|
||
assert "type to find" in screen.footer_hint
|
||
|
||
screen.handle("left")
|
||
assert screen._trail_scroll == 0
|
||
|
||
walked, width = _walked_chain(8)
|
||
assert "←→ trail" in walked.footer_hint # the hint shows it only where it does something
|
||
assert len(walked.footer_hint) <= 72
|
||
|
||
|
||
def test_walk_find_narrows_the_canvas_fan_but_not_the_walk() -> None:
|
||
"""Typing thins the ways onward. The focus and the node that the walk came from stay."""
|
||
bob = Contact(name="Bob-Tower", public_key="c7" * 32)
|
||
topo = MeshTopology(US, contacts=[Hub, ALICE, bob])
|
||
hub, alice = topo.canonical(Hub.public_key), topo.canonical(ALICE.public_key)
|
||
bob_id = topo.canonical(bob.public_key)
|
||
when = utcnow()
|
||
topo.add_walk([topo.self_id, hub], snrs=[6.0], when=when, source="trace")
|
||
topo.add_walk([hub, alice], snrs=[-2.0], when=when, source="packet")
|
||
topo.add_walk([hub, bob_id], snrs=[1.0], when=when, source="packet")
|
||
|
||
screen = WalkScreen(
|
||
session=_FakeSession(),
|
||
topo=topo,
|
||
contacts={hub: Hub, alice: ALICE, bob_id: bob},
|
||
self_label="Homestead",
|
||
)
|
||
screen.note_viewport(24)
|
||
screen.render_body(80)
|
||
screen._index = screen._rows().index(hub)
|
||
screen.handle("enter") # focus Hub, came from us
|
||
|
||
canvas = _plain(screen._canvas_lines(80, 12, None))
|
||
assert "Alice" in canvas and "Bob-Tower" in canvas # the whole fan, with no filter
|
||
|
||
for ch in "ali":
|
||
screen.handle("text", ch)
|
||
canvas = _plain(screen._canvas_lines(80, 12, None))
|
||
assert "Alice" in canvas # the way onward that the query matches
|
||
assert "Bob-Tower" not in canvas # and not the way that it does not match
|
||
# The focus is not a candidate. It is the walk so far, and it stays for each query.
|
||
assert "Hilltop-Repeater" in canvas
|
||
|
||
|
||
def test_walk_marks_wear_their_node_type_colour_not_the_key_hue() -> None:
|
||
"""A mark is a glyph and a colour: the same mark as the map and the route graph use."""
|
||
from meshterm.ui.marks import NODE_MARK, REPEATER_MARK, SELF_MARK
|
||
from meshterm.ui.theme import mark_rgb, node_style
|
||
|
||
topo = _topo()
|
||
screen = _screen(topo)
|
||
hub, alice = topo.canonical(Hub.public_key), topo.canonical(ALICE.public_key)
|
||
|
||
assert screen._glyph(hub) == (REPEATER_MARK[0], "type.repeater")
|
||
assert screen._glyph(alice) == (NODE_MARK[0], "type.node")
|
||
assert screen._marker_rgb(hub) == mark_rgb("type.repeater")
|
||
assert screen._marker_rgb(topo.self_id) == mark_rgb(SELF_MARK[1])
|
||
# Identity has its own lane and keeps it: the name has the hue of the key.
|
||
assert screen._marker_rgb(hub) != screen._label_rgb(hub)
|
||
assert screen._label_rgb(hub) == _plain_rgb(node_style(hub))
|
||
# The legend is a sample of those marks, so it shows the colour and the shape.
|
||
legend = screen._legend()
|
||
hues = {
|
||
legend.plain[span.start : span.end]: str(span.style)
|
||
for span in legend.spans
|
||
if span.end - span.start == 1
|
||
}
|
||
assert hues[REPEATER_MARK[0]] == "type.repeater"
|
||
assert hues[NODE_MARK[0]] == "type.node"
|
||
assert hues[SELF_MARK[0]] == SELF_MARK[1]
|
||
|
||
|
||
def _plain_rgb(style: str) -> tuple[int, int, int]:
|
||
from meshterm.ui.mapcanvas import parse_hex
|
||
|
||
return parse_hex(style.rsplit("#", 1)[-1])
|
||
|
||
|
||
def test_walk_fan_rows_leave_a_blank_line_between_each_and_around() -> None:
|
||
"""One marker in each row, a blank row between the rows, and a blank row above and below."""
|
||
screen = _screen(_hub_topo(4))
|
||
rows = screen._fan_rows(11, 5)
|
||
assert rows == [1, 3, 5, 7, 9] # 2n+1 = 11 rows exactly: 1 top, 1 bottom, 1 between
|
||
# The focus is level with the middle of the block, not the middle of the canvas. Thus the
|
||
# edges that leave it fan out in a symmetric way.
|
||
_fx, fy = screen._focus_pos(80, 11, 5)
|
||
assert fy >> 2 == rows[len(rows) // 2]
|
||
# A fan with fewer nodes than rows keeps the space. The block is centred in the rows.
|
||
assert screen._fan_rows(11, 3) == [3, 5, 7]
|
||
|
||
|
||
def test_walk_fan_gives_up_its_outer_rows_before_it_gives_up_a_node() -> None:
|
||
"""A blank row is worth a row until it costs a node. Below seven, the padding goes."""
|
||
screen = _screen(_hub_topo(4))
|
||
# Tall enough for seven with a blank row at the top and bottom (2·7+1 = 15): keep them.
|
||
assert screen._fan_capacity(15) == 7
|
||
assert screen._fan_rows(15, 7) == [1, 3, 5, 7, 9, 11, 13]
|
||
# One row less than that: the outer rows go to the seventh marker.
|
||
assert screen._fan_capacity(14) == 7
|
||
assert screen._fan_rows(14, 7)[0] == 0
|
||
# More room: the padding grows. It is not used for more nodes than fit.
|
||
assert screen._fan_rows(19, 7)[0] == 3
|
||
|
||
|
||
def test_walk_collapsed_marker_stands_in_for_the_selected_weak_link() -> None:
|
||
"""A selected collapsed node makes the ``…`` marker be that node: mark, name, and rank."""
|
||
screen = _screen(_hub_topo(12))
|
||
screen.render_body(80)
|
||
rows = screen._rows()
|
||
capacity = screen._fan_capacity(14)
|
||
hidden = rows[capacity - 1 :]
|
||
assert len(hidden) > 1
|
||
|
||
# When nothing is selected, the marker is the ellipsis with no name. It counts the nodes
|
||
# that it holds.
|
||
plain_canvas = _plain(screen._canvas_lines(80, 14, rows[0]))
|
||
assert f"… +{len(hidden)} weaker" in plain_canvas
|
||
assert "…" in plain_canvas
|
||
|
||
# When it is selected, it has the type mark and the name of that node. It also has the
|
||
# rank of the node among the collapsed nodes, in grey. Thus the count that the label had
|
||
# is not lost.
|
||
target = hidden[1]
|
||
canvas = screen._canvas_lines(80, 14, target)
|
||
body = _plain(canvas)
|
||
# The rank is west of the mark. Thus the name still ends where each other name on the
|
||
# fan ends, and the count reads as a note on the marker.
|
||
glyph, _colour = screen._glyph(target)
|
||
assert f"(2/{len(hidden)}) {glyph} {screen._label(target)}" in body
|
||
assert "weaker" not in body
|
||
# The name keeps its own hue and the counter is grey: two runs in one row.
|
||
from meshterm.ui.theme import mark_rgb
|
||
|
||
row = next(line for line in canvas if screen._label(target) in _plain([line]))
|
||
assert _code(screen._label_rgb(target)) in row
|
||
assert _code(mark_rgb("node.unknown")) in row
|
||
|
||
|
||
def _code(rgb: tuple[int, int, int]) -> str:
|
||
return f"38;2;{rgb[0]};{rgb[1]};{rgb[2]}m"
|
||
|
||
|
||
def _long_name_screen(names: list[str]) -> WalkScreen:
|
||
"""Us at the centre of a fan of `names`, with the strongest first."""
|
||
contacts = [
|
||
Contact(name=n, public_key=f"{i + 0x20:02x}" * 32, node_type=2) for i, n in enumerate(names)
|
||
]
|
||
topo = MeshTopology(US, contacts=contacts)
|
||
when = utcnow()
|
||
for i, c in enumerate(contacts):
|
||
node = topo.canonical(c.public_key)
|
||
for _ in range(len(contacts) - i):
|
||
topo.add_walk([topo.self_id, node], snrs=[8.0 - i], when=when, source="trace")
|
||
screen = WalkScreen(
|
||
session=_FakeSession(),
|
||
topo=topo,
|
||
contacts={topo.canonical(c.public_key): c for c in contacts},
|
||
self_label="Homestead",
|
||
)
|
||
screen.note_viewport(24)
|
||
return screen
|
||
|
||
|
||
def test_walk_only_the_long_named_marker_gives_up_reach_for_its_name() -> None:
|
||
"""A long name makes its own row lose some reach to the east. The rest of the fan stays."""
|
||
long_name = "Sensor-Rooftop-Longueuil-East" # 29 cells: more than any fixed margin
|
||
screen = _long_name_screen([long_name, "Bob", "Carol"])
|
||
body = _plain(screen._canvas_lines(80, 11, None))
|
||
assert long_name in body # written whole, with no trailing …
|
||
|
||
nodes = screen._fan_nodes()
|
||
placed = screen._place_neighbours(80, 11, nodes, False)
|
||
# The same fan with a short name in that slot: only the marker with the long name moved.
|
||
short = _long_name_screen(["Ann", "Bob", "Carol"])
|
||
baseline = short._place_neighbours(80, 11, short._fan_nodes(), False)
|
||
assert placed[nodes[0]][0] < baseline[short._fan_nodes()[0]][0]
|
||
assert [placed[n][0] for n in nodes[1:]] == [baseline[n][0] for n in short._fan_nodes()[1:]]
|
||
|
||
|
||
def test_walk_fan_keeps_its_reach_when_a_name_would_swallow_the_canvas() -> None:
|
||
"""Past a limit, the labels are clipped instead. A fan on top of the focus draws nothing."""
|
||
screen = _long_name_screen(["X" * 60, "Bob", "Carol"])
|
||
ax, _ay, _n = screen._focus_anchor(53, 11, 3)
|
||
placed = screen._place_neighbours(53, 11, screen._fan_nodes(), False)
|
||
assert min(x for x, _y in placed.values()) > ax # still a fan, not a pile on the anchor
|
||
assert "…" in _plain(screen._canvas_lines(53, 11, None)) # the label gave space instead
|
||
|
||
|
||
def test_walk_collapsed_slot_is_laid_out_for_its_resting_label_not_the_selection() -> None:
|
||
"""A walk over the collapsed nodes must not move the fan, so the … slot ignores them.
|
||
|
||
The geometry of the slot comes from the ``+n weaker`` that it shows at rest. The name of
|
||
a stand-in node is fitted into the room that remains, and it is truncated if it must be.
|
||
"""
|
||
screen = _long_name_screen(
|
||
["Ann", "Bob", "Carol", "Dee", "Eve", "Fay", "Gil", "Hal", "Sensor-Rooftop-Longueuil"]
|
||
)
|
||
screen.render_body(80)
|
||
rows = screen._rows()
|
||
hidden = rows[screen._fan_capacity(11) - 1 :]
|
||
assert len(hidden) > 1
|
||
|
||
at_rest = screen._canvas_lines(80, 11, rows[0])
|
||
marks = [_plain([line]).index("…") for line in at_rest if "…" in _plain([line])]
|
||
for node in hidden:
|
||
moved = screen._canvas_lines(80, 11, node)
|
||
glyph, _c = screen._glyph(node)
|
||
row = next(line for line in moved if screen._label(node)[:8] in _plain([line]))
|
||
assert _plain([row]).index(glyph) == marks[0] # the marker did not move
|
||
|
||
|
||
def test_walk_the_highlighted_row_is_the_node_the_canvas_lights() -> None:
|
||
"""The list and the picture read the same index from the same list, at each position.
|
||
|
||
The list was once rendered directly from the link table, but the highlight indexed the
|
||
rows. The table still had the node that the walk came from. Thus, from the position of
|
||
that node, each row named one link and drew another link. The highlight was one link
|
||
away from the link that the canvas lit.
|
||
"""
|
||
topo = _hub_topo(6)
|
||
screen = _screen(topo)
|
||
screen.render_body(80)
|
||
screen._index = 1
|
||
screen.handle("enter") # walk out, so there is a came-from node to leave out
|
||
screen.render_body(80)
|
||
|
||
rows = screen._rows()
|
||
assert topo.self_id not in rows # …and it is not in the rows
|
||
for index in range(len(rows)):
|
||
screen._index = index
|
||
body = _plain(screen.render_body(80))
|
||
picked = next(line for line in body.split("\n") if line.startswith("❯"))
|
||
assert screen._label(rows[index]) in picked
|
||
# Also, the canvas lights that same node, not its neighbour in the table.
|
||
canvas = _plain(screen._canvas_lines(80, 12, rows[index]))
|
||
assert screen._label(rows[index]) in canvas
|
||
|
||
|
||
def test_walk_collapsed_stand_in_wears_the_selection_white() -> None:
|
||
"""A collapsed node is drawn only while it is selected, so its colour is white."""
|
||
# The contacts have names, so the hue of the name is different from white.
|
||
screen = _long_name_screen([f"Node-{i:02d}" for i in range(12)])
|
||
screen.render_body(80)
|
||
rows = screen._rows()
|
||
target = rows[screen._fan_capacity(14) - 1 + 1]
|
||
assert screen._label_rgb(target) != (255, 255, 255)
|
||
|
||
canvas = screen._canvas_lines(80, 14, target)
|
||
row = next(line for line in canvas if screen._label(target) in _plain([line]))
|
||
assert _code((255, 255, 255)) in row # the same white that the selection of a drawn marker has
|
||
assert _code(screen._label_rgb(target)) not in row
|
||
|
||
|
||
def test_walk_link_cursor_clamps_at_both_ends() -> None:
|
||
"""The list window of the links does not wrap.
|
||
|
||
The ↑ key on the first row and the ↓ key past the last row stay where they are. They do
|
||
not move the window from one end to the other.
|
||
"""
|
||
screen = WalkScreen(session=_FakeSession(), topo=_hub_topo(14), contacts={}, self_label="us")
|
||
screen.note_viewport(20)
|
||
screen.render_body(80)
|
||
last = len(screen._rows()) - 1
|
||
screen.handle("up")
|
||
assert screen._index == 0
|
||
for _ in range(last + 5):
|
||
screen.handle("down")
|
||
assert screen._index == last
|