Files
MeshTerm/tests/test_surface.py
T
Jean-Pierre MartineauandClaude Opus 5 92d31a3191 Every file is spelled the way the formatter spells it
`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>
2026-09-07 01:07:19 -04:00

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"