`ruff format .` across the repo: 153 files, no behaviour change. This is the second gate CONTRIBUTING.md promises, and until now it reported 154 files needing reformat — so a first contributor ran the command the guide told them to run and could not tell their own diff from the background. Landed on its own, with nothing else in it, because a whole-repo reformat is only safe to skip past in blame if it is provably nothing but layout. It was checked that way rather than assumed: every changed file was formatted in memory and compared by full `ast.dump()`, and 152 of 153 come out structurally identical (the one exception was a docstring opening on a quote character, fixed in the commit before this one). The string and comment token streams were diffed separately, so a quote flip or an edited comment could not hide inside a re-layout — five quote-style normalisations and sixteen implicit-concatenation joins, all value-identical, and one commented-out line gaining the space after its `#`. `meshterm specimen` is byte-identical on both platforms, and the suite is 1888 green, gallery gate and `test_theme16` included. What actually changed, in order of how much of the diff it is: wrapped calls and signatures rejoin onto one line where they fit inside the hundred columns; the magic trailing comma expands what it is there to expand; complex slices gain their spaces (`[len(noise) :]`); a few redundant parens go. The tables that are laid out to be read were fenced in the previous commit and are untouched here. The aligned trailing-comment column is gone from about ninety lanes that were not fenced — including the `heat.*` ladder in `MESH_THEME`, which cannot be fenced where it sits. Every comment stays on the entry it describes, so nothing there reads wrong; it just no longer lines up. Add this commit to `.git-blame-ignore-revs`, which follows immediately. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
224 lines
9.1 KiB
Python
224 lines
9.1 KiB
Python
"""Tests for the TUI output surface's window-or-popup presentation.
|
|
|
|
:meth:`TuiUi.present` upgrades a short, text-only result ("✓ flood advertisement sent")
|
|
to a centered OK dialog instead of a full scrollable window. These cover the collapse
|
|
gate (:func:`_collapse_to_message`), the routing between the two presentations, and the
|
|
severity-to-border mapping the message dialog applies (:func:`_message_border`).
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import asyncio
|
|
from typing import Any
|
|
|
|
from rich.table import Table
|
|
from rich.text import Text
|
|
|
|
from meshterm.ui.surface import _DIALOG_MAX_CELLS, TuiUi, _collapse_to_message
|
|
from meshterm.ui.tui.session import TuiSession, _message_border
|
|
|
|
# -- the collapse gate ------------------------------------------------------------
|
|
|
|
|
|
def test_collapse_accepts_a_short_note_and_keeps_its_styling() -> None:
|
|
"""A one-line success note qualifies, with its markup spans intact."""
|
|
note = Text.from_markup("[ok]✓[/ok] flood advertisement sent")
|
|
message = _collapse_to_message([note])
|
|
assert message is not None
|
|
assert message.plain == "✓ flood advertisement sent"
|
|
assert any(str(span.style) == "ok" for span in message.spans)
|
|
|
|
|
|
def test_collapse_joins_a_few_notes_into_one_message() -> None:
|
|
"""Two or three separate notes collapse into one multi-line dialog message."""
|
|
notes = [Text("✓ device clock set"), Text("● wrote backup.toml")]
|
|
message = _collapse_to_message(notes)
|
|
assert message is not None
|
|
assert message.plain == "✓ device clock set\n● wrote backup.toml"
|
|
|
|
|
|
def test_collapse_rejects_tall_output() -> None:
|
|
"""More lines than fit a tidy popup fall back to the result window."""
|
|
assert _collapse_to_message([Text(f"line {i}") for i in range(4)]) is None
|
|
|
|
|
|
def test_collapse_rejects_wide_lines() -> None:
|
|
"""A line too wide for the popup (a long path, say) falls back to the window."""
|
|
assert _collapse_to_message([Text("x" * (_DIALOG_MAX_CELLS + 1))]) is None
|
|
|
|
|
|
def test_collapse_rejects_non_text_renderables() -> None:
|
|
"""Any table/panel in the buffer sends the whole result to the window."""
|
|
assert _collapse_to_message([Text("note"), Table()]) is None
|
|
assert _collapse_to_message([]) is None
|
|
|
|
|
|
# -- present() routing --------------------------------------------------------------
|
|
|
|
|
|
class _RecordingSession:
|
|
"""Stands in for :class:`TuiSession`, recording which presentation was used."""
|
|
|
|
def __init__(self) -> None:
|
|
self.dialogs: list[tuple[Text, str]] = []
|
|
self.scrolls: list[tuple[Any, str]] = []
|
|
|
|
async def message_dialog(self, message: Text, *, title: str = "") -> None:
|
|
self.dialogs.append((message, title))
|
|
|
|
async def scroll(self, body: Any, *, title: str = "", footer_hint: str = "") -> None:
|
|
self.scrolls.append((body, title))
|
|
|
|
|
|
async def test_present_floats_a_short_note_as_a_dialog() -> None:
|
|
"""A single outcome note pops as an OK dialog, not a full result window."""
|
|
session = _RecordingSession()
|
|
ui = TuiUi(session) # type: ignore[arg-type]
|
|
ui.note("[ok]✓[/ok] zero-hop advertisement sent")
|
|
await ui.present(title="Advert")
|
|
assert session.scrolls == []
|
|
((message, title),) = session.dialogs
|
|
assert message.plain == "✓ zero-hop advertisement sent"
|
|
assert title == "Advert"
|
|
|
|
|
|
async def test_present_keeps_big_output_in_the_result_window() -> None:
|
|
"""Tables (and any oversized output) still open the scrollable window."""
|
|
session = _RecordingSession()
|
|
ui = TuiUi(session) # type: ignore[arg-type]
|
|
ui.note("heading")
|
|
ui.show(Table(title="nodes"))
|
|
await ui.present(title="Nodes")
|
|
assert session.dialogs == []
|
|
assert [title for _, title in session.scrolls] == ["Nodes"]
|
|
|
|
|
|
async def test_present_clears_the_buffer_either_way() -> None:
|
|
"""After presenting, a second present has nothing to show (no double popup)."""
|
|
session = _RecordingSession()
|
|
ui = TuiUi(session) # type: ignore[arg-type]
|
|
ui.note("✓ done")
|
|
await ui.present(title="Once")
|
|
await ui.present(title="Twice")
|
|
assert len(session.dialogs) == 1 and session.scrolls == []
|
|
|
|
|
|
# -- the popup end-to-end through a real session ------------------------------------
|
|
|
|
|
|
async def test_message_dialog_floats_over_a_blank_backdrop_end_to_end() -> None:
|
|
"""On an empty stack the popup pushes a blank base beneath itself, then pops both.
|
|
|
|
This is the main-menu tool path: the menu is popped while a tool runs, so without
|
|
the backdrop the lone dialog would be drawn as the base (full-frame). Driving a
|
|
real session with piped keys proves Enter (OK) dismisses it and the stack unwinds.
|
|
"""
|
|
from prompt_toolkit.input.defaults import create_pipe_input
|
|
from prompt_toolkit.output import DummyOutput
|
|
|
|
with create_pipe_input() as inp:
|
|
session = TuiSession(input=inp, output=DummyOutput())
|
|
|
|
async def main() -> None:
|
|
inp.send_text("\r") # Enter commits the OK button
|
|
await session.message_dialog(
|
|
Text.from_markup("[ok]✓[/ok] flood advertisement sent"), title="Advert"
|
|
)
|
|
assert session._stack == [] # dialog and its backdrop both popped
|
|
|
|
await asyncio.wait_for(session.run(main()), timeout=5)
|
|
|
|
|
|
async def test_confirm_floats_over_an_existing_popup_end_to_end() -> None:
|
|
"""A confirm floats over an existing popup, end to end.
|
|
|
|
Opened over a floating detail popup, it renders and resolves: the render loop runs
|
|
the multi-layer float pool for real (base + detail + confirm), which is the case
|
|
the old single-float compositor stretched the detail to full-frame.
|
|
"""
|
|
from prompt_toolkit.input.defaults import create_pipe_input
|
|
from prompt_toolkit.output import DummyOutput
|
|
|
|
from meshterm.ui.tui.screen import ScrollScreen
|
|
from meshterm.ui.tui.select import Choice, SelectScreen
|
|
|
|
with create_pipe_input() as inp:
|
|
session = TuiSession(input=inp, output=DummyOutput())
|
|
|
|
async def main() -> None:
|
|
base = ScrollScreen(Text("channels"), title="Channels") # full-frame background
|
|
detail = SelectScreen("Ops", [Choice("Clear", "clr")]) # a floating popup
|
|
session.push(base)
|
|
session.push(detail)
|
|
# Two layers float over the one background while the confirm is up.
|
|
assert session._base_screen() is base
|
|
inp.send_text("\r") # Enter commits the confirm's default (Delete)
|
|
confirmed = await session.button_dialog(
|
|
"Clear Ops?", [("Cancel", 0), ("Clear", 1)], default=1, border_style="err"
|
|
)
|
|
assert confirmed == 1
|
|
assert session._float_layers() == [detail] # confirm gone, detail still afloat
|
|
session.pop(detail)
|
|
session.pop(base)
|
|
|
|
await asyncio.wait_for(session.run(main()), timeout=5)
|
|
|
|
|
|
# -- the message dialog's border tone -----------------------------------------------
|
|
|
|
|
|
def test_message_border_echoes_the_strongest_tone() -> None:
|
|
"""Err outranks warn; warn outranks the neutral accent; ok stays neutral."""
|
|
err = Text.from_markup("[err]✗ Trace failed:[/err] timeout")
|
|
warn = Text.from_markup("[warn]device rebooting[/warn]")
|
|
ok = Text.from_markup("[ok]✓[/ok] private key imported")
|
|
both = Text.from_markup("[warn]careful[/warn] [err]broken[/err]")
|
|
assert _message_border(err) == "err"
|
|
assert _message_border(warn) == "warn"
|
|
assert _message_border(ok) == "accent"
|
|
assert _message_border(both) == "err"
|
|
assert _message_border("plain string") == "accent"
|
|
|
|
|
|
# -- the button dialog's caution tiers ----------------------------------------------
|
|
|
|
|
|
class _ButtonRecordingSession:
|
|
"""Records the styling kwargs :meth:`TuiUi.dialog` hands the button dialog."""
|
|
|
|
def __init__(self) -> None:
|
|
self.kwargs: dict[str, Any] = {}
|
|
|
|
async def button_dialog(self, prompt: str, buttons: list, **kwargs: Any) -> Any:
|
|
self.kwargs = kwargs
|
|
return buttons[-1][1]
|
|
|
|
|
|
async def _dialog_styles(**kw: Any) -> dict[str, Any]:
|
|
session = _ButtonRecordingSession()
|
|
ui = TuiUi(session) # type: ignore[arg-type]
|
|
await ui.dialog("Delete this record?", [("Cancel", False), ("Delete", True)], **kw)
|
|
return session.kwargs
|
|
|
|
|
|
async def test_dialog_destructive_tier_borders_red() -> None:
|
|
"""A data-loss dialog draws in the reserved error red — prompt and border."""
|
|
styles = await _dialog_styles(destructive=True)
|
|
assert styles["border_style"] == "err"
|
|
assert styles["prompt_style"] == "err"
|
|
|
|
|
|
async def test_dialog_danger_tier_stays_amber() -> None:
|
|
"""A merely-disruptive dialog keeps the amber caution tone, never the deletion red."""
|
|
styles = await _dialog_styles(danger=True)
|
|
assert styles["border_style"] == "warn"
|
|
assert styles["prompt_style"] == "warn"
|
|
|
|
|
|
async def test_dialog_plain_is_neutral_and_destructive_outranks_danger() -> None:
|
|
"""No flag is the neutral accent frame; destructive wins over danger when both are set."""
|
|
plain = await _dialog_styles()
|
|
assert plain["border_style"] == "accent" and plain["prompt_style"] == ""
|
|
both = await _dialog_styles(danger=True, destructive=True)
|
|
assert both["border_style"] == "err" and both["prompt_style"] == "err"
|