diff --git a/lib/devbase/tui/actions_project.py b/lib/devbase/tui/actions_project.py index bf2792b..4fbd550 100644 --- a/lib/devbase/tui/actions_project.py +++ b/lib/devbase/tui/actions_project.py @@ -11,19 +11,14 @@ 収集ヘルパで CLI と同じ属性値を集め、破壊的な down は ``menu.confirm`` で確認する (plan 2.3 契約表 / 3.4 破壊的操作確認)。 -一覧表示・整形 (``list_projects`` / ``_build_menu_entries``) は ``commands/project`` -の純粋ロジックを再利用する (TUI からも CLI(table) からも共有)。 +プロジェクト一覧の表示・選択は ``tui.app`` (トップ画面) が担い、本モジュールは +選択された 1 行の処理 (``handle_row``) と questionary 不在時のフォールバックを提供する。 """ 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 @@ -31,20 +26,6 @@ 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]] = [ @@ -232,47 +213,23 @@ def _operation_menu(devbase_root: Path, name: str): return rc # 実行 rc → 呼び出し元へ -def run(devbase_root: Path): - """プロジェクト操作カテゴリ。一覧選択 → (running は操作サブメニュー / 他は up)。 +def handle_row(devbase_root: Path, row: dict): + """一覧で選択された 1 プロジェクト行を処理する (トップ画面から呼ばれる)。 戻り値プロトコル (トップループが ``is`` 同一性で判定する): - **操作を実行した場合**: ``dispatch_lifecycle`` の rc (``int``) を返す。 - 「実行したのでトップへ戻る、rc は呼び出し側が記憶」の意味。これにより + 「実行したので一覧へ戻る、rc は呼び出し側が記憶」の意味。これにより project 操作の失敗が ``devbase list`` の終了コードへ伝搬する。 - - ``menu.MENU_BACK``: 一覧で Esc/← (操作なしでトップへ) / プロジェクト無し。 - - ``None``: 一覧・サブメニューで Ctrl-C による全体中止。 + - ``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 + name = row["name"] + if str(row.get("status", "")).startswith("running"): + return _operation_menu(devbase_root, name) + return dispatch_lifecycle("up", name, scale=None) def fallback_select_and_up(rows: list[dict]) -> int: diff --git a/lib/devbase/tui/app.py b/lib/devbase/tui/app.py index 3ceb103..0fdda44 100644 --- a/lib/devbase/tui/app.py +++ b/lib/devbase/tui/app.py @@ -1,22 +1,21 @@ -"""トップ階層メニューとカテゴリ routing (`devbase list` の入口)。 +"""`devbase list` の入口: プロジェクト一覧を最上位画面とする TUI。 -``run(devbase_root, args)`` が ``cmd_project_list`` から呼ばれる新しい入口。 -プロジェクト一覧の選択だけだった旧挙動を、全カテゴリ -(project / env / plugin / snapshot / status) を束ねるトップ階層メニューへ拡張する。 - -PR1 で project、PR3 で env、PR4 で plugin、PR5 で snapshot/status を配線済みで、 -全カテゴリがトップ階層メニューから実行できる。 +``run(devbase_root, args)`` が ``cmd_project_list`` から呼ばれる入口。 +利用頻度が最も高い **プロジェクト一覧を起動直後のトップ画面** とし、 +プロジェクト選択 → (running なら操作サブメニュー / それ以外は up) を最短経路にする。 +env / plugin / snapshot / status は一覧の末尾に並ぶカテゴリ項目から遷移する。 後方互換 (plan 3.2): - ``--no-interactive`` / ``--plain`` (interactive=False) と非 TTY は従来どおり一覧 テーブルのみ。 -- questionary 不在時はトップメニューを出さず、従来の番号入力フォールバック +- questionary 不在時は一覧メニューを出さず、従来の番号入力フォールバック (project up) へ縮退して muscle-memory を保全する。 -- トップメニューでは「プロジェクト操作」を先頭に置き既定ハイライトとすることで、 - Enter 連打で従来の project 選択フローへ到達できるようにする。 +- 一覧は先頭プロジェクトを既定ハイライトとし、Enter 連打で従来の + 「最初のプロジェクトを up」へ最短到達できる。 -ナビ規約: トップメニューは Esc / Ctrl-C で中止 (戻り先なし)。各カテゴリ内では -Esc / ← でトップメニューへ戻る (``menu.MENU_BACK``)、Ctrl-C で全体中止 (``None``)。 +ナビ規約: トップ (プロジェクト一覧) は Esc / Ctrl-C で中止 (戻り先なし)。 +各カテゴリ・サブメニュー内では Esc / ← で 1 つ前へ戻る (``menu.MENU_BACK``)、 +Ctrl-C で全体中止 (``None``)。 """ from __future__ import annotations @@ -24,17 +23,21 @@ import sys from pathlib import Path -from devbase.commands.project import _print_table, list_projects +from devbase.commands.project import ( + _STATUS_COLOR, + _build_menu_entries, + _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)。 +# プロジェクト一覧の末尾に並べるカテゴリ (表示順)。``(key, label)`` で保持し、 +# 一覧メニューには ``label (key)`` 形式で表示する (key 入力での絞り込みも効く)。 TOP_CATEGORIES: list[tuple[str, str]] = [ - ("project", "プロジェクト操作"), ("env", "環境変数"), ("plugin", "プラグイン"), ("snapshot", "スナップショット"), @@ -49,14 +52,9 @@ def _route(category: str, devbase_root: Path): 戻り値は各カテゴリの戻り値プロトコルに従う: - 操作実行時はその rc (``int``) - - 操作なしでトップへ戻るときは ``menu.MENU_BACK`` + - 操作なしで一覧へ戻るときは ``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": @@ -65,12 +63,28 @@ def _route(category: str, devbase_root: Path): return actions_snapshot.run(devbase_root) if category == "status": return actions_status.run(devbase_root) - logger.info("「%s」は後続 PR で実装予定です。", _LABELS.get(category, category)) + logger.error("未知のカテゴリです: %s", _LABELS.get(category, category)) return menu.MENU_BACK +def _select_top(rows: list[dict]): + """トップ画面: プロジェクト一覧 + カテゴリ項目から 1 件選ばせる。 + + 戻り値: rows の index (``int`` = プロジェクト選択) / カテゴリ key (``str``) / + ``None`` (Esc・Ctrl-C → 終了)。プロジェクトとカテゴリは値の型で判別する。 + 件数が多いため文字入力での絞り込み (search=True) を有効にする。 + """ + entries = _build_menu_entries(rows, colorize=_STATUS_COLOR) + choices: list[tuple[str, object]] = [(entry, i) for i, entry in enumerate(entries)] + choices += [(f"{label} ({key})", key) for key, label in TOP_CATEGORIES] + return menu.select( + "プロジェクトまたは操作を選択 " + "(↑↓ 移動 / 名前で絞り込み / Enter 決定 / Esc・Ctrl-C 終了):", + choices, back=False, search=True) + + def _top_menu_loop(devbase_root: Path) -> int: - """トップ階層メニューのループ。 + """トップ画面 (プロジェクト一覧) のループ。 最後に実行した操作の rc (``last_rc``) を記憶し、中止時はそれを返すことで ``project up/down/rebuild`` の失敗が ``devbase list`` の終了コードへ伝搬する。 @@ -79,24 +93,33 @@ def _top_menu_loop(devbase_root: Path) -> int: 判定は必ず ``is`` 同一性で行う (rc=0 を ``None`` / ``MENU_BACK`` と誤マッチさせない)。 """ last_rc = 0 + projects_dir = Path(devbase_root) / "projects" while True: - choice = menu.select( - "操作カテゴリを選択 (↑↓ 移動 / Enter 決定 / Esc・Ctrl-C 中止):", - list(TOP_CATEGORIES), back=False, search=False) - if choice is None: + rows = list_projects(projects_dir) + if not rows: + # プロジェクト未作成でもカテゴリ操作 (env/plugin/...) は使えるため + # 終了せず案内だけ出して一覧 (カテゴリのみ) を表示する。 + logger.info("プロジェクトがありません (%s)。", projects_dir) + + sel = _select_top(rows) + if sel is None: # トップで Esc / Ctrl-C → これまでの実行 rc を返して終了 logger.info("中止しました。") return last_rc - result = _route(choice, devbase_root) + if isinstance(sel, str): + result = _route(sel, devbase_root) + else: + result = actions_project.handle_row(devbase_root, rows[sel]) + if result is None: - # カテゴリ内で Ctrl-C → 全体中止 (直近の実行 rc を返す) + # カテゴリ・サブメニュー内で Ctrl-C → 全体中止 (直近の実行 rc を返す) logger.info("中止しました。") return last_rc if result is menu.MENU_BACK: - # 操作なしでトップへ戻り再表示 (rc は更新しない) + # 操作なしで一覧へ戻り再表示 (rc は更新しない) continue - # int rc: 操作を実行した → rc を記憶してトップ再表示 + # int rc: 操作を実行した → rc を記憶して一覧を再表示 last_rc = result @@ -105,7 +128,7 @@ def run(devbase_root: Path, args) -> int: - interactive=False / 非 TTY: 一覧テーブルのみ (従来挙動)。 - questionary 不在: 番号入力フォールバック (project up) へ縮退。 - - それ以外: トップ階層メニューを開く。 + - それ以外: プロジェクト一覧トップの階層メニューを開く。 """ projects_dir = Path(devbase_root) / "projects" diff --git a/lib/devbase/tui/menu.py b/lib/devbase/tui/menu.py index 90d3077..f5331c0 100644 --- a/lib/devbase/tui/menu.py +++ b/lib/devbase/tui/menu.py @@ -47,10 +47,27 @@ # キーバインド (Esc / ←) # --------------------------------------------------------------------------- +def _add_key_binding(question, key, handler): + """生成済み ``Question.application`` にキーハンドラを後付けする共通処理。 + + select の application は素の ``KeyBindings`` を持つが、confirm/text/path は + ``merge_key_bindings`` 済みの ``_MergedKeyBindings`` (``add`` を持たない) の + ため、直接 ``add`` せず新しい ``KeyBindings`` を作って再マージする。 + """ + from prompt_toolkit.key_binding import KeyBindings, merge_key_bindings + + kb = KeyBindings() + kb.add(key)(handler) + existing = question.application.key_bindings + question.application.key_bindings = ( + merge_key_bindings([existing, kb]) if existing is not None else kb) + return question + + def _add_escape_binding(question, handler): - """questionary の select に Esc 単独押下のハンドラを後付けする共通処理。 + """questionary の question に Esc 単独押下のハンドラを後付けする共通処理。 - questionary 2.x の select は Ctrl-C / Ctrl-Q しか割り当てないため、生成済み + questionary 2.x は Ctrl-C / Ctrl-Q しか割り当てないため、生成済み ``Question.application`` の key_bindings に Escape ハンドラを足す。 Escape は矢印キー等のエスケープシーケンス (``\\x1b[A`` 等) の先頭バイトでも @@ -59,8 +76,7 @@ def _add_escape_binding(question, handler): """ from prompt_toolkit.keys import Keys - question.application.key_bindings.add(Keys.Escape)(handler) - return question + return _add_key_binding(question, Keys.Escape, handler) def with_escape_cancel(question): @@ -102,11 +118,15 @@ def with_escape_back(question, *, bind_left: bool = True): from prompt_toolkit.keys import Keys def _back(event): + # 戻る操作で残る「質問行 (未回答のまま collapse した行)」は次のメニュー描画と + # 重なり 1 行ずれの原因になるため、exit 前に erase_when_done を立てて + # プロンプト描画ごと消去する (Enter での通常回答行は従来どおり残る)。 + event.app.erase_when_done = True event.app.exit(result=MENU_BACK) - _add_escape_binding(question, _back) # Esc(互換・低速) + _add_escape_binding(question, _back) # Esc(互換・低速) if bind_left: - question.application.key_bindings.add(Keys.Left)(_back) # ←(即時) + _add_key_binding(question, Keys.Left, _back) # ←(即時) return question diff --git a/tests/cli/tui/test_actions_project.py b/tests/cli/tui/test_actions_project.py index 07de300..dac56b8 100644 --- a/tests/cli/tui/test_actions_project.py +++ b/tests/cli/tui/test_actions_project.py @@ -11,213 +11,91 @@ 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 +# handle_row(): 選択行の処理 (running → 操作サブメニュー / 他は直接 up) # --------------------------------------------------------------------------- +def _row(status): + return {"name": "carmo", "plugin": "p", "status": status} + + @pytest.mark.parametrize("action", ["up", "rebuild"]) -def test_run_running_row_shows_action_menu(monkeypatch, tmp_path, action): - """running 行を選ぶとサブメニューで操作を選び、引数不要の up/rebuild は即起動する。""" +def test_handle_row_running_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) + result = actions_project.handle_row(tmp_path, _row("running (2 containers)")) 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 を返す (終了コード伝搬)。""" +def test_handle_row_propagates_nonzero_dispatch_rc(monkeypatch, tmp_path): + """dispatch が非0 (失敗) を返したら handle_row もその 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 がトップへ伝搬する" + assert actions_project.handle_row(tmp_path, _row("running (1 containers)")) == 1 @pytest.mark.parametrize("status", ["stopped", "unknown"]) -def test_run_non_running_row_direct_up(monkeypatch, tmp_path, status): - """非 running 行はサブメニューを出さず直接 up する。""" +def test_handle_row_non_running_direct_up(monkeypatch, tmp_path, status): + """非 running 行はサブメニューを出さず直接 up する (PR1 非回帰)。""" 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) + result = actions_project.handle_row(tmp_path, _row(status)) 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 を返す)。""" +def test_handle_row_action_menu_back_returns_menu_back(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") - monkeypatch.setattr(status_mod, "_container_status_for", lambda entry, counts=None: None) - monkeypatch.setattr(actions_project, "_select_project", lambda rows: None) + monkeypatch.setattr(actions_project, "_select_action", lambda name: menu.MENU_BACK) called = [] monkeypatch.setattr(container_mod, "cmd_project", lambda args: called.append(1) or 0) - assert actions_project.run(tmp_path) is None + assert actions_project.handle_row( + tmp_path, _row("running (1 containers)")) is menu.MENU_BACK assert called == [] -def test_run_action_menu_back_returns_to_list(monkeypatch, tmp_path): - """running 行のサブメニューで Esc/← (MENU_BACK) を押すと一覧へ戻る。""" +def test_handle_row_action_menu_ctrl_c_aborts(monkeypatch, tmp_path): + """running 行のサブメニューで 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") - _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 actions_project.handle_row(tmp_path, _row("running (1 containers)")) 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 への委譲 +# _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 = {} diff --git a/tests/cli/tui/test_app.py b/tests/cli/tui/test_app.py index f3504cd..d2192ac 100644 --- a/tests/cli/tui/test_app.py +++ b/tests/cli/tui/test_app.py @@ -131,90 +131,116 @@ def test_run_interactive_opens_top_menu(tmp_path, monkeypatch): # --------------------------------------------------------------------------- -# トップ階層メニュー: routing +# トップ画面: プロジェクト一覧 + カテゴリ項目 # --------------------------------------------------------------------------- -def test_top_menu_project_first_highlighted(): - """「プロジェクト操作」が先頭 (既定ハイライト) で従来フローへ Enter 連打到達できる。""" - assert app.TOP_CATEGORIES[0] == ("project", "プロジェクト操作") +_ROWS = [{"name": "carmo", "plugin": "p", "status": "stopped"}, + {"name": "beta", "plugin": "q", "status": "running (1 containers)"}] -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)) +def _patch_loop(monkeypatch, selects, rows=None): + """_top_menu_loop の入力 (一覧と選択値) を注入する共通ヘルパ。""" + monkeypatch.setattr(app, "list_projects", + lambda projects_dir: list(_ROWS) if rows is None else rows) + it = iter(selects) + monkeypatch.setattr(app, "_select_top", lambda r: next(it)) - routed = [] - monkeypatch.setattr(actions_project, "run", - lambda root: routed.append(root) or menu.MENU_BACK) + +def test_select_top_appends_categories_after_projects(monkeypatch): + """トップ一覧はプロジェクト行が先頭、カテゴリ項目が末尾に並ぶ。""" + 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 0 + + monkeypatch.setattr(menu, "select", fake_select) + assert app._select_top(_ROWS) == 0 + assert captured["back"] is False, "トップは Esc=終了 (戻り先なし)" + assert captured["search"] is True, "名前絞り込みを有効化" + assert captured["values"][:2] == [0, 1], "プロジェクトは rows index" + assert captured["values"][2:] == ["env", "plugin", "snapshot", "status"] + assert captured["titles"][2] == "環境変数 (env)", "ラベル (key) 形式で表示" + + +def test_top_loop_project_selection_delegates_handle_row(monkeypatch, tmp_path): + """プロジェクト選択 (int) は actions_project.handle_row へ該当行を渡す。""" + _patch_loop(monkeypatch, [1, None]) + handled = [] + monkeypatch.setattr(actions_project, "handle_row", + lambda root, row: handled.append((root, row["name"])) or 0) rc = app._top_menu_loop(tmp_path) assert rc == 0 - assert routed == [tmp_path], "project カテゴリへ 1 回 routing される" + assert handled == [(tmp_path, "beta")], "選択 index の行が handle_row へ渡る" -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) +def test_top_loop_propagates_executed_rc(monkeypatch, tmp_path): + """操作実行で非0 rc が返ると、その後トップで中止しても rc がループ戻り値へ伝搬する。""" + _patch_loop(monkeypatch, [0, None]) + monkeypatch.setattr(actions_project, "handle_row", lambda root, row: 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 を維持 +def test_top_loop_back_does_not_overwrite_last_rc(monkeypatch, tmp_path): + """実行 rc を記憶後、カテゴリが MENU_BACK を返しても last_rc は上書きされない。""" + _patch_loop(monkeypatch, [0, "snapshot", None]) + monkeypatch.setattr(actions_project, "handle_row", lambda root, row: 1) 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): +def test_top_loop_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) + _patch_loop(monkeypatch, [0, None]) + monkeypatch.setattr(actions_project, "handle_row", lambda root, row: 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) +def test_top_loop_escape_exits(monkeypatch, tmp_path): + """トップ (一覧) で Esc/Ctrl-C (None) を押すと即終了 (rc=0)。""" + _patch_loop(monkeypatch, [None]) + handled = [] + monkeypatch.setattr(actions_project, "handle_row", + lambda root, row: handled.append(1) or 0) + assert app._top_menu_loop(tmp_path) == 0 - assert routed == [] + assert handled == [] -def test_top_menu_category_ctrl_c_aborts_whole_app(monkeypatch, tmp_path): +def test_top_loop_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 + _patch_loop(monkeypatch, ["env"]) + from devbase.tui import actions_env + monkeypatch.setattr(actions_env, "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)) +def test_top_loop_category_back_redisplays_list(monkeypatch, tmp_path): + """カテゴリが操作なし (MENU_BACK) で戻ったら一覧を再表示する。""" + _patch_loop(monkeypatch, ["snapshot", None]) 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 + + assert app._top_menu_loop(tmp_path) == 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_top_loop_empty_projects_still_offers_categories(monkeypatch, tmp_path): + """プロジェクト 0 件でも終了せず、カテゴリ操作 (status 等) が選べる。""" + _patch_loop(monkeypatch, ["status", None], rows=[]) + from devbase.tui import actions_status + ran = [] + monkeypatch.setattr(actions_status, "run", lambda root: ran.append(1) or 0) + + assert app._top_menu_loop(tmp_path) == 0 + assert ran == [1], "プロジェクト無しでもカテゴリへ遷移できる" def test_route_plugin_delegates(monkeypatch, tmp_path): diff --git a/tests/cli/tui/test_menu.py b/tests/cli/tui/test_menu.py index a0243da..b3918d1 100644 --- a/tests/cli/tui/test_menu.py +++ b/tests/cli/tui/test_menu.py @@ -76,6 +76,42 @@ def test_with_escape_back_bind_left_false_skips_left(): assert left == [], "search 有効メニューでは ← を入力カーソル用に空ける" +def test_with_escape_back_works_on_merged_key_bindings(monkeypatch): + """confirm/text/path の application は ``_MergedKeyBindings`` (``add`` 無し) を + 持つため、直接 ``add`` せず再マージ方式で Esc を後付けできること + (実 TTY での AttributeError クラッシュの回帰検証。monkeypatch なしの実 question)。""" + questionary = pytest.importorskip("questionary") + from prompt_toolkit.keys import Keys + + monkeypatch.setenv("TERM", "dumb") # CI 等の端末差異を吸収 + for q in (questionary.confirm("ok?", default=False), + questionary.text("name?"), + questionary.path("path?")): + menu.with_escape_back(q, bind_left=False) # AttributeError を出さないこと + # text/path は auto-suggest 由来の (Escape, f) を持つため単独 Esc のみ数える + esc = [b for b in q.application.key_bindings.bindings + if tuple(b.keys) == (Keys.Escape,)] + assert len(esc) == 1, f"{type(q)} に Esc が後付けされる" + + +def test_back_handler_sets_erase_when_done(): + """Esc/← の戻りは erase_when_done を立ててから exit し、未回答のまま collapse + した質問行が残って次メニューと重なる「1 行ずれ」を防ぐこと。""" + 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) + + esc = [b for b in q.application.key_bindings.bindings if Keys.Escape in b.keys] + captured = {} + fake_app = types.SimpleNamespace(exit=lambda **kw: captured.update(kw), + erase_when_done=False) + esc[0].handler(types.SimpleNamespace(app=fake_app)) + assert fake_app.erase_when_done is True, "戻る時は描画を消去する" + assert captured == {"result": menu.MENU_BACK} + + # --------------------------------------------------------------------------- # select: バインドの仕込みと戻り値 # ---------------------------------------------------------------------------