import curses from pathlib import Path import pytest from app.navigator import Navigator, VisibleEntry from app.ui import ( HistoryLine, RenderLine, TerminalUI, build_history_lines, build_render_lines, clamp_scroll_offset, format_entry, move_selection, open_terminal_streams, ) def test_move_selection_stays_within_bounds() -> None: assert move_selection(current_index=0, step=-1, entry_count=3) == 0 assert move_selection(current_index=1, step=1, entry_count=3) == 2 assert move_selection(current_index=2, step=1, entry_count=3) == 2 def test_format_entry_supports_parent_and_tree_indicators(tmp_path: Path) -> None: parent_entry = VisibleEntry( name="..", path=tmp_path, depth=0, has_children=True, is_expanded=False, is_parent=True, ) collapsed_entry = VisibleEntry( name="scripts", path=tmp_path / "scripts", depth=0, has_children=True, is_expanded=False, ) expanded_child = VisibleEntry( name="build", path=tmp_path / "scripts" / "build", depth=1, has_children=False, is_expanded=False, ) assert format_entry(parent_entry) == ".." assert format_entry(collapsed_entry) == "▶ scripts" assert format_entry(expanded_child) == " build" def test_build_render_lines_marks_selected_entry(tmp_path: Path) -> None: workspace = tmp_path / "workspace" workspace.mkdir() alpha = workspace / "alpha" beta = workspace / "beta" alpha.mkdir() beta.mkdir() navigator = Navigator(workspace) navigator.set_selected_index(2) assert build_render_lines(navigator) == [ RenderLine(text="..", is_selected=False), RenderLine(text=" alpha", is_selected=False), RenderLine(text=" beta", is_selected=True), ] def test_build_history_lines_marks_selected_history_entry(tmp_path: Path) -> None: first = tmp_path / "first" second = tmp_path / "second" assert build_history_lines((first, second), selected_index=1) == [ HistoryLine(text=str(first), is_selected=False), HistoryLine(text=str(second), is_selected=True), ] def test_clamp_scroll_offset_keeps_selection_visible_when_moving_down() -> None: assert clamp_scroll_offset(5, scroll_offset=0, visible_count=3, total_count=10) == 3 def test_clamp_scroll_offset_keeps_selection_visible_when_moving_up() -> None: assert clamp_scroll_offset(1, scroll_offset=4, visible_count=3, total_count=10) == 1 def test_clamp_scroll_offset_returns_zero_when_all_items_fit() -> None: assert clamp_scroll_offset(2, scroll_offset=1, visible_count=5, total_count=3) == 0 def test_toggle_history_mode_selects_current_history_entry(tmp_path: Path) -> None: root = tmp_path / "root" child = root / "child" root.mkdir() child.mkdir() navigator = Navigator(root) navigator.set_selected_index(1) navigator.enter_selected_directory() ui = TerminalUI() ui._toggle_history_mode(navigator) assert ui._history_mode is True assert ui._history_selected_index == 1 def test_history_mode_enter_selects_entry_and_exits(tmp_path: Path) -> None: root = tmp_path / "root" first = root / "first" second = root / "second" root.mkdir() first.mkdir() second.mkdir() navigator = Navigator(root) navigator.set_selected_index(1) navigator.enter_selected_directory() navigator.go_to_parent_directory() navigator.set_selected_index(2) navigator.enter_selected_directory() ui = TerminalUI() ui._toggle_history_mode(navigator) ui._handle_history_key(curses.KEY_UP, navigator) ui._handle_history_key(10, navigator) assert ui._history_mode is False assert navigator.current_path == root.resolve() assert navigator.selected_index == 0 def test_history_mode_toggle_off_keeps_current_directory(tmp_path: Path) -> None: root = tmp_path / "root" root.mkdir() navigator = Navigator(root) ui = TerminalUI() ui._toggle_history_mode(navigator) ui._toggle_history_mode(navigator) assert ui._history_mode is False assert navigator.current_path == root.resolve() def test_right_arrow_expands_collapsed_directory(tmp_path: Path) -> None: workspace = tmp_path / "workspace" scripts = workspace / "scripts" build = scripts / "build" workspace.mkdir() scripts.mkdir() build.mkdir() navigator = Navigator(workspace) navigator.set_selected_index(1) ui = TerminalUI() assert ui._handle_tree_key(curses.KEY_RIGHT, navigator) is None assert navigator.current_path == workspace.resolve() assert [entry.name for entry in navigator.visible_entries] == [ "..", "scripts", "build", ] def test_right_arrow_enters_already_expanded_directory(tmp_path: Path) -> None: workspace = tmp_path / "workspace" scripts = workspace / "scripts" build = scripts / "build" workspace.mkdir() scripts.mkdir() build.mkdir() navigator = Navigator(workspace) navigator.set_selected_index(1) navigator.expand_selected_directory() ui = TerminalUI() assert ui._handle_tree_key(curses.KEY_RIGHT, navigator) is None assert navigator.current_path == scripts.resolve() assert navigator.selected_index == 0 def test_left_arrow_goes_to_parent_when_selected_entry_is_collapsed( tmp_path: Path, ) -> None: parent = tmp_path / "parent" current = parent / "current" child = current / "child" parent.mkdir() current.mkdir() child.mkdir() navigator = Navigator(current) navigator.set_selected_index(1) ui = TerminalUI() assert ui._handle_tree_key(curses.KEY_LEFT, navigator) is None assert navigator.current_path == parent.resolve() assert navigator.selected_index == 0 def test_enter_returns_selected_path_without_navigating(tmp_path: Path) -> None: workspace = tmp_path / "workspace" child = workspace / "child" workspace.mkdir() child.mkdir() navigator = Navigator(workspace) navigator.set_selected_index(1) ui = TerminalUI() result = ui._handle_tree_key(10, navigator) assert result == child.resolve() assert navigator.current_path == workspace.resolve() def test_open_terminal_streams_raises_without_terminal( monkeypatch: pytest.MonkeyPatch, ) -> None: def raise_os_error(*args: object, **kwargs: object) -> object: raise OSError monkeypatch.setattr("builtins.open", raise_os_error) monkeypatch.setattr("sys.stdin.isatty", lambda: False) monkeypatch.setattr("sys.stderr.isatty", lambda: False) with pytest.raises(RuntimeError, match="Interactive terminal is required"): with open_terminal_streams(): pass