diff --git a/lib/devbase/tui/actions_plugin.py b/lib/devbase/tui/actions_plugin.py new file mode 100644 index 0000000..5d83541 --- /dev/null +++ b/lib/devbase/tui/actions_plugin.py @@ -0,0 +1,344 @@ +"""plugin カテゴリの TUI 操作フロー (PLAN31_2 PR4)。 + +``devbase plugin`` の全サブコマンド (list/install/uninstall/update/info/sync/migrate) +と ``plugin repo`` のサブ階層 (add/remove/list/refresh) を TUI から実行できるようにする。 +引数は ``tui.menu`` の収集ヘルパで CLI parser と同じ属性値 (plan 2.3 契約表) を集め、 +``tui.dispatch.dispatch_group`` 経由で既存ハンドラ ``cmd_plugin`` へ委譲する +(ロジック二重実装なし)。 + +uninstall/update/info および repo remove/refresh の ``name`` は、registry +(``plugins.yml``) から取得した導入済み plugin / 登録済みリポジトリの一覧から +選択させる (自由入力によるタイプミスを防ぐ)。破壊的な uninstall / repo remove は +``menu.confirm`` で実行前確認する (plan 3.4)。 + +ナビ規約 (actions_project と同一): +- Esc / ← = 1 つ前のメニューへ戻る (``menu.MENU_BACK``) +- Ctrl-C = 全体中止 (``None`` を伝搬) +- 引数収集の中止 (``_ARG_CANCEL``) = 直前のサブメニューを再表示 +""" + +from __future__ import annotations + +from pathlib import Path + +from devbase.errors import DevbaseError +from devbase.log import get_logger +from devbase.tui import menu +from devbase.tui.dispatch import dispatch_group + +logger = get_logger(__name__) + +# plugin サブコマンド (表示順 = ハイライト既定順)。閲覧系の list を先頭に置き、 +# Enter 連打で安全な一覧表示へ到達できるようにする。value は cmd_plugin の subcommand 名 +# (repo のみサブ階層メニューへの分岐)。 +_PLUGIN_OPS: list[tuple[str, str]] = [ + ("一覧表示 (list)", "list"), + ("インストール (install)", "install"), + ("アンインストール (uninstall)", "uninstall"), + ("更新 (update)", "update"), + ("詳細表示 (info)", "info"), + ("プロジェクトリンク再同期 (sync)", "sync"), + ("レガシー構成の移行 (migrate)", "migrate"), + ("リポジトリ管理 (repo)", "repo"), +] + +# plugin repo サブ階層 (表示順 = ハイライト既定順)。value は repo_command 名。 +_REPO_OPS: list[tuple[str, str]] = [ + ("リポジトリ一覧 (list)", "list"), + ("リポジトリ登録 (add)", "add"), + ("リポジトリ削除 (remove)", "remove"), + ("リポジトリ更新 (refresh)", "refresh"), +] + +# 引数収集を Esc/Ctrl-C で中止したことを示す番兵 (= サブメニューへ戻る)。 +# dispatch の rc (int) や ``None`` (= 全体中止) と区別する (actions_project と同じ)。 +_ARG_CANCEL = object() + + +def _dispatch(devbase_root: Path, subcommand: str, **attrs) -> int: + """``cmd_plugin`` へ委譲する (plan 2.3 の属性契約は呼び出し側が守る)。 + + import を呼び出し時まで遅延させ、テストが ``commands.plugin.cmd_plugin`` を + monkeypatch で差し替えられるようにする (dispatch_lifecycle と同じ流儀)。 + """ + from devbase.commands.plugin import cmd_plugin + + return dispatch_group(cmd_plugin, devbase_root, subcommand, **attrs) + + +# --------------------------------------------------------------------------- +# 名前選択 (registry から一覧を取得して選ばせる) +# --------------------------------------------------------------------------- + +def _installed_plugin_names(devbase_root: Path) -> list[str]: + """導入済み plugin 名の一覧を registry (plugins.yml) から取得する。""" + from devbase.plugin.registry import PluginRegistry + + try: + return [p.name for p in PluginRegistry(Path(devbase_root)).list_installed()] + except DevbaseError as e: + logger.error("%s", e) + return [] + + +def _repository_names(devbase_root: Path) -> list[str]: + """登録済みリポジトリ名の一覧を registry (plugins.yml) から取得する。""" + from devbase.plugin.registry import PluginRegistry + + try: + return [r.name for r in PluginRegistry(Path(devbase_root)).list_repositories()] + except DevbaseError as e: + logger.error("%s", e) + return [] + + +def _select_name(message: str, names: list[str], *, all_label: str | None = None): + """名前一覧から 1 件選ばせる共通ヘルパ。 + + ``all_label`` 指定時は「全対象」(value="") を先頭に置く。選択メニューの ``None`` + (Ctrl-C) と衝突させないため空文字を番兵にし、呼び出し側へは ``None`` に変換して + 返す (_select_build_image と同じ流儀)。 + + 戻り値: 名前 (``str``) / ``None`` (all_label 選択 = 全対象) / ``_ARG_CANCEL`` + (Esc・←・Ctrl-C 中止)。 + """ + choices: list[tuple[str, str]] = [] + if all_label is not None: + choices.append((all_label, "")) + choices += [(n, n) for n in names] + sel = menu.select( + f"{message} (↑↓ 移動 / Enter 決定 / ←・Esc 戻る / Ctrl-C 中止):", + choices, back=True, search=False) + if sel is menu.MENU_BACK or sel is None: + return _ARG_CANCEL + return sel or None # "" → None (全対象) + + +def _select_installed_plugin(devbase_root: Path, message: str, *, + all_label: str | None = None): + """導入済み plugin から 1 件選ばせる。対象が無ければ案内して ``_ARG_CANCEL``。""" + names = _installed_plugin_names(devbase_root) + if not names: + logger.info("導入済みの plugin がありません。" + "`plugin install` で導入してください。") + return _ARG_CANCEL + return _select_name(message, names, all_label=all_label) + + +def _select_repository(devbase_root: Path, message: str, *, + all_label: str | None = None): + """登録済みリポジトリから 1 件選ばせる。対象が無ければ案内して ``_ARG_CANCEL``。""" + names = _repository_names(devbase_root) + if not names: + logger.info("登録済みのリポジトリがありません。" + "`plugin repo add` で登録してください。") + return _ARG_CANCEL + return _select_name(message, names, all_label=all_label) + + +# --------------------------------------------------------------------------- +# サブメニュー +# --------------------------------------------------------------------------- + +def _select_operation(): + """plugin 操作を選ぶサブメニュー。 + + 戻り値: サブコマンド文字列 / ``MENU_BACK`` (Esc・← → トップへ戻る) / ``None`` + (Ctrl-C 中止)。 + """ + return menu.select( + "plugin 操作を選択 (↑↓ 移動 / Enter 決定 / ←・Esc 戻る / Ctrl-C 中止):", + list(_PLUGIN_OPS), back=True, search=False) + + +def _select_repo_operation(): + """plugin repo 操作を選ぶサブ階層メニュー。 + + 戻り値: repo_command 文字列 / ``MENU_BACK`` (Esc・← → plugin メニューへ戻る) / + ``None`` (Ctrl-C 中止)。 + """ + return menu.select( + "リポジトリ操作を選択 (↑↓ 移動 / Enter 決定 / ←・Esc 戻る / Ctrl-C 中止):", + list(_REPO_OPS), back=True, search=False) + + +# --------------------------------------------------------------------------- +# 各操作の引数収集 + dispatch (plan 2.3 契約) +# --------------------------------------------------------------------------- + +def _run_operation(devbase_root: Path, op: str): + """選択された plugin 操作の引数を収集して ``cmd_plugin`` へ委譲する。 + + 戻り値: dispatch の rc (``int``) / ``_ARG_CANCEL`` (引数収集を中止 = + サブメニューへ戻る)。破壊的な uninstall は ``menu.confirm`` で確認する (plan 3.4)。 + """ + if op == "list": + # --available: 導入済み一覧の代わりに未導入の利用可能 plugin を表示する。 + available = menu.confirm( + "未導入の利用可能 plugin を表示しますか (--available)?", default=False) + if available is None: + return _ARG_CANCEL + return _dispatch(devbase_root, "list", available=available) + + if op == "install": + source = menu.text( + "インストールする plugin の source (名前 / URL / パス)", + allow_empty=False) + if source is None: + return _ARG_CANCEL + link = menu.confirm( + "symlink としてインストールしますか (--link)?", default=False) + if link is None: + return _ARG_CANCEL + install_all = menu.confirm( + "リポジトリ内の全 plugin をインストールしますか (--all)?", default=False) + if install_all is None: + return _ARG_CANCEL + return _dispatch(devbase_root, "install", + source=source, link=link, install_all=install_all) + + if op == "uninstall": + name = _select_installed_plugin( + devbase_root, "アンインストールする plugin を選択") + if name is _ARG_CANCEL: + return _ARG_CANCEL + ok = menu.confirm(f"plugin '{name}' をアンインストールしますか?", default=False) + if not ok: # False (拒否) / None (中止) → 実行しない + return _ARG_CANCEL + return _dispatch(devbase_root, "uninstall", name=name) + + if op == "update": + # name=None で全 plugin 更新 (CLI の `plugin update` 引数省略と同じ)。 + name = _select_installed_plugin( + devbase_root, "更新する plugin を選択", + all_label="全 plugin を更新") + if name is _ARG_CANCEL: + return _ARG_CANCEL + return _dispatch(devbase_root, "update", name=name) + + if op == "info": + name = _select_installed_plugin( + devbase_root, "詳細を表示する plugin を選択") + if name is _ARG_CANCEL: + return _ARG_CANCEL + return _dispatch(devbase_root, "info", name=name) + + if op in ("sync", "migrate"): + # 引数なし (plan 2.3: sync/migrate は属性なし)。即実行。 + return _dispatch(devbase_root, op) + + # 到達しない (メニュー値は _PLUGIN_OPS に限定される)。保守的に no-op。 + logger.error("未知の操作です: %s", op) + return _ARG_CANCEL + + +def _run_repo_operation(devbase_root: Path, op: str): + """選択された plugin repo 操作の引数を収集して ``cmd_plugin`` へ委譲する。 + + repo 系は ``subcommand='repo'`` + ``repo_command=`` の二段属性で + ``cmd_repo`` へ分岐する (plan 2.3 契約)。戻り値プロトコルは ``_run_operation`` + と同じ。破壊的な remove は ``menu.confirm`` で確認する (plan 3.4)。 + """ + if op == "list": + return _dispatch(devbase_root, "repo", repo_command="list") + + if op == "add": + url = menu.text( + "登録するリポジトリの URL (GitHub は owner/repo 短縮形も可)", + allow_empty=False) + if url is None: + return _ARG_CANCEL + # --name は任意 (空で URL から自動命名)。空文字は None へ変換して渡す。 + name = menu.text("カスタム名 (--name 空で自動)", allow_empty=True) + if name is None: + return _ARG_CANCEL + return _dispatch(devbase_root, "repo", + repo_command="add", url=url, name=name or None) + + if op == "remove": + name = _select_repository(devbase_root, "削除するリポジトリを選択") + if name is _ARG_CANCEL: + return _ARG_CANCEL + ok = menu.confirm(f"リポジトリ '{name}' を削除しますか?", default=False) + if not ok: # False (拒否) / None (中止) → 実行しない + return _ARG_CANCEL + force = menu.confirm( + "未 commit / 未 push の変更があっても強制削除しますか (--force)?", + default=False) + if force is None: + return _ARG_CANCEL + return _dispatch(devbase_root, "repo", + repo_command="remove", name=name, force=force) + + if op == "refresh": + # name=None で全リポジトリを refresh (CLI の引数省略と同じ)。 + name = _select_repository( + devbase_root, "更新するリポジトリを選択", + all_label="全リポジトリを更新") + if name is _ARG_CANCEL: + return _ARG_CANCEL + return _dispatch(devbase_root, "repo", repo_command="refresh", name=name) + + # 到達しない (メニュー値は _REPO_OPS に限定される)。保守的に no-op。 + logger.error("未知のリポジトリ操作です: %s", op) + return _ARG_CANCEL + + +# --------------------------------------------------------------------------- +# メニューループ +# --------------------------------------------------------------------------- + +def _repo_menu(devbase_root: Path): + """plugin repo のサブ階層メニューを回す。 + + 戻り値プロトコル (``is`` 同一性判定): + - dispatch の rc (``int``): 操作を実行 → 呼び出し元へ (最終的にトップへ復帰)。 + - ``menu.MENU_BACK``: Esc/← で plugin メニューへ戻る。 + - ``None``: Ctrl-C で全体中止。 + + 引数収集を中止 (``_ARG_CANCEL``) した場合はサブ階層メニューを再表示する。 + """ + while True: + op = _select_repo_operation() + if op is menu.MENU_BACK: + return menu.MENU_BACK + if op is None: + return None + rc = _run_repo_operation(devbase_root, op) + if rc is _ARG_CANCEL: + continue # 引数収集を中止 → サブ階層メニューへ戻る + return rc # 実行 rc → 呼び出し元へ + + +def run(devbase_root: Path): + """プラグイン操作カテゴリ。操作選択 → 引数収集 → ``cmd_plugin`` へ委譲。 + + 戻り値プロトコル (トップループが ``is`` 同一性で判定する。actions_project と同じ): + - **操作を実行した場合**: dispatch の rc (``int``) を返す。失敗 (非0) は + ``devbase list`` の終了コードへ伝搬する。 + - ``menu.MENU_BACK``: 操作なしでトップメニューへ戻る (Esc/←)。 + - ``None``: Ctrl-C による全体中止。 + + repo はサブ階層メニュー (``_repo_menu``) へ分岐し、Esc/← で plugin メニューへ + 戻れる。引数収集を中止した場合は plugin メニューを再表示する。 + 操作完了後はトップメニューへ復帰する (plan 3.5 状態遷移: Exec → Top)。 + """ + while True: + op = _select_operation() + if op is menu.MENU_BACK: + return menu.MENU_BACK + if op is None: + return None + + if op == "repo": + rc = _repo_menu(devbase_root) + if rc is menu.MENU_BACK: + continue # plugin メニューへ戻る + return rc # 実行 rc (int) / None (Ctrl-C) を伝搬 + rc = _run_operation(devbase_root, op) + if rc is _ARG_CANCEL: + continue # 引数収集を中止 → plugin メニューへ戻る + + # 操作完了 → トップメニューへ復帰。rc は呼び出し側 (top loop) が記憶し + # 最終的な devbase の終了コードへ伝搬させる。 + return rc diff --git a/lib/devbase/tui/app.py b/lib/devbase/tui/app.py index 9a62158..b0103dd 100644 --- a/lib/devbase/tui/app.py +++ b/lib/devbase/tui/app.py @@ -4,8 +4,8 @@ プロジェクト一覧の選択だけだった旧挙動を、全カテゴリ (project / env / plugin / snapshot / status) を束ねるトップ階層メニューへ拡張する。 -PR1 では **project カテゴリのみ配線**し、env/plugin/snapshot/status は後続 PR -(PR3〜PR5) で各 ``actions_*`` を ``_route`` に足すまでプレースホルダ案内を出す。 +PR1 で project、PR4 で plugin カテゴリを配線済み。env/snapshot/status は後続 PR +(PR3/PR5) で各 ``actions_*`` を ``_route`` に足すまでプレースホルダ案内を出す。 後方互換 (plan 3.2): - ``--no-interactive`` / ``--plain`` (interactive=False) と非 TTY は従来どおり一覧 @@ -26,7 +26,7 @@ from devbase.commands.project import _print_table, list_projects from devbase.log import get_logger -from devbase.tui import actions_project, menu +from devbase.tui import actions_plugin, actions_project, menu logger = get_logger(__name__) @@ -56,7 +56,9 @@ def _route(category: str, devbase_root: Path): """ if category == "project": return actions_project.run(devbase_root) - # PR3: env, PR4: plugin, PR5: snapshot/status をここに追加する。 + if category == "plugin": + return actions_plugin.run(devbase_root) + # PR3: env, PR5: snapshot/status をここに追加する。 logger.info("「%s」は後続 PR で実装予定です。", _LABELS.get(category, category)) return menu.MENU_BACK diff --git a/tests/cli/tui/test_actions_plugin.py b/tests/cli/tui/test_actions_plugin.py new file mode 100644 index 0000000..a92d5a5 --- /dev/null +++ b/tests/cli/tui/test_actions_plugin.py @@ -0,0 +1,554 @@ +"""PLAN31_2 PR4: tui.actions_plugin (plugin カテゴリ操作) のテスト。 + +test_actions_project.py のパターンを踏襲し、`menu.*` を monkeypatch して選択値を +注入、`cmd_plugin` を mock して **plan 2.3 の契約どおりの属性を持つ Namespace** で +呼ばれることを各サブコマンド (repo 系含む) で検証する。破壊的操作 (uninstall / +repo remove) の confirm 拒否で未実行になること、Esc/←/Ctrl-C の遷移も検証する。 +""" + +from __future__ import annotations + +import pytest +import yaml + +from devbase.tui import actions_plugin, menu + + +def _seed_registry(root, plugins=(), repos=()): + """plugins.yml を生成して導入済み plugin / 登録済みリポジトリを注入する。""" + data = { + "repositories": [ + {"name": r, "url": f"https://github.com/o/{r}", + "added_at": "2026-01-01T00:00:00+00:00", "plugins": []} + for r in repos + ], + "installed_plugins": [ + {"name": p, "version": "1.0", "source": "o--r", + "installed_at": "2026-01-01T00:00:00+00:00", + "path": f"repos/o--r/{p}", "linked": False} + for p in plugins + ], + } + (root / "plugins.yml").write_text(yaml.safe_dump(data, allow_unicode=True)) + + +def _capture_dispatch(monkeypatch): + """cmd_plugin の呼び出し引数を全属性キャプチャするヘルパ。""" + from devbase.commands import plugin as plugin_mod + captured = {} + + def _spy(devbase_root, args): + captured["devbase_root"] = devbase_root + captured["subcommand"] = args.subcommand + for k in ("available", "source", "link", "install_all", "name", + "repo_command", "url", "force"): + if hasattr(args, k): + captured[k] = getattr(args, k) + return 0 + + monkeypatch.setattr(plugin_mod, "cmd_plugin", _spy) + return captured + + +def _no_dispatch(monkeypatch): + """cmd_plugin が呼ばれないことを検証するためのスパイ (呼び出しを記録)。""" + from devbase.commands import plugin as plugin_mod + called = [] + monkeypatch.setattr(plugin_mod, "cmd_plugin", + lambda root, args: called.append(1) or 0) + return called + + +# --------------------------------------------------------------------------- +# run(): plugin メニューのループと戻り値プロトコル +# --------------------------------------------------------------------------- + +def test_run_executes_and_returns_rc(monkeypatch, tmp_path): + """操作を実行したら dispatch の rc を返してトップへ復帰する。""" + captured = _capture_dispatch(monkeypatch) + monkeypatch.setattr(actions_plugin, "_select_operation", lambda: "sync") + assert actions_plugin.run(tmp_path) == 0 + assert captured["subcommand"] == "sync" + assert captured["devbase_root"] == tmp_path + + +def test_run_propagates_nonzero_dispatch_rc(monkeypatch, tmp_path): + """dispatch が非0 (失敗) を返したら run() もその rc を返す (終了コード伝搬)。""" + from devbase.commands import plugin as plugin_mod + monkeypatch.setattr(plugin_mod, "cmd_plugin", lambda root, args: 1) + monkeypatch.setattr(actions_plugin, "_select_operation", lambda: "sync") + assert actions_plugin.run(tmp_path) == 1 + + +def test_run_back_returns_menu_back(monkeypatch, tmp_path): + """plugin メニューで Esc/← (MENU_BACK) を押すとトップへ戻る (何も起動しない)。""" + called = _no_dispatch(monkeypatch) + monkeypatch.setattr(actions_plugin, "_select_operation", lambda: menu.MENU_BACK) + assert actions_plugin.run(tmp_path) is menu.MENU_BACK + assert called == [] + + +def test_run_ctrl_c_aborts(monkeypatch, tmp_path): + """plugin メニューで Ctrl-C (None) を押すと全体中止 (None を返す)。""" + called = _no_dispatch(monkeypatch) + monkeypatch.setattr(actions_plugin, "_select_operation", lambda: None) + assert actions_plugin.run(tmp_path) is None + assert called == [] + + +def test_run_arg_cancel_reshows_menu(monkeypatch, tmp_path): + """引数収集を中止 (_ARG_CANCEL) すると plugin メニューを再表示し、再選択で実行する。""" + select_calls = [] + # 1 回目: install (→ 引数収集中止) / 2 回目: sync (→ 実行) + monkeypatch.setattr(actions_plugin, "_select_operation", + lambda: (select_calls.append(1), + "install" if len(select_calls) == 1 else "sync")[1]) + + run_calls = [] + + def fake_run_op(root, op): + run_calls.append(op) + return actions_plugin._ARG_CANCEL if op == "install" else 0 + + monkeypatch.setattr(actions_plugin, "_run_operation", fake_run_op) + + assert actions_plugin.run(tmp_path) == 0 + assert run_calls == ["install", "sync"] + assert len(select_calls) == 2, "引数中止で plugin メニューが再表示される" + + +def test_run_repo_back_reshows_plugin_menu(monkeypatch, tmp_path): + """repo サブ階層で Esc/← (MENU_BACK) を押すと plugin メニューへ戻る。""" + select_calls = [] + monkeypatch.setattr(actions_plugin, "_select_operation", + lambda: (select_calls.append(1), + "repo" if len(select_calls) == 1 else menu.MENU_BACK)[1]) + monkeypatch.setattr(actions_plugin, "_repo_menu", lambda root: menu.MENU_BACK) + + assert actions_plugin.run(tmp_path) is menu.MENU_BACK + assert len(select_calls) == 2, "repo から戻ると plugin メニューが再表示される" + + +def test_run_repo_rc_propagates(monkeypatch, tmp_path): + """repo サブ階層で操作を実行したらその rc を返してトップへ復帰する。""" + monkeypatch.setattr(actions_plugin, "_select_operation", lambda: "repo") + monkeypatch.setattr(actions_plugin, "_repo_menu", lambda root: 1) + assert actions_plugin.run(tmp_path) == 1 + + +def test_run_repo_ctrl_c_aborts(monkeypatch, tmp_path): + """repo サブ階層で Ctrl-C (None) を受けたら全体中止を伝搬する。""" + monkeypatch.setattr(actions_plugin, "_select_operation", lambda: "repo") + monkeypatch.setattr(actions_plugin, "_repo_menu", lambda root: None) + assert actions_plugin.run(tmp_path) is None + + +# --------------------------------------------------------------------------- +# _select_operation / _select_repo_operation: menu.select への委譲 +# --------------------------------------------------------------------------- + +def test_select_operation_lists_all_ops(monkeypatch): + captured = {} + + def fake_select(message, choices, *, back, search): + captured.update(back=back, search=search, + values=[c[1] for c in choices]) + return "list" + + monkeypatch.setattr(menu, "select", fake_select) + assert actions_plugin._select_operation() == "list" + assert captured["back"] is True + assert captured["search"] is False + # 閲覧系の list を先頭にしつつ全 8 操作 (repo 含む) を提示する。 + assert captured["values"] == [ + "list", "install", "uninstall", "update", "info", "sync", "migrate", "repo"] + + +def test_select_repo_operation_lists_all_ops(monkeypatch): + captured = {} + + def fake_select(message, choices, *, back, search): + captured.update(back=back, values=[c[1] for c in choices]) + return "add" + + monkeypatch.setattr(menu, "select", fake_select) + assert actions_plugin._select_repo_operation() == "add" + assert captured["back"] is True + assert captured["values"] == ["list", "add", "remove", "refresh"] + + +# --------------------------------------------------------------------------- +# _run_operation: 各操作の引数収集 + dispatch 契約 (plan 2.3) +# --------------------------------------------------------------------------- + +@pytest.mark.parametrize("available", [True, False]) +def test_run_operation_list_available_flag(monkeypatch, tmp_path, available): + captured = _capture_dispatch(monkeypatch) + monkeypatch.setattr(menu, "confirm", lambda *a, **k: available) + assert actions_plugin._run_operation(tmp_path, "list") == 0 + assert captured["subcommand"] == "list" + assert captured["available"] is available + + +def test_run_operation_list_cancel(monkeypatch, tmp_path): + called = _no_dispatch(monkeypatch) + monkeypatch.setattr(menu, "confirm", lambda *a, **k: None) # Esc/Ctrl-C + assert actions_plugin._run_operation(tmp_path, "list") is actions_plugin._ARG_CANCEL + assert called == [] + + +def test_run_operation_install_collects_source_link_all(monkeypatch, tmp_path): + captured = _capture_dispatch(monkeypatch) + monkeypatch.setattr(menu, "text", lambda *a, **k: "owner/repo") + confirms = iter([True, False]) # link=True, install_all=False + monkeypatch.setattr(menu, "confirm", lambda *a, **k: next(confirms)) + assert actions_plugin._run_operation(tmp_path, "install") == 0 + assert captured["subcommand"] == "install" + assert captured["source"] == "owner/repo" + assert captured["link"] is True and captured["install_all"] is False + + +def test_run_operation_install_source_cancel(monkeypatch, tmp_path): + called = _no_dispatch(monkeypatch) + monkeypatch.setattr(menu, "text", lambda *a, **k: None) # Esc/Ctrl-C + assert actions_plugin._run_operation(tmp_path, "install") is actions_plugin._ARG_CANCEL + assert called == [] + + +def test_run_operation_install_confirm_cancel_midway(monkeypatch, tmp_path): + """install の途中 (install_all) で中止したら実行しない。""" + called = _no_dispatch(monkeypatch) + monkeypatch.setattr(menu, "text", lambda *a, **k: "owner/repo") + confirms = iter([True, None]) # link=True, install_all で中止 + monkeypatch.setattr(menu, "confirm", lambda *a, **k: next(confirms)) + assert actions_plugin._run_operation(tmp_path, "install") is actions_plugin._ARG_CANCEL + assert called == [] + + +def test_run_operation_uninstall_confirmed(monkeypatch, tmp_path): + """uninstall は一覧から選んだ name で confirm=True のとき実行する (plan 3.4)。""" + captured = _capture_dispatch(monkeypatch) + monkeypatch.setattr(actions_plugin, "_select_installed_plugin", + lambda root, msg, **k: "ndf") + monkeypatch.setattr(menu, "confirm", lambda *a, **k: True) + assert actions_plugin._run_operation(tmp_path, "uninstall") == 0 + assert captured["subcommand"] == "uninstall" and captured["name"] == "ndf" + + +@pytest.mark.parametrize("confirm_ret", [False, None]) +def test_run_operation_uninstall_cancelled_does_not_dispatch( + monkeypatch, tmp_path, confirm_ret): + """uninstall の confirm を拒否 (False) / 中止 (None) したら実行しない。""" + called = _no_dispatch(monkeypatch) + monkeypatch.setattr(actions_plugin, "_select_installed_plugin", + lambda root, msg, **k: "ndf") + monkeypatch.setattr(menu, "confirm", lambda *a, **k: confirm_ret) + assert actions_plugin._run_operation(tmp_path, "uninstall") is actions_plugin._ARG_CANCEL + assert called == [], "確認を拒否/中止したら uninstall しない" + + +def test_run_operation_uninstall_name_cancel(monkeypatch, tmp_path): + """name 選択を中止したら confirm も dispatch もしない。""" + called = _no_dispatch(monkeypatch) + monkeypatch.setattr(actions_plugin, "_select_installed_plugin", + lambda root, msg, **k: actions_plugin._ARG_CANCEL) + confirms = [] + monkeypatch.setattr(menu, "confirm", + lambda *a, **k: confirms.append(1) or True) + assert actions_plugin._run_operation(tmp_path, "uninstall") is actions_plugin._ARG_CANCEL + assert called == [] and confirms == [] + + +def test_run_operation_update_named(monkeypatch, tmp_path): + captured = _capture_dispatch(monkeypatch) + monkeypatch.setattr(actions_plugin, "_select_installed_plugin", + lambda root, msg, **k: "ndf") + assert actions_plugin._run_operation(tmp_path, "update") == 0 + assert captured["subcommand"] == "update" and captured["name"] == "ndf" + + +def test_run_operation_update_all_is_name_none(monkeypatch, tmp_path): + """「全 plugin を更新」は name=None で委譲する (CLI の引数省略と同じ)。""" + captured = _capture_dispatch(monkeypatch) + seen = {} + monkeypatch.setattr(actions_plugin, "_select_installed_plugin", + lambda root, msg, **k: seen.update(k) or None) + assert actions_plugin._run_operation(tmp_path, "update") == 0 + assert captured["subcommand"] == "update" and captured["name"] is None + assert seen.get("all_label"), "update では全 plugin 選択肢を提示する" + + +def test_run_operation_update_cancel(monkeypatch, tmp_path): + called = _no_dispatch(monkeypatch) + monkeypatch.setattr(actions_plugin, "_select_installed_plugin", + lambda root, msg, **k: actions_plugin._ARG_CANCEL) + assert actions_plugin._run_operation(tmp_path, "update") is actions_plugin._ARG_CANCEL + assert called == [] + + +def test_run_operation_info(monkeypatch, tmp_path): + captured = _capture_dispatch(monkeypatch) + monkeypatch.setattr(actions_plugin, "_select_installed_plugin", + lambda root, msg, **k: "ndf") + assert actions_plugin._run_operation(tmp_path, "info") == 0 + assert captured["subcommand"] == "info" and captured["name"] == "ndf" + + +def test_run_operation_info_cancel(monkeypatch, tmp_path): + called = _no_dispatch(monkeypatch) + monkeypatch.setattr(actions_plugin, "_select_installed_plugin", + lambda root, msg, **k: actions_plugin._ARG_CANCEL) + assert actions_plugin._run_operation(tmp_path, "info") is actions_plugin._ARG_CANCEL + assert called == [] + + +@pytest.mark.parametrize("op", ["sync", "migrate"]) +def test_run_operation_sync_migrate_no_attrs(monkeypatch, tmp_path, op): + """sync/migrate は引数収集なしで即委譲する (plan 2.3: 属性なし)。""" + captured = _capture_dispatch(monkeypatch) + assert actions_plugin._run_operation(tmp_path, op) == 0 + assert captured == {"devbase_root": tmp_path, "subcommand": op}, \ + "subcommand 以外の属性を載せない" + + +def test_run_operation_unknown_is_noop(monkeypatch, tmp_path): + called = _no_dispatch(monkeypatch) + assert actions_plugin._run_operation(tmp_path, "bogus") is actions_plugin._ARG_CANCEL + assert called == [] + + +# --------------------------------------------------------------------------- +# _run_repo_operation: repo 系の引数収集 + dispatch 契約 (plan 2.3) +# --------------------------------------------------------------------------- + +def test_run_repo_operation_list(monkeypatch, tmp_path): + """repo list は repo_command='list' のみで委譲する。""" + captured = _capture_dispatch(monkeypatch) + assert actions_plugin._run_repo_operation(tmp_path, "list") == 0 + assert captured == {"devbase_root": tmp_path, "subcommand": "repo", + "repo_command": "list"} + + +def test_run_repo_operation_add_with_custom_name(monkeypatch, tmp_path): + captured = _capture_dispatch(monkeypatch) + texts = iter(["https://github.com/o/r", "myrepo"]) # url, name + monkeypatch.setattr(menu, "text", lambda *a, **k: next(texts)) + assert actions_plugin._run_repo_operation(tmp_path, "add") == 0 + assert captured["subcommand"] == "repo" + assert captured["repo_command"] == "add" + assert captured["url"] == "https://github.com/o/r" + assert captured["name"] == "myrepo" + + +def test_run_repo_operation_add_empty_name_is_none(monkeypatch, tmp_path): + """カスタム名を空にすると name=None (URL から自動命名) で委譲する。""" + captured = _capture_dispatch(monkeypatch) + texts = iter(["o/r", ""]) # url, name(空) + monkeypatch.setattr(menu, "text", lambda *a, **k: next(texts)) + assert actions_plugin._run_repo_operation(tmp_path, "add") == 0 + assert captured["url"] == "o/r" and captured["name"] is None + + +def test_run_repo_operation_add_url_cancel(monkeypatch, tmp_path): + called = _no_dispatch(monkeypatch) + monkeypatch.setattr(menu, "text", lambda *a, **k: None) # Esc/Ctrl-C + assert actions_plugin._run_repo_operation(tmp_path, "add") is actions_plugin._ARG_CANCEL + assert called == [] + + +@pytest.mark.parametrize("force", [True, False]) +def test_run_repo_operation_remove_confirmed(monkeypatch, tmp_path, force): + """repo remove は confirm=True のとき force フラグ付きで実行する (plan 3.4)。""" + captured = _capture_dispatch(monkeypatch) + monkeypatch.setattr(actions_plugin, "_select_repository", + lambda root, msg, **k: "r1") + confirms = iter([True, force]) # 削除確認=True, force + monkeypatch.setattr(menu, "confirm", lambda *a, **k: next(confirms)) + assert actions_plugin._run_repo_operation(tmp_path, "remove") == 0 + assert captured["subcommand"] == "repo" + assert captured["repo_command"] == "remove" + assert captured["name"] == "r1" and captured["force"] is force + + +@pytest.mark.parametrize("confirm_ret", [False, None]) +def test_run_repo_operation_remove_cancelled_does_not_dispatch( + monkeypatch, tmp_path, confirm_ret): + """repo remove の confirm を拒否 (False) / 中止 (None) したら実行しない。""" + called = _no_dispatch(monkeypatch) + monkeypatch.setattr(actions_plugin, "_select_repository", + lambda root, msg, **k: "r1") + monkeypatch.setattr(menu, "confirm", lambda *a, **k: confirm_ret) + assert actions_plugin._run_repo_operation(tmp_path, "remove") is actions_plugin._ARG_CANCEL + assert called == [], "確認を拒否/中止したら remove しない" + + +def test_run_repo_operation_remove_force_cancel(monkeypatch, tmp_path): + """force の確認を中止 (None) したら実行しない。""" + called = _no_dispatch(monkeypatch) + monkeypatch.setattr(actions_plugin, "_select_repository", + lambda root, msg, **k: "r1") + confirms = iter([True, None]) # 削除確認=True, force で中止 + monkeypatch.setattr(menu, "confirm", lambda *a, **k: next(confirms)) + assert actions_plugin._run_repo_operation(tmp_path, "remove") is actions_plugin._ARG_CANCEL + assert called == [] + + +def test_run_repo_operation_remove_name_cancel(monkeypatch, tmp_path): + called = _no_dispatch(monkeypatch) + monkeypatch.setattr(actions_plugin, "_select_repository", + lambda root, msg, **k: actions_plugin._ARG_CANCEL) + assert actions_plugin._run_repo_operation(tmp_path, "remove") is actions_plugin._ARG_CANCEL + assert called == [] + + +def test_run_repo_operation_refresh_named(monkeypatch, tmp_path): + captured = _capture_dispatch(monkeypatch) + monkeypatch.setattr(actions_plugin, "_select_repository", + lambda root, msg, **k: "r1") + assert actions_plugin._run_repo_operation(tmp_path, "refresh") == 0 + assert captured["subcommand"] == "repo" + assert captured["repo_command"] == "refresh" and captured["name"] == "r1" + + +def test_run_repo_operation_refresh_all_is_name_none(monkeypatch, tmp_path): + """「全リポジトリを更新」は name=None で委譲する (CLI の引数省略と同じ)。""" + captured = _capture_dispatch(monkeypatch) + seen = {} + monkeypatch.setattr(actions_plugin, "_select_repository", + lambda root, msg, **k: seen.update(k) or None) + assert actions_plugin._run_repo_operation(tmp_path, "refresh") == 0 + assert captured["repo_command"] == "refresh" and captured["name"] is None + assert seen.get("all_label"), "refresh では全リポジトリ選択肢を提示する" + + +def test_run_repo_operation_refresh_cancel(monkeypatch, tmp_path): + called = _no_dispatch(monkeypatch) + monkeypatch.setattr(actions_plugin, "_select_repository", + lambda root, msg, **k: actions_plugin._ARG_CANCEL) + assert actions_plugin._run_repo_operation(tmp_path, "refresh") is actions_plugin._ARG_CANCEL + assert called == [] + + +# --------------------------------------------------------------------------- +# _repo_menu: サブ階層メニューのループ +# --------------------------------------------------------------------------- + +def test_repo_menu_back_returns_menu_back(monkeypatch, tmp_path): + monkeypatch.setattr(actions_plugin, "_select_repo_operation", + lambda: menu.MENU_BACK) + assert actions_plugin._repo_menu(tmp_path) is menu.MENU_BACK + + +def test_repo_menu_ctrl_c_aborts(monkeypatch, tmp_path): + monkeypatch.setattr(actions_plugin, "_select_repo_operation", lambda: None) + assert actions_plugin._repo_menu(tmp_path) is None + + +def test_repo_menu_arg_cancel_reshows_submenu(monkeypatch, tmp_path): + """引数収集を中止 (_ARG_CANCEL) するとサブ階層メニューを再表示し、再選択で実行する。""" + select_calls = [] + # 1 回目: add (→ 引数収集中止) / 2 回目: list (→ 実行) + monkeypatch.setattr(actions_plugin, "_select_repo_operation", + lambda: (select_calls.append(1), + "add" if len(select_calls) == 1 else "list")[1]) + + run_calls = [] + + def fake_run_op(root, op): + run_calls.append(op) + return actions_plugin._ARG_CANCEL if op == "add" else 0 + + monkeypatch.setattr(actions_plugin, "_run_repo_operation", fake_run_op) + + assert actions_plugin._repo_menu(tmp_path) == 0 + assert run_calls == ["add", "list"] + assert len(select_calls) == 2, "引数中止でサブ階層メニューが再表示される" + + +def test_repo_menu_returns_rc(monkeypatch, tmp_path): + monkeypatch.setattr(actions_plugin, "_select_repo_operation", lambda: "list") + monkeypatch.setattr(actions_plugin, "_run_repo_operation", lambda root, op: 1) + assert actions_plugin._repo_menu(tmp_path) == 1 + + +# --------------------------------------------------------------------------- +# 名前選択ヘルパ (_select_name / _select_installed_plugin / _select_repository) +# --------------------------------------------------------------------------- + +def test_select_name_lists_names(monkeypatch): + captured = {} + + def fake_select(message, choices, *, back, search): + captured.update(back=back, search=search, + values=[c[1] for c in choices]) + return "b" + + monkeypatch.setattr(menu, "select", fake_select) + assert actions_plugin._select_name("選択", ["a", "b"]) == "b" + assert captured["back"] is True + assert captured["values"] == ["a", "b"] + + +def test_select_name_all_label_first_and_maps_to_none(monkeypatch): + """all_label は value='' で先頭に置き、選択時は None に変換する。""" + captured = {} + + def fake_select(message, choices, *, back, search): + captured["values"] = [c[1] for c in choices] + return "" + + monkeypatch.setattr(menu, "select", fake_select) + assert actions_plugin._select_name("選択", ["a"], all_label="全対象") is None + assert captured["values"] == ["", "a"] + + +@pytest.mark.parametrize("sel", [None, "BACK"]) +def test_select_name_back_or_ctrl_c_is_cancel(monkeypatch, sel): + ret = menu.MENU_BACK if sel == "BACK" else None + monkeypatch.setattr(menu, "select", lambda *a, **k: ret) + assert actions_plugin._select_name("選択", ["a"]) is actions_plugin._ARG_CANCEL + + +def test_select_installed_plugin_reads_registry(monkeypatch, tmp_path): + """plugins.yml の導入済み plugin が選択肢に並ぶ (registry 結合)。""" + _seed_registry(tmp_path, plugins=("ndf", "carmo")) + captured = {} + + def fake_select(message, choices, *, back, search): + captured["values"] = [c[1] for c in choices] + return "ndf" + + monkeypatch.setattr(menu, "select", fake_select) + assert actions_plugin._select_installed_plugin(tmp_path, "選択") == "ndf" + assert captured["values"] == ["ndf", "carmo"] + + +def test_select_installed_plugin_empty_is_cancel(monkeypatch, tmp_path): + """導入済み plugin が無ければ選択メニューを出さず中止する。""" + selects = [] + monkeypatch.setattr(menu, "select", lambda *a, **k: selects.append(1) or None) + assert actions_plugin._select_installed_plugin(tmp_path, "選択") \ + is actions_plugin._ARG_CANCEL + assert selects == [] + + +def test_select_repository_reads_registry(monkeypatch, tmp_path): + """plugins.yml の登録済みリポジトリが選択肢に並ぶ (registry 結合)。""" + _seed_registry(tmp_path, repos=("r1", "r2")) + captured = {} + + def fake_select(message, choices, *, back, search): + captured["values"] = [c[1] for c in choices] + return "r2" + + monkeypatch.setattr(menu, "select", fake_select) + assert actions_plugin._select_repository(tmp_path, "選択") == "r2" + assert captured["values"] == ["r1", "r2"] + + +def test_select_repository_empty_is_cancel(monkeypatch, tmp_path): + selects = [] + monkeypatch.setattr(menu, "select", lambda *a, **k: selects.append(1) or None) + assert actions_plugin._select_repository(tmp_path, "選択") \ + is actions_plugin._ARG_CANCEL + assert selects == [] diff --git a/tests/cli/tui/test_app.py b/tests/cli/tui/test_app.py index eeae959..8ffd3d2 100644 --- a/tests/cli/tui/test_app.py +++ b/tests/cli/tui/test_app.py @@ -8,7 +8,7 @@ import types -from devbase.tui import actions_project, app, menu +from devbase.tui import actions_plugin, actions_project, app, menu def _make_plugin_project(root, plugin_path, proj): @@ -213,5 +213,12 @@ def test_route_project_delegates(monkeypatch, tmp_path): assert app._route("project", tmp_path) == "RESULT" +def test_route_plugin_delegates(monkeypatch, tmp_path): + """PR4: plugin カテゴリは actions_plugin.run へ routing される。""" + monkeypatch.setattr(actions_plugin, "run", lambda root: "RESULT") + assert app._route("plugin", tmp_path) == "RESULT" + + def test_route_unimplemented_returns_menu_back(tmp_path): - assert app._route("plugin", tmp_path) is menu.MENU_BACK + # snapshot は PR5 で配線されるまでプレースホルダ (MENU_BACK)。 + assert app._route("snapshot", tmp_path) is menu.MENU_BACK