diff --git a/issues/PLAN31_2_list-tui-unified.md b/issues/PLAN31_2_list-tui-unified.md index 94dd5eb..bacb20d 100644 --- a/issues/PLAN31_2_list-tui-unified.md +++ b/issues/PLAN31_2_list-tui-unified.md @@ -1,7 +1,7 @@ # PLAN31_2: `devbase list` TUI の統合UI化 > 元 issue: `issues/i31.md` 第2項 -> ステータス: 着手可(設計確定 2026-06-09・既存コード精読済み) +> ステータス: 実行中(2026-06-10 PR1 #56 実装+cross-review approved→release へ merge 済み / PR2〜5 未着手) > 関連: PLAN31_1 (init は installer に吸収)、PLAN06 (`project` 群) > 関連 skill: `/ndf:issue-plan-strategy`, `/ndf:implementation-plan` @@ -194,3 +194,62 @@ base branch: main 「プロジェクト操作」既定ハイライトと `--plain` 維持により muscle-memory を保全。 - **切り出しの競合**: `project.py` は i29〜i32 で頻繁に更新。PR1 は現行ロジックを保全 移送し、差分レビューしやすい単位を維持する。 + +## 7. 実装進捗 (2026-06-11 更新) + +`/ndf:issue-plan-strategy` の実行フェーズで release ブランチ + 個別 PR を先行作成し、 +ユーザー指示により全 PR を Draft 解除 (open) 済み。 + +- release branch: `release/PLAN31_2` (base: `main`) 作成・push 済み +- release PR: **#55** (open) +- 個別 PR (release base・先行作成・全 open): + +| PR | 番号 | branch | 状態 | +|---|---|---|---| +| PR1 | #56 | `feature/PLAN31_2-tui-framework` | **merged** (release へ統合 `50ab9c2`) | +| PR2 | #57 | `feature/PLAN31_2-project-ops` | **merged** (release へ統合 `29f4d9c`) | +| PR3 | #58 | `feature/PLAN31_2-env-ops` | **merged** (release へ統合 `56dc0f5`) | +| PR4 | #59 | `feature/PLAN31_2-plugin-ops` | **merged** (release へ統合 `7a1e763`) | +| PR5 | #60 | `feature/PLAN31_2-snapshot-status` | **merged** (release へ統合 `8db78e5`) | + +### PR1 (#56) 完了サマリ + +- 実装: `lib/devbase/tui/` 新設 (`menu` / `dispatch` / `app` / `actions_project`)。 + トップ階層メニュー化、既存 project up/down/rebuild の非回帰移送、`cmd_project_list` + → `tui.run` 委譲。`commands/project.py` は listing/整形へ縮約。 +- cross-review: **2 round で approved** (codex/gemini)。round1 で 3 件修正 + (rc 伝搬欠落=major / text・path 再帰除去=minor / questionary text・confirm・path の + Esc バインド=minor)。最終 head `5948c08`、open thread 0。 +- テスト: 520 passed / 1 skipped (基準 510 から +10、退行なし)。 + +### PR2〜5 への申し送り (土台の配線方法) + +- 各カテゴリは別ファイル `tui/actions_.py` を新設し、`tui/app.py:_route` に + 1 行 (`if category == "": return actions_.run(...)`) を追加するだけで配線できる + (現状 env/plugin/snapshot/status はプレースホルダ案内)。 +- 属性収集は `tui/menu.py` の `text` / `confirm` / `integer` / `path` を使う。 +- グループハンドラ (env/plugin/snapshot) への委譲は `tui/dispatch.py:dispatch_group` + (`handler(devbase_root, args)` 形式) を使う。project 系は `dispatch_lifecycle`。 +- 破壊的操作 (down/delete/uninstall/repo remove/restore) は `menu.confirm()` を挟む (plan 3.4)。 +- project スコープ依存 (`env set --project` / `env project` / `env edit`) は事前 chdir (plan 3.3)。 + +### PR3〜5 (#58/#59/#60) 完了サマリ (2026-06-11) + +- **PR3 env (#58)**: env 全 10 操作。cross-review 3 round で approved。codex major 2 件を + 修正 — ① `env list`「プロジェクトのみ」/ ② `env get` に取得元選択を追加し、 + プロジェクト選択 → chdir + `PWD` 切替 (`_run_in_project`) 経由で実行するよう統一。 + `cmd_env_*` は `PWD` 環境変数で現在地判定するため chdir 単独では不十分な点に注意。 +- **PR4 plugin (#59)**: plugin 全操作 + repo サブ階層。1 round・指摘 0 で approved。 + uninstall/update/info/repo remove/refresh の name は plugins.yml レジストリから選択。 +- **PR5 snapshot/status (#60)**: snapshot 全 6 操作 + status 閲覧。1 round・指摘 0 で + approved。restore/copy/delete の対象は `SnapshotManager.list()` の実一覧から選択。 +- **テスト**: release 統合後 690 passed / 1 skipped (基準 544 collected から退行なし)。 +- **plan 2.3/3.3 の訂正**: `env edit` は CWD スコープではなく常にグローバル + `$DEVBASE_ROOT/.env` を開く実装 (`cmd_env_edit`) のため、TUI でも chdir しない + (実装を正とした)。 +- **配線の競合解消**: PR3〜5 は全て `tui/app.py:_route` と `tests/cli/tui/test_app.py` + に触れるため、マージ順 (#58→#59→#60) に release を都度取り込み統合。全カテゴリ + 配線済みに伴い「未実装カテゴリ」前提のテストは MENU_BACK mock / 未知カテゴリ + fallback 検証へ書き換えた。 + +残作業: release PR #55 の結合レビュー (Step 7: PR 間整合・E2E 観点のみ) → main へ merge。 diff --git a/lib/devbase/commands/project.py b/lib/devbase/commands/project.py index 48788bf..37af37c 100644 --- a/lib/devbase/commands/project.py +++ b/lib/devbase/commands/project.py @@ -1,39 +1,28 @@ -"""Project listing commands (`devbase project list` / `devbase list`). +"""Project listing helpers (`devbase project list` / `devbase list`). -PLAN06 Task 3。`$DEVBASE_ROOT/projects/` 配下を NAME / PLUGIN / STATUS で一覧表示し、 -``--interactive`` で選択 → `project up` 起動を行う。 +PLAN06 Task 3 で追加した ``$DEVBASE_ROOT/projects/`` の一覧 (NAME / PLUGIN / STATUS) +表示と整形ロジックを担う。PLAN31_2 で **対話 TUI 部分は ``devbase.tui`` パッケージへ +分離**し、本モジュールは listing と整形 (table / メニュー表示文字列) の純粋ロジックに +専念する (TUI からも CLI table からも共有される)。 ライフサイクル操作 (up/down/ps/login/logs/scale/build) は引き続き -``commands/container.py`` の共有ハンドラが担当し、本モジュールは listing と -interactive 起動のみを担う。 +``commands/container.py`` の共有ハンドラが担当する。``cmd_project_list`` は +``devbase.tui.run`` を入口として呼ぶだけの薄いラッパになった。 """ from __future__ import annotations import os -import sys from pathlib import Path from devbase.log import get_logger logger = get_logger(__name__) -# questionary (prompt_toolkit ベース) は任意依存。未導入環境では番号入力に -# フォールバックするため、import 失敗を許容する。questionary は矢印キー移動 + -# 文字入力での絞り込み (use_search_filter) に対応し、prompt_toolkit が入力を -# 1 イベントずつ分解するため、旧 simple_term_menu のような ↑長押し時の入力 -# 取りこぼし (連結エスケープシーケンスの破棄) が構造的に発生しない。 -try: - import questionary - _HAVE_QUESTIONARY = True -except ImportError: # pragma: no cover - 未導入環境のフォールバック経路 - questionary = None - _HAVE_QUESTIONARY = False - -# STATUS 色付けの有効/無効。menu entry に生 ANSI を埋め込むと prompt_toolkit の -# 表示幅計算と干渉しうるため、実機検証が完了するまではメニューでは色を付けず -# False を既定とする (機能 > 装飾)。テーブル表示 (_print_table) は端末へ直接書く -# ため影響を受けず、色付けは別途 questionary の style で検討する。 +# STATUS 色付けの有効/無効。メニュー entry に生 ANSI を埋め込むと prompt_toolkit の +# 表示幅計算と干渉しうるため、実機検証が完了するまではメニューでは色を付けず False を +# 既定とする (機能 > 装飾)。テーブル表示 (_print_table) は端末へ直接書くため影響を +# 受けない。tui.actions_project が _build_menu_entries 呼び出し時に参照する。 _STATUS_COLOR = False @@ -171,225 +160,12 @@ def _build_menu_entries(rows: list[dict], colorize: bool = False) -> list[str]: return entries -def _start_project_action(name: str, action: str) -> int: - """``project `` を共有ハンドラ cmd_project 経由で起動する。 - - ``action`` は ``"up"`` / ``"down"`` / ``"rebuild"``。共有ハンドラ - (_dispatch_lifecycle) が ``name`` でディレクトリ解決 (chdir) してから各 - サブコマンドを実行する。``scale`` は up のみが参照するが、常に付与しても - 他コマンドは無視するため一律 None を渡す。 - """ - import types - - from devbase.commands.container import cmd_project - return cmd_project(types.SimpleNamespace(subcommand=action, name=name, scale=None)) - - -def _start_project_up(name: str) -> int: - """``project up `` を起動する (後方互換の薄いラッパ)。""" - return _start_project_action(name, "up") - - -# サブメニュー (_show_action_menu) で Esc を押した際の「トップメニューへ戻る」 -# シグナル。``None`` (= Ctrl-C による全体中止) と区別するための番兵。 -_MENU_BACK = object() - - -def _add_escape_binding(question, handler): - """questionary の select に Esc 単独押下のハンドラを後付けする共通処理。 - - questionary 2.x の select は Ctrl-C / Ctrl-Q しか割り当てないため、生成済み - ``Question.application`` の key_bindings に Escape ハンドラを足す。 - - Escape は矢印キー等のエスケープシーケンス (``\\x1b[A`` 等) の先頭バイトでも - あるため、``eager=False`` で登録し prompt_toolkit のフラッシュ待ちで単独 Esc - のみを拾う (矢印キー移動と衝突させない)。 - """ - from prompt_toolkit.keys import Keys - - question.application.key_bindings.add(Keys.Escape)(handler) - return question - - -def _with_escape_cancel(question): - """Esc 単独押下で中止する select を返す。 - - Ctrl-C と同じく ``KeyboardInterrupt`` で抜けるので ``ask()`` は ``None`` - (= 中止) を返す。トップメニュー (戻り先が無い) 用。 - """ - def _cancel(event): - event.app.exit(exception=KeyboardInterrupt, style="class:aborting") - - return _add_escape_binding(question, _cancel) - - -def _with_escape_back(question): - """← / Esc 押下で ``_MENU_BACK`` を返す select を返す。 - - Ctrl-C は questionary 既定どおり中止 (``ask()`` が ``None``) のまま残し、← と - Esc を「1 つ前のメニューへ戻る」シグナルに割り当てる。サブメニュー用。 - - Esc (``\\x1b``) は矢印キーのエスケープシーケンスの先頭バイトと衝突するため - prompt_toolkit のフラッシュ待ち分の遅延が体感される。左矢印 (``\\x1b[D``) は - 完結した曖昧さの無いシーケンスなので、これを主たる「戻る」キーとして即時に - 反応させ、Esc は互換のため残す。サブメニューは検索絞り込み (use_search_filter) - を使わないため、← をカーソル移動と衝突させずに割り当てられる。 - """ - from prompt_toolkit.keys import Keys - - def _back(event): - event.app.exit(result=_MENU_BACK) - - _add_escape_binding(question, _back) # Esc(互換・低速) - question.application.key_bindings.add(Keys.Left)(_back) # ←(即時) - return question - - -def _show_menu(rows: list[dict]) -> int | None: - """questionary の select を起動し、選択された rows の index を返す (中止時 None)。 - - テストではこの関数自体を monkeypatch して questionary の実起動を避ける。 - """ - entries = _build_menu_entries(rows, colorize=_STATUS_COLOR) - choices = [questionary.Choice(title=entry, value=i) - for i, entry in enumerate(entries)] - question = questionary.select( - "起動するプロジェクトを選択 (↑↓ 移動 / 名前で絞り込み / Enter 決定 / Esc・Ctrl-C 中止):", - choices=choices, - use_arrow_keys=True, - use_jk_keys=False, # use_search_filter と併用不可のため False - use_search_filter=True, # 文字入力でプロジェクト名等を部分一致絞り込み - use_shortcuts=False, # 単一キーショートカットは使わない - ) - return _with_escape_cancel(question).ask() # value (= rows index) / 中止時 None - - -def _show_action_menu(name: str): - """running 中プロジェクトの操作 (up/rebuild/down) を選ぶサブメニュー。 - - 戻り値: - - action 文字列 (``"up"`` / ``"rebuild"`` / ``"down"``): 操作を選択 - - ``_MENU_BACK``: Esc 押下 → トップメニューへ戻る - - ``None``: Ctrl-C 押下 → 全体中止 - - テストではこの関数を monkeypatch する。 - """ - choices = [ - questionary.Choice(title="再起動 (up)", value="up"), - questionary.Choice(title="再ビルド (rebuild --no-cache)", value="rebuild"), - questionary.Choice(title="停止 (down)", value="down"), - ] - question = questionary.select( - f"'{name}' は起動中です。操作を選択 " - "(↑↓ 移動 / Enter 決定 / ← ・Esc 戻る / Ctrl-C 中止):", - choices=choices, - use_arrow_keys=True, - use_shortcuts=False, - ) - return _with_escape_back(question).ask() - - -def _tui_select_and_up(rows: list[dict]) -> int: - """TUI メニューで 1 件選択して操作を起動する。 - - 選択行が running 中なら ``_show_action_menu`` で up/rebuild/down を選ばせ、 - それ以外 (stopped / unknown 等) は従来どおり直接 ``project up`` を起動する。 - サブメニューで Esc を押すと (``_MENU_BACK``) トップメニューへ戻る。 - """ - while True: - idx = _show_menu(rows) - if idx is None: - logger.info("中止しました。") - return 0 - - row = rows[idx] - name = row["name"] - if str(row.get("status", "")).startswith("running"): - action = _show_action_menu(name) - if action is _MENU_BACK: - continue # Esc → トップメニューへ戻る - if action is None: - logger.info("中止しました。") # Ctrl-C → 全体中止 - return 0 - return _start_project_action(name, action) - - return _start_project_action(name, "up") - - -def _interactive_select_and_up(rows: list[dict]) -> int: - """一覧から 1 件選択して ``project up`` を起動する (TTY 専用)。 - - questionary が利用可能なら矢印キー + 絞り込み対応の TUI メニューを使う。未導入 - 環境では現行の番号入力方式 (_fallback_select_and_up) にフォールバックする。 - """ - if _HAVE_QUESTIONARY: - return _tui_select_and_up(rows) - logger.warning( - "questionary が未導入のため番号入力にフォールバックします " - "(`uv sync` で導入すると矢印キー選択が使えます)。" - ) - return _fallback_select_and_up(rows) - - -def _fallback_select_and_up(rows: list[dict]) -> int: - """番号入力で 1 件選択し ``project up `` を起動する (questionary 未導入時のフォールバック)。 +def cmd_project_list(devbase_root: Path, args) -> int: + """`devbase project list [--interactive]` / `devbase list [--interactive]`。 - 外部依存 (questionary 等) を増やさず stdlib の ``input()`` で実装する。 - 非対話環境 (stdin が閉じている等で EOFError) ではエラー終了する。空入力は中止。 + 実体は ``devbase.tui.run`` (トップ階層メニュー) へ委譲する。非 TTY / + ``--no-interactive`` / questionary 不在時のフォールバックは tui 側で処理する。 """ - print("起動するプロジェクトを選択してください:") - for i, r in enumerate(rows, 1): - print(f" [{i}] {r['name']} ({r['plugin']}, {r['status']})") - - # 一覧取得が重い場合があるため、誤入力 (数値以外 / 範囲外) では即終了せず - # 再入力を促す。空入力は中止、非 TTY (EOFError) はエラー終了。 - while True: - try: - raw = input("番号 (空で中止): ").strip() - except EOFError: - logger.error("対話入力ができません (非 TTY 環境)。" - "`devbase project up ` で直接指定してください。") - return 1 - except KeyboardInterrupt: - # Ctrl+C は traceback を出さず中止として扱う。 - print() - logger.info("中止しました。") - return 0 - - if not raw: - logger.info("中止しました。") - return 0 - - try: - idx = int(raw) - except ValueError: - logger.error("番号で指定してください: %r", raw) - continue - - if not (1 <= idx <= len(rows)): - logger.error("範囲外の番号です: %d (1〜%d)", idx, len(rows)) - continue - - break - - return _start_project_up(rows[idx - 1]["name"]) + from devbase.tui import run as tui_run - -def cmd_project_list(devbase_root: Path, args) -> int: - """`devbase project list [--interactive]` / `devbase list [--interactive]`。""" - projects_dir = Path(devbase_root) / "projects" - rows = list_projects(projects_dir) - - if not rows: - logger.info("プロジェクトがありません (%s)。", projects_dir) - return 0 - - # 対話選択はデフォルト ON。ただし非 TTY (パイプ / CI / リダイレクト) では - # input() が EOFError になり実用にならないため、自動的に一覧表示へフォールバック。 - # stdin / stdout のいずれかが非 TTY (`devbase list | cat`, `> out.txt` 等) なら - # 対話プロンプトが表示できない / 読めないため、確実に一覧表示へフォールバックする。 - if getattr(args, "interactive", True) and sys.stdin.isatty() and sys.stdout.isatty(): - return _interactive_select_and_up(rows) - - _print_table(rows) - return 0 + return tui_run(Path(devbase_root), args) diff --git a/lib/devbase/tui/__init__.py b/lib/devbase/tui/__init__.py new file mode 100644 index 0000000..8b555dd --- /dev/null +++ b/lib/devbase/tui/__init__.py @@ -0,0 +1,18 @@ +"""devbase の統合 TUI (`devbase list`) パッケージ。 + +`commands/project.py` に一体化していた questionary ベースのメニュー資産を +PLAN31_2 でこのパッケージに分離した。役割分担: + +- ``menu`` : questionary ラッパ・``MENU_BACK`` 番兵・Esc/← バインド・引数収集ヘルパ +- ``dispatch`` : ``SimpleNamespace`` を組んで既存ハンドラを呼ぶ薄い委譲層 +- ``app`` : トップ階層メニューとカテゴリ routing (`run` が入口) +- ``actions_*`` : 各カテゴリ (project/env/plugin/snapshot/status) の操作フロー + +`run` を入口として再公開し、``cmd_project_list`` から ``tui.run`` で呼べるようにする。 +""" + +from __future__ import annotations + +from devbase.tui.app import run + +__all__ = ["run"] diff --git a/lib/devbase/tui/actions_env.py b/lib/devbase/tui/actions_env.py new file mode 100644 index 0000000..4170877 --- /dev/null +++ b/lib/devbase/tui/actions_env.py @@ -0,0 +1,444 @@ +"""env カテゴリの TUI 操作フロー (PLAN31_2 PR3)。 + +``devbase env`` の全サブコマンド (init/list/set/get/delete/edit/sync/project/ +export/import) をトップ階層メニューから実行できるようにする。引数収集は +``tui.menu`` のヘルパで CLI parser (cli.py ``_add_env_parser``) と同じ属性値を +集め、``tui.dispatch.dispatch_group`` 経由で既存ハンドラ ``cmd_env`` へ委譲する +(plan 2.3 契約表 / ロジック二重実装なし)。 + +project スコープ依存の扱い (plan 3.3): +- ``set --project`` / ``project`` / ``list`` (プロジェクトを含む表示範囲) / + ``get`` (プロジェクト取得) は CWD (環境変数 ``PWD``) のプロジェクト + ディレクトリで動くため、先にプロジェクト選択メニューで対象を選ばせて + chdir + ``PWD`` 差し替えしてからハンドラを呼び、実行後は必ず元へ復帰する + (``_run_in_project``)。``cmd_env_*`` は ``os.environ.get('PWD', os.getcwd())`` + で現在地を判定するため、``os.chdir`` だけでなく ``PWD`` も併せて切り替える。 +- ``edit`` は plan 3.3 で CWD スコープとされているが、実装 (``cmd_env_edit``) は + 常に ``$DEVBASE_ROOT/.env`` を開くグローバル操作のため、プロジェクト選択は + 行わない (plan 表と実装の乖離。parser / 実装を正とする)。 + +破壊的操作 ``delete`` は実行前に ``menu.confirm`` で確認する (plan 3.4)。 + +export/import は引数が多いため TUI では主要引数 (``dest`` / ``source``) のみ +収集し、残りは CLI parser の既定値と同一の属性を明示的に渡す (既定値の乖離を +防ぐ。細かい制御が必要な場合は CLI を使う想定)。 + +ナビ規約は actions_project と同一: +- サブメニューで Esc/← → カテゴリメニューへ戻る → (呼び出し元へ ``MENU_BACK``) +- Ctrl-C → ``None`` を伝搬して全体中止 +- 引数収集の中止 (``_ARG_CANCEL``) → サブメニューを再表示 +""" + +from __future__ import annotations + +import os +from pathlib import Path + +from devbase.commands.project import ( + _STATUS_COLOR, + _build_menu_entries, + list_projects, +) +from devbase.log import get_logger +from devbase.tui import menu +from devbase.tui.dispatch import dispatch_group + +logger = get_logger(__name__) + +# env カテゴリで選べる操作 (表示順 = ハイライト既定順)。参照系の list を先頭に +# 置き、Enter 連打で安全な一覧表示へ到達できるようにする。各 value は cmd_env の +# サブコマンド名。 +_ENV_OPS: list[tuple[str, str]] = [ + ("変数一覧 (list)", "list"), + ("値の取得 (get)", "get"), + ("変数の設定 (set)", "set"), + ("変数の削除 (delete)", "delete"), + ("エディタで編集 (edit)", "edit"), + ("認証情報の再同期 (sync)", "sync"), + ("プロジェクト変数の対話設定 (project)", "project"), + ("初期セットアップ (init)", "init"), + ("暗号化バンドルへエクスポート (export)", "export"), + ("バンドルからインポート (import)", "import"), +] + +# 引数収集を Esc/Ctrl-C で中止したことを示す番兵 (= サブメニューへ戻る)。 +# dispatch の rc (int) や ``None`` (= 全体中止) と区別する (actions_project と同じ)。 +_ARG_CANCEL = object() + + +def _dispatch(devbase_root: Path, subcommand: str, **attrs): + """``cmd_env`` への委譲 (dispatch_group の薄いラッパ)。 + + import を関数内で行うのは actions_project (dispatch_lifecycle) と同様、 + テストで ``devbase.commands.env.cmd_env`` を monkeypatch できるようにするため。 + """ + from devbase.commands import env as env_mod + + return dispatch_group(env_mod.cmd_env, devbase_root, subcommand, **attrs) + + +def _select_action(): + """env 操作を選ぶサブメニュー。 + + 戻り値: サブコマンド文字列 / ``MENU_BACK`` (Esc・← → トップへ戻る) / ``None`` + (Ctrl-C 中止)。 + """ + return menu.select( + "環境変数の操作を選択 " + "(↑↓ 移動 / Enter 決定 / ←・Esc 戻る / Ctrl-C 中止):", + list(_ENV_OPS), back=True, search=False) + + +def _select_project(devbase_root: Path): + """project スコープ操作の対象プロジェクトを選ぶ。 + + actions_project と同じ一覧取得 (``list_projects`` + ``_build_menu_entries``) を + 流用する。戻り値: プロジェクト名 (``str``) / ``None`` (Ctrl-C → 全体中止を呼び + 出し元へ伝搬) / ``_ARG_CANCEL`` (Esc → サブメニューへ戻る、またはプロジェクト無し)。 + """ + projects_dir = Path(devbase_root) / "projects" + rows = list_projects(projects_dir) + if not rows: + logger.info("プロジェクトがありません (%s)。", projects_dir) + return _ARG_CANCEL + + entries = _build_menu_entries(rows, colorize=_STATUS_COLOR) + choices = [(entry, i) for i, entry in enumerate(entries)] + idx = menu.select( + "対象プロジェクトを選択 " + "(↑↓ 移動 / 名前で絞り込み / Enter 決定 / Esc 戻る / Ctrl-C 中止):", + choices, back=True, search=True) + if idx is None: + return None # Ctrl-C → 全体中止 (ナビ規約) + if idx is menu.MENU_BACK: + return _ARG_CANCEL # Esc → サブメニューを再表示 + return rows[idx]["name"] + + +def _run_in_project(devbase_root: Path, project_name: str, fn): + """``projects/`` へ chdir + ``PWD`` を切り替えて fn を実行し、必ず復帰する。 + + ``cmd_env_set --project`` / ``cmd_env_project`` は + ``os.environ.get('PWD', os.getcwd())`` で現在地を判定する (wrapper の cd を + 前提とした PLAN06 機構) ため、``os.chdir`` だけでは不十分で ``PWD`` も + プロジェクトパスへ差し替える。``PWD`` は symlink を解決しない + ``projects/`` を指す (projects/ 配下判定を成立させるため)。 + + 戻り値: fn の rc / ``_ARG_CANCEL`` (対象ディレクトリへ移動できない場合)。 + """ + target = Path(devbase_root) / "projects" / project_name + old_cwd = Path.cwd() + old_pwd = os.environ.get("PWD") + try: + os.chdir(target) + except OSError as exc: + logger.error("プロジェクトディレクトリへ移動できません: %s (%s)", target, exc) + return _ARG_CANCEL + os.environ["PWD"] = str(target) + try: + return fn() + finally: + # 実行結果に関わらず必ず元の CWD / PWD へ復帰する (plan 3.3)。 + os.chdir(old_cwd) + if old_pwd is None: + os.environ.pop("PWD", None) + else: + os.environ["PWD"] = old_pwd + + +def _collect_assignment(): + """``env set`` の KEY=VALUE を収集する。 + + 形式エラー (``=`` 無し / キー名空) は ``cmd_env_set`` でも弾かれるが、TUI では + 実行前に再入力を促す。戻り値: 入力文字列 / ``MENU_BACK`` (Esc → サブメニューへ + 戻る) / ``None`` (Ctrl-C → 全体中止)。 + """ + while True: + raw = menu.text("設定する変数 (KEY=VALUE 形式)", allow_empty=False) + if raw is None or raw is menu.MENU_BACK: + return raw # None=Ctrl-C 全体中止 / MENU_BACK=Esc 戻る + if "=" not in raw or not raw.partition("=")[0].strip(): + logger.error("形式: KEY=VALUE (キー名は必須)") + continue + return raw + + +def _export_default_attrs() -> dict: + """``env export`` の CLI parser 既定値 (cli.py:246-279) と同一の属性セット。 + + TUI で収集しない引数も Namespace に明示的に載せ、CLI 実行と完全に同じ属性で + ハンドラを呼ぶ (getattr 既定値とのズレを防ぐ)。list は呼び出しごとに新規生成。 + """ + return { + "include_projects": None, + "exclude_projects": [], + "no_global": False, + "no_metadata": False, + "recipients": [], + "passphrase_env": None, + "passphrase_stdin": False, + "force_unencrypted": False, + "unsafe_allow_unencrypted_bucket": False, + } + + +def _import_default_attrs() -> dict: + """``env import`` の CLI parser 既定値 (cli.py:281-328) と同一の属性セット。""" + return { + "merge": "keep-existing", + "replace_keys": "", + "replace": False, + "dry_run": False, + "identities": [], + "passphrase_env": None, + "passphrase_stdin": False, + "include_projects": None, + "exclude_projects": [], + "no_global": False, + "no_metadata": False, + "merge_metadata": False, + "backup_dir": None, + "keep_last": 10, + } + + +def _run_list(devbase_root: Path): + """``env list``: 表示範囲 + 表示オプションを収集して一覧表示する。 + + ハンドラ (``cmd_env_list``) は CWD (PWD) が projects/ 配下のときだけ + プロジェクト .env を表示するため、プロジェクトを含む表示範囲 + (「グローバル + プロジェクト」「プロジェクトのみ」) は対象プロジェクトを + 選ばせて chdir + ``PWD`` 切替後に実行する (plan 3.3 / codex round3 指摘。 + TUI は通常 DEVBASE_ROOT で動くので、切替なしではプロジェクト分が表示 + されない)。「グローバルのみ」だけが切替なしで実行できる。 + """ + scope = menu.select( + "表示範囲を選択 (↑↓ 移動 / Enter 決定 / ←・Esc 戻る / Ctrl-C 中止):", + [("グローバル + プロジェクト", "both"), + ("グローバルのみ (--global)", "global"), + ("プロジェクトのみ (--project)", "project")], + back=True, search=False) + if scope is None: + return None # Ctrl-C → 全体中止 + if scope is menu.MENU_BACK: + return _ARG_CANCEL + + name = None + if scope in ("both", "project"): + name = _select_project(devbase_root) + if name is None: + return None # Ctrl-C → 全体中止 + if name is _ARG_CANCEL: + return _ARG_CANCEL + + reveal = menu.confirm("機密値を伏せ字にせず表示しますか (--reveal)?", default=False) + if reveal is None: + return None # Ctrl-C → 全体中止 + if reveal is menu.MENU_BACK: + return _ARG_CANCEL # Esc → サブメニューへ戻る + keys_only = menu.confirm("キー名のみ表示しますか (--keys)?", default=False) + if keys_only is None: + return None # Ctrl-C → 全体中止 + if keys_only is menu.MENU_BACK: + return _ARG_CANCEL # Esc → サブメニューへ戻る + + if scope in ("both", "project"): + return _run_in_project( + devbase_root, name, + lambda: _dispatch(devbase_root, "list", + global_only=False, + project_only=(scope == "project"), + reveal=reveal, keys_only=keys_only)) + return _dispatch(devbase_root, "list", + global_only=True, project_only=False, + reveal=reveal, keys_only=keys_only) + + +def _run_set(devbase_root: Path): + """``env set``: 設定先 (グローバル / プロジェクト) と KEY=VALUE を収集して設定する。 + + プロジェクト設定 (--project) は対象を選ばせて chdir してから実行する (plan 3.3)。 + """ + scope = menu.select( + "設定先を選択 (↑↓ 移動 / Enter 決定 / ←・Esc 戻る / Ctrl-C 中止):", + [("グローバル ($DEVBASE_ROOT/.env)", "global"), + ("プロジェクト (projects//.env, --project)", "project")], + back=True, search=False) + if scope is None: + return None # Ctrl-C → 全体中止 + if scope is menu.MENU_BACK: + return _ARG_CANCEL + + name = None + if scope == "project": + name = _select_project(devbase_root) + if name is None: + return None # Ctrl-C → 全体中止 + if name is _ARG_CANCEL: + return _ARG_CANCEL + + assignment = _collect_assignment() + if assignment is None: + return None # Ctrl-C → 全体中止 + if assignment is menu.MENU_BACK: + return _ARG_CANCEL # Esc → サブメニューへ戻る + + if scope == "project": + return _run_in_project( + devbase_root, name, + lambda: _dispatch(devbase_root, "set", + assignment=assignment, project=True)) + return _dispatch(devbase_root, "set", assignment=assignment, project=False) + + +def _run_get(devbase_root: Path): + """``env get``: 取得元 (グローバル / プロジェクト) と変数名を収集して値を表示する。 + + ``cmd_env_get`` はグローバル .env に無いキーを CWD (PWD) のプロジェクト .env へ + フォールバックして探すが、TUI は常に DEVBASE_ROOT で動くため、そのままでは + プロジェクト固有キーを取得できない。list/set と同様に取得元を選ばせ、 + プロジェクト選択時は chdir + ``PWD`` 切替後に実行する (codex round2 指摘)。 + """ + scope = menu.select( + "取得元を選択 (↑↓ 移動 / Enter 決定 / ←・Esc 戻る / Ctrl-C 中止):", + [("グローバル ($DEVBASE_ROOT/.env)", "global"), + ("プロジェクト (グローバルに無ければ projects//.env)", "project")], + back=True, search=False) + if scope is None: + return None # Ctrl-C → 全体中止 + if scope is menu.MENU_BACK: + return _ARG_CANCEL + + name = None + if scope == "project": + name = _select_project(devbase_root) + if name is None: + return None # Ctrl-C → 全体中止 + if name is _ARG_CANCEL: + return _ARG_CANCEL + + key = menu.text("取得する変数名", allow_empty=False) + if key is None: + return None # Ctrl-C → 全体中止 + if key is menu.MENU_BACK: + return _ARG_CANCEL # Esc → サブメニューへ戻る + + if scope == "project": + return _run_in_project( + devbase_root, name, + lambda: _dispatch(devbase_root, "get", key=key)) + return _dispatch(devbase_root, "get", key=key) + + +def _run_operation(devbase_root: Path, op: str): + """選択された env 操作の引数を収集して ``cmd_env`` へ委譲する。 + + 戻り値: dispatch の rc (``int``) / ``_ARG_CANCEL`` (Esc で引数収集を中止 = + サブメニューへ戻る) / ``None`` (選択・入力中の Ctrl-C → 全体中止)。 + 属性は plan 2.3 の契約表 (cli.py parser と同期確認済み) に従う。 + """ + if op == "sync": + # 引数なし。ソースファイルから認証情報を再同期する。 + return _dispatch(devbase_root, "sync") + + if op == "edit": + # 引数なし。$DEVBASE_ROOT/.env を $EDITOR で開く (グローバル操作。 + # plan 3.3 は CWD スコープとするが実装はグローバルのため chdir しない)。 + return _dispatch(devbase_root, "edit") + + if op == "init": + # 既存設定がある場合は --reset でやり直し (既存はバックアップされる)。 + reset = menu.confirm( + "既存の設定をバックアップしてやり直しますか (--reset)?", default=False) + if reset is None: + return None # Ctrl-C → 全体中止 + if reset is menu.MENU_BACK: + return _ARG_CANCEL # Esc → サブメニューへ戻る + return _dispatch(devbase_root, "init", reset=reset) + + if op == "list": + return _run_list(devbase_root) + + if op == "set": + return _run_set(devbase_root) + + if op == "get": + return _run_get(devbase_root) + + if op == "delete": + key = menu.text("削除する変数名", allow_empty=False) + if key is None: + return None # Ctrl-C → 全体中止 + if key is menu.MENU_BACK: + return _ARG_CANCEL # Esc → サブメニューへ戻る + # 破壊的操作のため実行前に確認する (plan 3.4)。拒否 (False) / Esc は実行せず + # サブメニューへ戻る。MENU_BACK は truthy のため is 判定を not より先に行う。 + ok = menu.confirm(f"変数 '{key}' をグローバル .env から削除しますか?", + default=False) + if ok is None: + return None # Ctrl-C → 全体中止 + if ok is menu.MENU_BACK or not ok: + return _ARG_CANCEL # Esc / 拒否 → 実行しない + return _dispatch(devbase_root, "delete", key=key) + + if op == "project": + # プロジェクト固有変数の対話設定。projects/ 配下で動く CWD スコープ操作の + # ため、対象を選ばせて chdir してから実行する (plan 3.3)。 + name = _select_project(devbase_root) + if name is None: + return None # Ctrl-C → 全体中止 + if name is _ARG_CANCEL: + return _ARG_CANCEL + return _run_in_project(devbase_root, name, + lambda: _dispatch(devbase_root, "project")) + + if op == "export": + # 主要引数 dest のみ収集。空入力は parser 既定 (./devbase-env-.dbenv)。 + dest = menu.path("出力先パス (空で既定: ./devbase-env-<タイムスタンプ>.dbenv)", + allow_empty=True) + if dest is None: + return None # Ctrl-C → 全体中止 + if dest is menu.MENU_BACK: + return _ARG_CANCEL # Esc → サブメニューへ戻る + return _dispatch(devbase_root, "export", dest=(dest or None), + **_export_default_attrs()) + + if op == "import": + # 主要引数 source のみ収集 (必須 positional)。merge は parser 既定の + # keep-existing (既存キー優先) で安全側。既存 .env はハンドラ側で + # バックアップされる。 + source = menu.path("インポートするバンドルのパス", allow_empty=False) + if source is None: + return None # Ctrl-C → 全体中止 + if source is menu.MENU_BACK: + return _ARG_CANCEL # Esc → サブメニューへ戻る + return _dispatch(devbase_root, "import", source=source, + **_import_default_attrs()) + + # 到達しない (メニュー値は _ENV_OPS に限定される)。保守的に no-op。 + logger.error("未知の操作です: %s", op) + return _ARG_CANCEL + + +def run(devbase_root: Path): + """環境変数カテゴリのエントリ。操作選択 → 引数収集 → cmd_env へ委譲。 + + 戻り値プロトコル (トップループが ``is`` 同一性で判定する。actions_project と同一): + - **操作を実行した場合**: dispatch の rc (``int``) を返す。「実行したのでトップへ + 戻る、rc は呼び出し側が記憶」の意味で、失敗が ``devbase list`` の終了コードへ + 伝搬する。 + - ``menu.MENU_BACK``: サブメニューで Esc/← (操作なしでトップへ)。 + - ``None``: Ctrl-C による全体中止。 + + 引数収集を中止 (``_ARG_CANCEL``) した場合はサブメニューを再表示する。 + """ + while True: + op = _select_action() + if op is menu.MENU_BACK: + return menu.MENU_BACK + if op is None: + return None + rc = _run_operation(devbase_root, op) + if rc is _ARG_CANCEL: + continue # 引数収集を中止 → サブメニューへ戻る + return rc # 実行 rc → トップへ復帰 (呼び出し側が記憶) diff --git a/lib/devbase/tui/actions_plugin.py b/lib/devbase/tui/actions_plugin.py new file mode 100644 index 0000000..901d85f --- /dev/null +++ b/lib/devbase/tui/actions_plugin.py @@ -0,0 +1,377 @@ +"""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``) / ``""`` (all_label 選択 = 全対象。呼び出し側で ``None`` + へ変換) / ``None`` (Ctrl-C → 全体中止を呼び出し元へ伝搬) / ``_ARG_CANCEL`` + (Esc・← → サブメニューへ戻る)。 + """ + 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 None: + return None # Ctrl-C → 全体中止 (ナビ規約) + if sel is menu.MENU_BACK: + return _ARG_CANCEL # Esc/← → サブメニューを再表示 + return sel # "" = 全対象 (呼び出し側で 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`` (Esc で引数収集を中止 = + サブメニューへ戻る) / ``None`` (選択・入力中の Ctrl-C → 全体中止)。 + 破壊的な uninstall は ``menu.confirm`` で確認する (plan 3.4)。 + """ + if op == "list": + # --available: 導入済み一覧の代わりに未導入の利用可能 plugin を表示する。 + available = menu.confirm( + "未導入の利用可能 plugin を表示しますか (--available)?", default=False) + if available is None: + return None # Ctrl-C → 全体中止 + if available is menu.MENU_BACK: + return _ARG_CANCEL # Esc → サブメニューへ戻る + return _dispatch(devbase_root, "list", available=available) + + if op == "install": + source = menu.text( + "インストールする plugin の source (名前 / URL / パス)", + allow_empty=False) + if source is None: + return None # Ctrl-C → 全体中止 + if source is menu.MENU_BACK: + return _ARG_CANCEL # Esc → サブメニューへ戻る + link = menu.confirm( + "symlink としてインストールしますか (--link)?", default=False) + if link is None: + return None # Ctrl-C → 全体中止 + if link is menu.MENU_BACK: + return _ARG_CANCEL # Esc → サブメニューへ戻る + install_all = menu.confirm( + "リポジトリ内の全 plugin をインストールしますか (--all)?", default=False) + if install_all is None: + return None # Ctrl-C → 全体中止 + if install_all is menu.MENU_BACK: + return _ARG_CANCEL # Esc → サブメニューへ戻る + 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 None: + return None # Ctrl-C → 全体中止 + if name is _ARG_CANCEL: + return _ARG_CANCEL + ok = menu.confirm(f"plugin '{name}' をアンインストールしますか?", default=False) + if ok is None: + return None # Ctrl-C → 全体中止 + if ok is menu.MENU_BACK or not ok: + return _ARG_CANCEL # Esc / 拒否 → 実行しない + 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 None: + return None # Ctrl-C → 全体中止 + if name is _ARG_CANCEL: + return _ARG_CANCEL + return _dispatch(devbase_root, "update", name=name or None) + + if op == "info": + name = _select_installed_plugin( + devbase_root, "詳細を表示する plugin を選択") + if name is None: + return None # Ctrl-C → 全体中止 + 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 None # Ctrl-C → 全体中止 + if url is menu.MENU_BACK: + return _ARG_CANCEL # Esc → サブメニューへ戻る + # --name は任意 (空で URL から自動命名)。空文字は None へ変換して渡す。 + name = menu.text("カスタム名 (--name 空で自動)", allow_empty=True) + if name is None: + return None # Ctrl-C → 全体中止 + if name is menu.MENU_BACK: + return _ARG_CANCEL # Esc → サブメニューへ戻る + 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 None: + return None # Ctrl-C → 全体中止 + if name is _ARG_CANCEL: + return _ARG_CANCEL + ok = menu.confirm(f"リポジトリ '{name}' を削除しますか?", default=False) + if ok is None: + return None # Ctrl-C → 全体中止 + if ok is menu.MENU_BACK or not ok: + return _ARG_CANCEL # Esc / 拒否 → 実行しない + force = menu.confirm( + "未 commit / 未 push の変更があっても強制削除しますか (--force)?", + default=False) + if force is None: + return None # Ctrl-C → 全体中止 + if force is menu.MENU_BACK: + return _ARG_CANCEL # Esc → サブメニューへ戻る + 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 None: + return None # Ctrl-C → 全体中止 + if name is _ARG_CANCEL: + return _ARG_CANCEL + return _dispatch(devbase_root, "repo", + repo_command="refresh", name=name or None) + + # 到達しない (メニュー値は _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/actions_project.py b/lib/devbase/tui/actions_project.py new file mode 100644 index 0000000..bf2792b --- /dev/null +++ b/lib/devbase/tui/actions_project.py @@ -0,0 +1,319 @@ +"""project カテゴリの TUI 操作フロー (PLAN31_2 PR1: 既存挙動の非回帰移送)。 + +旧 ``commands/project.py`` の ``_tui_select_and_up`` / ``_show_menu`` / +``_show_action_menu`` / ``_fallback_select_and_up`` をこのモジュールへ移送し、 +メニュー部品は ``tui.menu`` に、ハンドラ委譲は ``tui.dispatch`` に一般化した。 + +PR1 で **一覧選択 → (running なら操作サブメニュー) → それ以外は直接 up** を移送し、 +PR2 で running 操作サブメニューを **up/down/login/ps/logs/scale/build/rebuild の全操作** +へ拡張した。login/ps/logs/scale は running 中コンテナを対象とするため running 行限定、 +stopped/unknown は従来どおり直接 up (PR1 非回帰)。引数を要する操作は ``tui.menu`` の +収集ヘルパで CLI と同じ属性値を集め、破壊的な down は ``menu.confirm`` で確認する +(plan 2.3 契約表 / 3.4 破壊的操作確認)。 + +一覧表示・整形 (``list_projects`` / ``_build_menu_entries``) は ``commands/project`` +の純粋ロジックを再利用する (TUI からも CLI(table) からも共有)。 +""" + +from __future__ import annotations + +from pathlib import Path + +from devbase.commands.project import ( + _STATUS_COLOR, + _build_menu_entries, + list_projects, +) +from devbase.log import get_logger +from devbase.tui import menu +from devbase.tui.dispatch import dispatch_lifecycle + +logger = get_logger(__name__) + + +def _select_project(rows: list[dict]): + """一覧から 1 件選ばせ rows の index を返す。Esc → ``MENU_BACK`` / Ctrl-C → ``None``。 + + 件数が多いため文字入力での絞り込み (search=True) を有効にする。search 有効時は + ← が入力カーソル移動と衝突するため戻る操作は Esc のみ (menu.select が調整する)。 + """ + entries = _build_menu_entries(rows, colorize=_STATUS_COLOR) + choices = [(entry, i) for i, entry in enumerate(entries)] + return menu.select( + "操作するプロジェクトを選択 " + "(↑↓ 移動 / 名前で絞り込み / Enter 決定 / Esc 戻る / Ctrl-C 中止):", + choices, back=True, search=True) + + +# running 行で選べる操作 (表示順 = ハイライト既定順)。up を先頭に置き、PR1 同様 +# Enter 連打で再起動へ到達できるようにする。各 value は cmd_project のサブコマンド名。 +_RUNNING_OPS: list[tuple[str, str]] = [ + ("再起動 (up)", "up"), + ("停止 (down)", "down"), + ("ログイン (login)", "login"), + ("コンテナ状態 (ps)", "ps"), + ("ログ表示 (logs)", "logs"), + ("スケール変更 (scale)", "scale"), + ("イメージビルド (build)", "build"), + ("再ビルド (rebuild --no-cache)", "rebuild"), +] + +# 引数収集を Esc で中止したことを示す番兵 (= サブメニューへ戻る)。 +# dispatch の rc (int) や ``None`` (= Ctrl-C 全体中止) と区別する。 +_ARG_CANCEL = object() + +# _optional_int の Ctrl-C 番兵。「空入力 (None = 既定動作)」と衝突するため +# ``None`` を直接返せず、専用番兵で返して呼び出し側で ``None`` (全体中止) へ変換する。 +_ABORT = object() + + +def _select_action(name: str): + """running 中プロジェクトの操作を選ぶサブメニュー。 + + 戻り値: サブコマンド文字列 / ``MENU_BACK`` (Esc・← → 一覧へ戻る) / ``None`` (Ctrl-C 中止)。 + """ + return menu.select( + f"'{name}' は起動中です。操作を選択 " + "(↑↓ 移動 / Enter 決定 / ←・Esc 戻る / Ctrl-C 中止):", + list(_RUNNING_OPS), back=True, search=False) + + +def _optional_int(message: str, *, min_value: int = 0): + """空入力を許す整数収集 (logs --tail 等)。 + + 戻り値: ``int`` / ``None`` (空入力 = 既定動作) / ``_ARG_CANCEL`` (Esc → サブ + メニューへ戻る) / ``_ABORT`` (Ctrl-C → 全体中止。空入力の ``None`` と衝突する + ため専用番兵で返し、呼び出し側で ``None`` へ変換する)。 + 非数値・``min_value`` 未満は再入力を促す。``menu.integer`` は空入力で既定値を返す + 仕様のため、空 = None を表現したい optional な数値はこちらで扱う。``min_value`` の + 既定は 0 で、logs --tail に負数を渡して docker compose をエラーにするのを防ぐ。 + """ + while True: + raw = menu.text(message, allow_empty=True) + if raw is None: + return _ABORT # Ctrl-C → 全体中止 (呼び出し側で None へ変換) + if raw is menu.MENU_BACK: + return _ARG_CANCEL # Esc → サブメニューへ戻る + if raw == "": + return None + try: + value = int(raw) + except ValueError: + logger.error("整数で指定してください: %r", raw) + continue + if value < min_value: + logger.error("%d 以上で指定してください。", min_value) + continue + return value + + +def _select_build_image(devbase_root: Path): + """build 対象イメージを選ぶ。``containers//Dockerfile`` を列挙する。 + + 戻り値: イメージ名 (``str``) / ``""`` (compose.yml 全体ビルド。呼び出し側で + ``None`` へ変換) / ``None`` (Ctrl-C → 全体中止を呼び出し元へ伝搬) / + ``_ARG_CANCEL`` (Esc・← → サブメニューへ戻る)。``containers/`` が無い / 空なら + compose.yml 全体ビルド (``""``) にフォールバックする。 + """ + containers_dir = Path(devbase_root) / "containers" + images = sorted( + d.name for d in containers_dir.iterdir() + if d.is_dir() and (d / "Dockerfile").exists() + ) if containers_dir.is_dir() else [] + + if not images: + # 個別イメージが無ければ compose.yml 全体ビルド ("" = image なし) のみ。 + return "" + + # value="" を「compose.yml 全体」に割り当て、選択メニューの None (Ctrl-C = + # 全体中止) と衝突させない。呼び出し側で空文字 → None へ変換する。 + choices = [("compose.yml 全体をビルド", "")] + [(img, img) for img in images] + sel = menu.select( + "ビルドするイメージを選択 (↑↓ 移動 / Enter 決定 / ←・Esc 戻る / Ctrl-C 中止):", + choices, back=True, search=False) + if sel is None: + return None # Ctrl-C → 全体中止 (ナビ規約) + if sel is menu.MENU_BACK: + return _ARG_CANCEL # Esc/← → サブメニューを再表示 + return sel # "" = compose 全体 (呼び出し側で None へ変換) + + +def _run_operation(devbase_root: Path, name: str, op: str): + """選択された操作の引数を収集して ``dispatch_lifecycle`` で起動する。 + + 戻り値: dispatch の rc (``int``) / ``_ARG_CANCEL`` (Esc で引数収集を中止 = + サブメニューへ戻る) / ``None`` (選択・入力中の Ctrl-C → 全体中止)。 + 引数を要さない up/rebuild は即実行。down は破壊的のため ``menu.confirm`` で確認する。 + """ + if op in ("up", "rebuild"): + # up は scale 属性を参照する (常に None。他コマンドは無視する)。 + return dispatch_lifecycle(op, name, scale=None) + + if op == "down": + ok = menu.confirm(f"'{name}' のコンテナを停止しますか?", default=False) + if ok is None: + return None # Ctrl-C → 全体中止 + if ok is menu.MENU_BACK or not ok: + return _ARG_CANCEL # Esc / 拒否 → 実行しない + return dispatch_lifecycle("down", name) + + if op == "login": + # menu.text は空入力 (既定値を消して確定) で "" を返し、wrapper で --index= + # と展開されてコマンドが失敗する。menu.integer なら空入力は default=1 を返し、 + # min_value=1 で正の整数を保証する。cmd_login の index は文字列契約なので str 化。 + index = menu.integer("ログインするコンテナ番号", default=1, min_value=1) + if index is None: + return None # Ctrl-C → 全体中止 + if index is menu.MENU_BACK: + return _ARG_CANCEL # Esc → サブメニューへ戻る + return dispatch_lifecycle("login", name, index=str(index)) + + if op == "ps": + all_c = menu.confirm("停止中も含め全コンテナを表示しますか (--all)?", default=False) + if all_c is None: + return None # Ctrl-C → 全体中止 + if all_c is menu.MENU_BACK: + return _ARG_CANCEL # Esc → サブメニューへ戻る + return dispatch_lifecycle("ps", name, all=all_c) + + if op == "logs": + follow = menu.confirm("ログを追従表示しますか (--follow)?", default=False) + if follow is None: + return None # Ctrl-C → 全体中止 + if follow is menu.MENU_BACK: + return _ARG_CANCEL # Esc → サブメニューへ戻る + tail = _optional_int("末尾何行を表示しますか (空で全件)") + if tail is _ABORT: + return None # Ctrl-C → 全体中止 + if tail is _ARG_CANCEL: + return _ARG_CANCEL # Esc → サブメニューへ戻る + return dispatch_lifecycle("logs", name, follow=follow, tail=tail) + + if op == "scale": + new_scale = menu.integer(f"'{name}' の新しいコンテナ数", min_value=1) + if new_scale is None: + return None # Ctrl-C → 全体中止 + if new_scale is menu.MENU_BACK: + return _ARG_CANCEL # Esc → サブメニューへ戻る + return dispatch_lifecycle("scale", name, new_scale=new_scale) + + if op == "build": + image = _select_build_image(devbase_root) + if image is None: + return None # Ctrl-C → 全体中止 + if image is _ARG_CANCEL: + return _ARG_CANCEL + return dispatch_lifecycle("build", name, image=image or None) + + # 到達しない (メニュー値は _RUNNING_OPS に限定される)。保守的に no-op。 + logger.error("未知の操作です: %s", op) + return _ARG_CANCEL + + +def _operation_menu(devbase_root: Path, name: str): + """running 行の操作サブメニューを回す。 + + 戻り値プロトコル (run と同じ ``is`` 同一性判定): + - dispatch の rc (``int``): 操作を実行 → 呼び出し元へ (最終的にトップへ復帰)。 + - ``menu.MENU_BACK``: Esc/← で一覧へ戻る。 + - ``None``: Ctrl-C で全体中止。 + + 引数収集を中止 (``_ARG_CANCEL``) した場合はサブメニューを再表示する。 + """ + while True: + op = _select_action(name) + if op is menu.MENU_BACK: + return menu.MENU_BACK + if op is None: + return None + rc = _run_operation(devbase_root, name, op) + if rc is _ARG_CANCEL: + continue # 引数収集を中止 → サブメニューへ戻る + return rc # 実行 rc → 呼び出し元へ + + +def run(devbase_root: Path): + """プロジェクト操作カテゴリ。一覧選択 → (running は操作サブメニュー / 他は up)。 + + 戻り値プロトコル (トップループが ``is`` 同一性で判定する): + - **操作を実行した場合**: ``dispatch_lifecycle`` の rc (``int``) を返す。 + 「実行したのでトップへ戻る、rc は呼び出し側が記憶」の意味。これにより + project 操作の失敗が ``devbase list`` の終了コードへ伝搬する。 + - ``menu.MENU_BACK``: 一覧で Esc/← (操作なしでトップへ) / プロジェクト無し。 + - ``None``: 一覧・サブメニューで Ctrl-C による全体中止。 + + 選択行が running 中なら ``_operation_menu`` で全操作を選ばせ、それ以外 + (stopped / unknown) は従来どおり直接 ``project up`` を起動する (PR1 非回帰)。 + 操作完了後はトップメニューへ復帰する (plan 3.5 状態遷移: Exec → Top)。 + """ + projects_dir = Path(devbase_root) / "projects" + while True: + rows = list_projects(projects_dir) + if not rows: + logger.info("プロジェクトがありません (%s)。", projects_dir) + return menu.MENU_BACK + + idx = _select_project(rows) + if idx is menu.MENU_BACK: + return menu.MENU_BACK + if idx is None: + return None # Ctrl-C → 全体中止 + + row = rows[idx] + name = row["name"] + if str(row.get("status", "")).startswith("running"): + rc = _operation_menu(devbase_root, name) + if rc is menu.MENU_BACK: + continue # 一覧へ戻る + if rc is None: + return None # Ctrl-C → 全体中止 + else: + rc = dispatch_lifecycle("up", name, scale=None) + + # 操作完了 → トップメニューへ復帰。rc は呼び出し側 (top loop) が記憶し + # 最終的な devbase の終了コードへ伝搬させる。 + return rc + + +def fallback_select_and_up(rows: list[dict]) -> int: + """番号入力で 1 件選択し ``project up `` を起動する (questionary 未導入時)。 + + 旧 ``project.py:_fallback_select_and_up`` の非回帰移送。questionary 不在環境では + トップ階層メニューを出さず、この従来フロー (番号入力 → up) に縮退して muscle-memory + を保全する。外部依存を増やさず stdlib ``input()`` で実装する。空入力は中止、非 TTY + (EOFError) はエラー終了 (rc=1)、Ctrl-C は中止 (rc=0)。 + """ + print("起動するプロジェクトを選択してください:") + for i, r in enumerate(rows, 1): + print(f" [{i}] {r['name']} ({r['plugin']}, {r['status']})") + + # 一覧取得が重い場合があるため、誤入力 (数値以外 / 範囲外) では即終了せず再入力を促す。 + while True: + try: + raw = input("番号 (空で中止): ").strip() + except EOFError: + logger.error("対話入力ができません (非 TTY 環境)。" + "`devbase project up ` で直接指定してください。") + return 1 + except KeyboardInterrupt: + print() + logger.info("中止しました。") + return 0 + + if not raw: + logger.info("中止しました。") + return 0 + + try: + idx = int(raw) + except ValueError: + logger.error("番号で指定してください: %r", raw) + continue + + if not (1 <= idx <= len(rows)): + logger.error("範囲外の番号です: %d (1〜%d)", idx, len(rows)) + continue + + break + + return dispatch_lifecycle("up", rows[idx - 1]["name"], scale=None) diff --git a/lib/devbase/tui/actions_snapshot.py b/lib/devbase/tui/actions_snapshot.py new file mode 100644 index 0000000..59332ea --- /dev/null +++ b/lib/devbase/tui/actions_snapshot.py @@ -0,0 +1,251 @@ +"""snapshot カテゴリの TUI 操作フロー (PLAN31_2 PR5)。 + +サブコマンド選択メニュー → 引数収集 → ``dispatch_group(cmd_snapshot, ...)`` で +既存ハンドラへ委譲する。属性契約は plan 2.3 の表 (cli.py ``_add_snapshot_parser`` +と同期済みを確認): + +- create: ``name`` (None=タイムスタンプ自動命名), ``full`` (False) +- list: 追加属性なし +- restore: ``name``, ``point`` (None=全差分適用 / manager は 1 以上のみ受理) +- copy: ``name``, ``new_name`` +- delete: ``name`` +- rotate: ``keep`` (3) + +破壊的な restore / delete は実行前に ``menu.confirm`` で確認する (plan 3.4)。 +restore は ``cmd_snapshot`` 側にも TTY 時の input() 確認が残るが、TUI の規約として +メニュー段階でも確認する (多重確認になっても安全側に倒す)。 + +restore / copy / delete の対象 ``name`` は ``SnapshotManager.list()`` の既存一覧 +から選択させる (タイプミス防止)。一覧の取得に失敗した場合のみ自由入力へ縮退する。 +""" + +from __future__ import annotations + +from pathlib import Path + +from devbase.commands.snapshot import cmd_snapshot +from devbase.log import get_logger +from devbase.snapshot.manager import SnapshotManager +from devbase.tui import menu +from devbase.tui.dispatch import dispatch_group + +logger = get_logger(__name__) + +# snapshot カテゴリで選べる操作 (表示順 = ハイライト既定順)。閲覧のみで安全な +# list を先頭に置き、Enter 連打では破壊的操作に到達しないようにする。 +# 各 value は cmd_snapshot のサブコマンド名。 +_SNAPSHOT_OPS: list[tuple[str, str]] = [ + ("一覧表示 (list)", "list"), + ("作成 (create)", "create"), + ("復元 (restore)", "restore"), + ("複製 (copy)", "copy"), + ("削除 (delete)", "delete"), + ("ローテーション (rotate)", "rotate"), +] + +# 引数収集を Esc で中止したことを示す番兵 (= サブメニューへ戻る)。 +# dispatch の rc (int) や ``None`` (= Ctrl-C 全体中止) と区別する (actions_project と同じ規約)。 +_ARG_CANCEL = object() + +# _optional_point の Ctrl-C 番兵。「空入力 (None = 全差分適用)」と衝突するため +# ``None`` を直接返せず、専用番兵で返して呼び出し側で ``None`` (全体中止) へ変換する +# (actions_project._ABORT と同じ理由)。 +_ABORT = object() + + +def _select_operation(): + """snapshot の操作を選ぶサブメニュー。 + + 戻り値: サブコマンド文字列 / ``MENU_BACK`` (Esc・← → トップへ戻る) / ``None`` (Ctrl-C 中止)。 + """ + return menu.select( + "スナップショット操作を選択 " + "(↑↓ 移動 / Enter 決定 / ←・Esc 戻る / Ctrl-C 中止):", + list(_SNAPSHOT_OPS), back=True, search=False) + + +def _select_snapshot_name(devbase_root: Path, message: str): + """restore/copy/delete の対象スナップショット名を既存一覧から選ばせる。 + + 戻り値: スナップショット名 (``str``) / ``None`` (Ctrl-C → 全体中止を呼び出し元へ + 伝搬) / ``_ARG_CANCEL`` (Esc → 操作メニューへ戻る、または対象が 1 件もない)。 + 一覧の取得に失敗した場合は自由入力へ縮退する (存在チェックは委譲先の + ``SnapshotManager`` が行う。text 入力も Esc=戻る / Ctrl-C=全体中止を区別する)。 + """ + try: + snapshots = SnapshotManager(Path(devbase_root)).list() + except Exception: + logger.debug("スナップショット一覧の取得に失敗しました", exc_info=True) + snapshots = None + + if snapshots is None: + # 一覧が取れない環境では名前を直接入力させる。 + name = menu.text(message, allow_empty=False) + if name is None: + return None # Ctrl-C → 全体中止 (ナビ規約) + if name is menu.MENU_BACK: + return _ARG_CANCEL # Esc → 操作メニューを再表示 + return name + + if not snapshots: + logger.info("スナップショットがありません。先に作成 (create) してください。") + return _ARG_CANCEL + + # 作成日時を添えて選びやすくする (値は名前のみ)。件数が多い場合に備え + # 文字入力での絞り込み (search=True) を有効化。search 有効時の戻る操作は + # Esc のみ (menu.select が ← バインドを外す)。 + choices = [ + (f"{s.get('name', '?')} ({str(s.get('created_at') or 'N/A')[:19]})", + s.get("name")) + for s in snapshots + ] + sel = menu.select( + f"{message} (↑↓ 移動 / 名前で絞り込み / Enter 決定 / Esc 戻る / Ctrl-C 中止):", + choices, back=True, search=True) + if sel is None: + return None # Ctrl-C → 全体中止 (ナビ規約) + if sel is menu.MENU_BACK: + return _ARG_CANCEL # Esc → 操作メニューを再表示 + return sel + + +def _optional_point(message: str): + """restore の ``--point`` を収集する (空入力 = 全差分適用 = None)。 + + 戻り値: ``int`` / ``None`` (空入力) / ``_ARG_CANCEL`` (Esc → 操作メニューへ + 戻る) / ``_ABORT`` (Ctrl-C → 全体中止。空入力の ``None`` と衝突するため専用 + 番兵で返し、呼び出し側で ``None`` へ変換する)。 + ``menu.integer`` は空入力で既定値を返す仕様のため、空 = None を表現したい + optional な数値はこちらで扱う (actions_project._optional_int と同じ理由)。 + ``SnapshotManager.restore`` は point に正の整数のみ受理するため 1 以上を要求する。 + """ + while True: + raw = menu.text(message, allow_empty=True) + if raw is None: + return _ABORT # Ctrl-C → 全体中止 (呼び出し側で None へ変換) + if raw is menu.MENU_BACK: + return _ARG_CANCEL # Esc → 操作メニューへ戻る + if raw == "": + return None + try: + value = int(raw) + except ValueError: + logger.error("整数で指定してください: %r", raw) + continue + if value < 1: + logger.error("1 以上で指定してください。") + continue + return value + + +def _run_operation(devbase_root: Path, op: str): + """選択された操作の引数を収集して ``dispatch_group`` で ``cmd_snapshot`` へ委譲する。 + + 戻り値: dispatch の rc (``int``) / ``_ARG_CANCEL`` (Esc で引数収集を中止 = + サブメニューへ戻る) / ``None`` (選択・入力中の Ctrl-C → 全体中止)。 + 破壊的な restore / delete は ``menu.confirm`` で確認し、拒否時は実行しない (plan 3.4)。 + """ + if op == "list": + return dispatch_group(cmd_snapshot, devbase_root, "list") + + if op == "create": + name = menu.text("スナップショット名 (空でタイムスタンプ自動命名)", + allow_empty=True) + if name is None: + return None # Ctrl-C → 全体中止 + if name is menu.MENU_BACK: + return _ARG_CANCEL # Esc → 操作メニューへ戻る + full = menu.confirm("フルバックアップを強制しますか (--full)?", default=False) + if full is None: + return None # Ctrl-C → 全体中止 + if full is menu.MENU_BACK: + return _ARG_CANCEL # Esc → 操作メニューへ戻る + # 空入力は CLI の --name 省略と同じ None (自動命名) に正規化する。 + return dispatch_group(cmd_snapshot, devbase_root, "create", + name=name or None, full=full) + + if op == "restore": + name = _select_snapshot_name(devbase_root, "復元するスナップショットを選択") + if name is None: + return None # Ctrl-C → 全体中止 + if name is _ARG_CANCEL: + return _ARG_CANCEL + point = _optional_point("適用する差分番号 incr-N の上限 (--point / 空で全差分適用)") + if point is _ABORT: + return None # Ctrl-C → 全体中止 + if point is _ARG_CANCEL: + return _ARG_CANCEL # Esc → 操作メニューへ戻る + point_msg = f" (incr-{point:03d} まで)" if point is not None else "" + ok = menu.confirm( + f"'{name}'{point_msg} から復元しますか? 現在のボリュームデータは上書きされます。", + default=False) + if ok is None: + return None # Ctrl-C → 全体中止 + if ok is menu.MENU_BACK or not ok: + return _ARG_CANCEL # Esc / 拒否 → 実行しない + return dispatch_group(cmd_snapshot, devbase_root, "restore", + name=name, point=point) + + if op == "copy": + name = _select_snapshot_name(devbase_root, "複製元のスナップショットを選択") + if name is None: + return None # Ctrl-C → 全体中止 + if name is _ARG_CANCEL: + return _ARG_CANCEL + new_name = menu.text("複製先のスナップショット名", allow_empty=False) + if new_name is None: + return None # Ctrl-C → 全体中止 + if new_name is menu.MENU_BACK: + return _ARG_CANCEL # Esc → 操作メニューへ戻る + return dispatch_group(cmd_snapshot, devbase_root, "copy", + name=name, new_name=new_name) + + if op == "delete": + name = _select_snapshot_name(devbase_root, "削除するスナップショットを選択") + if name is None: + return None # Ctrl-C → 全体中止 + if name is _ARG_CANCEL: + return _ARG_CANCEL + ok = menu.confirm(f"スナップショット '{name}' を削除しますか?", default=False) + if ok is None: + return None # Ctrl-C → 全体中止 + if ok is menu.MENU_BACK or not ok: + return _ARG_CANCEL # Esc / 拒否 → 実行しない + return dispatch_group(cmd_snapshot, devbase_root, "delete", name=name) + + if op == "rotate": + # keep=0 は manager 実装上 no-op (空スライス) のため 1 以上を要求する。 + keep = menu.integer("保持する世代数 (--keep)", default=3, min_value=1) + if keep is None: + return None # Ctrl-C → 全体中止 + if keep is menu.MENU_BACK: + return _ARG_CANCEL # Esc → 操作メニューへ戻る + return dispatch_group(cmd_snapshot, devbase_root, "rotate", keep=keep) + + # 到達しない (メニュー値は _SNAPSHOT_OPS に限定される)。保守的に no-op。 + logger.error("未知の操作です: %s", op) + return _ARG_CANCEL + + +def run(devbase_root: Path): + """スナップショット操作カテゴリ。操作選択 → 引数収集 → 実行。 + + 戻り値プロトコル (トップループが ``is`` 同一性で判定する。actions_project と同じ): + - **操作を実行した場合**: ``dispatch_group`` の rc (``int``) を返す。 + 「実行したのでトップへ戻る、rc は呼び出し側が記憶」の意味。 + - ``menu.MENU_BACK``: 操作メニューで Esc/← (操作なしでトップへ)。 + - ``None``: Ctrl-C による全体中止。 + + 引数収集を中止 (``_ARG_CANCEL``) した場合は操作メニューを再表示する。 + 操作完了後はトップメニューへ復帰する (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 + rc = _run_operation(devbase_root, op) + if rc is _ARG_CANCEL: + continue # 引数収集を中止 → 操作メニューへ戻る + return rc # 実行 rc → 呼び出し元 (トップ) へ diff --git a/lib/devbase/tui/actions_status.py b/lib/devbase/tui/actions_status.py new file mode 100644 index 0000000..3ea2454 --- /dev/null +++ b/lib/devbase/tui/actions_status.py @@ -0,0 +1,18 @@ +"""status カテゴリの TUI 操作フロー (PLAN31_2 PR5)。 + +status は閲覧のみでサブコマンドも引数も持たない (``cmd_status(devbase_root)`` / +plan 2.2)。そのためメニューや引数収集を介さず、表示してそのまま rc を返す +薄い委譲に留める。rc (``int``) を返すことで「操作を実行した → トップへ復帰」 +の戻り値プロトコル (actions_project と同じ) に従い、トップループが rc を記憶する。 +""" + +from __future__ import annotations + +from pathlib import Path + +from devbase.commands.status import cmd_status + + +def run(devbase_root: Path) -> int: + """ステータスを表示し、rc を返してトップメニューへ復帰する。""" + return cmd_status(Path(devbase_root)) diff --git a/lib/devbase/tui/app.py b/lib/devbase/tui/app.py new file mode 100644 index 0000000..3ceb103 --- /dev/null +++ b/lib/devbase/tui/app.py @@ -0,0 +1,136 @@ +"""トップ階層メニューとカテゴリ routing (`devbase list` の入口)。 + +``run(devbase_root, args)`` が ``cmd_project_list`` から呼ばれる新しい入口。 +プロジェクト一覧の選択だけだった旧挙動を、全カテゴリ +(project / env / plugin / snapshot / status) を束ねるトップ階層メニューへ拡張する。 + +PR1 で project、PR3 で env、PR4 で plugin、PR5 で snapshot/status を配線済みで、 +全カテゴリがトップ階層メニューから実行できる。 + +後方互換 (plan 3.2): +- ``--no-interactive`` / ``--plain`` (interactive=False) と非 TTY は従来どおり一覧 + テーブルのみ。 +- questionary 不在時はトップメニューを出さず、従来の番号入力フォールバック + (project up) へ縮退して muscle-memory を保全する。 +- トップメニューでは「プロジェクト操作」を先頭に置き既定ハイライトとすることで、 + Enter 連打で従来の project 選択フローへ到達できるようにする。 + +ナビ規約: トップメニューは Esc / Ctrl-C で中止 (戻り先なし)。各カテゴリ内では +Esc / ← でトップメニューへ戻る (``menu.MENU_BACK``)、Ctrl-C で全体中止 (``None``)。 +""" + +from __future__ import annotations + +import sys +from pathlib import Path + +from devbase.commands.project import _print_table, list_projects +from devbase.log import get_logger +from devbase.tui import (actions_env, actions_plugin, actions_project, + actions_snapshot, actions_status, menu) + +logger = get_logger(__name__) + +# トップメニューのカテゴリ (表示順 = ハイライト既定順)。先頭の「プロジェクト操作」を +# 既定ハイライトにして従来フローへ Enter 連打で到達できるようにする (plan 3.2)。 +TOP_CATEGORIES: list[tuple[str, str]] = [ + ("project", "プロジェクト操作"), + ("env", "環境変数"), + ("plugin", "プラグイン"), + ("snapshot", "スナップショット"), + ("status", "ステータス"), +] + +_LABELS = dict(TOP_CATEGORIES) + + +def _route(category: str, devbase_root: Path): + """選択カテゴリのハンドラを呼ぶ。 + + 戻り値は各カテゴリの戻り値プロトコルに従う: + - 操作実行時はその rc (``int``) + - 操作なしでトップへ戻るときは ``menu.MENU_BACK`` + - Ctrl-C 全体中止のときは ``None`` + + 後続 PR は対応する ``actions_*`` の呼び出しをここに 1 行追加する + (各カテゴリ別ファイルのため衝突しにくい)。未実装カテゴリは ``MENU_BACK``。 + """ + if category == "project": + return actions_project.run(devbase_root) + if category == "env": + return actions_env.run(devbase_root) + if category == "plugin": + return actions_plugin.run(devbase_root) + if category == "snapshot": + return actions_snapshot.run(devbase_root) + if category == "status": + return actions_status.run(devbase_root) + logger.info("「%s」は後続 PR で実装予定です。", _LABELS.get(category, category)) + return menu.MENU_BACK + + +def _top_menu_loop(devbase_root: Path) -> int: + """トップ階層メニューのループ。 + + 最後に実行した操作の rc (``last_rc``) を記憶し、中止時はそれを返すことで + ``project up/down/rebuild`` の失敗が ``devbase list`` の終了コードへ伝搬する。 + 操作を何もしなかった場合 (Esc/Ctrl-C のみ) は ``last_rc`` の初期値 0。 + + 判定は必ず ``is`` 同一性で行う (rc=0 を ``None`` / ``MENU_BACK`` と誤マッチさせない)。 + """ + last_rc = 0 + while True: + choice = menu.select( + "操作カテゴリを選択 (↑↓ 移動 / Enter 決定 / Esc・Ctrl-C 中止):", + list(TOP_CATEGORIES), back=False, search=False) + if choice is None: + # トップで Esc / Ctrl-C → これまでの実行 rc を返して終了 + logger.info("中止しました。") + return last_rc + + result = _route(choice, devbase_root) + if result is None: + # カテゴリ内で Ctrl-C → 全体中止 (直近の実行 rc を返す) + logger.info("中止しました。") + return last_rc + if result is menu.MENU_BACK: + # 操作なしでトップへ戻り再表示 (rc は更新しない) + continue + # int rc: 操作を実行した → rc を記憶してトップ再表示 + last_rc = result + + +def run(devbase_root: Path, args) -> int: + """`devbase list` / `devbase project list` の入口。 + + - interactive=False / 非 TTY: 一覧テーブルのみ (従来挙動)。 + - questionary 不在: 番号入力フォールバック (project up) へ縮退。 + - それ以外: トップ階層メニューを開く。 + """ + projects_dir = Path(devbase_root) / "projects" + + # 対話はデフォルト ON。非 TTY (パイプ / CI / リダイレクト) は表示・読取りできない + # ため一覧表示へフォールバックする (stdin/stdout いずれかが非 TTY なら縮退)。 + interactive = (getattr(args, "interactive", True) + and sys.stdin.isatty() and sys.stdout.isatty()) + + if not interactive: + rows = list_projects(projects_dir) + if not rows: + logger.info("プロジェクトがありません (%s)。", projects_dir) + return 0 + _print_table(rows) + return 0 + + if not menu.HAVE_QUESTIONARY: + logger.warning( + "questionary が未導入のため番号入力にフォールバックします " + "(`uv sync` で導入すると階層メニューが使えます)。" + ) + rows = list_projects(projects_dir) + if not rows: + logger.info("プロジェクトがありません (%s)。", projects_dir) + return 0 + return actions_project.fallback_select_and_up(rows) + + return _top_menu_loop(devbase_root) diff --git a/lib/devbase/tui/dispatch.py b/lib/devbase/tui/dispatch.py new file mode 100644 index 0000000..590e4b8 --- /dev/null +++ b/lib/devbase/tui/dispatch.py @@ -0,0 +1,74 @@ +"""既存コマンドハンドラへの薄い委譲層。 + +TUI は CLI のロジックを再実装せず、``types.SimpleNamespace`` を組んで既存ハンドラ +(``cmd_project`` / ``cmd_env`` / ``cmd_plugin`` / ``cmd_snapshot`` 等) をそのまま呼ぶ。 +旧 ``project.py:_start_project_action`` を一般化したもの: + +- ``dispatch_lifecycle``: project ライフサイクル (up/down/login/.../rebuild)。 + ``cmd_project`` は ``args`` 1 つだけを取り、``name`` が真なら ``_dispatch_lifecycle`` + が ``projects/`` へ chdir してからサブコマンドを実行する (PLAN06 機構)。 +- ``dispatch_group``: env / plugin / snapshot 等の ``handler(devbase_root, args)`` + シグネチャを持つグループハンドラ向け (PR3 以降で使用)。 + +属性契約は ``issues/PLAN31_2_list-tui-unified.md`` 2.3 の表に従う。CLI 実行と差異を +出さないため、呼び出し側が CLI parser の既定値どおりの属性を ``**attrs`` で渡す。 +""" + +from __future__ import annotations + +import contextlib +import os +import types +from pathlib import Path +from typing import Callable + + +@contextlib.contextmanager +def _preserve_cwd_env(): + """ハンドラ実行前後で CWD と ``os.environ`` を保存・復元する。 + + CLI 経路は 1 コマンド = 1 プロセスのため、``_resolve_project_name`` が行う + ``os.chdir`` / env 反映 / ``COMPOSE_PROJECT_NAME`` 上書きはプロセス終了で消える。 + 一方 TUI は同一プロセスでトップメニューへ復帰し操作を続行するため、復元しないと + 直前プロジェクトの CWD / 環境変数 (PWD 含む) を後続操作 (env get 等) が参照して + しまう (PR #55 round1 codex/gemini major 指摘)。委譲チョークポイントである本層で + 一括復元し、各 actions_* / 共有ハンドラへ復元処理を散らさない。 + """ + old_cwd = os.getcwd() + old_env = os.environ.copy() + try: + yield + finally: + with contextlib.suppress(OSError): + os.chdir(old_cwd) + os.environ.clear() + os.environ.update(old_env) + + +def dispatch_lifecycle(subcommand: str, name: str | None = None, **attrs) -> int: + """``project [name]`` を共有ハンドラ ``cmd_project`` 経由で起動する。 + + ``name`` が指定されると ``_dispatch_lifecycle`` が対象ディレクトリへ chdir して + から実行する。``up`` の ``scale`` など各サブコマンド固有の属性は ``attrs`` で渡す + (未指定でも getattr の既定で吸収される)。chdir / env 変更は TUI セッションへ + 残留させないよう ``_preserve_cwd_env`` で実行後に復元する。 + """ + from devbase.commands.container import cmd_project + + ns = types.SimpleNamespace(subcommand=subcommand, name=name, **attrs) + with _preserve_cwd_env(): + return cmd_project(ns) + + +def dispatch_group(handler: Callable[[Path, object], int], devbase_root: Path, + subcommand: str, **attrs) -> int: + """``handler(devbase_root, args)`` 形式のグループハンドラを起動する。 + + env / plugin / snapshot の各 ``cmd_*`` は ``(devbase_root, args)`` を取り、 + ``args.subcommand`` で分岐する。TUI はサブコマンドと属性を ``SimpleNamespace`` に + 詰めてそのまま委譲する (PR3 以降の actions_* が利用)。現行ハンドラは CWD / + environ を変更しないが、lifecycle 側と契約を揃えるため同じく復元境界を張る。 + """ + ns = types.SimpleNamespace(subcommand=subcommand, **attrs) + with _preserve_cwd_env(): + return handler(devbase_root, ns) diff --git a/lib/devbase/tui/menu.py b/lib/devbase/tui/menu.py new file mode 100644 index 0000000..90d3077 --- /dev/null +++ b/lib/devbase/tui/menu.py @@ -0,0 +1,286 @@ +"""TUI メニューエンジン (questionary ラッパ + 引数収集ヘルパ)。 + +``commands/project.py`` にあった以下の資産を PLAN31_2 で集約・一般化した: + +- ``MENU_BACK`` 番兵 (旧 ``_MENU_BACK``) +- Esc / ← のキーバインド (旧 ``_with_escape_cancel`` / ``_with_escape_back``) +- 選択メニュー ``select`` (旧 ``_show_menu`` / ``_show_action_menu`` の共通部) +- 引数収集ヘルパ ``text`` / ``confirm`` / ``integer`` / ``path`` + (PR2 以降の各カテゴリ操作が CLI と同じ属性値を集めるために使う) + +questionary (prompt_toolkit ベース) は任意依存。未導入環境では ``HAVE_QUESTIONARY`` +が ``False`` になり、選択メニューは利用できない (app 側で番号入力フォールバックへ +縮退する)。引数収集ヘルパは questionary 不在時 stdlib ``input()`` で代替する。 + +ナビ規約 (旧 project.py から踏襲): +- Esc = 1 つ前のメニューへ戻る (サブメニュー / 引数入力プロンプト) / 中止 (トップメニュー) +- ← (Left) = 1 つ前のメニューへ戻る (検索絞り込みを使わないメニューのみ即時応答) +- Ctrl-C = 全体中止 (questionary 既定で ``ask()`` が ``None`` を返す) + +引数収集ヘルパ (text/confirm/path/integer) も選択メニューと同じく Esc (``MENU_BACK``) +と Ctrl-C (``None``) を区別して返す。呼び出し側 (actions_*) は ``None`` をトップ +ループまで伝搬して全体中止し、``MENU_BACK`` でサブメニューを再表示する。 + +テストではこのモジュールの関数を monkeypatch して questionary の実起動を避ける。 +""" + +from __future__ import annotations + +from devbase.log import get_logger + +logger = get_logger(__name__) + +# questionary は任意依存。未導入時は選択メニュー不可 / 引数収集は input() 代替。 +try: + import questionary + HAVE_QUESTIONARY = True +except ImportError: # pragma: no cover - 未導入環境のフォールバック経路 + questionary = None + HAVE_QUESTIONARY = False + +# サブメニューで Esc / ← を押した際の「1 つ前のメニューへ戻る」シグナル。 +# ``None`` (= Ctrl-C による全体中止) と区別するための番兵。 +MENU_BACK = object() + + +# --------------------------------------------------------------------------- +# キーバインド (Esc / ←) +# --------------------------------------------------------------------------- + +def _add_escape_binding(question, handler): + """questionary の select に Esc 単独押下のハンドラを後付けする共通処理。 + + questionary 2.x の select は Ctrl-C / Ctrl-Q しか割り当てないため、生成済み + ``Question.application`` の key_bindings に Escape ハンドラを足す。 + + Escape は矢印キー等のエスケープシーケンス (``\\x1b[A`` 等) の先頭バイトでも + あるため、``eager=False`` で登録し prompt_toolkit のフラッシュ待ちで単独 Esc + のみを拾う (矢印キー移動と衝突させない)。 + """ + from prompt_toolkit.keys import Keys + + question.application.key_bindings.add(Keys.Escape)(handler) + return question + + +def with_escape_cancel(question): + """Esc 単独押下で中止する question を返す (トップメニュー用)。 + + Ctrl-C と同じく ``KeyboardInterrupt`` で抜けるので ``ask()`` は ``None`` + (= 中止) を返す。戻り先が無い最上位メニューで使う。 + """ + def _cancel(event): + event.app.exit(exception=KeyboardInterrupt, style="class:aborting") + + return _add_escape_binding(question, _cancel) + + +def _ask_with_escape(question): + """Esc→``MENU_BACK`` を仕込んでから ``ask()`` する共通ヘルパ (text/confirm/path 用)。 + + questionary の text/confirm/path は既定で Esc バインドを持たないため、サブメニューの + ナビ規約 (Esc=1 つ前へ戻る / Ctrl-C=全体中止) と整合させるべく ``with_escape_back`` + を適用してから問い合わせる。← は入力カーソル移動と衝突するためバインドしない。 + 戻り値: 入力値 / ``MENU_BACK`` (Esc) / ``None`` (Ctrl-C)。 + """ + return with_escape_back(question, bind_left=False).ask() + + +def with_escape_back(question, *, bind_left: bool = True): + """Esc (と任意で ←) 押下で ``MENU_BACK`` を返す question を返す (サブメニュー / + 引数収集プロンプト用)。 + + Ctrl-C は questionary 既定どおり中止 (``ask()`` が ``None``) のまま残し、Esc + (と ←) を「1 つ前のメニューへ戻る」シグナルに割り当てる。 + + Esc (``\\x1b``) は矢印キーのエスケープシーケンスの先頭バイトと衝突するため + prompt_toolkit のフラッシュ待ち分の遅延が体感される。左矢印 (``\\x1b[D``) は + 完結した曖昧さの無いシーケンスなので、これを主たる「戻る」キーとして即時に + 反応させる。ただし検索絞り込み (use_search_filter) を使うメニューでは ← が + 入力カーソル移動と衝突するため、``bind_left=False`` で Esc のみに留める。 + """ + from prompt_toolkit.keys import Keys + + def _back(event): + event.app.exit(result=MENU_BACK) + + _add_escape_binding(question, _back) # Esc(互換・低速) + if bind_left: + question.application.key_bindings.add(Keys.Left)(_back) # ←(即時) + return question + + +# --------------------------------------------------------------------------- +# 選択メニュー +# --------------------------------------------------------------------------- + +def select(message: str, choices, *, back: bool = False, search: bool = False): + """questionary の select を起動し、選択値を返す共通関数。 + + Parameters + ---------- + message: プロンプト文言。 + choices: ``questionary.Choice`` のリスト、または ``(title, value)`` タプルの + リスト。後者は内部で ``Choice`` に変換する。 + back: True ならサブメニュー扱いで Esc/← → ``MENU_BACK`` を返す + (``with_escape_back``)。False ならトップメニュー扱いで Esc → 中止 + (``with_escape_cancel``)。 + search: True なら文字入力での部分一致絞り込み (use_search_filter) を有効化する。 + 件数の多い一覧 (プロジェクト選択等) 向け。search 有効時は ← が入力 + カーソル移動と衝突するため、back の ← バインドは無効化し Esc のみで戻る。 + + Returns + ------- + 選択された Choice の ``value`` / ``MENU_BACK`` (back かつ Esc・←) / ``None`` + (Ctrl-C、または back=False で Esc 中止)。 + + テストではこの関数自体を monkeypatch して questionary の実起動を避ける。 + """ + norm = [ + c if isinstance(c, questionary.Choice) + else questionary.Choice(title=c[0], value=c[1]) + for c in choices + ] + question = questionary.select( + message, + choices=norm, + use_arrow_keys=True, + # use_search_filter と use_jk_keys は併用不可。検索有効時のみ filter を使う。 + use_jk_keys=False, + use_search_filter=search, + use_shortcuts=False, + ) + if back: + # search 有効時は ← を入力カーソル用に空けておく (Esc のみで戻る)。 + question = with_escape_back(question, bind_left=not search) + else: + question = with_escape_cancel(question) + return question.ask() + + +# --------------------------------------------------------------------------- +# 引数収集ヘルパ (PR2 以降の各カテゴリ操作が CLI 相当の属性値を集めるのに使う) +# --------------------------------------------------------------------------- + +def text(message: str, *, default: str | None = None, + allow_empty: bool = True): + """自由入力 (1 行) を収集する。 + + 戻り値: 入力文字列 / ``MENU_BACK`` (Esc → 1 つ前のメニューへ戻る) / ``None`` + (Ctrl-C → 全体中止)。``allow_empty=False`` のとき空文字は受け付けず再入力を促す。 + questionary 不在時は stdlib ``input()`` で代替する (Esc は検出できないため + EOF / Ctrl-C のどちらも ``None`` = 中止)。 + """ + if HAVE_QUESTIONARY: + while True: # 空 (allow_empty=False) は再入力。自己再帰を避け while で回す。 + ans = _ask_with_escape(questionary.text(message, default=default or "")) + if ans is None or ans is MENU_BACK: + return ans # None=Ctrl-C 全体中止 / MENU_BACK=Esc 戻る + ans = ans.strip() + if not ans and not allow_empty: + logger.error("値を入力してください。") + continue + return ans + return _input_text(message, default=default, allow_empty=allow_empty) + + +def confirm(message: str, *, default: bool = False): + """y/n 確認を取る。 + + 戻り値: ``bool`` / ``MENU_BACK`` (Esc → 1 つ前のメニューへ戻る) / ``None`` + (Ctrl-C → 全体中止)。破壊的操作 (down / delete / uninstall 等) の実行前確認に + 使う。``MENU_BACK`` は truthy のため、呼び出し側は ``not ok`` 判定の前に必ず + ``is`` 同一性で番兵を判定すること。 + """ + if HAVE_QUESTIONARY: + return _ask_with_escape(questionary.confirm(message, default=default)) + return _input_confirm(message, default=default) + + +def integer(message: str, *, default: int | None = None, + min_value: int | None = None, max_value: int | None = None): + """整数を収集する (scale 等)。範囲外・非数値は再入力を促す。 + + 戻り値: ``int`` / ``MENU_BACK`` (Esc → 1 つ前のメニューへ戻る) / ``None`` + (Ctrl-C → 全体中止)。 + """ + default_str = "" if default is None else str(default) + while True: + raw = text(message, default=default_str, allow_empty=default is not None) + if raw is None or raw is MENU_BACK: + return raw # None=Ctrl-C 全体中止 / MENU_BACK=Esc 戻る + if raw == "" and default is not None: + return default + try: + value = int(raw) + except ValueError: + logger.error("整数で指定してください: %r", raw) + continue + if min_value is not None and value < min_value: + logger.error("%d 以上で指定してください。", min_value) + continue + if max_value is not None and value > max_value: + logger.error("%d 以下で指定してください。", max_value) + continue + return value + + +def path(message: str, *, default: str | None = None, + allow_empty: bool = True): + """ファイル / ディレクトリパスを収集する (export/import の dest/source 等)。 + + questionary 利用時は ``path`` プロンプト (補完付き)、不在時は ``input()`` 代替。 + 存在チェックは呼び出し側 (各ハンドラ) に委ねる。戻り値: パス文字列 / + ``MENU_BACK`` (Esc → 1 つ前のメニューへ戻る) / ``None`` (Ctrl-C → 全体中止)。 + """ + if HAVE_QUESTIONARY: + while True: # 空 (allow_empty=False) は再入力。自己再帰を避け while で回す。 + ans = _ask_with_escape(questionary.path(message, default=default or "")) + if ans is None or ans is MENU_BACK: + return ans # None=Ctrl-C 全体中止 / MENU_BACK=Esc 戻る + ans = ans.strip() + if not ans and not allow_empty: + logger.error("パスを入力してください。") + continue + return ans + return _input_text(message, default=default, allow_empty=allow_empty) + + +# --------------------------------------------------------------------------- +# input() フォールバック (questionary 不在時) +# --------------------------------------------------------------------------- + +def _input_text(message: str, *, default: str | None, + allow_empty: bool) -> str | None: + """``input()`` ベースの自由入力。EOF / Ctrl-C は中止 (``None``)。""" + suffix = f" [{default}]" if default else "" + while True: + try: + raw = input(f"{message}{suffix}: ").strip() + except (EOFError, KeyboardInterrupt): + print() + return None + if not raw and default is not None: + return default + if not raw and not allow_empty: + logger.error("値を入力してください。") + continue + return raw + + +def _input_confirm(message: str, *, default: bool) -> bool | None: + """``input()`` ベースの y/n 確認。EOF / Ctrl-C は中止 (``None``)。""" + hint = "Y/n" if default else "y/N" + while True: + try: + raw = input(f"{message} [{hint}]: ").strip().lower() + except (EOFError, KeyboardInterrupt): + print() + return None + if not raw: + return default + if raw in ("y", "yes"): + return True + if raw in ("n", "no"): + return False + logger.error("y / n で答えてください: %r", raw) diff --git a/tests/cli/test_project_list.py b/tests/cli/test_project_list.py index 88207dc..ac16dd9 100644 --- a/tests/cli/test_project_list.py +++ b/tests/cli/test_project_list.py @@ -1,24 +1,25 @@ -"""PLAN06 Task 3: `project list` 一覧表示 + `--interactive` 選択起動のテスト +"""PLAN06 Task 3 / PLAN31_2: `project list` 一覧表示 + 整形ロジックのテスト -検証対象: +検証対象 (listing / 整形の純粋ロジック。対話 TUI は tests/cli/tui/ に分離): - `lib/devbase/commands/project.py` - `_resolve_plugin_name`: symlink 先から plugin 名を解決する (衝突 suffix 耐性) - `list_projects`: projects/ 配下を NAME/PLUGIN/STATUS で列挙する - - `cmd_project_list`: table 表示 / `--interactive` での選択起動 + - `_build_menu_entries` / `_color_status`: メニュー表示文字列の整形 + - `cmd_project_list`: table 表示 (非対話) / tui.run への委譲 - `lib/devbase/commands/status.py` - `_container_status_for`: per-entry status 抽出後の回帰 - `lib/devbase/cli.py` - `project list` parser / dispatch ルーティング / トップレベル `list` シノニム / prefix 解決 + +PLAN31_2 で対話メニュー (questionary ベース) は `devbase.tui` パッケージへ移送した。 +それらのテストは `tests/cli/tui/` を参照。 """ from __future__ import annotations -import os import types from pathlib import Path -import pytest - from devbase import cli @@ -192,7 +193,7 @@ def test_list_projects_empty_when_no_projects_dir(tmp_path): # --------------------------------------------------------------------------- -# cmd_project_list: table 出力 +# cmd_project_list: table 出力 (非対話) / 委譲 # --------------------------------------------------------------------------- def test_cmd_project_list_prints_table(tmp_path, monkeypatch, capsys): @@ -222,215 +223,21 @@ def test_cmd_project_list_empty(tmp_path, capsys): assert rc == 0 -def test_cmd_project_list_non_tty_falls_back_to_table(tmp_path, monkeypatch, capsys): - """interactive=True (デフォルト) でも非 TTY では一覧表示にフォールバックする。""" - from devbase.commands import project as project_mod - from devbase.commands import status as status_mod - from devbase.commands import container as container_mod - - _make_plugin_project(tmp_path, "repos/o--r/alpha", "alpha-proj") - _link_project(tmp_path, "alpha-proj", "repos/o--r/alpha", "alpha-proj") - monkeypatch.setattr(status_mod, "_container_status_for", - lambda entry, counts=None: {"name": entry.name, "status": "stopped", "count": 0}) - monkeypatch.setattr(project_mod.sys.stdin, "isatty", lambda: False) - - called = [] - monkeypatch.setattr(container_mod, "cmd_project", lambda args: called.append(1) or 0) - - args = types.SimpleNamespace(interactive=True) - rc = project_mod.cmd_project_list(tmp_path, args) - out = capsys.readouterr().out - - assert rc == 0 - assert called == [], "非 TTY では対話起動しない" - assert "alpha-proj" in out - - -def test_cmd_project_list_stdout_non_tty_falls_back_to_table(tmp_path, monkeypatch, capsys): - """stdin が TTY でも stdout が非 TTY (`devbase list | cat` / `> out.txt`) なら - 対話起動せず一覧表示へフォールバックする。""" - from devbase.commands import project as project_mod - from devbase.commands import status as status_mod - from devbase.commands import container as container_mod - - _make_plugin_project(tmp_path, "repos/o--r/alpha", "alpha-proj") - _link_project(tmp_path, "alpha-proj", "repos/o--r/alpha", "alpha-proj") - monkeypatch.setattr(status_mod, "_container_status_for", - lambda entry, counts=None: {"name": entry.name, "status": "stopped", "count": 0}) - monkeypatch.setattr(project_mod.sys.stdin, "isatty", lambda: True) - monkeypatch.setattr(project_mod.sys.stdout, "isatty", lambda: False) - - called = [] - monkeypatch.setattr(container_mod, "cmd_project", lambda args: called.append(1) or 0) - - args = types.SimpleNamespace(interactive=True) - rc = project_mod.cmd_project_list(tmp_path, args) - out = capsys.readouterr().out - - assert rc == 0 - assert called == [], "stdout 非 TTY では対話起動しない" - assert "alpha-proj" in out - - -# --------------------------------------------------------------------------- -# cmd_project_list: --interactive -# --------------------------------------------------------------------------- - -def test_cmd_project_list_interactive_selects_and_ups(tmp_path, monkeypatch): - from devbase.commands import project as project_mod - from devbase.commands import status as status_mod - from devbase.commands import container as container_mod - - _make_plugin_project(tmp_path, "repos/o--r/alpha", "alpha-proj") - _link_project(tmp_path, "alpha-proj", "repos/o--r/alpha", "alpha-proj") - _make_plugin_project(tmp_path, "plugins/beta", "beta-proj") - _link_project(tmp_path, "beta-proj", "plugins/beta", "beta-proj") - monkeypatch.setattr(status_mod, "_container_status_for", lambda entry, counts=None: None) - - # 対話選択は TTY 環境でのみ起動するため isatty を True に固定する。 - monkeypatch.setattr(project_mod.sys.stdin, "isatty", lambda: True) - monkeypatch.setattr(project_mod.sys.stdout, "isatty", lambda: True) - monkeypatch.setattr(project_mod, "_HAVE_QUESTIONARY", False) - # 番号 "2" を選択 (sorted: alpha-proj=1, beta-proj=2) - monkeypatch.setattr("builtins.input", lambda *a, **k: "2") - - captured = {} - monkeypatch.setattr(container_mod, "cmd_project", - lambda args: captured.update( - subcommand=args.subcommand, name=args.name) or 0) - - args = types.SimpleNamespace(interactive=True) - rc = project_mod.cmd_project_list(tmp_path, args) - - assert rc == 0 - assert captured["subcommand"] == "up" - assert captured["name"] == "beta-proj" - - -def test_cmd_project_list_interactive_empty_input_aborts(tmp_path, monkeypatch): - from devbase.commands import project as project_mod - from devbase.commands import status as status_mod - from devbase.commands import container as container_mod - - _make_plugin_project(tmp_path, "repos/o--r/alpha", "alpha-proj") - _link_project(tmp_path, "alpha-proj", "repos/o--r/alpha", "alpha-proj") - monkeypatch.setattr(status_mod, "_container_status_for", lambda entry, counts=None: None) - monkeypatch.setattr(project_mod.sys.stdin, "isatty", lambda: True) - monkeypatch.setattr(project_mod.sys.stdout, "isatty", lambda: True) - monkeypatch.setattr(project_mod, "_HAVE_QUESTIONARY", False) - monkeypatch.setattr("builtins.input", lambda *a, **k: "") - - called = [] - monkeypatch.setattr(container_mod, "cmd_project", lambda args: called.append(1) or 0) - - args = types.SimpleNamespace(interactive=True) - rc = project_mod.cmd_project_list(tmp_path, args) - assert rc == 0 - assert called == [], "空入力では up を起動しない" - - -def test_cmd_project_list_interactive_non_tty_eof(tmp_path, monkeypatch): - """非対話環境 (input が EOFError) では up を起動せずエラー終了する。""" +def test_cmd_project_list_delegates_to_tui_run(tmp_path, monkeypatch): + """cmd_project_list は devbase.tui.run へ委譲する薄いラッパであること。""" from devbase.commands import project as project_mod - from devbase.commands import status as status_mod - from devbase.commands import container as container_mod + from devbase import tui as tui_pkg - _make_plugin_project(tmp_path, "repos/o--r/alpha", "alpha-proj") - _link_project(tmp_path, "alpha-proj", "repos/o--r/alpha", "alpha-proj") - monkeypatch.setattr(status_mod, "_container_status_for", lambda entry, counts=None: None) - - def raise_eof(*a, **k): - raise EOFError - - monkeypatch.setattr(project_mod.sys.stdin, "isatty", lambda: True) - monkeypatch.setattr(project_mod.sys.stdout, "isatty", lambda: True) - monkeypatch.setattr(project_mod, "_HAVE_QUESTIONARY", False) - monkeypatch.setattr("builtins.input", raise_eof) - called = [] - monkeypatch.setattr(container_mod, "cmd_project", lambda args: called.append(1) or 0) - - args = types.SimpleNamespace(interactive=True) - rc = project_mod.cmd_project_list(tmp_path, args) - assert rc == 1 - assert called == [] - - -def test_cmd_project_list_interactive_keyboard_interrupt_aborts(tmp_path, monkeypatch): - """Ctrl+C (KeyboardInterrupt) は traceback を出さず中止 (rc=0) として扱う。""" - from devbase.commands import project as project_mod - from devbase.commands import status as status_mod - from devbase.commands import container as container_mod - - _make_plugin_project(tmp_path, "repos/o--r/alpha", "alpha-proj") - _link_project(tmp_path, "alpha-proj", "repos/o--r/alpha", "alpha-proj") - monkeypatch.setattr(status_mod, "_container_status_for", lambda entry, counts=None: None) - - def raise_interrupt(*a, **k): - raise KeyboardInterrupt - - monkeypatch.setattr(project_mod.sys.stdin, "isatty", lambda: True) - monkeypatch.setattr(project_mod.sys.stdout, "isatty", lambda: True) - monkeypatch.setattr(project_mod, "_HAVE_QUESTIONARY", False) - monkeypatch.setattr("builtins.input", raise_interrupt) - called = [] - monkeypatch.setattr(container_mod, "cmd_project", lambda args: called.append(1) or 0) - - args = types.SimpleNamespace(interactive=True) - rc = project_mod.cmd_project_list(tmp_path, args) - assert rc == 0 - assert called == [] - - -def test_cmd_project_list_interactive_out_of_range_reprompts(tmp_path, monkeypatch): - """範囲外の番号では即終了せず再入力を促す。有効入力で最終的に up する。""" - from devbase.commands import project as project_mod - from devbase.commands import status as status_mod - from devbase.commands import container as container_mod - - _make_plugin_project(tmp_path, "repos/o--r/alpha", "alpha-proj") - _link_project(tmp_path, "alpha-proj", "repos/o--r/alpha", "alpha-proj") - monkeypatch.setattr(status_mod, "_container_status_for", lambda entry, counts=None: None) - - monkeypatch.setattr(project_mod.sys.stdin, "isatty", lambda: True) - monkeypatch.setattr(project_mod.sys.stdout, "isatty", lambda: True) - monkeypatch.setattr(project_mod, "_HAVE_QUESTIONARY", False) - # "99" (範囲外) → "1" (有効) の順に入力 → 再入力後に up が起動する - inputs = iter(["99", "1"]) - monkeypatch.setattr("builtins.input", lambda *a, **k: next(inputs)) captured = {} - monkeypatch.setattr(container_mod, "cmd_project", - lambda args: captured.update(name=args.name) or 0) + monkeypatch.setattr(tui_pkg, "run", + lambda root, args: captured.update(root=root, args=args) or 0) args = types.SimpleNamespace(interactive=True) rc = project_mod.cmd_project_list(tmp_path, args) - assert rc == 0 - assert captured["name"] == "alpha-proj" - - -def test_cmd_project_list_interactive_non_numeric_reprompts(tmp_path, monkeypatch): - """数値以外の入力では即終了せず再入力を促す。""" - from devbase.commands import project as project_mod - from devbase.commands import status as status_mod - from devbase.commands import container as container_mod - - _make_plugin_project(tmp_path, "repos/o--r/alpha", "alpha-proj") - _link_project(tmp_path, "alpha-proj", "repos/o--r/alpha", "alpha-proj") - monkeypatch.setattr(status_mod, "_container_status_for", lambda entry, counts=None: None) - - monkeypatch.setattr(project_mod.sys.stdin, "isatty", lambda: True) - monkeypatch.setattr(project_mod.sys.stdout, "isatty", lambda: True) - monkeypatch.setattr(project_mod, "_HAVE_QUESTIONARY", False) - # "abc" (数値以外) → "1" (有効) - inputs = iter(["abc", "1"]) - monkeypatch.setattr("builtins.input", lambda *a, **k: next(inputs)) - captured = {} - monkeypatch.setattr(container_mod, "cmd_project", - lambda args: captured.update(name=args.name) or 0) - args = types.SimpleNamespace(interactive=True) - rc = project_mod.cmd_project_list(tmp_path, args) assert rc == 0 - assert captured["name"] == "alpha-proj" + assert captured["root"] == Path(tmp_path) + assert captured["args"] is args # --------------------------------------------------------------------------- @@ -572,7 +379,7 @@ def test_get_container_status_uses_per_entry(tmp_path, monkeypatch): # --------------------------------------------------------------------------- -# TUI: _build_menu_entries / _color_status +# 整形: _build_menu_entries / _color_status # --------------------------------------------------------------------------- def test_build_menu_entries_number_label_and_mapping(): @@ -618,256 +425,3 @@ def test_build_menu_entries_plain_has_no_ansi(): entries = _build_menu_entries(rows, colorize=False) assert "\033[" not in entries[0] - - -# --------------------------------------------------------------------------- -# TUI: _interactive_select_and_up のディスパッチ (TUI 経路) -# --------------------------------------------------------------------------- - -def test_cmd_project_list_tui_selects_and_ups(tmp_path, monkeypatch): - from devbase.commands import project as project_mod - from devbase.commands import status as status_mod - from devbase.commands import container as container_mod - - _make_plugin_project(tmp_path, "repos/o--r/alpha", "alpha-proj") - _link_project(tmp_path, "alpha-proj", "repos/o--r/alpha", "alpha-proj") - _make_plugin_project(tmp_path, "plugins/beta", "beta-proj") - _link_project(tmp_path, "beta-proj", "plugins/beta", "beta-proj") - monkeypatch.setattr(status_mod, "_container_status_for", lambda entry, counts=None: None) - monkeypatch.setattr(project_mod.sys.stdin, "isatty", lambda: True) - monkeypatch.setattr(project_mod.sys.stdout, "isatty", lambda: True) - - # questionary 導入済み相当にし、メニューは index=1 (beta-proj) を返すよう差し替え - monkeypatch.setattr(project_mod, "_HAVE_QUESTIONARY", True) - monkeypatch.setattr(project_mod, "_show_menu", lambda rows: 1) - - captured = {} - monkeypatch.setattr(container_mod, "cmd_project", - lambda args: captured.update( - subcommand=args.subcommand, name=args.name) or 0) - - args = types.SimpleNamespace(interactive=True) - rc = project_mod.cmd_project_list(tmp_path, args) - - assert rc == 0 - assert captured["subcommand"] == "up" - assert captured["name"] == "beta-proj" - - -def test_cmd_project_list_tui_abort_returns_zero(tmp_path, monkeypatch): - from devbase.commands import project as project_mod - from devbase.commands import status as status_mod - from devbase.commands import container as container_mod - - _make_plugin_project(tmp_path, "repos/o--r/alpha", "alpha-proj") - _link_project(tmp_path, "alpha-proj", "repos/o--r/alpha", "alpha-proj") - monkeypatch.setattr(status_mod, "_container_status_for", lambda entry, counts=None: None) - monkeypatch.setattr(project_mod.sys.stdin, "isatty", lambda: True) - monkeypatch.setattr(project_mod.sys.stdout, "isatty", lambda: True) - monkeypatch.setattr(project_mod, "_HAVE_QUESTIONARY", True) - # ESC 等での中止は _show_menu が None を返す - monkeypatch.setattr(project_mod, "_show_menu", lambda rows: None) - - called = [] - monkeypatch.setattr(container_mod, "cmd_project", lambda args: called.append(1) or 0) - - args = types.SimpleNamespace(interactive=True) - rc = project_mod.cmd_project_list(tmp_path, args) - - assert rc == 0 - assert called == [], "中止時は up を起動しない" - - -@pytest.mark.parametrize("action", ["up", "rebuild", "down"]) -def test_tui_running_row_shows_action_menu(monkeypatch, action): - """running 行を選ぶとサブメニューで up/rebuild/down を選び、その subcommand で起動する。""" - from devbase.commands import project as project_mod - from devbase.commands import container as container_mod - - rows = [{"name": "carmo", "plugin": "-", "status": "running (2 containers)"}] - monkeypatch.setattr(project_mod, "_show_menu", lambda rows: 0) - seen = {} - monkeypatch.setattr(project_mod, "_show_action_menu", - lambda name: seen.update(name=name) or action) - - captured = {} - monkeypatch.setattr(container_mod, "cmd_project", - lambda args: captured.update( - subcommand=args.subcommand, name=args.name) or 0) - - rc = project_mod._tui_select_and_up(rows) - assert rc == 0 - assert seen["name"] == "carmo" # action menu に対象名が渡る - assert captured["subcommand"] == action - assert captured["name"] == "carmo" - - -def test_tui_running_action_abort_starts_nothing(monkeypatch): - """running 行のサブメニューを中止 (None) したら何も起動しない。""" - from devbase.commands import project as project_mod - from devbase.commands import container as container_mod - - rows = [{"name": "carmo", "plugin": "-", "status": "running (1 containers)"}] - monkeypatch.setattr(project_mod, "_show_menu", lambda rows: 0) - monkeypatch.setattr(project_mod, "_show_action_menu", lambda name: None) - - called = [] - monkeypatch.setattr(container_mod, "cmd_project", lambda args: called.append(1) or 0) - - assert project_mod._tui_select_and_up(rows) == 0 - assert called == [] - - -@pytest.mark.parametrize("status", ["stopped", "unknown"]) -def test_tui_non_running_row_direct_up(monkeypatch, status): - """非 running 行はサブメニューを出さず従来どおり直接 up する。""" - from devbase.commands import project as project_mod - from devbase.commands import container as container_mod - - rows = [{"name": "carmo", "plugin": "-", "status": status}] - monkeypatch.setattr(project_mod, "_show_menu", lambda rows: 0) - - action_menu_calls = [] - monkeypatch.setattr(project_mod, "_show_action_menu", - lambda name: action_menu_calls.append(name) or "down") - - captured = {} - monkeypatch.setattr(container_mod, "cmd_project", - lambda args: captured.update( - subcommand=args.subcommand, name=args.name) or 0) - - rc = project_mod._tui_select_and_up(rows) - assert rc == 0 - assert action_menu_calls == [], "非 running ではサブメニューを出さない" - assert captured["subcommand"] == "up" - assert captured["name"] == "carmo" - - -def test_with_escape_cancel_registers_escape_binding(): - """_with_escape_cancel が select に単独 Esc 中止バインドを後付けすること。""" - questionary = pytest.importorskip("questionary") - from prompt_toolkit.keys import Keys - - from devbase.commands import project as project_mod - - q = questionary.select("t", choices=[questionary.Choice(title="a", value=0)]) - assert project_mod._with_escape_cancel(q) is q # 同じ question を返す - - esc = [b for b in q.application.key_bindings.bindings if Keys.Escape in b.keys] - assert len(esc) == 1 - # eager=False: 矢印キー等のエスケープシーケンス (\x1b[A 等) の先頭と衝突させない - assert esc[0].eager() is False - - # ハンドラは Ctrl-C と同様 KeyboardInterrupt で app を抜ける (= ask() が None) - captured = {} - fake_app = types.SimpleNamespace(exit=lambda **kw: captured.update(kw)) - esc[0].handler(types.SimpleNamespace(app=fake_app)) - assert captured["exception"] is KeyboardInterrupt - - -@pytest.mark.parametrize("call", [ - lambda m: m._show_menu([{"name": "carmo", "plugin": "-", "status": "stopped"}]), - lambda m: m._show_action_menu("carmo"), -]) -def test_select_menus_wire_escape_binding(monkeypatch, call): - """_show_menu / _show_action_menu が select に Esc バインドを仕込んでから ask する。""" - pytest.importorskip("questionary") - from prompt_toolkit.key_binding import KeyBindings - from prompt_toolkit.keys import Keys - - from devbase.commands import project as project_mod - - kb = KeyBindings() - fake_q = types.SimpleNamespace( - application=types.SimpleNamespace(key_bindings=kb), - ask=lambda: "sentinel", - ) - monkeypatch.setattr(project_mod.questionary, "select", lambda *a, **k: fake_q) - - assert call(project_mod) == "sentinel" - esc = [b for b in kb.bindings if Keys.Escape in b.keys] - assert len(esc) == 1, "Esc バインドが登録されていない" - - -def test_with_escape_back_returns_sentinel_on_escape(): - """_with_escape_back の Esc ハンドラは _MENU_BACK を result として返すこと。""" - questionary = pytest.importorskip("questionary") - from prompt_toolkit.keys import Keys - - from devbase.commands import project as project_mod - - q = questionary.select("t", choices=[questionary.Choice(title="a", value="a")]) - assert project_mod._with_escape_back(q) is q - - esc = [b for b in q.application.key_bindings.bindings if Keys.Escape in b.keys] - assert len(esc) == 1 - assert esc[0].eager() is False # 矢印キーのエスケープシーケンスと衝突させない - - # ハンドラは Ctrl-C (KeyboardInterrupt=全体中止) と異なり _MENU_BACK を返す - captured = {} - fake_app = types.SimpleNamespace(exit=lambda **kw: captured.update(kw)) - esc[0].handler(types.SimpleNamespace(app=fake_app)) - assert captured == {"result": project_mod._MENU_BACK} - - # ← (Left) も「戻る」に割り当て、Esc のフラッシュ待ち遅延を回避して即応させる - left = [b for b in q.application.key_bindings.bindings if Keys.Left in b.keys] - assert len(left) == 1 - captured.clear() - left[0].handler(types.SimpleNamespace(app=fake_app)) - assert captured == {"result": project_mod._MENU_BACK} - - -def test_tui_running_action_escape_returns_to_top_menu(monkeypatch): - """running 行のサブメニューで Esc (_MENU_BACK) を押すとトップメニューへ戻る。""" - from devbase.commands import project as project_mod - from devbase.commands import container as container_mod - - rows = [ - {"name": "carmo", "plugin": "-", "status": "running (1 containers)"}, - {"name": "beta", "plugin": "-", "status": "stopped"}, - ] - # 1 回目: running 行 (idx0) を選ぶ / 2 回目: stopped 行 (idx1) を選ぶ - menu_calls = [] - monkeypatch.setattr( - project_mod, "_show_menu", - lambda rows: (menu_calls.append(1), 0 if len(menu_calls) == 1 else 1)[1]) - # サブメニューでは Esc → _MENU_BACK (トップメニューへ戻る) - monkeypatch.setattr(project_mod, "_show_action_menu", - lambda name: project_mod._MENU_BACK) - - captured = {} - monkeypatch.setattr(container_mod, "cmd_project", - lambda args: captured.update( - subcommand=args.subcommand, name=args.name) or 0) - - rc = project_mod._tui_select_and_up(rows) - assert rc == 0 - assert len(menu_calls) == 2, "Esc でトップメニューが再表示される" - # 2 回目に選んだ stopped 行が直接 up される - assert captured["name"] == "beta" - assert captured["subcommand"] == "up" - - -def test_interactive_falls_back_when_no_terminal_menu(tmp_path, monkeypatch): - """questionary 未導入時は input() 番号入力にフォールバックして up する。""" - from devbase.commands import project as project_mod - from devbase.commands import status as status_mod - from devbase.commands import container as container_mod - - _make_plugin_project(tmp_path, "repos/o--r/alpha", "alpha-proj") - _link_project(tmp_path, "alpha-proj", "repos/o--r/alpha", "alpha-proj") - monkeypatch.setattr(status_mod, "_container_status_for", lambda entry, counts=None: None) - monkeypatch.setattr(project_mod.sys.stdin, "isatty", lambda: True) - monkeypatch.setattr(project_mod.sys.stdout, "isatty", lambda: True) - monkeypatch.setattr(project_mod, "_HAVE_QUESTIONARY", False) - monkeypatch.setattr("builtins.input", lambda *a, **k: "1") - - captured = {} - monkeypatch.setattr(container_mod, "cmd_project", - lambda args: captured.update(name=args.name) or 0) - - args = types.SimpleNamespace(interactive=True) - rc = project_mod.cmd_project_list(tmp_path, args) - - assert rc == 0 - assert captured["name"] == "alpha-proj" diff --git a/tests/cli/tui/__init__.py b/tests/cli/tui/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/cli/tui/test_actions_env.py b/tests/cli/tui/test_actions_env.py new file mode 100644 index 0000000..9f2a300 --- /dev/null +++ b/tests/cli/tui/test_actions_env.py @@ -0,0 +1,751 @@ +"""PLAN31_2 PR3: tui.actions_env (env カテゴリ操作) のテスト。 + +test_actions_project.py のパターンを踏襲し、`menu.*` を monkeypatch して選択値を +注入、`cmd_env` を mock して **plan 2.3 の契約どおりの属性を持つ Namespace** で +呼ばれることを各サブコマンドで検証する。project スコープ操作の chdir →復帰、 +破壊的操作 (delete) の confirm、Esc/←/Ctrl-C の遷移も検証する。 +""" + +from __future__ import annotations + +import os +from pathlib import Path + +import pytest + +from devbase.tui import actions_env, menu + + +def _make_plugin_project(root, plugin_path, proj): + target = root / plugin_path / "projects" / proj + target.mkdir(parents=True, exist_ok=True) + return target + + +def _link_project(root, link_name, plugin_path, proj): + projects_dir = root / "projects" + projects_dir.mkdir(exist_ok=True) + (projects_dir / link_name).symlink_to(Path("..") / plugin_path / "projects" / proj) + + +def _capture_dispatch(monkeypatch): + """cmd_env の呼び出しを (root, 全属性, 実行時 CWD/PWD) でキャプチャするヘルパ。""" + from devbase.commands import env as env_mod + captured = {} + + def _spy(devbase_root, args): + captured["root"] = devbase_root + captured["attrs"] = dict(vars(args)) + captured["cwd"] = os.getcwd() + captured["pwd"] = os.environ.get("PWD") + return 0 + + monkeypatch.setattr(env_mod, "cmd_env", _spy) + return captured + + +# --------------------------------------------------------------------------- +# run(): 操作選択 → 実行 / Esc / Ctrl-C / 引数収集中止 +# --------------------------------------------------------------------------- + +def test_run_executes_and_returns_rc(monkeypatch, tmp_path): + """操作を選んで実行したら dispatch の rc を返す (トップへ復帰)。""" + captured = _capture_dispatch(monkeypatch) + monkeypatch.setattr(actions_env, "_select_action", lambda: "sync") + + assert actions_env.run(tmp_path) == 0 + assert captured["root"] == tmp_path + assert captured["attrs"] == {"subcommand": "sync"} + + +def test_run_propagates_nonzero_dispatch_rc(monkeypatch, tmp_path): + """dispatch が非0 (失敗) を返したら run() もその rc を返す (終了コード伝搬)。""" + from devbase.commands import env as env_mod + monkeypatch.setattr(env_mod, "cmd_env", lambda root, args: 1) + monkeypatch.setattr(actions_env, "_select_action", lambda: "sync") + + assert actions_env.run(tmp_path) == 1 + + +def test_run_back_returns_to_top(monkeypatch, tmp_path): + """サブメニューで Esc/← (MENU_BACK) を押すとトップへ戻る (何も実行しない)。""" + from devbase.commands import env as env_mod + called = [] + monkeypatch.setattr(env_mod, "cmd_env", lambda root, args: called.append(1) or 0) + monkeypatch.setattr(actions_env, "_select_action", lambda: menu.MENU_BACK) + + assert actions_env.run(tmp_path) is menu.MENU_BACK + assert called == [] + + +def test_run_ctrl_c_aborts(monkeypatch, tmp_path): + """サブメニューで Ctrl-C (None) を押すと全体中止 (None を返す)。""" + from devbase.commands import env as env_mod + called = [] + monkeypatch.setattr(env_mod, "cmd_env", lambda root, args: called.append(1) or 0) + monkeypatch.setattr(actions_env, "_select_action", lambda: None) + + assert actions_env.run(tmp_path) is None + assert called == [] + + +def test_run_arg_cancel_reshows_submenu(monkeypatch, tmp_path): + """引数収集を中止 (_ARG_CANCEL) するとサブメニューを再表示し、再選択で実行する。""" + select_calls = [] + # 1 回目: delete を選ぶ (→ 引数収集中止) / 2 回目: sync を選ぶ (→ 実行) + monkeypatch.setattr(actions_env, "_select_action", + lambda: (select_calls.append(1), + "delete" if len(select_calls) == 1 else "sync")[1]) + + run_calls = [] + + def fake_run_op(root, op): + run_calls.append(op) + return actions_env._ARG_CANCEL if op == "delete" else 0 + + monkeypatch.setattr(actions_env, "_run_operation", fake_run_op) + + assert actions_env.run(tmp_path) == 0 + assert run_calls == ["delete", "sync"] + assert len(select_calls) == 2, "引数中止でサブメニューが再表示される" + + +def test_run_propagates_ctrl_c_from_operation(monkeypatch, tmp_path): + """引数収集中の Ctrl-C (None) はサブメニューを再表示せず全体中止を伝搬する。""" + select_calls = [] + monkeypatch.setattr(actions_env, "_select_action", + lambda: select_calls.append(1) or "list") + monkeypatch.setattr(actions_env, "_run_operation", lambda root, op: None) + + assert actions_env.run(tmp_path) is None + assert len(select_calls) == 1, "Ctrl-C でサブメニューを再表示しない" + + +# --------------------------------------------------------------------------- +# _select_action: menu.select への委譲 (全 10 サブコマンドの提示) +# --------------------------------------------------------------------------- + +def test_select_action_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_env._select_action() == "list" + assert captured["back"] is True + assert captured["search"] is False + # 参照系の list を先頭にしつつ env の全 10 サブコマンドを提示する (PR3)。 + assert sorted(captured["values"]) == sorted([ + "init", "list", "set", "get", "delete", + "edit", "sync", "project", "export", "import"]) + assert captured["values"][0] == "list", "Enter 連打で安全な一覧表示に到達できる" + + +# --------------------------------------------------------------------------- +# _run_operation: 引数なし系 (sync / edit / init) +# --------------------------------------------------------------------------- + +def test_run_operation_sync_no_attrs(monkeypatch, tmp_path): + captured = _capture_dispatch(monkeypatch) + assert actions_env._run_operation(tmp_path, "sync") == 0 + assert captured["attrs"] == {"subcommand": "sync"} + + +def test_run_operation_edit_is_global_no_project_select(monkeypatch, tmp_path): + """edit は $DEVBASE_ROOT/.env を開くグローバル操作。プロジェクト選択も chdir もしない。""" + captured = _capture_dispatch(monkeypatch) + monkeypatch.setattr(actions_env, "_select_project", + lambda root: pytest.fail("edit でプロジェクト選択してはいけない")) + + before = os.getcwd() + assert actions_env._run_operation(tmp_path, "edit") == 0 + assert captured["attrs"] == {"subcommand": "edit"} + assert captured["cwd"] == before, "edit は chdir しない" + + +@pytest.mark.parametrize("reset", [True, False]) +def test_run_operation_init_collects_reset(monkeypatch, tmp_path, reset): + """init は confirm の結果を --reset として渡す (plan 2.3: reset 既定 False)。""" + captured = _capture_dispatch(monkeypatch) + monkeypatch.setattr(menu, "confirm", lambda *a, **k: reset) + assert actions_env._run_operation(tmp_path, "init") == 0 + assert captured["attrs"] == {"subcommand": "init", "reset": reset} + + +@pytest.mark.parametrize("confirm_ret", ["BACK", None]) +def test_run_operation_init_cancel(monkeypatch, tmp_path, confirm_ret): + """init の confirm で Esc は再表示 (_ARG_CANCEL)、Ctrl-C は全体中止 (None)。""" + from devbase.commands import env as env_mod + called = [] + monkeypatch.setattr(env_mod, "cmd_env", lambda root, args: called.append(1) or 0) + ret = menu.MENU_BACK if confirm_ret == "BACK" else None + monkeypatch.setattr(menu, "confirm", lambda *a, **k: ret) + expected = actions_env._ARG_CANCEL if confirm_ret == "BACK" else None + assert actions_env._run_operation(tmp_path, "init") is expected + assert called == [] + + +# --------------------------------------------------------------------------- +# _run_operation: list (表示範囲 + reveal/keys) +# --------------------------------------------------------------------------- + +def test_run_operation_list_global_scope_no_chdir(monkeypatch, tmp_path): + """list の「グローバルのみ」は --global へ写像し、プロジェクト選択も chdir もしない。""" + captured = _capture_dispatch(monkeypatch) + monkeypatch.setattr(menu, "select", lambda *a, **k: "global") + monkeypatch.setattr(actions_env, "_select_project", + lambda root: pytest.fail("global でプロジェクト選択してはいけない")) + confirms = iter([True, False]) # reveal=True, keys_only=False + monkeypatch.setattr(menu, "confirm", lambda *a, **k: next(confirms)) + + before = os.getcwd() + assert actions_env._run_operation(tmp_path, "list") == 0 + assert captured["attrs"] == { + "subcommand": "list", + "global_only": True, "project_only": False, + "reveal": True, "keys_only": False, + } + assert captured["cwd"] == before, "global スコープは chdir しない" + + +def test_run_operation_list_both_scope_chdirs_and_restores(monkeypatch, tmp_path): + """list の「グローバル + プロジェクト」も対象を選ばせて chdir + PWD 切替後に実行する。 + + cmd_env_list は PWD が projects/ 配下のときだけプロジェクト .env を表示する + ため、DEVBASE_ROOT のまま global_only=False で呼んでもグローバルしか表示 + されない (codex round3 指摘の回帰テスト)。 + """ + captured = _capture_dispatch(monkeypatch) + target = tmp_path / "projects" / "carmo" + target.mkdir(parents=True) + monkeypatch.setattr(menu, "select", lambda *a, **k: "both") + monkeypatch.setattr(actions_env, "_select_project", lambda root: "carmo") + confirms = iter([True, False]) # reveal=True, keys_only=False + monkeypatch.setattr(menu, "confirm", lambda *a, **k: next(confirms)) + monkeypatch.setenv("PWD", str(tmp_path)) + + before = os.getcwd() + assert actions_env._run_operation(tmp_path, "list") == 0 + assert captured["attrs"] == { + "subcommand": "list", + "global_only": False, "project_only": False, + "reveal": True, "keys_only": False, + } + # ハンドラ実行中は projects/carmo に居る (グローバル + プロジェクト両方が出る) + assert captured["cwd"] == str(target) + assert captured["pwd"] == str(target) + # 実行後は元の CWD / PWD へ復帰する (try/finally) + assert os.getcwd() == before + assert os.environ["PWD"] == str(tmp_path) + + +def test_run_operation_list_project_chdirs_and_restores(monkeypatch, tmp_path): + """list の「プロジェクトのみ」は対象を選ばせて chdir + PWD 切替後に実行し、復帰する。 + + cmd_env_list は PWD が projects/ 配下のときだけプロジェクト .env を表示する + ため、切替なしでは何も表示されない (codex round1 指摘の回帰テスト)。 + """ + captured = _capture_dispatch(monkeypatch) + target = tmp_path / "projects" / "carmo" + target.mkdir(parents=True) + monkeypatch.setattr(menu, "select", lambda *a, **k: "project") + monkeypatch.setattr(actions_env, "_select_project", lambda root: "carmo") + confirms = iter([False, True]) # reveal=False, keys_only=True + monkeypatch.setattr(menu, "confirm", lambda *a, **k: next(confirms)) + monkeypatch.setenv("PWD", str(tmp_path)) + + before = os.getcwd() + assert actions_env._run_operation(tmp_path, "list") == 0 + assert captured["attrs"] == { + "subcommand": "list", + "global_only": False, "project_only": True, + "reveal": False, "keys_only": True, + } + # ハンドラ実行中は projects/carmo に居る (CWD と PWD の両方を切り替える) + assert captured["cwd"] == str(target) + assert captured["pwd"] == str(target) + # 実行後は元の CWD / PWD へ復帰する (try/finally) + assert os.getcwd() == before + assert os.environ["PWD"] == str(tmp_path) + + +def test_run_operation_list_project_select_cancel(monkeypatch, tmp_path): + """list のプロジェクト選択を中止したら表示オプション収集にも進まない。""" + from devbase.commands import env as env_mod + called = [] + monkeypatch.setattr(env_mod, "cmd_env", lambda root, args: called.append(1) or 0) + monkeypatch.setattr(menu, "select", lambda *a, **k: "project") + monkeypatch.setattr(actions_env, "_select_project", + lambda root: actions_env._ARG_CANCEL) + monkeypatch.setattr(menu, "confirm", + lambda *a, **k: pytest.fail("選択中止後に確認を求めない")) + assert actions_env._run_operation(tmp_path, "list") is actions_env._ARG_CANCEL + assert called == [] + + +@pytest.mark.parametrize("scope_ret", ["BACK", None]) +def test_run_operation_list_scope_cancel(monkeypatch, tmp_path, scope_ret): + """表示範囲選択で Esc/← は再表示 (_ARG_CANCEL)、Ctrl-C は全体中止 (None)。""" + from devbase.commands import env as env_mod + called = [] + monkeypatch.setattr(env_mod, "cmd_env", lambda root, args: called.append(1) or 0) + ret = menu.MENU_BACK if scope_ret == "BACK" else None + monkeypatch.setattr(menu, "select", lambda *a, **k: ret) + expected = actions_env._ARG_CANCEL if scope_ret == "BACK" else None + assert actions_env._run_operation(tmp_path, "list") is expected + assert called == [] + + +@pytest.mark.parametrize("confirm_ret", ["BACK", None]) +def test_run_operation_list_confirm_cancel(monkeypatch, tmp_path, confirm_ret): + """reveal の confirm で Esc は再表示 (_ARG_CANCEL)、Ctrl-C は全体中止 (None)。""" + from devbase.commands import env as env_mod + called = [] + monkeypatch.setattr(env_mod, "cmd_env", lambda root, args: called.append(1) or 0) + monkeypatch.setattr(menu, "select", lambda *a, **k: "global") + ret = menu.MENU_BACK if confirm_ret == "BACK" else None + monkeypatch.setattr(menu, "confirm", lambda *a, **k: ret) + expected = actions_env._ARG_CANCEL if confirm_ret == "BACK" else None + assert actions_env._run_operation(tmp_path, "list") is expected + assert called == [] + + +# --------------------------------------------------------------------------- +# _run_operation: get / delete +# --------------------------------------------------------------------------- + +def test_run_operation_get_global_collects_key(monkeypatch, tmp_path): + """グローバル取得は chdir せず key のみ渡して委譲する。""" + captured = _capture_dispatch(monkeypatch) + monkeypatch.setattr(menu, "select", lambda *a, **k: "global") + monkeypatch.setattr(menu, "text", lambda *a, **k: "MY_KEY") + + before = os.getcwd() + assert actions_env._run_operation(tmp_path, "get") == 0 + assert captured["attrs"] == {"subcommand": "get", "key": "MY_KEY"} + assert captured["cwd"] == before, "グローバル取得は chdir しない" + + +def test_run_operation_get_project_chdirs_and_restores(monkeypatch, tmp_path): + """get のプロジェクト取得は対象へ chdir + PWD 切替後に実行し、復帰する。 + + cmd_env_get はグローバル .env に無いキーを CWD (PWD) のプロジェクト .env へ + フォールバックして探すため、切替なしではプロジェクト固有キーを取得できない + (codex round2 指摘の回帰テスト)。 + """ + captured = _capture_dispatch(monkeypatch) + target = tmp_path / "projects" / "carmo" + target.mkdir(parents=True) + monkeypatch.setattr(menu, "select", lambda *a, **k: "project") + monkeypatch.setattr(actions_env, "_select_project", lambda root: "carmo") + monkeypatch.setattr(menu, "text", lambda *a, **k: "DB_HOST") + monkeypatch.setenv("PWD", str(tmp_path)) + + before = os.getcwd() + assert actions_env._run_operation(tmp_path, "get") == 0 + assert captured["attrs"] == {"subcommand": "get", "key": "DB_HOST"} + # ハンドラ実行中は projects/carmo に居る (CWD と PWD の両方を切り替える) + assert captured["cwd"] == str(target) + assert captured["pwd"] == str(target) + # 実行後は元の CWD / PWD へ復帰する (try/finally) + assert os.getcwd() == before + assert os.environ["PWD"] == str(tmp_path) + + +def test_run_operation_get_project_select_cancel(monkeypatch, tmp_path): + """get のプロジェクト選択を中止したらキー入力にも進まない。""" + from devbase.commands import env as env_mod + called = [] + monkeypatch.setattr(env_mod, "cmd_env", lambda root, args: called.append(1) or 0) + monkeypatch.setattr(menu, "select", lambda *a, **k: "project") + monkeypatch.setattr(actions_env, "_select_project", + lambda root: actions_env._ARG_CANCEL) + monkeypatch.setattr(menu, "text", + lambda *a, **k: pytest.fail("選択中止後に入力を求めない")) + assert actions_env._run_operation(tmp_path, "get") is actions_env._ARG_CANCEL + assert called == [] + + +@pytest.mark.parametrize("scope_ret", ["BACK", None]) +def test_run_operation_get_scope_cancel(monkeypatch, tmp_path, scope_ret): + """取得元選択で Esc/← は再表示 (_ARG_CANCEL)、Ctrl-C は全体中止 (None)。""" + from devbase.commands import env as env_mod + called = [] + monkeypatch.setattr(env_mod, "cmd_env", lambda root, args: called.append(1) or 0) + ret = menu.MENU_BACK if scope_ret == "BACK" else None + monkeypatch.setattr(menu, "select", lambda *a, **k: ret) + expected = actions_env._ARG_CANCEL if scope_ret == "BACK" else None + assert actions_env._run_operation(tmp_path, "get") is expected + assert called == [] + + +@pytest.mark.parametrize("text_ret", ["BACK", None]) +def test_run_operation_get_key_cancel(monkeypatch, tmp_path, text_ret): + """キー入力で Esc は再表示 (_ARG_CANCEL)、Ctrl-C は全体中止 (None)。""" + from devbase.commands import env as env_mod + called = [] + monkeypatch.setattr(env_mod, "cmd_env", lambda root, args: called.append(1) or 0) + monkeypatch.setattr(menu, "select", lambda *a, **k: "global") + ret = menu.MENU_BACK if text_ret == "BACK" else None + monkeypatch.setattr(menu, "text", lambda *a, **k: ret) + expected = actions_env._ARG_CANCEL if text_ret == "BACK" else None + assert actions_env._run_operation(tmp_path, "get") is expected + assert called == [] + + +def test_run_operation_delete_confirmed(monkeypatch, tmp_path): + """delete は confirm=True で削除を実行する (plan 3.4 破壊的操作)。""" + captured = _capture_dispatch(monkeypatch) + monkeypatch.setattr(menu, "text", lambda *a, **k: "OLD_KEY") + monkeypatch.setattr(menu, "confirm", lambda *a, **k: True) + assert actions_env._run_operation(tmp_path, "delete") == 0 + assert captured["attrs"] == {"subcommand": "delete", "key": "OLD_KEY"} + + +@pytest.mark.parametrize("confirm_ret", [False, "BACK", None]) +def test_run_operation_delete_cancelled_does_not_dispatch(monkeypatch, tmp_path, + confirm_ret): + """delete の confirm を拒否 (False) / Esc / Ctrl-C したら削除しない。 + + 拒否と Esc はサブメニュー再表示 (_ARG_CANCEL)、Ctrl-C は全体中止 (None)。 + """ + from devbase.commands import env as env_mod + called = [] + monkeypatch.setattr(env_mod, "cmd_env", lambda root, args: called.append(1) or 0) + monkeypatch.setattr(menu, "text", lambda *a, **k: "OLD_KEY") + ret = menu.MENU_BACK if confirm_ret == "BACK" else confirm_ret + monkeypatch.setattr(menu, "confirm", lambda *a, **k: ret) + expected = None if confirm_ret is None else actions_env._ARG_CANCEL + assert actions_env._run_operation(tmp_path, "delete") is expected + assert called == [], "確認を拒否/中止したら delete しない" + + +@pytest.mark.parametrize("text_ret", ["BACK", None]) +def test_run_operation_delete_key_cancel(monkeypatch, tmp_path, text_ret): + """delete のキー入力を中止したら confirm にも進まない。""" + from devbase.commands import env as env_mod + called = [] + monkeypatch.setattr(env_mod, "cmd_env", lambda root, args: called.append(1) or 0) + ret = menu.MENU_BACK if text_ret == "BACK" else None + monkeypatch.setattr(menu, "text", lambda *a, **k: ret) + monkeypatch.setattr(menu, "confirm", + lambda *a, **k: pytest.fail("キー未入力で confirm しない")) + expected = actions_env._ARG_CANCEL if text_ret == "BACK" else None + assert actions_env._run_operation(tmp_path, "delete") is expected + assert called == [] + + +# --------------------------------------------------------------------------- +# _run_operation: set (グローバル / プロジェクト + chdir) +# --------------------------------------------------------------------------- + +def test_run_operation_set_global(monkeypatch, tmp_path): + """グローバル設定は chdir せず project=False で委譲する (plan 2.3)。""" + captured = _capture_dispatch(monkeypatch) + monkeypatch.setattr(menu, "select", lambda *a, **k: "global") + monkeypatch.setattr(menu, "text", lambda *a, **k: "API_KEY=secret") + + before = os.getcwd() + assert actions_env._run_operation(tmp_path, "set") == 0 + assert captured["attrs"] == {"subcommand": "set", + "assignment": "API_KEY=secret", "project": False} + assert captured["cwd"] == before, "グローバル設定は chdir しない" + + +def test_run_operation_set_project_chdirs_and_restores(monkeypatch, tmp_path): + """プロジェクト設定は対象へ chdir + PWD 切替後に project=True で委譲し、復帰する。""" + captured = _capture_dispatch(monkeypatch) + target = tmp_path / "projects" / "carmo" + target.mkdir(parents=True) + monkeypatch.setattr(menu, "select", lambda *a, **k: "project") + monkeypatch.setattr(actions_env, "_select_project", lambda root: "carmo") + monkeypatch.setattr(menu, "text", lambda *a, **k: "DB_HOST=localhost") + monkeypatch.setenv("PWD", str(tmp_path)) + + before = os.getcwd() + assert actions_env._run_operation(tmp_path, "set") == 0 + assert captured["attrs"] == {"subcommand": "set", + "assignment": "DB_HOST=localhost", "project": True} + # ハンドラ実行中は projects/carmo に居る (CWD と PWD の両方を切り替える) + assert captured["cwd"] == str(target) + assert captured["pwd"] == str(target) + # 実行後は元の CWD / PWD へ復帰する (try/finally) + assert os.getcwd() == before + assert os.environ["PWD"] == str(tmp_path) + + +def test_run_operation_set_project_select_cancel(monkeypatch, tmp_path): + """プロジェクト選択を中止したら assignment 入力にも進まない。""" + from devbase.commands import env as env_mod + called = [] + monkeypatch.setattr(env_mod, "cmd_env", lambda root, args: called.append(1) or 0) + monkeypatch.setattr(menu, "select", lambda *a, **k: "project") + monkeypatch.setattr(actions_env, "_select_project", + lambda root: actions_env._ARG_CANCEL) + monkeypatch.setattr(menu, "text", + lambda *a, **k: pytest.fail("選択中止後に入力を求めない")) + assert actions_env._run_operation(tmp_path, "set") is actions_env._ARG_CANCEL + assert called == [] + + +@pytest.mark.parametrize("scope_ret", ["BACK", None]) +def test_run_operation_set_scope_cancel(monkeypatch, tmp_path, scope_ret): + """設定先選択で Esc/← は再表示 (_ARG_CANCEL)、Ctrl-C は全体中止 (None)。""" + from devbase.commands import env as env_mod + called = [] + monkeypatch.setattr(env_mod, "cmd_env", lambda root, args: called.append(1) or 0) + ret = menu.MENU_BACK if scope_ret == "BACK" else None + monkeypatch.setattr(menu, "select", lambda *a, **k: ret) + expected = actions_env._ARG_CANCEL if scope_ret == "BACK" else None + assert actions_env._run_operation(tmp_path, "set") is expected + assert called == [] + + +@pytest.mark.parametrize("text_ret", ["BACK", None]) +def test_run_operation_set_assignment_cancel(monkeypatch, tmp_path, text_ret): + """assignment 入力で Esc は再表示 (_ARG_CANCEL)、Ctrl-C は全体中止 (None)。""" + from devbase.commands import env as env_mod + called = [] + monkeypatch.setattr(env_mod, "cmd_env", lambda root, args: called.append(1) or 0) + monkeypatch.setattr(menu, "select", lambda *a, **k: "global") + ret = menu.MENU_BACK if text_ret == "BACK" else None + monkeypatch.setattr(menu, "text", lambda *a, **k: ret) + expected = actions_env._ARG_CANCEL if text_ret == "BACK" else None + assert actions_env._run_operation(tmp_path, "set") is expected + assert called == [] + + +# --------------------------------------------------------------------------- +# _run_operation: project (chdir + 復帰) +# --------------------------------------------------------------------------- + +def test_run_operation_project_chdirs_and_restores(monkeypatch, tmp_path): + """env project は対象プロジェクトへ chdir + PWD 切替後に実行し、復帰する (plan 3.3)。""" + captured = _capture_dispatch(monkeypatch) + target = tmp_path / "projects" / "carmo" + target.mkdir(parents=True) + monkeypatch.setattr(actions_env, "_select_project", lambda root: "carmo") + monkeypatch.setenv("PWD", str(tmp_path)) + + before = os.getcwd() + assert actions_env._run_operation(tmp_path, "project") == 0 + assert captured["attrs"] == {"subcommand": "project"} + assert captured["cwd"] == str(target) + assert captured["pwd"] == str(target) + assert os.getcwd() == before + assert os.environ["PWD"] == str(tmp_path) + + +def test_run_operation_project_select_cancel(monkeypatch, tmp_path): + from devbase.commands import env as env_mod + called = [] + monkeypatch.setattr(env_mod, "cmd_env", lambda root, args: called.append(1) or 0) + monkeypatch.setattr(actions_env, "_select_project", + lambda root: actions_env._ARG_CANCEL) + assert actions_env._run_operation(tmp_path, "project") is actions_env._ARG_CANCEL + assert called == [] + + +def test_run_operation_project_select_ctrl_c_aborts(monkeypatch, tmp_path): + """プロジェクト選択中の Ctrl-C は None を伝搬して全体中止する (codex round2 指摘)。""" + from devbase.commands import env as env_mod + called = [] + monkeypatch.setattr(env_mod, "cmd_env", lambda root, args: called.append(1) or 0) + monkeypatch.setattr(actions_env, "_select_project", lambda root: None) + assert actions_env._run_operation(tmp_path, "project") is None + assert called == [] + + +def test_run_in_project_restores_cwd_on_exception(monkeypatch, tmp_path): + """ハンドラが例外を投げても CWD / PWD は復帰する (try/finally)。""" + target = tmp_path / "projects" / "carmo" + target.mkdir(parents=True) + monkeypatch.setenv("PWD", "/original/pwd") + + def _boom(): + raise RuntimeError("handler failed") + + before = os.getcwd() + with pytest.raises(RuntimeError): + actions_env._run_in_project(tmp_path, "carmo", _boom) + assert os.getcwd() == before + assert os.environ["PWD"] == "/original/pwd" + + +def test_run_in_project_restores_unset_pwd(monkeypatch, tmp_path): + """元の環境に PWD が無い場合は復帰時に PWD を残さない。""" + target = tmp_path / "projects" / "carmo" + target.mkdir(parents=True) + monkeypatch.delenv("PWD", raising=False) + + seen = {} + + def _probe(): + seen["pwd"] = os.environ.get("PWD") + return 0 + + assert actions_env._run_in_project(tmp_path, "carmo", _probe) == 0 + assert seen["pwd"] == str(target) + assert "PWD" not in os.environ + + +def test_run_in_project_missing_dir_cancels(monkeypatch, tmp_path): + """対象ディレクトリへ移動できない場合は実行せず _ARG_CANCEL (メニューへ戻る)。""" + called = [] + result = actions_env._run_in_project(tmp_path, "ghost", + lambda: called.append(1) or 0) + assert result is actions_env._ARG_CANCEL + assert called == [] + + +# --------------------------------------------------------------------------- +# _run_operation: export / import (parser 既定値との同期) +# --------------------------------------------------------------------------- + +def test_run_operation_export_default_dest(monkeypatch, tmp_path): + """export は空入力で dest=None、残り属性は CLI parser 既定値と一致する。""" + captured = _capture_dispatch(monkeypatch) + monkeypatch.setattr(menu, "path", lambda *a, **k: "") + assert actions_env._run_operation(tmp_path, "export") == 0 + assert captured["attrs"] == { + "subcommand": "export", "dest": None, + "include_projects": None, "exclude_projects": [], + "no_global": False, "no_metadata": False, "recipients": [], + "passphrase_env": None, "passphrase_stdin": False, + "force_unencrypted": False, "unsafe_allow_unencrypted_bucket": False, + } + + +def test_run_operation_export_explicit_dest(monkeypatch, tmp_path): + captured = _capture_dispatch(monkeypatch) + monkeypatch.setattr(menu, "path", lambda *a, **k: "/tmp/bundle.dbenv") + assert actions_env._run_operation(tmp_path, "export") == 0 + assert captured["attrs"]["dest"] == "/tmp/bundle.dbenv" + + +@pytest.mark.parametrize("path_ret", ["BACK", None]) +def test_run_operation_export_cancel(monkeypatch, tmp_path, path_ret): + """dest 入力で Esc は再表示 (_ARG_CANCEL)、Ctrl-C は全体中止 (None)。""" + from devbase.commands import env as env_mod + called = [] + monkeypatch.setattr(env_mod, "cmd_env", lambda root, args: called.append(1) or 0) + ret = menu.MENU_BACK if path_ret == "BACK" else None + monkeypatch.setattr(menu, "path", lambda *a, **k: ret) + expected = actions_env._ARG_CANCEL if path_ret == "BACK" else None + assert actions_env._run_operation(tmp_path, "export") is expected + assert called == [] + + +def test_run_operation_import_collects_source(monkeypatch, tmp_path): + """import は source を収集し、残り属性は CLI parser 既定値と一致する。""" + captured = _capture_dispatch(monkeypatch) + monkeypatch.setattr(menu, "path", lambda *a, **k: "/tmp/bundle.dbenv") + assert actions_env._run_operation(tmp_path, "import") == 0 + assert captured["attrs"] == { + "subcommand": "import", "source": "/tmp/bundle.dbenv", + "merge": "keep-existing", "replace_keys": "", "replace": False, + "dry_run": False, "identities": [], + "passphrase_env": None, "passphrase_stdin": False, + "include_projects": None, "exclude_projects": [], + "no_global": False, "no_metadata": False, "merge_metadata": False, + "backup_dir": None, "keep_last": 10, + } + + +@pytest.mark.parametrize("path_ret", ["BACK", None]) +def test_run_operation_import_cancel(monkeypatch, tmp_path, path_ret): + """source 入力で Esc は再表示 (_ARG_CANCEL)、Ctrl-C は全体中止 (None)。""" + from devbase.commands import env as env_mod + called = [] + monkeypatch.setattr(env_mod, "cmd_env", lambda root, args: called.append(1) or 0) + ret = menu.MENU_BACK if path_ret == "BACK" else None + monkeypatch.setattr(menu, "path", lambda *a, **k: ret) + expected = actions_env._ARG_CANCEL if path_ret == "BACK" else None + assert actions_env._run_operation(tmp_path, "import") is expected + assert called == [] + + +def test_export_defaults_match_cli_parser(tmp_path): + """TUI が補う export 既定値が cli.py parser の parse 結果と一致する (plan 6 同期)。""" + from devbase import cli + parsed = vars(cli._create_parser().parse_args(["env", "export"])) + tui_attrs = {"dest": None, **actions_env._export_default_attrs()} + for key, value in tui_attrs.items(): + assert parsed[key] == value, f"export 属性 {key} が parser 既定値と乖離" + + +def test_import_defaults_match_cli_parser(tmp_path): + """TUI が補う import 既定値が cli.py parser の parse 結果と一致する (plan 6 同期)。""" + from devbase import cli + parsed = vars(cli._create_parser().parse_args(["env", "import", "b.dbenv"])) + tui_attrs = {"source": "b.dbenv", **actions_env._import_default_attrs()} + for key, value in tui_attrs.items(): + assert parsed[key] == value, f"import 属性 {key} が parser 既定値と乖離" + + +# --------------------------------------------------------------------------- +# _collect_assignment / _select_project +# --------------------------------------------------------------------------- + +def test_collect_assignment_valid(monkeypatch): + monkeypatch.setattr(menu, "text", lambda *a, **k: "K=V") + assert actions_env._collect_assignment() == "K=V" + + +def test_collect_assignment_reprompts_invalid(monkeypatch): + """`=` 無し / キー名空は再入力を促す (cmd_env_set 到達前に弾く)。""" + vals = iter(["NOEQUAL", "=value", "K=V"]) + monkeypatch.setattr(menu, "text", lambda *a, **k: next(vals)) + assert actions_env._collect_assignment() == "K=V" + + +def test_collect_assignment_cancel(monkeypatch): + """Ctrl-C (None) は None、Esc (MENU_BACK) は MENU_BACK をそのまま返す。""" + monkeypatch.setattr(menu, "text", lambda *a, **k: None) + assert actions_env._collect_assignment() is None + + monkeypatch.setattr(menu, "text", lambda *a, **k: menu.MENU_BACK) + assert actions_env._collect_assignment() is menu.MENU_BACK + + +def test_select_project_returns_name(monkeypatch, tmp_path): + """一覧 (actions_project と同じ取得方法) から選んだ行の name を返す。""" + from devbase.commands import status as status_mod + _make_plugin_project(tmp_path, "repos/o--r/p", "carmo") + _link_project(tmp_path, "carmo", "repos/o--r/p", "carmo") + monkeypatch.setattr(status_mod, "_container_status_for", + lambda entry, counts=None: None) + + captured = {} + + def fake_select(message, choices, *, back, search): + captured.update(back=back, search=search, n=len(choices)) + return 0 + + monkeypatch.setattr(menu, "select", fake_select) + assert actions_env._select_project(tmp_path) == "carmo" + assert captured == {"back": True, "search": True, "n": 1} + + +@pytest.mark.parametrize("sel_ret", ["BACK", None]) +def test_select_project_cancel(monkeypatch, tmp_path, sel_ret): + """Esc (MENU_BACK) は _ARG_CANCEL、Ctrl-C (None) は None (全体中止) を返す。""" + from devbase.commands import status as status_mod + _make_plugin_project(tmp_path, "repos/o--r/p", "carmo") + _link_project(tmp_path, "carmo", "repos/o--r/p", "carmo") + monkeypatch.setattr(status_mod, "_container_status_for", + lambda entry, counts=None: None) + ret = menu.MENU_BACK if sel_ret == "BACK" else None + monkeypatch.setattr(menu, "select", lambda *a, **k: ret) + expected = actions_env._ARG_CANCEL if sel_ret == "BACK" else None + assert actions_env._select_project(tmp_path) is expected + + +def test_select_project_empty_cancels(monkeypatch, tmp_path): + """プロジェクトが無いときは選択メニューを出さず _ARG_CANCEL。""" + monkeypatch.setattr(menu, "select", + lambda *a, **k: pytest.fail("空一覧でメニューを出さない")) + assert actions_env._select_project(tmp_path) is actions_env._ARG_CANCEL diff --git a/tests/cli/tui/test_actions_plugin.py b/tests/cli/tui/test_actions_plugin.py new file mode 100644 index 0000000..93aa30a --- /dev/null +++ b/tests/cli/tui/test_actions_plugin.py @@ -0,0 +1,605 @@ +"""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 + + +@pytest.mark.parametrize("confirm_ret", ["BACK", None]) +def test_run_operation_list_cancel(monkeypatch, tmp_path, confirm_ret): + """confirm で Esc は再表示 (_ARG_CANCEL)、Ctrl-C は全体中止 (None)。""" + called = _no_dispatch(monkeypatch) + ret = menu.MENU_BACK if confirm_ret == "BACK" else None + monkeypatch.setattr(menu, "confirm", lambda *a, **k: ret) + expected = actions_plugin._ARG_CANCEL if confirm_ret == "BACK" else None + assert actions_plugin._run_operation(tmp_path, "list") is expected + 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 + + +@pytest.mark.parametrize("text_ret", ["BACK", None]) +def test_run_operation_install_source_cancel(monkeypatch, tmp_path, text_ret): + """source 入力で Esc は再表示 (_ARG_CANCEL)、Ctrl-C は全体中止 (None)。""" + called = _no_dispatch(monkeypatch) + ret = menu.MENU_BACK if text_ret == "BACK" else None + monkeypatch.setattr(menu, "text", lambda *a, **k: ret) + expected = actions_plugin._ARG_CANCEL if text_ret == "BACK" else None + assert actions_plugin._run_operation(tmp_path, "install") is expected + assert called == [] + + +@pytest.mark.parametrize("confirm_ret", ["BACK", None]) +def test_run_operation_install_confirm_cancel_midway(monkeypatch, tmp_path, + confirm_ret): + """install の途中 (install_all) で Esc / Ctrl-C したら実行しない。""" + called = _no_dispatch(monkeypatch) + monkeypatch.setattr(menu, "text", lambda *a, **k: "owner/repo") + ret = menu.MENU_BACK if confirm_ret == "BACK" else None + confirms = iter([True, ret]) # link=True, install_all で中止 + monkeypatch.setattr(menu, "confirm", lambda *a, **k: next(confirms)) + expected = actions_plugin._ARG_CANCEL if confirm_ret == "BACK" else None + assert actions_plugin._run_operation(tmp_path, "install") is expected + 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, "BACK", None]) +def test_run_operation_uninstall_cancelled_does_not_dispatch( + monkeypatch, tmp_path, confirm_ret): + """uninstall の confirm を拒否 (False) / Esc / Ctrl-C したら実行しない。 + + 拒否と Esc はサブメニュー再表示 (_ARG_CANCEL)、Ctrl-C は全体中止 (None)。 + """ + called = _no_dispatch(monkeypatch) + monkeypatch.setattr(actions_plugin, "_select_installed_plugin", + lambda root, msg, **k: "ndf") + ret = menu.MENU_BACK if confirm_ret == "BACK" else confirm_ret + monkeypatch.setattr(menu, "confirm", lambda *a, **k: ret) + expected = None if confirm_ret is None else actions_plugin._ARG_CANCEL + assert actions_plugin._run_operation(tmp_path, "uninstall") is expected + 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 "") + 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_update_ctrl_c_aborts(monkeypatch, tmp_path): + """name 選択中の Ctrl-C は None を伝搬して全体中止する (codex round2 指摘)。""" + called = _no_dispatch(monkeypatch) + monkeypatch.setattr(actions_plugin, "_select_installed_plugin", + lambda root, msg, **k: None) + assert actions_plugin._run_operation(tmp_path, "update") is None + 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 + + +@pytest.mark.parametrize("text_ret", ["BACK", None]) +def test_run_repo_operation_add_url_cancel(monkeypatch, tmp_path, text_ret): + """url 入力で Esc は再表示 (_ARG_CANCEL)、Ctrl-C は全体中止 (None)。""" + called = _no_dispatch(monkeypatch) + ret = menu.MENU_BACK if text_ret == "BACK" else None + monkeypatch.setattr(menu, "text", lambda *a, **k: ret) + expected = actions_plugin._ARG_CANCEL if text_ret == "BACK" else None + assert actions_plugin._run_repo_operation(tmp_path, "add") is expected + 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, "BACK", None]) +def test_run_repo_operation_remove_cancelled_does_not_dispatch( + monkeypatch, tmp_path, confirm_ret): + """repo remove の confirm を拒否 (False) / Esc / Ctrl-C したら実行しない。 + + 拒否と Esc はサブメニュー再表示 (_ARG_CANCEL)、Ctrl-C は全体中止 (None)。 + """ + called = _no_dispatch(monkeypatch) + monkeypatch.setattr(actions_plugin, "_select_repository", + lambda root, msg, **k: "r1") + ret = menu.MENU_BACK if confirm_ret == "BACK" else confirm_ret + monkeypatch.setattr(menu, "confirm", lambda *a, **k: ret) + expected = None if confirm_ret is None else actions_plugin._ARG_CANCEL + assert actions_plugin._run_repo_operation(tmp_path, "remove") is expected + assert called == [], "確認を拒否/中止したら remove しない" + + +@pytest.mark.parametrize("confirm_ret", ["BACK", None]) +def test_run_repo_operation_remove_force_cancel(monkeypatch, tmp_path, confirm_ret): + """force の確認で Esc / Ctrl-C したら実行しない。""" + called = _no_dispatch(monkeypatch) + monkeypatch.setattr(actions_plugin, "_select_repository", + lambda root, msg, **k: "r1") + ret = menu.MENU_BACK if confirm_ret == "BACK" else None + confirms = iter([True, ret]) # 削除確認=True, force で中止 + monkeypatch.setattr(menu, "confirm", lambda *a, **k: next(confirms)) + expected = actions_plugin._ARG_CANCEL if confirm_ret == "BACK" else None + assert actions_plugin._run_repo_operation(tmp_path, "remove") is expected + 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 "") + 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 == [] + + +def test_run_repo_operation_remove_ctrl_c_aborts(monkeypatch, tmp_path): + """リポジトリ選択中の Ctrl-C は None を伝搬して全体中止する (codex round2 指摘)。""" + called = _no_dispatch(monkeypatch) + monkeypatch.setattr(actions_plugin, "_select_repository", + lambda root, msg, **k: None) + monkeypatch.setattr(menu, "confirm", + lambda *a, **k: pytest.fail("Ctrl-C 後に確認を求めない")) + assert actions_plugin._run_repo_operation(tmp_path, "remove") is None + 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_returns_empty(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="全対象") == "" + assert captured["values"] == ["", "a"] + + +@pytest.mark.parametrize("sel", [None, "BACK"]) +def test_select_name_back_or_ctrl_c(monkeypatch, sel): + """Esc/← (MENU_BACK) は _ARG_CANCEL、Ctrl-C (None) は None (全体中止) を返す。""" + ret = menu.MENU_BACK if sel == "BACK" else None + monkeypatch.setattr(menu, "select", lambda *a, **k: ret) + expected = actions_plugin._ARG_CANCEL if sel == "BACK" else None + assert actions_plugin._select_name("選択", ["a"]) is expected + + +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_actions_project.py b/tests/cli/tui/test_actions_project.py new file mode 100644 index 0000000..07de300 --- /dev/null +++ b/tests/cli/tui/test_actions_project.py @@ -0,0 +1,598 @@ +"""PLAN31_2 PR1: tui.actions_project (project カテゴリ操作) のテスト。 + +旧 commands/project.py の _tui_select_and_up / 番号入力フォールバックの非回帰検証を +tui.actions_project へ移送したもの。`menu.select` を monkeypatch して選択値を注入する。 +""" + +from __future__ import annotations + +import pytest + +from devbase.tui import actions_project, menu + + +def _make_plugin_project(root, plugin_path, proj): + target = root / plugin_path / "projects" / proj + target.mkdir(parents=True, exist_ok=True) + return target + + +def _link_project(root, link_name, plugin_path, proj): + from pathlib import Path + projects_dir = root / "projects" + projects_dir.mkdir(exist_ok=True) + (projects_dir / link_name).symlink_to(Path("..") / plugin_path / "projects" / proj) + + +# --------------------------------------------------------------------------- +# run(): 一覧選択 → up/rebuild/down +# --------------------------------------------------------------------------- + +@pytest.mark.parametrize("action", ["up", "rebuild"]) +def test_run_running_row_shows_action_menu(monkeypatch, tmp_path, action): + """running 行を選ぶとサブメニューで操作を選び、引数不要の up/rebuild は即起動する。""" + from devbase.commands import container as container_mod + from devbase.commands import status as status_mod + + _make_plugin_project(tmp_path, "repos/o--r/p", "carmo") + _link_project(tmp_path, "carmo", "repos/o--r/p", "carmo") + monkeypatch.setattr(status_mod, "_container_status_for", + lambda entry, counts=None: {"name": entry.name, + "status": "running (2 containers)", "count": 2}) + + # _select_project → index 0、_select_action → action + monkeypatch.setattr(actions_project, "_select_project", lambda rows: 0) + seen = {} + monkeypatch.setattr(actions_project, "_select_action", + lambda name: seen.update(name=name) or action) + + captured = {} + monkeypatch.setattr(container_mod, "cmd_project", + lambda args: captured.update( + subcommand=args.subcommand, name=args.name) or 0) + + result = actions_project.run(tmp_path) + assert result == 0 # 操作完了 → dispatch の rc を返す + assert seen["name"] == "carmo" + assert captured == {"subcommand": action, "name": "carmo"} + + +def test_run_propagates_nonzero_dispatch_rc(monkeypatch, tmp_path): + """dispatch が非0 (失敗) を返したら run() もその rc を返す (終了コード伝搬)。""" + from devbase.commands import container as container_mod + from devbase.commands import status as status_mod + + _make_plugin_project(tmp_path, "repos/o--r/p", "carmo") + _link_project(tmp_path, "carmo", "repos/o--r/p", "carmo") + monkeypatch.setattr(status_mod, "_container_status_for", + lambda entry, counts=None: {"name": entry.name, + "status": "running (1 containers)", "count": 1}) + monkeypatch.setattr(actions_project, "_select_project", lambda rows: 0) + monkeypatch.setattr(actions_project, "_select_action", lambda name: "up") + monkeypatch.setattr(container_mod, "cmd_project", lambda args: 1) + + result = actions_project.run(tmp_path) + assert result == 1, "非0 rc がトップへ伝搬する" + + +@pytest.mark.parametrize("status", ["stopped", "unknown"]) +def test_run_non_running_row_direct_up(monkeypatch, tmp_path, status): + """非 running 行はサブメニューを出さず直接 up する。""" + from devbase.commands import container as container_mod + from devbase.commands import status as status_mod + + _make_plugin_project(tmp_path, "repos/o--r/p", "carmo") + _link_project(tmp_path, "carmo", "repos/o--r/p", "carmo") + monkeypatch.setattr(status_mod, "_container_status_for", + lambda entry, counts=None: ({"name": entry.name, "status": status, "count": 0} + if status == "stopped" else None)) + + monkeypatch.setattr(actions_project, "_select_project", lambda rows: 0) + action_calls = [] + monkeypatch.setattr(actions_project, "_select_action", + lambda name: action_calls.append(name) or "down") + + captured = {} + monkeypatch.setattr(container_mod, "cmd_project", + lambda args: captured.update( + subcommand=args.subcommand, name=args.name) or 0) + + result = actions_project.run(tmp_path) + assert result == 0 # 直接 up の rc を返す + assert action_calls == [], "非 running ではサブメニューを出さない" + assert captured == {"subcommand": "up", "name": "carmo"} + + +def test_run_select_back_returns_to_top(monkeypatch, tmp_path): + """一覧で Esc/← (MENU_BACK) を押すとトップメニューへ戻る (何も起動しない)。""" + from devbase.commands import container as container_mod + from devbase.commands import status as status_mod + + _make_plugin_project(tmp_path, "repos/o--r/p", "carmo") + _link_project(tmp_path, "carmo", "repos/o--r/p", "carmo") + monkeypatch.setattr(status_mod, "_container_status_for", lambda entry, counts=None: None) + monkeypatch.setattr(actions_project, "_select_project", lambda rows: menu.MENU_BACK) + + called = [] + monkeypatch.setattr(container_mod, "cmd_project", lambda args: called.append(1) or 0) + + assert actions_project.run(tmp_path) is menu.MENU_BACK + assert called == [] + + +def test_run_select_ctrl_c_aborts(monkeypatch, tmp_path): + """一覧で Ctrl-C (None) を押すと全体中止 (None を返す)。""" + from devbase.commands import container as container_mod + from devbase.commands import status as status_mod + + _make_plugin_project(tmp_path, "repos/o--r/p", "carmo") + _link_project(tmp_path, "carmo", "repos/o--r/p", "carmo") + monkeypatch.setattr(status_mod, "_container_status_for", lambda entry, counts=None: None) + monkeypatch.setattr(actions_project, "_select_project", lambda rows: None) + + called = [] + monkeypatch.setattr(container_mod, "cmd_project", lambda args: called.append(1) or 0) + + assert actions_project.run(tmp_path) is None + assert called == [] + + +def test_run_action_menu_back_returns_to_list(monkeypatch, tmp_path): + """running 行のサブメニューで Esc/← (MENU_BACK) を押すと一覧へ戻る。""" + from devbase.commands import container as container_mod + from devbase.commands import status as status_mod + + _make_plugin_project(tmp_path, "repos/o--r/p", "carmo") + _link_project(tmp_path, "carmo", "repos/o--r/p", "carmo") + _make_plugin_project(tmp_path, "repos/o--r/q", "beta") + _link_project(tmp_path, "beta", "repos/o--r/q", "beta") + + def fake_status(entry, counts=None): + st = "running (1 containers)" if entry.name == "carmo" else "stopped" + return {"name": entry.name, "status": st, "count": 1} + + monkeypatch.setattr(status_mod, "_container_status_for", fake_status) + + # sorted 順: beta(stopped)=idx0, carmo(running)=idx1 + # 1 回目: carmo(running, idx1) → action menu で MENU_BACK → 一覧へ戻る + # 2 回目: beta(stopped, idx0) → 直接 up + select_calls = [] + monkeypatch.setattr(actions_project, "_select_project", + lambda rows: (select_calls.append(1), + 1 if len(select_calls) == 1 else 0)[1]) + monkeypatch.setattr(actions_project, "_select_action", lambda name: menu.MENU_BACK) + + captured = {} + monkeypatch.setattr(container_mod, "cmd_project", + lambda args: captured.update( + subcommand=args.subcommand, name=args.name) or 0) + + result = actions_project.run(tmp_path) + assert result == 0 # 2 回目の直接 up の rc を返す + assert len(select_calls) == 2, "MENU_BACK で一覧が再表示される" + assert captured == {"subcommand": "up", "name": "beta"} + + +def test_run_action_menu_ctrl_c_aborts(monkeypatch, tmp_path): + """running 行のサブメニューで Ctrl-C (None) を押すと全体中止。""" + from devbase.commands import container as container_mod + from devbase.commands import status as status_mod + + _make_plugin_project(tmp_path, "repos/o--r/p", "carmo") + _link_project(tmp_path, "carmo", "repos/o--r/p", "carmo") + monkeypatch.setattr(status_mod, "_container_status_for", + lambda entry, counts=None: {"name": entry.name, + "status": "running (1 containers)", "count": 1}) + monkeypatch.setattr(actions_project, "_select_project", lambda rows: 0) + monkeypatch.setattr(actions_project, "_select_action", lambda name: None) + + called = [] + monkeypatch.setattr(container_mod, "cmd_project", lambda args: called.append(1) or 0) + + assert actions_project.run(tmp_path) is None + assert called == [] + + +def test_run_empty_projects_returns_back(monkeypatch, tmp_path): + """プロジェクトが無いときはトップメニューへ戻る (MENU_BACK)。""" + from devbase.commands import status as status_mod + monkeypatch.setattr(status_mod, "_container_status_for", lambda entry, counts=None: None) + # projects/ ディレクトリ無し + assert actions_project.run(tmp_path) is menu.MENU_BACK + + +# --------------------------------------------------------------------------- +# _select_project / _select_action: menu.select への委譲 +# --------------------------------------------------------------------------- + +def test_select_project_uses_search_back_menu(monkeypatch): + rows = [{"name": "carmo", "plugin": "-", "status": "stopped"}] + captured = {} + + def fake_select(message, choices, *, back, search): + captured.update(back=back, search=search, n=len(choices)) + return 0 + + monkeypatch.setattr(menu, "select", fake_select) + assert actions_project._select_project(rows) == 0 + assert captured == {"back": True, "search": True, "n": 1} + + +def test_select_action_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 "logs" + + monkeypatch.setattr(menu, "select", fake_select) + assert actions_project._select_action("carmo") == "logs" + assert captured["back"] is True + assert captured["search"] is False + # up を先頭にしつつ全8操作を提示する (PR2)。 + assert captured["values"] == [ + "up", "down", "login", "ps", "logs", "scale", "build", "rebuild"] + assert captured["values"][0] == "up", "Enter 連打で up に到達できる" + + +# --------------------------------------------------------------------------- +# _run_operation: 各操作の引数収集 + dispatch 契約 (plan 2.3) +# --------------------------------------------------------------------------- + +def _capture_dispatch(monkeypatch): + """cmd_project の呼び出し引数を全属性キャプチャするヘルパ。""" + from devbase.commands import container as container_mod + captured = {} + + def _spy(args): + captured["subcommand"] = args.subcommand + captured["name"] = args.name + for k in ("scale", "index", "all", "follow", "tail", "new_scale", "image"): + if hasattr(args, k): + captured[k] = getattr(args, k) + return 0 + + monkeypatch.setattr(container_mod, "cmd_project", _spy) + return captured + + +def test_run_operation_up_passes_scale_none(monkeypatch, tmp_path): + captured = _capture_dispatch(monkeypatch) + assert actions_project._run_operation(tmp_path, "carmo", "up") == 0 + assert captured["subcommand"] == "up" and captured["scale"] is None + + +def test_run_operation_rebuild(monkeypatch, tmp_path): + captured = _capture_dispatch(monkeypatch) + assert actions_project._run_operation(tmp_path, "carmo", "rebuild") == 0 + assert captured["subcommand"] == "rebuild" and captured["name"] == "carmo" + + +def test_run_operation_down_confirmed(monkeypatch, tmp_path): + """down は confirm=True で停止を実行する (plan 3.4)。""" + captured = _capture_dispatch(monkeypatch) + monkeypatch.setattr(menu, "confirm", lambda *a, **k: True) + assert actions_project._run_operation(tmp_path, "carmo", "down") == 0 + assert captured["subcommand"] == "down" + + +@pytest.mark.parametrize("confirm_ret", [False, "BACK", None]) +def test_run_operation_down_cancelled_does_not_dispatch(monkeypatch, tmp_path, confirm_ret): + """down の confirm を拒否 (False) / Esc / Ctrl-C したら停止しない。 + + 拒否と Esc はサブメニュー再表示 (_ARG_CANCEL)、Ctrl-C は全体中止 (None)。 + """ + from devbase.commands import container as container_mod + called = [] + monkeypatch.setattr(container_mod, "cmd_project", lambda args: called.append(1) or 0) + ret = menu.MENU_BACK if confirm_ret == "BACK" else confirm_ret + monkeypatch.setattr(menu, "confirm", lambda *a, **k: ret) + expected = None if confirm_ret is None else actions_project._ARG_CANCEL + assert actions_project._run_operation(tmp_path, "carmo", "down") is expected + assert called == [], "確認を拒否/中止したら down しない" + + +def test_run_operation_login_collects_index(monkeypatch, tmp_path): + captured = _capture_dispatch(monkeypatch) + # menu.integer で正の整数を保証し、index は文字列契約のため str 化して渡す。 + monkeypatch.setattr(menu, "integer", lambda *a, **k: 3) + assert actions_project._run_operation(tmp_path, "carmo", "login") == 0 + assert captured["subcommand"] == "login" and captured["index"] == "3" + + +@pytest.mark.parametrize("int_ret", ["BACK", None]) +def test_run_operation_login_cancel(monkeypatch, tmp_path, int_ret): + """番号入力で Esc は再表示 (_ARG_CANCEL)、Ctrl-C は全体中止 (None)。""" + from devbase.commands import container as container_mod + called = [] + monkeypatch.setattr(container_mod, "cmd_project", lambda args: called.append(1) or 0) + ret = menu.MENU_BACK if int_ret == "BACK" else None + monkeypatch.setattr(menu, "integer", lambda *a, **k: ret) + expected = actions_project._ARG_CANCEL if int_ret == "BACK" else None + assert actions_project._run_operation(tmp_path, "carmo", "login") is expected + assert called == [] + + +def test_run_operation_ps_all_flag(monkeypatch, tmp_path): + captured = _capture_dispatch(monkeypatch) + monkeypatch.setattr(menu, "confirm", lambda *a, **k: True) + assert actions_project._run_operation(tmp_path, "carmo", "ps") == 0 + assert captured["subcommand"] == "ps" and captured["all"] is True + + +def test_run_operation_logs_follow_and_tail(monkeypatch, tmp_path): + captured = _capture_dispatch(monkeypatch) + monkeypatch.setattr(menu, "confirm", lambda *a, **k: True) # follow + monkeypatch.setattr(actions_project, "_optional_int", lambda msg: 50) # tail=50 + assert actions_project._run_operation(tmp_path, "carmo", "logs") == 0 + assert captured["subcommand"] == "logs" + assert captured["follow"] is True and captured["tail"] == 50 + + +def test_run_operation_logs_tail_empty_is_none(monkeypatch, tmp_path): + captured = _capture_dispatch(monkeypatch) + monkeypatch.setattr(menu, "confirm", lambda *a, **k: False) + monkeypatch.setattr(actions_project, "_optional_int", lambda msg: None) # 空 = 全件 + assert actions_project._run_operation(tmp_path, "carmo", "logs") == 0 + assert captured["follow"] is False and captured["tail"] is None + + +def test_run_operation_logs_tail_ctrl_c_aborts(monkeypatch, tmp_path): + """tail 入力中の Ctrl-C (_ABORT) は None を伝搬して全体中止する (round4 major)。""" + from devbase.commands import container as container_mod + called = [] + monkeypatch.setattr(container_mod, "cmd_project", lambda args: called.append(1) or 0) + monkeypatch.setattr(menu, "confirm", lambda *a, **k: True) + monkeypatch.setattr(actions_project, "_optional_int", + lambda msg: actions_project._ABORT) + assert actions_project._run_operation(tmp_path, "carmo", "logs") is None + assert called == [] + + +def test_run_operation_scale_collects_int(monkeypatch, tmp_path): + captured = _capture_dispatch(monkeypatch) + monkeypatch.setattr(menu, "integer", lambda *a, **k: 4) + assert actions_project._run_operation(tmp_path, "carmo", "scale") == 0 + assert captured["subcommand"] == "scale" and captured["new_scale"] == 4 + + +@pytest.mark.parametrize("int_ret", ["BACK", None]) +def test_run_operation_scale_cancel(monkeypatch, tmp_path, int_ret): + """コンテナ数入力で Esc は再表示 (_ARG_CANCEL)、Ctrl-C は全体中止 (None)。""" + from devbase.commands import container as container_mod + called = [] + monkeypatch.setattr(container_mod, "cmd_project", lambda args: called.append(1) or 0) + ret = menu.MENU_BACK if int_ret == "BACK" else None + monkeypatch.setattr(menu, "integer", lambda *a, **k: ret) + expected = actions_project._ARG_CANCEL if int_ret == "BACK" else None + assert actions_project._run_operation(tmp_path, "carmo", "scale") is expected + assert called == [] + + +def test_run_operation_build_selects_image(monkeypatch, tmp_path): + captured = _capture_dispatch(monkeypatch) + monkeypatch.setattr(actions_project, "_select_build_image", lambda root: "web") + assert actions_project._run_operation(tmp_path, "carmo", "build") == 0 + assert captured["subcommand"] == "build" and captured["image"] == "web" + + +def test_run_operation_build_compose_all_is_image_none(monkeypatch, tmp_path): + """compose.yml 全体 ('') は image=None で委譲する (CLI の引数省略と同じ)。""" + captured = _capture_dispatch(monkeypatch) + monkeypatch.setattr(actions_project, "_select_build_image", lambda root: "") + assert actions_project._run_operation(tmp_path, "carmo", "build") == 0 + assert captured["subcommand"] == "build" and captured["image"] is None + + +def test_run_operation_build_cancel(monkeypatch, tmp_path): + from devbase.commands import container as container_mod + called = [] + monkeypatch.setattr(container_mod, "cmd_project", lambda args: called.append(1) or 0) + monkeypatch.setattr(actions_project, "_select_build_image", + lambda root: actions_project._ARG_CANCEL) + assert actions_project._run_operation(tmp_path, "carmo", "build") is actions_project._ARG_CANCEL + assert called == [] + + +def test_run_operation_build_ctrl_c_aborts(monkeypatch, tmp_path): + """イメージ選択中の Ctrl-C は None を伝搬して全体中止する (codex round2 指摘)。""" + from devbase.commands import container as container_mod + called = [] + monkeypatch.setattr(container_mod, "cmd_project", lambda args: called.append(1) or 0) + monkeypatch.setattr(actions_project, "_select_build_image", lambda root: None) + assert actions_project._run_operation(tmp_path, "carmo", "build") is None + assert called == [] + + +# --------------------------------------------------------------------------- +# _optional_int / _select_build_image +# --------------------------------------------------------------------------- + +def test_optional_int_value(monkeypatch): + monkeypatch.setattr(menu, "text", lambda *a, **k: "20") + assert actions_project._optional_int("tail") == 20 + + +def test_optional_int_empty_is_none(monkeypatch): + monkeypatch.setattr(menu, "text", lambda *a, **k: "") + assert actions_project._optional_int("tail") is None + + +def test_optional_int_cancel(monkeypatch): + """Ctrl-C (None) は _ABORT、Esc (MENU_BACK) は _ARG_CANCEL を返す。 + + 空入力の ``None`` (= 既定動作) と Ctrl-C を区別するため、Ctrl-C は専用番兵 + ``_ABORT`` で返す (PR #55 round4 major)。 + """ + monkeypatch.setattr(menu, "text", lambda *a, **k: None) + assert actions_project._optional_int("tail") is actions_project._ABORT + + monkeypatch.setattr(menu, "text", lambda *a, **k: menu.MENU_BACK) + assert actions_project._optional_int("tail") is actions_project._ARG_CANCEL + + +def test_optional_int_reprompts_non_numeric(monkeypatch): + vals = iter(["abc", "7"]) + monkeypatch.setattr(menu, "text", lambda *a, **k: next(vals)) + assert actions_project._optional_int("tail") == 7 + + +def test_optional_int_reprompts_negative(monkeypatch): + """負数 (min_value=0 未満) は弾いて再入力を促す (logs --tail への負数防止)。""" + vals = iter(["-5", "10"]) + monkeypatch.setattr(menu, "text", lambda *a, **k: next(vals)) + assert actions_project._optional_int("tail") == 10 + + +def test_select_build_image_lists_containers(monkeypatch, tmp_path): + """containers//Dockerfile を列挙し、選択値をそのまま返す。""" + for img in ("web", "db"): + d = tmp_path / "containers" / img + d.mkdir(parents=True) + (d / "Dockerfile").write_text("FROM scratch\n") + # Dockerfile 無しのディレクトリは除外される + (tmp_path / "containers" / "nodockerfile").mkdir() + + captured = {} + + def fake_select(message, choices, *, back, search): + captured["values"] = [c[1] for c in choices] + return "db" + + monkeypatch.setattr(menu, "select", fake_select) + assert actions_project._select_build_image(tmp_path) == "db" + # 先頭は compose 全体 (value="")、続いて sorted な img 名 + assert captured["values"] == ["", "db", "web"] + + +def test_select_build_image_compose_all_is_empty(monkeypatch, tmp_path): + """『compose.yml 全体』(value='') を選ぶと '' を返す (呼び出し側で None へ変換)。""" + d = tmp_path / "containers" / "web" + d.mkdir(parents=True) + (d / "Dockerfile").write_text("FROM scratch\n") + monkeypatch.setattr(menu, "select", lambda *a, **k: "") + assert actions_project._select_build_image(tmp_path) == "" + + +def test_select_build_image_no_containers_returns_empty(tmp_path): + """containers/ が無ければ選択メニューを出さず compose 全体 ('')。""" + assert actions_project._select_build_image(tmp_path) == "" + + +@pytest.mark.parametrize("sel", ["BACK", None]) +def test_select_build_image_cancel(monkeypatch, tmp_path, sel): + """Esc/← (MENU_BACK) は _ARG_CANCEL、Ctrl-C (None) は None (全体中止) を返す。""" + d = tmp_path / "containers" / "web" + d.mkdir(parents=True) + (d / "Dockerfile").write_text("FROM scratch\n") + ret = menu.MENU_BACK if sel == "BACK" else None + monkeypatch.setattr(menu, "select", lambda *a, **k: ret) + expected = actions_project._ARG_CANCEL if sel == "BACK" else None + assert actions_project._select_build_image(tmp_path) is expected + + +# --------------------------------------------------------------------------- +# _operation_menu: 引数収集中止 → サブメニュー再表示 +# --------------------------------------------------------------------------- + +def test_operation_menu_arg_cancel_reshows_submenu(monkeypatch, tmp_path): + """引数収集を中止 (_ARG_CANCEL) するとサブメニューを再表示し、再選択で実行する。""" + select_calls = [] + # 1 回目: scale を選ぶ (→ 引数収集中止) / 2 回目: up を選ぶ (→ 実行) + monkeypatch.setattr(actions_project, "_select_action", + lambda name: (select_calls.append(1), + "scale" if len(select_calls) == 1 else "up")[1]) + + run_calls = [] + + def fake_run_op(root, name, op): + run_calls.append(op) + return actions_project._ARG_CANCEL if op == "scale" else 0 + + monkeypatch.setattr(actions_project, "_run_operation", fake_run_op) + + assert actions_project._operation_menu(tmp_path, "carmo") == 0 + assert run_calls == ["scale", "up"] + assert len(select_calls) == 2, "引数中止でサブメニューが再表示される" + + +# --------------------------------------------------------------------------- +# fallback_select_and_up: 番号入力 (questionary 不在) の非回帰 +# --------------------------------------------------------------------------- + +def test_fallback_selects_and_ups(monkeypatch): + from devbase.commands import container as container_mod + rows = [{"name": "alpha", "plugin": "-", "status": "stopped"}, + {"name": "beta", "plugin": "-", "status": "stopped"}] + monkeypatch.setattr("builtins.input", lambda *a, **k: "2") + + captured = {} + monkeypatch.setattr(container_mod, "cmd_project", + lambda args: captured.update( + subcommand=args.subcommand, name=args.name) or 0) + + assert actions_project.fallback_select_and_up(rows) == 0 + assert captured == {"subcommand": "up", "name": "beta"} + + +def test_fallback_empty_input_aborts(monkeypatch): + from devbase.commands import container as container_mod + rows = [{"name": "alpha", "plugin": "-", "status": "stopped"}] + monkeypatch.setattr("builtins.input", lambda *a, **k: "") + called = [] + monkeypatch.setattr(container_mod, "cmd_project", lambda args: called.append(1) or 0) + assert actions_project.fallback_select_and_up(rows) == 0 + assert called == [] + + +def test_fallback_non_tty_eof(monkeypatch): + from devbase.commands import container as container_mod + rows = [{"name": "alpha", "plugin": "-", "status": "stopped"}] + + def _eof(*a, **k): + raise EOFError + + monkeypatch.setattr("builtins.input", _eof) + called = [] + monkeypatch.setattr(container_mod, "cmd_project", lambda args: called.append(1) or 0) + assert actions_project.fallback_select_and_up(rows) == 1 + assert called == [] + + +def test_fallback_keyboard_interrupt_aborts(monkeypatch): + from devbase.commands import container as container_mod + rows = [{"name": "alpha", "plugin": "-", "status": "stopped"}] + + def _interrupt(*a, **k): + raise KeyboardInterrupt + + monkeypatch.setattr("builtins.input", _interrupt) + called = [] + monkeypatch.setattr(container_mod, "cmd_project", lambda args: called.append(1) or 0) + assert actions_project.fallback_select_and_up(rows) == 0 + assert called == [] + + +def test_fallback_out_of_range_then_valid(monkeypatch): + from devbase.commands import container as container_mod + rows = [{"name": "alpha", "plugin": "-", "status": "stopped"}] + inputs = iter(["99", "1"]) + monkeypatch.setattr("builtins.input", lambda *a, **k: next(inputs)) + captured = {} + monkeypatch.setattr(container_mod, "cmd_project", + lambda args: captured.update(name=args.name) or 0) + assert actions_project.fallback_select_and_up(rows) == 0 + assert captured == {"name": "alpha"} + + +def test_fallback_non_numeric_then_valid(monkeypatch): + from devbase.commands import container as container_mod + rows = [{"name": "alpha", "plugin": "-", "status": "stopped"}] + inputs = iter(["abc", "1"]) + monkeypatch.setattr("builtins.input", lambda *a, **k: next(inputs)) + captured = {} + monkeypatch.setattr(container_mod, "cmd_project", + lambda args: captured.update(name=args.name) or 0) + assert actions_project.fallback_select_and_up(rows) == 0 + assert captured == {"name": "alpha"} diff --git a/tests/cli/tui/test_actions_snapshot.py b/tests/cli/tui/test_actions_snapshot.py new file mode 100644 index 0000000..3351c7d --- /dev/null +++ b/tests/cli/tui/test_actions_snapshot.py @@ -0,0 +1,452 @@ +"""PLAN31_2 PR5: tui.actions_snapshot (snapshot カテゴリ操作) のテスト。 + +`menu.*` を monkeypatch して選択・入力値を注入し、`cmd_snapshot` を mock して +plan 2.3 の契約どおりの属性を持つ Namespace で呼ばれることを検証する +(test_actions_project.py のパターン踏襲)。 +""" + +from __future__ import annotations + +import pytest + +from devbase.tui import actions_snapshot, menu + + +def _capture_dispatch(monkeypatch): + """cmd_snapshot の呼び出し引数を全属性キャプチャするヘルパ。 + + actions_snapshot は ``cmd_snapshot`` をモジュール global として参照するため、 + actions_snapshot 側を monkeypatch する。 + """ + captured = {} + + def _spy(devbase_root, args): + captured["devbase_root"] = devbase_root + captured["subcommand"] = args.subcommand + for k in ("name", "full", "point", "new_name", "keep"): + if hasattr(args, k): + captured[k] = getattr(args, k) + return 0 + + monkeypatch.setattr(actions_snapshot, "cmd_snapshot", _spy) + return captured + + +def _no_dispatch(monkeypatch): + """cmd_snapshot が呼ばれないことを検証するためのスパイ。""" + called = [] + monkeypatch.setattr(actions_snapshot, "cmd_snapshot", + lambda root, args: called.append(1) or 0) + return called + + +# --------------------------------------------------------------------------- +# _select_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 "create" + + monkeypatch.setattr(menu, "select", fake_select) + assert actions_snapshot._select_operation() == "create" + assert captured["back"] is True + assert captured["search"] is False + # 安全な list を先頭にしつつ全 6 操作を提示する。 + assert captured["values"] == [ + "list", "create", "restore", "copy", "delete", "rotate"] + assert captured["values"][0] == "list", "Enter 連打で破壊的操作に到達しない" + + +# --------------------------------------------------------------------------- +# run(): 操作メニューの遷移 (Esc/← / Ctrl-C / 引数中止の再表示 / rc 伝搬) +# --------------------------------------------------------------------------- + +def test_run_back_returns_to_top(monkeypatch, tmp_path): + """操作メニューで Esc/← (MENU_BACK) を押すとトップへ戻る (何も起動しない)。""" + called = _no_dispatch(monkeypatch) + monkeypatch.setattr(actions_snapshot, "_select_operation", lambda: menu.MENU_BACK) + assert actions_snapshot.run(tmp_path) is menu.MENU_BACK + assert called == [] + + +def test_run_ctrl_c_aborts(monkeypatch, tmp_path): + """操作メニューで Ctrl-C (None) を押すと全体中止 (None を返す)。""" + called = _no_dispatch(monkeypatch) + monkeypatch.setattr(actions_snapshot, "_select_operation", lambda: None) + assert actions_snapshot.run(tmp_path) is None + assert called == [] + + +def test_run_executes_and_returns_rc(monkeypatch, tmp_path): + """操作を実行したら dispatch の rc を返す (非0 もそのまま伝搬)。""" + monkeypatch.setattr(actions_snapshot, "_select_operation", lambda: "list") + monkeypatch.setattr(actions_snapshot, "_run_operation", lambda root, op: 1) + assert actions_snapshot.run(tmp_path) == 1 + + +def test_run_arg_cancel_reshows_menu(monkeypatch, tmp_path): + """引数収集を中止 (_ARG_CANCEL) すると操作メニューを再表示し、再選択で実行する。""" + select_calls = [] + # 1 回目: delete (→ 引数収集中止) / 2 回目: list (→ 実行) + monkeypatch.setattr(actions_snapshot, "_select_operation", + lambda: (select_calls.append(1), + "delete" if len(select_calls) == 1 else "list")[1]) + + run_calls = [] + + def fake_run_op(root, op): + run_calls.append(op) + return actions_snapshot._ARG_CANCEL if op == "delete" else 0 + + monkeypatch.setattr(actions_snapshot, "_run_operation", fake_run_op) + + assert actions_snapshot.run(tmp_path) == 0 + assert run_calls == ["delete", "list"] + assert len(select_calls) == 2, "引数中止で操作メニューが再表示される" + + +# --------------------------------------------------------------------------- +# _run_operation: 各操作の引数収集 + dispatch 契約 (plan 2.3) +# --------------------------------------------------------------------------- + +def test_run_operation_list_no_extra_attrs(monkeypatch, tmp_path): + """list は引数収集なしで即委譲する (追加属性なし)。""" + captured = _capture_dispatch(monkeypatch) + assert actions_snapshot._run_operation(tmp_path, "list") == 0 + assert captured == {"devbase_root": tmp_path, "subcommand": "list"} + + +def test_run_operation_create_collects_name_and_full(monkeypatch, tmp_path): + captured = _capture_dispatch(monkeypatch) + monkeypatch.setattr(menu, "text", lambda *a, **k: "snap1") + monkeypatch.setattr(menu, "confirm", lambda *a, **k: True) # --full + assert actions_snapshot._run_operation(tmp_path, "create") == 0 + assert captured["subcommand"] == "create" + assert captured["name"] == "snap1" and captured["full"] is True + + +def test_run_operation_create_empty_name_is_none(monkeypatch, tmp_path): + """空入力の name は CLI の --name 省略と同じ None (自動命名) に正規化する。""" + captured = _capture_dispatch(monkeypatch) + monkeypatch.setattr(menu, "text", lambda *a, **k: "") + monkeypatch.setattr(menu, "confirm", lambda *a, **k: False) + assert actions_snapshot._run_operation(tmp_path, "create") == 0 + assert captured["name"] is None and captured["full"] is False + + +@pytest.mark.parametrize("text_ret", ["BACK", None]) +def test_run_operation_create_name_cancel(monkeypatch, tmp_path, text_ret): + """name 入力で Esc は再表示 (_ARG_CANCEL)、Ctrl-C は全体中止 (None)。""" + called = _no_dispatch(monkeypatch) + ret = menu.MENU_BACK if text_ret == "BACK" else None + monkeypatch.setattr(menu, "text", lambda *a, **k: ret) + expected = actions_snapshot._ARG_CANCEL if text_ret == "BACK" else None + assert actions_snapshot._run_operation(tmp_path, "create") is expected + assert called == [] + + +@pytest.mark.parametrize("confirm_ret", ["BACK", None]) +def test_run_operation_create_full_cancel(monkeypatch, tmp_path, confirm_ret): + """--full の confirm で Esc は再表示 (_ARG_CANCEL)、Ctrl-C は全体中止 (None)。""" + called = _no_dispatch(monkeypatch) + monkeypatch.setattr(menu, "text", lambda *a, **k: "snap1") + ret = menu.MENU_BACK if confirm_ret == "BACK" else None + monkeypatch.setattr(menu, "confirm", lambda *a, **k: ret) + expected = actions_snapshot._ARG_CANCEL if confirm_ret == "BACK" else None + assert actions_snapshot._run_operation(tmp_path, "create") is expected + assert called == [] + + +def test_run_operation_restore_confirmed(monkeypatch, tmp_path): + """restore は confirm=True で name/point を契約どおり渡す (plan 3.4)。""" + captured = _capture_dispatch(monkeypatch) + monkeypatch.setattr(actions_snapshot, "_select_snapshot_name", + lambda root, msg: "snap1") + monkeypatch.setattr(actions_snapshot, "_optional_point", lambda msg: 2) + monkeypatch.setattr(menu, "confirm", lambda *a, **k: True) + assert actions_snapshot._run_operation(tmp_path, "restore") == 0 + assert captured["subcommand"] == "restore" + assert captured["name"] == "snap1" and captured["point"] == 2 + + +def test_run_operation_restore_point_empty_is_none(monkeypatch, tmp_path): + captured = _capture_dispatch(monkeypatch) + monkeypatch.setattr(actions_snapshot, "_select_snapshot_name", + lambda root, msg: "snap1") + monkeypatch.setattr(actions_snapshot, "_optional_point", lambda msg: None) + monkeypatch.setattr(menu, "confirm", lambda *a, **k: True) + assert actions_snapshot._run_operation(tmp_path, "restore") == 0 + assert captured["point"] is None + + +@pytest.mark.parametrize("confirm_ret", [False, "BACK", None]) +def test_run_operation_restore_cancelled_does_not_dispatch( + monkeypatch, tmp_path, confirm_ret): + """restore の confirm を拒否 (False) / Esc / Ctrl-C したら実行しない (plan 3.4)。 + + 拒否と Esc は操作メニュー再表示 (_ARG_CANCEL)、Ctrl-C は全体中止 (None)。 + """ + called = _no_dispatch(monkeypatch) + monkeypatch.setattr(actions_snapshot, "_select_snapshot_name", + lambda root, msg: "snap1") + monkeypatch.setattr(actions_snapshot, "_optional_point", lambda msg: None) + ret = menu.MENU_BACK if confirm_ret == "BACK" else confirm_ret + monkeypatch.setattr(menu, "confirm", lambda *a, **k: ret) + expected = None if confirm_ret is None else actions_snapshot._ARG_CANCEL + assert actions_snapshot._run_operation(tmp_path, "restore") is expected + assert called == [], "確認を拒否/中止したら restore しない" + + +def test_run_operation_restore_name_cancel(monkeypatch, tmp_path): + called = _no_dispatch(monkeypatch) + monkeypatch.setattr(actions_snapshot, "_select_snapshot_name", + lambda root, msg: actions_snapshot._ARG_CANCEL) + assert actions_snapshot._run_operation( + tmp_path, "restore") is actions_snapshot._ARG_CANCEL + assert called == [] + + +def test_run_operation_restore_name_ctrl_c_aborts(monkeypatch, tmp_path): + """対象選択中の Ctrl-C は None を伝搬して全体中止する (codex round2 指摘)。""" + called = _no_dispatch(monkeypatch) + monkeypatch.setattr(actions_snapshot, "_select_snapshot_name", + lambda root, msg: None) + monkeypatch.setattr(menu, "confirm", + lambda *a, **k: pytest.fail("Ctrl-C 後に確認を求めない")) + assert actions_snapshot._run_operation(tmp_path, "restore") is None + assert called == [] + + +def test_run_operation_restore_point_cancel(monkeypatch, tmp_path): + called = _no_dispatch(monkeypatch) + monkeypatch.setattr(actions_snapshot, "_select_snapshot_name", + lambda root, msg: "snap1") + monkeypatch.setattr(actions_snapshot, "_optional_point", + lambda msg: actions_snapshot._ARG_CANCEL) + assert actions_snapshot._run_operation( + tmp_path, "restore") is actions_snapshot._ARG_CANCEL + assert called == [] + + +def test_run_operation_restore_point_ctrl_c_aborts(monkeypatch, tmp_path): + """point 入力中の Ctrl-C (_ABORT) は None を伝搬して全体中止する (round4 major)。""" + called = _no_dispatch(monkeypatch) + monkeypatch.setattr(actions_snapshot, "_select_snapshot_name", + lambda root, msg: "snap1") + monkeypatch.setattr(actions_snapshot, "_optional_point", + lambda msg: actions_snapshot._ABORT) + monkeypatch.setattr(menu, "confirm", + lambda *a, **k: pytest.fail("Ctrl-C 後に確認を求めない")) + assert actions_snapshot._run_operation(tmp_path, "restore") is None + assert called == [] + + +def test_run_operation_copy_collects_new_name(monkeypatch, tmp_path): + captured = _capture_dispatch(monkeypatch) + monkeypatch.setattr(actions_snapshot, "_select_snapshot_name", + lambda root, msg: "snap1") + monkeypatch.setattr(menu, "text", lambda *a, **k: "snap1-copy") + assert actions_snapshot._run_operation(tmp_path, "copy") == 0 + assert captured["subcommand"] == "copy" + assert captured["name"] == "snap1" and captured["new_name"] == "snap1-copy" + + +@pytest.mark.parametrize("text_ret", ["BACK", None]) +def test_run_operation_copy_new_name_cancel(monkeypatch, tmp_path, text_ret): + """new_name 入力で Esc は再表示 (_ARG_CANCEL)、Ctrl-C は全体中止 (None)。""" + called = _no_dispatch(monkeypatch) + monkeypatch.setattr(actions_snapshot, "_select_snapshot_name", + lambda root, msg: "snap1") + ret = menu.MENU_BACK if text_ret == "BACK" else None + monkeypatch.setattr(menu, "text", lambda *a, **k: ret) + expected = actions_snapshot._ARG_CANCEL if text_ret == "BACK" else None + assert actions_snapshot._run_operation(tmp_path, "copy") is expected + assert called == [] + + +def test_run_operation_delete_confirmed(monkeypatch, tmp_path): + """delete は confirm=True で name を契約どおり渡す (plan 3.4)。""" + captured = _capture_dispatch(monkeypatch) + monkeypatch.setattr(actions_snapshot, "_select_snapshot_name", + lambda root, msg: "snap1") + monkeypatch.setattr(menu, "confirm", lambda *a, **k: True) + assert actions_snapshot._run_operation(tmp_path, "delete") == 0 + assert captured["subcommand"] == "delete" and captured["name"] == "snap1" + + +@pytest.mark.parametrize("confirm_ret", [False, "BACK", None]) +def test_run_operation_delete_cancelled_does_not_dispatch( + monkeypatch, tmp_path, confirm_ret): + """delete の confirm を拒否 (False) / Esc / Ctrl-C したら削除しない (plan 3.4)。 + + 拒否と Esc は操作メニュー再表示 (_ARG_CANCEL)、Ctrl-C は全体中止 (None)。 + """ + called = _no_dispatch(monkeypatch) + monkeypatch.setattr(actions_snapshot, "_select_snapshot_name", + lambda root, msg: "snap1") + ret = menu.MENU_BACK if confirm_ret == "BACK" else confirm_ret + monkeypatch.setattr(menu, "confirm", lambda *a, **k: ret) + expected = None if confirm_ret is None else actions_snapshot._ARG_CANCEL + assert actions_snapshot._run_operation(tmp_path, "delete") is expected + assert called == [], "確認を拒否/中止したら delete しない" + + +def test_run_operation_rotate_collects_keep(monkeypatch, tmp_path): + captured = _capture_dispatch(monkeypatch) + seen = {} + + def fake_integer(message, *, default=None, min_value=None, max_value=None): + seen.update(default=default, min_value=min_value) + return 5 + + monkeypatch.setattr(menu, "integer", fake_integer) + assert actions_snapshot._run_operation(tmp_path, "rotate") == 0 + assert captured["subcommand"] == "rotate" and captured["keep"] == 5 + # CLI 既定 (--keep 3) と同じ既定値を提示し、no-op な 0 以下は弾く。 + assert seen == {"default": 3, "min_value": 1} + + +@pytest.mark.parametrize("int_ret", ["BACK", None]) +def test_run_operation_rotate_cancel(monkeypatch, tmp_path, int_ret): + """keep 入力で Esc は再表示 (_ARG_CANCEL)、Ctrl-C は全体中止 (None)。""" + called = _no_dispatch(monkeypatch) + ret = menu.MENU_BACK if int_ret == "BACK" else None + monkeypatch.setattr(menu, "integer", lambda *a, **k: ret) + expected = actions_snapshot._ARG_CANCEL if int_ret == "BACK" else None + assert actions_snapshot._run_operation(tmp_path, "rotate") is expected + assert called == [] + + +def test_run_operation_unknown_op_is_noop(monkeypatch, tmp_path): + called = _no_dispatch(monkeypatch) + assert actions_snapshot._run_operation( + tmp_path, "bogus") is actions_snapshot._ARG_CANCEL + assert called == [] + + +# --------------------------------------------------------------------------- +# _select_snapshot_name: 既存一覧からの選択 / 縮退 +# --------------------------------------------------------------------------- + +class _FakeManager: + """SnapshotManager の list() だけを差し替える小道具。""" + + snapshots: list[dict] | Exception = [] + + def __init__(self, devbase_root): + pass + + def list(self): + if isinstance(type(self).snapshots, Exception): + raise type(self).snapshots + return type(self).snapshots + + +def test_select_snapshot_name_lists_and_returns_value(monkeypatch, tmp_path): + """既存一覧を (名前+作成日時) で提示し、選択値 (名前) をそのまま返す。""" + _FakeManager.snapshots = [ + {"name": "snap1", "created_at": "2026-06-10T12:00:00.123456"}, + {"name": "snap2"}, # created_at 欠落でも落ちない + ] + monkeypatch.setattr(actions_snapshot, "SnapshotManager", _FakeManager) + + captured = {} + + def fake_select(message, choices, *, back, search): + captured.update(back=back, search=search, + titles=[c[0] for c in choices], + values=[c[1] for c in choices]) + return "snap2" + + monkeypatch.setattr(menu, "select", fake_select) + assert actions_snapshot._select_snapshot_name(tmp_path, "選択") == "snap2" + assert captured["back"] is True and captured["search"] is True + assert captured["values"] == ["snap1", "snap2"] + assert "2026-06-10T12:00:00" in captured["titles"][0] + + +@pytest.mark.parametrize("sel", ["BACK", None]) +def test_select_snapshot_name_cancel(monkeypatch, tmp_path, sel): + """Esc (MENU_BACK) は _ARG_CANCEL、Ctrl-C (None) は None (全体中止) を返す。""" + _FakeManager.snapshots = [{"name": "snap1", "created_at": "2026-06-10"}] + monkeypatch.setattr(actions_snapshot, "SnapshotManager", _FakeManager) + ret = menu.MENU_BACK if sel == "BACK" else None + monkeypatch.setattr(menu, "select", lambda *a, **k: ret) + expected = actions_snapshot._ARG_CANCEL if sel == "BACK" else None + assert actions_snapshot._select_snapshot_name(tmp_path, "選択") is expected + + +def test_select_snapshot_name_empty_list_cancels(monkeypatch, tmp_path): + """対象が 1 件も無ければ案内を出して中止 (選択メニューは出さない)。""" + _FakeManager.snapshots = [] + monkeypatch.setattr(actions_snapshot, "SnapshotManager", _FakeManager) + select_calls = [] + monkeypatch.setattr(menu, "select", + lambda *a, **k: select_calls.append(1) or None) + assert actions_snapshot._select_snapshot_name( + tmp_path, "選択") is actions_snapshot._ARG_CANCEL + assert select_calls == [] + + +def test_select_snapshot_name_list_failure_falls_back_to_text(monkeypatch, tmp_path): + """一覧取得に失敗したら自由入力へ縮退する (存在チェックは委譲先に任せる)。""" + _FakeManager.snapshots = RuntimeError("boom") + monkeypatch.setattr(actions_snapshot, "SnapshotManager", _FakeManager) + monkeypatch.setattr(menu, "text", lambda *a, **k: "typed-name") + assert actions_snapshot._select_snapshot_name(tmp_path, "選択") == "typed-name" + + +@pytest.mark.parametrize("text_ret", ["BACK", None]) +def test_select_snapshot_name_text_fallback_cancel(monkeypatch, tmp_path, text_ret): + """text 縮退でも Esc は _ARG_CANCEL、Ctrl-C は None (全体中止) を返す。""" + _FakeManager.snapshots = RuntimeError("boom") + monkeypatch.setattr(actions_snapshot, "SnapshotManager", _FakeManager) + ret = menu.MENU_BACK if text_ret == "BACK" else None + monkeypatch.setattr(menu, "text", lambda *a, **k: ret) + expected = actions_snapshot._ARG_CANCEL if text_ret == "BACK" else None + assert actions_snapshot._select_snapshot_name(tmp_path, "選択") is expected + + +# --------------------------------------------------------------------------- +# _optional_point +# --------------------------------------------------------------------------- + +def test_optional_point_value(monkeypatch): + monkeypatch.setattr(menu, "text", lambda *a, **k: "3") + assert actions_snapshot._optional_point("point") == 3 + + +def test_optional_point_empty_is_none(monkeypatch): + monkeypatch.setattr(menu, "text", lambda *a, **k: "") + assert actions_snapshot._optional_point("point") is None + + +def test_optional_point_cancel(monkeypatch): + """Ctrl-C (None) は _ABORT、Esc (MENU_BACK) は _ARG_CANCEL を返す。 + + 空入力の ``None`` (= 全差分適用) と Ctrl-C を区別するため、Ctrl-C は専用番兵 + ``_ABORT`` で返す (PR #55 round4 major)。 + """ + monkeypatch.setattr(menu, "text", lambda *a, **k: None) + assert actions_snapshot._optional_point("point") is actions_snapshot._ABORT + + monkeypatch.setattr(menu, "text", lambda *a, **k: menu.MENU_BACK) + assert actions_snapshot._optional_point("point") is actions_snapshot._ARG_CANCEL + + +def test_optional_point_reprompts_non_numeric(monkeypatch): + vals = iter(["abc", "7"]) + monkeypatch.setattr(menu, "text", lambda *a, **k: next(vals)) + assert actions_snapshot._optional_point("point") == 7 + + +def test_optional_point_reprompts_non_positive(monkeypatch): + """manager は point に正の整数のみ受理するため 0 以下は弾いて再入力を促す。""" + vals = iter(["0", "-1", "2"]) + monkeypatch.setattr(menu, "text", lambda *a, **k: next(vals)) + assert actions_snapshot._optional_point("point") == 2 diff --git a/tests/cli/tui/test_actions_status.py b/tests/cli/tui/test_actions_status.py new file mode 100644 index 0000000..6ca2dfd --- /dev/null +++ b/tests/cli/tui/test_actions_status.py @@ -0,0 +1,25 @@ +"""PLAN31_2 PR5: tui.actions_status (status 閲覧) のテスト。 + +status は引数なしの閲覧のみ (`cmd_status(devbase_root)` / plan 2.2)。 +`cmd_status` を mock し、devbase_root だけで呼ばれて rc がそのまま返ることを検証する。 +""" + +from __future__ import annotations + +import pytest + +from devbase.tui import actions_status + + +@pytest.mark.parametrize("rc", [0, 1]) +def test_run_delegates_to_cmd_status_and_returns_rc(monkeypatch, tmp_path, rc): + """cmd_status(devbase_root) へ委譲し、rc (0/非0) をそのままトップへ返す。""" + captured = {} + + def _spy(devbase_root): + captured["devbase_root"] = devbase_root + return rc + + monkeypatch.setattr(actions_status, "cmd_status", _spy) + assert actions_status.run(tmp_path) == rc + assert captured["devbase_root"] == tmp_path diff --git a/tests/cli/tui/test_app.py b/tests/cli/tui/test_app.py new file mode 100644 index 0000000..f3504cd --- /dev/null +++ b/tests/cli/tui/test_app.py @@ -0,0 +1,249 @@ +"""PLAN31_2 PR1: tui.app (トップ階層メニュー & 入口 run) のテスト。 + +旧 commands/project.py の cmd_project_list (非 TTY / questionary 不在フォールバック) +の非回帰検証を tui.app へ移送し、トップ階層メニューの routing を追加検証する。 +""" + +from __future__ import annotations + +import types + +from devbase.tui import actions_plugin, actions_project, app, menu + + +def _make_plugin_project(root, plugin_path, proj): + target = root / plugin_path / "projects" / proj + target.mkdir(parents=True, exist_ok=True) + + +def _link_project(root, link_name, plugin_path, proj): + from pathlib import Path + projects_dir = root / "projects" + projects_dir.mkdir(exist_ok=True) + (projects_dir / link_name).symlink_to(Path("..") / plugin_path / "projects" / proj) + + +def _seed(root, monkeypatch, status=None): + from devbase.commands import status as status_mod + _make_plugin_project(root, "repos/o--r/alpha", "alpha-proj") + _link_project(root, "alpha-proj", "repos/o--r/alpha", "alpha-proj") + monkeypatch.setattr(status_mod, "_container_status_for", lambda entry, counts=None: status) + + +# --------------------------------------------------------------------------- +# run(): 非対話 / 非 TTY → table フォールバック +# --------------------------------------------------------------------------- + +def test_run_non_interactive_prints_table(tmp_path, monkeypatch, capsys): + _seed(tmp_path, monkeypatch, status={"name": "x", "status": "stopped", "count": 0}) + rc = app.run(tmp_path, types.SimpleNamespace(interactive=False)) + out = capsys.readouterr().out + assert rc == 0 + assert "alpha-proj" in out and "NAME" in out + + +def test_run_stdin_non_tty_falls_back_to_table(tmp_path, monkeypatch, capsys): + _seed(tmp_path, monkeypatch, status={"name": "x", "status": "stopped", "count": 0}) + monkeypatch.setattr(app.sys.stdin, "isatty", lambda: False) + called = [] + monkeypatch.setattr(app, "_top_menu_loop", lambda root: called.append(1) or 0) + + rc = app.run(tmp_path, types.SimpleNamespace(interactive=True)) + out = capsys.readouterr().out + assert rc == 0 + assert called == [], "非 TTY ではトップメニューを開かない" + assert "alpha-proj" in out + + +def test_run_stdout_non_tty_falls_back_to_table(tmp_path, monkeypatch, capsys): + _seed(tmp_path, monkeypatch, status={"name": "x", "status": "stopped", "count": 0}) + monkeypatch.setattr(app.sys.stdin, "isatty", lambda: True) + monkeypatch.setattr(app.sys.stdout, "isatty", lambda: False) + called = [] + monkeypatch.setattr(app, "_top_menu_loop", lambda root: called.append(1) or 0) + + rc = app.run(tmp_path, types.SimpleNamespace(interactive=True)) + out = capsys.readouterr().out + assert rc == 0 + assert called == [] + assert "alpha-proj" in out + + +def test_run_non_interactive_empty(tmp_path, monkeypatch, capsys): + from devbase.commands import status as status_mod + monkeypatch.setattr(status_mod, "_container_status_for", lambda entry, counts=None: None) + rc = app.run(tmp_path, types.SimpleNamespace(interactive=False)) + assert rc == 0 + + +# --------------------------------------------------------------------------- +# run(): questionary 不在 → 番号入力フォールバック (project up) +# --------------------------------------------------------------------------- + +def test_run_no_questionary_falls_back_to_number_input(tmp_path, monkeypatch): + _seed(tmp_path, monkeypatch, status=None) + monkeypatch.setattr(app.sys.stdin, "isatty", lambda: True) + monkeypatch.setattr(app.sys.stdout, "isatty", lambda: True) + monkeypatch.setattr(menu, "HAVE_QUESTIONARY", False) + + called = {} + monkeypatch.setattr(actions_project, "fallback_select_and_up", + lambda rows: called.update(n=len(rows)) or 0) + top_called = [] + monkeypatch.setattr(app, "_top_menu_loop", lambda root: top_called.append(1) or 0) + + rc = app.run(tmp_path, types.SimpleNamespace(interactive=True)) + assert rc == 0 + assert called == {"n": 1}, "questionary 不在時は番号入力フォールバックへ" + assert top_called == [], "トップメニューは開かない" + + +def test_run_no_questionary_empty_projects(tmp_path, monkeypatch): + from devbase.commands import status as status_mod + monkeypatch.setattr(status_mod, "_container_status_for", lambda entry, counts=None: None) + monkeypatch.setattr(app.sys.stdin, "isatty", lambda: True) + monkeypatch.setattr(app.sys.stdout, "isatty", lambda: True) + monkeypatch.setattr(menu, "HAVE_QUESTIONARY", False) + + called = [] + monkeypatch.setattr(actions_project, "fallback_select_and_up", + lambda rows: called.append(1) or 0) + rc = app.run(tmp_path, types.SimpleNamespace(interactive=True)) + assert rc == 0 + assert called == [], "プロジェクトが無ければフォールバックも呼ばない" + + +# --------------------------------------------------------------------------- +# run(): questionary 利用可 → トップ階層メニュー +# --------------------------------------------------------------------------- + +def test_run_interactive_opens_top_menu(tmp_path, monkeypatch): + _seed(tmp_path, monkeypatch, status=None) + monkeypatch.setattr(app.sys.stdin, "isatty", lambda: True) + monkeypatch.setattr(app.sys.stdout, "isatty", lambda: True) + monkeypatch.setattr(menu, "HAVE_QUESTIONARY", True) + + called = {} + monkeypatch.setattr(app, "_top_menu_loop", lambda root: called.update(root=root) or 0) + rc = app.run(tmp_path, types.SimpleNamespace(interactive=True)) + assert rc == 0 + assert called["root"] == tmp_path + + +# --------------------------------------------------------------------------- +# トップ階層メニュー: routing +# --------------------------------------------------------------------------- + +def test_top_menu_project_first_highlighted(): + """「プロジェクト操作」が先頭 (既定ハイライト) で従来フローへ Enter 連打到達できる。""" + assert app.TOP_CATEGORIES[0] == ("project", "プロジェクト操作") + + +def test_top_menu_routes_project_then_back_to_top(monkeypatch, tmp_path): + """カテゴリ選択 → project 実行 (MENU_BACK) → トップ再表示 → Esc (None) で終了。""" + selects = iter(["project", None]) # 1 回目 project、2 回目 Esc 中止 + monkeypatch.setattr(menu, "select", lambda *a, **k: next(selects)) + + routed = [] + monkeypatch.setattr(actions_project, "run", + lambda root: routed.append(root) or menu.MENU_BACK) + + rc = app._top_menu_loop(tmp_path) + assert rc == 0 + assert routed == [tmp_path], "project カテゴリへ 1 回 routing される" + + +def test_top_menu_propagates_executed_rc(monkeypatch, tmp_path): + """カテゴリ実行で非0 rc が返ると、その後トップで中止しても rc がループ戻り値へ伝搬する。""" + selects = iter(["project", None]) # 1 回目 project 実行、2 回目 Esc 中止 + monkeypatch.setattr(menu, "select", lambda *a, **k: next(selects)) + # actions_project.run が rc=1 (実行・失敗) を返す + monkeypatch.setattr(actions_project, "run", lambda root: 1) + + assert app._top_menu_loop(tmp_path) == 1 + + +def test_top_menu_back_does_not_overwrite_last_rc(monkeypatch, tmp_path): + """実行 rc を記憶後、別カテゴリが MENU_BACK を返しても last_rc は上書きされない。""" + selects = iter(["project", "snapshot", None]) + monkeypatch.setattr(menu, "select", lambda *a, **k: next(selects)) + runs = iter([1]) # project 実行 → rc=1 + monkeypatch.setattr(actions_project, "run", lambda root: next(runs)) + # snapshot は操作なしで戻る (MENU_BACK) → last_rc を維持 + from devbase.tui import actions_snapshot + monkeypatch.setattr(actions_snapshot, "run", lambda root: menu.MENU_BACK) + + assert app._top_menu_loop(tmp_path) == 1 + + +def test_top_menu_zero_rc_propagates(monkeypatch, tmp_path): + """rc=0 が int として正しく扱われる (None/MENU_BACK と誤マッチしない)。""" + selects = iter(["project", None]) + monkeypatch.setattr(menu, "select", lambda *a, **k: next(selects)) + monkeypatch.setattr(actions_project, "run", lambda root: 0) + + assert app._top_menu_loop(tmp_path) == 0 + + +def test_top_menu_escape_aborts(monkeypatch, tmp_path): + """トップメニューで Esc/Ctrl-C (None) を押すと即終了 (rc=0)。""" + monkeypatch.setattr(menu, "select", lambda *a, **k: None) + routed = [] + monkeypatch.setattr(actions_project, "run", lambda root: routed.append(1) or menu.MENU_BACK) + assert app._top_menu_loop(tmp_path) == 0 + assert routed == [] + + +def test_top_menu_category_ctrl_c_aborts_whole_app(monkeypatch, tmp_path): + """カテゴリ内で Ctrl-C (None) を受けたら全体中止する。""" + monkeypatch.setattr(menu, "select", lambda *a, **k: "project") + monkeypatch.setattr(actions_project, "run", lambda root: None) # Ctrl-C + assert app._top_menu_loop(tmp_path) == 0 + + +def test_top_menu_menu_back_category_returns_to_top(monkeypatch, tmp_path): + """カテゴリが操作なし (MENU_BACK) で戻ったらトップメニューを再表示する。""" + selects = iter(["snapshot", None]) + monkeypatch.setattr(menu, "select", lambda *a, **k: next(selects)) + from devbase.tui import actions_snapshot + monkeypatch.setattr(actions_snapshot, "run", lambda root: menu.MENU_BACK) + # _route が MENU_BACK を返してループ継続 → 2 回目 None で終了 + rc = app._top_menu_loop(tmp_path) + assert rc == 0 + + +def test_route_project_delegates(monkeypatch, tmp_path): + monkeypatch.setattr(actions_project, "run", lambda root: "RESULT") + 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_env_delegates(monkeypatch, tmp_path): + """env カテゴリは actions_env.run へ routing される (PR3)。""" + from devbase.tui import actions_env + monkeypatch.setattr(actions_env, "run", lambda root: "ENV_RESULT") + assert app._route("env", tmp_path) == "ENV_RESULT" + + +def test_route_unknown_category_returns_menu_back(tmp_path): + # 全カテゴリ配線済みのため、未知カテゴリへの防御的 fallback (MENU_BACK) を検証。 + assert app._route("unknown", tmp_path) is menu.MENU_BACK + + +def test_route_snapshot_delegates(monkeypatch, tmp_path): + """PR5: snapshot カテゴリは actions_snapshot.run へ配線される。""" + from devbase.tui import actions_snapshot + monkeypatch.setattr(actions_snapshot, "run", lambda root: "SNAP") + assert app._route("snapshot", tmp_path) == "SNAP" + + +def test_route_status_delegates(monkeypatch, tmp_path): + """PR5: status カテゴリは actions_status.run へ配線される。""" + from devbase.tui import actions_status + monkeypatch.setattr(actions_status, "run", lambda root: "STATUS") + assert app._route("status", tmp_path) == "STATUS" diff --git a/tests/cli/tui/test_dispatch.py b/tests/cli/tui/test_dispatch.py new file mode 100644 index 0000000..5d9d72b --- /dev/null +++ b/tests/cli/tui/test_dispatch.py @@ -0,0 +1,124 @@ +"""PLAN31_2 PR1: tui.dispatch (ハンドラ委譲層) のテスト。""" + +from __future__ import annotations + +import os +from pathlib import Path + +from devbase.tui import dispatch + + +def test_dispatch_lifecycle_builds_namespace_and_calls_cmd_project(monkeypatch): + """dispatch_lifecycle は subcommand/name/attrs を載せた Namespace で cmd_project を呼ぶ。""" + from devbase.commands import container as container_mod + + captured = {} + monkeypatch.setattr(container_mod, "cmd_project", + lambda args: captured.update( + subcommand=args.subcommand, name=args.name, + scale=getattr(args, "scale", "MISSING")) or 0) + + rc = dispatch.dispatch_lifecycle("up", "carmo", scale=None) + assert rc == 0 + assert captured == {"subcommand": "up", "name": "carmo", "scale": None} + + +def test_dispatch_lifecycle_name_optional(monkeypatch): + """name 省略時は None が載る (container 経路相当)。""" + from devbase.commands import container as container_mod + + captured = {} + monkeypatch.setattr(container_mod, "cmd_project", + lambda args: captured.update(name=args.name) or 0) + + dispatch.dispatch_lifecycle("rebuild") + assert captured == {"name": None} + + +def test_dispatch_lifecycle_restores_cwd_and_env(monkeypatch, tmp_path): + """ハンドラが chdir / 環境変数変更したまま戻っても呼び出し前の状態へ復元する。 + + PR #55 round1 major 回帰テスト: TUI は同一プロセスで継続するため、 + `_resolve_project_name` 相当の chdir / env 反映 / COMPOSE_PROJECT_NAME 上書きが + トップメニュー復帰後の操作 (env get 等) へ残留してはならない。 + """ + from devbase.commands import container as container_mod + + other = tmp_path / "projects" / "carmo" + other.mkdir(parents=True) + + def mutating_handler(args): + os.chdir(other) # chdir 残留を模擬 + os.environ["COMPOSE_PROJECT_NAME"] = "carmo" # 上書き残留を模擬 + os.environ["DEV_SERVICE_NAME"] = "leaked" # 新規キー残留を模擬 + os.environ.pop("DEVBASE_TEST_KEEP", None) # 既存キー削除を模擬 + return 0 + + monkeypatch.setattr(container_mod, "cmd_project", mutating_handler) + monkeypatch.chdir(tmp_path) + monkeypatch.setenv("DEVBASE_TEST_KEEP", "orig") + monkeypatch.delenv("DEV_SERVICE_NAME", raising=False) + monkeypatch.setenv("COMPOSE_PROJECT_NAME", "before") + + rc = dispatch.dispatch_lifecycle("up", "carmo", scale=None) + + assert rc == 0 + assert Path.cwd() == tmp_path # CWD 復元 + assert os.environ["COMPOSE_PROJECT_NAME"] == "before" # 上書き復元 + assert "DEV_SERVICE_NAME" not in os.environ # 漏えいキー除去 + assert os.environ["DEVBASE_TEST_KEEP"] == "orig" # 削除キー復元 + + +def test_dispatch_lifecycle_restores_state_on_exception(monkeypatch, tmp_path): + """ハンドラが例外を投げても CWD / 環境変数は復元される (try/finally 保証)。""" + import pytest + + from devbase.commands import container as container_mod + + def raising_handler(args): + os.chdir(tmp_path) + os.environ["DEV_SERVICE_NAME"] = "leaked" + raise RuntimeError("boom") + + monkeypatch.setattr(container_mod, "cmd_project", raising_handler) + old_cwd = Path.cwd() + monkeypatch.delenv("DEV_SERVICE_NAME", raising=False) + + with pytest.raises(RuntimeError): + dispatch.dispatch_lifecycle("up", "carmo", scale=None) + + assert Path.cwd() == old_cwd + assert "DEV_SERVICE_NAME" not in os.environ + + +def test_dispatch_group_restores_cwd_and_env(tmp_path, monkeypatch): + """dispatch_group も lifecycle と同じ復元境界を張る (契約整合)。""" + + def mutating_handler(devbase_root, args): + os.chdir(tmp_path) + os.environ["DEV_SERVICE_NAME"] = "leaked" + return 0 + + monkeypatch.delenv("DEV_SERVICE_NAME", raising=False) + old_cwd = Path.cwd() + + rc = dispatch.dispatch_group(mutating_handler, Path("/devbase"), "init") + + assert rc == 0 + assert Path.cwd() == old_cwd + assert "DEV_SERVICE_NAME" not in os.environ + + +def test_dispatch_group_builds_namespace_and_calls_handler(): + """dispatch_group は (devbase_root, args) 形式のハンドラへ委譲する。""" + captured = {} + + def handler(devbase_root, args): + captured["root"] = devbase_root + captured["subcommand"] = args.subcommand + captured["reset"] = args.reset + return 7 + + rc = dispatch.dispatch_group(handler, Path("/devbase"), "init", reset=True) + assert rc == 7 + assert captured == {"root": Path("/devbase"), "subcommand": "init", "reset": True} diff --git a/tests/cli/tui/test_menu.py b/tests/cli/tui/test_menu.py new file mode 100644 index 0000000..a0243da --- /dev/null +++ b/tests/cli/tui/test_menu.py @@ -0,0 +1,380 @@ +"""PLAN31_2 PR1: tui.menu (メニューエンジン) のテスト。 + +旧 commands/project.py の Esc/← バインド・select 起動テストを移送し、引数収集 +ヘルパ (text/confirm/integer/path) のフォールバック挙動を追加検証する。 +""" + +from __future__ import annotations + +import types + +import pytest + +from devbase.tui import menu + + +# --------------------------------------------------------------------------- +# Esc / ← キーバインド +# --------------------------------------------------------------------------- + +def test_with_escape_cancel_registers_escape_binding(): + """with_escape_cancel が select に単独 Esc 中止バインドを後付けすること。""" + questionary = pytest.importorskip("questionary") + from prompt_toolkit.keys import Keys + + q = questionary.select("t", choices=[questionary.Choice(title="a", value=0)]) + assert menu.with_escape_cancel(q) is q # 同じ question を返す + + esc = [b for b in q.application.key_bindings.bindings if Keys.Escape in b.keys] + assert len(esc) == 1 + # eager=False: 矢印キー等のエスケープシーケンス (\x1b[A 等) の先頭と衝突させない + assert esc[0].eager() is False + + # ハンドラは Ctrl-C と同様 KeyboardInterrupt で app を抜ける (= ask() が None) + captured = {} + fake_app = types.SimpleNamespace(exit=lambda **kw: captured.update(kw)) + esc[0].handler(types.SimpleNamespace(app=fake_app)) + assert captured["exception"] is KeyboardInterrupt + + +def test_with_escape_back_returns_sentinel_on_escape_and_left(): + """with_escape_back の Esc / ← ハンドラは MENU_BACK を result として返すこと。""" + questionary = pytest.importorskip("questionary") + from prompt_toolkit.keys import Keys + + q = questionary.select("t", choices=[questionary.Choice(title="a", value="a")]) + assert menu.with_escape_back(q) is q + + esc = [b for b in q.application.key_bindings.bindings if Keys.Escape in b.keys] + assert len(esc) == 1 + assert esc[0].eager() is False # 矢印キーのエスケープシーケンスと衝突させない + + captured = {} + fake_app = types.SimpleNamespace(exit=lambda **kw: captured.update(kw)) + esc[0].handler(types.SimpleNamespace(app=fake_app)) + assert captured == {"result": menu.MENU_BACK} + + # ← (Left) も「戻る」に割り当て、Esc のフラッシュ待ち遅延を回避して即応させる + left = [b for b in q.application.key_bindings.bindings if Keys.Left in b.keys] + assert len(left) == 1 + captured.clear() + left[0].handler(types.SimpleNamespace(app=fake_app)) + assert captured == {"result": menu.MENU_BACK} + + +def test_with_escape_back_bind_left_false_skips_left(): + """bind_left=False (検索絞り込みメニュー) のとき ← はバインドしない。""" + questionary = pytest.importorskip("questionary") + from prompt_toolkit.keys import Keys + + q = questionary.select("t", choices=[questionary.Choice(title="a", value="a")]) + menu.with_escape_back(q, bind_left=False) + + esc = [b for b in q.application.key_bindings.bindings if Keys.Escape in b.keys] + left = [b for b in q.application.key_bindings.bindings if Keys.Left in b.keys] + assert len(esc) == 1 + assert left == [], "search 有効メニューでは ← を入力カーソル用に空ける" + + +# --------------------------------------------------------------------------- +# select: バインドの仕込みと戻り値 +# --------------------------------------------------------------------------- + +def _fake_select(monkeypatch, *, ask_result="sentinel"): + """questionary.select を差し替え、生成された fake question を返すヘルパ。""" + from prompt_toolkit.key_binding import KeyBindings + + holder = {} + + def _factory(message, **kwargs): + kb = KeyBindings() + q = types.SimpleNamespace( + application=types.SimpleNamespace(key_bindings=kb), + ask=lambda: ask_result, + ) + holder["question"] = q + holder["kwargs"] = kwargs + return q + + monkeypatch.setattr(menu.questionary, "select", _factory) + return holder + + +def test_select_back_false_wires_escape_cancel(monkeypatch): + """back=False のトップメニューは Esc 中止バインドを仕込んでから ask する。""" + pytest.importorskip("questionary") + from prompt_toolkit.keys import Keys + + holder = _fake_select(monkeypatch) + result = menu.select("t", [("a", 0)], back=False) + assert result == "sentinel" + + kb = holder["question"].application.key_bindings + esc = [b for b in kb.bindings if Keys.Escape in b.keys] + assert len(esc) == 1 + # 中止ハンドラ: KeyboardInterrupt で抜ける + captured = {} + esc[0].handler(types.SimpleNamespace( + app=types.SimpleNamespace(exit=lambda **kw: captured.update(kw)))) + assert captured["exception"] is KeyboardInterrupt + + +def test_select_back_true_search_false_binds_left(monkeypatch): + """back=True / search=False (サブメニュー) は Esc と ← を戻るに割り当てる。""" + pytest.importorskip("questionary") + from prompt_toolkit.keys import Keys + + holder = _fake_select(monkeypatch) + menu.select("t", [("a", 0)], back=True, search=False) + + kb = holder["question"].application.key_bindings + assert [b for b in kb.bindings if Keys.Escape in b.keys] + assert [b for b in kb.bindings if Keys.Left in b.keys] + assert holder["kwargs"]["use_search_filter"] is False + + +def test_select_back_true_search_true_no_left(monkeypatch): + """back=True / search=True (一覧) は ← を空け Esc のみ戻る、filter を有効化。""" + pytest.importorskip("questionary") + from prompt_toolkit.keys import Keys + + holder = _fake_select(monkeypatch) + menu.select("t", [("a", 0)], back=True, search=True) + + kb = holder["question"].application.key_bindings + assert [b for b in kb.bindings if Keys.Escape in b.keys] + assert [b for b in kb.bindings if Keys.Left in b.keys] == [] + assert holder["kwargs"]["use_search_filter"] is True + + +def test_select_converts_tuple_choices(monkeypatch): + """(title, value) タプルは questionary.Choice に変換されて渡る。""" + questionary = pytest.importorskip("questionary") + + holder = _fake_select(monkeypatch) + menu.select("t", [("ラベルA", "va"), ("ラベルB", "vb")], back=False) + + choices = holder["kwargs"]["choices"] + assert all(isinstance(c, questionary.Choice) for c in choices) + assert [c.value for c in choices] == ["va", "vb"] + + +# --------------------------------------------------------------------------- +# 引数収集ヘルパ: questionary 経路の Esc バインドと再入力ループ +# --------------------------------------------------------------------------- + +def _fake_question(monkeypatch, factory_name, *, ask_result): + """questionary. を差し替え、生成 question を holder に集めるヘルパ。""" + from prompt_toolkit.key_binding import KeyBindings + + holder = {"questions": []} + + def _factory(message, **kwargs): + kb = KeyBindings() + ans = ask_result.pop(0) if isinstance(ask_result, list) else ask_result + q = types.SimpleNamespace( + application=types.SimpleNamespace(key_bindings=kb), + ask=lambda ans=ans: ans, + ) + holder["questions"].append(q) + return q + + monkeypatch.setattr(menu.questionary, factory_name, _factory) + return holder + + +def test_text_questionary_binds_escape_back(monkeypatch): + """questionary 経路の text に Esc→戻る (MENU_BACK) バインドが付き、← は空けること。""" + pytest.importorskip("questionary") + from prompt_toolkit.keys import Keys + + monkeypatch.setattr(menu, "HAVE_QUESTIONARY", True) + holder = _fake_question(monkeypatch, "text", ask_result="hello") + assert menu.text("名前") == "hello" + + kb = holder["questions"][0].application.key_bindings + esc = [b for b in kb.bindings if Keys.Escape in b.keys] + assert len(esc) == 1 + captured = {} + esc[0].handler(types.SimpleNamespace( + app=types.SimpleNamespace(exit=lambda **kw: captured.update(kw)))) + assert captured == {"result": menu.MENU_BACK} + # ← は入力カーソル移動に使うためバインドしない (bind_left=False) + assert [b for b in kb.bindings if Keys.Left in b.keys] == [] + + +def test_confirm_questionary_binds_escape_back(monkeypatch): + """questionary 経路の confirm に Esc→戻る (MENU_BACK) バインドが付くこと。""" + pytest.importorskip("questionary") + from prompt_toolkit.keys import Keys + + monkeypatch.setattr(menu, "HAVE_QUESTIONARY", True) + holder = _fake_question(monkeypatch, "confirm", ask_result=True) + assert menu.confirm("本当に?") is True + + kb = holder["questions"][0].application.key_bindings + esc = [b for b in kb.bindings if Keys.Escape in b.keys] + assert len(esc) == 1 + captured = {} + esc[0].handler(types.SimpleNamespace( + app=types.SimpleNamespace(exit=lambda **kw: captured.update(kw)))) + assert captured == {"result": menu.MENU_BACK} + + +def test_path_questionary_binds_escape_back(monkeypatch): + """questionary 経路の path に Esc バインドが付くこと。""" + pytest.importorskip("questionary") + from prompt_toolkit.keys import Keys + + monkeypatch.setattr(menu, "HAVE_QUESTIONARY", True) + holder = _fake_question(monkeypatch, "path", ask_result="/tmp/x") + assert menu.path("dest") == "/tmp/x" + + kb = holder["questions"][0].application.key_bindings + esc = [b for b in kb.bindings if Keys.Escape in b.keys] + assert len(esc) == 1 + + +def test_text_questionary_ctrl_c_returns_none(monkeypatch): + """questionary 経路の text で Ctrl-C (ask が None) のとき None (全体中止) を返す。""" + pytest.importorskip("questionary") + monkeypatch.setattr(menu, "HAVE_QUESTIONARY", True) + _fake_question(monkeypatch, "text", ask_result=None) + assert menu.text("名前") is None + + +def test_text_questionary_escape_returns_menu_back(monkeypatch): + """questionary 経路の text で Esc (ask が MENU_BACK) のとき MENU_BACK を返す。 + + allow_empty=False でも番兵を strip せずそのまま返す (PR #55 round4 major: + Esc=戻る と Ctrl-C=全体中止 を呼び出し側で区別できるようにする)。 + """ + pytest.importorskip("questionary") + monkeypatch.setattr(menu, "HAVE_QUESTIONARY", True) + _fake_question(monkeypatch, "text", ask_result=menu.MENU_BACK) + assert menu.text("名前", allow_empty=False) is menu.MENU_BACK + + +def test_confirm_questionary_escape_returns_menu_back(monkeypatch): + """questionary 経路の confirm で Esc のとき MENU_BACK を返す (None=Ctrl-C と区別)。""" + pytest.importorskip("questionary") + monkeypatch.setattr(menu, "HAVE_QUESTIONARY", True) + _fake_question(monkeypatch, "confirm", ask_result=menu.MENU_BACK) + assert menu.confirm("本当に?") is menu.MENU_BACK + + +def test_path_questionary_escape_returns_menu_back(monkeypatch): + """questionary 経路の path で Esc のとき MENU_BACK を返す (None=Ctrl-C と区別)。""" + pytest.importorskip("questionary") + monkeypatch.setattr(menu, "HAVE_QUESTIONARY", True) + _fake_question(monkeypatch, "path", ask_result=menu.MENU_BACK) + assert menu.path("dest", allow_empty=False) is menu.MENU_BACK + + +def test_integer_questionary_escape_returns_menu_back(monkeypatch): + """integer は text の MENU_BACK (Esc) を int 変換せずそのまま伝搬する。""" + pytest.importorskip("questionary") + monkeypatch.setattr(menu, "HAVE_QUESTIONARY", True) + _fake_question(monkeypatch, "text", ask_result=menu.MENU_BACK) + assert menu.integer("scale") is menu.MENU_BACK + + +def test_text_questionary_reprompts_on_empty_via_loop(monkeypatch): + """allow_empty=False で空入力は while ループで再入力を促す (自己再帰しない)。""" + pytest.importorskip("questionary") + monkeypatch.setattr(menu, "HAVE_QUESTIONARY", True) + holder = _fake_question(monkeypatch, "text", ask_result=["", " valid "]) + assert menu.text("名前", allow_empty=False) == "valid" + assert len(holder["questions"]) == 2, "空入力で 1 度再プロンプトされる" + + +def test_path_questionary_reprompts_on_empty_via_loop(monkeypatch): + """path も allow_empty=False の空入力で while ループ再入力する。""" + pytest.importorskip("questionary") + monkeypatch.setattr(menu, "HAVE_QUESTIONARY", True) + holder = _fake_question(monkeypatch, "path", ask_result=["", "/tmp/ok"]) + assert menu.path("dest", allow_empty=False) == "/tmp/ok" + assert len(holder["questions"]) == 2 + + +# --------------------------------------------------------------------------- +# 引数収集ヘルパ: input() フォールバック (questionary 不在経路) +# --------------------------------------------------------------------------- + +def test_text_fallback_returns_input(monkeypatch): + monkeypatch.setattr(menu, "HAVE_QUESTIONARY", False) + monkeypatch.setattr("builtins.input", lambda *a, **k: " hello ") + assert menu.text("名前") == "hello" + + +def test_text_fallback_default_on_empty(monkeypatch): + monkeypatch.setattr(menu, "HAVE_QUESTIONARY", False) + monkeypatch.setattr("builtins.input", lambda *a, **k: "") + assert menu.text("名前", default="dflt") == "dflt" + + +def test_text_fallback_abort_on_eof(monkeypatch): + monkeypatch.setattr(menu, "HAVE_QUESTIONARY", False) + + def _eof(*a, **k): + raise EOFError + + monkeypatch.setattr("builtins.input", _eof) + assert menu.text("名前") is None + + +def test_confirm_fallback_yes_no(monkeypatch): + monkeypatch.setattr(menu, "HAVE_QUESTIONARY", False) + answers = iter(["y"]) + monkeypatch.setattr("builtins.input", lambda *a, **k: next(answers)) + assert menu.confirm("本当に?") is True + + answers = iter(["n"]) + monkeypatch.setattr("builtins.input", lambda *a, **k: next(answers)) + assert menu.confirm("本当に?") is False + + +def test_confirm_fallback_empty_uses_default(monkeypatch): + monkeypatch.setattr(menu, "HAVE_QUESTIONARY", False) + monkeypatch.setattr("builtins.input", lambda *a, **k: "") + assert menu.confirm("本当に?", default=True) is True + assert menu.confirm("本当に?", default=False) is False + + +def test_confirm_fallback_abort_on_ctrl_c(monkeypatch): + monkeypatch.setattr(menu, "HAVE_QUESTIONARY", False) + + def _interrupt(*a, **k): + raise KeyboardInterrupt + + monkeypatch.setattr("builtins.input", _interrupt) + assert menu.confirm("本当に?") is None + + +def test_integer_fallback_valid(monkeypatch): + monkeypatch.setattr(menu, "HAVE_QUESTIONARY", False) + monkeypatch.setattr("builtins.input", lambda *a, **k: "3") + assert menu.integer("scale") == 3 + + +def test_integer_fallback_reprompts_on_non_numeric_and_range(monkeypatch): + monkeypatch.setattr(menu, "HAVE_QUESTIONARY", False) + inputs = iter(["abc", "0", "5"]) # 非数値 → 範囲外(min=1) → 有効 + monkeypatch.setattr("builtins.input", lambda *a, **k: next(inputs)) + assert menu.integer("scale", min_value=1) == 5 + + +def test_integer_fallback_default_on_empty(monkeypatch): + monkeypatch.setattr(menu, "HAVE_QUESTIONARY", False) + monkeypatch.setattr("builtins.input", lambda *a, **k: "") + assert menu.integer("keep", default=3) == 3 + + +def test_integer_fallback_abort(monkeypatch): + monkeypatch.setattr(menu, "HAVE_QUESTIONARY", False) + + def _eof(*a, **k): + raise EOFError + + monkeypatch.setattr("builtins.input", _eof) + assert menu.integer("scale") is None