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