From 7011c6badabd56690a61e4c2b8abc811102b10f5 Mon Sep 17 00:00:00 2001 From: yiekheng Date: Sat, 2 May 2026 17:00:40 +0800 Subject: [PATCH] feat(bot_cli): implement interactive TUI menu and add subparser entry --- app/bot_cli.py | 40 +++++++++++++++++++++++++++++++++++- tests/test_bot_cli.py | 48 +++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 87 insertions(+), 1 deletion(-) diff --git a/app/bot_cli.py b/app/bot_cli.py index 5c9ee01..c99c450 100644 --- a/app/bot_cli.py +++ b/app/bot_cli.py @@ -8,8 +8,43 @@ import sys from .cm_bot_hal import CM_BOT_HAL +# Map TUI shortcuts to argparse subcommand names so the REPL reuses the +# same dispatch table as one-shot invocations. +_TUI_ALIASES = {"1": "register", "2": "set-pin", "3": "insert-user"} + + def cmd_interactive(_args): - raise NotImplementedError("cmd_interactive is implemented in a later task") + """Telegram-style menu in a TTY loop. stdlib only.""" + print("CM Bot CLI — interactive (type 'q' to quit, '?' for menu)") + while True: + print() + print(" 1 Register / get next account") + print(" 2 Set security PIN") + print(" 3 Insert into user table") + print(" credit Read account credit") + print(" transfer One-shot credit transfer") + print(" monitor [N] Run monitor once (default 20)") + print(" q Quit") + try: + line = input("> ").strip() + except (EOFError, KeyboardInterrupt): + print() + return + if not line: + continue + if line in ("q", "quit", "exit"): + return + if line in ("?", "help", "menu"): + continue + argv = line.split() + argv[0] = _TUI_ALIASES.get(argv[0], argv[0]) + try: + args = build_parser().parse_args(argv) + args.func(args) + except SystemExit: + continue + except Exception as exc: + print(f"ERROR: {exc}", file=sys.stderr) def _print_user(user: dict) -> None: @@ -118,6 +153,9 @@ def build_parser() -> argparse.ArgumentParser: sp.add_argument("--target", type=int, default=20) sp.set_defaults(func=cmd_monitor_once) + sp = sub.add_parser("interactive", help="Drop into the TUI menu.") + sp.set_defaults(func=cmd_interactive) + return p diff --git a/tests/test_bot_cli.py b/tests/test_bot_cli.py index 8e56770..da99b73 100644 --- a/tests/test_bot_cli.py +++ b/tests/test_bot_cli.py @@ -241,5 +241,53 @@ class CmdMonitorOnceTests(unittest.TestCase): self.assertEqual(args.target, 20) +class CmdInteractiveTests(unittest.TestCase): + @mock.patch("builtins.input", side_effect=["q"]) + @mock.patch.object(bot_cli, "CM_BOT_HAL") + def test_q_exits_cleanly(self, mock_hal_class, mock_input): + out = io.StringIO() + with contextlib.redirect_stdout(out): + bot_cli.cmd_interactive(argparse.Namespace()) + self.assertIn("CM Bot CLI", out.getvalue()) + + @mock.patch("builtins.input", side_effect=["", "q"]) + @mock.patch.object(bot_cli, "CM_BOT_HAL") + def test_blank_line_continues_loop(self, mock_hal_class, mock_input): + out = io.StringIO() + with contextlib.redirect_stdout(out): + bot_cli.cmd_interactive(argparse.Namespace()) + self.assertGreaterEqual(out.getvalue().count("Register / get next account"), 2) + + @mock.patch("builtins.input", side_effect=EOFError) + @mock.patch.object(bot_cli, "CM_BOT_HAL") + def test_eof_exits_cleanly(self, mock_hal_class, mock_input): + out = io.StringIO() + with contextlib.redirect_stdout(out): + bot_cli.cmd_interactive(argparse.Namespace()) + self.assertIn("CM Bot CLI", out.getvalue()) + + @mock.patch("builtins.input", side_effect=["1", "q"]) + @mock.patch.object(bot_cli, "CM_BOT_HAL") + def test_alias_1_dispatches_to_register(self, mock_hal_class, mock_input): + mock_hal = mock_hal_class.return_value + mock_hal.get_user_api.return_value = { + "username": "u", "password": "p", "link": "l", + } + out = io.StringIO() + with contextlib.redirect_stdout(out): + bot_cli.cmd_interactive(argparse.Namespace()) + self.assertIn("Username: u", out.getvalue()) + mock_hal.get_user_api.assert_called_once_with() + + @mock.patch("builtins.input", side_effect=["nonsense", "q"]) + @mock.patch.object(bot_cli, "CM_BOT_HAL") + def test_unknown_subcommand_keeps_loop_alive(self, mock_hal_class, mock_input): + out = io.StringIO() + err = io.StringIO() + with contextlib.redirect_stdout(out), contextlib.redirect_stderr(err): + bot_cli.cmd_interactive(argparse.Namespace()) + self.assertIn("CM Bot CLI", out.getvalue()) + + if __name__ == "__main__": unittest.main()