Files
cd-browser/tests/test_ui.py
Saky 60723c33bc feat: improve terminal navigation and UI behavior
- refine tree and history navigation behavior
- improve scrolling continuity in the terminal UI
- keep AI worklog and prompt log updated
- validate changes with project quality checks
2026-03-16 00:28:27 +01:00

237 lines
6.7 KiB
Python

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