486 lines
13 KiB
Python
486 lines
13 KiB
Python
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
|