Edge scroll reached only the eight screens that answered cursor_at_edge, one gate each; the trace screen, the autocomplete prompt and anything built tomorrow went without. Now no screen says where its edges are. An arrow goes to the screen like any key, and the next paint reads the answer off the page: a highlight on the same line, drawn the same, moved nothing, so the page takes the line instead. Comparing the drawn line, not just its index, keeps a list that scrolls in its own window from looking stuck. Arrows pressed faster than the screen paints are judged together. All a screen supplies is its highlight's line in cursor_line, which keeping it in view asked for already. The eight gates and row_at_edge are gone. The snap-back's arrow is now the one pointing at the off-screen highlight, read from where the paint left it, so it no longer needs to know which edge the page went past. Home and End take the page to its ends after the screen has handled them, without assuming the highlight followed. The remote CLI opts out (edge_scrolls = False): the line it keeps in view is its prompt, and its arrows recall history. The autocomplete prompt now names its highlighted suggestion, so a short box follows it; an empty select list's "no matches" line stands in for the highlight; and the reorder list's Home and End jump to its first and last rows, as on every list. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
3930 lines
168 KiB
Python
3930 lines
168 KiB
Python
# SPDX-License-Identifier: Apache-2.0
|
||
"""Unit tests for the reusable text-UI library (``meshterm.ui.tui``).
|
||
|
||
These exercise the pure logic — ANSI rendering/slicing, selection filtering and navigation,
|
||
scroll math, the frame composition's terminal-fit guarantee, prompt editing/validation, and
|
||
the progress handle's Rich-``Progress`` parity — without standing up a real prompt_toolkit
|
||
application, so they run fast and headless.
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import asyncio
|
||
|
||
from prompt_toolkit.input.defaults import create_pipe_input
|
||
from prompt_toolkit.output import DummyOutput
|
||
from rich.cells import cell_len
|
||
from rich.console import Group
|
||
from rich.panel import Panel
|
||
from rich.table import Table
|
||
from rich.text import Text
|
||
|
||
from meshterm import copyright_notice
|
||
from meshterm.ui.menus import section_heading
|
||
from meshterm.ui.pathline import CRACK_TAIL, PathHop, PathLine
|
||
from meshterm.ui.tui import frame
|
||
from meshterm.ui.tui.progress import ProgressScreen
|
||
from meshterm.ui.tui.prompt import (
|
||
AutocompleteScreen,
|
||
ButtonDialog,
|
||
ConfirmScreen,
|
||
TextScreen,
|
||
)
|
||
from meshterm.ui.tui.render import render_lines, render_to_ansi
|
||
from meshterm.ui.tui.screen import CANCEL, Screen, ScrollScreen
|
||
from meshterm.ui.tui.select import Choice, ReorderScreen, SelectScreen, Separator
|
||
from meshterm.ui.tui.session import TuiSession
|
||
from meshterm.ui.tui.spinner import spinner_interval
|
||
from tests.conftest import plain as _plain
|
||
|
||
_UNSET = object()
|
||
|
||
|
||
class _Fut:
|
||
"""A minimal stand-in for an asyncio.Future used to capture a screen's result."""
|
||
|
||
def __init__(self) -> None:
|
||
self.result = _UNSET
|
||
self._done = False
|
||
|
||
def done(self) -> bool:
|
||
return self._done
|
||
|
||
def set_result(self, value: object) -> None:
|
||
self.result = value
|
||
self._done = True
|
||
|
||
|
||
def _run(screen, action: str, data: str = ""):
|
||
"""Attach a fake future, dispatch one action, and return the captured result."""
|
||
screen.future = _Fut()
|
||
screen.handle(action, data)
|
||
return screen.future.result
|
||
|
||
|
||
# --- render ------------------------------------------------------------------
|
||
|
||
|
||
def test_render_lines_counts_visible_rows() -> None:
|
||
"""A three-row table renders to a matching count of ANSI lines (no phantom blank)."""
|
||
table = Table(show_header=False, box=None)
|
||
table.add_column("a")
|
||
for value in ("one", "two", "three"):
|
||
table.add_row(value)
|
||
lines = render_lines(table, 40)
|
||
assert len(lines) == 3
|
||
assert not lines[-1].strip().endswith("\n")
|
||
|
||
|
||
def test_render_to_ansi_wraps_to_width() -> None:
|
||
"""Rendering honors the requested width, wrapping long text onto multiple lines."""
|
||
long = Text("word " * 40) # 200 chars, must wrap under width 20
|
||
lines = render_to_ansi(long, 20).split("\n")
|
||
assert len(lines) > 1
|
||
|
||
|
||
# --- select ------------------------------------------------------------------
|
||
|
||
|
||
def _menu() -> SelectScreen:
|
||
items = [
|
||
Separator("── group ──"),
|
||
Choice("alpha", 1),
|
||
Choice("beta", 2),
|
||
Choice("gamma", 3),
|
||
]
|
||
return SelectScreen("pick", items, default=2)
|
||
|
||
|
||
def test_select_default_and_arrows_walk_the_choices() -> None:
|
||
"""The default choice is preselected and the arrows step over the selectable choices."""
|
||
screen = _menu()
|
||
assert _run(screen, "enter") == 2 # default is beta
|
||
screen = _menu()
|
||
screen.handle("down") # beta -> gamma
|
||
screen.handle("up") # gamma -> beta
|
||
screen.handle("up") # beta -> alpha
|
||
assert _run(screen, "enter") == 1
|
||
|
||
|
||
def test_select_filter_narrows_but_keeps_separators() -> None:
|
||
"""Typing filters to matching choices while the section headings stay in place."""
|
||
screen = _menu()
|
||
screen.handle("text", "a") # matches alpha, beta, gamma — all contain 'a'
|
||
rows = screen._rows()
|
||
assert [r.title for r in rows if isinstance(r, Separator)] == ["── group ──"]
|
||
assert {r.title for r in rows if isinstance(r, Choice)} == {"alpha", "beta", "gamma"}
|
||
screen.handle("text", "l") # now 'al' -> only alpha (the heading still shows)
|
||
rows = screen._rows()
|
||
assert [r.title for r in rows if isinstance(r, Choice)] == ["alpha"]
|
||
assert [r.title for r in rows if isinstance(r, Separator)] == ["── group ──"]
|
||
assert _run(screen, "enter") == 1
|
||
|
||
|
||
def test_select_filter_ignores_leading_and_trailing_spaces() -> None:
|
||
"""A leading space never begins the filter; a trailing one is dropped when matching."""
|
||
screen = _menu()
|
||
screen.handle("text", " ") # ignored — the filter never starts with whitespace
|
||
assert screen._filter == ""
|
||
for ch in "alpha ": # "alpha", then a trailing space
|
||
screen.handle("text", ch)
|
||
assert screen._filter == "alpha " # the space stays in the buffer…
|
||
# …but matching strips it, so the trailing space doesn't stop "alpha" from matching.
|
||
assert [r.title for r in screen._rows() if isinstance(r, Choice)] == ["alpha"]
|
||
|
||
|
||
def test_select_non_filterable_ignores_typing() -> None:
|
||
"""With filtering off, typed keys neither narrow the list nor add a filter line."""
|
||
screen = SelectScreen("pick", [Choice("alpha", 1), Choice("beta", 2)], filterable=False)
|
||
screen.handle("text", "a")
|
||
screen.handle("backspace")
|
||
assert screen._filter == ""
|
||
assert len(screen._rows()) == 2 # nothing was filtered out
|
||
|
||
|
||
def test_select_delete_hint_follows_the_highlight() -> None:
|
||
"""The 'Del remove' atom shows only while the cursor sits on a deletable row."""
|
||
screen = SelectScreen(
|
||
"pick",
|
||
[Choice("keep", 1), Choice("drop", 2, deletable=True)],
|
||
footer_hint="↑↓ move · Enter select · Esc quit",
|
||
delete_hint="Del remove",
|
||
filterable=False,
|
||
)
|
||
# On the non-deletable row, the footer is the plain base hint.
|
||
assert "Del remove" not in screen.footer_hint
|
||
screen.handle("down") # move onto the deletable row
|
||
# The atom appears, spliced before the trailing Esc clause (Esc stays last).
|
||
assert screen.footer_hint == "↑↓ move · Enter select · Del remove · Esc quit"
|
||
screen.handle("up") # back to the plain row
|
||
assert "Del remove" not in screen.footer_hint
|
||
# The box is always sized for the fullest footer, so it never widens on the move.
|
||
assert "Del remove" in screen.sizing_footer_hint
|
||
|
||
|
||
def test_select_no_delete_hint_leaves_footer_fixed() -> None:
|
||
"""Without a delete_hint, a deletable row doesn't touch the footer."""
|
||
screen = SelectScreen(
|
||
"pick", [Choice("drop", 1, deletable=True)], footer_hint="↑↓ move · Esc quit"
|
||
)
|
||
assert screen.footer_hint == "↑↓ move · Esc quit"
|
||
assert screen.sizing_footer_hint == "↑↓ move · Esc quit"
|
||
|
||
|
||
def test_select_escape_cancels() -> None:
|
||
"""Esc resolves the sentinel rather than a value."""
|
||
assert _run(_menu(), "escape") is CANCEL
|
||
|
||
|
||
def test_select_cursor_line_tracks_selection() -> None:
|
||
"""The reported cursor line accounts for separators (and the filter line)."""
|
||
screen = _menu() # default beta -> row index 2 (sep, alpha, beta)
|
||
screen.render_body(40)
|
||
assert screen.cursor_line() == 2
|
||
|
||
|
||
def test_select_cursor_line_counts_a_prompt_once() -> None:
|
||
"""Under a prompt, the reported cursor line is the highlighted row's own line.
|
||
|
||
The rows are laid out after the prompt's lines, so the index they are recorded at
|
||
already counts them. Adding the prompt's height on top reported a line further down,
|
||
and the frame kept *that* line in view: walking ↑ then carried the real highlight above
|
||
the window's top edge (the archive preview, whose prompt sits over a long list).
|
||
"""
|
||
items = [Choice(f"c{i}", i) for i in range(12)]
|
||
screen = SelectScreen("pick", items, prompt="Read the list, then pick one.", default=3)
|
||
lines = screen.render_body(40)
|
||
assert "❯ c3" in Text.from_ansi(lines[screen.cursor_line()]).plain
|
||
|
||
|
||
def test_select_callable_title_re_renders_live() -> None:
|
||
"""A callable title is resolved on every repaint, so a live badge tracks state."""
|
||
unread = {"n": 0}
|
||
screen = SelectScreen("pick", [Choice(lambda: f"chan ● {unread['n']}", 1)])
|
||
assert "chan ● 0" in "\n".join(screen.render_body(40))
|
||
unread["n"] = 3 # a message arrived while the list is open
|
||
assert "chan ● 3" in "\n".join(screen.render_body(40))
|
||
|
||
|
||
def test_select_width_aware_title_fits_itself_to_the_row() -> None:
|
||
"""A width-aware title fits itself to the row it is drawn on.
|
||
|
||
A route middle-elides, while the natural form still feeds filtering and the
|
||
dialog's own width measure.
|
||
"""
|
||
seen: list[int] = []
|
||
|
||
def fitted(width: int) -> str:
|
||
seen.append(width)
|
||
return "you → hub → far" if width >= 20 else "you ⋯ far"
|
||
|
||
screen = SelectScreen("pick", [Choice(fitted, 1)])
|
||
assert "you ⋯ far" in "\n".join(screen.render_body(12))
|
||
assert seen[-1] == 10 # the row's content area: the width less the 2-cell pointer
|
||
assert "you → hub → far" in "\n".join(screen.render_body(40))
|
||
assert screen.dialog_width > cell_len("you → hub → far") # measured at its fullest
|
||
screen.handle("text", "hub") # the filter reads the natural (unbounded) form
|
||
assert screen._rows() == screen._items
|
||
|
||
|
||
def test_select_row_cracks_a_chip_path_and_ellipsizes_everything_else() -> None:
|
||
"""A row too wide for the list is cut, not truncated.
|
||
|
||
A row carrying a path line (a trophy walk, a probe candidate) breaks its chip off
|
||
on the crack, while an ordinary prose row keeps the ellipsis. The row itself
|
||
decides which — the list has no idea it is ever holding a route.
|
||
"""
|
||
route = PathLine(
|
||
[PathHop(f"NODE{i:02d}", key=f"{i:02x}aa") for i in range(8)], mode="powerline"
|
||
).text()
|
||
screen = SelectScreen("pick", [Choice(route, 1), Choice("a plainly worded row", 2)])
|
||
rows = [ln for ln in _plain(screen.render_body(16)).split("\n") if ln.strip()]
|
||
route_row = next(ln for ln in rows if "NODE" in ln)
|
||
prose_row = next(ln for ln in rows if "plainly" in ln)
|
||
assert route_row.rstrip().endswith(CRACK_TAIL) and "…" not in route_row
|
||
assert prose_row.rstrip().endswith("…") # prose was shortened; that is what happened
|
||
|
||
|
||
def test_select_hscroll_highlight_keeps_the_natural_row() -> None:
|
||
"""In an hscroll list the highlighted row keeps its natural, unfitted text.
|
||
|
||
←→ slide the full line there, while every other row still elides itself to the
|
||
width.
|
||
"""
|
||
|
||
def fitted(width: int) -> str:
|
||
return "start middle end" if width >= 20 else "start ⋯ end"
|
||
|
||
items = [Choice(fitted, 1), Choice(fitted, 2)]
|
||
screen = SelectScreen("pick", items, hscroll=True)
|
||
body = "\n".join(screen.render_body(14))
|
||
assert "start middl" in body # the highlighted row: natural form, cropped by the screen
|
||
assert "start ⋯ end" in body # the unhighlighted row fitted itself
|
||
|
||
|
||
def test_select_hscroll_highlight_draws_a_fitted_row_fitted() -> None:
|
||
"""A row whose fitted form is complete (``Choice.fitted``) stays fitted under the highlight.
|
||
|
||
The Channels list's rows shorten their trailing chart to the width; drawn natural under
|
||
the highlight, the chart came back cut off on exactly the row being read. Nothing past
|
||
such a row's edge is worth sliding to, so it doesn't claim to overflow either: no ←→
|
||
atom, and → leaves it where it is.
|
||
"""
|
||
|
||
def chart_row(width: int) -> str:
|
||
return "lanes " + "⣀" * max(0, min(12, width - 6))
|
||
|
||
screen = SelectScreen("pick", [Choice(chart_row, 1, fitted=True)], hscroll=True)
|
||
lines = [Text.from_ansi(line).plain for line in screen.render_body(14)]
|
||
assert lines[0] == "❯ lanes " + "⣀" * 6 # fitted to the 12-cell content area
|
||
assert not screen._selected_overflows()
|
||
screen.handle("right")
|
||
assert [Text.from_ansi(line).plain for line in screen.render_body(14)] == lines
|
||
|
||
|
||
def test_select_filter_matches_callable_title() -> None:
|
||
"""Type-to-filter matches against a callable title's current text."""
|
||
screen = SelectScreen("pick", [Choice(lambda: "alpha", 1), Choice("beta", 2)])
|
||
screen.handle("text", "alp")
|
||
assert [r.label for r in screen._rows()] == ["alpha"]
|
||
assert _run(screen, "enter") == 1
|
||
|
||
|
||
# --- edge scroll ---------------------------------------------------------------
|
||
|
||
|
||
def _edge_list() -> SelectScreen:
|
||
"""Six lines of lead-in over four choices and a closing note: taller than a 4-row view."""
|
||
items: list = [Separator(f"lead {i}") for i in range(6)]
|
||
items += [Choice(name, name) for name in ("alpha", "beta", "gamma", "delta")]
|
||
items.append(Separator("closing note"))
|
||
return SelectScreen("pick", items)
|
||
|
||
|
||
def _press(screen, action: str, data: str = "") -> None:
|
||
"""Dispatch one key as the session does: edge scroll first, then the screen itself."""
|
||
if not screen.edge_scroll(action):
|
||
screen.handle(action, data)
|
||
|
||
|
||
def _view(screen, viewport: int = 4) -> list[str]:
|
||
"""Paint once through the frame's own slicer and return the visible rows' text."""
|
||
visible, _above, _below = frame._visible_slice(screen, screen.render_body(40), viewport)
|
||
return [_plain([line]).strip() for line in visible]
|
||
|
||
|
||
def test_edge_scroll_brings_back_what_sits_above_the_first_row() -> None:
|
||
"""↑ on the first row scrolls the page a line instead, up to its very top.
|
||
|
||
Following the highlight down had scrolled the lead-in off, and nothing up there can
|
||
take the highlight, so before edge scroll there was no way back to it. The highlight
|
||
may scroll off screen meanwhile; the snap-back brings it home without moving it.
|
||
"""
|
||
screen = _edge_list()
|
||
assert _view(screen) == ["lead 3", "lead 4", "lead 5", "❯ alpha"]
|
||
_press(screen, "up") # alpha is the first row: the page scrolls a line instead
|
||
assert _view(screen)[0] == "lead 2"
|
||
for _ in range(5):
|
||
_press(screen, "up")
|
||
view = _view(screen)
|
||
assert view == ["lead 0", "lead 1", "lead 2", "lead 3"] # the top; alpha is off screen
|
||
_press(screen, "up")
|
||
assert _view(screen) == view # nothing further up: it stays
|
||
# The snap-back: ↓ only brings alpha back, still highlighted; the next ↓ moves.
|
||
_press(screen, "down")
|
||
assert "❯ alpha" in _view(screen)
|
||
_press(screen, "down")
|
||
assert "❯ beta" in _view(screen)
|
||
|
||
|
||
def test_edge_scroll_brings_back_what_sits_below_the_last_row() -> None:
|
||
"""↓ on the last row scrolls the closing note into view; ↑ moves on as ever."""
|
||
screen = _edge_list()
|
||
_view(screen)
|
||
for _ in range(3):
|
||
_press(screen, "down")
|
||
assert _view(screen) == ["alpha", "beta", "gamma", "❯ delta"] # the note is below
|
||
_press(screen, "down") # delta is the last row
|
||
assert _view(screen) == ["beta", "gamma", "❯ delta", "closing note"]
|
||
_press(screen, "up") # the highlight is in view, so this simply moves it
|
||
assert "❯ gamma" in _view(screen)
|
||
|
||
|
||
def test_enter_on_a_highlight_scrolled_off_brings_it_back_instead() -> None:
|
||
"""Nothing runs that can't be seen: the first Enter only snaps the highlight back."""
|
||
screen = _edge_list()
|
||
screen.future = _Fut()
|
||
_view(screen)
|
||
for _ in range(3):
|
||
_press(screen, "up")
|
||
assert "❯ alpha" not in _view(screen)
|
||
_press(screen, "enter")
|
||
assert screen.future.result is _UNSET # nothing was chosen…
|
||
assert "❯ alpha" in _view(screen) # …the highlight came back
|
||
_press(screen, "enter")
|
||
assert screen.future.result == "alpha"
|
||
|
||
|
||
def test_home_and_end_take_the_page_to_its_very_ends() -> None:
|
||
"""End shows the closing note under the last row; Home the lead-in over the first."""
|
||
screen = _edge_list()
|
||
_view(screen)
|
||
_press(screen, "end")
|
||
assert _view(screen) == ["beta", "gamma", "❯ delta", "closing note"]
|
||
_press(screen, "home")
|
||
assert _view(screen) == ["lead 0", "lead 1", "lead 2", "lead 3"]
|
||
_press(screen, "down") # the snap-back: alpha, the highlight Home left, comes into view
|
||
assert "❯ alpha" in _view(screen)
|
||
|
||
|
||
def test_any_other_key_ends_the_edge_scroll() -> None:
|
||
"""A typed filter moves the highlight, so the frame follows it again at once."""
|
||
screen = _edge_list()
|
||
_view(screen)
|
||
for _ in range(3):
|
||
_press(screen, "up")
|
||
_view(screen)
|
||
assert screen.edge_scrolled
|
||
_press(screen, "text", "g")
|
||
assert not screen.edge_scrolled
|
||
assert "❯ gamma" in _view(screen)
|
||
|
||
|
||
def test_a_screen_without_a_highlight_keeps_its_own_arrows() -> None:
|
||
"""Edge scroll stays out of a plain scroll screen: its ↑↓ are its own."""
|
||
screen = ScrollScreen(Text("\n".join(f"row {i}" for i in range(20))), title="t")
|
||
for action in ("up", "down", "home", "end", "enter"):
|
||
assert not screen.edge_scroll(action)
|
||
|
||
|
||
class _Rows(Screen):
|
||
"""A screen with no edge-scroll code at all: a lead-in, a highlight over rows, a note.
|
||
|
||
Its ↑↓ clamp as every cursor's do, its Home and End move nothing, and it names its
|
||
highlight's line in ``cursor_line`` — which is all a screen supplies. ``window`` draws
|
||
the rows that many at a time, scrolling with the highlight as a ListWindow does.
|
||
"""
|
||
|
||
def __init__(self, count: int = 3, *, window: int | None = None) -> None:
|
||
super().__init__()
|
||
self._count = count
|
||
self._window = window or count
|
||
self._index = 0
|
||
self._top = 0
|
||
self._cursor: int | None = None
|
||
|
||
def render_body(self, width: int) -> list[str]:
|
||
self._top = max(min(self._top, self._index), self._index - self._window + 1)
|
||
lines = [f"lead {i}" for i in range(4)]
|
||
for i in range(self._top, self._top + self._window):
|
||
if i == self._index:
|
||
self._cursor = len(lines)
|
||
lines.append(("❯ " if i == self._index else " ") + f"row {i}")
|
||
lines.append("note")
|
||
return lines
|
||
|
||
def cursor_line(self) -> int | None:
|
||
return self._cursor
|
||
|
||
def handle(self, action: str, data: str = "") -> None:
|
||
if action == "up":
|
||
self._index = max(0, self._index - 1)
|
||
elif action == "down":
|
||
self._index = min(self._count - 1, self._index + 1)
|
||
|
||
|
||
def test_every_screen_with_a_highlight_edge_scrolls() -> None:
|
||
"""Edge scroll is inherited: a screen that only names its highlight's line has it.
|
||
|
||
_Rows says nothing about where its edges are — the paint reads them off the page.
|
||
"""
|
||
screen = _Rows()
|
||
assert _view(screen) == ["lead 1", "lead 2", "lead 3", "❯ row 0"]
|
||
_press(screen, "up") # row 0 is the first row: the page takes the line
|
||
assert _view(screen) == ["lead 0", "lead 1", "lead 2", "lead 3"]
|
||
_press(screen, "down") # the snap-back
|
||
assert "❯ row 0" in _view(screen)
|
||
for _ in range(2):
|
||
_press(screen, "down")
|
||
_view(screen)
|
||
_press(screen, "down") # row 2 is the last: the note comes into view
|
||
assert _view(screen) == ["row 0", "row 1", "❯ row 2", "note"]
|
||
|
||
|
||
def test_several_arrows_before_a_paint_scroll_as_many_lines() -> None:
|
||
"""Arrows faster than the paint are judged together: three at the edge, three lines."""
|
||
screen = _Rows()
|
||
_press(screen, "up") # before any paint: nothing on the page to judge yet
|
||
_view(screen)
|
||
for _ in range(2):
|
||
_press(screen, "down")
|
||
_view(screen)
|
||
for _ in range(3):
|
||
_press(screen, "down")
|
||
view = _view(screen)
|
||
assert view == ["row 0", "row 1", "❯ row 2", "note"] # clamped at the page's end
|
||
|
||
|
||
def test_a_list_scrolling_in_its_own_window_is_not_at_its_edge() -> None:
|
||
"""A highlight holding its line while rows pass under it has moved: no edge scroll.
|
||
|
||
Walking ↓ down a windowed list keeps the highlight on the window's last line, so the
|
||
line's index alone can't tell it from one that stayed put; the line as drawn can.
|
||
"""
|
||
screen = _Rows(6, window=2)
|
||
_view(screen)
|
||
_press(screen, "down")
|
||
_view(screen)
|
||
before = screen.scroll
|
||
_press(screen, "down") # row 2: the same body line as row 1, drawn differently
|
||
assert "❯ row 2" in _view(screen)
|
||
assert not screen.edge_scrolled and screen.scroll == before
|
||
for _ in range(3):
|
||
_press(screen, "down")
|
||
_view(screen)
|
||
_press(screen, "down") # row 5 is the last
|
||
assert _view(screen)[-1] == "note" and screen.edge_scrolled
|
||
|
||
|
||
def test_a_screen_can_keep_its_arrows_from_edge_scroll() -> None:
|
||
"""``edge_scrolls`` off — the remote CLI, whose ↑↓ recall history — never scrolls."""
|
||
screen = _Rows()
|
||
screen.edge_scrolls = False
|
||
view = _view(screen)
|
||
_press(screen, "up")
|
||
assert _view(screen) == view and not screen.edge_scrolled
|
||
|
||
|
||
def test_home_that_moves_no_highlight_leaves_the_arrows_to_it() -> None:
|
||
"""Home takes the page to its top; only the page says where the highlight went.
|
||
|
||
_Rows's Home moves nothing, so ↑ afterwards is still a step up the rows — the arrow
|
||
pointing away from an off-screen highlight goes to the screen, and the highlight
|
||
comes back into view with it.
|
||
"""
|
||
screen = _Rows()
|
||
_view(screen)
|
||
for _ in range(2):
|
||
_press(screen, "down")
|
||
_view(screen)
|
||
_press(screen, "home")
|
||
assert _view(screen)[0] == "lead 0"
|
||
_press(screen, "up")
|
||
assert "❯ row 1" in _view(screen)
|
||
|
||
|
||
def test_a_list_with_no_rows_still_edge_scrolls() -> None:
|
||
"""With nothing to highlight, the empty state holds the highlight's place."""
|
||
screen = _edge_list()
|
||
_view(screen)
|
||
_press(screen, "text", "z")
|
||
_press(screen, "text", "z")
|
||
assert _view(screen)[-1] == "no matches"
|
||
_press(screen, "up")
|
||
view = _view(screen)
|
||
assert "no matches" not in view and view[-1] == "closing note"
|
||
|
||
|
||
def test_reorder_home_and_end_reach_the_ends() -> None:
|
||
"""Home and End jump the reorder list's highlight too, as on every list."""
|
||
screen = ReorderScreen("order", ["a", "b", "c"])
|
||
screen.handle("end")
|
||
assert any(line.startswith("❯ c") for line in _view(screen))
|
||
screen.handle("home")
|
||
assert any(line.startswith("❯ a") for line in _view(screen))
|
||
|
||
|
||
def test_autocomplete_keeps_its_highlighted_suggestion_in_view() -> None:
|
||
"""A box too short for every suggestion follows the highlight, and edge-scrolls."""
|
||
screen = AutocompleteScreen("t", [f"opt{i}" for i in range(8)], prompt="pick one")
|
||
_view(screen, 5)
|
||
for _ in range(7):
|
||
_press(screen, "down")
|
||
_view(screen, 5)
|
||
assert _view(screen, 5)[-1] == "❯ opt7"
|
||
for _ in range(7):
|
||
_press(screen, "up")
|
||
_view(screen, 5)
|
||
assert _view(screen, 5)[0] == "❯ opt0"
|
||
_press(screen, "up") # the first suggestion: the field comes back above it
|
||
assert _view(screen, 5)[1] == "❯ opt0"
|
||
|
||
|
||
def test_select_clamps_at_the_ends() -> None:
|
||
"""Up on the first row and Down on the last stay put — no cursor in the app rolls over."""
|
||
items = [Choice("alpha", 1), Choice("beta", 2), Choice("gamma", 3)]
|
||
screen = SelectScreen("pick", items)
|
||
screen.handle("up") # already on the first choice — must not jump to the last
|
||
assert _run(screen, "enter") == 1
|
||
screen = SelectScreen("pick", items, default=3) # last choice
|
||
screen.handle("down") # already on the last — must not wrap to the first
|
||
assert _run(screen, "enter") == 3
|
||
|
||
|
||
def test_select_pageup_pagedown_jump_by_a_screenful() -> None:
|
||
"""PageDown/PageUp move the highlight a screenful at a time, clamped to the choice range."""
|
||
items = [Choice(f"c{i}", i) for i in range(30)]
|
||
screen = SelectScreen("pick", items) # starts on the first choice
|
||
screen.note_metrics(total=30, viewport=11) # a screenful is viewport - 1 = 10 rows
|
||
screen.handle("pagedown")
|
||
assert _run(screen, "enter") == 10 # advanced one page (viewport - 1) down
|
||
screen = SelectScreen("pick", items, default=25)
|
||
screen.note_metrics(total=30, viewport=11)
|
||
screen.handle("pageup")
|
||
assert _run(screen, "enter") == 25 - 10 # and one page back up
|
||
|
||
|
||
def _grouped_menu(default: object = None) -> SelectScreen:
|
||
"""A two-section menu long enough that each section scrolls past a small viewport."""
|
||
items: list = [section_heading("Channels")]
|
||
items += [Choice(f"chan{i}", ("c", i)) for i in range(6)]
|
||
items += [section_heading("Direct")]
|
||
items += [Choice(f"peer{i}", ("d", i)) for i in range(8)]
|
||
return SelectScreen("pick", items, default=default)
|
||
|
||
|
||
def _top_plain(screen: SelectScreen, viewport: int) -> str:
|
||
"""Slice the screen at its selection-driven scroll and return the top row's plain text."""
|
||
lines = screen.render_body(40)
|
||
visible, _above, _below = frame._visible_slice(screen, lines, viewport)
|
||
return Text.from_ansi(visible[0]).plain.strip()
|
||
|
||
|
||
def test_select_pins_section_heading_when_it_scrolls_off() -> None:
|
||
"""Selecting deep in a section keeps that section's heading pinned to the top row."""
|
||
# Highlighting a channel far enough down pushes the "Channels" heading off the top, so it
|
||
# is re-pinned rather than vanishing.
|
||
assert _top_plain(_grouped_menu(default=("c", 5)), viewport=6) == "── Channels ──"
|
||
# Deep into the Direct group, the pinned heading switches to that section's.
|
||
assert _top_plain(_grouped_menu(default=("d", 6)), viewport=6) == "── Direct ──"
|
||
|
||
|
||
def test_select_does_not_pin_a_heading_that_is_still_visible() -> None:
|
||
"""With the list scrolled to the top, the real heading shows — nothing is pinned over it."""
|
||
screen = _grouped_menu() # default selection is the first choice, so scroll stays at 0
|
||
lines = screen.render_body(40)
|
||
visible, above, _below = frame._visible_slice(screen, lines, 6)
|
||
assert Text.from_ansi(visible[0]).plain.strip() == "── Channels ──"
|
||
assert above is False # top of the list; no pinned duplicate and no "more above"
|
||
|
||
|
||
def _trophy_shaped() -> SelectScreen:
|
||
"""The Trophy case's shape: a heading, its description, then that board's rows."""
|
||
items: list = [section_heading("Longest haul")]
|
||
items += [Separator(f" description line {i}", style="muted") for i in range(2)]
|
||
items += [Choice(f"rec{i}", ("l", i)) for i in range(6)]
|
||
items += [section_heading("Widest arc"), Separator(" no records yet", style="muted")]
|
||
items += [Choice(f"arc{i}", ("a", i)) for i in range(6)]
|
||
return SelectScreen("Trophy case", items, default=("l", 5))
|
||
|
||
|
||
def _blocks(screen: SelectScreen) -> list[list[str]]:
|
||
"""Each recorded sticky block's rows, as plain text."""
|
||
return [
|
||
[Text.from_ansi(line).plain.strip() for line in rows]
|
||
for _idx, rows in screen._sticky_headers
|
||
]
|
||
|
||
|
||
def test_select_blocks_a_heading_with_the_prose_written_under_it() -> None:
|
||
"""A heading's landmark runs on through the separators that immediately follow it.
|
||
|
||
The Trophy case's shape: each discipline's ``── heading ──`` is followed by its wrapped
|
||
description, which explains the rows below and so belongs overhead with the heading —
|
||
while prose that follows a *row* (a stray note, the exit group's blank) labels nothing
|
||
and is no landmark at all.
|
||
"""
|
||
screen = _trophy_shaped()
|
||
screen.render_body(40)
|
||
assert _blocks(screen) == [
|
||
["── Longest haul ──", "description line 0", "description line 1"],
|
||
["── Widest arc ──", "no records yet"],
|
||
]
|
||
|
||
|
||
def test_select_pins_a_block_row_only_once_it_has_scrolled_off() -> None:
|
||
"""A block hands its rows over one at a time, so the pins continue into the body.
|
||
|
||
While the description is still the top content row the heading alone pins over it; once
|
||
both are gone the two pin together. Never the prose alone — the row that says *which*
|
||
section this is leads whatever is overhead.
|
||
"""
|
||
screen = _trophy_shaped()
|
||
screen.render_body(40)
|
||
screen.note_metrics(total=20, viewport=12)
|
||
plain = lambda scroll: [ # noqa: E731 - a one-liner reader for the assertions below
|
||
Text.from_ansi(line).plain.strip() for line in screen.sticky_rows(scroll)
|
||
]
|
||
assert plain(0) == [] # the heading is the top row itself; nothing to duplicate
|
||
assert plain(1) == ["── Longest haul ──"] # its description is still on screen
|
||
assert plain(2) == ["── Longest haul ──", "description line 0"]
|
||
assert plain(4) == [
|
||
"── Longest haul ──",
|
||
"description line 0",
|
||
"description line 1",
|
||
]
|
||
# A block never eats more than half the viewport — the rows go from the end, so the
|
||
# heading is the last thing a short terminal gives up.
|
||
screen.note_metrics(total=20, viewport=4)
|
||
assert plain(4) == ["── Longest haul ──", "description line 0"]
|
||
screen.note_metrics(total=20, viewport=2)
|
||
assert plain(4) == ["── Longest haul ──"]
|
||
|
||
|
||
def test_select_pins_the_heading_not_the_prose_beneath_it() -> None:
|
||
"""The pinned rows always *lead* with the heading, never the last muted line under it."""
|
||
screen = _trophy_shaped()
|
||
assert _top_plain(screen, viewport=6) == "── Longest haul ──"
|
||
# And the empty-state note can't stand in for its heading either.
|
||
assert _top_plain(_reselect(screen, ("a", 4)), viewport=6) == "── Widest arc ──"
|
||
|
||
|
||
def _reselect(screen: SelectScreen, value: object) -> SelectScreen:
|
||
"""Move a select screen's highlight to ``value`` (by walking Down to it)."""
|
||
while screen._choices()[screen._index].value != value:
|
||
screen.handle("down")
|
||
return screen
|
||
|
||
|
||
def test_select_pinned_heading_keeps_the_last_row_reachable() -> None:
|
||
"""Even with a heading pinned, the bottom choice stays fully visible (not clipped)."""
|
||
screen = _grouped_menu()
|
||
screen.handle("end") # highlight the final choice
|
||
lines = screen.render_body(40)
|
||
visible, _above, below = frame._visible_slice(screen, lines, 6)
|
||
assert Text.from_ansi(visible[0]).plain.strip() == "── Direct ──" # heading pinned
|
||
assert any("peer7" in Text.from_ansi(row).plain for row in visible) # last row shown
|
||
assert below is False # and we know we're at the bottom
|
||
|
||
|
||
def _columned_menu(default: object = None) -> SelectScreen:
|
||
"""A grouped menu led by a pinned column header, like the config editor's."""
|
||
items: list = [Separator(" SETTING VALUE", pinned=True)]
|
||
items += [section_heading("Channels")]
|
||
items += [Choice(f"chan{i}", ("c", i)) for i in range(6)]
|
||
items += [section_heading("Direct")]
|
||
items += [Choice(f"peer{i}", ("d", i)) for i in range(8)]
|
||
return SelectScreen("pick", items, default=default)
|
||
|
||
|
||
def test_select_pins_a_column_header_above_the_section_heading() -> None:
|
||
"""A pinned column header rides the whole list, the governing heading under it."""
|
||
screen = _columned_menu(default=("d", 6)) # deep in the second section
|
||
lines = screen.render_body(40)
|
||
visible, above, _below = frame._visible_slice(screen, lines, 7)
|
||
assert [Text.from_ansi(row).plain.strip() for row in visible[:2]] == [
|
||
"SETTING VALUE", # the lanes, pinned for every section
|
||
"── Direct ──", # over the section the highlight is in
|
||
]
|
||
assert above is True
|
||
assert any("peer6" in Text.from_ansi(row).plain for row in visible) # highlight in view
|
||
# The pinned header is no section landmark — only the two headings are.
|
||
assert _blocks(screen) == [["── Channels ──"], ["── Direct ──"]]
|
||
|
||
|
||
def test_select_column_header_shows_itself_at_the_top_and_pins_alone() -> None:
|
||
"""Unscrolled it just draws; past it, it pins even before any heading scrolls off."""
|
||
screen = _columned_menu() # highlight on the first choice — the list sits at the top
|
||
lines = screen.render_body(40)
|
||
visible, above, _below = frame._visible_slice(screen, lines, 8)
|
||
assert Text.from_ansi(visible[0]).plain.strip() == "SETTING VALUE"
|
||
assert above is False # nothing pinned over the real row, nothing above it
|
||
# Scrolled one row on, the header pins while its own section heading is still the top
|
||
# content row — so it is the only pin.
|
||
assert screen.sticky_rows(1) == [screen._pinned_header[1]]
|
||
|
||
|
||
def test_select_pinned_column_header_keeps_the_last_row_reachable() -> None:
|
||
"""Two pinned rows still leave the bottom choice fully visible (not clipped)."""
|
||
screen = _columned_menu()
|
||
screen.handle("end") # highlight the final choice
|
||
lines = screen.render_body(40)
|
||
visible, _above, below = frame._visible_slice(screen, lines, 7)
|
||
assert Text.from_ansi(visible[0]).plain.strip() == "SETTING VALUE"
|
||
assert any("peer7" in Text.from_ansi(row).plain for row in visible)
|
||
assert below is False
|
||
|
||
|
||
def test_select_walking_up_from_the_bottom_keeps_the_highlight_in_view() -> None:
|
||
"""↑ from the last row to the first never leaves the highlight outside the window.
|
||
|
||
At the bottom the window slides down under its pinned rows, so they cost stale lines at
|
||
the top rather than the last ones. Walking back up, the highlight reaches the top line
|
||
of the scroll window before the scroll has to move. A slide past that line hid the row
|
||
the reader had just stepped onto, for one step with a column header pinned and for two
|
||
with a section heading under it.
|
||
"""
|
||
for viewport in (5, 6, 7, 8):
|
||
screen = _columned_menu()
|
||
screen.handle("end")
|
||
for _ in range(len(screen._choices())):
|
||
visible, _above, _below = frame._visible_slice(screen, screen.render_body(40), viewport)
|
||
rows = [Text.from_ansi(row).plain for row in visible]
|
||
current = screen._current_choice().label
|
||
assert any(f"❯ {current}" in row for row in rows), (viewport, current, rows)
|
||
screen.handle("up")
|
||
|
||
|
||
def test_select_resolves_a_width_aware_separator_at_the_render_width() -> None:
|
||
"""A callable separator title is handed the render width, so a header can fit itself."""
|
||
screen = SelectScreen(
|
||
"pick", [Separator(lambda w: f"HEADER@{w}", pinned=True), Choice("row", 1)]
|
||
)
|
||
assert Text.from_ansi(screen.render_body(30)[0]).plain.strip() == "HEADER@30"
|
||
assert Text.from_ansi(screen.render_body(48)[0]).plain.strip() == "HEADER@48"
|
||
# Natural-width measurement asks for the fullest form, not a terminal-sized one, so the
|
||
# box is sized to the whole header and only the terminal can force it to abbreviate.
|
||
natural = SelectScreen(
|
||
"pick",
|
||
[Separator(lambda w: "H" * min(w, 120), pinned=True), Choice("row", 1)],
|
||
footer_hint="Esc back",
|
||
)
|
||
assert natural.dialog_width >= 120
|
||
|
||
|
||
def test_select_pinned_header_crops_where_a_plain_separator_wraps() -> None:
|
||
"""A pinned row must stay exactly one row: too wide, it ellipsizes rather than wraps."""
|
||
wide = "SETTING" + " " * 40 + "DESCRIPTION"
|
||
screen = SelectScreen("pick", [Separator(wide, pinned=True), Choice("row", 1)])
|
||
lines = screen.render_body(24)
|
||
assert screen._pinned_header == (0, lines[0])
|
||
assert Text.from_ansi(lines[0]).plain.rstrip().endswith("…")
|
||
assert len(lines) == 2 # the header and the one choice — nothing wrapped onto a row
|
||
# An ordinary separator still wraps, each row of it counted as its own body line.
|
||
plain = SelectScreen("pick", [Separator(wide), Choice("row", 1)])
|
||
assert len(plain.render_body(24)) == 3
|
||
|
||
|
||
def test_screen_sticky_rows_stack_the_pinned_header_over_the_section_heading() -> None:
|
||
"""The shared rule composing both pins: whole-list header first, then the section's."""
|
||
screen = Screen()
|
||
screen.note_metrics(total=40, viewport=12)
|
||
screen._pinned_header = (0, "COLUMNS")
|
||
screen._sticky_headers = [(1, ["A"]), (5, ["B"])]
|
||
assert screen.sticky_rows(0) == [] # nothing has scrolled off yet
|
||
assert screen.sticky_rows(1) == ["COLUMNS"] # heading A is itself the top row
|
||
assert screen.sticky_rows(3) == ["COLUMNS", "A"] # inside section A
|
||
assert screen.sticky_rows(9) == ["COLUMNS", "B"] # below every heading → the last one
|
||
assert Screen().sticky_rows(9) == [] # nothing recorded → nothing pinned
|
||
|
||
|
||
def test_screen_sticky_block_picks_the_governing_recorded_block() -> None:
|
||
"""The base Screen.sticky_block logic is generic over any recorded landmark list.
|
||
|
||
Both the select list and the chat transcript reuse it by populating ``_sticky_headers``;
|
||
this exercises the shared rule directly: take the last block starting at or above the
|
||
offset, and pin exactly the rows of it the offset has passed.
|
||
"""
|
||
screen = Screen()
|
||
screen.note_metrics(total=40, viewport=12)
|
||
screen._sticky_headers = [(0, ["A"]), (5, ["B", "b"]), (12, ["C"])]
|
||
assert screen.sticky_block(0) == [] # block A is itself the top row
|
||
assert screen.sticky_block(3) == ["A"] # scrolled past A, before B → A governs
|
||
assert screen.sticky_block(5) == [] # block B's heading is now the top row
|
||
assert screen.sticky_block(6) == ["B"] # its second row is still on screen
|
||
assert screen.sticky_block(7) == ["B", "b"] # both gone → both pin
|
||
assert screen.sticky_block(20) == ["C"] # below every block → the last one pins
|
||
assert Screen().sticky_block(9) == [] # no recorded landmarks → nothing to pin
|
||
|
||
|
||
def test_select_ctrl_page_jumps_between_sections() -> None:
|
||
"""Ctrl+PageDown lands on the next section's first choice; Ctrl+PageUp walks back up."""
|
||
screen = _grouped_menu() # Channels (6) then Direct (8), highlight on the first choice
|
||
screen.handle("ctrl_pagedown")
|
||
assert _run(screen, "enter") == ("d", 0) # jumped to the first Direct choice
|
||
screen.handle("ctrl_pageup")
|
||
assert _run(screen, "enter") == ("c", 0) # already atop Direct → back to Channels' first
|
||
screen.handle("ctrl_pagedown")
|
||
assert _run(screen, "enter") == ("d", 0) # and forward to Direct again
|
||
|
||
|
||
# --- scroll ------------------------------------------------------------------
|
||
|
||
|
||
def test_screen_scroll_helpers_page_and_clamp_to_metrics() -> None:
|
||
"""The shared scroll helpers page by a screenful and clamp to the recorded body/viewport."""
|
||
screen = Screen()
|
||
screen.note_metrics(total=100, viewport=10)
|
||
screen.scroll_pages(1)
|
||
assert screen.scroll == 9 # a page is viewport - 1
|
||
screen.scroll_to_bottom()
|
||
assert screen.scroll == 90 # total - viewport
|
||
screen.scroll_lines(50)
|
||
assert screen.scroll == 90 # clamped, never past the bottom
|
||
screen.scroll_to_top()
|
||
assert screen.scroll == 0
|
||
|
||
|
||
def test_screen_section_scroll_walks_recorded_headers() -> None:
|
||
"""Ctrl+PageUp/PageDown move the scroll offset between recorded section boundaries."""
|
||
screen = Screen()
|
||
screen.note_metrics(total=100, viewport=10)
|
||
screen._sticky_headers = [(0, ["A"]), (20, ["B"]), (60, ["C"])]
|
||
screen.scroll_to_next_section()
|
||
assert screen.scroll == 20 # from the top → start of section B
|
||
screen.scroll_to_next_section()
|
||
assert screen.scroll == 60 # → start of C
|
||
screen.scroll_to_next_section()
|
||
assert screen.scroll == 90 # no section past C → clamp to the bottom
|
||
screen.scroll = 40 # mid-section B
|
||
screen.scroll_to_section_start()
|
||
assert screen.scroll == 20 # up to B's start
|
||
screen.scroll_to_section_start()
|
||
assert screen.scroll == 0 # already atop B → previous section (A at the top)
|
||
|
||
|
||
def test_scroll_screen_ctrl_edges_and_sectionless_fallback() -> None:
|
||
"""Ctrl+Home/End reach the edges; with no sections Ctrl+PageUp/PageDown do too."""
|
||
body = Text("\n".join(f"line {i}" for i in range(100)))
|
||
screen = ScrollScreen(body, title="log")
|
||
screen.render_body(40) # sets total = 100
|
||
screen.note_viewport(10)
|
||
screen.handle("ctrl_end")
|
||
assert screen.scroll == 90
|
||
screen.handle("ctrl_home")
|
||
assert screen.scroll == 0
|
||
screen.handle("ctrl_pagedown") # no sections recorded → falls through to the bottom
|
||
assert screen.scroll == 90
|
||
screen.handle("ctrl_pageup")
|
||
assert screen.scroll == 0
|
||
|
||
|
||
# --- scroll (existing) -------------------------------------------------------
|
||
|
||
|
||
def test_scroll_screen_paging_and_clamp() -> None:
|
||
"""PageDown advances by a page, End jumps to the bottom, both clamped to content."""
|
||
body = Text("\n".join(f"line {i}" for i in range(100)))
|
||
screen = ScrollScreen(body, title="log")
|
||
screen.render_body(40) # sets total = 100
|
||
screen.note_viewport(10)
|
||
screen.handle("pagedown")
|
||
assert screen.scroll == 9 # page = viewport - 1
|
||
screen.handle("end")
|
||
assert screen.scroll == 90 # total - viewport
|
||
screen.handle("home")
|
||
assert screen.scroll == 0
|
||
screen.handle("up")
|
||
assert screen.scroll == 0 # clamped, never negative
|
||
|
||
|
||
def test_scroll_screen_escape_resolves_none() -> None:
|
||
"""A result window dismisses to ``None`` on Esc or Enter."""
|
||
assert _run(ScrollScreen(Text("x")), "escape") is None
|
||
assert _run(ScrollScreen(Text("x")), "enter") is None
|
||
|
||
|
||
# --- reorder -----------------------------------------------------------------
|
||
|
||
|
||
def test_reorder_apply_row_commits_new_order() -> None:
|
||
"""Enter grabs and drops a list row; Enter on Apply commits the rearrangement."""
|
||
screen = ReorderScreen("order", ["a", "b", "c"])
|
||
screen.handle("enter") # grab "a"
|
||
assert screen._grabbed and "Enter drop" in screen.footer_hint
|
||
screen.handle("down") # carry it past "b"
|
||
screen.handle("enter") # drop
|
||
assert not screen._grabbed and "Enter grab" in screen.footer_hint
|
||
screen.handle("down") # cursor from position 1 past "c"…
|
||
screen.handle("down") # …onto the Apply row
|
||
assert _run(screen, "enter") == [1, 0, 2]
|
||
|
||
|
||
def test_reorder_actions_follow_the_dirty_state() -> None:
|
||
"""Untouched order offers no action row at all; a change brings Apply plus discard-Back.
|
||
|
||
Esc leaves either way, so an untouched list spends nothing on saying so — the pair
|
||
appears only once there is something to apply, and Apply has no key of its own.
|
||
"""
|
||
screen = ReorderScreen("order", ["a", "b", "c"])
|
||
assert screen._actions() == []
|
||
screen.handle("enter")
|
||
screen.handle("down") # dirty now
|
||
assert [key for key, _ in screen._actions()] == ["apply", "back"]
|
||
screen.handle("up") # moved back home — clean again
|
||
assert screen._actions() == []
|
||
|
||
|
||
def test_reorder_back_row_and_escape_cancel_discarding_moves() -> None:
|
||
"""Enter on Back — like Esc — resolves the sentinel, so the caller keeps the old order."""
|
||
screen = ReorderScreen("order", ["a", "b", "c"])
|
||
screen.handle("enter")
|
||
screen.handle("down")
|
||
assert _run(screen, "escape") is CANCEL
|
||
|
||
screen = ReorderScreen("order", ["a", "b", "c"])
|
||
screen.handle("enter")
|
||
screen.handle("down")
|
||
screen.handle("enter") # drop at position 1; the order is dirty
|
||
for _ in range(3): # cursor 1 → 2 → Apply → Back
|
||
screen.handle("down")
|
||
assert _run(screen, "enter") is CANCEL
|
||
|
||
|
||
def test_reorder_cursor_runs_into_the_action_rows_and_clamps() -> None:
|
||
"""↑ on the first row stays put; ↓ walks the list and on into the action rows, then stops.
|
||
|
||
Clean, the list is the whole cursor space; once dirty the two action rows join it and
|
||
the cursor runs on through them to the bottom — never round to the top.
|
||
"""
|
||
screen = ReorderScreen("order", ["a", "b"])
|
||
screen.handle("up") # already atop: no last row to fall onto
|
||
assert screen._index == 0
|
||
screen.handle("down") # onto the last list row, there being no action rows
|
||
assert screen._index == 1
|
||
screen.handle("down") # and stop there
|
||
assert screen._index == 1
|
||
|
||
screen.handle("enter") # grab row 1…
|
||
screen.handle("up") # …and carry it up: dirty, so Apply and Back join the space
|
||
screen.handle("enter") # drop it — the cursor rode it to the first list row
|
||
screen.handle("down") # onto the second list row
|
||
screen.handle("down") # off the list, onto Apply
|
||
screen.handle("down") # …then the discard-Back row below it
|
||
assert screen._index == 3
|
||
screen.handle("down") # the bottom of the space: it stays
|
||
assert screen._index == 3
|
||
|
||
|
||
def test_reorder_ignores_typed_characters_including_space() -> None:
|
||
"""Typed characters — the spacebar included — neither grab a row nor resolve the screen."""
|
||
screen = ReorderScreen("order", ["a", "b"])
|
||
screen.future = _Fut()
|
||
screen.handle("text", "x")
|
||
screen.handle("text", " ") # Space no longer grabs; Enter is the grab key
|
||
screen.handle("space")
|
||
assert not screen._grabbed
|
||
assert not screen.future.done()
|
||
|
||
|
||
def test_reorder_dialog_width_is_stable_across_states() -> None:
|
||
"""The reorder dialog is one width in every state it can be in.
|
||
|
||
The natural width fits the widest of rows, hints, and dirty actions, and never
|
||
changes as the user grabs a row or dirties the order, so the popup doesn't resize.
|
||
"""
|
||
screen = ReorderScreen("order", ["🔒 alpha", "# b"])
|
||
w = screen.dialog_width
|
||
screen.handle("enter") # grab
|
||
assert screen.dialog_width == w
|
||
screen.handle("down") # dirty: Apply/discard rows appear
|
||
assert screen.dialog_width == w
|
||
|
||
|
||
# --- frame -------------------------------------------------------------------
|
||
|
||
|
||
def test_compose_base_fills_exactly_terminal_height() -> None:
|
||
"""The composed base view is exactly ``rows`` lines regardless of content size."""
|
||
tall = Text("\n".join(f"row {i}" for i in range(200)))
|
||
screen = ScrollScreen(tall, title="big")
|
||
for rows in (10, 24, 50):
|
||
out = frame.compose_base(Text("header"), screen, "Esc back", 80, rows)
|
||
assert out.count("\n") + 1 == rows
|
||
|
||
|
||
def test_a_flush_screen_draws_up_to_the_side_borders() -> None:
|
||
"""A ``flush`` base gets the two padding columns back; an ordinary one keeps its air."""
|
||
|
||
class Fill(Screen):
|
||
floating = False
|
||
|
||
def render_body(self, width: int) -> list[str]:
|
||
return ["#" * width] * 3
|
||
|
||
for flush, row in ((True, "│" + "#" * 78 + "│"), (False, "│ " + "#" * 76 + " │")):
|
||
screen = Fill()
|
||
screen.flush = flush
|
||
lines = [_plain(ln) for ln in frame.compose_base(Text("h"), screen, "", 80, 12).split("\n")]
|
||
assert row in lines, (flush, lines)
|
||
|
||
|
||
def test_compose_dialog_is_bounded() -> None:
|
||
"""A dialog for a huge renderable never exceeds the terminal height."""
|
||
tall = Text("\n".join(f"row {i}" for i in range(200)))
|
||
out = frame.compose_dialog(ScrollScreen(tall, title="d"), 80, 20)
|
||
assert out.count("\n") + 1 <= 20
|
||
|
||
|
||
def _box_height(screen: Screen) -> int:
|
||
"""The row height of the dialog box ``compose_dialog`` draws for ``screen``."""
|
||
return frame.compose_dialog(screen, 80, 40).count("\n") + 1
|
||
|
||
|
||
def test_ordinary_dialog_box_resizes_to_each_body() -> None:
|
||
"""A plain (non grow-only) dialog sizes to whatever body it is currently showing."""
|
||
short = _box_height(ScrollScreen(Text("one line"), title="d"))
|
||
tall = _box_height(ScrollScreen(Text("\n".join(f"row {i}" for i in range(15))), title="d"))
|
||
assert tall > short
|
||
|
||
|
||
class _GrowScreen(Screen):
|
||
"""A grow-only dialog whose body height is set per paint, for the ratchet test."""
|
||
|
||
grow_only = True
|
||
|
||
def __init__(self) -> None:
|
||
super().__init__()
|
||
self.title = "d"
|
||
self.footer_hint = ""
|
||
self.n = 1
|
||
|
||
def render_body(self, width: int) -> list[str]:
|
||
return [f"row {i}" for i in range(self.n)]
|
||
|
||
|
||
def test_grow_only_dialog_box_holds_its_tallest_size() -> None:
|
||
"""A grow-only dialog grows its box for a taller body and never shrinks for a shorter one."""
|
||
screen = _GrowScreen()
|
||
screen.n = 2
|
||
small = _box_height(screen)
|
||
screen.n = 16
|
||
grown = _box_height(screen)
|
||
assert grown > small # a taller body enlarges the box
|
||
screen.n = 2
|
||
assert _box_height(screen) == grown # a shorter body after keeps the larger box
|
||
|
||
|
||
def _box_width(screen: Screen) -> int:
|
||
"""The column width of the dialog box ``compose_dialog`` draws for ``screen``."""
|
||
out = Text.from_ansi(frame.compose_dialog(screen, 80, 40)).plain
|
||
return max(cell_len(line.rstrip()) for line in out.split("\n"))
|
||
|
||
|
||
def test_grow_only_dialog_box_holds_its_widest_size() -> None:
|
||
"""A grow-only dialog's natural width ratchets too: it widens but never narrows."""
|
||
screen = _GrowScreen()
|
||
screen.dialog_width = 30
|
||
narrow = _box_width(screen)
|
||
screen.dialog_width = 60
|
||
wide = _box_width(screen)
|
||
assert wide > narrow # a wider body enlarges the box
|
||
screen.dialog_width = 30
|
||
assert _box_width(screen) == wide # a narrower one after keeps the wider box
|
||
# An ordinary dialog keeps sizing to each width as it comes.
|
||
plain = ScrollScreen(Text("x"), title="d")
|
||
plain.dialog_width = 60
|
||
wide = _box_width(plain)
|
||
plain.dialog_width = 30
|
||
assert _box_width(plain) < wide
|
||
|
||
|
||
def test_compose_startup_is_chromeless_and_shows_banner() -> None:
|
||
"""The startup splash fills the height, draws the banner, and omits header/footer bars."""
|
||
screen = SelectScreen("pick", [Choice("alpha", 1), Choice("beta", 2)])
|
||
screen.chrome = False
|
||
screen.banner = ["LOGO-ROW-A", "LOGO-ROW-B"]
|
||
out = frame.compose_startup(screen, 80, 24)
|
||
assert out.count("\n") + 1 == 24 # fills the terminal height exactly
|
||
plain = Text.from_ansi(out).plain
|
||
assert "LOGO-ROW-A" in plain and "LOGO-ROW-B" in plain # banner is drawn
|
||
assert "pick" in plain # the box keeps its title
|
||
# The box is content-sized, not full width: no rendered line spans the whole terminal.
|
||
assert all(len(line.rstrip()) < 80 for line in plain.split("\n"))
|
||
|
||
|
||
def test_compose_bare_is_the_body_alone_on_blank_rows() -> None:
|
||
"""A bare frame: no header, no footer, no title, no box — the body, sat a little high."""
|
||
body = Group(Text("CODE", justify="center"), Text("https://x", justify="center"))
|
||
screen = ScrollScreen(body, title="Share x", floating=False)
|
||
screen.bare = True
|
||
out = frame.compose_bare(screen, 53, 26)
|
||
rows = out.split("\n")
|
||
assert len(rows) == 26 # fills the terminal height exactly
|
||
plain = [Text.from_ansi(row).plain for row in rows]
|
||
assert not any("Share x" in row or "Esc" in row for row in plain) # no title, no hint
|
||
assert not any("─" in row or "│" in row or "╭" in row for row in plain) # no box
|
||
filled = [i for i, row in enumerate(plain) if row.strip()]
|
||
assert filled == [9, 10] # two body rows, anchored at 2/5 of the blank space
|
||
assert plain[9].rstrip() == " " * 24 + "CODE" # centred across the whole width
|
||
assert screen._scroll_viewport == 26 # the height, stated before the body was asked for
|
||
|
||
|
||
def test_compose_bare_windows_a_body_taller_than_the_frame_from_the_top() -> None:
|
||
"""Too tall to sit, the body scrolls: the top of it — the code — is whole first."""
|
||
body = Group(*(Text(f"row {i}") for i in range(40)))
|
||
screen = ScrollScreen(body, floating=False)
|
||
screen.bare = True
|
||
rows = Text.from_ansi(frame.compose_bare(screen, 53, 26)).plain.split("\n")
|
||
assert len(rows) == 26
|
||
assert rows[0].strip() == "row 0" and rows[25].strip() == "row 25"
|
||
screen.scroll_to_bottom()
|
||
rows = Text.from_ansi(frame.compose_bare(screen, 53, 26)).plain.split("\n")
|
||
assert rows[25].strip() == "row 39"
|
||
|
||
|
||
def test_startup_splash_gives_rows_back_in_order_when_short() -> None:
|
||
"""A short terminal sheds the blank line first, then the top of the mark — never the box.
|
||
|
||
The lettering says what the app is and the box is what the user came to use; the globe
|
||
over the lettering is decoration, so it is what pays. Cropping from the *top* means the
|
||
lettering holds its place while the mark thins above it.
|
||
"""
|
||
marks = [f"MARK{i:02d}" for i in range(16)] # a stand-in with the real mark's height
|
||
|
||
def splash(rows: int) -> list[str]:
|
||
screen = SelectScreen("pick", [Choice("alpha", 1), Choice("beta", 2)])
|
||
screen.chrome = False
|
||
screen.banner = list(marks)
|
||
return Text.from_ansi(frame.compose_startup(screen, 80, rows)).plain.split("\n")
|
||
|
||
tall = splash(40)
|
||
# Roomy: every row of the mark, and a blank line between it and the box.
|
||
assert all(m in "\n".join(tall) for m in marks)
|
||
mark_end = max(i for i, line in enumerate(tall) if marks[-1] in line)
|
||
assert tall[mark_end + 1].strip() == ""
|
||
|
||
# Walk the terminal down a row at a time and note when each concession is made. The
|
||
# exact heights depend on how tall the box below happens to be, so pin the order.
|
||
gap_lost = cropped = None
|
||
for rows in range(40, 8, -1):
|
||
lines = splash(rows)
|
||
end = max((i for i, ln in enumerate(lines) if marks[-1] in ln), default=None)
|
||
has_gap = end is not None and end + 1 < len(lines) and lines[end + 1].strip() == ""
|
||
if gap_lost is None and not has_gap:
|
||
gap_lost = rows
|
||
if cropped is None and marks[0] not in "\n".join(lines):
|
||
cropped = rows
|
||
if end is not None: # while any of the mark is drawn, its last row is drawn
|
||
assert marks[-1] in "\n".join(lines), f"{rows} rows: lost the mark's last row"
|
||
assert gap_lost is not None, "the blank line was never given back"
|
||
assert cropped is not None, "the mark was never cropped"
|
||
assert gap_lost > cropped, "the blank line must go before the mark is cropped"
|
||
|
||
# The crop is bounded: the first row past _BANNER_CROP_ROWS is never sheared, however
|
||
# short the terminal gets. (Below a certain height the block simply outgrows the screen
|
||
# and the trailing clip takes the bottom — a different mechanism, not the crop.)
|
||
keeper = marks[frame._BANNER_CROP_ROWS]
|
||
for rows in range(20, 5, -1):
|
||
assert keeper in "\n".join(splash(rows)), f"{rows} rows: cropped past the bound"
|
||
|
||
|
||
def test_startup_splash_fits_every_platform_width() -> None:
|
||
"""The real wordmark, drawn at each platform's own width, never overruns it.
|
||
|
||
The full-size mark is 71 cells; a PicoCalc console is 53. The splash is the one screen
|
||
the gallery doesn't cover, and it shipped torn there until the narrow mark landed —
|
||
so this is the gate.
|
||
"""
|
||
from meshterm.platforms import PICOCALC_LYRA, REGULAR, set_platform
|
||
from meshterm.ui.logo import load_logo
|
||
|
||
try:
|
||
for platform in (REGULAR, PICOCALC_LYRA):
|
||
set_platform(platform)
|
||
cols = platform.readable_cols
|
||
screen = SelectScreen("Choose a device", [Choice("alpha", 1)])
|
||
screen.chrome = False
|
||
screen.banner = load_logo() # what a real caller sets: the full-size mark
|
||
screen.footnote = copyright_notice()
|
||
out = frame.compose_startup(screen, cols, platform.readable_rows)
|
||
for i, line in enumerate(Text.from_ansi(out).plain.split("\n")):
|
||
assert cell_len(line) <= cols, (
|
||
f"{platform.name} splash line {i} is {cell_len(line)} cells, over {cols}"
|
||
)
|
||
finally:
|
||
set_platform(REGULAR)
|
||
|
||
|
||
def test_logo_takes_the_widest_mark_that_fits_the_columns() -> None:
|
||
"""The screen picks the size, not the platform — a narrow desktop gets the small mark."""
|
||
from meshterm.ui.logo import load_logo, logo_width
|
||
|
||
wide = logo_width(load_logo())
|
||
narrow = logo_width(load_logo(53))
|
||
assert 0 < narrow <= 53 < wide
|
||
assert logo_width(load_logo(wide)) == wide # room for the big one → the big one
|
||
assert logo_width(load_logo(wide - 1)) == narrow # a column short → step down
|
||
assert load_logo(narrow - 1) == [] # nothing fits: no banner beats a torn one
|
||
|
||
|
||
def test_compose_startup_shows_footnote_under_logo() -> None:
|
||
"""The startup footnote sits directly under the logo.
|
||
|
||
A footnote (a copyright, say) is drawn muted and right-aligned to the logo's own
|
||
right edge, so the two read as one signed block.
|
||
"""
|
||
screen = SelectScreen("pick", [Choice("a", 1)])
|
||
screen.chrome = False
|
||
screen.banner = ["A" * 40, "B" * 40] # a wide wordmark to hang the note off
|
||
screen.footnote = "note-xyz"
|
||
lines = Text.from_ansi(frame.compose_startup(screen, 80, 20)).plain.split("\n")
|
||
logo_rows = [i for i, ln in enumerate(lines) if set(ln.strip()) in ({"A"}, {"B"})]
|
||
note_row = next(i for i, ln in enumerate(lines) if "note-xyz" in ln)
|
||
assert lines[note_row].strip() == "note-xyz" # its own line, nothing else on it
|
||
assert note_row == logo_rows[-1] + 1 # immediately under the logo, no gap
|
||
# Right edges align: the note ends at the same column the logo ends.
|
||
assert len(lines[note_row].rstrip()) == len(lines[logo_rows[-1]].rstrip())
|
||
|
||
|
||
def test_compose_startup_box_is_horizontally_centered() -> None:
|
||
"""The content-sized box is centered, so its rows carry a leading left margin."""
|
||
screen = SelectScreen("pick", [Choice("a", 1)])
|
||
screen.chrome = False
|
||
lines = Text.from_ansi(frame.compose_startup(screen, 80, 20)).plain.split("\n")
|
||
box_lines = [ln for ln in lines if ln.strip()]
|
||
assert box_lines and all(ln.startswith(" ") for ln in box_lines) # centered inset
|
||
|
||
|
||
# --- frames ------------------------------------------------------------------
|
||
|
||
|
||
def _cell_color(line: str, idx: int) -> tuple[int, int, int]:
|
||
"""Resolve the foreground RGB of the character at ``idx`` in an ANSI line."""
|
||
from meshterm.ui.tui.render import _console
|
||
|
||
style = Text.from_ansi(line).get_style_at_offset(_console(80), idx)
|
||
assert style.color is not None
|
||
triplet = style.color.get_truecolor()
|
||
return (triplet.red, triplet.green, triplet.blue)
|
||
|
||
|
||
_ACCENT = (129, 140, 248) # the theme's accent border, #818cf8
|
||
|
||
|
||
def _find(plain: str, glyphs: frozenset[str]) -> int:
|
||
"""The index of the first box glyph from ``glyphs``.
|
||
|
||
The render console may substitute square corners for rounded ones on legacy
|
||
Windows, so a test matches the whole family rather than one character.
|
||
"""
|
||
return next(i for i, ch in enumerate(plain) if ch in glyphs)
|
||
|
||
|
||
_CORNERS = frozenset("╭┌╮┐╰└╯┘")
|
||
_EDGES = frozenset("─│")
|
||
|
||
|
||
def _border_colours(lines: list[str]) -> set[tuple[int, int, int]]:
|
||
"""Every distinct colour the box-drawing glyphs in ``lines`` are painted in."""
|
||
return {
|
||
_cell_color(line, i)
|
||
for line in lines
|
||
for i, ch in enumerate(Text.from_ansi(line).plain)
|
||
if ch in _CORNERS or ch in _EDGES
|
||
}
|
||
|
||
|
||
def test_a_dialog_leaves_its_hint_to_the_footer_line() -> None:
|
||
"""On the desktop the box says nothing about keys — the footer row below already does.
|
||
|
||
The frame draws the *top* screen's hint on its footer line, so a dialog that also
|
||
carried the hint in its own border put the same sentence on one frame twice (JP,
|
||
2026-08-31). The border falls silent and the footer is the single place hints live.
|
||
"""
|
||
hint = "←→ choose · Enter select · Esc cancel"
|
||
screen = ScrollScreen(Text("Delete this contact?"), title="Delete contact")
|
||
screen._footer_hint = hint
|
||
box = _plain(frame.compose_dialog(screen, 72, 20))
|
||
assert "Esc cancel" not in box and "Enter select" not in box
|
||
|
||
base = ScrollScreen(Text("row"), title="Contacts", floating=False)
|
||
footer = _plain(frame.compose_base(Text("h"), base, hint, 72, 20).split("\n")[-1])
|
||
assert footer.strip() == hint
|
||
|
||
|
||
def test_a_silent_dialog_border_still_says_there_is_more() -> None:
|
||
"""With no hint to carry, the clip arrows keep the base frame's own ``more`` wording."""
|
||
tall = ScrollScreen(Text("\n".join(f"line {i}" for i in range(40))), title="Packet")
|
||
tall._footer_hint = "↑↓ newer/older · Esc close"
|
||
assert "↓ more" in _plain(frame.compose_dialog(tall, 72, 16))
|
||
|
||
|
||
def test_a_frame_draws_in_one_colour() -> None:
|
||
"""A border is its own colour the whole way round — no corner lit, no edge fading.
|
||
|
||
The frames were lit from the top-left for a while: the corner blended toward white and
|
||
the highlight decayed along the top and left edges. It read as a gradient laid over the
|
||
chrome rather than as the box being a box, so the pass is gone and the border draws flat.
|
||
Nested panels are checked with the outer frame, because the pass lit those too.
|
||
"""
|
||
inner = Panel(Text("body"), border_style="accent", width=20)
|
||
screen = ScrollScreen(Group(Text("above"), inner), title="outer")
|
||
lines = frame.compose_base(Text("h"), screen, "hint", 60, 20).split("\n")
|
||
assert _border_colours(lines) == {_ACCENT}, "the base frame draws in more than one colour"
|
||
|
||
dialog = frame.compose_dialog(ScrollScreen(Text("body"), title="d"), 80, 20)
|
||
assert _border_colours(dialog.split("\n")) == {_ACCENT}, "so does a dialog's"
|
||
|
||
|
||
# --- prompts -----------------------------------------------------------------
|
||
|
||
|
||
def test_text_screen_edits_and_validates() -> None:
|
||
"""Typing edits the buffer; a failing validator blocks submit and shows the error."""
|
||
screen = TextScreen("name?", validate=lambda v: True if v == "ok" else "nope")
|
||
for ch in "xy":
|
||
screen.handle("text", ch)
|
||
screen.future = _Fut()
|
||
screen.handle("enter") # 'xy' fails validation
|
||
assert not screen.future.done()
|
||
assert screen._error == "nope"
|
||
screen.handle("backspace")
|
||
screen.handle("backspace")
|
||
for ch in "ok":
|
||
screen.handle("text", ch)
|
||
assert _run(screen, "enter") == "ok"
|
||
|
||
|
||
def test_text_screen_byte_limit_gauges_and_blocks_an_oversize_entry() -> None:
|
||
"""A byte-limited field shows the shared used/limit gauge and blocks Enter over the cap."""
|
||
import re
|
||
|
||
screen = TextScreen("msg?", byte_limit=10)
|
||
for ch in "hello":
|
||
screen.handle("text", ch)
|
||
body = re.sub(r"\x1b\[[0-9;]*m", "", "\n".join(screen.render_body(40)))
|
||
assert "5/10" in body # the gauge reads used/limit
|
||
for ch in " world": # 11 bytes total, over the 10-byte cap
|
||
screen.handle("text", ch)
|
||
screen.future = _Fut()
|
||
screen.handle("enter")
|
||
assert not screen.future.done() # the over-limit entry is blocked
|
||
assert "Too long by 1 byte" in screen._error
|
||
# Trimming back within budget lets it submit.
|
||
for _ in range(2):
|
||
screen.handle("backspace")
|
||
assert _run(screen, "enter") == "hello wor"
|
||
|
||
|
||
def test_line_editor_word_motion() -> None:
|
||
"""Ctrl+Left/Right hop by word — to the current word's start, else the previous/next."""
|
||
from meshterm.ui.tui.prompt import LineEditor
|
||
|
||
editor = LineEditor("the quick brown fox") # cursor at the end (len 20)
|
||
editor.edit("ctrl_left")
|
||
assert editor.cursor == 17 # start of "fox"
|
||
editor.edit("ctrl_left")
|
||
assert editor.cursor == 11 # skips the double space → start of "brown"
|
||
editor.edit("ctrl_left")
|
||
assert editor.cursor == 4 # start of "quick"
|
||
editor.edit("ctrl_right")
|
||
assert editor.cursor == 11 # forward over "quick" and the spaces → start of "brown"
|
||
editor.cursor = 13 # mid-"brown"
|
||
editor.edit("ctrl_left")
|
||
assert editor.cursor == 11 # to the current word's start, not the previous word
|
||
|
||
|
||
def test_text_screen_password_masks() -> None:
|
||
"""A password field renders bullets, not the typed characters."""
|
||
screen = TextScreen("pw?", password=True)
|
||
for ch in "secret":
|
||
screen.handle("text", ch)
|
||
rendered = "\n".join(screen.render_body(40))
|
||
assert "secret" not in rendered
|
||
assert "•" in rendered
|
||
|
||
|
||
def test_line_editor_caps_length_and_truncates_paste() -> None:
|
||
"""A max_length editor swallows keys past the cap and truncates an over-long paste."""
|
||
from meshterm.ui.tui.prompt import LineEditor
|
||
|
||
editor = LineEditor("", max_length=6)
|
||
for ch in "123456":
|
||
assert editor.edit("text", ch) is True
|
||
assert editor.edit("text", "9") is False # at capacity — key swallowed, buffer unchanged
|
||
assert editor.text == "123456"
|
||
|
||
pasted = LineEditor("", max_length=6)
|
||
pasted.edit("text", "12345678") # one over-long insert
|
||
assert pasted.text == "123456" # filled only the six available slots
|
||
|
||
|
||
def test_line_editor_paste_folds_controls_and_respects_max_length() -> None:
|
||
"""A ``paste`` action folds newlines/controls to spaces and inserts the run at the cursor."""
|
||
from meshterm.ui.tui.prompt import LineEditor
|
||
|
||
editor = LineEditor("ab")
|
||
editor.cursor = 1
|
||
assert editor.edit("paste", "X\nY") is True
|
||
assert editor.text == "aX Yb" # the newline became a space, inserted mid-buffer
|
||
|
||
capped = LineEditor("", max_length=3)
|
||
capped.edit("paste", "hello")
|
||
assert capped.text == "hel" # a paste is trimmed to the remaining room, like a big insert
|
||
|
||
assert LineEditor("z").edit("paste", "") is False # nothing to paste leaves the buffer
|
||
|
||
|
||
def test_pin_dialog_shows_six_slots_with_dots_for_blanks() -> None:
|
||
"""The PIN field is six fixed slots: bullets for typed digits, centre dots for blanks."""
|
||
from meshterm.ui.tui.prompt import PinDialog
|
||
|
||
dialog = PinDialog("MeshCore-Testbench")
|
||
# Empty: six blank centre dots, no bullets yet.
|
||
field = dialog._editor.render(slots=PinDialog.PIN_LENGTH).plain
|
||
assert field.count("·") == 6
|
||
assert "•" not in field
|
||
|
||
# After three digits: three bullets, three remaining centre dots.
|
||
for ch in "123":
|
||
dialog.handle("text", ch)
|
||
field = dialog._editor.render(slots=PinDialog.PIN_LENGTH).plain
|
||
assert field.count("•") == 3
|
||
assert field.count("·") == 3
|
||
|
||
# A full six-digit PIN fills every slot; typing more is capped at six.
|
||
for ch in "456999":
|
||
dialog.handle("text", ch)
|
||
assert dialog._editor.text == "123456"
|
||
field = dialog._editor.render(slots=PinDialog.PIN_LENGTH).plain
|
||
assert field.count("•") == 6
|
||
assert "·" not in field
|
||
|
||
|
||
def test_confirm_toggle_and_default() -> None:
|
||
"""The confirm toggles with arrows/letters and returns the chosen bool."""
|
||
screen = ConfirmScreen("sure?", default=True)
|
||
assert _run(screen, "enter") is True
|
||
screen = ConfirmScreen("sure?", default=True)
|
||
screen.handle("left") # toggle to No
|
||
assert _run(screen, "enter") is False
|
||
screen = ConfirmScreen("sure?", default=True)
|
||
screen.handle("text", "n")
|
||
assert _run(screen, "enter") is False
|
||
|
||
|
||
def test_button_dialog_enter_commits_highlighted() -> None:
|
||
"""Enter returns the highlighted button's value; the default sets the highlight."""
|
||
screen = ButtonDialog("quit?", [("Yes", True), ("No", False)], default=0)
|
||
assert _run(screen, "enter") is True
|
||
screen = ButtonDialog("quit?", [("Yes", True), ("No", False)], default=1)
|
||
assert _run(screen, "enter") is False
|
||
|
||
|
||
def test_button_dialog_arrows_move_highlight() -> None:
|
||
"""←/→ move the highlight between the buttons and clamp at the ends; Tab still cycles.
|
||
|
||
Tab is the exception: it has no reverse of its own, so on the last chip it comes round
|
||
rather than dead-ending.
|
||
"""
|
||
screen = ButtonDialog("quit?", [("Yes", True), ("No", False)], default=0)
|
||
screen.handle("right") # → No
|
||
assert _run(screen, "enter") is False
|
||
screen = ButtonDialog("quit?", [("Yes", True), ("No", False)], default=0)
|
||
screen.handle("left") # already leftmost — stays on Yes
|
||
assert _run(screen, "enter") is True
|
||
screen = ButtonDialog("quit?", [("Yes", True), ("No", False)], default=1)
|
||
screen.handle("right") # already rightmost — stays on No
|
||
assert _run(screen, "enter") is False
|
||
screen = ButtonDialog("quit?", [("Yes", True), ("No", False)], default=1)
|
||
screen.handle("tab") # No -> round to Yes
|
||
assert _run(screen, "enter") is True
|
||
|
||
|
||
def test_button_dialog_shortcut_keys_commit_instantly() -> None:
|
||
"""A mapped shortcut key commits its value straight away, bypassing the highlight."""
|
||
screen = ButtonDialog(
|
||
"quit?", [("Yes", True), ("No", False)], default=1, keys={"y": True, "n": False}
|
||
)
|
||
assert _run(screen, "text", "Y") is True # case-insensitive, ignores the No default
|
||
screen = ButtonDialog(
|
||
"quit?", [("Yes", True), ("No", False)], default=0, keys={"y": True, "n": False}
|
||
)
|
||
assert _run(screen, "text", "n") is False
|
||
|
||
|
||
def test_button_dialog_escape_cancels() -> None:
|
||
"""Esc resolves with CANCEL so the caller can treat it as 'stay'."""
|
||
from meshterm.ui.tui.screen import CANCEL
|
||
|
||
screen = ButtonDialog("quit?", [("Yes", True), ("No", False)])
|
||
assert _run(screen, "escape") is CANCEL
|
||
|
||
|
||
def test_button_dialog_renders_a_styled_text_prompt_line_per_line() -> None:
|
||
"""A pre-styled multi-line Text prompt renders each line, keeping its content."""
|
||
message = Text.from_markup("[ok]✓[/ok] clock set\n[ok]●[/ok] wrote backup.toml")
|
||
screen = ButtonDialog(message, [("OK", "ok")])
|
||
body = "\n".join(screen.render_body(60))
|
||
plain = Text.from_ansi(body).plain
|
||
assert "✓ clock set" in plain
|
||
assert "● wrote backup.toml" in plain
|
||
assert "OK" in plain
|
||
|
||
|
||
def test_button_dialog_sizes_to_the_widest_prompt_line() -> None:
|
||
"""dialog_width follows the longest line of a multi-line Text prompt."""
|
||
wide = "a really quite long outcome line for sizing"
|
||
message = Text(f"short\n{wide}")
|
||
screen = ButtonDialog(message, [("OK", "ok")])
|
||
assert screen.dialog_width == len(wide) + 12 # matches the margin the dialog adds
|
||
|
||
|
||
def test_autocomplete_suggests_and_tab_completes() -> None:
|
||
"""Suggestions match case-insensitively; Tab fills the highlighted one; Enter commits."""
|
||
screen = AutocompleteScreen("target?", ["Alice", "Bob", "alfred"])
|
||
for ch in "al":
|
||
screen.handle("text", ch)
|
||
assert screen._suggestions() == ["Alice", "alfred"]
|
||
screen.handle("down") # highlight 'alfred'
|
||
screen.handle("tab") # fill it
|
||
assert screen._editor.text == "alfred"
|
||
assert _run(screen, "enter") == "alfred"
|
||
|
||
|
||
def test_autocomplete_accepts_free_text() -> None:
|
||
"""Text with no matching suggestion still commits verbatim (e.g. a hex prefix)."""
|
||
screen = AutocompleteScreen("target?", ["Alice"])
|
||
for ch in "3d":
|
||
screen.handle("text", ch)
|
||
assert _run(screen, "enter") == "3d"
|
||
|
||
|
||
# --- device picker -----------------------------------------------------------
|
||
|
||
|
||
def test_device_picker_builds_aligned_columns(tmp_path) -> None:
|
||
"""The picker lays devices out in columns that line up across rows of differing widths."""
|
||
from meshterm.core.device_store import DeviceStore
|
||
from meshterm.core.discovery import DiscoveredDevice
|
||
from meshterm.ui.device_picker import prompt_device
|
||
|
||
devices = [
|
||
DiscoveredDevice(port="COM5", product="Wio SX1262", vid=0x2886),
|
||
DiscoveredDevice(port="/dev/ttyUSB0", product="FT232R USB UART", vid=0x0403),
|
||
]
|
||
captured: dict = {}
|
||
|
||
class _Ui:
|
||
async def select_startup( # noqa: ANN001, ANN201, ANN003
|
||
self, title, items, *, default=None, banner=None, footnote=None, **_kw
|
||
):
|
||
captured["items"] = items
|
||
captured["banner"] = banner
|
||
captured["footnote"] = footnote
|
||
return None # skip: never reaches the smoke test
|
||
|
||
async def _never(_device): # verify is unused when the user skips
|
||
raise AssertionError("verify should not run when selection is skipped")
|
||
|
||
store = DeviceStore(tmp_path / "devices.json")
|
||
asyncio.run(prompt_device(_Ui(), devices, store, _never))
|
||
# The banner (wordmark) is passed through so the splash can draw it.
|
||
assert captured["banner"] and any("█" in row for row in captured["banner"])
|
||
# No footnote: the wordmark carries its own copyright, so the splash adds none.
|
||
assert captured["footnote"] is None
|
||
# Each device row's port sits at the same column, proving the name column is padded.
|
||
rows = [
|
||
it.label.plain if hasattr(it.label, "plain") else it.label
|
||
for it in captured["items"]
|
||
if isinstance(it, Choice)
|
||
]
|
||
# Two devices plus the trailing action rows (add a network device, then Quit).
|
||
assert len(rows) == 4
|
||
assert rows[-2].strip().endswith("Add a network device…") # no caveat tag trailing it
|
||
assert rows[-1].strip().endswith("Quit")
|
||
device_rows = rows[:2]
|
||
assert all(port in row for port, row in zip(("COM5", "/dev/ttyUSB0"), device_rows, strict=True))
|
||
assert device_rows[0].index("COM5") == device_rows[1].index("/dev/ttyUSB0")
|
||
|
||
|
||
def test_device_picker_address_lane_is_the_one_that_adapts() -> None:
|
||
"""DEVICE shows its name in full; PORT / ADDRESS takes whatever is left.
|
||
|
||
A name is what the reader came to read and recognises their own radio by; an address
|
||
is a disambiguator, and does that job from its last ten cells. macOS is what forced
|
||
the question — CoreBluetooth reports no MAC but a per-machine 36-cell UUID, and even
|
||
its ports run long — which sized the lane to 36 and left DEVICE the width of its own
|
||
heading.
|
||
"""
|
||
from rich.cells import cell_len
|
||
|
||
from meshterm.core.discovery import DiscoveredDevice
|
||
from meshterm.ui.device_picker import _ADDRESS_MIN, _fit_target, _lane_widths
|
||
|
||
name = "MeshCore-Johnputer Wardriver"
|
||
uuid = "12345678-1234-1234-1234-123456789ABC"
|
||
mac_devices = [
|
||
DiscoveredDevice(transport="ble", address="C6:B6:26:BC:7F:09", name=name, product=name),
|
||
DiscoveredDevice("COM3", product="Some Adapter", vid=0x1234),
|
||
]
|
||
uuid_devices = [
|
||
DiscoveredDevice(transport="ble", address=uuid, name=name, product=name),
|
||
DiscoveredDevice("/dev/cu.Bluetooth-Incoming-Port"),
|
||
]
|
||
|
||
# The name is shown whole on both, and a 36-cell address does not shrink it: the lane
|
||
# that gives is the address, and it never drops below the floor that keeps it useful.
|
||
for devices in (mac_devices, uuid_devices):
|
||
name_w, port_w = _lane_widths(devices, {})
|
||
assert name_w == cell_len(name)
|
||
assert port_w >= _ADDRESS_MIN
|
||
assert _lane_widths(mac_devices, {})[0] == _lane_widths(uuid_devices, {})[0]
|
||
|
||
# A target that fits is untouched, and a long one keeps its tail -- which is what tells
|
||
# two of them apart, and the whole reason the cut takes the front.
|
||
_, port_w = _lane_widths(uuid_devices, {})
|
||
assert _fit_target("COM3", port_w) == "COM3"
|
||
assert _fit_target(uuid, port_w).endswith("9ABC")
|
||
assert cell_len(_fit_target(uuid, port_w)) <= port_w
|
||
assert _fit_target("/dev/cu.usbmodem1101", 14) != _fit_target("/dev/cu.usbmodem1102", 14)
|
||
|
||
# Nothing is hoarded: a lone short port hands its slack back to the name.
|
||
short = [DiscoveredDevice("COM5", product="Wio SX1262", vid=0x2886)]
|
||
assert _lane_widths(short, {})[1] == cell_len("COM5")
|
||
|
||
# And where the name cannot fit whatever happens, the address is left whole rather
|
||
# than ellipsized alongside it -- one cut in the row instead of two.
|
||
sprawling = [
|
||
DiscoveredDevice("/dev/ttyUSB0", product="CP2102 USB to UART Bridge Controller", vid=0x10C4)
|
||
]
|
||
name_w, port_w = _lane_widths(sprawling, {})
|
||
assert port_w == cell_len("/dev/ttyUSB0")
|
||
assert name_w < cell_len("CP2102 USB to UART Bridge Controller")
|
||
|
||
|
||
def test_device_picker_names_and_sorts_known_devices(tmp_path) -> None:
|
||
"""A confirmed device shows its node name (in white), sorts to the top, and marks type."""
|
||
from meshterm.core.device_store import DeviceStore
|
||
from meshterm.core.discovery import DiscoveredDevice
|
||
from meshterm.ui.device_picker import _BLE_ICON, _SERIAL_ICON, prompt_device
|
||
|
||
# A previously-confirmed serial node, an unknown serial port, and a BLE companion.
|
||
known = DiscoveredDevice(
|
||
port="COM11", serial_number="SN1", description="USB Serial Device (COM11)"
|
||
)
|
||
unknown = DiscoveredDevice(port="COM3", product="Some Adapter", vid=0x1234)
|
||
ble = DiscoveredDevice(
|
||
transport="ble", address="AA:BB:CC:DD:EE:FF", name="MeshCore-Roam", product="MeshCore-Roam"
|
||
)
|
||
devices = [unknown, known, ble] # discovery order: known is *not* first
|
||
|
||
store = DeviceStore(tmp_path / "devices.json")
|
||
store.remember(known, node_name="BaseStation")
|
||
|
||
captured: dict = {}
|
||
|
||
class _Ui:
|
||
async def select_startup( # noqa: ANN001, ANN201, ANN003
|
||
self, title, items, *, default=None, banner=None, footnote=None, **_kw
|
||
):
|
||
captured["items"] = items
|
||
return None # skip past the smoke test
|
||
|
||
async def _never(_device):
|
||
raise AssertionError("verify should not run when selection is skipped")
|
||
|
||
asyncio.run(prompt_device(_Ui(), devices, store, _never))
|
||
rows = [it.title for it in captured["items"] if isinstance(it, Choice)]
|
||
device_rows = rows[:-1] # drop the trailing Quit row
|
||
|
||
# The confirmed device sorts to the very top and is shown by its mesh node name, not the
|
||
# OS's generic "USB Serial Device" description.
|
||
top = device_rows[0]
|
||
assert "BaseStation" in top.plain
|
||
assert "USB Serial Device" not in top.plain
|
||
# …and that name is painted white ("device.known") so it stands out.
|
||
assert any(span.style == "device.known" for span in top.spans)
|
||
|
||
# The TYPE column marks the transport: a serial glyph for the wired node, the Bluetooth
|
||
# rune for the companion advertised over BLE.
|
||
assert _SERIAL_ICON in top.plain
|
||
assert any(_BLE_ICON in row.plain for row in device_rows)
|
||
|
||
|
||
def test_device_picker_reinjects_remembered_tcp_device(tmp_path) -> None:
|
||
"""A remembered TCP companion reappears in the picker even though it can't be scanned for."""
|
||
from meshterm.core.device_store import DeviceStore
|
||
from meshterm.core.discovery import tcp_device
|
||
from meshterm.ui.device_picker import _TCP_ICON, prompt_device
|
||
|
||
store = DeviceStore(tmp_path / "devices.json")
|
||
store.remember(tcp_device("192.168.1.50", 5000), node_name="WifiNode")
|
||
|
||
captured: dict = {}
|
||
|
||
class _Ui:
|
||
async def select_startup( # noqa: ANN001, ANN201, ANN003
|
||
self, title, items, *, default=None, banner=None, footnote=None, **_kw
|
||
):
|
||
captured["items"] = items
|
||
return None # skip past the smoke test
|
||
|
||
async def _never(_device):
|
||
raise AssertionError("verify should not run when selection is skipped")
|
||
|
||
# Nothing discovered this session, yet the remembered network device is rebuilt into the list.
|
||
asyncio.run(prompt_device(_Ui(), [], store, _never))
|
||
rows = [it.title for it in captured["items"] if isinstance(it, Choice)]
|
||
device_rows = [r for r in rows if hasattr(r, "plain") and "WifiNode" in r.plain]
|
||
assert device_rows, "the remembered TCP device should be listed"
|
||
top = device_rows[0]
|
||
assert "192.168.1.50:5000" in top.plain # its host:port sits in the address column
|
||
assert _TCP_ICON in top.plain # marked with the network TYPE glyph
|
||
|
||
|
||
def test_device_picker_lists_configured_tcp_profile(tmp_path) -> None:
|
||
"""A ``[profiles.*]`` TCP entry shows up in the picker under its alias, ready to select."""
|
||
from meshterm.core.config import DeviceProfile
|
||
from meshterm.core.device_store import DeviceStore
|
||
from meshterm.ui.device_picker import _TCP_ICON, prompt_device
|
||
|
||
store = DeviceStore(tmp_path / "devices.json")
|
||
profiles = {
|
||
"bridge": DeviceProfile(name="bridge", transport="tcp", host="127.0.0.1", tcp_port=5000)
|
||
}
|
||
|
||
captured: dict = {}
|
||
|
||
class _Ui:
|
||
async def select_startup( # noqa: ANN001, ANN201, ANN003
|
||
self, title, items, *, default=None, banner=None, footnote=None, **_kw
|
||
):
|
||
captured["items"] = items
|
||
return None # skip past the smoke test
|
||
|
||
async def _never(_device):
|
||
raise AssertionError("verify should not run when selection is skipped")
|
||
|
||
# Nothing scanned or remembered — the profile alone puts the endpoint in the list.
|
||
asyncio.run(prompt_device(_Ui(), [], store, _never, profiles))
|
||
rows = [it.title for it in captured["items"] if isinstance(it, Choice)]
|
||
device_rows = [r for r in rows if hasattr(r, "plain") and "bridge" in r.plain]
|
||
assert device_rows, "the configured TCP profile should be listed"
|
||
top = device_rows[0]
|
||
assert "127.0.0.1:5000" in top.plain # its host:port sits in the address column
|
||
assert _TCP_ICON in top.plain # marked with the network TYPE glyph
|
||
|
||
|
||
def test_device_picker_lists_configured_serial_profile(tmp_path) -> None:
|
||
"""A configured serial profile is listed in the picker under its alias.
|
||
|
||
A soldered ``/dev/ttyS1`` is ready to select even though pyserial's scan never
|
||
produces that platform port.
|
||
"""
|
||
from meshterm.core.config import DeviceProfile
|
||
from meshterm.core.device_store import DeviceStore
|
||
from meshterm.ui.device_picker import prompt_device
|
||
|
||
store = DeviceStore(tmp_path / "devices.json")
|
||
profiles = {"picocalc": DeviceProfile(name="picocalc", port="/dev/ttyS1", baudrate=115200)}
|
||
|
||
captured: dict = {}
|
||
|
||
class _Ui:
|
||
async def select_startup( # noqa: ANN001, ANN201, ANN003
|
||
self, title, items, *, default=None, banner=None, footnote=None, **_kw
|
||
):
|
||
captured["items"] = items
|
||
return None # skip past the smoke test
|
||
|
||
async def _never(_device):
|
||
raise AssertionError("verify should not run when selection is skipped")
|
||
|
||
# Nothing scanned or remembered — the serial profile alone puts the port in the list.
|
||
asyncio.run(prompt_device(_Ui(), [], store, _never, profiles))
|
||
rows = [it.title for it in captured["items"] if isinstance(it, Choice)]
|
||
device_rows = [r for r in rows if hasattr(r, "plain") and "picocalc" in r.plain]
|
||
assert device_rows, "the configured serial profile should be listed"
|
||
assert "/dev/ttyS1" in device_rows[0].plain # its port sits in the address column
|
||
|
||
|
||
def test_device_picker_serial_profile_yields_to_scanned_port(tmp_path) -> None:
|
||
"""A serial profile yields to the scanned port when both name the same thing.
|
||
|
||
The row pyserial found wins, because it carries real USB metadata the bare profile
|
||
does not.
|
||
"""
|
||
from meshterm.core.config import DeviceProfile
|
||
from meshterm.core.device_store import DeviceStore
|
||
from meshterm.core.discovery import DiscoveredDevice
|
||
from meshterm.ui.device_picker import prompt_device
|
||
|
||
store = DeviceStore(tmp_path / "devices.json")
|
||
scanned = [DiscoveredDevice(port="/dev/ttyUSB0", product="XIAO", vid=0x2886, pid=0x8044)]
|
||
profiles = {"radio": DeviceProfile(name="radio", port="/dev/ttyUSB0")}
|
||
|
||
captured: dict = {}
|
||
|
||
class _Ui:
|
||
async def select_startup( # noqa: ANN001, ANN201, ANN003
|
||
self, title, items, *, default=None, banner=None, footnote=None, **_kw
|
||
):
|
||
captured["items"] = items
|
||
return None
|
||
|
||
async def _never(_device):
|
||
raise AssertionError("verify should not run when selection is skipped")
|
||
|
||
asyncio.run(prompt_device(_Ui(), scanned, store, _never, profiles))
|
||
rows = [it.title for it in captured["items"] if isinstance(it, Choice)]
|
||
usb_rows = [r for r in rows if hasattr(r, "plain") and "/dev/ttyUSB0" in r.plain]
|
||
assert len(usb_rows) == 1, "the scanned port must not be duplicated by the profile"
|
||
|
||
|
||
def test_device_picker_profile_yields_to_remembered_endpoint(tmp_path) -> None:
|
||
"""A profile at an already-remembered endpoint doesn't double-list — the richer row wins."""
|
||
from meshterm.core.config import DeviceProfile
|
||
from meshterm.core.device_store import DeviceStore
|
||
from meshterm.core.discovery import tcp_device
|
||
from meshterm.ui.device_picker import prompt_device
|
||
|
||
store = DeviceStore(tmp_path / "devices.json")
|
||
# Confirmed before, so it carries the real node name learned at connect time.
|
||
store.remember(tcp_device("127.0.0.1", 5000), node_name="uConsole")
|
||
profiles = {
|
||
"bridge": DeviceProfile(name="bridge", transport="tcp", host="127.0.0.1", tcp_port=5000)
|
||
}
|
||
|
||
captured: dict = {}
|
||
|
||
class _Ui:
|
||
async def select_startup( # noqa: ANN001, ANN201, ANN003
|
||
self, title, items, *, default=None, banner=None, footnote=None, **_kw
|
||
):
|
||
captured["items"] = items
|
||
return None
|
||
|
||
async def _never(_device):
|
||
raise AssertionError("verify should not run when selection is skipped")
|
||
|
||
asyncio.run(prompt_device(_Ui(), [], store, _never, profiles))
|
||
rows = [it.title for it in captured["items"] if isinstance(it, Choice)]
|
||
endpoint_rows = [r for r in rows if hasattr(r, "plain") and "127.0.0.1:5000" in r.plain]
|
||
assert len(endpoint_rows) == 1, "the endpoint should appear exactly once"
|
||
# The remembered node name wins over the bare profile alias.
|
||
assert "uConsole" in endpoint_rows[0].plain
|
||
assert "bridge" not in endpoint_rows[0].plain
|
||
|
||
|
||
def test_device_picker_adds_network_device(tmp_path) -> None:
|
||
"""The 'add a network device' row prompts for host:port and confirms the TCP companion."""
|
||
from meshterm.core.device_store import DeviceStore
|
||
from meshterm.ui.device_picker import _ADD_TCP, prompt_device
|
||
|
||
store = DeviceStore(tmp_path / "devices.json")
|
||
probed: dict = {}
|
||
|
||
class _Ui:
|
||
async def select_startup( # noqa: ANN001, ANN201, ANN003
|
||
self, title, items, *, default=None, banner=None, footnote=None, **_kw
|
||
):
|
||
# Choose the "add a network device" action row.
|
||
return next(it.value for it in items if isinstance(it, Choice) and it.value is _ADD_TCP)
|
||
|
||
async def prompt_text_startup(
|
||
self,
|
||
title,
|
||
*,
|
||
prompt="",
|
||
default="",
|
||
validate=None,
|
||
help_text="",
|
||
banner=None,
|
||
footnote=None,
|
||
):
|
||
assert validate("192.168.1.50:5000") is True # the validator accepts a good endpoint
|
||
return "192.168.1.50:5000"
|
||
|
||
async def busy_startup(self, message, coro, *, title="", banner=None, footnote=None):
|
||
return await coro
|
||
|
||
async def verify(device, pin=None):
|
||
probed["target"] = device.target
|
||
return {"adv_name": "WifiNode"} # a genuine companion answers
|
||
|
||
chosen = asyncio.run(prompt_device(_Ui(), [], store, verify))
|
||
assert chosen is not None and chosen.is_tcp and chosen.target == "192.168.1.50:5000"
|
||
assert probed["target"] == "192.168.1.50:5000"
|
||
# The confirmed network device is remembered forever, with the node name it reported.
|
||
remembered = store.load()
|
||
assert remembered is not None and remembered.is_tcp and remembered.node_name == "WifiNode"
|
||
|
||
|
||
def test_device_picker_removes_network_device_on_delete(tmp_path) -> None:
|
||
"""Delete on a network row confirms, forgets it, and it's gone from the re-drawn list."""
|
||
from meshterm.core.device_store import DeviceStore
|
||
from meshterm.core.discovery import tcp_device
|
||
from meshterm.ui.device_picker import _QUIT, prompt_device
|
||
from meshterm.ui.tui import DeleteRequest
|
||
|
||
store = DeviceStore(tmp_path / "devices.json")
|
||
store.remember(tcp_device("192.168.1.50", 5000), node_name="WifiNode")
|
||
|
||
seen_rows: list[list] = []
|
||
confirmed_prompts: list = []
|
||
|
||
class _Ui:
|
||
def __init__(self) -> None:
|
||
self._passes = 0
|
||
|
||
async def select_startup( # noqa: ANN001, ANN201, ANN003
|
||
self, title, items, *, default=None, banner=None, footnote=None, **_kw
|
||
):
|
||
names = [
|
||
it.label.plain
|
||
for it in items
|
||
if isinstance(it, Choice) and hasattr(it.label, "plain")
|
||
]
|
||
seen_rows.append(names)
|
||
self._passes += 1
|
||
if self._passes == 1:
|
||
# First pass: the network row is present and marked deletable — press Delete.
|
||
row = next(
|
||
it
|
||
for it in items
|
||
if isinstance(it, Choice) and getattr(it.value, "is_tcp", False)
|
||
)
|
||
assert row.deletable
|
||
return DeleteRequest(row.value)
|
||
# Second pass (after the removal): leave the picker.
|
||
return _QUIT
|
||
|
||
async def confirm_startup(
|
||
self,
|
||
prompt,
|
||
*,
|
||
title="",
|
||
confirm_label="Remove",
|
||
banner=None,
|
||
footnote=None,
|
||
backdrop_items=None,
|
||
backdrop_default=None,
|
||
):
|
||
confirmed_prompts.append(prompt)
|
||
# The confirm floats over the picker: it's handed the rows to redraw behind it,
|
||
# with the row being removed pre-highlighted.
|
||
assert backdrop_items is not None
|
||
assert getattr(backdrop_default, "is_tcp", False)
|
||
return True # the user confirms the removal
|
||
|
||
async def _never(_device, _pin=None):
|
||
raise AssertionError("verify should not run when a row is deleted, not chosen")
|
||
|
||
result = asyncio.run(prompt_device(_Ui(), [], store, _never))
|
||
assert result is None # quit on the second pass
|
||
# The confirm named the device and its endpoint.
|
||
assert confirmed_prompts and "WifiNode" in confirmed_prompts[0]
|
||
assert "192.168.1.50:5000" in confirmed_prompts[0]
|
||
# It was there on the first draw and gone on the second, and the store forgot it.
|
||
assert any("WifiNode" in name for name in seen_rows[0])
|
||
assert not any("WifiNode" in name for name in seen_rows[1])
|
||
assert store.load() is None
|
||
|
||
|
||
def test_device_picker_keeps_network_device_when_removal_cancelled(tmp_path) -> None:
|
||
"""Cancelling the Delete confirm leaves the remembered network device untouched."""
|
||
from meshterm.core.device_store import DeviceStore
|
||
from meshterm.core.discovery import tcp_device
|
||
from meshterm.ui.device_picker import _QUIT, prompt_device
|
||
from meshterm.ui.tui import DeleteRequest
|
||
|
||
store = DeviceStore(tmp_path / "devices.json")
|
||
store.remember(tcp_device("192.168.1.50", 5000), node_name="WifiNode")
|
||
|
||
class _Ui:
|
||
def __init__(self) -> None:
|
||
self._passes = 0
|
||
|
||
async def select_startup( # noqa: ANN001, ANN201, ANN003
|
||
self, title, items, *, default=None, banner=None, footnote=None, **_kw
|
||
):
|
||
self._passes += 1
|
||
if self._passes == 1:
|
||
row = next(
|
||
it
|
||
for it in items
|
||
if isinstance(it, Choice) and getattr(it.value, "is_tcp", False)
|
||
)
|
||
return DeleteRequest(row.value)
|
||
return _QUIT
|
||
|
||
async def confirm_startup(
|
||
self,
|
||
prompt,
|
||
*,
|
||
title="",
|
||
confirm_label="Remove",
|
||
banner=None,
|
||
footnote=None,
|
||
backdrop_items=None,
|
||
backdrop_default=None,
|
||
):
|
||
return False # the user backs out (Cancel / Esc)
|
||
|
||
async def _never(_device, _pin=None):
|
||
raise AssertionError("verify should not run")
|
||
|
||
asyncio.run(prompt_device(_Ui(), [], store, _never))
|
||
# Nothing was forgotten — the device is still remembered.
|
||
remembered = store.load()
|
||
assert remembered is not None and remembered.node_name == "WifiNode"
|
||
|
||
|
||
def test_confirm_startup_floats_red_over_the_picker_backdrop() -> None:
|
||
"""The removal confirm floats as a red popup over a redrawn picker, not a full splash."""
|
||
from meshterm.core.discovery import tcp_device
|
||
|
||
session = TuiSession()
|
||
device = tcp_device("192.168.1.50", 5000, name="WifiNode")
|
||
items = [Choice("WifiNode (192.168.1.50:5000)", device, deletable=True)]
|
||
|
||
async def main() -> None:
|
||
task = asyncio.ensure_future(
|
||
session.confirm_startup(
|
||
"Remove WifiNode?",
|
||
title="Remove network device",
|
||
banner=["MESHTERM"],
|
||
backdrop_items=items,
|
||
backdrop_default=device,
|
||
)
|
||
)
|
||
# Let confirm_startup push the backdrop list and float the confirm over it.
|
||
for _ in range(3):
|
||
await asyncio.sleep(0)
|
||
|
||
base = session._base_screen()
|
||
floats = session._float_layers()
|
||
# The picker is redrawn as the chromeless base; the confirm floats over it (not a
|
||
# full-screen splash that replaces the list).
|
||
assert isinstance(base, SelectScreen) and base.chrome is False
|
||
assert len(floats) == 1
|
||
dialog = floats[0]
|
||
assert isinstance(dialog, ButtonDialog)
|
||
assert dialog.border_style == "err" # the reserved data-loss red
|
||
|
||
dialog.resolve(True) # commit the removal
|
||
result = await task
|
||
assert result is True
|
||
assert session._stack == [] # the backdrop is torn down with the dialog
|
||
|
||
asyncio.run(main())
|
||
|
||
|
||
class _PickerUi:
|
||
"""A fake splash UI that always selects the first device, then dismisses messages."""
|
||
|
||
def __init__(self) -> None:
|
||
self.notes: list = []
|
||
|
||
async def select_startup( # noqa: ANN001, ANN201, ANN003
|
||
self, title, items, *, default=None, banner=None, footnote=None, **_kw
|
||
):
|
||
return next(it.value for it in items if isinstance(it, Choice))
|
||
|
||
async def notify_startup(self, renderable, *, title="", banner=None, footnote=None):
|
||
self.notes.append(renderable)
|
||
|
||
async def busy_startup(self, message, coro, *, title="", banner=None, footnote=None):
|
||
return await coro
|
||
|
||
|
||
def test_device_picker_smoke_tests_and_reprompts(tmp_path) -> None:
|
||
"""A failed smoke test re-prompts; a passing one is remembered as confirmed."""
|
||
from meshterm.core.device_store import DeviceStore
|
||
from meshterm.core.discovery import DiscoveredDevice
|
||
from meshterm.ui.device_picker import prompt_device
|
||
|
||
devices = [DiscoveredDevice(port="COM5", serial_number="SN1", product="Wio SX1262")]
|
||
store = DeviceStore(tmp_path / "devices.json")
|
||
ui = _PickerUi()
|
||
|
||
# First probe fails (not MeshCore), second answers with self-info.
|
||
results = [None, {"adv_name": "BaseStation"}]
|
||
|
||
async def verify(_device, _pin=None):
|
||
return results.pop(0)
|
||
|
||
chosen = asyncio.run(prompt_device(ui, devices, store, verify))
|
||
assert chosen is devices[0]
|
||
assert len(ui.notes) == 1 # the "not a MeshCore device" message was shown once
|
||
remembered = store.load()
|
||
assert remembered is not None and remembered.node_name == "BaseStation"
|
||
assert store.is_known(devices[0])
|
||
|
||
|
||
def test_device_picker_leaves_the_copyright_to_the_wordmark(tmp_path) -> None:
|
||
"""No splash states the copyright itself — the wordmark's own art already carries it."""
|
||
from meshterm.core.device_store import DeviceStore
|
||
from meshterm.core.discovery import DiscoveredDevice
|
||
from meshterm.ui.device_picker import prompt_device
|
||
|
||
devices = [DiscoveredDevice(port="COM5", serial_number="SN1", product="Wio SX1262")]
|
||
store = DeviceStore(tmp_path / "devices.json")
|
||
footnotes: list = []
|
||
|
||
class _Ui:
|
||
async def select_startup( # noqa: ANN001, ANN201, ANN003
|
||
self, title, items, *, default=None, banner=None, footnote=None, **_kw
|
||
):
|
||
footnotes.append(("select", footnote))
|
||
return next(it.value for it in items if isinstance(it, Choice))
|
||
|
||
async def notify_startup(self, renderable, *, title="", banner=None, footnote=None):
|
||
footnotes.append(("notify", footnote))
|
||
|
||
async def busy_startup(self, message, coro, *, title="", banner=None, footnote=None):
|
||
footnotes.append(("busy", footnote))
|
||
return await coro
|
||
|
||
# First probe fails (re-pick), second passes — exercising every post-selection screen.
|
||
results = [None, {"adv_name": "BaseStation"}]
|
||
|
||
async def verify(_device, _pin=None):
|
||
return results.pop(0)
|
||
|
||
asyncio.run(prompt_device(_Ui(), devices, store, verify))
|
||
# Every splash draws the logo, and the logo is where the copyright lives now — so none
|
||
# of them adds a line of its own: not the opening picker, the smoke-test spinner, the
|
||
# failure notice, nor the re-opened picker.
|
||
assert len(footnotes) > 1
|
||
assert all(footnote is None for _kind, footnote in footnotes)
|
||
|
||
|
||
def test_device_picker_prompts_and_retries_ble_pin(tmp_path) -> None:
|
||
"""A PIN-protected device opens the popup, re-asks on a wrong code, then connects."""
|
||
from meshterm.core.connection import DeviceAuthenticationError
|
||
from meshterm.core.device_store import DeviceStore
|
||
from meshterm.core.discovery import DiscoveredDevice
|
||
from meshterm.ui.device_picker import prompt_device
|
||
|
||
devices = [
|
||
DiscoveredDevice(transport="ble", address="00:11:22:33:44:55", name="MeshCore-Testbench")
|
||
]
|
||
store = DeviceStore(tmp_path / "devices.json")
|
||
|
||
entered = iter(["000000", "654321"]) # a wrong code, then the right one
|
||
errors: list = []
|
||
|
||
class _Ui:
|
||
async def select_startup( # noqa: ANN001, ANN201, ANN003
|
||
self, title, items, *, default=None, banner=None, footnote=None, **_kw
|
||
):
|
||
return next(it.value for it in items if isinstance(it, Choice))
|
||
|
||
async def notify_startup(self, renderable, *, title="", banner=None, footnote=None):
|
||
raise AssertionError("a successful PIN connect shows no failure notice")
|
||
|
||
async def busy_startup(self, message, coro, *, title="", banner=None, footnote=None):
|
||
return await coro
|
||
|
||
async def prompt_pin_startup(
|
||
self, device_name, *, error="", help_text="", banner=None, footnote=None
|
||
):
|
||
errors.append(error)
|
||
return next(entered)
|
||
|
||
async def verify(_device, pin=None):
|
||
if pin != "654321": # the first probe (no PIN) and the wrong code both get rejected
|
||
# The connection names the refusal; the picker carries its hint into the dialog.
|
||
hint = "That PIN was rejected — check the code and try again." if pin else ""
|
||
raise DeviceAuthenticationError("needs a Bluetooth pairing PIN", hint=hint)
|
||
return {"adv_name": "Pinned", "model": "Seeed Tracker T1000-E"}
|
||
|
||
chosen = asyncio.run(prompt_device(_Ui(), devices, store, verify))
|
||
assert chosen is devices[0]
|
||
# Asked twice: first with no error, then with the refusal's own hint after the wrong code.
|
||
assert errors[0] == ""
|
||
assert "rejected" in errors[1].lower()
|
||
remembered = store.load()
|
||
assert remembered is not None
|
||
assert remembered.node_name == "Pinned"
|
||
assert remembered.hardware_model == "Seeed Tracker T1000-E" # learned once connected
|
||
|
||
|
||
def test_device_picker_pin_cancel_returns_to_list(tmp_path) -> None:
|
||
"""Esc on the PIN popup returns to the device list instead of connecting."""
|
||
from meshterm.core.connection import DeviceAuthenticationError
|
||
from meshterm.core.device_store import DeviceStore
|
||
from meshterm.core.discovery import DiscoveredDevice
|
||
from meshterm.ui.device_picker import _QUIT, prompt_device
|
||
|
||
devices = [
|
||
DiscoveredDevice(transport="ble", address="00:11:22:33:44:55", name="MeshCore-Testbench")
|
||
]
|
||
store = DeviceStore(tmp_path / "devices.json")
|
||
picks = iter([0, "quit"]) # pick the device once, then quit the re-opened list
|
||
|
||
class _Ui:
|
||
async def select_startup( # noqa: ANN001, ANN201, ANN003
|
||
self, title, items, *, default=None, banner=None, footnote=None, **_kw
|
||
):
|
||
choices = [it for it in items if isinstance(it, Choice)]
|
||
step = next(picks)
|
||
if step == "quit":
|
||
return next(it.value for it in choices if it.value is _QUIT)
|
||
return choices[step].value
|
||
|
||
async def busy_startup(self, message, coro, *, title="", banner=None, footnote=None):
|
||
return await coro
|
||
|
||
async def prompt_pin_startup(
|
||
self, device_name, *, error="", help_text="", banner=None, footnote=None
|
||
):
|
||
return None # the user cancels the PIN entry
|
||
|
||
async def verify(_device, pin=None):
|
||
raise DeviceAuthenticationError("needs a Bluetooth pairing PIN")
|
||
|
||
result = asyncio.run(prompt_device(_Ui(), devices, store, verify))
|
||
assert result is None # cancelling the PIN then quitting leaves the picker empty-handed
|
||
assert store.load() is None # nothing was remembered
|
||
|
||
|
||
def test_device_picker_quit_row_returns_none(tmp_path) -> None:
|
||
"""Choosing the trailing Quit row leaves the picker (None) without a smoke test."""
|
||
from meshterm.core.device_store import DeviceStore
|
||
from meshterm.core.discovery import DiscoveredDevice
|
||
from meshterm.ui.device_picker import _QUIT, prompt_device
|
||
|
||
devices = [DiscoveredDevice(port="COM5", product="Wio SX1262")]
|
||
|
||
class _QuitUi:
|
||
async def select_startup( # noqa: ANN001, ANN201, ANN003
|
||
self, title, items, *, default=None, banner=None, footnote=None, **_kw
|
||
):
|
||
# The last choice is the Quit row; picking it signals "exit".
|
||
quit_choice = [it for it in items if isinstance(it, Choice) and it.value is _QUIT]
|
||
assert quit_choice, "the picker offers a Quit row"
|
||
return quit_choice[0].value
|
||
|
||
async def _never(_device):
|
||
raise AssertionError("verify must not run when the user quits")
|
||
|
||
store = DeviceStore(tmp_path / "devices.json")
|
||
assert asyncio.run(prompt_device(_QuitUi(), devices, store, _never)) is None
|
||
|
||
|
||
# --- busy splash --------------------------------------------------------------
|
||
|
||
|
||
def test_busy_screen_spins_over_its_message() -> None:
|
||
"""The busy splash shows an ASCII spinner beside its message and advances on tick."""
|
||
from rich.text import Text as RichText
|
||
|
||
from meshterm.ui.tui.screen import BusyScreen
|
||
from meshterm.ui.tui.spinner import Spinner
|
||
|
||
screen = BusyScreen("Talking to Wio on COM5…")
|
||
|
||
def glyph() -> str:
|
||
"""The first (spinner) character of the rendered body, ANSI codes stripped."""
|
||
return RichText.from_ansi("\n".join(screen.render_body(60))).plain.lstrip()[0]
|
||
|
||
plain = RichText.from_ansi("\n".join(screen.render_body(60))).plain
|
||
assert "Talking to Wio on COM5" in plain
|
||
assert glyph() in Spinner.BRAILLE # a spinner glyph leads the line
|
||
|
||
# Ticking cycles through every frame and returns to the first.
|
||
seen = {glyph()}
|
||
for _ in range(len(Spinner.BRAILLE) - 1):
|
||
screen.tick()
|
||
seen.add(glyph())
|
||
assert seen == set(Spinner.BRAILLE)
|
||
|
||
|
||
def test_spinner_cycles_and_resets() -> None:
|
||
"""The reusable Spinner advances through its frames, wraps, and resets."""
|
||
from meshterm.ui.tui.spinner import Spinner
|
||
|
||
spinner = Spinner("ab", style="warn")
|
||
assert spinner.frame == "a"
|
||
assert spinner.text().plain == "a" and spinner.text().style == "warn"
|
||
spinner.tick()
|
||
assert spinner.frame == "b"
|
||
spinner.tick() # wraps back to the first frame
|
||
assert spinner.frame == "a"
|
||
spinner.tick()
|
||
spinner.reset()
|
||
assert spinner.frame == "a"
|
||
|
||
|
||
# --- progress ----------------------------------------------------------------
|
||
|
||
|
||
def test_progress_handle_matches_rich_api() -> None:
|
||
"""add_task/advance/update mirror the Rich Progress subset the tools rely on."""
|
||
screen = ProgressScreen("work")
|
||
task = screen.add_task("tracing", total=5)
|
||
screen.advance(task)
|
||
screen.advance(task, 2)
|
||
assert screen._tasks[task].completed == 3
|
||
screen.update(task, description="tracing more", completed=5, total=5)
|
||
assert screen._tasks[task].description == "tracing more"
|
||
assert screen._tasks[task].completed == 5
|
||
# Renders without error at a realistic width.
|
||
assert screen.render_body(60)
|
||
|
||
|
||
def test_progress_chip_animates_between_advances() -> None:
|
||
"""The working chip spins on tick alone, so a task that rarely advances still reads alive.
|
||
|
||
A trace advances just once, at the very end; the meter would sit motionless until then.
|
||
Ticking the dialog must change what it renders even with the task's count untouched.
|
||
"""
|
||
screen = ProgressScreen("trace")
|
||
screen.add_task("tracing", total=1) # will sit at 0/1 for the whole wait
|
||
before = "\n".join(screen.render_body(60))
|
||
screen.tick()
|
||
after = "\n".join(screen.render_body(60))
|
||
assert before != after # the chip moved even though nothing advanced
|
||
|
||
|
||
def test_progress_completed_task_shows_check_and_indeterminate_has_no_track() -> None:
|
||
"""A finished task flips its chip to ✓; an unknown-total task draws no dead track."""
|
||
from rich.text import Text as RichText
|
||
|
||
screen = ProgressScreen("work")
|
||
done = screen.add_task("tracing", total=2)
|
||
screen.advance(done, 2)
|
||
screen.add_task("optimizing", total=None)
|
||
plain = RichText.from_ansi("\n".join(screen.render_body(60))).plain
|
||
assert "✓ tracing" in plain
|
||
# The indeterminate row carries only the spinning chip + its count, never a track glyph.
|
||
indet_line = next(line for line in plain.splitlines() if "optimizing" in line)
|
||
assert "⠶" not in indet_line and "⣿" not in indet_line
|
||
|
||
|
||
# --- session stack -----------------------------------------------------------
|
||
|
||
|
||
async def test_session_runs_and_exits_when_main_returns() -> None:
|
||
"""The app starts, drives the main coroutine, and exits cleanly when it returns."""
|
||
with create_pipe_input() as inp:
|
||
session = TuiSession(input=inp, output=DummyOutput())
|
||
ran = {}
|
||
|
||
async def main() -> None:
|
||
ran["done"] = True
|
||
|
||
await asyncio.wait_for(session.run(main()), timeout=5)
|
||
assert ran["done"] is True
|
||
|
||
|
||
async def test_session_select_dispatches_piped_keys() -> None:
|
||
"""A select resolves the value chosen via piped Down+Enter keystrokes, end to end."""
|
||
with create_pipe_input() as inp:
|
||
session = TuiSession(input=inp, output=DummyOutput())
|
||
captured = {}
|
||
|
||
async def main() -> None:
|
||
captured["value"] = await session.select(
|
||
"pick", [Choice("a", 1), Choice("b", 2), Choice("c", 3)]
|
||
)
|
||
|
||
inp.send_text("\x1b[B\x1b[B\r") # Down, Down, Enter -> third choice
|
||
await asyncio.wait_for(session.run(main()), timeout=5)
|
||
assert captured["value"] == 3
|
||
|
||
|
||
async def test_session_busy_startup_animates_and_returns() -> None:
|
||
"""busy_startup awaits the task behind a chromeless spinner splash, then pops it."""
|
||
from meshterm.ui.tui.screen import BusyScreen
|
||
|
||
with create_pipe_input() as inp:
|
||
session = TuiSession(input=inp, output=DummyOutput())
|
||
captured = {}
|
||
|
||
async def work() -> str:
|
||
# While the task runs the busy splash is the chromeless base screen.
|
||
assert isinstance(session.top, BusyScreen)
|
||
assert session.top.chrome is False
|
||
await asyncio.sleep(0.3) # long enough for the spinner to tick at least once
|
||
return "ok"
|
||
|
||
async def main() -> None:
|
||
captured["value"] = await session.busy_startup("checking…", work())
|
||
|
||
await asyncio.wait_for(session.run(main()), timeout=5)
|
||
assert captured["value"] == "ok"
|
||
assert session.top is None # the splash was popped when the task finished
|
||
|
||
|
||
async def test_busy_screens_spin_at_the_platform_cadence() -> None:
|
||
"""Neither busy screen carries a spin rate of its own.
|
||
|
||
A repaint on the PicoCalc console costs more than these loops used to wait between
|
||
them, so a hardcoded cadence spent the event loop redrawing the spinner and starved
|
||
the device read it was covering for — the wait got *slower* for being animated. Both
|
||
default to the one platform decision, like every other animated wait in the app.
|
||
"""
|
||
import inspect
|
||
|
||
from meshterm.ui.tui.session import TuiSession as _Session
|
||
|
||
for name in ("busy_startup", "busy_overlay"):
|
||
interval = inspect.signature(getattr(_Session, name)).parameters["interval"]
|
||
assert interval.default is None, f"{name} pins its own spin rate: {interval.default!r}"
|
||
|
||
|
||
async def test_busy_startup_keeps_ticking_through_a_slow_await() -> None:
|
||
"""The spinner animates while the awaited work runs, not just before and after.
|
||
|
||
The animation shares the event loop with the request it reports on, so anything that
|
||
blocks the loop stops it dead — which is the whole reason the slow parts of a device
|
||
read (enumerating serial ports, reading history) are handed to a thread.
|
||
"""
|
||
from meshterm.ui.tui.screen import BusyScreen
|
||
|
||
with create_pipe_input() as inp:
|
||
session = TuiSession(input=inp, output=DummyOutput())
|
||
frames: list[str] = []
|
||
|
||
async def work() -> str:
|
||
screen = session.top
|
||
assert isinstance(screen, BusyScreen)
|
||
for _ in range(6): # span several spins without blocking the loop
|
||
await asyncio.sleep(spinner_interval())
|
||
frames.append(screen._spinner.frame)
|
||
return "ok"
|
||
|
||
async def main() -> None:
|
||
await session.busy_startup("checking…", work(), interval=spinner_interval() / 4)
|
||
|
||
await asyncio.wait_for(session.run(main()), timeout=15)
|
||
assert len(set(frames)) > 1, f"the spinner never advanced: {frames}"
|
||
|
||
|
||
async def test_session_text_dispatches_typed_keys() -> None:
|
||
"""A text prompt captures typed characters and commits on Enter, end to end."""
|
||
with create_pipe_input() as inp:
|
||
session = TuiSession(input=inp, output=DummyOutput())
|
||
captured = {}
|
||
|
||
async def main() -> None:
|
||
captured["value"] = await session.text("name?")
|
||
|
||
inp.send_text("hi\r")
|
||
await asyncio.wait_for(session.run(main()), timeout=5)
|
||
assert captured["value"] == "hi"
|
||
|
||
|
||
def test_session_stack_and_float_selection() -> None:
|
||
"""The stack tracks top/base and only floats a deeper screen over its parent."""
|
||
session = TuiSession()
|
||
assert session.top is None
|
||
base = ScrollScreen(Text("base"), title="base")
|
||
session.push(base)
|
||
assert session.top is base
|
||
assert session._base_screen() is base
|
||
assert not session._has_float() # single screen: no float
|
||
|
||
dialog = SelectScreen("pick", [Choice("a", 1)])
|
||
session.push(dialog)
|
||
assert session._has_float() # deeper screen floats
|
||
assert session._base_screen() is base # base is the parent beneath the dialog
|
||
session.pop(dialog)
|
||
assert session.top is base
|
||
assert not session._has_float()
|
||
|
||
|
||
def test_session_stacks_every_dialog_over_one_full_frame_background() -> None:
|
||
"""A dialog over a dialog: both float over the single background, neither full-frame.
|
||
|
||
The old compositor drew only the top float and rendered the second-from-top as the
|
||
full-frame base — so opening a confirm over a popup stretched that popup to fill the
|
||
frame. Now the background is the deepest full-frame screen and every floating layer
|
||
above it stays its own centered box.
|
||
"""
|
||
session = TuiSession()
|
||
channels = SelectScreen("Channels", [Choice("Ops", 1)]) # a tool's own floating list
|
||
detail = SelectScreen("Ops (private)", [Choice("Clear", "clr")]) # its item popup
|
||
confirm = ButtonDialog("Clear Ops?", [("Cancel", 0), ("Clear", 1)], border_style="err")
|
||
for screen in (channels, detail, confirm):
|
||
session.push(screen)
|
||
|
||
# The bottom (all-floating) screen is the one full-frame background; the detail popup
|
||
# and the confirm both float over it — the detail is no longer promoted to the base.
|
||
assert session._base_screen() is channels
|
||
assert session._float_layers() == [detail, confirm]
|
||
|
||
session.pop(confirm)
|
||
assert session._float_layers() == [detail] # detail stays a float, not full-frame
|
||
assert session._base_screen() is channels
|
||
|
||
|
||
def test_session_background_is_the_topmost_full_frame_screen() -> None:
|
||
"""A non-floating screen (a map, a scroll window) is the background under any dialogs."""
|
||
session = TuiSession()
|
||
full = ScrollScreen(Text("map"), title="map") # floating=False
|
||
dialog = ButtonDialog("go?", [("No", 0), ("Yes", 1)])
|
||
session.push(full)
|
||
session.push(dialog)
|
||
assert session._base_screen() is full
|
||
assert session._float_layers() == [dialog]
|
||
|
||
|
||
def test_dispatch_promotes_nav_actions_while_right_ctrl_is_held(monkeypatch) -> None:
|
||
"""A bare navigation key becomes its Ctrl chord while right Ctrl is held.
|
||
|
||
This is the rescue for layouts (Canadian Multilingual Standard) that claim right
|
||
Ctrl as a character modifier and strip the ctrl flag from the console's arrow
|
||
event. Actions with no Ctrl sibling pass through untouched, as does everything
|
||
once the key is released.
|
||
"""
|
||
from meshterm.ui.tui import session as session_mod
|
||
|
||
session = TuiSession()
|
||
seen: list[str] = []
|
||
|
||
class Probe(ScrollScreen):
|
||
def handle(self, action: str, data: str = "") -> None:
|
||
seen.append(action)
|
||
|
||
session.push(Probe(Text("x")))
|
||
|
||
monkeypatch.setattr(session_mod, "_right_ctrl_down", lambda: True)
|
||
session._dispatch("left")
|
||
session._dispatch("home")
|
||
# Enter has a sibling too — it is the one key a terminal can't spell chorded itself.
|
||
session._dispatch("enter")
|
||
session._dispatch("escape") # no ctrl sibling: untouched even while held
|
||
monkeypatch.setattr(session_mod, "_right_ctrl_down", lambda: False)
|
||
session._dispatch("left")
|
||
assert seen == ["ctrl_left", "ctrl_home", "ctrl_enter", "escape", "left"]
|
||
|
||
|
||
def test_dispatch_promotes_letter_chords_while_right_ctrl_is_held(monkeypatch) -> None:
|
||
"""A bare letter becomes its Ctrl-letter chord while right Ctrl is held.
|
||
|
||
The same rescue as the nav keys, for the ^R/^P shortcuts a layout-claimed right
|
||
Ctrl would otherwise strip to plain text. Only the mapped letters promote
|
||
(case-folded, with the now-stale data dropped); other text — and everything once
|
||
the key is released — stays text.
|
||
"""
|
||
from meshterm.ui.tui import session as session_mod
|
||
|
||
session = TuiSession()
|
||
seen: list[tuple[str, str]] = []
|
||
|
||
class Probe(ScrollScreen):
|
||
def handle(self, action: str, data: str = "") -> None:
|
||
seen.append((action, data))
|
||
|
||
session.push(Probe(Text("x")))
|
||
|
||
monkeypatch.setattr(session_mod, "_right_ctrl_down", lambda: True)
|
||
session._dispatch("text", "r") # ^R retry
|
||
session._dispatch("text", "P") # ^P paths, case-folded
|
||
session._dispatch("text", "x") # unmapped letter: stays text even while held
|
||
monkeypatch.setattr(session_mod, "_right_ctrl_down", lambda: False)
|
||
session._dispatch("text", "r") # released: plain text again
|
||
assert seen == [("retry", ""), ("paths", ""), ("text", "x"), ("text", "r")]
|
||
|
||
|
||
def test_right_ctrl_rescue_covers_the_sessions_own_chords(monkeypatch) -> None:
|
||
"""The right-Ctrl rescue reaches the session's own chords, not just screen actions.
|
||
|
||
^V and ^C are answered by the session itself, and a layout-claimed right Ctrl must
|
||
reach them too — right Ctrl-V pastes the clipboard into a compose line rather than
|
||
typing a ``v``, right Ctrl-C quits. Neither pseudo-action is ever forwarded to a
|
||
screen.
|
||
"""
|
||
from meshterm.ui.tui import session as session_mod
|
||
|
||
session = TuiSession()
|
||
seen: list[tuple[str, str]] = []
|
||
|
||
class Probe(ScrollScreen):
|
||
def handle(self, action: str, data: str = "") -> None:
|
||
seen.append((action, data))
|
||
|
||
class FakeApp:
|
||
def __init__(self) -> None:
|
||
self.exited = False
|
||
self.is_running = True
|
||
|
||
def exit(self) -> None:
|
||
self.exited = True
|
||
|
||
def invalidate(self) -> None:
|
||
pass
|
||
|
||
session.push(Probe(Text("x")))
|
||
session._app = FakeApp()
|
||
monkeypatch.setattr(session_mod, "_read_clipboard", lambda: "pasted")
|
||
|
||
monkeypatch.setattr(session_mod, "_right_ctrl_down", lambda: True)
|
||
session._dispatch("text", "v") # ^V: clipboard reaches the screen as a paste
|
||
assert seen == [("paste", "pasted")]
|
||
session._dispatch("text", "c") # ^C: quits, and nothing lands on the screen
|
||
assert seen == [("paste", "pasted")]
|
||
assert session._app.exited is True
|
||
|
||
monkeypatch.setattr(session_mod, "_right_ctrl_down", lambda: False)
|
||
session._dispatch("text", "v") # released: a plain typed character again
|
||
assert seen[-1] == ("text", "v")
|
||
|
||
|
||
def test_every_ctrl_letter_chord_is_bound_on_both_ctrl_keys() -> None:
|
||
"""Every Ctrl-letter chord is bound on both Ctrl keys.
|
||
|
||
The chord table drives the prompt_toolkit bindings, so a chord can never be bound
|
||
for the left Ctrl without its right-Ctrl rescue — the drift the two used to be
|
||
able to develop when the letter map was maintained by hand.
|
||
"""
|
||
from prompt_toolkit.keys import Keys
|
||
|
||
from meshterm.ui.tui.session import _CTRL_LETTER_CHORDS, _KEY_ACTIONS
|
||
|
||
for letter, action in _CTRL_LETTER_CHORDS.items():
|
||
key = getattr(Keys, f"Control{letter.upper()}")
|
||
assert _KEY_ACTIONS[key] == action
|
||
# Enter/Tab/Backspace are spelled c-m/c-i/c-h: claiming those letters would rebind them.
|
||
assert not {"m", "i", "h"} & set(_CTRL_LETTER_CHORDS)
|
||
assert _KEY_ACTIONS[Keys.Enter] == "enter"
|
||
|
||
|
||
def test_wide_glyph_detection_flags_emoji_not_marks() -> None:
|
||
"""The wide-glyph check flags emoji, and leaves the app's own marks alone.
|
||
|
||
The desync only ever comes from a width-2 glyph the terminal may draw narrower.
|
||
Node-type marks, status marks and chart braille are width-1 everywhere, so they
|
||
must not trip the check — that they seemed to was the earlier misdiagnosis.
|
||
"""
|
||
from meshterm.ui.tui.session import _has_wide_glyph
|
||
|
||
assert _has_wide_glyph("👋")
|
||
assert _has_wide_glyph("Bob 👋 waved")
|
||
assert _has_wide_glyph("clock 🕒 sync")
|
||
assert _has_wide_glyph("⚡ explore") # a width-2 icon still counts
|
||
assert not _has_wide_glyph("plain ascii row")
|
||
assert not _has_wide_glyph("★ ▲ ● ■ ◉ ○") # node-type marks: width 1
|
||
assert not _has_wide_glyph("⠿⣿⡇ chart") # braille: width 1
|
||
assert not _has_wide_glyph("✓ ✗ ⚠ … done") # status marks: width 1
|
||
|
||
|
||
def _repaint_harness():
|
||
"""A session wired to a fake pt app, plus its remembered 3-row frame of ``A`` cells."""
|
||
import types
|
||
|
||
from prompt_toolkit.data_structures import Size
|
||
from prompt_toolkit.layout.screen import Char
|
||
from prompt_toolkit.layout.screen import Screen as PtScreen
|
||
|
||
remembered = PtScreen()
|
||
for row in range(3):
|
||
for x in range(10):
|
||
remembered.data_buffer[row][x] = Char("A")
|
||
session = TuiSession()
|
||
session._app = types.SimpleNamespace(
|
||
renderer=types.SimpleNamespace(_last_screen=remembered),
|
||
output=types.SimpleNamespace(get_size=lambda: Size(rows=10, columns=60)),
|
||
invalidate=lambda: None,
|
||
)
|
||
return session, remembered
|
||
|
||
|
||
def _row_text(screen, row: int) -> str:
|
||
"""The remembered frame's row as plain characters (the scrub's sentinel shows through)."""
|
||
return "".join(screen.data_buffer[row][x].char for x in range(10))
|
||
|
||
|
||
def test_a_wide_glyph_frame_upgrades_to_a_full_repaint() -> None:
|
||
"""A frame carrying a wide glyph upgrades the next paint to a full repaint.
|
||
|
||
prompt_toolkit paints differentially with a *relative* cursor — sound only while
|
||
every glyph is one cell. A width-2 glyph the terminal draws in one cell (an emoji
|
||
in a chat line) leaves the row's cursor model off; a later paint that skips the
|
||
unchanged emoji then strands stale cells to its right. With no remembered frame to
|
||
compare against, such a frame drops pt's cached frame and takes the erase_down +
|
||
redraw instead. A frame of only width-1 glyphs keeps the fast differential paint —
|
||
this holds wherever the glyph is, floating dialog or not.
|
||
"""
|
||
session, _remembered = _repaint_harness()
|
||
session._emit("Bob 👋 says hi")
|
||
assert session._app.renderer._last_screen is None
|
||
|
||
# A frame of only width-1 glyphs — plain text, node marks, chart braille — keeps the
|
||
# efficient differential paint.
|
||
session, remembered = _repaint_harness()
|
||
session._emit("★ you ▲ repeater ● node ⠿ chart")
|
||
assert session._app.renderer._last_screen is remembered
|
||
session._emit("★ you ▲ repeater ● node ⠿ chart · moved") # changed, still all width-1
|
||
assert session._app.renderer._last_screen is remembered
|
||
assert _row_text(remembered, 0) == "A" * 10 # nothing scrubbed either
|
||
|
||
|
||
def test_only_the_rows_that_changed_are_repainted() -> None:
|
||
"""A ticking header repaints the header, not the screen under it.
|
||
|
||
The requirement a wide glyph imposes is that its *row* be rewritten whole, from column 0;
|
||
pt steps down a row with a carriage return, so the misalignment can never reach the rows
|
||
below. The background composes one line per terminal row, so the rows that changed are
|
||
exactly what needs rewriting — and an idle frame changes none of them, which is what the
|
||
1 Hz refresh was flickering over.
|
||
"""
|
||
session, remembered = _repaint_harness()
|
||
frame_1 = "🎯 Farthest node\nrow one\nrow two"
|
||
session._emit(frame_1)
|
||
assert session._app.renderer._last_screen is None # nothing to compare against yet
|
||
|
||
session._app.renderer._last_screen = remembered
|
||
session._emit(frame_1) # the timer tick: same frame, nothing touched at all
|
||
assert session._app.renderer._last_screen is remembered
|
||
assert [_row_text(remembered, y) for y in range(3)] == ["A" * 10] * 3
|
||
|
||
session._emit("🎯 Farthest node\nrow one changed\nrow two")
|
||
assert session._app.renderer._last_screen is remembered # no erase, no full redraw
|
||
assert _row_text(remembered, 1) == "" * 10 # the one changed row, marked whole
|
||
assert _row_text(remembered, 0) == "A" * 10 # …and the rows around it left alone
|
||
assert _row_text(remembered, 2) == "A" * 10
|
||
|
||
# A frame whose height moved has no row mapping to trust — repaint everything.
|
||
session._emit("🎯 Farthest node\nrow one changed")
|
||
assert session._app.renderer._last_screen is None
|
||
|
||
|
||
def test_a_changed_layer_repaints_over_a_still_wide_glyph_frame() -> None:
|
||
"""Any layer changing upgrades the paint while a wide glyph is drawn *anywhere*.
|
||
|
||
A plain dialog moving over a base row that carries an emoji is rewritten from a model of
|
||
that row the terminal disagrees with, so it is the frame as a whole — not the layer that
|
||
happened to change — that decides. A layer *leaving* counts as a change too: a closing
|
||
dialog just stops rendering, and the cells it gives back to the base would otherwise be
|
||
rewritten piecemeal.
|
||
"""
|
||
import types
|
||
|
||
from prompt_toolkit.data_structures import Size
|
||
from prompt_toolkit.layout.screen import Screen as PtScreen
|
||
|
||
session = TuiSession()
|
||
remembered = PtScreen()
|
||
session._app = types.SimpleNamespace(
|
||
renderer=types.SimpleNamespace(_last_screen=remembered),
|
||
output=types.SimpleNamespace(get_size=lambda: Size(rows=10, columns=60)),
|
||
invalidate=lambda: None,
|
||
)
|
||
session._emit("🎯 the board behind", "base") # a wide glyph on the background
|
||
session._app.renderer._last_screen = remembered
|
||
session._emit("plain dialog, frame 1", "float0") # no emoji of its own…
|
||
assert session._app.renderer._last_screen is None # …but the frame carries one
|
||
|
||
session._app.renderer._last_screen = remembered
|
||
session._emit("plain dialog, frame 1", "float0") # unchanged again → left alone
|
||
assert session._app.renderer._last_screen is remembered
|
||
|
||
# The dialog closes: one screen on the stack means no float layer this paint, so
|
||
# reconciling drops it — and, with the emoji-bearing base still drawn, the cells it hands
|
||
# back are repainted whole rather than piecemeal.
|
||
session.push(Screen())
|
||
session._app.renderer._last_screen = remembered
|
||
session._reconcile_layers()
|
||
assert "float0" not in session._layers
|
||
assert session._app.renderer._last_screen is None
|
||
|
||
# With nothing wide left drawn, a layer leaving costs no repaint at all.
|
||
session._layers.clear()
|
||
session._emit("plain base", "base")
|
||
session._emit("plain dialog", "float0")
|
||
session._app.renderer._last_screen = remembered
|
||
session._reconcile_layers()
|
||
assert session._app.renderer._last_screen is remembered
|
||
|
||
|
||
def test_floating_text_prompt_is_a_popup_over_a_blank_base() -> None:
|
||
"""``text(floating=True)`` floats as a centered popup even on an empty stack.
|
||
|
||
A mid-flow modal — a remote-admin password, between the node picker and the admin menu —
|
||
must float like the button dialogs, so a blank base is slipped beneath it rather than
|
||
letting the prompt fill the frame the way a tool's primary entry screen does.
|
||
"""
|
||
session = TuiSession()
|
||
|
||
async def main() -> None:
|
||
task = asyncio.ensure_future(session.text("Password", password=True, floating=True))
|
||
for _ in range(5):
|
||
await asyncio.sleep(0)
|
||
if session._has_float():
|
||
break
|
||
floats = session._float_layers()
|
||
assert len(floats) == 1 and isinstance(floats[0], TextScreen)
|
||
assert session._base_screen() is not floats[0] # a blank base sits beneath it
|
||
|
||
floats[0].resolve("hunter2")
|
||
assert await task == "hunter2"
|
||
assert session._stack == [] # the blank base is torn down with the prompt
|
||
|
||
asyncio.run(main())
|
||
|
||
|
||
def test_default_text_prompt_is_the_full_frame_base() -> None:
|
||
"""A default ``text`` prompt on an empty stack *is* the frame — a tool's primary entry.
|
||
|
||
The Trace target's typed fallback stands in for the select picker, so it fills the frame
|
||
rather than floating over a blank base (the floating popup is opt-in, see above).
|
||
"""
|
||
session = TuiSession()
|
||
|
||
async def main() -> None:
|
||
task = asyncio.ensure_future(session.text("Target node"))
|
||
for _ in range(5):
|
||
await asyncio.sleep(0)
|
||
if session.top is not None:
|
||
break
|
||
assert isinstance(session.top, TextScreen)
|
||
assert not session._has_float() # no float — the prompt is the background
|
||
assert session._base_screen() is session.top
|
||
|
||
session.top.resolve("Hub")
|
||
assert await task == "Hub"
|
||
|
||
asyncio.run(main())
|
||
|
||
|
||
# --- busy skeleton card ------------------------------------------------------
|
||
|
||
|
||
def test_busy_overlay_renders_only_its_title_chip_and_caption() -> None:
|
||
"""A titled, captioned card shows its heading and the working chip + caption — no more.
|
||
|
||
The card used to carry a Knight-Rider scanning bar under the caption (JP, 2026-08-29);
|
||
the chip is the whole animation now, so the card is two lines and nothing travels
|
||
across it.
|
||
"""
|
||
import re
|
||
|
||
from meshterm.ui.tui.overlay import BusyOverlay
|
||
|
||
overlay = BusyOverlay("reading from Waymarker…", title="Nodes", fade=0.0) # full bright at once
|
||
ansi = overlay.render()
|
||
assert "Nodes" in ansi # the heading naming the screen being fetched
|
||
assert "reading from Waymarker" in ansi # the caption beside the chip
|
||
assert overlay.spinner.frame in ansi # the one-cell working chip
|
||
plain = re.sub(r"\[[0-9;]*m", "", ansi)
|
||
rows = [line for line in plain.splitlines() if line.strip()]
|
||
assert len(rows) == 2 # the title and the chip line; no bar, no spacer row
|
||
assert "⠶" not in plain # the LED lamps are gone, not merely unlit
|
||
|
||
|
||
def test_busy_overlay_chip_ticks_with_the_animation() -> None:
|
||
"""The card's working chip is the reusable one-cell Spinner and advances on tick."""
|
||
from meshterm.ui.tui.overlay import BusyOverlay
|
||
from meshterm.ui.tui.spinner import Spinner
|
||
|
||
overlay = BusyOverlay()
|
||
assert isinstance(overlay.spinner, Spinner)
|
||
first = overlay.spinner.frame
|
||
overlay.tick()
|
||
assert overlay.spinner.frame != first
|
||
|
||
|
||
def test_busy_overlay_holds_black_then_fades_in() -> None:
|
||
"""Brightness is 0 through the hold, then climbs to full colour over the fade window."""
|
||
from meshterm.ui.tui.overlay import BusyOverlay
|
||
|
||
overlay = BusyOverlay(hold=0.1, fade=0.2)
|
||
assert overlay.brightness == 0.0 # nothing paints during the hold
|
||
overlay.started_at -= 0.1 # to the very end of the hold
|
||
assert overlay.brightness < 0.2 # only now beginning to glow up from black
|
||
overlay.started_at -= 0.2 # past the full fade window
|
||
assert overlay.brightness == 1.0 # fully lit
|
||
|
||
|
||
def test_dim_color_scales_hex_toward_black() -> None:
|
||
"""The fade dimmer scales the hex channels and preserves attribute words like ``bold``."""
|
||
from meshterm.ui.tui.overlay import dim_color
|
||
|
||
assert dim_color("#38bdf8", 1.0) == "#38bdf8" # untouched at full brightness
|
||
assert dim_color("#ffffff", 0.0) == "#000000" # black at zero
|
||
assert dim_color("bold #ffffff", 0.5) == "bold #808080" # keeps 'bold', halves the colour
|
||
|
||
|
||
def test_overlay_fade_restarts_when_re_exposed_after_a_prompt() -> None:
|
||
"""Popping back to an empty stack replays the black-hold + fade, not a full-bright snap."""
|
||
from meshterm.ui.tui.overlay import BusyOverlay
|
||
|
||
session = TuiSession()
|
||
overlay = BusyOverlay()
|
||
session._overlay = overlay
|
||
overlay.started_at -= 10 # pretend the intro already finished
|
||
assert overlay.brightness == 1.0
|
||
|
||
screen = ScrollScreen(Text("prompt"))
|
||
session.push(screen) # a prompt covers the card
|
||
session.pop(screen) # dismissed → card re-exposed on the now-empty stack
|
||
assert overlay.brightness == 0.0 # the fade restarted from black
|
||
|
||
|
||
async def test_session_busy_overlay_shows_between_screens_and_clears() -> None:
|
||
"""The overlay floats while a block runs on an empty stack, and is dropped afterwards."""
|
||
with create_pipe_input() as inp:
|
||
session = TuiSession(input=inp, output=DummyOutput())
|
||
seen = {}
|
||
|
||
async def main() -> None:
|
||
async with session.busy_overlay("working…"):
|
||
await asyncio.sleep(0.05) # still within the initial hold
|
||
seen["hidden_during_hold"] = not session._overlay_visible()
|
||
await asyncio.sleep(0.3) # past the 200ms hold: the ring has faded in
|
||
seen["active"] = session._overlay is not None
|
||
seen["visible_empty_stack"] = session._overlay_visible()
|
||
seen["rendered"] = bool(session._render_overlay().value.strip())
|
||
# With a screen on the stack the ring stays hidden so it can't bury a prompt.
|
||
session.push(ScrollScreen(Text("prompt"), title="p"))
|
||
seen["hidden_over_screen"] = not session._overlay_visible()
|
||
session.pop()
|
||
|
||
await asyncio.wait_for(session.run(main()), timeout=5)
|
||
|
||
assert seen["hidden_during_hold"] is True
|
||
assert seen["active"] is True
|
||
assert seen["visible_empty_stack"] is True
|
||
assert seen["rendered"] is True
|
||
assert seen["hidden_over_screen"] is True
|
||
assert session._overlay is None # cleared on exit
|
||
|
||
|
||
# --- horizontal scroll (opt-in) -------------------------------------------------------
|
||
|
||
|
||
def _hscroll_screen(width_of_rows: int = 60) -> SelectScreen:
|
||
from meshterm.ui.tui.select import Choice, SelectScreen, Separator
|
||
|
||
return SelectScreen(
|
||
"long",
|
||
[
|
||
Separator("HEAD-" + "h" * width_of_rows),
|
||
Choice("row-one-" + "x" * width_of_rows + "-tail", 1),
|
||
Choice("short", 2),
|
||
],
|
||
hscroll=True,
|
||
filterable=True,
|
||
)
|
||
|
||
|
||
def _row_plains(screen, width: int) -> list[str]:
|
||
import re
|
||
|
||
return [re.sub(r"\x1b\[[0-9;]*m", "", ln) for ln in screen.render_body(width)]
|
||
|
||
|
||
def test_select_hscroll_shifts_only_the_highlighted_row() -> None:
|
||
"""→ slides the highlighted row under its pinned pointer; other rows and headers hold."""
|
||
screen = _hscroll_screen() # the long "row-one" is highlighted by default
|
||
before = _row_plains(screen, 40)
|
||
assert any("row-one-" in ln for ln in before)
|
||
screen.handle("right")
|
||
shifted = _row_plains(screen, 40)
|
||
# The highlighted row's head scrolled off, under the still-pinned pointer…
|
||
assert any(ln.startswith("❯ ") for ln in shifted)
|
||
assert not any("row-one-" in ln for ln in shifted)
|
||
# …but the section header did not move, and the short row is untouched.
|
||
assert any(ln.strip().startswith("HEAD-") for ln in shifted)
|
||
assert any("short" in ln for ln in shifted)
|
||
screen.handle("left")
|
||
assert any("row-one-" in ln for ln in _row_plains(screen, 40))
|
||
|
||
|
||
def test_select_hscroll_clamps_at_the_highlighted_rows_tail() -> None:
|
||
"""→ stops once the *highlighted* row's own end is in view, not the widest row's."""
|
||
screen = _hscroll_screen()
|
||
for _ in range(50):
|
||
screen.handle("right")
|
||
plains = _row_plains(screen, 40) # the render clamps the shift
|
||
assert any("-tail" in ln for ln in plains) # the highlighted row's end is visible
|
||
row_len = len("row-one-" + "x" * 60 + "-tail")
|
||
# Clamped to the first whole step that brings the tail inside the lane (the width less
|
||
# the pointer, less the cell a scrolled row spends on its left cut mark) — stopping on
|
||
# the exact flush-right cell would leave a right mark promising a remainder.
|
||
step = screen._HSCROLL_STEP
|
||
assert screen._hshift == -(-(row_len - (40 - 2 - 1)) // step) * step
|
||
|
||
|
||
def test_select_hscroll_resets_when_the_highlight_moves() -> None:
|
||
"""The shift is per-row: moving the highlight (or editing the filter) drops it to the start."""
|
||
screen = _hscroll_screen()
|
||
screen.handle("right")
|
||
assert screen._hshift > 0
|
||
screen.handle("down") # moving to another row abandons that row's scroll
|
||
assert screen._hshift == 0
|
||
screen.handle("right")
|
||
screen.handle("text", "r") # a filter edit resets it too
|
||
assert screen._hshift == 0
|
||
|
||
|
||
def test_select_hscroll_only_acts_on_an_overflowing_row() -> None:
|
||
"""←→ and its footer atom appear only while the highlighted row overflows the width."""
|
||
screen = _hscroll_screen()
|
||
screen.render_body(40) # the long row-one is highlighted and overflows 40 cells
|
||
assert "←→ scroll" in screen.footer_hint
|
||
screen.handle("down") # the short row fits — nothing to scroll
|
||
screen.render_body(40)
|
||
assert "←→ scroll" not in screen.footer_hint
|
||
screen.handle("right")
|
||
screen.render_body(40)
|
||
assert screen._hshift == 0 # a row that fits can't shift
|
||
|
||
|
||
def test_select_hscroll_from_pins_the_rows_head_and_slides_only_its_run() -> None:
|
||
"""A row that declares a head block keeps it drawn while ←→ scroll everything past it."""
|
||
from meshterm.ui.tui.select import Choice, SelectScreen
|
||
|
||
lanes = "#1 Aug 09 "
|
||
screen = SelectScreen(
|
||
"long",
|
||
[Choice(lanes + "run-" + "y" * 60 + "-end", 1, hscroll_from=len(lanes))],
|
||
hscroll=True,
|
||
)
|
||
screen.handle("right")
|
||
row = _row_plains(screen, 40)[0]
|
||
assert row.startswith("❯ " + lanes) # the lanes never move…
|
||
assert "run-" not in row # …while the run behind them has slid off to the left
|
||
for _ in range(50):
|
||
screen.handle("right")
|
||
end = _row_plains(screen, 40)[0]
|
||
assert end.startswith("❯ " + lanes) and "-end" in end # the tail is reachable
|
||
|
||
|
||
def test_select_scrolls_for_a_row_that_pins_a_head_without_being_told() -> None:
|
||
"""A row declaring ``hscroll_from`` turns its list's scrolling on by itself.
|
||
|
||
The builders that lay out label+description rows (:func:`~meshterm.ui.menus.menu_rows`,
|
||
the main menu) never see the screen their list is opened in — ``ctx.ui.select`` builds
|
||
it — so the row is where the intent has to live.
|
||
"""
|
||
from meshterm.ui.tui.select import Choice, SelectScreen
|
||
|
||
lanes = "Send advert "
|
||
row = Choice(lanes + "Announce this node " + "and then some " * 6, 1, hscroll_from=len(lanes))
|
||
screen = SelectScreen("menu", [row]) # no hscroll= anywhere
|
||
screen.handle("right")
|
||
drawn = _row_plains(screen, 40)[0]
|
||
assert drawn.startswith("❯ " + lanes) # the name stays pinned…
|
||
assert "Announce this node" not in drawn # …and the description has slid under it
|
||
# A list of plain rows still ignores ←→ entirely.
|
||
plain = SelectScreen("menu", [Choice("x" * 80, 1)])
|
||
before = _row_plains(plain, 40)[0]
|
||
plain.handle("right")
|
||
assert _row_plains(plain, 40)[0] == before
|
||
|
||
|
||
def test_a_list_that_says_no_hscroll_is_not_overruled_by_its_rows() -> None:
|
||
"""``hscroll=False`` holds against rows that pin a head, through a row swap too.
|
||
|
||
The editor pages end their lanes at the edge; their Actions rows pin heads, and the
|
||
rows' say-so used to turn the whole page's ←→ scrolling on regardless.
|
||
"""
|
||
from meshterm.ui.tui.select import Choice, SelectScreen
|
||
|
||
lanes = "Send advert "
|
||
row = Choice(lanes + "Announce this node " + "and then some " * 6, 1, hscroll_from=len(lanes))
|
||
screen = SelectScreen("editor", [row], hscroll=False)
|
||
before = _row_plains(screen, 40)[0]
|
||
screen.handle("right")
|
||
assert _row_plains(screen, 40)[0] == before
|
||
assert "←→" not in screen.footer_hint
|
||
screen.replace_items([row]) # a refresh must not turn it back on
|
||
screen.handle("right")
|
||
assert _row_plains(screen, 40)[0] == before
|
||
|
||
|
||
def test_menu_rows_pin_their_label_lane_so_only_the_description_slides() -> None:
|
||
"""The shared label+description builder hands each row its own head block."""
|
||
from meshterm.ui.menus import menu_rows
|
||
|
||
rows = menu_rows(
|
||
[("Sync clock…", "Set the device clock", 1), ("Reboot device…", "Restart the companion", 2)]
|
||
)
|
||
lane = rows[0].hscroll_from
|
||
assert lane and all(row.hscroll_from == lane for row in rows)
|
||
# The head block ends exactly where the descriptions start, on every row.
|
||
for row in rows:
|
||
assert row.label.plain[lane:].startswith(("Set the", "Restart the"))
|
||
|
||
|
||
def test_select_hscroll_hint_is_gated_on_the_run_not_the_whole_row() -> None:
|
||
"""A row whose *run* fits earns no ←→ atom, however wide its pinned head makes it.
|
||
|
||
The hint may only advertise a key that would do something (the footer's own rule), and
|
||
on a row that pins a head block the key moves the run alone — so a long lane block in
|
||
front of a short tail is not overflow, it is just a wide row.
|
||
"""
|
||
from meshterm.ui.tui.select import Choice, SelectScreen
|
||
|
||
lanes = "#1 ✓ replied min -6.0 dB 248 ms via "
|
||
fits = SelectScreen(
|
||
"probe", [Choice(lanes + "3d,f2", 1, hscroll_from=len(lanes))], hscroll=True
|
||
)
|
||
fits.render_body(60)
|
||
assert "←→ scroll" not in fits.footer_hint
|
||
|
||
overflows = SelectScreen(
|
||
"probe",
|
||
[Choice(lanes + ",".join(["3d"] * 20), 1, hscroll_from=len(lanes))],
|
||
hscroll=True,
|
||
)
|
||
overflows.render_body(60)
|
||
assert "←→ scroll" in overflows.footer_hint
|
||
|
||
|
||
def test_select_hscroll_marks_both_edges_the_run_continues_past() -> None:
|
||
"""A scrolled row cracks/ellipsizes at whichever side its run runs on."""
|
||
from meshterm.ui.pathline import _ELLIPSIS
|
||
from meshterm.ui.tui.select import Choice, SelectScreen
|
||
|
||
screen = SelectScreen("long", [Choice("z" * 200, 1)], hscroll=True)
|
||
screen.handle("right")
|
||
row = _row_plains(screen, 40)[0]
|
||
# Plain prose has no chip fill to shear, so both marks fall back to the ellipsis.
|
||
assert row.startswith("❯ " + _ELLIPSIS) and row.endswith(_ELLIPSIS)
|
||
|
||
|
||
def test_select_without_hscroll_ignores_left_right() -> None:
|
||
"""The flag defaults off: ←/→ stay inert and rows render exactly as before."""
|
||
from meshterm.ui.tui.select import Choice, SelectScreen
|
||
|
||
screen = SelectScreen("plain", [Choice("row", 1)])
|
||
before = screen.render_body(40)
|
||
screen.handle("right")
|
||
screen.handle("left")
|
||
assert screen.render_body(40) == before and screen._hshift == 0
|
||
|
||
|
||
# --- row detail lines (opt-in) -------------------------------------------------------
|
||
|
||
|
||
def test_select_choice_detail_hangs_under_its_row() -> None:
|
||
"""A Choice.detail draws as a second, indented line right under its title."""
|
||
from meshterm.ui.tui.select import Choice, SelectScreen
|
||
|
||
screen = SelectScreen(
|
||
"pick", [Choice("first row", 1, detail="weakest -6.0 dB · 3×"), Choice("second", 2)]
|
||
)
|
||
lines = _row_plains(screen, 40)
|
||
idx = next(i for i, ln in enumerate(lines) if "first row" in ln)
|
||
assert lines[idx + 1].startswith(" weakest -6.0 dB")
|
||
assert "second" in lines[idx + 2]
|
||
|
||
|
||
def test_select_choice_without_detail_draws_one_line() -> None:
|
||
"""A Choice with no detail (the default, or one resolving empty) stays a single line."""
|
||
from meshterm.ui.tui.select import Choice, SelectScreen
|
||
|
||
screen = SelectScreen("pick", [Choice("first row", 1), Choice("second", 2, detail="")])
|
||
lines = _row_plains(screen, 40)
|
||
assert lines[1].strip() == "second"
|
||
|
||
|
||
def test_select_cursor_tracks_the_highlight_past_a_detail_line() -> None:
|
||
"""The cursor lands on the title line even when an earlier row grew a detail line."""
|
||
from meshterm.ui.tui.select import Choice, SelectScreen
|
||
|
||
screen = SelectScreen(
|
||
"pick",
|
||
[Choice("first row", 1, detail="has detail"), Choice("second", 2)],
|
||
default=2,
|
||
)
|
||
lines = _row_plains(screen, 40)
|
||
assert screen.cursor_line() == next(i for i, ln in enumerate(lines) if "second" in ln)
|
||
|
||
|
||
def test_select_hscroll_leaves_the_detail_line_unshifted() -> None:
|
||
"""←→ slides the highlighted row's title only — its detail line never scrolls."""
|
||
from meshterm.ui.tui.select import Choice, SelectScreen
|
||
|
||
long_title = "row-one-" + "x" * 60 + "-tail"
|
||
screen = SelectScreen("pick", [Choice(long_title, 1, detail="weakest -6.0 dB")], hscroll=True)
|
||
screen.handle("right")
|
||
lines = _row_plains(screen, 40)
|
||
assert "weakest -6.0 dB" in lines[1]
|
||
|
||
|
||
# --- the fast path's frame source ----------------------------------------------------
|
||
|
||
|
||
def _framed_session():
|
||
"""A session with just enough of an app behind it to compose a frame at 53x26."""
|
||
import types
|
||
|
||
from prompt_toolkit.data_structures import Size
|
||
|
||
session = TuiSession()
|
||
session._app = types.SimpleNamespace(
|
||
renderer=types.SimpleNamespace(_last_screen=None),
|
||
output=types.SimpleNamespace(get_size=lambda: Size(rows=26, columns=53)),
|
||
invalidate=lambda: None,
|
||
)
|
||
return session
|
||
|
||
|
||
def test_a_dialog_frame_is_composed_here_not_handed_to_prompt_toolkit() -> None:
|
||
"""A float used to send the whole frame down prompt_toolkit's renderer.
|
||
|
||
That cost its full grid rebuild and diff on every keystroke — in every confirm, picker
|
||
and viewer in the app. The float's placement is reproducible (an unanchored, unsized
|
||
float is centred), so the frame source returns the finished picture with the box merged
|
||
in, and the row diff applies to dialogs like everything else.
|
||
"""
|
||
session = _framed_session()
|
||
session.push(ScrollScreen(Text("the list beneath"), title="Nodes", floating=False))
|
||
session.push(ButtonDialog("Remove this contact?", [("Cancel", 0), ("Remove", 1)]))
|
||
|
||
text = session._plain_frame()
|
||
assert text is not None, "a dialog must not fall back to prompt_toolkit"
|
||
rows = text.split("\n")
|
||
assert len(rows) == 26
|
||
body = "\n".join(rows)
|
||
assert "Remove this contact?" in _plain(body)
|
||
assert "the list beneath" in _plain(body), "the backdrop must show around the box"
|
||
|
||
|
||
def test_a_bare_base_paints_no_chrome_at_all() -> None:
|
||
"""The session draws a bare base through compose_bare: no bars, no lane, no box."""
|
||
screen = ScrollScreen(Text("CODE", justify="center"), title="Share x", floating=False)
|
||
screen.bare = True
|
||
session = _framed_session()
|
||
session.push(screen)
|
||
text = session._plain_frame()
|
||
assert text is not None
|
||
rows = text.split("\n")
|
||
assert len(rows) == 26
|
||
plain = _plain(text)
|
||
assert "CODE" in plain
|
||
for chrome in ("MeshTerm", "Share x", "Esc", "F1", "─", "│"):
|
||
assert chrome not in plain, f"a bare frame drew {chrome!r}"
|
||
|
||
|
||
def test_the_busy_overlay_is_still_prompt_toolkits_to_place() -> None:
|
||
"""The one float we don't place: a content-sized window the float container measures."""
|
||
from meshterm.ui.tui.overlay import BusyOverlay
|
||
|
||
session = _framed_session()
|
||
assert session._plain_frame() is None # an empty stack has no background either
|
||
overlay = BusyOverlay(title="Starting up")
|
||
overlay.started_at -= overlay.hold + overlay.fade # past the hold: it is on screen
|
||
session._overlay = overlay
|
||
assert session._overlay_visible()
|
||
assert session._plain_frame() is None
|
||
|
||
|
||
def test_the_rows_a_dialog_does_not_reach_come_back_unchanged() -> None:
|
||
"""What makes compositing worth doing: the diff still skips the untouched rows."""
|
||
session = _framed_session()
|
||
session.push(
|
||
ScrollScreen(Text("\n".join(f"line {i}" for i in range(40))), title="Nodes", floating=False)
|
||
)
|
||
first = session._plain_frame().split("\n")
|
||
session.push(ButtonDialog("Sure?", [("Cancel", 0), ("Yes", 1)]))
|
||
second = session._plain_frame().split("\n")
|
||
|
||
unchanged = sum(1 for a, b in zip(first, second, strict=True) if a == b)
|
||
assert unchanged >= 10, "the box should only rewrite the rows it covers"
|
||
|
||
|
||
# --- the body is drawn one viewport at a time ----------------------------------------
|
||
|
||
|
||
def test_a_long_list_only_rasterizes_the_rows_the_viewport_shows() -> None:
|
||
"""141 contacts laid out 150 rows tall still only shows twenty of them.
|
||
|
||
Rendering the rest was pure waste on every keystroke *and* on the once-a-second tick.
|
||
The line count has to stay exact, though — the scroll clamp, the ``↑↓ more`` markers and
|
||
the sticky-header offsets are all measured against it.
|
||
"""
|
||
from meshterm.ui.tui.screen import LazyLines
|
||
|
||
drawn: list[int] = []
|
||
|
||
def title(i: int):
|
||
def build():
|
||
drawn.append(i)
|
||
return f"contact {i:03d}"
|
||
|
||
return build
|
||
|
||
screen = SelectScreen("Contacts", [Choice(title(i), i) for i in range(150)])
|
||
lines = screen.render_body(53)
|
||
|
||
assert isinstance(lines, LazyLines)
|
||
assert len(lines) == 150 # the count is exact and cost nothing
|
||
assert drawn == [], "no row is rasterized until something reads it"
|
||
|
||
window = lines[0:20]
|
||
assert len(window) == 20
|
||
assert sorted(drawn) == list(range(20))
|
||
assert "contact 000" in _plain(window[0])
|
||
|
||
lines[5] # a re-read is memoized, never a second render
|
||
assert sorted(drawn) == list(range(20))
|
||
|
||
|
||
def test_a_lazy_body_slices_frames_and_scrolls_exactly_as_a_list_did() -> None:
|
||
"""The substitution has to be invisible to the frame: same height, same clip flags.
|
||
|
||
Deferring a row's *drawing* must never defer how many rows there are — the scroll
|
||
clamp, the ``↑↓ more`` subtitle and the pinned heading are all measured against that
|
||
count, and every one of them would go wrong if the tail were merely unbuilt.
|
||
"""
|
||
items = [section_heading("Group")]
|
||
items += [Choice(f"row {i}", i) for i in range(60)]
|
||
screen = SelectScreen("Long", items, footer_hint="Esc back")
|
||
screen.floating = False
|
||
|
||
out = frame.compose_base(Text("hdr"), screen, "Esc back", 53, 26)
|
||
assert len(out.split("\n")) == 26
|
||
plain = _plain(out)
|
||
assert "row 0" in plain and "↓ more" in plain
|
||
assert screen._scroll_total == 61 # the whole body is still measured
|
||
|
||
for _ in range(45): # walk the highlight past the fold
|
||
screen.handle("down")
|
||
plain = _plain(frame.compose_base(Text("hdr"), screen, "Esc back", 53, 26))
|
||
assert "row 45" in plain, "the highlight must still be kept in view"
|
||
assert "row 0 " not in plain, "the top of the list should have scrolled away"
|
||
assert "── Group ──" in plain, "the section heading pins once it scrolls off"
|
||
|
||
|
||
def test_an_off_screen_rows_live_title_is_left_alone() -> None:
|
||
"""A row's callable title is resolved only while the row is actually on screen."""
|
||
calls: list[str] = []
|
||
items = [
|
||
Choice(lambda: (calls.append("top"), "top row")[1], 0),
|
||
*[Choice(f"filler {i}", i) for i in range(1, 40)],
|
||
Choice(lambda: (calls.append("bottom"), "bottom row")[1], 40),
|
||
]
|
||
screen = SelectScreen("Live", items)
|
||
screen.floating = False
|
||
lines = screen.render_body(53)
|
||
lines[0:10]
|
||
assert calls == ["top"]
|
||
|
||
|
||
def _button_dialog_hints() -> list[tuple[str, int, int | None, str]]:
|
||
"""Every literal ``footer_hint=`` on a button dialog: (file, line, buttons, hint).
|
||
|
||
``buttons`` is the count when the call passes a list literal, else ``None`` — a
|
||
computed row (the quit confirm grows a third button on a bonded device) can hold any
|
||
number, so it is held to the many-button rule.
|
||
"""
|
||
import ast
|
||
from pathlib import Path
|
||
|
||
found: list[tuple[str, int, int | None, str]] = []
|
||
root = Path(__file__).resolve().parent.parent / "meshterm"
|
||
for path in sorted(root.rglob("*.py")):
|
||
tree = ast.parse(path.read_text(encoding="utf-8"))
|
||
for node in ast.walk(tree):
|
||
if not isinstance(node, ast.Call):
|
||
continue
|
||
func = node.func
|
||
name = func.attr if isinstance(func, ast.Attribute) else getattr(func, "id", "")
|
||
if name not in ("button_dialog", "ButtonDialog"):
|
||
continue
|
||
hint = next(
|
||
(
|
||
k.value.value
|
||
for k in node.keywords
|
||
if k.arg == "footer_hint" and isinstance(k.value, ast.Constant)
|
||
),
|
||
None,
|
||
)
|
||
if hint is None:
|
||
continue # the default hint, which is the generic one
|
||
row = node.args[1] if len(node.args) > 1 else None
|
||
count = len(row.elts) if isinstance(row, ast.List) else None
|
||
found.append((path.name, node.lineno, count, hint))
|
||
return found
|
||
|
||
|
||
def test_a_button_dialogs_hint_never_names_the_verb_of_one_button() -> None:
|
||
"""Enter commits *the highlighted* button, so a row of them may only say ``select``.
|
||
|
||
The quit confirm used to promise ``Enter quit · Esc cancel``, which was true only
|
||
while Quit happened to be highlighted — ←→ moves it, and on a bonded device a third
|
||
button sits between the two (JP, 2026-08-31). The same hint also left out the one key
|
||
that changes what Enter will do. A row-picking list may name its committing verb
|
||
(``Enter adopt path``) because every row commits the same way; a button row cannot.
|
||
|
||
A dialog with a single button is the exception the rule is shaped around: a lone
|
||
acknowledgement has nothing to choose between, so it names its verb (``Enter OK``)
|
||
and does not advertise an ←→ that would move nothing.
|
||
"""
|
||
for where, line, buttons, hint in _button_dialog_hints():
|
||
if buttons == 1:
|
||
assert "←→" not in hint, f"{where}:{line} offers ←→ over a single button"
|
||
continue
|
||
assert "Enter select" in hint, (
|
||
f"{where}:{line} promises {hint!r} — Enter commits whichever button is "
|
||
"highlighted, so the atom stays 'Enter select'"
|
||
)
|
||
assert "←→" in hint, (
|
||
f"{where}:{line} hides ←→, the key that moves the highlight Enter commits"
|
||
)
|
||
|
||
|
||
# --- hiding devices on the splash --------------------------------------------
|
||
|
||
|
||
def _hide_store(tmp_path): # noqa: ANN001, ANN202
|
||
"""A device registry on a scratch file."""
|
||
from meshterm.core.device_store import DeviceStore
|
||
|
||
return DeviceStore(tmp_path / "devices.json")
|
||
|
||
|
||
def test_hidden_devices_survive_the_session_that_hid_them(tmp_path) -> None: # noqa: ANN001
|
||
"""Hiding is written to the registry file, so a fresh store still knows about it."""
|
||
store = _hide_store(tmp_path)
|
||
assert store.hidden_ids() == set()
|
||
|
||
store.hide("usb-0001")
|
||
store.hide("usb-0002")
|
||
store.hide("usb-0001") # idempotent
|
||
assert _hide_store(tmp_path).hidden_ids() == {"usb-0001", "usb-0002"}
|
||
|
||
assert _hide_store(tmp_path).show_all() == 2
|
||
assert _hide_store(tmp_path).hidden_ids() == set()
|
||
assert _hide_store(tmp_path).show_all() == 0 # nothing left to bring back
|
||
|
||
|
||
def test_hiding_leaves_the_registry_and_its_default_alone(tmp_path) -> None: # noqa: ANN001
|
||
"""A hidden device is still remembered, still the default, still reachable by name.
|
||
|
||
Hiding is a *listing* choice on one screen. Everything that resolves a device without
|
||
the splash — ``--port``, a profile, the reconnect — must not be able to tell.
|
||
"""
|
||
from meshterm.core.discovery import serial_device
|
||
|
||
store = _hide_store(tmp_path)
|
||
device = serial_device("COM7", name="A Radio")
|
||
store.remember(device, node_name="Wardriver")
|
||
store.hide(device.stable_id)
|
||
|
||
fresh = _hide_store(tmp_path)
|
||
assert fresh.hidden_ids() == {device.stable_id}
|
||
assert fresh.is_known(device)
|
||
remembered = fresh.load()
|
||
assert remembered is not None and remembered.node_name == "Wardriver"
|
||
|
||
|
||
def test_connecting_to_a_hidden_device_shows_it_again(tmp_path) -> None: # noqa: ANN001
|
||
"""Confirming a device is the plainest statement that it belongs on the list."""
|
||
from meshterm.core.discovery import serial_device
|
||
|
||
store = _hide_store(tmp_path)
|
||
device = serial_device("COM7", name="A Radio")
|
||
store.hide(device.stable_id)
|
||
store.remember(device, node_name="Wardriver")
|
||
assert store.hidden_ids() == set()
|
||
|
||
|
||
async def test_the_splash_hides_the_highlighted_device_and_redraws(tmp_path) -> None: # noqa: ANN001
|
||
"""Pressing h drops the row and remembers it; the redrawn list is the feedback."""
|
||
from meshterm.core.discovery import serial_device
|
||
from meshterm.ui.device_picker import prompt_device
|
||
from meshterm.ui.tui import Choice, KeyRequest
|
||
|
||
probe = serial_device("COM3", name="A Debug Probe")
|
||
radio = serial_device("COM7", name="A Radio")
|
||
store = _hide_store(tmp_path)
|
||
drawn: list[list] = []
|
||
|
||
class _Ui:
|
||
"""Presses h on the probe, then picks whatever is left."""
|
||
|
||
def __init__(self) -> None:
|
||
self.round = 0
|
||
|
||
async def select_startup(self, title, items, **kw): # noqa: ANN001, ANN003, ANN201
|
||
drawn.append([it for it in items if isinstance(it, Choice)])
|
||
self.round += 1
|
||
if self.round == 1:
|
||
return KeyRequest(kw["keys"]["h"], probe)
|
||
return radio
|
||
|
||
async def busy_startup(self, message, coro, **kw): # noqa: ANN001, ANN003, ANN201
|
||
return await coro
|
||
|
||
async def verify(device, pin): # noqa: ANN001, ANN202
|
||
return {"name": "Wardriver"}
|
||
|
||
chosen = await prompt_device(_Ui(), [probe, radio], store, verify)
|
||
assert chosen is radio
|
||
assert store.hidden_ids() == {probe.stable_id}
|
||
# The first pass offered both devices; the second offered only the one left.
|
||
assert probe in [row.value for row in drawn[0]]
|
||
assert probe not in [row.value for row in drawn[1]]
|
||
|
||
|
||
def test_the_splash_says_so_when_it_is_empty_only_because_of_hiding() -> None:
|
||
"""Nothing-detected would be a lie, and with no rows there is no footer atom either."""
|
||
from meshterm.core.discovery import serial_device
|
||
from meshterm.ui.device_picker import _build_items
|
||
|
||
lines = [str(it.title) for it in _build_items([], None, {}, hidden=2)]
|
||
assert any("2 devices hidden" in line and "⇧H" in line for line in lines)
|
||
|
||
detected = [str(it.title) for it in _build_items([], None, {}, hidden=0)]
|
||
assert any("no companion devices detected" in line for line in detected)
|
||
|
||
# With something still listed the footer names ⇧H on every row, so the line would be a
|
||
# row of the box spent saying it twice.
|
||
radio = serial_device("COM7", name="A Radio")
|
||
rows = [str(it.title) for it in _build_items([radio], None, {}, hidden=2)]
|
||
assert not any("hidden" in line for line in rows)
|
||
|
||
|
||
def test_the_splash_action_rows_share_one_icon_column() -> None:
|
||
"""Add-a-network-device and Quit start their words in one cell, measured, on both platforms.
|
||
|
||
They used to be literal ``" 🌐 Add…"`` / ``" 🚪 Quit"`` strings that lined up only because
|
||
the two emoji happen to be the same width, and that kept their icons on the PicoCalc, where
|
||
every other command row drops its icon lane.
|
||
"""
|
||
from rich.cells import cell_len
|
||
|
||
from meshterm.platforms import PICOCALC_LYRA, REGULAR, set_platform
|
||
from meshterm.ui.device_picker import _ADD_TCP, _QUIT, _action_rows
|
||
from meshterm.ui.tui import Choice
|
||
|
||
def starts() -> dict:
|
||
rows = {it.value: it.title.plain for it in _action_rows() if isinstance(it, Choice)}
|
||
words = {_ADD_TCP: "Add a network device…", _QUIT: "Quit"}
|
||
return {
|
||
value: cell_len(rows[value][: rows[value].index(word)]) for value, word in words.items()
|
||
}
|
||
|
||
assert len(set(starts().values())) == 1
|
||
set_platform(PICOCALC_LYRA)
|
||
try:
|
||
assert set(starts().values()) == {2}, "the icons go, and no padding stays behind"
|
||
finally:
|
||
set_platform(REGULAR)
|
||
|
||
|
||
def test_hiding_a_device_leaves_the_highlight_where_the_row_was() -> None:
|
||
"""The next device down, or the one above at the end — so a run of adapters clears in a run."""
|
||
from meshterm.core.discovery import serial_device
|
||
from meshterm.ui.device_picker import _after_hiding
|
||
|
||
first, middle, last = (serial_device(f"COM{n}", name=f"D{n}") for n in (1, 2, 3))
|
||
listed = [first, middle, last]
|
||
|
||
assert _after_hiding(listed, first) is middle
|
||
assert _after_hiding(listed, middle) is last
|
||
# Off the end there is nowhere further down, so the highlight steps up rather than
|
||
# rolling round to the top — nothing in the app rolls over.
|
||
assert _after_hiding(listed, last) is middle
|
||
assert _after_hiding([first], first) is None # an emptied list has nothing to land on
|
||
|
||
|
||
def test_the_splash_names_a_shortcut_only_where_it_would_act() -> None:
|
||
"""Named on a device row only, and ⇧H only while hidden — the Del remove rule."""
|
||
from meshterm.core.discovery import serial_device
|
||
from meshterm.ui.device_picker import _ADD_TCP, _QUIT, _shortcut_hint
|
||
|
||
radio = serial_device("COM7", name="A Radio")
|
||
|
||
nothing_hidden = _shortcut_hint(0)
|
||
assert nothing_hidden(radio) == "h hide" # no way back is offered; there is nothing back
|
||
assert nothing_hidden(_ADD_TCP) == "" # nothing to hide on the action rows
|
||
assert nothing_hidden(_QUIT) == ""
|
||
assert nothing_hidden(None) == "" # an empty list highlights nothing at all
|
||
|
||
with_hidden = _shortcut_hint(2)
|
||
assert with_hidden(radio) == "h hide · ⇧H show all"
|
||
# ⇧H is screen-wide, so it rides whatever row the reader happens to be standing on.
|
||
assert with_hidden(_QUIT) == "⇧H show all"
|
||
|
||
|
||
def test_a_hint_too_long_for_its_box_drops_atoms_rather_than_its_tail() -> None:
|
||
"""Cutting the line at the edge would take Esc, which is the one atom that must survive."""
|
||
from meshterm.ui.tui.frame import fit_hint
|
||
|
||
full = "↑↓ move · ←→ scroll · Enter select · h hide · ⇧H show all · Esc quit"
|
||
assert fit_hint(full, 100) == full # room for everything: untouched
|
||
|
||
# Dropped from the right, in front of Esc — never Esc itself.
|
||
assert fit_hint(full, 56) == "↑↓ move · ←→ scroll · Enter select · h hide · Esc quit"
|
||
assert fit_hint(full, 45) == "↑↓ move · ←→ scroll · Enter select · Esc quit"
|
||
assert fit_hint(full, 20).endswith("Esc quit")
|
||
|
||
# ...unless the caller names atoms it can spare first, in the order it can spare them:
|
||
# the scroll keys are already named by the move atom, and between the two hide keys the
|
||
# one to keep is the way back.
|
||
spared = fit_hint(full, 56, shed_first=("←→ scroll", "h hide"))
|
||
assert spared == "↑↓ move · Enter select · h hide · ⇧H show all · Esc quit"
|
||
assert (
|
||
fit_hint(full, 50, shed_first=("←→ scroll", "h hide"))
|
||
== "↑↓ move · Enter select · ⇧H show all · Esc quit"
|
||
)
|
||
|
||
|
||
def test_the_splash_scrolls_the_whole_row_it_is_on() -> None:
|
||
"""←→ move the entire row, lanes included, rather than sliding the tail under them.
|
||
|
||
The Trophy case pins its rank/date/score lanes because those always fit and only the
|
||
walk overflows, so they are the reader's place in a long list. This list inverts that:
|
||
on a narrow terminal it is the device name and the address that get cut, so pinning
|
||
them would pin the truncation in place and the one thing ←→ could not reach would be
|
||
the text somebody most wants to finish reading.
|
||
|
||
Scrolling has to be asked for outright here, because it is a row pinning a head block
|
||
that normally turns it on and no row does now.
|
||
"""
|
||
from meshterm.core.device_store import RememberedDevice
|
||
from meshterm.core.discovery import serial_device
|
||
from meshterm.platforms import PICOCALC_LYRA, REGULAR, set_platform
|
||
from meshterm.ui.device_picker import _build_items
|
||
from meshterm.ui.tui import Choice, SelectScreen
|
||
|
||
radio = serial_device("COM7", name="Wardriver")
|
||
registry = {
|
||
radio.stable_id: RememberedDevice(
|
||
stable_id=radio.stable_id,
|
||
port="COM7",
|
||
label="COM7",
|
||
last_connected="2026-09-01T00:00:00+00:00",
|
||
node_name="Wardriver",
|
||
hardware_model="Seeed Wio Tracker 1110 Development Kit",
|
||
)
|
||
}
|
||
try:
|
||
for platform, width in ((REGULAR, 66), (PICOCALC_LYRA, 47)):
|
||
set_platform(platform)
|
||
items = _build_items([radio], registry[radio.stable_id], registry)
|
||
screen = SelectScreen(
|
||
"Select a companion device", items, filterable=False, hscroll=True
|
||
)
|
||
row = next(it for it in items if isinstance(it, Choice))
|
||
assert not row.hscroll_from, "no lane is pinned on the splash any more"
|
||
|
||
before = _plain(screen.render_body(width)[1])
|
||
for _ in range(3):
|
||
screen.handle("right")
|
||
after = _plain(screen.render_body(width)[1])
|
||
assert before != after, platform.name
|
||
# The head moved with everything else: what was at the left edge is gone.
|
||
assert not after.startswith(before[:8]), f"the lanes stayed put on {platform.name}"
|
||
# And the name is what scrolled out of view, which is the point -- it is the
|
||
# part a narrow terminal cuts, so it has to be reachable.
|
||
assert "Wardriver" in before
|
||
finally:
|
||
set_platform(REGULAR)
|
||
|
||
|
||
def test_the_splash_hint_stays_inside_the_box_it_is_drawn_in() -> None:
|
||
"""The chromeless splash keeps its hint in its own border, on both platforms.
|
||
|
||
The border is the terminal less the gutter the box floats over — not the full readable
|
||
width — and on the PicoCalc that is 47 cells, which is what makes every atom here
|
||
conditional rather than merely tidy.
|
||
"""
|
||
from rich.cells import cell_len
|
||
|
||
from meshterm.core.discovery import serial_device
|
||
from meshterm.platforms import PICOCALC_LYRA, REGULAR
|
||
from meshterm.ui.device_picker import _shortcut_hint
|
||
from meshterm.ui.tui.select import splice_hint
|
||
|
||
base = "↑↓ move · Enter select · Esc bye" # the splash's own send-off
|
||
radio = serial_device("COM7", name="A Radio")
|
||
common = splice_hint(base, _shortcut_hint(0)(radio))
|
||
for platform in (REGULAR, PICOCALC_LYRA):
|
||
budget = platform.readable_cols - platform.dialog_margin - 2
|
||
assert cell_len(common) <= budget, (platform.name, common)
|
||
|
||
|
||
def test_a_shortcut_is_only_honoured_where_a_letter_is_free() -> None:
|
||
"""A filtering list spends its letters on the query, so it declares no shortcuts.
|
||
|
||
Otherwise a device named "Homestead" could not be typed on a list that had claimed h.
|
||
"""
|
||
from meshterm.ui.tui import Choice, KeyRequest
|
||
from meshterm.ui.tui.select import SelectScreen
|
||
|
||
token = object()
|
||
fixed = SelectScreen("fixed", [Choice("a row", 1)], filterable=False, keys={"h": token})
|
||
fixed.future = None
|
||
resolved: list = []
|
||
fixed.resolve = lambda value: resolved.append(value) # type: ignore[method-assign]
|
||
fixed.handle("text", "h")
|
||
assert resolved == [KeyRequest(token, 1)]
|
||
|
||
filtering = SelectScreen("filtering", [Choice("Homestead", 1)], keys={"h": token})
|
||
filtering.resolve = lambda value: resolved.append(value) # type: ignore[method-assign]
|
||
filtering.handle("text", "h")
|
||
assert len(resolved) == 1 # nothing new resolved
|
||
assert filtering._filter == "h" # the letter went to the query, where it belongs
|
||
|
||
|
||
# -- the splash keeps looking ---------------------------------------------------------
|
||
|
||
|
||
def _drive_picker(tmp_path, monkeypatch, *, serial_rounds, ble_rounds=None, hide=None):
|
||
"""Run the picker's rescan against a scripted sequence of scans.
|
||
|
||
The fake UI stands in for the splash being open: it is handed the ``live`` callable
|
||
and runs it until the script is exhausted, recording every redraw. Discovery is
|
||
replaced rather than mocked at the port level, because what is under test is the
|
||
rescan's behaviour -- when it redraws, when it keeps quiet -- and not pyserial.
|
||
"""
|
||
import asyncio
|
||
|
||
from meshterm.core.device_store import DeviceStore
|
||
from meshterm.ui import device_picker
|
||
|
||
monkeypatch.setattr(device_picker, "_POLL_S", 0.001)
|
||
monkeypatch.setattr(device_picker, "_BLE_EVERY_S", 0.0025)
|
||
monkeypatch.setattr(device_picker, "_BLE_WINDOW_S", 0.0)
|
||
|
||
serial = list(serial_rounds)
|
||
ble = list(ble_rounds or [[]])
|
||
calls = {"serial": 0, "ble": 0}
|
||
|
||
def _serial():
|
||
calls["serial"] += 1
|
||
return list(serial[min(calls["serial"] - 1, len(serial) - 1)])
|
||
|
||
async def _ble(_timeout):
|
||
calls["ble"] += 1
|
||
return list(ble[min(calls["ble"] - 1, len(ble) - 1)])
|
||
|
||
monkeypatch.setattr(device_picker, "discover_devices", _serial)
|
||
monkeypatch.setattr(device_picker, "discover_ble_devices", _ble)
|
||
|
||
store = DeviceStore(tmp_path / "devices.json")
|
||
for stable in hide or []:
|
||
store.hide(stable)
|
||
|
||
redraws: list = []
|
||
# Scans to let run: every scripted round, then enough more that the last round's redraw
|
||
# has happened (a round redraws before the next scan starts) and the Bluetooth duty
|
||
# cycle — one listen per few polls — has had room to show.
|
||
scans = len(serial) + 5
|
||
|
||
class _Ui:
|
||
async def select_startup( # noqa: ANN001, ANN201, ANN003
|
||
self, title, items, *, default=None, banner=None, footnote=None, live=None, **_kw
|
||
):
|
||
task = asyncio.ensure_future(live(redraws.append))
|
||
# Wait for the scans themselves, never for a span of time: a fixed 50 ms held a
|
||
# handful of rounds on a quiet machine and sometimes only one under a full run,
|
||
# since Windows rounds each 1 ms sleep up to its ~15 ms timer tick.
|
||
loop = asyncio.get_running_loop()
|
||
deadline = loop.time() + 10.0
|
||
while calls["serial"] < scans and loop.time() < deadline:
|
||
await asyncio.sleep(0.001)
|
||
task.cancel()
|
||
try:
|
||
await task
|
||
except asyncio.CancelledError:
|
||
pass
|
||
return None
|
||
|
||
async def _never(_device):
|
||
raise AssertionError("verify should not run when selection is skipped")
|
||
|
||
asyncio.run(device_picker.prompt_device(_Ui(), list(serial[0]), store, _never))
|
||
assert calls["serial"] >= scans, f"the rescan ran {calls['serial']} of {scans} scans in 10 s"
|
||
return redraws, calls
|
||
|
||
|
||
def _labels(items) -> list[str]:
|
||
return [
|
||
(it.title.plain if hasattr(it.title, "plain") else str(it.title))
|
||
for it in items
|
||
if isinstance(it, Choice)
|
||
]
|
||
|
||
|
||
def test_splash_redraws_when_a_device_is_plugged_in(tmp_path, monkeypatch) -> None:
|
||
"""A radio attached after the splash opened appears without restarting MeshTerm."""
|
||
from meshterm.core.discovery import DiscoveredDevice
|
||
|
||
first = DiscoveredDevice(port="COM5", product="Wio SX1262", vid=0x2886)
|
||
later = DiscoveredDevice(port="COM9", product="RAK4631", vid=0x239A)
|
||
|
||
redraws, _ = _drive_picker(tmp_path, monkeypatch, serial_rounds=[[first], [first, later]])
|
||
|
||
assert redraws, "the splash never redrew after the device appeared"
|
||
assert any("COM9" in label for label in _labels(redraws[-1]))
|
||
|
||
|
||
def test_splash_stays_quiet_when_nothing_changed(tmp_path, monkeypatch) -> None:
|
||
"""A poll that finds the same ports redraws nothing.
|
||
|
||
This is the half that matters for somebody mid-way through arrowing down the list:
|
||
a redraw they did not ask for, on a list that has not changed, is the screen moving
|
||
under their hands for no reason.
|
||
"""
|
||
from meshterm.core.discovery import DiscoveredDevice
|
||
|
||
same = [DiscoveredDevice(port="COM5", product="Wio SX1262", vid=0x2886)]
|
||
redraws, calls = _drive_picker(tmp_path, monkeypatch, serial_rounds=[same])
|
||
|
||
assert calls["serial"] > 1, "the rescan never ran"
|
||
assert redraws == []
|
||
|
||
|
||
def test_splash_notices_a_device_going_away(tmp_path, monkeypatch) -> None:
|
||
"""Unplugging removes the row, which is the same question asked backwards."""
|
||
from meshterm.core.discovery import DiscoveredDevice
|
||
|
||
one = DiscoveredDevice(port="COM5", product="Wio SX1262", vid=0x2886)
|
||
redraws, _ = _drive_picker(tmp_path, monkeypatch, serial_rounds=[[one], []])
|
||
|
||
assert redraws, "the splash never redrew after the device went away"
|
||
assert not any("COM5" in label for label in _labels(redraws[-1]))
|
||
|
||
|
||
def test_a_rescan_does_not_unhide_what_the_reader_hid(tmp_path, monkeypatch) -> None:
|
||
"""A refresh is not ⇧H. Whatever `h` hid stays hidden when the list is rebuilt."""
|
||
from meshterm.core.discovery import DiscoveredDevice
|
||
|
||
kept = DiscoveredDevice(port="COM5", product="Wio SX1262", vid=0x2886)
|
||
buried = DiscoveredDevice(port="COM7", product="FT232R USB UART", vid=0x0403)
|
||
later = DiscoveredDevice(port="COM9", product="RAK4631", vid=0x239A)
|
||
|
||
redraws, _ = _drive_picker(
|
||
tmp_path,
|
||
monkeypatch,
|
||
serial_rounds=[[kept, buried], [kept, buried, later]],
|
||
hide=[buried.stable_id],
|
||
)
|
||
|
||
assert redraws, "the splash never redrew"
|
||
labels = _labels(redraws[-1])
|
||
assert any("COM9" in label for label in labels)
|
||
assert not any("COM7" in label for label in labels)
|
||
|
||
|
||
def test_bluetooth_is_not_scanned_on_every_poll(tmp_path, monkeypatch) -> None:
|
||
"""Serial is cheap and polled; Bluetooth is a listen and gets a duty cycle.
|
||
|
||
Each BLE scan keeps the radio listening, and this is the screen somebody leaves open
|
||
on a battery-powered handheld while they go and find a cable -- so it must not be in
|
||
the poll loop.
|
||
"""
|
||
from meshterm.core.discovery import DiscoveredDevice
|
||
|
||
same = [DiscoveredDevice(port="COM5", product="Wio SX1262", vid=0x2886)]
|
||
_, calls = _drive_picker(tmp_path, monkeypatch, serial_rounds=[same])
|
||
|
||
assert calls["serial"] > calls["ble"], (
|
||
f"bluetooth scanned {calls['ble']} time(s) against {calls['serial']} serial poll(s)"
|
||
)
|
||
|
||
|
||
def test_the_picker_only_passes_arguments_the_splash_surface_accepts(tmp_path) -> None:
|
||
"""Every keyword `prompt_device` sends must exist on `Ui.select_startup`.
|
||
|
||
This is the shape of bug a test double hides. `prompt_device` calls the *surface*,
|
||
which delegates to the session; adding an argument to the session alone crashes the
|
||
startup path and nothing else, because every fake Ui in this file absorbs unknown
|
||
keywords with `**kwargs` and is perfectly happy. So the fake here binds what it is
|
||
given against the real signature, and a keyword the surface has never heard of raises
|
||
exactly where a running MeshTerm would.
|
||
"""
|
||
import inspect
|
||
|
||
from meshterm.core.device_store import DeviceStore
|
||
from meshterm.ui.device_picker import prompt_device
|
||
from meshterm.ui.surface import Ui
|
||
|
||
signature = inspect.signature(Ui.select_startup)
|
||
|
||
class _Ui:
|
||
async def select_startup(self, title, items, **kw): # noqa: ANN001, ANN003, ANN201
|
||
signature.bind(self, title, items, **kw) # TypeError on an unknown keyword
|
||
return None
|
||
|
||
async def _never(_device):
|
||
raise AssertionError("verify should not run when selection is skipped")
|
||
|
||
store = DeviceStore(tmp_path / "devices.json")
|
||
asyncio.run(prompt_device(_Ui(), [], store, _never))
|