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>
146 lines
5.8 KiB
Python
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"
|