The reviewer asked for a known-good interactive-menu library instead of the custom termios/select key reader; arrow keys had failed in their terminal. The TTY path now delegates to questionary.select (pointer ">", bold highlight via pointer/selected styles), keeping choice values mapped to list indices. Cancellable menus graft an eager Escape binding onto the prompt's own key-binding registry so Esc backs out while Ctrl-C still aborts fixed menus and backs out of cancellable ones. The non-TTY numbered fallback is untouched, and hand-rolled internals (_arrow_menu/_read_keypress/_option_line/_draw_menu_rows) are gone.
308 lines
11 KiB
Python
308 lines
11 KiB
Python
"""Unit tests for the wizard menus — scripted input, no raw terminal involved."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import io
|
|
import os
|
|
import pty
|
|
import re
|
|
import select
|
|
import subprocess
|
|
import sys
|
|
import time
|
|
import types
|
|
import unittest
|
|
from pathlib import Path
|
|
from typing import Any, cast
|
|
from unittest import mock
|
|
|
|
from weekly_activity.wizard import _CANCELLED, _menu
|
|
|
|
|
|
class NumberedMenuFallbackTest(unittest.TestCase):
|
|
"""When stdin/stdout are not a TTY the numbered prompt stays as it was."""
|
|
|
|
def run_numbered(self, answers: list[str], options: list[str], *, cancellable: bool = False):
|
|
"""Run _menu with forced non-TTY stdio and scripted interactive answers."""
|
|
fake_stdin, fake_stdout = io.StringIO(), io.StringIO()
|
|
with (
|
|
mock.patch.object(sys, "stdin", fake_stdin),
|
|
mock.patch.object(sys, "stdout", fake_stdout),
|
|
mock.patch("builtins.input", side_effect=iter(answers)),
|
|
):
|
|
picked = _menu("Pick one", options, cancellable=cancellable)
|
|
return picked, fake_stdout.getvalue()
|
|
|
|
def test_returns_zero_based_index_of_chosen_option(self) -> None:
|
|
picked, output = self.run_numbered(["2"], ["Alpha", "Beta", "Gamma"])
|
|
self.assertEqual(picked, 1)
|
|
self.assertIn("Pick one", output)
|
|
self.assertIn(" 2) Beta", output)
|
|
|
|
def test_empty_answer_backs_out_of_cancellable_menu(self) -> None:
|
|
picked, _ = self.run_numbered([""], ["Alpha", "Beta"], cancellable=True)
|
|
self.assertIsNone(picked)
|
|
|
|
def test_back_number_returns_none(self) -> None:
|
|
picked, _ = self.run_numbered(["3"], ["Alpha", "Beta"], cancellable=True)
|
|
self.assertIsNone(picked)
|
|
|
|
def test_invalid_answers_loop_until_valid_one(self) -> None:
|
|
picked, output = self.run_numbered(["9", "nope", "3"], ["Alpha", "Beta", "Gamma"])
|
|
self.assertEqual(picked, 2)
|
|
self.assertEqual(output.count("Please enter a number between 1 and 3."), 2)
|
|
|
|
|
|
# --- Interactive (TTY) path -----------------------------------------------
|
|
|
|
|
|
class _Tty(io.StringIO):
|
|
"""A stream claiming to be a terminal so _menu routes to questionary."""
|
|
|
|
def isatty(self) -> bool:
|
|
return True
|
|
|
|
|
|
class _FakeKeyBindings:
|
|
"""Registry mimicking prompt_toolkit's enough to capture added handlers."""
|
|
|
|
def __init__(self) -> None:
|
|
self.added: list[tuple[tuple[Any, ...], Any]] = []
|
|
|
|
def add(self, *keys: Any, eager: bool = False):
|
|
def register(handler):
|
|
self.added.append((keys, handler))
|
|
return handler
|
|
|
|
return register
|
|
|
|
|
|
class _FakeApplication:
|
|
def __init__(self) -> None:
|
|
self.key_bindings = _FakeKeyBindings()
|
|
self.exit_calls: list[dict[str, Any]] = []
|
|
|
|
def exit(self, result: Any = None, exception: BaseException | None = None, style=None) -> None:
|
|
self.exit_calls.append({"result": result, "exception": exception})
|
|
|
|
|
|
class _FakeQuestion:
|
|
def __init__(self, outcome: Any) -> None:
|
|
self.application = _FakeApplication()
|
|
self.outcome = outcome
|
|
|
|
def unsafe_ask(self) -> Any:
|
|
if isinstance(self.outcome, BaseException):
|
|
raise self.outcome
|
|
return self.outcome
|
|
|
|
|
|
class _FakeQuestionary:
|
|
"""Stand-in exposing exactly the surface wizard._tty_menu touches."""
|
|
|
|
def __init__(self, outcome: Any) -> None:
|
|
self.outcome = outcome
|
|
self.select_outcome = _FakeQuestion(outcome)
|
|
self.seen: dict[str, Any] = {}
|
|
|
|
def Choice(self, **kwargs: Any) -> Any:
|
|
return types.SimpleNamespace(**kwargs)
|
|
|
|
@staticmethod
|
|
def Style(styles: list[tuple[str, str]]) -> list[tuple[str, str]]:
|
|
return styles
|
|
|
|
def select(self, message: str, choices: list[Any], **kwargs: Any) -> _FakeQuestion:
|
|
self.seen.update(message=message, choices=list(choices), **kwargs)
|
|
return self.select_outcome
|
|
|
|
|
|
class QuestionaryDelegationTest(unittest.TestCase):
|
|
"""On terminals _menu must hand selection to questionary, not re-implement it."""
|
|
|
|
def ask_tty(
|
|
self,
|
|
options: list[str],
|
|
outcome: Any,
|
|
*,
|
|
cancellable: bool,
|
|
) -> tuple[int | None, _FakeQuestion, dict[str, Any]]:
|
|
"""Run the TTY branch against an injected fake questionary module."""
|
|
fake = _FakeQuestionary(outcome)
|
|
|
|
with (
|
|
mock.patch.object(sys, "stdin", _Tty()),
|
|
mock.patch.object(sys, "stdout", _Tty()),
|
|
mock.patch.dict(sys.modules, {"questionary": cast("Any", fake)}),
|
|
):
|
|
picked = _menu("Pick one", options, cancellable=cancellable)
|
|
return picked, fake.select_outcome, fake.seen
|
|
|
|
def test_non_cancellable_menu_delegates_and_maps_choice_value_to_index(self) -> None:
|
|
picked, question, seen = self.ask_tty(
|
|
["Alpha", "Beta", "Gamma"],
|
|
outcome=2,
|
|
cancellable=False,
|
|
)
|
|
self.assertEqual(picked, 2)
|
|
self.assertEqual(seen["message"], "Pick one")
|
|
self.assertEqual(seen["pointer"], ">")
|
|
self.assertEqual(
|
|
[(choice.title, choice.value) for choice in seen["choices"]],
|
|
[("Alpha", 0), ("Beta", 1), ("Gamma", 2)],
|
|
)
|
|
# A fixed menu grafts nothing onto questionary's own bindings.
|
|
self.assertEqual(question.application.key_bindings.added, [])
|
|
|
|
def test_cancellable_menu_keeps_fixed_menus_behaviour_on_ctrl_c(self) -> None:
|
|
with self.assertRaises(KeyboardInterrupt):
|
|
self.ask_tty(["Alpha", "Beta"], outcome=KeyboardInterrupt(), cancellable=False)
|
|
|
|
def test_cancellable_menu_maps_ctrl_c_to_none(self) -> None:
|
|
picked, _, _ = self.ask_tty(
|
|
["Alpha", "Beta"],
|
|
outcome=KeyboardInterrupt(),
|
|
cancellable=True,
|
|
)
|
|
self.assertIsNone(picked)
|
|
|
|
def test_cancellable_menu_grafts_escape_onto_the_prompt_bindings(self) -> None:
|
|
picked, question, _ = self.ask_tty(
|
|
["Alpha", "Beta"],
|
|
outcome=_CANCELLED,
|
|
cancellable=True,
|
|
)
|
|
self.assertIsNone(picked) # a non-int answer means "backed out"
|
|
keys, handler = question.application.key_bindings.added[-1]
|
|
self.assertIn("escape", keys)
|
|
handler(types.SimpleNamespace(app=question.application))
|
|
self.assertIs(question.application.exit_calls[0]["result"], _CANCELLED)
|
|
|
|
|
|
# --- Real-terminal proof over a pty ----------------------------------------
|
|
|
|
|
|
_MENU_CHILD = """
|
|
import os
|
|
import sys
|
|
|
|
sys.path.insert(0, os.environ["WIZARD_SRC"])
|
|
from weekly_activity.wizard import _tty_menu # noqa: E402
|
|
|
|
picked = _tty_menu("Pick one", ["Alpha", "Beta", "Gamma"], cancellable=True)
|
|
print(f"PICKED={picked}", flush=True)
|
|
"""
|
|
|
|
_PICKED_LINE = re.compile(rb"PICKED=(\d+|None)")
|
|
|
|
|
|
class ArrowMenuPtyTest(unittest.TestCase):
|
|
"""Drive the genuine questionary menu across a real pty with raw keypresses.
|
|
|
|
This is the regression case the reviewer hit: arrow presses must move the
|
|
highlight of a live menu, which scripted ``io.StringIO`` stdin cannot reach.
|
|
"""
|
|
|
|
TIMEOUT_S = 30.0
|
|
|
|
@classmethod
|
|
def setUpClass(cls) -> None:
|
|
cls.child_env = {
|
|
**os.environ,
|
|
"WIZARD_SRC": str(Path(__file__).resolve().parents[1] / "src"),
|
|
}
|
|
|
|
def drive_menu(self, sends: list[bytes]) -> tuple[str, int]:
|
|
"""Render the menu on a pty, send keystrokes, return screen + exit code."""
|
|
master, slave = pty.openpty()
|
|
child = subprocess.Popen(
|
|
[sys.executable, "-c", _MENU_CHILD],
|
|
stdin=slave,
|
|
stdout=slave,
|
|
stderr=subprocess.PIPE,
|
|
env=self.child_env,
|
|
)
|
|
os.close(slave)
|
|
screen = bytearray()
|
|
|
|
try:
|
|
self._await(screen, master, child, lambda: b"Gamma" in screen, "menu render")
|
|
for payload in sends:
|
|
os.write(master, payload)
|
|
time.sleep(0.2) # let the vt100 parser see keystrokes separately
|
|
self._await(
|
|
screen,
|
|
master,
|
|
child,
|
|
lambda: _PICKED_LINE.search(bytes(screen)) is not None,
|
|
"final PICKED line",
|
|
)
|
|
child.wait(timeout=self.TIMEOUT_S)
|
|
err = child.stderr.read().decode("utf-8", "replace") if child.stderr else ""
|
|
self.assertEqual(child.returncode, 0, f"menu child failed: {err}")
|
|
return bytes(screen).decode("utf-8", "replace"), child.returncode
|
|
finally:
|
|
if child.poll() is None:
|
|
child.kill()
|
|
child.wait()
|
|
if child.stderr is not None:
|
|
child.stderr.close()
|
|
os.close(master)
|
|
|
|
def _await(
|
|
self,
|
|
screen: bytearray,
|
|
master: int,
|
|
child: subprocess.Popen[bytes],
|
|
condition,
|
|
stage: str,
|
|
) -> None:
|
|
"""Read the pty until ``condition`` holds on accumulated output."""
|
|
deadline = time.monotonic() + self.TIMEOUT_S
|
|
while not condition():
|
|
remaining = deadline - time.monotonic()
|
|
if remaining <= 0:
|
|
break
|
|
readable, _, _ = select.select([master], [], [], min(remaining, 0.2))
|
|
if readable:
|
|
try:
|
|
chunk = os.read(master, 4096)
|
|
except OSError: # slave closed: the child exited early
|
|
break
|
|
if chunk:
|
|
screen.extend(chunk)
|
|
else:
|
|
return
|
|
if child.poll() is None:
|
|
child.kill()
|
|
child.wait(timeout=5)
|
|
err = child.stderr.read().decode("utf-8", "replace") if child.stderr else ""
|
|
self.fail(f"{stage}: stalled; tail={bytes(screen)[-300:]!r} stderr={err!r}")
|
|
|
|
def _picked(self, screen: str) -> str:
|
|
found = _PICKED_LINE.search(screen.encode())
|
|
if found is None:
|
|
self.fail(f"no PICKED line in {screen[-300:]!r}")
|
|
return found.group(1).decode()
|
|
|
|
def test_down_arrow_moves_highlight_and_enter_accepts(self) -> None:
|
|
# Start on "Alpha"; one Down must land on "Beta" — the reported bug.
|
|
screen, _ = self.drive_menu([b"\x1b[B", b"\r"])
|
|
self.assertEqual(self._picked(screen), "1")
|
|
|
|
def test_repeated_down_arrows_reach_third_option(self) -> None:
|
|
screen, _ = self.drive_menu([b"\x1b[B", b"\x1b[B", b"\r"])
|
|
self.assertEqual(self._picked(screen), "2")
|
|
|
|
def test_enter_without_movement_accepts_first_option(self) -> None:
|
|
screen, _ = self.drive_menu([b"\r"])
|
|
self.assertEqual(self._picked(screen), "0")
|
|
|
|
def test_escape_backs_out_of_a_cancellable_menu(self) -> None:
|
|
screen, _ = self.drive_menu([b"\x1b"])
|
|
self.assertEqual(self._picked(screen), "None")
|
|
|
|
|
|
if __name__ == "__main__":
|
|
unittest.main()
|