Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 27 additions & 2 deletions lib/devbase/tui/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,8 @@

ナビ規約: トップ (プロジェクト一覧) は Esc / Ctrl-C で中止 (戻り先なし)。
各カテゴリ・サブメニュー内では Esc / ← で 1 つ前へ戻る (``menu.MENU_BACK``)、
Ctrl-C で全体中止 (``None``)。
Ctrl-C で全体中止 (``None``)。操作を実行した後は出力を読めるよう Enter を
待ってから一覧を再表示する (``_pause_for_review``)。
"""

from __future__ import annotations
Expand Down Expand Up @@ -66,6 +67,26 @@ def _route(category: str, devbase_root: Path):
return module.run(devbase_root)


def _pause_for_review() -> bool:
"""操作出力を読めるよう、一覧の再表示前に Enter を待つ。

操作実行直後にトップ一覧を再描画すると、plugin list 等の表示系操作の出力が
一瞬で流れて読めない。questionary 系プロンプトは画面を書き換えるため、
stdlib の ``input()`` で素朴に待ち、出力をそのまま画面に残す。

戻り値: ``True`` = 一覧へ戻る / ``False`` = Ctrl-C (全体中止)。
非 TTY 等で stdin を読めない場合 (EOFError/OSError) は待たずに戻る。
"""
try:
input("Enter キーで一覧へ戻ります...")
except KeyboardInterrupt:
print()
return False
except (EOFError, OSError):
pass
return True


def _select_top(rows: list[dict]):
"""トップ画面: プロジェクト一覧 + カテゴリ項目から 1 件選ばせる。

Expand Down Expand Up @@ -118,8 +139,12 @@ def _top_menu_loop(devbase_root: Path) -> int:
if result is menu.MENU_BACK:
# 操作なしで一覧へ戻り再表示 (rc は更新しない)
continue
# int rc: 操作を実行した → rc を記憶して一覧を再表示
# int rc: 操作を実行した → rc を記憶し、出力を読めるよう Enter を
# 待ってから一覧を再表示する (即時再描画で出力が流れるのを防ぐ)。
last_rc = result
if not _pause_for_review():
logger.info("中止しました。")
return last_rc


def run(devbase_root: Path, args) -> int:
Expand Down
68 changes: 67 additions & 1 deletion tests/cli/tui/test_app.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@

import types

import pytest

from devbase.tui import actions_plugin, actions_project, app, menu


Expand Down Expand Up @@ -139,11 +141,18 @@ def test_run_interactive_opens_top_menu(tmp_path, monkeypatch):


def _patch_loop(monkeypatch, selects, rows=None):
"""_top_menu_loop の入力 (一覧と選択値) を注入する共通ヘルパ。"""
"""_top_menu_loop の入力 (一覧と選択値) を注入する共通ヘルパ。

操作実行後の Enter 待ち (`_pause_for_review`) は即継続にスタブし、
呼び出し回数を返す (pause 自体の挙動は専用テストで検証する)。
"""
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))
pauses = []
monkeypatch.setattr(app, "_pause_for_review", lambda: pauses.append(1) or True)
return pauses


def test_select_top_appends_categories_after_projects(monkeypatch):
Expand Down Expand Up @@ -243,6 +252,63 @@ def test_top_loop_empty_projects_still_offers_categories(monkeypatch, tmp_path):
assert ran == [1], "プロジェクト無しでもカテゴリへ遷移できる"


# ---------------------------------------------------------------------------
# 操作実行後の Enter 待ち (_pause_for_review): 出力が流れる前に読めるようにする
# ---------------------------------------------------------------------------

def test_top_loop_pauses_after_execution(monkeypatch, tmp_path):
"""操作を実行したら一覧の再表示前に Enter を待つ (出力を読めるようにする)。"""
pauses = _patch_loop(monkeypatch, ["plugin", None])
from devbase.tui import actions_plugin
monkeypatch.setattr(actions_plugin, "run", lambda root: 0)

assert app._top_menu_loop(tmp_path) == 0
assert pauses == [1], "実行後は一覧再表示の前に Enter を待つ"


def test_top_loop_no_pause_on_menu_back(monkeypatch, tmp_path):
"""操作なし (MENU_BACK) で戻ったときは Enter を待たない (出力がないため)。"""
pauses = _patch_loop(monkeypatch, ["plugin", None])
from devbase.tui import actions_plugin
monkeypatch.setattr(actions_plugin, "run", lambda root: menu.MENU_BACK)

assert app._top_menu_loop(tmp_path) == 0
assert pauses == [], "MENU_BACK では Enter を待たない"


def test_top_loop_pause_ctrl_c_exits_with_last_rc(monkeypatch, tmp_path):
"""Enter 待ちで Ctrl-C (False) を受けたら直近の実行 rc で全体中止する。"""
_patch_loop(monkeypatch, ["plugin"])
from devbase.tui import actions_plugin
monkeypatch.setattr(actions_plugin, "run", lambda root: 1)
monkeypatch.setattr(app, "_pause_for_review", lambda: False)

assert app._top_menu_loop(tmp_path) == 1


def test_pause_for_review_enter_returns_true(monkeypatch):
monkeypatch.setattr("builtins.input", lambda *a: "")
assert app._pause_for_review() is True


def test_pause_for_review_ctrl_c_returns_false(monkeypatch):
def _interrupt(*a):
raise KeyboardInterrupt

monkeypatch.setattr("builtins.input", _interrupt)
assert app._pause_for_review() is False


@pytest.mark.parametrize("exc", [EOFError, OSError])
def test_pause_for_review_unreadable_stdin_returns_true(monkeypatch, exc):
"""非 TTY 等で stdin を読めない場合は待たずに一覧へ戻る (ハングしない)。"""
def _unreadable(*a):
raise exc

monkeypatch.setattr("builtins.input", _unreadable)
assert app._pause_for_review() is True


def test_route_plugin_delegates(monkeypatch, tmp_path):
"""PR4: plugin カテゴリは actions_plugin.run へ routing される。"""
monkeypatch.setattr(actions_plugin, "run", lambda root: "RESULT")
Expand Down
Loading