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_project.py b/lib/devbase/tui/actions_project.py new file mode 100644 index 0000000..3af0615 --- /dev/null +++ b/lib/devbase/tui/actions_project.py @@ -0,0 +1,146 @@ +"""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/rebuild/down サブメニュー) → +それ以外は直接 up** までで、login/ps/logs/scale/build の追加は PR2 で行う。 + +一覧表示・整形 (``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) + + +def _select_action(name: str): + """running 中プロジェクトの操作 (up/rebuild/down) を選ぶサブメニュー。 + + 戻り値: action 文字列 / ``MENU_BACK`` (Esc・← → 一覧へ戻る) / ``None`` (Ctrl-C 中止)。 + """ + choices = [ + ("再起動 (up)", "up"), + ("再ビルド (rebuild --no-cache)", "rebuild"), + ("停止 (down)", "down"), + ] + return menu.select( + f"'{name}' は起動中です。操作を選択 " + "(↑↓ 移動 / Enter 決定 / ←・Esc 戻る / Ctrl-C 中止):", + choices, back=True, search=False) + + +def run(devbase_root: Path): + """プロジェクト操作カテゴリ。一覧選択 → up/rebuild/down を起動する。 + + 戻り値プロトコル (トップループが ``is`` 同一性で判定する): + - **操作を実行した場合**: ``dispatch_lifecycle`` の rc (``int``) を返す。 + 「実行したのでトップへ戻る、rc は呼び出し側が記憶」の意味。これにより + ``project up/down/rebuild`` の失敗が ``devbase list`` の終了コードへ伝搬する。 + - ``menu.MENU_BACK``: 一覧で Esc/← (操作なしでトップへ) / プロジェクト無し。 + - ``None``: 一覧・サブメニューで Ctrl-C による全体中止。 + + 選択行が running 中なら ``_select_action`` で up/rebuild/down を選ばせ、それ以外 + (stopped / unknown 等) は従来どおり直接 ``project up`` を起動する。サブメニューで + Esc/← を押すと (``MENU_BACK``) 一覧へ戻る。操作完了後はトップメニューへ復帰する + (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"): + action = _select_action(name) + if action is menu.MENU_BACK: + continue # 一覧へ戻る + if action is None: + return None # Ctrl-C → 全体中止 + rc = dispatch_lifecycle(action, name, scale=None) + 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/app.py b/lib/devbase/tui/app.py new file mode 100644 index 0000000..9a62158 --- /dev/null +++ b/lib/devbase/tui/app.py @@ -0,0 +1,128 @@ +"""トップ階層メニューとカテゴリ routing (`devbase list` の入口)。 + +``run(devbase_root, args)`` が ``cmd_project_list`` から呼ばれる新しい入口。 +プロジェクト一覧の選択だけだった旧挙動を、全カテゴリ +(project / env / plugin / snapshot / status) を束ねるトップ階層メニューへ拡張する。 + +PR1 では **project カテゴリのみ配線**し、env/plugin/snapshot/status は後続 PR +(PR3〜PR5) で各 ``actions_*`` を ``_route`` に足すまでプレースホルダ案内を出す。 + +後方互換 (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_project, 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) + # PR3: env, PR4: plugin, PR5: snapshot/status をここに追加する。 + 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..9bae6cd --- /dev/null +++ b/lib/devbase/tui/dispatch.py @@ -0,0 +1,46 @@ +"""既存コマンドハンドラへの薄い委譲層。 + +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 types +from pathlib import Path +from typing import Callable + + +def dispatch_lifecycle(subcommand: str, name: str | None = None, **attrs) -> int: + """``project [name]`` を共有ハンドラ ``cmd_project`` 経由で起動する。 + + ``name`` が指定されると ``_dispatch_lifecycle`` が対象ディレクトリへ chdir して + から実行する。``up`` の ``scale`` など各サブコマンド固有の属性は ``attrs`` で渡す + (未指定でも getattr の既定で吸収される)。 + """ + from devbase.commands.container import cmd_project + + ns = types.SimpleNamespace(subcommand=subcommand, name=name, **attrs) + 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_* が利用)。 + """ + ns = types.SimpleNamespace(subcommand=subcommand, **attrs) + 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..ee14637 --- /dev/null +++ b/lib/devbase/tui/menu.py @@ -0,0 +1,270 @@ +"""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`` を返す) + +テストではこのモジュールの関数を 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 を返す (トップメニュー / text・confirm・path 用)。 + + Ctrl-C と同じく ``KeyboardInterrupt`` で抜けるので ``ask()`` は ``None`` + (= 中止) を返す。戻り先が無い最上位メニューや引数収集プロンプトで使う。 + select だけでなく ``question.application`` を持つ text/confirm/path にも適用できる。 + """ + def _cancel(event): + event.app.exit(exception=KeyboardInterrupt, style="class:aborting") + + return _add_escape_binding(question, _cancel) + + +def _ask_with_escape(question): + """Esc→中止 (None) を仕込んでから ``ask()`` する共通ヘルパ (text/confirm/path 用)。 + + questionary の text/confirm/path は既定で Esc バインドを持たないため、選択メニューの + ナビ規約 (Esc=中止) と整合させるべく ``with_escape_cancel`` を適用してから問い合わせる。 + """ + return with_escape_cancel(question).ask() + + +def with_escape_back(question, *, bind_left: bool = True): + """Esc (と任意で ←) 押下で ``MENU_BACK`` を返す select を返す (サブメニュー用)。 + + 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) -> str | None: + """自由入力 (1 行) を収集する。中止 (Ctrl-C / Esc) は ``None`` を返す。 + + ``allow_empty=False`` のとき空文字は受け付けず再入力を促す。questionary 不在時は + stdlib ``input()`` で代替する。 + """ + if HAVE_QUESTIONARY: + while True: # 空 (allow_empty=False) は再入力。自己再帰を避け while で回す。 + ans = _ask_with_escape(questionary.text(message, default=default or "")) + if ans is None: + return None + 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) -> bool | None: + """y/n 確認を取る。中止 (Ctrl-C / Esc) は ``None`` を返す。 + + 破壊的操作 (down / delete / uninstall 等) の実行前確認に使う。 + """ + 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) -> int | None: + """整数を収集する (scale 等)。範囲外・非数値は再入力を促す。中止は ``None``。""" + 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: + return None + 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) -> str | None: + """ファイル / ディレクトリパスを収集する (export/import の dest/source 等)。 + + questionary 利用時は ``path`` プロンプト (補完付き)、不在時は ``input()`` 代替。 + 存在チェックは呼び出し側 (各ハンドラ) に委ねる。中止は ``None``。 + """ + if HAVE_QUESTIONARY: + while True: # 空 (allow_empty=False) は再入力。自己再帰を避け while で回す。 + ans = _ask_with_escape(questionary.path(message, default=default or "")) + if ans is None: + return None + 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_project.py b/tests/cli/tui/test_actions_project.py new file mode 100644 index 0000000..15e40f0 --- /dev/null +++ b/tests/cli/tui/test_actions_project.py @@ -0,0 +1,314 @@ +"""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", "down"]) +def test_run_running_row_shows_action_menu(monkeypatch, tmp_path, action): + """running 行を選ぶとサブメニューで up/rebuild/down を選び、その subcommand で起動する。""" + 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: "down") + 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_three_ops(monkeypatch): + captured = {} + + def fake_select(message, choices, *, back, search): + captured.update(back=back, search=search, + values=[c[1] for c in choices]) + return "rebuild" + + monkeypatch.setattr(menu, "select", fake_select) + assert actions_project._select_action("carmo") == "rebuild" + assert captured["back"] is True + assert captured["search"] is False + assert captured["values"] == ["up", "rebuild", "down"] + + +# --------------------------------------------------------------------------- +# 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_app.py b/tests/cli/tui/test_app.py new file mode 100644 index 0000000..eeae959 --- /dev/null +++ b/tests/cli/tui/test_app.py @@ -0,0 +1,217 @@ +"""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_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", "env", 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)) + # env は未実装カテゴリ (_route が MENU_BACK) → last_rc を維持 + + 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_unimplemented_category_returns_to_top(monkeypatch, tmp_path): + """未実装カテゴリ (env 等) はプレースホルダ案内を出してトップへ戻る。""" + selects = iter(["env", None]) + monkeypatch.setattr(menu, "select", lambda *a, **k: next(selects)) + # _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_unimplemented_returns_menu_back(tmp_path): + assert app._route("plugin", tmp_path) is menu.MENU_BACK diff --git a/tests/cli/tui/test_dispatch.py b/tests/cli/tui/test_dispatch.py new file mode 100644 index 0000000..f62a77e --- /dev/null +++ b/tests/cli/tui/test_dispatch.py @@ -0,0 +1,49 @@ +"""PLAN31_2 PR1: tui.dispatch (ハンドラ委譲層) のテスト。""" + +from __future__ import annotations + +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_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..ad51425 --- /dev/null +++ b/tests/cli/tui/test_menu.py @@ -0,0 +1,342 @@ +"""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_cancel(monkeypatch): + """questionary 経路の text に Esc→中止 (KeyboardInterrupt) バインドが付くこと。""" + 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["exception"] is KeyboardInterrupt + + +def test_confirm_questionary_binds_escape_cancel(monkeypatch): + """questionary 経路の confirm に Esc→中止バインドが付くこと。""" + 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["exception"] is KeyboardInterrupt + + +def test_path_questionary_binds_escape_cancel(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_escape_returns_none(monkeypatch): + """questionary 経路の text で Esc/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_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