import curses from pathlib import Path import pytest from app.navigator import Navigator, VisibleEntry from app.ui import ( ESCAPE_KEY, HistoryLine, RenderLine, TerminalUI, build_history_lines, build_render_lines, build_status_line, 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_format_entry_marks_files_with_file_indicator(tmp_path: Path) -> None: file_entry = VisibleEntry( name="notes.txt", path=tmp_path / "notes.txt", depth=0, has_children=False, is_expanded=False, is_file=True, ) assert format_entry(file_entry) == "• notes.txt" 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 == 0 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_DOWN, 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_renders_most_recent_entry_first(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) lines, _selected_index = ui._build_history_lines_for_render( navigator.history.entries() ) assert lines[0].text == str(second.resolve()) def test_history_mode_slash_activates_history_filter() -> None: ui = TerminalUI() navigator = Navigator(Path.cwd()) ui._history_mode = True ui._handle_history_key(ord("/"), navigator) assert ui._history_filter_mode is True assert ui._history_filter_query == "" def test_history_filter_enter_selects_filtered_entry_and_exits_history( tmp_path: Path, ) -> None: root = tmp_path / "root" alpha = root / "alpha" beta = root / "beta" root.mkdir() alpha.mkdir() beta.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._history_filter_mode = True ui._history_filter_query = "alpha" ui._handle_history_key(10, navigator) assert navigator.current_path == alpha.resolve() assert ui._history_mode is False assert ui._history_filter_mode is False def test_history_filter_escape_disables_only_history_filter(tmp_path: Path) -> None: root = tmp_path / "root" root.mkdir() navigator = Navigator(root) ui = TerminalUI() ui._toggle_history_mode(navigator) ui._history_filter_mode = True ui._history_filter_query = "ro" ui._handle_history_key(ESCAPE_KEY, navigator) assert ui._history_mode is True assert ui._history_filter_mode is False assert ui._history_filter_query == "" 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_toggle_files_key_updates_navigator_state(tmp_path: Path) -> None: root = tmp_path / "root" root.mkdir() (root / "folder").mkdir() (root / "notes.txt").write_text("content", encoding="utf-8") navigator = Navigator(root) ui = TerminalUI() assert navigator.show_files is False assert ui._handle_tree_key(ord("a"), navigator) is None assert navigator.show_files is True def test_filter_matching_is_case_insensitive(tmp_path: Path) -> None: root = tmp_path / "root" root.mkdir() (root / "Alpha").mkdir() (root / "beta").mkdir() navigator = Navigator(root) ui = TerminalUI() ui._filter_mode = True ui._filter_query = "alp" matches = ui._matching_indices(navigator.visible_entries) assert matches == [1] def test_build_status_line_shows_filter_off_when_filter_mode_is_disabled( tmp_path: Path, ) -> None: root = tmp_path / "root" root.mkdir() navigator = Navigator(root) status_line = build_status_line( navigator, filter_mode=False, filter_query="", dev_summary="git:- stack:-", ) assert "filter: off" in status_line def test_build_status_line_shows_empty_filter_without_off_when_filter_is_active( tmp_path: Path, ) -> None: root = tmp_path / "root" root.mkdir() navigator = Navigator(root) status_line = build_status_line( navigator, filter_mode=True, filter_query="", dev_summary="git:- stack:-", ) assert "filter: |" in status_line def test_filter_mode_uses_g_and_g_upper_as_query_characters(tmp_path: Path) -> None: root = tmp_path / "root" root.mkdir() (root / "alpha").mkdir() (root / "gamma").mkdir() navigator = Navigator(root) ui = TerminalUI() ui._filter_mode = True assert ui._handle_filter_key(ord("g"), navigator) is None assert ui._handle_filter_key(ord("G"), navigator) is None assert ui._filter_query == "gG" def test_filter_enter_on_directory_navigates_and_exits_filter(tmp_path: Path) -> None: root = tmp_path / "root" child = root / "child" root.mkdir() child.mkdir() navigator = Navigator(root) ui = TerminalUI() ui._filter_mode = True ui._filter_query = "child" result = ui._handle_filter_key(10, navigator) assert result is None assert ui._filter_mode is False assert navigator.current_path == child.resolve() def test_filter_enter_on_file_opens_menu_and_exits_filter( tmp_path: Path, monkeypatch: pytest.MonkeyPatch ) -> None: root = tmp_path / "root" root.mkdir() file_path = root / "notes.txt" file_path.write_text("hello", encoding="utf-8") navigator = Navigator(root) navigator.toggle_files() ui = TerminalUI() ui._filter_mode = True ui._filter_query = "notes" opened: dict[str, bool] = {"called": False} def fake_open_with_selected( _navigator: Navigator, _stdscr: curses.window | None ) -> None: opened["called"] = True monkeypatch.setattr(ui, "_open_with_selected", fake_open_with_selected) result = ui._handle_filter_key(10, navigator) assert result is None assert ui._filter_mode is False assert opened["called"] is True assert navigator.selected_entry.path == file_path.resolve() def test_end_key_jumps_to_last_entry(tmp_path: Path) -> None: root = tmp_path / "root" root.mkdir() for name in ("alpha", "beta", "gamma"): (root / name).mkdir() navigator = Navigator(root) ui = TerminalUI() assert ui._handle_tree_key(curses.KEY_END, navigator) is None assert navigator.selected_index == len(navigator.visible_entries) - 1 def test_info_mode_toggles_with_i_and_escape(tmp_path: Path) -> None: root = tmp_path / "root" root.mkdir() navigator = Navigator(root) ui = TerminalUI() assert ui._info_mode is False assert ui._handle_tree_key(ord("i"), navigator) is None assert ui._info_mode is True ui._handle_info_key(ESCAPE_KEY) assert ui._info_mode is False 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