diff --git a/lib/devbase/tui/actions_snapshot.py b/lib/devbase/tui/actions_snapshot.py new file mode 100644 index 0000000..5ca3f40 --- /dev/null +++ b/lib/devbase/tui/actions_snapshot.py @@ -0,0 +1,214 @@ +"""snapshot カテゴリの TUI 操作フロー (PLAN31_2 PR5)。 + +サブコマンド選択メニュー → 引数収集 → ``dispatch_group(cmd_snapshot, ...)`` で +既存ハンドラへ委譲する。属性契約は plan 2.3 の表 (cli.py ``_add_snapshot_parser`` +と同期済みを確認): + +- create: ``name`` (None=タイムスタンプ自動命名), ``full`` (False) +- list: 追加属性なし +- restore: ``name``, ``point`` (None=全差分適用 / manager は 1 以上のみ受理) +- copy: ``name``, ``new_name`` +- delete: ``name`` +- rotate: ``keep`` (3) + +破壊的な restore / delete は実行前に ``menu.confirm`` で確認する (plan 3.4)。 +restore は ``cmd_snapshot`` 側にも TTY 時の input() 確認が残るが、TUI の規約として +メニュー段階でも確認する (多重確認になっても安全側に倒す)。 + +restore / copy / delete の対象 ``name`` は ``SnapshotManager.list()`` の既存一覧 +から選択させる (タイプミス防止)。一覧の取得に失敗した場合のみ自由入力へ縮退する。 +""" + +from __future__ import annotations + +from pathlib import Path + +from devbase.commands.snapshot import cmd_snapshot +from devbase.log import get_logger +from devbase.snapshot.manager import SnapshotManager +from devbase.tui import menu +from devbase.tui.dispatch import dispatch_group + +logger = get_logger(__name__) + +# snapshot カテゴリで選べる操作 (表示順 = ハイライト既定順)。閲覧のみで安全な +# list を先頭に置き、Enter 連打では破壊的操作に到達しないようにする。 +# 各 value は cmd_snapshot のサブコマンド名。 +_SNAPSHOT_OPS: list[tuple[str, str]] = [ + ("一覧表示 (list)", "list"), + ("作成 (create)", "create"), + ("復元 (restore)", "restore"), + ("複製 (copy)", "copy"), + ("削除 (delete)", "delete"), + ("ローテーション (rotate)", "rotate"), +] + +# 引数収集を Esc/Ctrl-C で中止したことを示す番兵 (= サブメニューへ戻る)。 +# dispatch の rc (int) や ``None`` (= 全体中止) と区別する (actions_project と同じ規約)。 +_ARG_CANCEL = object() + + +def _select_operation(): + """snapshot の操作を選ぶサブメニュー。 + + 戻り値: サブコマンド文字列 / ``MENU_BACK`` (Esc・← → トップへ戻る) / ``None`` (Ctrl-C 中止)。 + """ + return menu.select( + "スナップショット操作を選択 " + "(↑↓ 移動 / Enter 決定 / ←・Esc 戻る / Ctrl-C 中止):", + list(_SNAPSHOT_OPS), back=True, search=False) + + +def _select_snapshot_name(devbase_root: Path, message: str): + """restore/copy/delete の対象スナップショット名を既存一覧から選ばせる。 + + 戻り値: スナップショット名 (``str``) / ``_ARG_CANCEL`` (Esc・Ctrl-C 中止、 + または対象が 1 件もない)。一覧の取得に失敗した場合は自由入力へ縮退する + (存在チェックは委譲先の ``SnapshotManager`` が行う)。 + """ + try: + snapshots = SnapshotManager(Path(devbase_root)).list() + except Exception: + logger.debug("スナップショット一覧の取得に失敗しました", exc_info=True) + snapshots = None + + if snapshots is None: + # 一覧が取れない環境では名前を直接入力させる (中止は None → _ARG_CANCEL)。 + name = menu.text(message, allow_empty=False) + return _ARG_CANCEL if name is None else name + + if not snapshots: + logger.info("スナップショットがありません。先に作成 (create) してください。") + return _ARG_CANCEL + + # 作成日時を添えて選びやすくする (値は名前のみ)。件数が多い場合に備え + # 文字入力での絞り込み (search=True) を有効化。search 有効時の戻る操作は + # Esc のみ (menu.select が ← バインドを外す)。 + choices = [ + (f"{s.get('name', '?')} ({str(s.get('created_at') or 'N/A')[:19]})", + s.get("name")) + for s in snapshots + ] + sel = menu.select( + f"{message} (↑↓ 移動 / 名前で絞り込み / Enter 決定 / Esc 戻る / Ctrl-C 中止):", + choices, back=True, search=True) + if sel is menu.MENU_BACK or sel is None: + return _ARG_CANCEL + return sel + + +def _optional_point(message: str): + """restore の ``--point`` を収集する (空入力 = 全差分適用 = None)。 + + 戻り値: ``int`` / ``None`` (空入力) / ``_ARG_CANCEL`` (Esc・Ctrl-C 中止)。 + ``menu.integer`` は空入力で既定値を返す仕様のため、空 = None を表現したい + optional な数値はこちらで扱う (actions_project._optional_int と同じ理由)。 + ``SnapshotManager.restore`` は point に正の整数のみ受理するため 1 以上を要求する。 + """ + while True: + raw = menu.text(message, allow_empty=True) + if raw is None: + return _ARG_CANCEL + if raw == "": + return None + try: + value = int(raw) + except ValueError: + logger.error("整数で指定してください: %r", raw) + continue + if value < 1: + logger.error("1 以上で指定してください。") + continue + return value + + +def _run_operation(devbase_root: Path, op: str): + """選択された操作の引数を収集して ``dispatch_group`` で ``cmd_snapshot`` へ委譲する。 + + 戻り値: dispatch の rc (``int``) / ``_ARG_CANCEL`` (引数収集を中止 = サブメニューへ戻る)。 + 破壊的な restore / delete は ``menu.confirm`` で確認し、拒否時は実行しない (plan 3.4)。 + """ + if op == "list": + return dispatch_group(cmd_snapshot, devbase_root, "list") + + if op == "create": + name = menu.text("スナップショット名 (空でタイムスタンプ自動命名)", + allow_empty=True) + if name is None: + return _ARG_CANCEL + full = menu.confirm("フルバックアップを強制しますか (--full)?", default=False) + if full is None: + return _ARG_CANCEL + # 空入力は CLI の --name 省略と同じ None (自動命名) に正規化する。 + return dispatch_group(cmd_snapshot, devbase_root, "create", + name=name or None, full=full) + + if op == "restore": + name = _select_snapshot_name(devbase_root, "復元するスナップショットを選択") + if name is _ARG_CANCEL: + return _ARG_CANCEL + point = _optional_point("適用する差分番号 incr-N の上限 (--point / 空で全差分適用)") + if point is _ARG_CANCEL: + return _ARG_CANCEL + point_msg = f" (incr-{point:03d} まで)" if point is not None else "" + ok = menu.confirm( + f"'{name}'{point_msg} から復元しますか? 現在のボリュームデータは上書きされます。", + default=False) + if not ok: # False (拒否) / None (中止) → 実行しない + return _ARG_CANCEL + return dispatch_group(cmd_snapshot, devbase_root, "restore", + name=name, point=point) + + if op == "copy": + name = _select_snapshot_name(devbase_root, "複製元のスナップショットを選択") + if name is _ARG_CANCEL: + return _ARG_CANCEL + new_name = menu.text("複製先のスナップショット名", allow_empty=False) + if new_name is None: + return _ARG_CANCEL + return dispatch_group(cmd_snapshot, devbase_root, "copy", + name=name, new_name=new_name) + + if op == "delete": + name = _select_snapshot_name(devbase_root, "削除するスナップショットを選択") + if name is _ARG_CANCEL: + return _ARG_CANCEL + ok = menu.confirm(f"スナップショット '{name}' を削除しますか?", default=False) + if not ok: # False (拒否) / None (中止) → 実行しない + return _ARG_CANCEL + return dispatch_group(cmd_snapshot, devbase_root, "delete", name=name) + + if op == "rotate": + # keep=0 は manager 実装上 no-op (空スライス) のため 1 以上を要求する。 + keep = menu.integer("保持する世代数 (--keep)", default=3, min_value=1) + if keep is None: + return _ARG_CANCEL + return dispatch_group(cmd_snapshot, devbase_root, "rotate", keep=keep) + + # 到達しない (メニュー値は _SNAPSHOT_OPS に限定される)。保守的に no-op。 + logger.error("未知の操作です: %s", op) + return _ARG_CANCEL + + +def run(devbase_root: Path): + """スナップショット操作カテゴリ。操作選択 → 引数収集 → 実行。 + + 戻り値プロトコル (トップループが ``is`` 同一性で判定する。actions_project と同じ): + - **操作を実行した場合**: ``dispatch_group`` の rc (``int``) を返す。 + 「実行したのでトップへ戻る、rc は呼び出し側が記憶」の意味。 + - ``menu.MENU_BACK``: 操作メニューで Esc/← (操作なしでトップへ)。 + - ``None``: Ctrl-C による全体中止。 + + 引数収集を中止 (``_ARG_CANCEL``) した場合は操作メニューを再表示する。 + 操作完了後はトップメニューへ復帰する (plan 3.5 状態遷移: Exec → Top)。 + """ + while True: + op = _select_operation() + if op is menu.MENU_BACK: + return menu.MENU_BACK + if op is None: + return None + rc = _run_operation(devbase_root, op) + if rc is _ARG_CANCEL: + continue # 引数収集を中止 → 操作メニューへ戻る + return rc # 実行 rc → 呼び出し元 (トップ) へ diff --git a/lib/devbase/tui/actions_status.py b/lib/devbase/tui/actions_status.py new file mode 100644 index 0000000..3ea2454 --- /dev/null +++ b/lib/devbase/tui/actions_status.py @@ -0,0 +1,18 @@ +"""status カテゴリの TUI 操作フロー (PLAN31_2 PR5)。 + +status は閲覧のみでサブコマンドも引数も持たない (``cmd_status(devbase_root)`` / +plan 2.2)。そのためメニューや引数収集を介さず、表示してそのまま rc を返す +薄い委譲に留める。rc (``int``) を返すことで「操作を実行した → トップへ復帰」 +の戻り値プロトコル (actions_project と同じ) に従い、トップループが rc を記憶する。 +""" + +from __future__ import annotations + +from pathlib import Path + +from devbase.commands.status import cmd_status + + +def run(devbase_root: Path) -> int: + """ステータスを表示し、rc を返してトップメニューへ復帰する。""" + return cmd_status(Path(devbase_root)) diff --git a/lib/devbase/tui/app.py b/lib/devbase/tui/app.py index 5542a8c..3ceb103 100644 --- a/lib/devbase/tui/app.py +++ b/lib/devbase/tui/app.py @@ -4,8 +4,8 @@ プロジェクト一覧の選択だけだった旧挙動を、全カテゴリ (project / env / plugin / snapshot / status) を束ねるトップ階層メニューへ拡張する。 -PR1 で project、PR3 で env、PR4 で plugin カテゴリを配線済み。snapshot/status は -後続 PR (PR5) で各 ``actions_*`` を ``_route`` に足すまでプレースホルダ案内を出す。 +PR1 で project、PR3 で env、PR4 で plugin、PR5 で snapshot/status を配線済みで、 +全カテゴリがトップ階層メニューから実行できる。 後方互換 (plan 3.2): - ``--no-interactive`` / ``--plain`` (interactive=False) と非 TTY は従来どおり一覧 @@ -26,7 +26,8 @@ from devbase.commands.project import _print_table, list_projects from devbase.log import get_logger -from devbase.tui import actions_env, actions_plugin, actions_project, menu +from devbase.tui import (actions_env, actions_plugin, actions_project, + actions_snapshot, actions_status, menu) logger = get_logger(__name__) @@ -60,7 +61,10 @@ def _route(category: str, devbase_root: Path): return actions_env.run(devbase_root) if category == "plugin": return actions_plugin.run(devbase_root) - # PR5: snapshot/status をここに追加する。 + if category == "snapshot": + return actions_snapshot.run(devbase_root) + if category == "status": + return actions_status.run(devbase_root) logger.info("「%s」は後続 PR で実装予定です。", _LABELS.get(category, category)) return menu.MENU_BACK diff --git a/tests/cli/tui/test_actions_snapshot.py b/tests/cli/tui/test_actions_snapshot.py new file mode 100644 index 0000000..4e9801b --- /dev/null +++ b/tests/cli/tui/test_actions_snapshot.py @@ -0,0 +1,398 @@ +"""PLAN31_2 PR5: tui.actions_snapshot (snapshot カテゴリ操作) のテスト。 + +`menu.*` を monkeypatch して選択・入力値を注入し、`cmd_snapshot` を mock して +plan 2.3 の契約どおりの属性を持つ Namespace で呼ばれることを検証する +(test_actions_project.py のパターン踏襲)。 +""" + +from __future__ import annotations + +import pytest + +from devbase.tui import actions_snapshot, menu + + +def _capture_dispatch(monkeypatch): + """cmd_snapshot の呼び出し引数を全属性キャプチャするヘルパ。 + + actions_snapshot は ``cmd_snapshot`` をモジュール global として参照するため、 + actions_snapshot 側を monkeypatch する。 + """ + captured = {} + + def _spy(devbase_root, args): + captured["devbase_root"] = devbase_root + captured["subcommand"] = args.subcommand + for k in ("name", "full", "point", "new_name", "keep"): + if hasattr(args, k): + captured[k] = getattr(args, k) + return 0 + + monkeypatch.setattr(actions_snapshot, "cmd_snapshot", _spy) + return captured + + +def _no_dispatch(monkeypatch): + """cmd_snapshot が呼ばれないことを検証するためのスパイ。""" + called = [] + monkeypatch.setattr(actions_snapshot, "cmd_snapshot", + lambda root, args: called.append(1) or 0) + return called + + +# --------------------------------------------------------------------------- +# _select_operation: menu.select への委譲 +# --------------------------------------------------------------------------- + +def test_select_operation_lists_all_ops(monkeypatch): + captured = {} + + def fake_select(message, choices, *, back, search): + captured.update(back=back, search=search, + values=[c[1] for c in choices]) + return "create" + + monkeypatch.setattr(menu, "select", fake_select) + assert actions_snapshot._select_operation() == "create" + assert captured["back"] is True + assert captured["search"] is False + # 安全な list を先頭にしつつ全 6 操作を提示する。 + assert captured["values"] == [ + "list", "create", "restore", "copy", "delete", "rotate"] + assert captured["values"][0] == "list", "Enter 連打で破壊的操作に到達しない" + + +# --------------------------------------------------------------------------- +# run(): 操作メニューの遷移 (Esc/← / Ctrl-C / 引数中止の再表示 / rc 伝搬) +# --------------------------------------------------------------------------- + +def test_run_back_returns_to_top(monkeypatch, tmp_path): + """操作メニューで Esc/← (MENU_BACK) を押すとトップへ戻る (何も起動しない)。""" + called = _no_dispatch(monkeypatch) + monkeypatch.setattr(actions_snapshot, "_select_operation", lambda: menu.MENU_BACK) + assert actions_snapshot.run(tmp_path) is menu.MENU_BACK + assert called == [] + + +def test_run_ctrl_c_aborts(monkeypatch, tmp_path): + """操作メニューで Ctrl-C (None) を押すと全体中止 (None を返す)。""" + called = _no_dispatch(monkeypatch) + monkeypatch.setattr(actions_snapshot, "_select_operation", lambda: None) + assert actions_snapshot.run(tmp_path) is None + assert called == [] + + +def test_run_executes_and_returns_rc(monkeypatch, tmp_path): + """操作を実行したら dispatch の rc を返す (非0 もそのまま伝搬)。""" + monkeypatch.setattr(actions_snapshot, "_select_operation", lambda: "list") + monkeypatch.setattr(actions_snapshot, "_run_operation", lambda root, op: 1) + assert actions_snapshot.run(tmp_path) == 1 + + +def test_run_arg_cancel_reshows_menu(monkeypatch, tmp_path): + """引数収集を中止 (_ARG_CANCEL) すると操作メニューを再表示し、再選択で実行する。""" + select_calls = [] + # 1 回目: delete (→ 引数収集中止) / 2 回目: list (→ 実行) + monkeypatch.setattr(actions_snapshot, "_select_operation", + lambda: (select_calls.append(1), + "delete" if len(select_calls) == 1 else "list")[1]) + + run_calls = [] + + def fake_run_op(root, op): + run_calls.append(op) + return actions_snapshot._ARG_CANCEL if op == "delete" else 0 + + monkeypatch.setattr(actions_snapshot, "_run_operation", fake_run_op) + + assert actions_snapshot.run(tmp_path) == 0 + assert run_calls == ["delete", "list"] + assert len(select_calls) == 2, "引数中止で操作メニューが再表示される" + + +# --------------------------------------------------------------------------- +# _run_operation: 各操作の引数収集 + dispatch 契約 (plan 2.3) +# --------------------------------------------------------------------------- + +def test_run_operation_list_no_extra_attrs(monkeypatch, tmp_path): + """list は引数収集なしで即委譲する (追加属性なし)。""" + captured = _capture_dispatch(monkeypatch) + assert actions_snapshot._run_operation(tmp_path, "list") == 0 + assert captured == {"devbase_root": tmp_path, "subcommand": "list"} + + +def test_run_operation_create_collects_name_and_full(monkeypatch, tmp_path): + captured = _capture_dispatch(monkeypatch) + monkeypatch.setattr(menu, "text", lambda *a, **k: "snap1") + monkeypatch.setattr(menu, "confirm", lambda *a, **k: True) # --full + assert actions_snapshot._run_operation(tmp_path, "create") == 0 + assert captured["subcommand"] == "create" + assert captured["name"] == "snap1" and captured["full"] is True + + +def test_run_operation_create_empty_name_is_none(monkeypatch, tmp_path): + """空入力の name は CLI の --name 省略と同じ None (自動命名) に正規化する。""" + captured = _capture_dispatch(monkeypatch) + monkeypatch.setattr(menu, "text", lambda *a, **k: "") + monkeypatch.setattr(menu, "confirm", lambda *a, **k: False) + assert actions_snapshot._run_operation(tmp_path, "create") == 0 + assert captured["name"] is None and captured["full"] is False + + +def test_run_operation_create_name_cancel(monkeypatch, tmp_path): + called = _no_dispatch(monkeypatch) + monkeypatch.setattr(menu, "text", lambda *a, **k: None) # Esc/Ctrl-C + assert actions_snapshot._run_operation( + tmp_path, "create") is actions_snapshot._ARG_CANCEL + assert called == [] + + +def test_run_operation_create_full_cancel(monkeypatch, tmp_path): + called = _no_dispatch(monkeypatch) + monkeypatch.setattr(menu, "text", lambda *a, **k: "snap1") + monkeypatch.setattr(menu, "confirm", lambda *a, **k: None) # Esc/Ctrl-C + assert actions_snapshot._run_operation( + tmp_path, "create") is actions_snapshot._ARG_CANCEL + assert called == [] + + +def test_run_operation_restore_confirmed(monkeypatch, tmp_path): + """restore は confirm=True で name/point を契約どおり渡す (plan 3.4)。""" + captured = _capture_dispatch(monkeypatch) + monkeypatch.setattr(actions_snapshot, "_select_snapshot_name", + lambda root, msg: "snap1") + monkeypatch.setattr(actions_snapshot, "_optional_point", lambda msg: 2) + monkeypatch.setattr(menu, "confirm", lambda *a, **k: True) + assert actions_snapshot._run_operation(tmp_path, "restore") == 0 + assert captured["subcommand"] == "restore" + assert captured["name"] == "snap1" and captured["point"] == 2 + + +def test_run_operation_restore_point_empty_is_none(monkeypatch, tmp_path): + captured = _capture_dispatch(monkeypatch) + monkeypatch.setattr(actions_snapshot, "_select_snapshot_name", + lambda root, msg: "snap1") + monkeypatch.setattr(actions_snapshot, "_optional_point", lambda msg: None) + monkeypatch.setattr(menu, "confirm", lambda *a, **k: True) + assert actions_snapshot._run_operation(tmp_path, "restore") == 0 + assert captured["point"] is None + + +@pytest.mark.parametrize("confirm_ret", [False, None]) +def test_run_operation_restore_cancelled_does_not_dispatch( + monkeypatch, tmp_path, confirm_ret): + """restore の confirm を拒否 (False) / 中止 (None) したら実行しない (plan 3.4)。""" + called = _no_dispatch(monkeypatch) + monkeypatch.setattr(actions_snapshot, "_select_snapshot_name", + lambda root, msg: "snap1") + monkeypatch.setattr(actions_snapshot, "_optional_point", lambda msg: None) + monkeypatch.setattr(menu, "confirm", lambda *a, **k: confirm_ret) + assert actions_snapshot._run_operation( + tmp_path, "restore") is actions_snapshot._ARG_CANCEL + assert called == [], "確認を拒否/中止したら restore しない" + + +def test_run_operation_restore_name_cancel(monkeypatch, tmp_path): + called = _no_dispatch(monkeypatch) + monkeypatch.setattr(actions_snapshot, "_select_snapshot_name", + lambda root, msg: actions_snapshot._ARG_CANCEL) + assert actions_snapshot._run_operation( + tmp_path, "restore") is actions_snapshot._ARG_CANCEL + assert called == [] + + +def test_run_operation_restore_point_cancel(monkeypatch, tmp_path): + called = _no_dispatch(monkeypatch) + monkeypatch.setattr(actions_snapshot, "_select_snapshot_name", + lambda root, msg: "snap1") + monkeypatch.setattr(actions_snapshot, "_optional_point", + lambda msg: actions_snapshot._ARG_CANCEL) + assert actions_snapshot._run_operation( + tmp_path, "restore") is actions_snapshot._ARG_CANCEL + assert called == [] + + +def test_run_operation_copy_collects_new_name(monkeypatch, tmp_path): + captured = _capture_dispatch(monkeypatch) + monkeypatch.setattr(actions_snapshot, "_select_snapshot_name", + lambda root, msg: "snap1") + monkeypatch.setattr(menu, "text", lambda *a, **k: "snap1-copy") + assert actions_snapshot._run_operation(tmp_path, "copy") == 0 + assert captured["subcommand"] == "copy" + assert captured["name"] == "snap1" and captured["new_name"] == "snap1-copy" + + +def test_run_operation_copy_new_name_cancel(monkeypatch, tmp_path): + called = _no_dispatch(monkeypatch) + monkeypatch.setattr(actions_snapshot, "_select_snapshot_name", + lambda root, msg: "snap1") + monkeypatch.setattr(menu, "text", lambda *a, **k: None) # Esc/Ctrl-C + assert actions_snapshot._run_operation( + tmp_path, "copy") is actions_snapshot._ARG_CANCEL + assert called == [] + + +def test_run_operation_delete_confirmed(monkeypatch, tmp_path): + """delete は confirm=True で name を契約どおり渡す (plan 3.4)。""" + captured = _capture_dispatch(monkeypatch) + monkeypatch.setattr(actions_snapshot, "_select_snapshot_name", + lambda root, msg: "snap1") + monkeypatch.setattr(menu, "confirm", lambda *a, **k: True) + assert actions_snapshot._run_operation(tmp_path, "delete") == 0 + assert captured["subcommand"] == "delete" and captured["name"] == "snap1" + + +@pytest.mark.parametrize("confirm_ret", [False, None]) +def test_run_operation_delete_cancelled_does_not_dispatch( + monkeypatch, tmp_path, confirm_ret): + """delete の confirm を拒否 (False) / 中止 (None) したら削除しない (plan 3.4)。""" + called = _no_dispatch(monkeypatch) + monkeypatch.setattr(actions_snapshot, "_select_snapshot_name", + lambda root, msg: "snap1") + monkeypatch.setattr(menu, "confirm", lambda *a, **k: confirm_ret) + assert actions_snapshot._run_operation( + tmp_path, "delete") is actions_snapshot._ARG_CANCEL + assert called == [], "確認を拒否/中止したら delete しない" + + +def test_run_operation_rotate_collects_keep(monkeypatch, tmp_path): + captured = _capture_dispatch(monkeypatch) + seen = {} + + def fake_integer(message, *, default=None, min_value=None, max_value=None): + seen.update(default=default, min_value=min_value) + return 5 + + monkeypatch.setattr(menu, "integer", fake_integer) + assert actions_snapshot._run_operation(tmp_path, "rotate") == 0 + assert captured["subcommand"] == "rotate" and captured["keep"] == 5 + # CLI 既定 (--keep 3) と同じ既定値を提示し、no-op な 0 以下は弾く。 + assert seen == {"default": 3, "min_value": 1} + + +def test_run_operation_rotate_cancel(monkeypatch, tmp_path): + called = _no_dispatch(monkeypatch) + monkeypatch.setattr(menu, "integer", lambda *a, **k: None) + assert actions_snapshot._run_operation( + tmp_path, "rotate") is actions_snapshot._ARG_CANCEL + assert called == [] + + +def test_run_operation_unknown_op_is_noop(monkeypatch, tmp_path): + called = _no_dispatch(monkeypatch) + assert actions_snapshot._run_operation( + tmp_path, "bogus") is actions_snapshot._ARG_CANCEL + assert called == [] + + +# --------------------------------------------------------------------------- +# _select_snapshot_name: 既存一覧からの選択 / 縮退 +# --------------------------------------------------------------------------- + +class _FakeManager: + """SnapshotManager の list() だけを差し替える小道具。""" + + snapshots: list[dict] | Exception = [] + + def __init__(self, devbase_root): + pass + + def list(self): + if isinstance(type(self).snapshots, Exception): + raise type(self).snapshots + return type(self).snapshots + + +def test_select_snapshot_name_lists_and_returns_value(monkeypatch, tmp_path): + """既存一覧を (名前+作成日時) で提示し、選択値 (名前) をそのまま返す。""" + _FakeManager.snapshots = [ + {"name": "snap1", "created_at": "2026-06-10T12:00:00.123456"}, + {"name": "snap2"}, # created_at 欠落でも落ちない + ] + monkeypatch.setattr(actions_snapshot, "SnapshotManager", _FakeManager) + + captured = {} + + def fake_select(message, choices, *, back, search): + captured.update(back=back, search=search, + titles=[c[0] for c in choices], + values=[c[1] for c in choices]) + return "snap2" + + monkeypatch.setattr(menu, "select", fake_select) + assert actions_snapshot._select_snapshot_name(tmp_path, "選択") == "snap2" + assert captured["back"] is True and captured["search"] is True + assert captured["values"] == ["snap1", "snap2"] + assert "2026-06-10T12:00:00" in captured["titles"][0] + + +@pytest.mark.parametrize("sel", ["BACK", None]) +def test_select_snapshot_name_cancel(monkeypatch, tmp_path, sel): + """Esc (MENU_BACK) / Ctrl-C (None) は _ARG_CANCEL に正規化する。""" + _FakeManager.snapshots = [{"name": "snap1", "created_at": "2026-06-10"}] + monkeypatch.setattr(actions_snapshot, "SnapshotManager", _FakeManager) + ret = menu.MENU_BACK if sel == "BACK" else None + monkeypatch.setattr(menu, "select", lambda *a, **k: ret) + assert actions_snapshot._select_snapshot_name( + tmp_path, "選択") is actions_snapshot._ARG_CANCEL + + +def test_select_snapshot_name_empty_list_cancels(monkeypatch, tmp_path): + """対象が 1 件も無ければ案内を出して中止 (選択メニューは出さない)。""" + _FakeManager.snapshots = [] + monkeypatch.setattr(actions_snapshot, "SnapshotManager", _FakeManager) + select_calls = [] + monkeypatch.setattr(menu, "select", + lambda *a, **k: select_calls.append(1) or None) + assert actions_snapshot._select_snapshot_name( + tmp_path, "選択") is actions_snapshot._ARG_CANCEL + assert select_calls == [] + + +def test_select_snapshot_name_list_failure_falls_back_to_text(monkeypatch, tmp_path): + """一覧取得に失敗したら自由入力へ縮退する (存在チェックは委譲先に任せる)。""" + _FakeManager.snapshots = RuntimeError("boom") + monkeypatch.setattr(actions_snapshot, "SnapshotManager", _FakeManager) + monkeypatch.setattr(menu, "text", lambda *a, **k: "typed-name") + assert actions_snapshot._select_snapshot_name(tmp_path, "選択") == "typed-name" + + +def test_select_snapshot_name_text_fallback_cancel(monkeypatch, tmp_path): + _FakeManager.snapshots = RuntimeError("boom") + monkeypatch.setattr(actions_snapshot, "SnapshotManager", _FakeManager) + monkeypatch.setattr(menu, "text", lambda *a, **k: None) + assert actions_snapshot._select_snapshot_name( + tmp_path, "選択") is actions_snapshot._ARG_CANCEL + + +# --------------------------------------------------------------------------- +# _optional_point +# --------------------------------------------------------------------------- + +def test_optional_point_value(monkeypatch): + monkeypatch.setattr(menu, "text", lambda *a, **k: "3") + assert actions_snapshot._optional_point("point") == 3 + + +def test_optional_point_empty_is_none(monkeypatch): + monkeypatch.setattr(menu, "text", lambda *a, **k: "") + assert actions_snapshot._optional_point("point") is None + + +def test_optional_point_cancel(monkeypatch): + monkeypatch.setattr(menu, "text", lambda *a, **k: None) + assert actions_snapshot._optional_point( + "point") is actions_snapshot._ARG_CANCEL + + +def test_optional_point_reprompts_non_numeric(monkeypatch): + vals = iter(["abc", "7"]) + monkeypatch.setattr(menu, "text", lambda *a, **k: next(vals)) + assert actions_snapshot._optional_point("point") == 7 + + +def test_optional_point_reprompts_non_positive(monkeypatch): + """manager は point に正の整数のみ受理するため 0 以下は弾いて再入力を促す。""" + vals = iter(["0", "-1", "2"]) + monkeypatch.setattr(menu, "text", lambda *a, **k: next(vals)) + assert actions_snapshot._optional_point("point") == 2 diff --git a/tests/cli/tui/test_actions_status.py b/tests/cli/tui/test_actions_status.py new file mode 100644 index 0000000..6ca2dfd --- /dev/null +++ b/tests/cli/tui/test_actions_status.py @@ -0,0 +1,25 @@ +"""PLAN31_2 PR5: tui.actions_status (status 閲覧) のテスト。 + +status は引数なしの閲覧のみ (`cmd_status(devbase_root)` / plan 2.2)。 +`cmd_status` を mock し、devbase_root だけで呼ばれて rc がそのまま返ることを検証する。 +""" + +from __future__ import annotations + +import pytest + +from devbase.tui import actions_status + + +@pytest.mark.parametrize("rc", [0, 1]) +def test_run_delegates_to_cmd_status_and_returns_rc(monkeypatch, tmp_path, rc): + """cmd_status(devbase_root) へ委譲し、rc (0/非0) をそのままトップへ返す。""" + captured = {} + + def _spy(devbase_root): + captured["devbase_root"] = devbase_root + return rc + + monkeypatch.setattr(actions_status, "cmd_status", _spy) + assert actions_status.run(tmp_path) == rc + assert captured["devbase_root"] == tmp_path diff --git a/tests/cli/tui/test_app.py b/tests/cli/tui/test_app.py index 6346284..f3504cd 100644 --- a/tests/cli/tui/test_app.py +++ b/tests/cli/tui/test_app.py @@ -169,7 +169,9 @@ def test_top_menu_back_does_not_overwrite_last_rc(monkeypatch, tmp_path): 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 は未実装カテゴリ (_route が MENU_BACK) → last_rc を維持 + # snapshot は操作なしで戻る (MENU_BACK) → last_rc を維持 + from devbase.tui import actions_snapshot + monkeypatch.setattr(actions_snapshot, "run", lambda root: menu.MENU_BACK) assert app._top_menu_loop(tmp_path) == 1 @@ -199,10 +201,12 @@ def test_top_menu_category_ctrl_c_aborts_whole_app(monkeypatch, tmp_path): assert app._top_menu_loop(tmp_path) == 0 -def test_top_menu_unimplemented_category_returns_to_top(monkeypatch, tmp_path): - """未実装カテゴリ (snapshot 等) はプレースホルダ案内を出してトップへ戻る。""" +def test_top_menu_menu_back_category_returns_to_top(monkeypatch, tmp_path): + """カテゴリが操作なし (MENU_BACK) で戻ったらトップメニューを再表示する。""" selects = iter(["snapshot", None]) monkeypatch.setattr(menu, "select", lambda *a, **k: next(selects)) + from devbase.tui import actions_snapshot + monkeypatch.setattr(actions_snapshot, "run", lambda root: menu.MENU_BACK) # _route が MENU_BACK を返してループ継続 → 2 回目 None で終了 rc = app._top_menu_loop(tmp_path) assert rc == 0 @@ -226,6 +230,20 @@ def test_route_env_delegates(monkeypatch, tmp_path): assert app._route("env", tmp_path) == "ENV_RESULT" -def test_route_unimplemented_returns_menu_back(tmp_path): - # snapshot は PR5 で配線されるまでプレースホルダ (MENU_BACK)。 - assert app._route("snapshot", tmp_path) is menu.MENU_BACK +def test_route_unknown_category_returns_menu_back(tmp_path): + # 全カテゴリ配線済みのため、未知カテゴリへの防御的 fallback (MENU_BACK) を検証。 + assert app._route("unknown", tmp_path) is menu.MENU_BACK + + +def test_route_snapshot_delegates(monkeypatch, tmp_path): + """PR5: snapshot カテゴリは actions_snapshot.run へ配線される。""" + from devbase.tui import actions_snapshot + monkeypatch.setattr(actions_snapshot, "run", lambda root: "SNAP") + assert app._route("snapshot", tmp_path) == "SNAP" + + +def test_route_status_delegates(monkeypatch, tmp_path): + """PR5: status カテゴリは actions_status.run へ配線される。""" + from devbase.tui import actions_status + monkeypatch.setattr(actions_status, "run", lambda root: "STATUS") + assert app._route("status", tmp_path) == "STATUS"