From 2c2f13ce4100410c8f9c6b0d0d4f4e9c5a85d332 Mon Sep 17 00:00:00 2001 From: Jean-Pierre Martineau Date: Mon, 27 Jul 2026 19:05:43 -0400 Subject: [PATCH] Path lines: gapped chips, both ends automatic, routes name nodes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The trace surfaces' route lanes were spending their width on hex nobody reads there: every name carried the hash it is addressed by, and the wire spec one lane below already spells that out verbatim, at the width it goes on the air. The route lane, the planned-route preview and the composer's live preview now name nodes and nothing else; an unnamed hop still reads as its hash, since that is the only identity it has. The composer's preview becomes that same lane rather than its own dialect — pathline chips, hop-boundary wrapping, our two ends the bare star. Both stars fade. Setting out from us and landing back on us are fixtures of every such route, no more composed (or removable) than a mirrored return leg, so they wear the automatic grey — dark slate as a chip, never the loud you white. Finding the turn a wrapped route folds at now reads the faded *tail* rather than the first faded hop, which the leading star had become. Chips are separated, not interlocked: every seam draws its point with no background, so the page shows through the taper and a route reads as a row of blocks the way oh-my-posh spaces its segments. Two neighbours landing on one fill can no longer fuse, without that being a special case. Finally, two places that had the right information and didn't use it: the composer's suggestion list fell back to bare hex for a short hop matching several contacts (the topology won't guess, so it names nothing), and the not-a-trail confirm printed whole 12-hex ids. Both now go through the owning screen's own resolver and its chosen path-hash width, so a node reads the same in the dialog as in the window behind it. Co-Authored-By: Claude Opus 5 (1M context) --- meshterm/ui/path_composer.py | 75 +++++++++++++++++++++++---------- meshterm/ui/pathline.py | 80 +++++++++++++++++++++++------------- meshterm/ui/trace_screen.py | 21 +++++++--- meshterm/ui/tx_screen.py | 1 + meshterm/ui/widgets.py | 8 +++- tests/test_live_screens.py | 9 ++-- tests/test_pathline.py | 47 +++++++++++++-------- tests/test_topology.py | 52 +++++++++++++++++------ 8 files changed, 204 insertions(+), 89 deletions(-) diff --git a/meshterm/ui/path_composer.py b/meshterm/ui/path_composer.py index d76b2a8..657e577 100644 --- a/meshterm/ui/path_composer.py +++ b/meshterm/ui/path_composer.py @@ -1,7 +1,7 @@ """The trace path composer: build a forced route hop by hop, guided by observed links. A floating dialog over a live trace screen. The route under construction reads across -the top — ``us → hop → … → us`` — and beneath it sits a suggestion list: the nodes the +the top — ``★ → hop → … → ★`` — and beneath it sits a suggestion list: the nodes the topology evidence says the path's current tail can hear, strongest observed link first (see :meth:`~meshterm.services.topology.MeshTopology.next_hops`). Every link is bidirectional evidence, so a path that was ever *received* through two nodes proposes @@ -17,7 +17,7 @@ sent to the device is one walk that ends within our earshot): yours to choose. An *Auto* action hands routing back to the device. * **Path mode** (*Trace path* — no target at all): the whole walk is yours, hop by hop, until the route comes back within earshot of us — only the final landing on - our own node is filled in (and dimmed). The preview opens as just ``us → us``. + our own node is filled in (and dimmed). The preview opens as just ``★ → ★``. There is no *Auto* action, because with no destination there is nothing for the device to route to. @@ -46,10 +46,14 @@ Interaction, following the reorder screen's cursor-over-rows-and-actions pattern under the preview: still walkable, but no longer a *trail*, so the trophy case will ignore it (the no-cheat rule — see :mod:`~meshterm.services.records`). -Nodes and the route preview render through the shared path widget -(:func:`~meshterm.ui.widgets.path_text`, trace flavour) — ``Name (hash)`` with names -in their app-wide hues and our own node the white ``you`` — so a node reads the same -wherever it appears. The suggestion list scrolls in a window under the pinned route +Nodes render through the shared path widget in the trace flavour — ``Name (hash)`` +with names in their app-wide hues, the hash at the session's chosen path-hash width, +and a hop the topology can't name falling back to the owning screen's own resolver +(see :meth:`PathComposerScreen._resolve_entry`), so a node reads the same here as in +the trace window this dialog floats over. The route preview is that same trace-window +route lane, live: :mod:`~meshterm.ui.pathline` chips (or arrows), wrapped at hop +boundaries, our two ends the bare ``★``, and no hash repeated after a name — the hex +is on the *Use this path* row, verbatim. The suggestion list scrolls in a window under the pinned route preview (faint ``↑/↓ n more`` markers at its edges; PgUp/PgDn stride by a windowful), so the route under construction never leaves the screen. """ @@ -68,7 +72,7 @@ from .theme import snr_style from .tui.render import render_lines, render_to_ansi from .tui.screen import ListWindow, Screen from .pathline import PathLine, path_line -from .widgets import _age_seconds, _format_age, path_text +from .widgets import NodeResolver, _age_seconds, _format_age, _identity, path_text #: Sentinel spec meaning "no forced path — let the device route" (the trace screen's #: empty-spec convention). Only meaningful in target mode; a path walk has no target @@ -146,6 +150,7 @@ class PathComposerScreen(Screen): hops: Optional[list[str]] = None, cursor: Optional[int] = None, fetch_nodes: frozenset[str] = frozenset(), + resolve: NodeResolver = _identity, ) -> None: """Build the composer. @@ -171,6 +176,9 @@ class PathComposerScreen(Screen): fetch_nodes: Canonical ids whose live neighbour table can be fetched (repeater contacts with a public key); when the path's tail is one of them, the *fetch neighbours* row appears. + resolve: The owning screen's node resolver — the fallback that names a hop + the topology can't (see :meth:`_resolve_entry`), so the composer reads + the same names the screen that opened it does. """ super().__init__() self.title = f"Compose path — {target_label}" if target_label else "Compose path" @@ -180,6 +188,7 @@ class PathComposerScreen(Screen): self._device_label = device_label self._device_hash = (device_hash or "").lower().removeprefix("0x") self._topology = topology + self._resolve = resolve self._width_bytes = width_bytes self._hops: list[str] = list(hops or []) #: The insertion cursor: which joining arrow of the editable leg it sits on. @@ -314,10 +323,23 @@ class PathComposerScreen(Screen): return node def _resolve_entry(self, entry: str) -> str: - """Resolve a preview entry to its display name (the target by its label).""" + """Resolve an entry to its display name — the target's label, else a node's. + + Two resolvers, in order, because they answer different questions. The topology + names a node it has *identified*: a hop hash that prefix-matches exactly one + contact is that contact, so its canonical id carries the name. But a short hash + — a 1-byte hop, the common case at the protocol's default width — often matches + several contacts, and rather than guess, the topology keeps it as itself and + knows no name for it. The owning screen's resolver + (:func:`~meshterm.services.trace_runner.make_node_resolver`) is the same + first-match lookup the trace screens name their walked hops by, so falling back + to it means a node the trace window shows as *YUL-Poly* is *YUL-Poly* here too, + instead of a bare ``3d`` the reader has to decode. Nothing is invented: an + unmatched hash comes back unchanged and renders as a hash. + """ if self._target_id is not None and entry == (self._target_hash or self._target_id): return self._target_label or entry - return self._topology.display_name(entry) or entry + return self._topology.display_name(entry) or self._resolve(entry) or entry def _node_text(self, node: str, *, dim: bool = False) -> Text: """One route node through THE path widget, the trace presentation. @@ -343,15 +365,26 @@ class PathComposerScreen(Screen): def _route_preview(self) -> "PathLine": """The route under construction, endpoints filled in automatically. - THE path widget in the trace flavour: target mode shows the composed - outbound in full colour, then the pinned target and the mirrored return - resolved and dimmed right alongside it (``dim_from``) — the dimming reads - as "this half isn't yours to compose". Path mode shows every composed hop - in full colour (they are all yours) between our own node at both ends, - with only the final landing back on us dimmed. The caller renders the line - with the insertion cursor riding the arrow it stands on (``cursor_arrow``), - a reverse-video ``→`` — which pins the preview to plain arrows: an editor - needs the seams its cursor sits in. + THE path widget, drawn exactly as the trace screen that opened this dialog + draws its route lane — the composer is a live preview of that lane, so the two + must be the same picture. Target mode shows the composed outbound in full + colour, then the pinned target and the mirrored return resolved and dimmed + right alongside it (``dim_from``) — the dimming reads as "this half isn't yours + to compose". Path mode shows every composed hop in full colour (they are all + yours) between our own node at both ends, with only the final landing back on + us dimmed. + + Both our ends go bare (``bare_self``): a composed walk always leaves us and + comes home to us, so the ``★`` says it in one cell and leaves the rest of the + dialog's width to the hops being chosen. Named hops show no hash either — the + wire spec on the *Use this path* row is the hex, verbatim and at the width it + goes on the air, so repeating it after every name would only crowd the route + this dialog exists to shape. An unnamed hop still reads as its hash, truncated + to the session's chosen path-hash width. + + The caller renders the line with the insertion cursor riding the arrow it + stands on (``cursor_arrow``), a reverse-video ``→`` — which pins the preview to + plain arrows: an editor needs the seams its cursor sits in. """ entries: list[Optional[str]] = [None] entries.extend(self._path_entry(hop) for hop in self._hops) @@ -362,9 +395,10 @@ class PathComposerScreen(Screen): return path_line( entries, self._resolve_entry, prefix_bytes=self._width_bytes, self_name=self._device_label, - show_hash=True, hash_bytes=self._width_bytes, + hash_bytes=self._width_bytes, device_hash=self._device_hash or None, dim_from=(2 if self._mirrored else 1) + len(self._hops), + bare_self=True, ) def _suggestion_text(self, suggestion) -> Text: # noqa: ANN001 @@ -393,9 +427,8 @@ class PathComposerScreen(Screen): if kind == "hop": return self._suggestion_text(payload) if kind == "fetch": - name = self._topology.display_name(str(payload)) or str(payload)[:12] text = Text("⇣ Fetch neighbours from ", style="") - text.append(name, style="brand") + text.append_text(self._node_text(str(payload))) # named like every other row text.append(" (asks the repeater over the mesh)", style="muted") return text if payload == _USE: diff --git a/meshterm/ui/pathline.py b/meshterm/ui/pathline.py index 2d4828f..1ca7f17 100644 --- a/meshterm/ui/pathline.py +++ b/meshterm/ui/pathline.py @@ -8,13 +8,13 @@ caller), a :class:`PathLine` separates *what the hops are* from *how the line is drawn*, so every surface the survey found can eventually route through it: * **Two separator styles.** ``plain`` joins hops with muted ``→`` arrows — today's - look, unchanged. ``powerline`` renders each hop as a colour-filled chip joined by - the solid-triangle separator U+E0B0 (foreground = previous chip's fill, background - = next's — the interlocking oh-my-posh look, opening to a page-coloured gap where - two neighbours share a fill and the interlock would fuse them into one block), the - chip fill being the node's hash-derived hue + look, unchanged. ``powerline`` renders each hop as a colour-filled chip separated by + the solid-triangle U+E0B0 drawn on the page — the gapped oh-my-posh look: each chip + tapers out to a point and the page shows through the wedge behind it, so a route + reads as a row of distinct blocks rather than one fused ribbon. The + chip fill is the node's hash-derived hue (:func:`~meshterm.ui.theme.node_style`), our own node the pure ``you`` white, a - keyless hop grey. ``auto`` (the default) picks powerline exactly + faded hop dark slate, a keyless hop grey. ``auto`` (the default) picks powerline exactly when the terminal can draw it (:func:`~meshterm.ui.termfont.powerline_enabled` — a recommended font, a glyph-capable renderer, or the user's override) and falls back to arrows everywhere else, so no terminal ever sees tofu. @@ -438,17 +438,24 @@ class PathLine: def _turn_seam(self) -> Optional[int]: """The hop index a fold would read best at — where the route turns — or ``None``. - Two things mark a turn. A dimmed tail is one: the composed outbound leg ends - and the mirrored return begins (see :attr:`PathHop.dim`). A walked boomerang is + Two things mark a turn. A dimmed *tail* is one: the composed outbound leg ends + and the mirrored return begins (see :attr:`PathHop.dim`). It is read as the run + of faded hops that reaches the end of the line, not merely the first faded hop — + a route's opening ``★`` fades too (setting out from us is nobody's choice + either), and that lone fixture at the head marks nothing. A walked boomerang is the other — nothing is dimmed once a trace has actually answered, but the hop sequence is its own mirror, so its middle *is* that same turn; folding the plan and the walk that answered it in the same place keeps the two readings of one route looking like one route. Either way the fold needs real legs on both - sides — at least two hops each — so a path whose *only* dim hop is the + sides — at least two hops each — so a path whose only faded tail is the automatic landing back on us never widows it onto a line of its own; that lone - faded tail marks no turn, and the walk's own mirror still gets to name one. + hop marks no turn, and the walk's own mirror still gets to name one. """ - seam = next((i for i, hop in enumerate(self._hops) if hop.dim), None) + seam: Optional[int] = len(self._hops) + while seam and self._hops[seam - 1].dim: + seam -= 1 + if seam == len(self._hops): # nothing faded at the tail + seam = None if seam is None or len(self._hops) - seam < 2: marks = [(hop.label, hop.annotation) for hop in self._hops] if len(marks) % 2 and marks == marks[::-1]: @@ -523,7 +530,7 @@ class PathLine: def _render_chips( self, hops: list[PathHop], *, carry_in: bool = False, carry_on: bool = False ) -> Text: - """Powerline chips: each hop filled with its hue, seams interlocked. + """Powerline chips: each hop filled with its hue, a page-wide gap at every seam. The two outer ends say whether this line *is* the path or only part of it. A line that opens the path opens rounded (squared off where the font has no @@ -558,27 +565,40 @@ class PathLine: @staticmethod def _seam(before: str, after: str) -> str: - """One seam's style: interlocked into the next fill, or open onto the page. + """One seam's style: the previous chip's point, open onto the page. - Two ways to draw the point. Interlocked — the previous fill *on* the next - one — is the tighter look and the default. But two neighbours can land on the - same fill (a hue collision, a run of dimmed hops, two keyless greys), and then - an interlock is invisible: one fused block where the route has two nodes. - Those open onto the page instead, the taper leaving the sliver of background - that says where one chip ends and the next begins. + The point is drawn in the previous fill with *no* background of its own, so the + page shows through the wedge it tapers out of — the small gap oh-my-posh leaves + between segments. The alternative (interlocking the point into the next chip's + fill) packs the row tighter, but it fuses the route into one continuous ribbon, + and two neighbours that happen to land on the same fill — a hue collision, a run + of faded hops, two keyless greys — then read as a single block where the route + has two nodes. One gap, every seam: a chip is always a chip. + + Args: + before: The fill the seam tapers out of (the previous chip's). + after: The fill the next chip opens on — no longer part of the seam, kept + so callers still read as "this seam, between these two fills". """ - return before if before == after else f"{before} on {after}" + return before def _chip_fill(self, hop: PathHop) -> str: - """A chip's fill colour: override, white you, dim slate, hue, keyless grey.""" + """A chip's fill colour: override, dim slate, white you, hue, keyless grey. + + The fade outranks identity — including our own. A dimmed hop is one nobody + composed (an automatic landing back home, a mirrored return leg), and the white + ``you`` chip is the loudest thing on the line: our automatic end must recede + with the rest of the automatic half, not shout over the hops that *are* news. + Plain mode says the same thing by fading the name. + """ if hop.style: resolved = _style_hex(hop.style) if resolved: return resolved - if hop.you: - return _YOU_BG if hop.dim: return _DIM_BG + if hop.you: + return _YOU_BG if hop.key: return _style_hex(node_style(hop.key)) or _KEYLESS_BG return _KEYLESS_BG @@ -656,9 +676,10 @@ 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. A ``★`` in the *last* - position also fades: coming home closes every route automatically, so - that end is no more composed than a resolved return leg is. + 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. mode: The :class:`PathLine` mode (``"auto"``/``"powerline"``/``"plain"``). Returns: @@ -670,8 +691,11 @@ def path_line( dim = dim_from is not None and i >= dim_from if hop is None: if bare_self: - homecoming = i == len(shown) - 1 # nobody composed the way back to us - built.append(PathHop(SELF_GLYPH, you=True, dim=dim or homecoming)) + # 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)) continue note = _shorten(device_hash, hash_bytes) if (show_hash and device_hash) else None built.append( diff --git a/meshterm/ui/trace_screen.py b/meshterm/ui/trace_screen.py index eae47d4..0bda737 100644 --- a/meshterm/ui/trace_screen.py +++ b/meshterm/ui/trace_screen.py @@ -857,7 +857,9 @@ class TraceScreen(Screen): Both ends go bare (``bare_self``): every walk on this screen leaves us and comes back to us, so naming ourselves twice would only push the hops that *are* news - out of the lane. + out of the lane. Named hops go bare of their hash too: this lane answers *which + nodes*, and the hex it would repeat after every name is already spelled out + verbatim, at the width it goes on the air, in the ``path`` lane below. Args: current: The newest successful trace of this session, if any. @@ -871,7 +873,7 @@ class TraceScreen(Screen): if current is not None: route = _route_path( current, self._device_label, self._resolve, self._device_hash, - bare_self=True, + bare_self=True, show_hash=False, ) return route.wrapped(width, indent=indent) planned = self._planned_route() @@ -883,7 +885,7 @@ class TraceScreen(Screen): if self._previous is not None: route = _route_path( self._previous, self._device_label, self._resolve, self._device_hash, - bare_self=True, + bare_self=True, show_hash=False, ) lines = route.wrapped(width, indent=indent) stamp = self._previous.timestamp.astimezone().strftime("%b %d %H:%M") @@ -930,7 +932,12 @@ class TraceScreen(Screen): try: prompt = Text("This walk crosses ", style="warn") prompt.append_text( - _link_text(edge[0], edge[1], self._device_label, self._resolve) + # The hashes read at the width the screen addresses hops by, like + # every other node this session shows. + _link_text( + edge[0], edge[1], self._device_label, self._resolve, + self._width_bytes(), self._device_hash, + ) ) prompt.append(" twice in the same direction, ", style="warn") prompt.append( @@ -964,7 +971,8 @@ class TraceScreen(Screen): seam (the turn). A path walk's spec is the whole route (hand-composed, or the last stored walk), so every hop renders in full colour and only the automatic landing back on us stays faint. Our own endpoints go bare, as in - :meth:`_route_value` — the arrow alone. + :meth:`_route_value` — the arrow alone — and so does every hop's hash: the lane + names nodes, and the spec below it is the hex, verbatim. """ spec, _ = self._effective_spec() tokens = [h.strip() for h in spec.split(",") if h.strip()] @@ -980,7 +988,7 @@ class TraceScreen(Screen): self._resolve, prefix_bytes=8, # spec tokens are the addressed slices: light them whole self_name=self._device_label, - show_hash=True, + show_hash=False, dim_from=1 + len(outbound), bare_self=True, ) @@ -1632,6 +1640,7 @@ async def _open_session( hops=seed, cursor=seed_cursor, fetch_nodes=frozenset(fetchable), + resolve=resolve, # name a hop the topology left ambiguous, as we do ) result = await session.run_screen(screen) if result is CANCEL: diff --git a/meshterm/ui/tx_screen.py b/meshterm/ui/tx_screen.py index 1aee0ac..2354089 100644 --- a/meshterm/ui/tx_screen.py +++ b/meshterm/ui/tx_screen.py @@ -658,6 +658,7 @@ async def open_tx_optimize( target_hash=admin_hash, target_label=admin_node.name, hops=[topo.canonical(h) or h for h in screen.route_hops], + resolve=resolve, # name a hop the topology left ambiguous, as we do ) result = await session.run_screen(composer) if result is CANCEL or not isinstance(result, str): diff --git a/meshterm/ui/widgets.py b/meshterm/ui/widgets.py index 428f148..421d85f 100644 --- a/meshterm/ui/widgets.py +++ b/meshterm/ui/widgets.py @@ -562,6 +562,7 @@ def _route_path( device_hash: Optional[str] = None, *, bare_self: bool = False, + show_hash: bool = True, ) -> PathLine: """A trace's walked route as THE path widget's line object. @@ -578,6 +579,11 @@ def _route_path( bare_self: Draw both ``us`` endpoints as their bare arrow — for a surface (the trace screens' route lane) where a walk starting and ending on us is the premise, not news. + show_hash: Annotate each *named* hop with the hash it was addressed by. On for + the scripted table output, where the hash is half the answer; off for the + live screens, whose route lane reads as the sequence of nodes and leaves + the hex to the wire-spec lane below it. Unnamed hops always show their + hash — it is the only identity they have. Returns: The route's :class:`~meshterm.ui.pathline.PathLine` (hopless — reading @@ -591,7 +597,7 @@ def _route_path( hops = [None if not node or node == device_label else node for node in nodes] return path_line( hops, resolve, prefix_bytes=hash_bytes or 8, self_name=device_label, - show_hash=True, hash_bytes=hash_bytes, device_hash=device_hash, + show_hash=show_hash, hash_bytes=hash_bytes, device_hash=device_hash, bare_self=bare_self, ) diff --git a/tests/test_live_screens.py b/tests/test_live_screens.py index f166e9c..874be43 100644 --- a/tests/test_live_screens.py +++ b/tests/test_live_screens.py @@ -669,8 +669,8 @@ def test_route_lane_wraps_at_hop_boundaries_under_its_own_column() -> None: """A route too wide for the lane breaks between hops, never inside one. Every continuation hangs under the value column (never back at column zero), the - line it continues from ends on the ``→`` cue, and no name is ever parted from the - hash it is addressed by. + line it continues from ends on the ``→`` cue, and every name survives whole — the + lane names nodes and leaves the hex to the ``path`` lane below it. """ names = {"3d": "YUL-Cartierville", "f2": "Mile-End-Rooftop", "27": "Beaubien-Sud"} screen, _ = _trace_screen(mode="path") @@ -681,8 +681,9 @@ def test_route_lane_wraps_at_hop_boundaries_under_its_own_column() -> None: assert all(len(line) <= 60 for line in lines) assert all(line.startswith(" " * 7) for line in lines[1:]) # hanging, not column 0 assert all(line.rstrip().endswith("→") for line in lines[:-1]) - for hop, name in names.items(): # every name still carries its own hash, whole - assert any(f"{name} ({hop})" in line for line in lines) + for hop, name in names.items(): # names whole, and no hash trailing any of them + assert any(name in line for line in lines) + assert all(f"({hop})" not in line for line in lines) def test_path_lane_breaks_the_wire_spec_after_a_comma() -> None: diff --git a/tests/test_pathline.py b/tests/test_pathline.py index 0013a38..0bb97cb 100644 --- a/tests/test_pathline.py +++ b/tests/test_pathline.py @@ -86,9 +86,10 @@ def test_empty_path_reads_as_the_callers_word() -> None: assert PathLine([], mode="plain").wrapped(40)[0].plain == "direct" -def test_chips_interlock_fills_through_the_separator() -> None: - """Each seam's triangle wears the previous chip's fill as foreground on the next - chip's fill as background; the last points into the page (foreground only).""" +def test_chips_are_separated_by_a_gap_not_an_interlock() -> None: + """Every seam's triangle carries only a foreground — the previous chip's fill — so + the page shows through the wedge and the chips read as separate blocks (the + oh-my-posh gap), interior seams and the closing edge alike.""" alice_fill = node_style("aa").split()[-1] line = PathLine( [PathHop("Alice", key="aa"), PathHop("you", you=True)], mode="powerline" @@ -97,7 +98,7 @@ def test_chips_interlock_fills_through_the_separator() -> None: assert text.plain == f" Alice {POWERLINE_SEP} you {POWERLINE_SEP}" seam_styles = [str(s.style) for s in text.spans if text.plain[s.start:s.end] == POWERLINE_SEP] - assert seam_styles == [f"{alice_fill} on #ffffff", "#ffffff"] + assert seam_styles == [alice_fill, "#ffffff"] # no "on": nothing behind the point def test_chips_keep_the_same_words_and_honour_style_overrides() -> None: @@ -340,19 +341,19 @@ def test_rounded_caps_finish_a_path_only_where_the_font_has_them(monkeypatch) -> assert all(text.cell_len <= 20 for text in wrapped) -def test_same_fill_neighbours_get_the_open_seam() -> None: - """Two chips of one colour — a hue collision, a run of dimmed hops — would fuse - into a single block behind an interlocked seam, so theirs opens onto the page and - leaves a visible gap. Chips of different fills keep interlocking.""" - same = PathLine([PathHop("A", key="aa"), PathHop("B", key="aa")], mode="powerline") +def test_every_seam_opens_onto_the_page_whatever_the_fills() -> None: + """One gap rule, no exceptions: a seam never takes a background, so two chips of + one colour — a hue collision, a run of dimmed hops — can't fuse into a single block, + and two chips of different colours are still visibly two chips.""" hue = _style_hex(node_style("aa")) + same = PathLine([PathHop("A", key="aa"), PathHop("B", key="aa")], mode="powerline") seam = next(s for s in same.text().spans if same.text().plain[s.start] == POWERLINE_SEP) assert str(seam.style) == hue # foreground only: the page shows through the taper apart = PathLine([PathHop("A", key="aa"), PathHop("B", key="77")], mode="powerline") text = apart.text() seams = [str(s.style) for s in text.spans if text.plain[s.start] == POWERLINE_SEP] - assert seams[0] == f"{hue} on {_style_hex(node_style('77'))}" + assert seams[0] == hue # the fill it tapers out of, and nothing behind it dimmed = PathLine([PathHop("A", dim=True), PathHop("B", dim=True)], mode="powerline") text = dimmed.text() @@ -360,18 +361,30 @@ def test_same_fill_neighbours_get_the_open_seam() -> None: assert " on " not in gaps[0] # a dimmed return leg reads as hops, not one bar -def test_bare_self_stands_us_on_a_star_and_fades_the_way_home() -> None: - """``bare_self`` replaces our name and hash with the app-wide ★ at both ends. The - one we set out from stays the you white; the one we land back on fades, because - coming home closes every route automatically — nobody composed it.""" +def test_bare_self_stands_us_on_a_star_and_fades_both_ends() -> None: + """``bare_self`` replaces our name and hash with the app-wide ★ at both ends, and + both fade: setting out from us and landing back on us are fixtures of the route, + not choices, so they wear the same automatic grey as a mirrored return leg — + and, in chips, the same dark slate rather than the loud you white.""" hops = [None, *(f"{i:02x}aa" for i in range(6)), None] - text = path_line(hops, prefix_bytes=2, self_name="Me", show_hash=True, - device_hash="a1b2", bare_self=True, mode="plain").text() + line = path_line(hops, prefix_bytes=2, self_name="Me", show_hash=True, + device_hash="a1b2", bare_self=True, mode="plain") + text = line.text() body = " → ".join(f"{i:02x}aa" for i in range(6)) assert text.plain == f"{SELF_GLYPH} → {body} → {SELF_GLYPH}" assert "Me" not in text.plain and "a1b2" not in text.plain 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", "faint"] # out in white, home grey + assert [str(s.style) for s in stars] == ["faint", "faint"] + chips = PathLine(line.hops, mode="powerline").text() + star_fills = [str(s.style) for s in chips.spans + if chips.plain[s.start : s.end] == SELF_GLYPH] + assert all("#ffffff" not in fill for fill in star_fills) # never the you white + + wrapped = path_line(hops, prefix_bytes=2, self_name="Me", bare_self=True, + mode="powerline").wrapped(28, indent=2) + assert len(wrapped) > 1 + assert all(text.plain[2 + WRAP_OFFSET] == POWERLINE_SEP for text in wrapped[1:]) + assert all(text.cell_len <= 28 for text in wrapped) lines = path_line(hops, prefix_bytes=2, self_name="Me", bare_self=True, mode="powerline").wrapped(28, indent=2) diff --git a/tests/test_topology.py b/tests/test_topology.py index e5227d2..11fac9c 100644 --- a/tests/test_topology.py +++ b/tests/test_topology.py @@ -23,6 +23,7 @@ from meshterm.persistence.repository import ( from meshterm.services.path_probe import ProbeCandidate, ProbeOutcome, probe_paths from meshterm.services.topology import build_topology, collapse_width, render_custom_spec from meshterm.ui.path_composer import AUTO_SPEC, FetchNeighbours, PathComposerScreen +from meshterm.ui.pathline import SELF_GLYPH from meshterm.ui.tui.screen import CANCEL US = "aaaaaaaaaaaa" @@ -392,7 +393,9 @@ async def test_probe_paths_defaults_to_one_trace_per_candidate() -> None: # --- path composer --------------------------------------------------------------------- -def _composer(topo, hops=None, fetch_nodes=frozenset(), target=True, cursor=None): # noqa: ANN001 +def _composer( # noqa: ANN001 + topo, hops=None, fetch_nodes=frozenset(), target=True, cursor=None, resolve=None +): """A composer over ``topo`` — pinned on Far (target mode) or target-less.""" pinned = ( dict( @@ -411,6 +414,7 @@ def _composer(topo, hops=None, fetch_nodes=frozenset(), target=True, cursor=None hops=list(hops or []), cursor=cursor, fetch_nodes=fetch_nodes, + **({"resolve": resolve} if resolve is not None else {}), **pinned, ) screen.note_viewport(30) # the frame records the dialog budget before each paint @@ -443,7 +447,7 @@ def test_composer_windows_rows_under_the_pinned_route_preview() -> None: screen = _composer(_topo(trace_paths=walks, contacts=[FAR])) screen.note_viewport(9) # a short dialog budget: preview + heading + a few rows body = re.sub(r"\x1b\[[0-9;]*m", "", "\n".join(screen.render_body(90))) - assert "Us (aa)" in body # the route preview is pinned, never scrolled out + assert SELF_GLYPH in body # the route preview is pinned, never scrolled out assert "↓" in body and "more" in body # hidden rows are counted at the edge screen.handle("end") # cursor to the last action row — the window follows body = re.sub(r"\x1b\[[0-9;]*m", "", "\n".join(screen.render_body(90))) @@ -604,24 +608,48 @@ async def test_composer_commits_spec_auto_and_cancel() -> None: assert cancelled.future.result() is CANCEL -def test_composer_path_mode_opens_us_to_us_without_auto() -> None: - """Target-less, the preview is just ``us → us`` and there is no Auto action. +def test_composer_path_mode_opens_star_to_star_without_auto() -> None: + """Target-less, the preview is just ``★ → ★`` and there is no Auto action. - Only the final landing back on us dims — the hops (and the opening endpoint) - are all the user's to compose, and with no destination there is nothing for - the device to route to. + Both ends are our own node, bare and faded: a walk always leaves us and comes home + to us, and neither end is the user's to compose or remove — the automatic grey says + so. With no destination there is nothing for the device to route to either. """ screen = _composer(_topo(), target=False) assert screen.title == "Compose path" # no target to name preview = screen._route_preview().text(cursor_arrow=screen._cursor) - assert preview.plain == "Us (aa) → Us (aa)" # endpoints carry our hash - faint = sum( - span.end - span.start for span in preview.spans if "faint" in str(span.style) - ) - assert 0 < faint < len(preview.plain) # some dimming (the landing), never all + assert preview.plain == f"{SELF_GLYPH} → {SELF_GLYPH}" # our name and hash go unsaid + stars = [ + str(span.style) for span in preview.spans + if preview.plain[span.start : span.end] == SELF_GLYPH + ] + assert stars == ["faint", "faint"] # read-only, like every auto-managed chip assert "Auto" not in _rows_plain(screen) +def test_composer_names_an_ambiguous_hop_through_the_owning_screens_resolver() -> None: + """A short hop the topology won't name still reads as a name, with its hash beside it. + + A 1-byte hop that prefix-matches two contacts is ambiguous, so the topology refuses + to guess and keeps it as the bare hash — which left the suggestion list proposing + opaque hex for nodes the trace window behind it was happily naming. The owning + screen's resolver is the fallback, so both surfaces say the same thing. + """ + twin = Contact(name="Twin", public_key="3d99" + "0" * 60) # makes a bare "3d" ambiguous + topo = _topo(trace_paths=[_traced(("3d", 12.0), (None, 12.0))], + contacts=[REPEATER, twin]) + assert topo.display_name("3d") is None # two contacts match: the graph won't pick + + import re + + blind = _composer(topo, target=False) + assert "Hub" not in _rows_plain(blind) # without a fallback: bare hex, as before + + named = _composer(topo, target=False, resolve=lambda h: "Hub" if h == "3d" else h) + body = re.sub(r"\x1b\[[0-9;]*m", "", _rows_plain(named)) + assert "Hub (3d)" in body # the name, and the hash it is addressed by beside it + + async def test_composer_path_mode_suggests_through_anything_and_commits_verbatim() -> None: """A path walk routes *through* any node and commits exactly the composed hops.""" import asyncio