Files
MeshTerm/tests/test_surface.py
T
Jean-Pierre MartineauandClaude Fable 5 3a972ea743 Short outcomes pop as OK dialogs instead of filling the result frame
A one-line result like "✓ flood advertisement sent" used to open the
full scrollable result window — a huge frame for a single sentence.
TuiUi.present() now collapses small, text-only output (at most 3 lines,
none wider than 76 cells) into a centered popup with an OK button,
floating over whatever screen is beneath; tables, long errors, and
multi-line output keep the window. Fixed centrally in present(), the
upgrade covers every such site at once: adverts, clock sync, backups,
key import, "applied N changes", tool failures, and future ones.

ButtonDialog learns to render a pre-styled (multi-line) Text prompt so
note markup survives into the popup, and the border echoes the message's
strongest tone (err > warn > accent). When the popup would sit on an
empty stack (a tool run from the popped main menu), a blank base frame
is pushed beneath it first — the same trick as the reconnect dialog —
so it draws as a floating box, not as the base.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-10 01:32:58 -04:00

146 lines
5.8 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)
# -- 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"