Files
weekly-activity/tests/test_wizard.py
kosmos daf0a203a0 refactor(wizard): replace hand-rolled arrow-key menu with questionary
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.
2026-08-27 08:36:56 +00:00

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()