Files
MeshTerm/tests/test_records_screen.py
Jean-Pierre MartineauandClaude Haiku 5.5 eb2d314551 Section headings carry no icon
Chat's Channels, Rooms, and Direct headings and the trophy case's
discipline headings drop their emoji, for a cleaner look. The trophy
marks stay on the delete list's rows and the record page's header.
section_heading no longer strips icons, since no heading has one.

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
2026-10-15 23:56:36 -04:00

945 lines
41 KiB
Python
Raw Permalink Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# SPDX-License-Identifier: Apache-2.0
"""Trophy-case dialog tests: one record's full story, headless.
These tests work like the other screen tests. ``render_body`` only returns lines, and
``handle`` only changes state. Thus the tests can check the stats lanes, the route, and the
renamed *Trace this path* action without a terminal.
"""
from __future__ import annotations
import asyncio
import io
import re
from datetime import datetime, timezone
from pathlib import Path
import pytest
from rich.cells import cell_len
from rich.console import Console
from meshterm.context import AppContext
from meshterm.core.admin_store import AdminStore
from meshterm.core.config import Settings
from meshterm.core.device_store import DeviceStore
from meshterm.core.models import NODE_TYPE_REPEATER, Contact, HeardNode
from meshterm.persistence.repository import DiscoveredPath, Repository
from meshterm.services.records import CATEGORIES, CATEGORY_BY_ID
from meshterm.ui.records_screen import (
RecordScreen,
WalkVertex,
discipline_label,
discipline_lane,
node_geo_lookup,
open_records,
)
from meshterm.ui.surface import TuiUi
from meshterm.ui.tui import frame
from meshterm.ui.tui.prompt import TypedConfirmDialog
from meshterm.ui.tui.screen import CANCEL
from meshterm.ui.tui.select import SelectScreen
from meshterm.ui.tui.session import TuiSession
from meshterm.ui.widgets import node_marker, self_marker
from tests.conftest import plain as _plain # the only function that strips and joins a screen
HUB_ID, FAR_ID = "3d63c6429436", "f2c24f54551e"
def _loop_shape() -> list[WalkVertex]:
"""A circuit of three points (us and two hops with positions). It encloses an area."""
sglyph, scolor = self_marker()
hglyph, hcolor = node_marker(2) # a repeater
nglyph, ncolor = node_marker(1) # a plain node
return [
WalkVertex(0.0, 0.0, sglyph, scolor, True),
WalkVertex(2.0, 1.0, hglyph, hcolor, False, label=HUB_ID[:2]),
WalkVertex(1.0, 3.0, nglyph, ncolor, False, label=FAR_ID[:2]),
]
def _resolve(hop: str) -> str:
return {HUB_ID: "Hilltop-Repeater", FAR_ID: "Far"}.get(hop, hop)
def _record(**kw) -> DiscoveredPath:
defaults = dict(
id=1,
category="grand_tour",
width_bytes=1,
spec="3d,f2",
route=(HUB_ID, FAR_ID),
score=2.0,
stats={
"hop_count": 2,
"distinct_nodes": 2,
"repeats": False,
"min_snr": 6.0,
"km_travelled": 3.2,
"km_complete": True,
"far_km": 1.5,
"rtt_ms": 250.0,
},
app_version="0.1.0",
discovered_at=datetime(2026, 7, 12, 14, 30, tzinfo=timezone.utc),
)
defaults.update(kw)
return DiscoveredPath(**defaults)
def _dialog(record: DiscoveredPath, rank: int = 1, **kw) -> RecordScreen:
return RecordScreen(
record,
CATEGORY_BY_ID[record.category],
rank,
resolve=_resolve,
device_label="Homestead",
device_hash=None,
**kw,
)
def test_record_dialog_shows_stats_route_and_the_trace_action() -> None:
"""The score is in the unit of the category, the route has names and us at both ends."""
screen = _dialog(_record())
info = _plain(screen.render_body(60))
assert "2 nodes" in info # the score, in the unit of the discipline
assert "Trace this path" in info # the renamed action (it was "Walk again")
assert "Walk again" not in info
assert "Delete record" in info
assert "Back" not in info # no exit row: the Esc key leaves the page
screen.handle("tab")
route = _plain(screen.render_body(60))
assert "Hilltop-Repeater" in route # a relay with a name on the route
assert "Homestead" in route # us, at both ends of the route that was walked
assert "Trace this path" in route and "Delete record" not in route # Enter here traces
def test_record_dialog_draws_the_walk_as_a_route_graph() -> None:
"""The screen draws the walk on the route graph: us as a star at both ends, over a caption."""
screen = _dialog(_record())
screen.note_viewport(40) # the frame records this before each real paint
screen.handle("tab") # the Route tab
lines = _plain(screen.render_body(60)).splitlines()
legend = next(i for i, line in enumerate(lines) if "▲ repeater" in line)
assert "you → … → you" in lines[legend - 1] and "labels = hash byte" in lines[legend - 1]
graph = "\n".join(lines[_strip_end(lines) : legend - 1]) # the drawing, up to its caption
assert graph.count("★") == 2 # our node marks both endpoints of the round trip
assert any("⠀" <= ch <= "⣿" for ch in graph) # the edges are drawn in braille
def test_record_graph_is_only_as_tall_as_one_lane_needs() -> None:
"""A walk is one path: the marker row, and a label row only where a label is placed.
The shared widget has a default minimum height of 5 rows. It keeps room for a fan of
alternatives, but a walk never fans. Thus the walk was in rows of blank canvas, and it
moved the route and the actions down the card with no benefit.
"""
for record in (_record(), _record(route=(HUB_ID, FAR_ID, HUB_ID, FAR_ID, HUB_ID))):
lines = _dialog(record)._graph_lines(60) # for any number of relays: still one lane
assert 0 < len(lines) <= 3
assert all(_plain([line]).strip() for line in lines) # no blank row in the lines
def test_record_dialog_route_runs_unlabelled_across_the_whole_card() -> None:
"""The route is the path widget at full width, with no ``route`` lane of twelve cells.
The caption of the graph above is ``you → … → you``, so the line is the route. The label
lane only took cells from the hops. The line wraps at hop boundaries, so a long walk
folds and is not truncated. Our own two ends use the ★ that the graph above already
uses for us.
"""
dialog = _dialog(_record(route=tuple([HUB_ID, FAR_ID] * 4)))
dialog.note_viewport(40) # the frame records this before each real paint
dialog.handle("tab") # the Route tab
body = _plain(dialog.render_body(60)).splitlines()
# The route closes the stage of the Route tab. It runs from under the legend of the
# graph to the blank line before the action row.
legend_at = next(i for i, line in enumerate(body) if "▲ repeater" in line)
route = body[legend_at + 1 : body.index("", legend_at)]
assert not route[0].startswith("route") # no label lane
assert route[0].startswith("★") # our end opens the walk with the star of the app…
assert route[-1].endswith("★") # …and closes it with the same star
assert "Homestead" not in "".join(route) # never our name, and never our key
assert "Hilltop-Repeater" in "".join(route) # the hops have their full names
assert len(route) > 1 # it folded and was not truncated…
# …and each fold hangs under the step.
assert all(line.startswith(" ") for line in route[1:])
def test_record_dialog_shows_the_node_type_legend() -> None:
"""The standard node-type legend is under the graph, so that the user can read its markers."""
screen = _dialog(_record())
screen.note_viewport(40) # the frame records this before each real paint
screen.handle("tab") # the Route tab
body = _plain(screen.render_body(60))
assert "★ you" in body and "▲ repeater" in body and "◉ sensor" in body
def test_record_dialog_legend_is_one_line_on_a_handheld_and_breaks_between_entries() -> None:
"""At 53 cells the whole legend fits on one line. When narrower, a mark stays with its name.
The word ``room`` instead of ``room server`` makes the legend 52 cells, which is inside
the 53 cells of the PicoCalc and the Cardputer. Where the legend must still break, it
breaks between entries. A break inside an entry leaves a glyph at the end of a line and
its name at the start of the next line.
"""
screen = _dialog(_record())
screen.note_viewport(40) # the frame records this before each real paint
screen.handle("tab") # the Route tab
entries = ("★ you", "▲ repeater", "● companion", "■ room", "◉ sensor")
body = _plain(screen.render_body(53)).splitlines()
assert any(all(entry in line for entry in entries) for line in body)
assert not any("room server" in line for line in body)
narrow = _plain(screen.render_body(40))
for entry in entries:
assert entry in narrow
assert all(cell_len(line) <= 40 for line in narrow.splitlines())
def test_record_dialog_legend_steps_aside_on_a_short_frame() -> None:
"""On the 11 rows of the Cardputer, the Route tab keeps its graph and route, not the legend."""
screen = _dialog(_record())
screen.handle("tab") # the Route tab
screen.note_viewport(11)
body = _plain(screen.render_body(53))
assert "you → … → you" in body and "▲ repeater" not in body
def test_record_graph_marks_a_repeater_relay_with_its_triangle() -> None:
"""A relay of the repeater type draws ▲, not the generic ● dot."""
dialog = _dialog(_record(), type_of=lambda h: 2 if h == HUB_ID else None)
graph = _plain(dialog._graph_lines(60))
assert "▲" in graph # the repeater relay, with its glyph from the map
def test_record_graph_collapses_a_revisited_route_to_distinct_nodes() -> None:
"""A revisited route collapses to its distinct nodes in the record graph.
A boomerang route turns back. The depth graph cannot stack a node on itself, so it
places each node once. Thus it draws the same graph as the distinct route.
"""
looping = _dialog(_record(route=(HUB_ID, FAR_ID, HUB_ID)))
distinct = _dialog(_record(route=(HUB_ID, FAR_ID)))
assert looping._graph_lines(60) == distinct._graph_lines(60)
graph = _plain(looping._graph_lines(60))
assert "3d" in graph and "f2" in graph # both distinct relays have a label, none is piled
def test_record_dialog_title_names_the_discipline_and_rank() -> None:
"""The title bar shows only the type of page. The header line names the record."""
screen = _dialog(_record(), rank=3)
assert screen.title == "Record"
assert (
_plain(screen.render_body(60)).splitlines()[0].endswith("Most nodes — 3rd place: 2 nodes")
)
def test_record_dialog_marks_incomplete_distance_as_a_lower_bound() -> None:
"""A Longest-distance walk with a hop that has no position shows ``≥``, never an exact value."""
record = _record(
category="long_haul",
score=8.4,
stats={"km_travelled": 8.4, "km_complete": False, "hop_count": 3, "distinct_nodes": 3},
)
body = _plain(_dialog(record).render_body(60))
assert "≥ 8.4 km" in body
def test_record_dialog_enter_commits_the_trace_action() -> None:
"""The highlight starts on Trace this path, so the Enter key returns ``"trace"``."""
dialog = _dialog(_record())
captured: list = []
dialog.resolve = captured.append # type: ignore[method-assign]
assert dialog._actions[dialog._index] == "trace"
dialog.handle("enter")
assert captured == ["trace"]
def test_record_dialog_down_moves_to_delete() -> None:
"""The ↑ and ↓ keys move through the action rows in order."""
dialog = _dialog(_record())
dialog.handle("down")
assert dialog._actions[dialog._index] == "delete"
def test_record_dialog_actions_start_both_labels_in_the_same_cell() -> None:
"""*Trace this path* and *Delete record…* share one icon column, on both platforms.
``👣`` is two cells and ``🗑`` is one. When each mark was measured alone, the words of
the delete row started one column to the left of the words of the trace row. The test
measures in cells, not characters, because the eye compares the cell where the words
start. If the platform draws no icon lane, the marks go and their padding must go too.
Then both labels start right after the pointer.
"""
from meshterm.platforms import PICOCALC_LYRA, set_platform
def starts() -> set[int]:
lines = _plain(_dialog(_record()).render_body(60)).splitlines()
rows = [
line[: line.index(word)]
for word in ("Trace this path", "Delete record…")
for line in lines
if word in line
]
assert len(rows) == 2, lines
return {cell_len(head) for head in rows}
regular = starts()
assert len(regular) == 1 # one column for both words…
assert regular == {2 + 2 + 1} # …after the pointer, the lane of two cells, and its space
set_platform(PICOCALC_LYRA)
assert starts() == {2} # no lane, no padding that remains: the words follow the pointer
def test_record_dialog_names_the_far_point() -> None:
"""The name of the node that the walk reached is next to the far-point distance."""
body = _plain(_dialog(_record(), far_label="Far").render_body(60))
assert re.search(r"far point\s+1\.5 km\s+Far", body)
def test_record_dialog_names_the_link_the_longest_leg_spanned() -> None:
"""The leg lane has its distance and the two nodes between which the leg crossed."""
record = _record(
category="long_leg",
score=12.4,
stats={
"hop_count": 2,
"distinct_nodes": 2,
"km_travelled": 21.0,
"km_complete": True,
"leg_km": 12.4,
"leg_link": [HUB_ID, FAR_ID],
},
)
body = _plain(_dialog(record).render_body(60))
assert re.search(r"longest leg\s+12\.4 km\s+Hilltop-Repeater → Far", body)
def test_record_dialog_stars_our_own_end_of_the_longest_leg() -> None:
"""A leg that leaves home has the ★ of the app at our end, never our name."""
record = _record(
category="long_leg",
score=12.4,
stats={"hop_count": 1, "distinct_nodes": 1, "leg_km": 12.4, "leg_link": [None, HUB_ID]},
)
lane = next(
line
for line in _plain(_dialog(record).render_body(60)).splitlines()
if "longest leg" in line
)
assert "★ → Hilltop-Repeater" in lane and "Homestead" not in lane
def test_record_dialog_skips_the_leg_lane_for_a_record_stored_without_one() -> None:
"""A record that was set before the discipline existed has no leg to show."""
body = _plain(_dialog(_record()).render_body(60))
assert "longest leg" not in body
def test_record_dialog_shows_reliability_when_the_far_node_has_history() -> None:
"""The reliability lane shows the percentage and the sample count.
Without history, the lane is absent.
"""
with_rate = _plain(_dialog(_record(), reliability=(0.875, 7, 8)).render_body(60))
assert re.search(r"reliability\s+88%", with_rate)
assert "7/8" in with_rate
without = _plain(_dialog(_record()).render_body(60))
assert "reliability" not in without
def _is_braille(ch: str) -> bool:
return chr(0x2800) <= ch <= chr(0x28FF)
def _strip_end(lines: list[str]) -> int:
"""The index after the rows of the tab strip.
A strip of two tabs closes on its bottom rule. This is the one row that turns up into
the wall of the active tab (``╯``). A lone tab collapses to a ``── Stats ──`` heading of
one row.
"""
for i, line in enumerate(lines):
if "╯" in line or "── " in line:
return i + 1
raise AssertionError(f"no tab strip in {lines[:5]}")
def test_record_opens_on_info_with_route_and_area_tabs_beside_it() -> None:
"""A walk that the screen can draw has three tabs (Info, Route, and Area) and opens on Info.
The organization is the same as on the node page. The strip is the title of the page, so
the stats start directly under it (identity first), and the actions of the page are at
the foot. The screen does not draw the route or the ground on this tab, because each has
a tab of its own.
"""
screen = _dialog(_record(), far_label="Far", shape=_loop_shape())
lines = _plain(screen.render_body(58)).splitlines()
strip = "\n".join(lines[: _strip_end(lines)])
assert "Info" in strip and "Route" in strip and "Area" in strip, "all three boxed"
body = [line for line in lines[_strip_end(lines) :] if line]
assert body[0].startswith("spec") and body[1].startswith("recorded") # identity first
assert body[2].startswith("walk") # the score is in the header, not in a lane
assert "Trace this path" in "\n".join(body) and "Delete record" in "\n".join(body)
assert not any(_is_braille(ch) for line in body for ch in line), "no drawing here"
assert "Hilltop-Repeater" not in "\n".join(body), "the route has a tab of its own"
assert "Far" in "\n".join(body) # the far-point name is a stat, not a label of the drawing
def test_record_header_names_the_discipline_the_standing_and_the_metric() -> None:
"""One line above the tabs: ``<mark> <discipline> — <nth> place: <score>``.
This is the identity header of the node page, for a record. The line writes the
discipline in the same way as its row in the delete list. The mark has padding to the measured
lane, so a ``🛣`` of one cell and a ``🎯`` of two cells start their titles in the same
cell. The score lane that repeated the metric on Info does not exist now.
"""
screen = _dialog(_record(), shape=_loop_shape())
lines = _plain(screen.render_body(58)).splitlines()
grand_tour = CATEGORY_BY_ID["grand_tour"]
expected = f"{discipline_label(grand_tour, discipline_lane())} — 1st place: 2 nodes"
assert lines[0] == expected # the first row, before the space above the strip
assert "score" not in _plain(screen.render_body(58))
for _ in range(3): # the header stays in place on each tab
screen.handle("tab")
assert _plain(screen.render_body(58)).splitlines()[0] == expected
partial = _record(
category="long_haul",
score=8.4,
stats={"km_travelled": 8.4, "km_complete": False, "hop_count": 3, "distinct_nodes": 3},
)
head = _plain(_dialog(partial, rank=5).render_body(58)).splitlines()[0]
assert head.endswith("Longest distance — 5th place: ≥ 8.4 km") # the board spelling of a bound
# The mark lane is the lane of the delete list: the titles of the two marks start in one cell.
long_haul = CATEGORY_BY_ID["long_haul"]
assert cell_len(discipline_label(long_haul, discipline_lane()).split("Longest")[0]) == cell_len(
discipline_label(grand_tour, discipline_lane()).split("Most")[0]
)
def test_record_route_tab_draws_the_walk_and_enter_traces_it() -> None:
"""The Route tab has the graph over the route line, with Trace this path as its one row.
The rule of the node page for its Routes tab is that Enter on the visible route arms its
trace. Thus the tab has that action only. The Delete action of the page stays on Info.
"""
screen = _dialog(_record(), far_label="Far", shape=_loop_shape())
screen.note_viewport(40) # the frame records this before each real paint
screen.handle("tab")
lines = _plain(screen.render_body(58)).splitlines()
stage = [line for line in lines[_strip_end(lines) :] if line]
assert "▲ repeater" in "\n".join(stage) # the legend of the graph
assert any(line.startswith("★") and line.endswith("★") for line in stage) # the route line
assert stage[-1].endswith("Trace this path — reopen in Trace path")
assert "Delete record" not in "\n".join(stage)
assert "spec" not in "\n".join(stage), "the stats have a tab of their own"
captured: list = []
screen.resolve = captured.append # type: ignore[method-assign]
screen.handle("enter")
assert captured == ["trace"]
def test_record_area_tab_fills_the_stage_with_hash_byte_pins() -> None:
"""⇧Tab turns to the Area tab: the ground, with pins and labels, sized to the viewport.
The drawing was once beside the stats, in a third of the width, with no labels, because
there was no cell for a label. Now it has its own tab and uses all the rows that the strip
leaves. Thus the user can match a pin with the graph node on the Route tab by the shared
byte, and a taller terminal gives a bigger shape.
"""
screen = _dialog(_record(), far_label="Far", shape=_loop_shape())
screen.note_metrics(40, 24)
screen.handle("shift_tab") # Info → Area, the last tab
lines = _plain(screen.render_body(58)).splitlines()
stage = [line for line in lines[_strip_end(lines) :] if line]
block = "\n".join(stage)
assert any(_is_braille(ch) for ch in block) # the area, in braille
assert "★" in block # us, pinned at the origin
assert "▲" in block # the repeater vertex, with its glyph from the map
assert HUB_ID[:2] in block and FAR_ID[:2] in block # the hash byte of each hop is by its pin
assert stage[-1] == "north up · labels = hash byte" # the caption explains the drawing
assert "Trace this path" not in block # a picture: no action rows
assert len(lines) <= 24, "the Area tab never outgrows the viewport"
screen.note_metrics(40, 40)
taller = _plain(screen.render_body(58)).splitlines()
assert len(taller) > len(lines), "a taller viewport is a taller drawing"
screen.handle("tab") # …and round to Info again
assert "Delete record" in _plain(screen.render_body(58))
def test_record_area_drawing_spends_the_width_it_is_given() -> None:
"""The drawing is as wide as the page. A wider page gives a bigger shape, not padding."""
def drawn_width(width: int) -> int:
screen = _dialog(_record(), shape=_loop_shape())
# Enough rows, so that the proportions of the shape depend on the width, not the height.
screen.note_metrics(40, 40)
screen.handle("shift_tab") # the Area tab
lines = _plain(screen.render_body(width)).splitlines()
return max(len(line.rstrip()) for line in lines[_strip_end(lines) : -1])
assert drawn_width(72) > drawn_width(40)
def test_record_area_tab_says_so_when_too_narrow_to_draw() -> None:
"""Below the minimum width of the drawing, the Area tab says why it is empty."""
screen = _dialog(_record(), far_label="Far", shape=_loop_shape())
screen.handle("shift_tab") # the Area tab
body = _plain(screen.render_body(12))
assert not any(_is_braille(ch) for ch in body)
assert "no room" in body # cut at the edge like each row, but it is there
_SEEN = datetime(2026, 10, 1, tzinfo=timezone.utc)
def _heard(node: str, lat: float | None, lon: float | None) -> HeardNode:
return HeardNode(
node=node, name=None, count=1, median_snr=None, best_snr=None, last_rssi=None,
last_seen=_SEEN, lat=lat, lon=lon, node_type=NODE_TYPE_REPEATER,
) # fmt: skip
def test_the_area_drawing_puts_a_contact_position_over_a_heard_one() -> None:
"""A contact's position wins over a heard advert's position.
The trace tools score a walk with the contacts put over the heard adverts, and the name
resolver also puts the contacts first. The area drawing once took the heard position
first. Thus a node whose heard advert and contact disagreed was drawn at a place that
the score did not measure.
"""
heard = [_heard(HUB_ID, 45.40, -73.90)]
contacts = [Contact(name="Hub", public_key=HUB_ID + "0" * 52, lat=45.55, lon=-73.60)]
pos, _ = node_geo_lookup(heard, contacts)(HUB_ID)
assert pos == (45.55, -73.60)
def test_the_area_drawing_falls_back_to_the_heard_position() -> None:
"""A contact with no position, or at 0,0, leaves the heard position in use."""
heard = [_heard(HUB_ID, 45.40, -73.90), _heard(FAR_ID, 46.10, -74.20)]
contacts = [
Contact(name="Hub", public_key=HUB_ID + "0" * 52),
Contact(name="Far", key_prefix=FAR_ID, lat=0.0, lon=0.0),
]
lookup = node_geo_lookup(heard, contacts)
assert lookup(HUB_ID) == ((45.40, -73.90), NODE_TYPE_REPEATER)
assert lookup(FAR_ID)[0] == (46.10, -74.20)
assert lookup("0123456789ab") == (None, None)
def test_record_without_a_shape_has_no_area_tab() -> None:
"""If no hop has a position, there is no Area tab. Info and Route still switch."""
screen = _dialog(_record())
lines = _plain(screen.render_body(60)).splitlines()
strip = "\n".join(lines[: _strip_end(lines)])
assert "Info" in strip and "Route" in strip and "Area" not in strip
screen.handle("shift_tab")
assert screen._tab == "Route"
def test_record_footer_names_only_what_each_tab_answers() -> None:
"""Each tab hints only the keys that it answers.
Info hints the cursor keys. Route hints only Enter (it has one row). Area hints only the
switch and Esc.
"""
screen = _dialog(_record(), shape=_loop_shape())
assert screen.footer_hint == "Tab/⇧Tab switch · ↑↓ move · Enter select · Esc back"
screen.note_metrics(40, 10) # taller than the viewport: the pager has its atom
assert "PgUp/PgDn scroll" in screen.footer_hint
screen.handle("tab")
screen.note_metrics(10, 24)
assert screen.footer_hint == "Tab/⇧Tab switch · Enter select · Esc back"
screen.handle("tab")
assert screen.footer_hint == "Tab/⇧Tab switch · Esc back"
screen.handle("down") # inert on the Area tab
assert screen._index == 0
def test_record_dialog_scrolls_free_of_the_action_cursor() -> None:
"""PgUp, PgDn, Home, and End scroll the card. The highlight of the actions does not follow."""
dialog = _dialog(_record(), shape=_loop_shape())
dialog.note_metrics(40, 10) # a body taller than the viewport
assert dialog.cursor_line() is None # opens at the top, free of the highlight
dialog.handle("pagedown")
assert dialog.scroll > 0 and not dialog._follow
dialog.handle("end")
assert dialog.scroll == 30 # total 40 − viewport 10
dialog.handle("home")
assert dialog.scroll == 0
def test_record_dialog_arrows_follow_the_action_cursor() -> None:
"""An arrow key starts the highlight-follow again, so the selected action stays visible."""
dialog = _dialog(_record())
dialog.handle("pagedown") # into free scroll
assert not dialog._follow
dialog.handle("down")
assert dialog._follow
dialog.render_body(60) # the frame renders before it reads the cursor
assert dialog.cursor_line() is not None
# --- the browser loop: dialogs float over the trophy case, never over a blank frame ---
@pytest.fixture()
def tui_ctx(tmp_path: Path) -> AppContext:
"""A context with a mock backend and a headless TUI session (no prompt_toolkit app)."""
settings = Settings(config_dir=tmp_path, db_path=tmp_path / "records.db")
ctx = AppContext(
console=Console(file=io.StringIO()),
settings=settings,
repo=Repository(settings.db_path),
device_store=DeviceStore(tmp_path / "devices.json"),
admin_store=AdminStore(tmp_path / "admin.json"),
mock=True,
)
ctx.ui = TuiUi(TuiSession())
yield ctx
ctx.repo.close()
async def _step_until(predicate, *, limit: int = 200):
"""Yield to the event loop until ``predicate()`` is true. Return its (last) value."""
value = predicate()
for _ in range(limit):
if value:
return value
await asyncio.sleep(0)
value = predicate()
return value
def _trophy_case(session, *, other_than=None):
"""The trophy-case browser on top of the stack, if it is the top screen."""
top = session.top
if isinstance(top, SelectScreen) and top.title == "Trophy case" and top is not other_than:
return top
return None
async def test_the_trophy_case_fills_the_frame_over_a_trace_too(tui_ctx) -> None:
"""The trophy case is a place, so it is full-frame from each way in.
From the menu, it is the only screen, and the fallback of the compositor drew it
full-frame. When the user opened it from a trace, the trace screen was still pushed,
and the same list came up as a box that fits its content. The reason is that a bare
``SelectScreen`` floats by default.
"""
from meshterm.ui.tui.screen import ScrollScreen
ctx = tui_ctx
session = ctx.ui.session
trace = ScrollScreen("", title="Trace — Lakeside", floating=False)
session.push(trace)
task = asyncio.ensure_future(open_records(ctx))
try:
browser = await _step_until(lambda: _trophy_case(session))
assert browser is not None, "the trophy case list never opened"
assert not browser.floating
assert session._base_screen() is browser, "the page fills the frame, not a box over it"
assert not session._has_float()
browser.handle("escape")
await task
assert session._base_screen() is trace, "and Esc lands back on the trace"
finally:
if not task.done():
task.cancel()
session.pop(trace)
async def test_delete_all_confirm_floats_over_the_browser(tui_ctx) -> None:
"""The trophy case stays drawn full-frame behind the "Delete all records" confirm.
Regression: the code ran the browser and then popped it before the confirm opened. Thus
the confirm floated over a blank frame, and the background was "cleared". Now the
browser never leaves the stack (``session.stay``). It is the full-frame base while the
red typed-delete gate floats over it. When the user backs out, the same screen returns,
with its highlight, and not a new screen.
"""
ctx = tui_ctx
session = ctx.ui.session
ctx.repo.record_discovery(
"grand_tour",
1,
"3d,f2",
(HUB_ID, FAR_ID),
score=2.0,
stats={"hop_count": 2, "distinct_nodes": 2},
app_version="0.1.0",
)
task = asyncio.ensure_future(open_records(ctx))
try:
browser = await _step_until(lambda: _trophy_case(session))
assert browser is not None, "the trophy case list never opened"
browser.resolve(("del_all", None, 0, None)) # select "Delete all records"
confirm = await _step_until(
lambda: session._float_layers()[0] if session._has_float() else None
)
assert isinstance(confirm, TypedConfirmDialog) # the red typed-delete gate
assert session._base_screen() is browser # the browser is still the full-frame base
confirm.resolve(CANCEL) # the Esc key: back out without a delete
again = await _step_until(lambda: _trophy_case(session))
assert again is browser, "nothing was deleted, so the same screen carries on"
again.handle("escape") # the Esc key leaves the trophy case
result = await task
finally:
if not task.done():
task.cancel()
assert result == {"records": 1} # the user backed out, so the record remains
async def test_delete_a_disciplines_records_is_a_popup_over_the_browser(tui_ctx) -> None:
"""The choice of a discipline to delete floats a dialog over the browser, not a full list.
The discipline picker is a floating :class:`SelectScreen`. The browser stays on the
stack, so the picker draws as a centred dialog over the visible trophy case
(``_base_screen`` stays the browser). It does not replace the whole frame.
"""
ctx = tui_ctx
session = ctx.ui.session
ctx.repo.record_discovery(
"grand_tour",
1,
"3d,f2",
(HUB_ID, FAR_ID),
score=2.0,
stats={"hop_count": 2, "distinct_nodes": 2},
app_version="0.1.0",
)
task = asyncio.ensure_future(open_records(ctx))
try:
browser = await _step_until(lambda: _trophy_case(session))
assert browser is not None
browser.resolve(("del_cat", None, 0, None)) # "Delete a discipline's records…"
picker = await _step_until(
lambda: session._float_layers()[0] if session._has_float() else None
)
assert isinstance(picker, SelectScreen) # a floating dialog, not the frame
assert picker.title == "Delete a discipline's records"
assert session._base_screen() is browser # the browser stays behind it
picker.resolve(CANCEL) # the Esc key: abandon the choice
again = await _step_until(lambda: _trophy_case(session))
assert again is browser, "the pick was abandoned, so the same screen carries on"
again.handle("escape") # the Esc key
result = await task
finally:
if not task.done():
task.cancel()
assert result == {"records": 1}
async def test_a_board_row_spends_its_cells_on_the_walk(tui_ctx) -> None:
"""A record row starts with the rank, the day, and the score, then the walk with bare ends.
Each record is a boomerang. When the row named us at both ends, it said the same thing
twice and used more cells than the whole score lane. Thus the ends are ``★``, and the
score lane has the width of the widest score of this board, not a fixed twelve.
"""
ctx = tui_ctx
session = ctx.ui.session
ctx.repo.record_discovery(
"grand_tour",
1,
"3d,f2",
(HUB_ID, FAR_ID, HUB_ID),
score=2.0,
stats={"hop_count": 3, "distinct_nodes": 2},
app_version="0.1.0",
)
task = asyncio.ensure_future(open_records(ctx))
try:
browser = await _step_until(lambda: _trophy_case(session))
assert browser is not None
label = next(
choice.label
for choice in browser._choices()
if isinstance(choice.value, tuple) and choice.value[0] == "open"
)
row = label.plain
assert row.startswith("#1 ") # the standing on the board
assert "2 nodes" in row # the score, in the unit of the discipline
assert row.count("★") == 2 # our two ends, bare: the row does not name us twice
assert "Homestead" not in row # …so the device name is not on the line
# Also, those ends are us, in the white of "you". We compose nothing here, so
# there is no "not yours" fade to use.
stars = [s for s in label.spans if row[s.start : s.end] == "★"]
assert [str(s.style) for s in stars] == ["you", "you"]
assert "3d" in row and "f2" in row # the hops, at the hash width of the record
# The lanes before the walk keep to what they must show: a score lane with the width
# of the widest score of this board (no dead padding), and the day without the minute
# that the dialog shows. Each cell that they do not use is a hop that the route can show.
assert "2 nodes ★" in row
assert ":" not in row[: row.index("★")]
finally:
if not task.done():
task.cancel()
async def test_the_board_pins_its_discipline_heading_and_description(tui_ctx) -> None:
"""When the list scrolls into a board, its ``── discipline ──`` heading and description pin.
Each discipline heading has a word-wrapped description under it. The description says
what that board scores, and this is what the user needs when the list is scrolled
part of the way down the board. The two pin as one block. When each separator was a
candidate for pinning, the last muted line under a heading won the top row alone, and
the heading never stuck. This was worst for the first discipline, because the first
scrolled row is under its description.
"""
ctx = tui_ctx
session = ctx.ui.session
for i in range(6): # one full board, so its records outlast a short viewport
ctx.repo.record_discovery(
"grand_tour",
1,
f"3d,{i:02x}",
(HUB_ID, FAR_ID),
score=float(i + 1),
stats={"hop_count": 2, "distinct_nodes": 2},
app_version="0.1.0",
)
task = asyncio.ensure_future(open_records(ctx))
try:
browser = await _step_until(lambda: _trophy_case(session))
assert browser is not None
# Only the six disciplines are landmarks. Each block starts with its own heading and
# has the description that is under it. Nothing else is a candidate.
browser.render_body(72)
blocks = [
[_plain([line]).strip() for line in rows] for _idx, rows in browser._sticky_headers
]
# A heading is the title only. The mark of the discipline stays on its rows.
assert [rows[0] for rows in blocks] == [f"── {c.title} ──" for c in CATEGORIES]
assert all(len(rows) >= 2 for rows in blocks) # each carries its description too
# A highlight deep in the one board that has records scrolls its heading off the top
# of a short viewport. The heading is the first item that pins, and its description
# is under it.
for _ in range(4):
browser.handle("down")
visible, above, _below = frame._visible_slice(browser, browser.render_body(72), 10)
board = CATEGORY_BY_ID["grand_tour"]
assert _plain([visible[0]]).strip() == f"── {board.title} ──"
assert _plain([visible[1]]).strip().startswith(board.description[:20])
assert above is True
browser.resolve(None) # the Esc key
await task
finally:
if not task.done():
task.cancel()
async def test_trace_this_path_nests_above_the_browser_and_comes_back_to_it(
tui_ctx, monkeypatch
) -> None:
"""Trace this path opens over the trophy case, and the Esc key from it lands back on the record.
The stack was once flattened. The browser closed first, and the whole flow returned when
the trace closed. This put the user on the main menu. It was an escape that the code
built by hand, from a stack that had no other way out. Navigation is a strict stack now,
so a sub-view nests like each other sub-view, and the ^W key leaves the whole excursion at
once.
"""
ctx = tui_ctx
session = ctx.ui.session
ctx.repo.record_discovery(
"grand_tour",
1,
"3d,f2",
(HUB_ID, FAR_ID),
score=2.0,
stats={"hop_count": 2, "distinct_nodes": 2},
app_version="0.1.0",
)
walked: list[str] = []
async def fake_trace_path(ctx_, spec=""): # noqa: ANN001
walked.append(spec)
# The browser is still on the stack below while the trace runs.
assert any(isinstance(s, SelectScreen) and s.title == "Trophy case" for s in session._stack)
return 0
monkeypatch.setattr("meshterm.ui.trace_screen.open_trace_path", fake_trace_path)
task = asyncio.ensure_future(open_records(ctx))
try:
browser = await _step_until(lambda: _trophy_case(session))
assert browser is not None
record = ctx.repo.discoveries("grand_tour")[0]
browser.resolve(("open", CATEGORY_BY_ID["grand_tour"], 1, record))
dialog = await _step_until(
lambda: session.top if isinstance(session.top, RecordScreen) else None
)
assert session._base_screen() is dialog, "the record is a page, full-frame"
dialog.resolve("trace") # "Trace this path"
# The trace ran and the browser is back on top. It is the same object, not a new one.
again = await _step_until(lambda: _trophy_case(session))
assert again is browser
again.handle("escape") # only the Esc key leaves the trophy case
result = await task
finally:
if not task.done():
task.cancel()
assert walked == ["3d,f2"] # the spec of the record went directly to Trace path
assert result == {"records": 1}
async def test_browser_cuts_every_row_the_way_it_cuts_the_highlighted_one(tui_ctx) -> None:
"""The browser cuts unselected walks and does not elide the middle.
The highlight only adds the shift. Both ends of the walk of a record are the same ``★``
on each row (a record is a boomerang). The ``⋯`` rescue saves the two endpoints of a
route from a right truncation. Here the rescue gives nothing back, and it uses cells
that the front of the walk needs. The front is the part that is different from row to
row. Thus each row has the whole line and the browser cuts it at the lane, the same as
the highlighted row at shift zero.
"""
ctx = tui_ctx
long_route = tuple(f"{byte:02x}c24f54551e" for byte in range(0x20, 0x2C))
for rank, route in enumerate((long_route, long_route[::-1]), start=1):
ctx.repo.record_discovery(
"grand_tour",
1,
f"{route[0][:2]},f2",
route,
score=float(20 - rank),
stats={"hop_count": len(route), "distinct_nodes": len(route)},
app_version="0.1.0",
)
task = asyncio.ensure_future(open_records(ctx))
try:
browser = await _step_until(lambda: _trophy_case(ctx.ui.session))
assert browser is not None
rows = [_plain([line]) for line in browser.render_body(72)]
walks = [row for row in rows if "★" in row]
assert len(walks) == 2, rows # one highlighted, one not
assert not any("⋯" in row for row in walks) # nothing elides the middle now
# Highlighted or not, each walk starts at our star right after the score lane…
assert all(re.search(r"#\d+ .*nodes ★ → ", row) for row in walks), walks
# …and runs on past the lane. It does not close on a second endpoint that was saved.
assert all(row.rstrip().endswith("…") for row in walks), walks
browser.resolve(None) # the Esc key
await task
finally:
if not task.done():
task.cancel()