feat(bot_cli): implement interactive TUI menu and add subparser entry

This commit is contained in:
yiekheng 2026-05-02 17:00:40 +08:00
parent f472a94916
commit 7011c6bada
2 changed files with 87 additions and 1 deletions

View File

@ -8,8 +8,43 @@ import sys
from .cm_bot_hal import CM_BOT_HAL 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): 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 <whatsapp_link> Set security PIN")
print(" 3 <f_username> <t_username> Insert into user table")
print(" credit <username> <password> Read account credit")
print(" transfer <fu> <fp> <tu> <tp> 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: def _print_user(user: dict) -> None:
@ -118,6 +153,9 @@ def build_parser() -> argparse.ArgumentParser:
sp.add_argument("--target", type=int, default=20) sp.add_argument("--target", type=int, default=20)
sp.set_defaults(func=cmd_monitor_once) 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 return p

View File

@ -241,5 +241,53 @@ class CmdMonitorOnceTests(unittest.TestCase):
self.assertEqual(args.target, 20) 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__": if __name__ == "__main__":
unittest.main() unittest.main()