Initial release: cd-browser v0.1.0

This commit is contained in:
2026-03-14 17:21:49 +01:00
commit f03417f2f2
39 changed files with 2351 additions and 0 deletions

0
tests/__init__.py Normal file
View File

43
tests/test_cli.py Normal file
View File

@@ -0,0 +1,43 @@
from pathlib import Path
import pytest
from app.cli import run_cli
from app.navigator import Navigator
class StubUI:
def __init__(self, result: Path) -> None:
self.result = result
self.navigator: Navigator | None = None
def run(self, navigator: Navigator) -> Path:
self.navigator = navigator
return self.result
def test_run_cli_uses_current_working_directory(
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
) -> None:
stub_ui = StubUI(tmp_path)
monkeypatch.setattr(Path, "cwd", lambda: tmp_path)
result = run_cli(ui=stub_ui)
assert result == tmp_path.resolve()
assert stub_ui.navigator is not None
assert stub_ui.navigator.current_path == tmp_path.resolve()
def test_run_cli_uses_provided_start_path(tmp_path: Path) -> None:
start_path = tmp_path / "workspace"
start_path.mkdir()
final_path = tmp_path / "final"
final_path.mkdir()
stub_ui = StubUI(final_path)
result = run_cli(start_path=start_path, ui=stub_ui)
assert result == final_path.resolve()
assert stub_ui.navigator is not None
assert stub_ui.navigator.current_path == start_path.resolve()

46
tests/test_filesystem.py Normal file
View File

@@ -0,0 +1,46 @@
from pathlib import Path
import pytest
from app.filesystem import DirectoryEntry, has_subdirectories, list_directories
def test_has_subdirectories_detects_nested_directory(tmp_path: Path) -> None:
parent = tmp_path / "parent"
child = parent / "child"
child.mkdir(parents=True)
assert has_subdirectories(parent) is True
def test_has_subdirectories_returns_false_without_nested_directories(
tmp_path: Path,
) -> None:
directory = tmp_path / "empty"
directory.mkdir()
(directory / "file.txt").write_text("content", encoding="utf-8")
assert has_subdirectories(directory) is False
def test_list_directories_returns_sorted_directory_metadata(tmp_path: Path) -> None:
alpha = tmp_path / "alpha"
beta = tmp_path / "Beta"
file_path = tmp_path / "notes.txt"
alpha.mkdir()
beta.mkdir()
(beta / "nested").mkdir()
file_path.write_text("ignore me", encoding="utf-8")
assert list_directories(tmp_path) == [
DirectoryEntry(name="alpha", path=alpha, has_children=False),
DirectoryEntry(name="Beta", path=beta, has_children=True),
]
def test_list_directories_raises_for_missing_directory(tmp_path: Path) -> None:
missing_path = tmp_path / "missing"
with pytest.raises(FileNotFoundError):
list_directories(missing_path)

84
tests/test_history.py Normal file
View File

@@ -0,0 +1,84 @@
from pathlib import Path
import pytest
from app.history import SessionHistory
def test_visit_records_current_directory(tmp_path: Path) -> None:
history = SessionHistory()
first = tmp_path / "first"
first.mkdir()
visited = history.visit(first)
assert visited == first.resolve()
assert history.current == first.resolve()
assert history.entries() == (first.resolve(),)
def test_visit_drops_forward_history_after_new_branch(tmp_path: Path) -> None:
history = SessionHistory()
first = tmp_path / "first"
second = tmp_path / "second"
third = tmp_path / "third"
for path in (first, second, third):
path.mkdir()
history.visit(first)
history.visit(second)
assert history.back() == first.resolve()
history.visit(third)
assert history.entries() == (first.resolve(), third.resolve())
assert history.current == third.resolve()
assert history.forward() is None
def test_visit_does_not_duplicate_current_entry(tmp_path: Path) -> None:
history = SessionHistory()
directory = tmp_path / "directory"
directory.mkdir()
history.visit(directory)
history.visit(directory)
assert history.entries() == (directory.resolve(),)
def test_back_and_forward_move_through_history(tmp_path: Path) -> None:
history = SessionHistory()
first = tmp_path / "first"
second = tmp_path / "second"
for path in (first, second):
path.mkdir()
history.visit(first)
history.visit(second)
assert history.back() == first.resolve()
assert history.back() is None
assert history.forward() == second.resolve()
assert history.forward() is None
def test_select_moves_to_specific_history_entry(tmp_path: Path) -> None:
history = SessionHistory()
first = tmp_path / "first"
second = tmp_path / "second"
for path in (first, second):
path.mkdir()
history.visit(first)
history.visit(second)
assert history.select(0) == first.resolve()
assert history.current == first.resolve()
def test_select_raises_for_invalid_index() -> None:
history = SessionHistory()
with pytest.raises(IndexError):
history.select(0)

182
tests/test_navigator.py Normal file
View File

@@ -0,0 +1,182 @@
from pathlib import Path
import pytest
from app.history import SessionHistory
from app.navigator import Navigator
def test_navigator_initial_state_includes_parent_entry(tmp_path: Path) -> None:
current = tmp_path / "workspace"
current.mkdir()
alpha = current / "alpha"
beta = current / "beta"
alpha.mkdir()
beta.mkdir()
navigator = Navigator(current)
assert navigator.current_path == current.resolve()
assert navigator.selected_index == 0
assert [entry.name for entry in navigator.visible_entries] == [
"..",
"alpha",
"beta",
]
def test_expand_selected_directory_reveals_nested_entries(tmp_path: Path) -> None:
current = tmp_path / "workspace"
current.mkdir()
scripts = current / "scripts"
scripts.mkdir()
build = scripts / "build"
build.mkdir()
navigator = Navigator(current)
navigator.set_selected_index(1)
assert navigator.expand_selected_directory() is True
assert [(entry.name, entry.depth) for entry in navigator.visible_entries] == [
("..", 0),
("scripts", 0),
("build", 1),
]
def test_expand_selected_directory_returns_false_without_children(
tmp_path: Path,
) -> None:
current = tmp_path / "workspace"
current.mkdir()
docs = current / "docs"
docs.mkdir()
navigator = Navigator(current)
navigator.set_selected_index(1)
assert navigator.expand_selected_directory() is False
assert [entry.name for entry in navigator.visible_entries] == ["..", "docs"]
def test_collapse_selected_directory_hides_nested_entries(tmp_path: Path) -> None:
current = tmp_path / "workspace"
current.mkdir()
scripts = current / "scripts"
scripts.mkdir()
(scripts / "build").mkdir()
navigator = Navigator(current)
navigator.set_selected_index(1)
navigator.expand_selected_directory()
assert navigator.collapse_selected_directory() is True
assert [entry.name for entry in navigator.visible_entries] == ["..", "scripts"]
def test_collapse_selected_child_moves_to_parent_and_collapses(tmp_path: Path) -> None:
current = tmp_path / "workspace"
current.mkdir()
scripts = current / "scripts"
scripts.mkdir()
(scripts / "build").mkdir()
navigator = Navigator(current)
navigator.set_selected_index(1)
navigator.expand_selected_directory()
navigator.set_selected_index(2)
assert navigator.collapse_selected_directory() is True
assert navigator.selected_entry.name == "scripts"
assert [entry.name for entry in navigator.visible_entries] == ["..", "scripts"]
def test_enter_selected_directory_changes_current_path_and_updates_history(
tmp_path: Path,
) -> None:
current = tmp_path / "workspace"
current.mkdir()
child = current / "child"
child.mkdir()
history = SessionHistory()
navigator = Navigator(current, history=history)
navigator.set_selected_index(1)
selected_path = navigator.enter_selected_directory()
assert selected_path == child.resolve()
assert navigator.current_path == child.resolve()
assert navigator.selected_index == 0
assert navigator.history.entries() == (current.resolve(), child.resolve())
def test_enter_parent_entry_moves_to_parent_directory(tmp_path: Path) -> None:
parent = tmp_path / "parent"
parent.mkdir()
current = parent / "current"
current.mkdir()
navigator = Navigator(current)
moved_path = navigator.enter_selected_directory()
assert moved_path == parent.resolve()
assert navigator.current_path == parent.resolve()
def test_go_to_parent_directory_moves_up_one_level(tmp_path: Path) -> None:
parent = tmp_path / "parent"
parent.mkdir()
current = parent / "current"
current.mkdir()
navigator = Navigator(current)
assert navigator.go_to_parent_directory() == parent.resolve()
assert navigator.current_path == parent.resolve()
def test_history_navigation_restores_previous_path(tmp_path: Path) -> None:
root = tmp_path / "root"
root.mkdir()
first = root / "first"
second = root / "second"
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()
assert navigator.go_back() == root.resolve()
assert navigator.current_path == root.resolve()
assert navigator.go_back() == first.resolve()
assert navigator.go_forward() == root.resolve()
assert navigator.go_forward() == second.resolve()
def test_select_history_entry_changes_current_path(tmp_path: Path) -> None:
root = tmp_path / "root"
root.mkdir()
child = root / "child"
child.mkdir()
navigator = Navigator(root)
navigator.set_selected_index(1)
navigator.enter_selected_directory()
assert navigator.select_history_entry(0) == root.resolve()
assert navigator.current_path == root.resolve()
def test_set_selected_index_raises_for_invalid_index(tmp_path: Path) -> None:
current = tmp_path / "workspace"
current.mkdir()
navigator = Navigator(current)
with pytest.raises(IndexError):
navigator.set_selected_index(1)

18
tests/test_smoke.py Normal file
View File

@@ -0,0 +1,18 @@
from pathlib import Path
import pytest
from app.main import main
def test_main_runs(
monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
) -> None:
monkeypatch.setattr("app.main.run_cli", lambda: Path("/tmp/final"))
main()
captured = capsys.readouterr()
assert captured.out == "/tmp/final\n"
assert captured.err == ""

80
tests/test_ui.py Normal file
View File

@@ -0,0 +1,80 @@
from pathlib import Path
import pytest
from app.navigator import Navigator, VisibleEntry
from app.ui import (
RenderLine,
build_render_lines,
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_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