Trophy case: the cells belong to the walk
A board of records is a list of routes, and the routes were the part with no room left. Every row spent thirty-odd cells before the first hop — `#1 Jul 12 14:30`, a score padded to a fixed twelve — and then opened the walk by naming our own node in full, `Homestead (cc)`, twice, on every row, on a screen where every record starts and ends on us by construction. So the rows draw on THE path widget with both ends bare: the app-wide ★, which the graph on the record card already plants on us and its legend already reads back as "you". The stars keep the `you` white — path_line's fade means "not yours to compose", and nothing on a board of walks already made is being composed, which is what `dim_self` now says. The lanes in front are held to what they actually say: the day the record was set (the card carries the time), and a score lane fitted to the widest score on *that* board rather than to the worst case everywhere. The record card was carrying the same kind of slack. Its route ran under a `route` label lane, hanging in the twelve cells left over — under a graph captioned "you → … → you", where the line can only be the route. It now runs unlabelled across the whole card, wrapping at hop boundaries, on the same widget as the rows, with the same ★ at both ends. And the card was three rows taller than its content. The route graph took the shared widget's five-row floor, which reserves room for a fan of alternatives; one walk is one lane and never fans, so it draws in three — and both stacked blocks now hand back the rows nothing landed on. The area drawing keeps two rows in hand for a tall shape, and a walk that comes out flat never reaches them: that, plus the graph's unused label row, is where the three blank lines under `round trip` came from. Trimming happens after the drawing, so nothing placed is ever lost. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
0a41303366
commit
65a47af037
4 files changed
+287
-45
No files matched your search
+20
-10
@@ -42,7 +42,9 @@ drawn*, so every surface the survey found can eventually route through it:
|
||||
reads identically whichever mode drew it. A surface whose route always starts and
|
||||
ends on us can ask for ``bare_self``, replacing our name and hash with the app-wide
|
||||
``★`` — the reader already knows who both ends are, and the cells belong to the hops
|
||||
that *are* news.
|
||||
that *are* news. Those stars fade where the route is being *composed* (our two ends
|
||||
are fixtures, not choices); a surface that only *shows* a walk passes ``dim_self=False``
|
||||
and keeps us in the ``you`` white.
|
||||
|
||||
Existing call sites still render through ``path_text``; migrating them here is a
|
||||
separate, per-surface pass. The composer's insertion cursor rides along as a hop in
|
||||
@@ -725,6 +727,7 @@ def path_line(
|
||||
dim_from: Optional[int] = None,
|
||||
hash_as_name: bool = False,
|
||||
bare_self: bool = False,
|
||||
dim_self: bool = True,
|
||||
cursor: Optional[int] = None,
|
||||
mode: str = "auto",
|
||||
) -> PathLine:
|
||||
@@ -759,10 +762,15 @@ def path_line(
|
||||
bare_self: Draw our own device (a ``None`` hop) as the bare
|
||||
:data:`SELF_GLYPH`. For a surface whose route *always* begins and ends on
|
||||
us, the name and hash on both ends say nothing the reader doesn't know,
|
||||
and cost the cells the hops in between need. Both stars also fade: setting
|
||||
out from us and coming home to us are fixtures of every such route, no more
|
||||
composed — or removable — than a mirrored return leg is, so they wear the
|
||||
same automatic grey.
|
||||
and cost the cells the hops in between need.
|
||||
dim_self: Fade the bare stars (the default). On a surface where the route is
|
||||
something you *compose*, setting out from us and coming home to us are
|
||||
fixtures — no more yours to choose or remove than a mirrored return leg —
|
||||
so they wear the same automatic grey, and the colour on the line belongs to
|
||||
the nodes actually being picked. Pass ``False`` where nothing is being
|
||||
composed (a record of a walk already made): there is no "not yours" to say,
|
||||
and our node then reads in the app-wide ``you`` white like everywhere else.
|
||||
A ``dim_from`` that reaches a star still fades it either way.
|
||||
cursor: Splice an editor's insertion slot (:data:`CURSOR_GLYPH`) in *at* this
|
||||
rendered-hop index — the position a chosen hop would take, so index ``0``
|
||||
opens the route and ``len`` closes it. Counted over rendered hops exactly
|
||||
@@ -779,11 +787,13 @@ def path_line(
|
||||
dim = dim_from is not None and i >= dim_from
|
||||
if hop is None:
|
||||
if bare_self:
|
||||
# Both our ends are fixed: a route leaves us and comes home to us, and
|
||||
# neither end is anybody's to compose or remove. So the stars fade like
|
||||
# every other automatic hop — the colour on the line belongs to the
|
||||
# nodes actually being chosen.
|
||||
built.append(PathHop(SELF_GLYPH, you=True, dim=True))
|
||||
# Where the route is being composed, both our ends are fixed — a walk
|
||||
# leaves us and comes home to us, and neither end is anybody's to choose
|
||||
# or remove — so the stars fade like every other automatic hop and the
|
||||
# colour belongs to the nodes actually being picked. Where nothing is
|
||||
# being composed (``dim_self=False``), that has nothing to say, and our
|
||||
# own node keeps the app-wide ``you`` white.
|
||||
built.append(PathHop(SELF_GLYPH, you=True, dim=dim_self or dim))
|
||||
continue
|
||||
note = _shorten(device_hash, hash_bytes) if (show_hash and device_hash) else None
|
||||
built.append(
|
||||
|
||||
+116
-34
@@ -6,15 +6,20 @@ opened from the main menu. Every successful trace — a *Trace target* boomerang
|
||||
(see :mod:`~meshterm.services.records`); this screen is where the survivors live.
|
||||
|
||||
* the browser groups the six disciplines, each under its heading with a one-line
|
||||
description of the game (word-wrapped when it must), then that discipline's records
|
||||
ranked best-first — dated, scored in the discipline's own unit, with the walked route
|
||||
through THE path widget. A discipline holding records at more than one hash width tags
|
||||
each row with its width, since the widths are genuinely different games;
|
||||
description of the game (word-wrapped when it must) — the two pin overhead together as
|
||||
one block while that board scrolls, so a row deep in a discipline still says which game
|
||||
it is winning and what that game scores — then the discipline's records ranked
|
||||
best-first: the day, the score in the discipline's own unit, and the walk itself on THE
|
||||
path widget with both ends bare (every record is a boomerang, so the cells go to the
|
||||
hops). A discipline holding records at more than one hash width tags each row with its
|
||||
width, since the widths are genuinely different games;
|
||||
* opening a record floats :class:`RecordDialog` — every stat the walk was measured by
|
||||
(the far point named with the node it reached), the walk drawn two ways: on THE route
|
||||
graph (the Message paths dialog's shape, us at both ends) and, beside the stats, as the
|
||||
enclosed area it swept on a braille mini-map (us and every positioned hop, coloured node
|
||||
pins, no labels); then the full route and spec, and when/by which app version it was
|
||||
graph (the Message paths dialog's shape, us at both ends, drawn no taller than one lane
|
||||
needs) and, beside the stats, as the enclosed area it swept on a braille mini-map (us and
|
||||
every positioned hop, coloured node pins, no labels); then the full route — the path
|
||||
widget again, unlabelled across the card's whole width, wrapping at hop boundaries — and
|
||||
the spec, and when/by which app version it was
|
||||
set. The card scrolls (PgUp/PgDn/Home/End) when it outgrows the terminal. From there
|
||||
*Trace this path* reopens Trace path with the record's route prefilled, so a claim
|
||||
worth re-testing is one Enter from the air again — and when that screen closes, the
|
||||
@@ -33,6 +38,7 @@ import textwrap
|
||||
from dataclasses import dataclass
|
||||
from typing import TYPE_CHECKING, Optional, Sequence
|
||||
|
||||
from rich.cells import cell_len
|
||||
from rich.text import Text
|
||||
|
||||
from ..core.geo import haversine_km
|
||||
@@ -40,8 +46,9 @@ from ..persistence.repository import DiscoveredPath
|
||||
from ..services import trace_runner
|
||||
from ..services.records import CATEGORIES, CATEGORY_BY_ID, Category, _local_xy
|
||||
from .mapcanvas import RGB, MapCanvas
|
||||
from .menus import back_rows, section_heading
|
||||
from .menus import back_rows, fit_cells, section_heading
|
||||
from .pathgraph import PathLayer, render_path_graph
|
||||
from .pathline import path_line
|
||||
from .theme import name_style, snr_style
|
||||
from .tui.render import render_hanging, render_to_ansi
|
||||
from .tui.screen import Screen
|
||||
@@ -51,7 +58,6 @@ from .widgets import (
|
||||
name_rgb,
|
||||
node_marker,
|
||||
node_type_legend,
|
||||
path_text,
|
||||
route_graph_style,
|
||||
self_marker,
|
||||
)
|
||||
@@ -67,6 +73,12 @@ _DESC_WRAP = 64
|
||||
#: on the canvas, so it needs no colour to set it apart — white just reads as "the walk".
|
||||
_WALK_EDGE = (255, 255, 255)
|
||||
|
||||
#: Canvas rows a record's route graph draws in. One walk is one lane, so the graph is flat
|
||||
#: and needs only its marker row with a row either side for the labels — the shared widget's
|
||||
#: own default (5) leaves a scored walk sitting in two rows of blank canvas. A fan would grow
|
||||
#: past this on its own; nothing here ever fans.
|
||||
_GRAPH_ROWS = 3
|
||||
|
||||
#: The area drawing's one body tone — a plain muted slate the enclosed region and its loop
|
||||
#: both draw in. Deliberately a single shade (no dimmer interior wash under a brighter rim):
|
||||
#: an even-odd fill shades only the enclosed side, and the coloured node pins carry the
|
||||
@@ -83,6 +95,25 @@ _POLY_EXTRA_ROWS = 2
|
||||
_SIDE_BY_SIDE_MIN = 44
|
||||
|
||||
|
||||
def _drawn_rows(lines: list[str]) -> list[str]:
|
||||
"""``lines`` with the rows nothing actually landed on stripped from either end.
|
||||
|
||||
Both blocks the card stacks are drawn on a canvas sized for the worst case and then
|
||||
filled: the stats' area drawing keeps :data:`_POLY_EXTRA_ROWS` in hand for a tall shape,
|
||||
and the route graph pads a row either side of its markers for the labels. A shape that
|
||||
comes out flat, or labels that all seat on one side, leave those rows empty — and an
|
||||
empty row still costs a line of card, stacking up as a gap the layout never intended
|
||||
(three blank lines between the stats and the graph where one was meant). Trimming happens
|
||||
*after* the drawing, so nothing placed is ever lost: only rows that stayed blank go.
|
||||
"""
|
||||
kept = list(lines)
|
||||
while kept and not Text.from_ansi(kept[-1]).plain.strip():
|
||||
kept.pop()
|
||||
while kept and not Text.from_ansi(kept[0]).plain.strip():
|
||||
kept.pop(0)
|
||||
return kept
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class WalkVertex:
|
||||
"""One positioned point of a walk's circuit, projected onto the local km plane.
|
||||
@@ -114,7 +145,8 @@ class RecordDialog(Screen):
|
||||
drawn beside them on a braille mini-map (us at the origin, every positioned hop pinned
|
||||
in its own name hue, no labels) when the terminal has the room; below, the walk on THE
|
||||
route graph (us at both ends, relays wearing their map marker over a node-type key),
|
||||
the route in full (wrapped, never truncated), and the
|
||||
the route in full on THE path widget — no label lane, the card's whole width, wrapped
|
||||
at hop boundaries and never truncated — and the
|
||||
record's provenance — when it was set and by which app version. The card scrolls
|
||||
(PgUp/PgDn/Home/End) when it outgrows the frame, while the arrows drive the actions.
|
||||
Two actions besides Back: *Trace this path* reopens Trace path with the record's route
|
||||
@@ -235,6 +267,12 @@ class RecordDialog(Screen):
|
||||
re-walking one node must never make the shape look bigger than the ground it covered —
|
||||
one node, one marker, whatever the spec asked for. An overheard via chain has the
|
||||
opposite duty: nothing there is ours to inflate, and a repeat is evidence.
|
||||
|
||||
One walk is one lane, so the graph draws in :data:`_GRAPH_ROWS` — the marker row and
|
||||
a row either side for the labels — rather than the widget's default, which reserves
|
||||
room for a fan this canvas never carries and spends it on blank rows. Whichever of
|
||||
those two label rows stays empty is handed back (:func:`_drawn_rows`), so the graph
|
||||
sits exactly one blank line under the stats and the caption follows it directly.
|
||||
"""
|
||||
seen: set[str] = set()
|
||||
hops: list[str] = []
|
||||
@@ -248,11 +286,12 @@ class RecordDialog(Screen):
|
||||
source=self._device_label,
|
||||
type_of=self._type_of,
|
||||
)
|
||||
return render_path_graph(
|
||||
return _drawn_rows(render_path_graph(
|
||||
[PathLayer(hops=tuple(hops), color=_WALK_EDGE, priority=3)],
|
||||
width,
|
||||
glyph_of=glyph_of, label_of=label_of, label_rgb_of=label_rgb_of,
|
||||
)
|
||||
min_rows=_GRAPH_ROWS,
|
||||
))
|
||||
|
||||
def _stat_lanes(self) -> list[Text]:
|
||||
"""Every stat the walk was measured by, as label/value lanes (score → round trip).
|
||||
@@ -314,7 +353,10 @@ class RecordDialog(Screen):
|
||||
|
||||
With a drawable walk and room to spare, the polygon takes a fixed cell box on the
|
||||
right and the lanes are cropped to the column beside it; too narrow, or no shape,
|
||||
and the lanes reclaim the whole width and the drawing is dropped.
|
||||
and the lanes reclaim the whole width and the drawing is dropped. The block is
|
||||
trimmed to the rows something landed on (:func:`_drawn_rows`), so a shape that comes
|
||||
out flat hands its unused rows back instead of leaving the card gaping under the
|
||||
last stat.
|
||||
"""
|
||||
if not self._shape or width < _SIDE_BY_SIDE_MIN:
|
||||
return [render_to_ansi(lane, width, no_wrap=True) for lane in lanes]
|
||||
@@ -331,7 +373,7 @@ class RecordDialog(Screen):
|
||||
row.append(" ")
|
||||
row.append_text(Text.from_ansi(poly[i]))
|
||||
out.append(render_to_ansi(row, width, no_wrap=True))
|
||||
return out
|
||||
return _drawn_rows(out)
|
||||
|
||||
def _shape_lines(self, cell_w: int, cell_h: int) -> list[str]:
|
||||
"""Draw the walk's enclosed area on a braille canvas: fill, loop, coloured pins.
|
||||
@@ -390,16 +432,28 @@ class RecordDialog(Screen):
|
||||
lines.append(render_to_ansi(node_type_legend(), width, no_wrap=True))
|
||||
|
||||
lines.append("")
|
||||
route = path_text(
|
||||
# The walk itself, on THE path widget, across the card's whole width. No ``route``
|
||||
# label lane: the line under a route graph captioned "you → … → you" is the route,
|
||||
# and the twelve cells a label would take are hops the reader came here for. It wraps
|
||||
# at hop boundaries (never mid-name, never mid-chip) rather than hanging under a lane.
|
||||
# Our two ends stand on the app-wide ★ rather than repeating our name and key — the
|
||||
# graph above already marks us with the same star at both ends, and the legend under
|
||||
# it reads that star back as "you" — kept in the ``you`` white, since nothing about a
|
||||
# walk already made is ours to compose.
|
||||
route = path_line(
|
||||
[None, *record.route, None],
|
||||
self._resolve,
|
||||
prefix_bytes=record.width_bytes,
|
||||
self_name=self._device_label,
|
||||
show_hash=True,
|
||||
hash_bytes=record.width_bytes,
|
||||
device_hash=self._device_hash,
|
||||
bare_self=True,
|
||||
dim_self=False,
|
||||
)
|
||||
lines.extend(
|
||||
render_to_ansi(line, width, no_wrap=True) for line in route.wrapped(width)
|
||||
)
|
||||
lines.extend(render_hanging(Text("route ", style="muted"), route, width,
|
||||
indent=12))
|
||||
spec = Text(record.spec, style="brand")
|
||||
spec.append(f" ({record.width_bytes}-byte hops)", style="muted")
|
||||
lines.extend(render_hanging(Text("spec ", style="muted"), spec, width,
|
||||
@@ -594,30 +648,52 @@ async def open_records(ctx: "AppContext") -> dict:
|
||||
rows.append(Separator(f" {line}", style="muted"))
|
||||
return rows
|
||||
|
||||
def browser_row(
|
||||
rank: int, category: Category, record: DiscoveredPath, *, show_width: bool
|
||||
) -> Text:
|
||||
"""One record row: rank, date, score (width when it disambiguates), route."""
|
||||
row = Text(f"#{rank} ", style="muted")
|
||||
row.append(record.discovered_at.astimezone().strftime("%b %d %H:%M"),
|
||||
style="muted")
|
||||
row.append(" ")
|
||||
def scored(category: Category, record: DiscoveredPath) -> str:
|
||||
"""A record's score in its discipline's unit, bounded where the walk was partial."""
|
||||
score = category.format_score(record.score)
|
||||
if category.id == "long_haul" and not record.stats.get("km_complete", True):
|
||||
score = "≥ " + score
|
||||
row.append(f"{score:<12}", style="accent")
|
||||
return score
|
||||
|
||||
def browser_row(
|
||||
rank: int,
|
||||
category: Category,
|
||||
record: DiscoveredPath,
|
||||
*,
|
||||
show_width: bool,
|
||||
score_w: int,
|
||||
) -> Text:
|
||||
"""One record row: rank, date, score (width when it disambiguates), then the walk.
|
||||
|
||||
The columns left of the walk are held to what they actually say — the day the record
|
||||
was set (the dialog carries the time), and a score lane fitted to the widest score on
|
||||
*this* board rather than a fixed twelve — because every cell they don't spend is a hop
|
||||
the route gets to show. The walk itself draws on THE path widget with both ends bare
|
||||
(:data:`~meshterm.ui.pathline.SELF_GLYPH`): a record is a boomerang by construction,
|
||||
so naming ourselves twice per row would cost more cells than the whole score lane and
|
||||
tell the reader what every other row already told them. Those stars keep the ``you``
|
||||
white (``dim_self=False``) — the fade means "not yours to compose", and nothing on a
|
||||
board of walks already made is being composed. Overflow stays the list's job — the
|
||||
browser h-scrolls the highlighted row, so a long walk is read by sliding it, not by
|
||||
eliding it here.
|
||||
"""
|
||||
row = Text(f"#{rank} ", style="muted")
|
||||
row.append(record.discovered_at.astimezone().strftime("%b %d"), style="muted")
|
||||
row.append(" ")
|
||||
row.append(fit_cells(scored(category, record), score_w), style="accent")
|
||||
if show_width:
|
||||
row.append(f"{record.width_bytes} B ", style="muted")
|
||||
row.append(" ")
|
||||
row.append(f" {record.width_bytes} B", style="muted")
|
||||
row.append(" ")
|
||||
row.append_text(
|
||||
path_text(
|
||||
path_line(
|
||||
[None, *record.route, None],
|
||||
resolve,
|
||||
self_name=device_label,
|
||||
show_hash=True,
|
||||
prefix_bytes=record.width_bytes,
|
||||
hash_bytes=record.width_bytes,
|
||||
device_hash=device_hash,
|
||||
)
|
||||
self_name=device_label,
|
||||
bare_self=True,
|
||||
dim_self=False,
|
||||
).text()
|
||||
)
|
||||
return row
|
||||
|
||||
@@ -664,9 +740,15 @@ async def open_records(ctx: "AppContext") -> dict:
|
||||
if not board:
|
||||
items.append(Separator(" no records yet", style="muted"))
|
||||
show_width = len({r.width_bytes for r in board}) > 1
|
||||
# The score lane is this board's own widest score — "3 nodes" and "+6.0 dB"
|
||||
# measure differently, and a lane sized for the worst case everywhere would
|
||||
# spend the difference on padding in front of every route.
|
||||
score_w = max((cell_len(scored(category, r)) for r in board), default=0)
|
||||
for rank, record in enumerate(board, start=1):
|
||||
items.append(Choice(
|
||||
title=browser_row(rank, category, record, show_width=show_width),
|
||||
title=browser_row(
|
||||
rank, category, record, show_width=show_width, score_w=score_w
|
||||
),
|
||||
value=("open", category, rank, record),
|
||||
))
|
||||
total = len(ctx.repo.discoveries())
|
||||
|
||||
@@ -426,3 +426,29 @@ def test_bare_self_stands_us_on_a_star_and_fades_both_ends() -> None:
|
||||
assert len(lines) > 1
|
||||
assert all(line.plain[2 + WRAP_OFFSET] == POWERLINE_SEP for line in lines[1:])
|
||||
assert all(line.cell_len <= 28 for line in lines)
|
||||
|
||||
|
||||
def test_bare_self_keeps_us_in_the_you_white_where_nothing_is_composed() -> None:
|
||||
"""``dim_self=False`` un-mutes the stars: the fade means "not yours to compose".
|
||||
|
||||
A surface that only *shows* a walk (the Trophy case's boards and record card) composes
|
||||
nothing, so there is no "not yours" to say and our own node reads in the app-wide ``you``
|
||||
white — the same identity colour it wears everywhere else. A ``dim_from`` that reaches a
|
||||
star still fades it, so a mirrored return leg keeps its grey either way.
|
||||
"""
|
||||
hops = [None, *(f"{i:02x}aa" for i in range(4)), None]
|
||||
text = path_line(hops, prefix_bytes=2, self_name="Me", bare_self=True,
|
||||
dim_self=False, mode="plain").text()
|
||||
stars = [s for s in text.spans if text.plain[s.start : s.end] == SELF_GLYPH]
|
||||
assert [str(s.style) for s in stars] == ["you", "you"]
|
||||
chips = path_line(hops, prefix_bytes=2, self_name="Me", bare_self=True,
|
||||
dim_self=False, mode="powerline").text()
|
||||
fills = [str(s.style) for s in chips.spans
|
||||
if chips.plain[s.start : s.end] == SELF_GLYPH]
|
||||
assert all("#ffffff" in fill for fill in fills) # the you white, as a chip fill
|
||||
|
||||
# The explicit fade still rules: a star inside a dimmed return leg stays grey.
|
||||
faded = path_line(hops, prefix_bytes=2, self_name="Me", bare_self=True,
|
||||
dim_self=False, dim_from=3, mode="plain").text()
|
||||
tail = [s for s in faded.spans if faded.plain[s.start : s.end] == SELF_GLYPH]
|
||||
assert [str(s.style) for s in tail] == ["you", "faint"]
|
||||
@@ -21,9 +21,10 @@ from meshterm.core.admin_store import AdminStore
|
||||
from meshterm.core.config import Settings
|
||||
from meshterm.core.device_store import DeviceStore
|
||||
from meshterm.persistence.repository import DiscoveredPath, Repository
|
||||
from meshterm.services.records import CATEGORY_BY_ID
|
||||
from meshterm.services.records import CATEGORIES, CATEGORY_BY_ID
|
||||
from meshterm.ui.records_screen import RecordDialog, WalkVertex, 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
|
||||
@@ -96,6 +97,42 @@ def test_record_dialog_draws_the_walk_as_a_route_graph() -> None:
|
||||
assert any("⠀" <= ch <= "⣿" for ch in body) # braille edges are drawn
|
||||
|
||||
|
||||
def test_record_graph_is_only_as_tall_as_one_lane_needs() -> None:
|
||||
"""A walk is one path: the marker row, plus a label row only where a label landed.
|
||||
|
||||
The shared widget's default floor (5) reserves room for a fan of alternatives; nothing
|
||||
here ever fans, so the walk sat in rows of blank canvas and pushed the route and the
|
||||
actions down the card for nothing.
|
||||
"""
|
||||
for record in (_record(), _record(route=(HUB_ID, FAR_ID, HUB_ID, FAR_ID, HUB_ID))):
|
||||
lines = _dialog(record)._graph_lines(60) # however many relays: still one lane
|
||||
assert 0 < len(lines) <= 3
|
||||
assert all(_plain([line]).strip() for line in lines) # not a blank row among them
|
||||
|
||||
|
||||
def test_record_dialog_route_runs_unlabelled_across_the_whole_card() -> None:
|
||||
"""The route is THE path widget at full width — no ``route`` lane eating twelve cells.
|
||||
|
||||
Under a graph captioned ``you → … → you``, the line *is* the route; the label lane only
|
||||
cost hops. It wraps at hop boundaries, so a long walk folds instead of truncating, and
|
||||
our own two ends stand on the ★ the graph above already marks us with.
|
||||
"""
|
||||
dialog = _dialog(_record(route=tuple([HUB_ID, FAR_ID] * 4)))
|
||||
body = _plain(dialog.render_body(60)).splitlines()
|
||||
spec_at = next(i for i, line in enumerate(body) if line.startswith("spec"))
|
||||
# The route's own first line: the last one before the spec that isn't a hanging fold.
|
||||
route_at = next(i for i in range(spec_at - 1, 0, -1) if not body[i].startswith(" "))
|
||||
route = body[route_at:spec_at]
|
||||
assert not route[0].startswith("route") # no label lane
|
||||
assert route[0].startswith("★") # our end opens the walk on the app-wide star…
|
||||
assert route[-1].endswith("★") # …and closes it on the same
|
||||
assert "Homestead" not in "".join(route) # never our name, and never our key
|
||||
assert "YUL-Cartierville" in "".join(route) # the hops themselves are named in full
|
||||
assert len(route) > 1 # it folded rather than truncating…
|
||||
# …every fold hanging under the step, and the labelled lanes resume at the spec.
|
||||
assert all(line.startswith(" ") for line in route[1:])
|
||||
|
||||
|
||||
def test_record_dialog_shows_the_node_type_legend() -> None:
|
||||
"""The standard node-type key sits under the graph, so its markers read."""
|
||||
body = _plain(_dialog(_record()).render_body(60))
|
||||
@@ -330,6 +367,93 @@ async def test_delete_a_disciplines_records_is_a_popup_over_the_browser(tui_ctx)
|
||||
assert result == {"records": 1}
|
||||
|
||||
|
||||
async def test_a_board_row_spends_its_cells_on_the_walk(tui_ctx) -> None:
|
||||
"""A record row leads with rank/day/score, then the walk with both ends bare.
|
||||
|
||||
Every record is a boomerang, so naming ourselves at both ends said nothing twice a row
|
||||
and cost more cells than the whole score lane; the ends go to ``★`` and the score lane
|
||||
is fitted to this board's own widest score instead of 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 discipline's unit
|
||||
assert row.count("★") == 2 # our two ends, bare — never named twice a row
|
||||
assert "Homestead" not in row # …so the device name is nowhere on the line
|
||||
# And those ends are *us*, in the you white — nothing here is ours to compose, so
|
||||
# there is no "not yours" fade to wear.
|
||||
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 record's own hash width
|
||||
# The lanes before the walk are held to what they say: a score lane fitted to this
|
||||
# board's widest score (no dead padding), and the day without the minute the dialog
|
||||
# carries — every cell they don't spend is a hop the route gets to 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:
|
||||
"""Scrolling into a board keeps its ``── discipline ──`` heading *and* description overhead.
|
||||
|
||||
Every discipline heading is followed by its word-wrapped description — what that board
|
||||
scores, which is exactly what a reader partway down it needs. The two pin as one block:
|
||||
with every separator a pinning candidate, the *last muted line* under a heading won the
|
||||
top row on its own and the heading never stuck — the first discipline's least of all,
|
||||
since its description is what the very first scrolled row sits under.
|
||||
"""
|
||||
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
|
||||
# Exactly the six disciplines are landmarks, each block led by its own heading and
|
||||
# carrying the description written 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]
|
||||
assert [rows[0] for rows in blocks] == [f"── {c.icon} {c.title} ──" for c in CATEGORIES]
|
||||
assert all(len(rows) >= 2 for rows in blocks) # each carries its description too
|
||||
# Highlighting deep in the one populated board scrolls its heading off the top of a
|
||||
# short viewport; the heading leads what pins, its description 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.icon} {board.title} ──"
|
||||
assert _plain([visible[1]]).strip().startswith(board.description[:20])
|
||||
assert above is True
|
||||
browser.resolve(("back", None, 0, None))
|
||||
await task
|
||||
finally:
|
||||
if not task.done():
|
||||
task.cancel()
|
||||
|
||||
|
||||
async def test_trace_this_path_unwinds_to_the_menu_not_the_browser(
|
||||
tui_ctx, monkeypatch
|
||||
) -> None:
|
||||
|
||||
Reference in new issue
Block a user