hermes-agent/tests/cli/test_cli_force_redraw.py

185 lines
6.6 KiB
Python

"""Tests for CLI redraw helpers used to recover from terminal buffer drift.
Covers:
- _force_full_redraw (#8688 cmux tab switch, /redraw, Ctrl+L)
- the resize handler we install over prompt_toolkit's _on_resize (#5474)
Both behaviors are exercised against fake prompt_toolkit renderer/output
objects — we're asserting the escape sequences the CLI sends, not that
the terminal physically repainted.
"""
from unittest.mock import MagicMock
import pytest
import cli as cli_mod
from cli import HermesCLI
@pytest.fixture
def bare_cli():
"""A HermesCLI with no __init__ — we only exercise the redraw helper."""
cli = object.__new__(HermesCLI)
return cli
class TestForceFullRedraw:
def test_no_app_is_safe(self, bare_cli):
# _force_full_redraw must be a no-op when the TUI isn't running.
bare_cli._app = None
bare_cli._force_full_redraw() # must not raise
def test_missing_app_attr_is_safe(self, bare_cli):
# Simulate HermesCLI before the TUI has ever been constructed.
bare_cli._force_full_redraw() # must not raise
def test_sends_full_clear_replays_then_invalidates(self, bare_cli, monkeypatch):
app = MagicMock()
out = app.renderer.output
bare_cli._app = app
events = []
out.reset_attributes.side_effect = lambda: events.append("reset_attrs")
out.erase_screen.side_effect = lambda: events.append("erase")
out.cursor_goto.side_effect = lambda *_: events.append("home")
out.flush.side_effect = lambda: events.append("flush")
app.renderer.reset.side_effect = lambda **_: events.append("renderer_reset")
monkeypatch.setattr(cli_mod, "_replay_output_history", lambda: events.append("replay"))
app.invalidate.side_effect = lambda: events.append("invalidate")
bare_cli._force_full_redraw()
# Must erase screen, home cursor, and flush — in that order.
out.reset_attributes.assert_called_once()
out.erase_screen.assert_called_once()
out.cursor_goto.assert_called_once_with(0, 0)
out.flush.assert_called_once()
# Must reset prompt_toolkit's tracked screen/cursor state so the
# next incremental redraw starts from a clean (0, 0) origin.
app.renderer.reset.assert_called_once_with(leave_alternate_screen=False)
# Must schedule a repaint.
app.invalidate.assert_called_once()
assert events == [
"reset_attrs",
"erase",
"home",
"flush",
"renderer_reset",
"replay",
"invalidate",
]
def test_resize_rebuilds_scrollback_before_prompt_toolkit_redraw(self, bare_cli, monkeypatch):
app = MagicMock()
out = app.renderer.output
events = []
out.reset_attributes.side_effect = lambda: events.append("reset_attrs")
out.erase_screen.side_effect = lambda: events.append("erase")
out.write_raw.side_effect = lambda text: events.append(("raw", text))
out.cursor_goto.side_effect = lambda *_: events.append("home")
out.flush.side_effect = lambda: events.append("flush")
app.renderer.reset.side_effect = lambda **_: events.append("renderer_reset")
monkeypatch.setattr(cli_mod, "_replay_output_history", lambda: events.append("replay"))
original_on_resize = lambda: events.append("original_resize")
bare_cli._recover_after_resize(app, original_on_resize)
assert events == [
"reset_attrs",
"erase",
("raw", "\x1b[3J"),
"home",
"flush",
"renderer_reset",
"replay",
"original_resize",
]
app.invalidate.assert_not_called()
def test_force_redraw_uses_full_screen_clear_without_scrollback_clear(self, bare_cli):
app = MagicMock()
bare_cli._app = app
bare_cli._force_full_redraw()
app.renderer.output.erase_screen.assert_called_once()
app.renderer.output.cursor_goto.assert_called_once_with(0, 0)
app.renderer.output.write_raw.assert_not_called()
def test_resize_recovery_is_debounced(self, bare_cli, monkeypatch):
timers = []
calls = []
class FakeTimer:
def __init__(self, delay, callback):
self.delay = delay
self.callback = callback
self.cancelled = False
self.daemon = False
timers.append(self)
def start(self):
calls.append(("start", self.delay))
def cancel(self):
self.cancelled = True
calls.append(("cancel", self.delay))
def fire(self):
self.callback()
app = MagicMock()
app.loop.call_soon_threadsafe.side_effect = lambda cb: cb()
monkeypatch.setattr(cli_mod.threading, "Timer", FakeTimer)
monkeypatch.setattr(
bare_cli,
"_recover_after_resize",
lambda _app, _orig: calls.append(("recover", _orig())),
)
original_one = lambda: "first"
original_two = lambda: "second"
bare_cli._schedule_resize_recovery(app, original_one, delay=0.25)
assert bare_cli._resize_recovery_pending is True
bare_cli._schedule_resize_recovery(app, original_two, delay=0.25)
assert len(timers) == 2
assert timers[0].cancelled is True
timers[0].fire()
assert ("recover", "first") not in calls
timers[1].fire()
assert ("recover", "second") in calls
assert bare_cli._resize_recovery_pending is False
def test_invalidate_is_suppressed_while_resize_recovery_is_pending(self, bare_cli):
app = MagicMock()
bare_cli._app = app
bare_cli._last_invalidate = 0.0
bare_cli._resize_recovery_pending = True
bare_cli._invalidate(min_interval=0)
app.invalidate.assert_not_called()
def test_swallows_renderer_exceptions(self, bare_cli):
# If the renderer blows up for any reason, the helper must not
# propagate — otherwise a stray Ctrl+L would crash the CLI.
app = MagicMock()
app.renderer.output.erase_screen.side_effect = RuntimeError("boom")
bare_cli._app = app
bare_cli._force_full_redraw() # must not raise
# invalidate() is still attempted after a renderer failure.
app.invalidate.assert_called_once()
def test_swallows_invalidate_exceptions(self, bare_cli):
app = MagicMock()
app.invalidate.side_effect = RuntimeError("boom")
bare_cli._app = app
bare_cli._force_full_redraw() # must not raise