Initial commit
Co-Authored-By: Quentin Torroba <quentin.torroba@mistral.ai> Co-Authored-By: Laure Hugo <laure.hugo@mistral.ai> Co-Authored-By: Benjamin Trom <benjamin.trom@mistral.ai> Co-Authored-By: Mathias Gesbert <mathias.gesbert@ext.mistral.ai> Co-Authored-By: Michel Thomazo <michel.thomazo@mistral.ai> Co-Authored-By: Clément Drouin <clement.drouin@mistral.ai> Co-Authored-By: Vincent Guilloux <vincent.guilloux@mistral.ai> Co-Authored-By: Valentin Berard <val@mistral.ai> Co-Authored-By: Mistral Vibe <vibe@mistral.ai>
This commit is contained in:
commit
fa15fc977b
200 changed files with 30484 additions and 0 deletions
231
tests/autocompletion/test_file_indexer.py
Normal file
231
tests/autocompletion/test_file_indexer.py
Normal file
|
|
@ -0,0 +1,231 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Callable, Generator
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
from pathlib import Path
|
||||
import time
|
||||
|
||||
import pytest
|
||||
|
||||
from vibe.core.autocompletion.file_indexer import FileIndexer
|
||||
|
||||
# This suite runs against the real filesystem and watcher. A faked store/watcher
|
||||
# split would be faster to unit-test, but given time constraints and the low churn
|
||||
# expected for this feature, integration coverage was chosen as a trade-off.
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def file_indexer() -> Generator[FileIndexer]:
|
||||
indexer = FileIndexer()
|
||||
yield indexer
|
||||
indexer.shutdown()
|
||||
|
||||
|
||||
def _wait_for(condition: Callable[[], bool], timeout=3.0) -> bool:
|
||||
deadline = time.monotonic() + timeout
|
||||
while time.monotonic() < deadline:
|
||||
if condition():
|
||||
return True
|
||||
time.sleep(0.05)
|
||||
return False
|
||||
|
||||
|
||||
def test_updates_index_on_file_creation(
|
||||
tmp_path: Path, monkeypatch: pytest.MonkeyPatch, file_indexer: FileIndexer
|
||||
) -> None:
|
||||
monkeypatch.chdir(tmp_path)
|
||||
file_indexer.get_index(Path("."))
|
||||
|
||||
target = tmp_path / "new_file.py"
|
||||
target.write_text("", encoding="utf-8")
|
||||
|
||||
assert _wait_for(
|
||||
lambda: any(
|
||||
entry.rel == target.name for entry in file_indexer.get_index(Path("."))
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def test_updates_index_on_file_deletion(
|
||||
tmp_path: Path, monkeypatch: pytest.MonkeyPatch, file_indexer: FileIndexer
|
||||
) -> None:
|
||||
monkeypatch.chdir(tmp_path)
|
||||
target = tmp_path / "new_file.py"
|
||||
target.write_text("", encoding="utf-8")
|
||||
file_indexer.get_index(Path("."))
|
||||
|
||||
target.unlink()
|
||||
|
||||
assert _wait_for(
|
||||
lambda: all(
|
||||
entry.rel != target.name for entry in file_indexer.get_index(Path("."))
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def test_updates_index_on_file_rename(
|
||||
tmp_path: Path, monkeypatch: pytest.MonkeyPatch, file_indexer: FileIndexer
|
||||
) -> None:
|
||||
monkeypatch.chdir(tmp_path)
|
||||
old_file = tmp_path / "old_name.py"
|
||||
old_file.write_text("", encoding="utf-8")
|
||||
file_indexer.get_index(Path("."))
|
||||
|
||||
new_file = tmp_path / "new_name.py"
|
||||
old_file.rename(new_file)
|
||||
|
||||
assert _wait_for(
|
||||
lambda: all(
|
||||
entry.rel != old_file.name for entry in file_indexer.get_index(Path("."))
|
||||
)
|
||||
and any(
|
||||
entry.rel == new_file.name for entry in file_indexer.get_index(Path("."))
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def test_updates_index_on_folder_rename(
|
||||
tmp_path: Path, monkeypatch: pytest.MonkeyPatch, file_indexer: FileIndexer
|
||||
) -> None:
|
||||
monkeypatch.chdir(tmp_path)
|
||||
old_folder = tmp_path / "old_folder"
|
||||
old_folder.mkdir()
|
||||
number_of_files = 5
|
||||
file_names = [f"file{i}.py" for i in range(1, number_of_files + 1)]
|
||||
old_file_paths = [old_folder / name for name in file_names]
|
||||
for file_path in old_file_paths:
|
||||
file_path.write_text("", encoding="utf-8")
|
||||
file_indexer.get_index(Path("."))
|
||||
|
||||
new_folder = tmp_path / "new_folder"
|
||||
old_folder.rename(new_folder)
|
||||
|
||||
assert _wait_for(
|
||||
lambda: (
|
||||
entries := file_indexer.get_index(Path(".")),
|
||||
all(not entry.rel.startswith("old_folder/") for entry in entries)
|
||||
and all(
|
||||
any(entry.rel == f"new_folder/{name}" for entry in entries)
|
||||
for name in file_names
|
||||
),
|
||||
)[1]
|
||||
)
|
||||
|
||||
|
||||
def test_updates_index_incrementally_by_default(
|
||||
tmp_path: Path, monkeypatch: pytest.MonkeyPatch, file_indexer: FileIndexer
|
||||
) -> None:
|
||||
monkeypatch.chdir(tmp_path)
|
||||
file_indexer.get_index(Path("."))
|
||||
|
||||
rebuilds_before = file_indexer.stats.rebuilds
|
||||
incremental_before = file_indexer.stats.incremental_updates
|
||||
|
||||
target = tmp_path / "stats_file.py"
|
||||
target.write_text("", encoding="utf-8")
|
||||
|
||||
assert _wait_for(
|
||||
lambda: any(
|
||||
entry.rel == target.name for entry in file_indexer.get_index(Path("."))
|
||||
)
|
||||
)
|
||||
|
||||
assert file_indexer.stats.rebuilds == rebuilds_before
|
||||
assert file_indexer.stats.incremental_updates >= incremental_before + 1
|
||||
|
||||
|
||||
def test_rebuilds_index_when_mass_change_threshold_is_exceeded(
|
||||
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
mass_change_threshold = 5
|
||||
# in an ideal world, we would use "threshold + 1", but in reality, we need to test with a
|
||||
# number of files important enough to MAKE SURE that a batch of >= threshold events will be
|
||||
# detected by the watcher
|
||||
number_of_files = mass_change_threshold * 3
|
||||
monkeypatch.chdir(tmp_path)
|
||||
indexer = FileIndexer(mass_change_threshold=mass_change_threshold)
|
||||
try:
|
||||
indexer.get_index(Path("."))
|
||||
rebuilds_before = indexer.stats.rebuilds
|
||||
|
||||
ThreadPoolExecutor(max_workers=number_of_files).map(
|
||||
lambda i: (tmp_path / f"bulk{i}.py").write_text("", encoding="utf-8"),
|
||||
range(number_of_files),
|
||||
)
|
||||
|
||||
assert _wait_for(lambda: len(indexer.get_index(Path("."))) == number_of_files)
|
||||
# we do not assert that "incremental_updates" did not change,
|
||||
# as the watcher potentially reported some batches of events that were
|
||||
# smaller than the threshold
|
||||
assert indexer.stats.rebuilds >= rebuilds_before + 1
|
||||
finally:
|
||||
indexer.shutdown()
|
||||
|
||||
|
||||
def test_switching_between_roots_restarts_index(
|
||||
tmp_path: Path,
|
||||
tmp_path_factory: pytest.TempPathFactory,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
file_indexer: FileIndexer,
|
||||
) -> None:
|
||||
first_root = tmp_path
|
||||
second_root = tmp_path_factory.mktemp("second-root")
|
||||
(first_root / "first.py").write_text("", encoding="utf-8")
|
||||
(second_root / "second.py").write_text("", encoding="utf-8")
|
||||
|
||||
monkeypatch.chdir(first_root)
|
||||
assert _wait_for(
|
||||
lambda: any(
|
||||
entry.rel == "first.py" for entry in file_indexer.get_index(Path("."))
|
||||
)
|
||||
)
|
||||
|
||||
monkeypatch.chdir(second_root)
|
||||
assert _wait_for(
|
||||
lambda: all(
|
||||
entry.rel != "first.py" for entry in file_indexer.get_index(Path("."))
|
||||
)
|
||||
)
|
||||
assert _wait_for(
|
||||
lambda: any(
|
||||
entry.rel == "second.py" for entry in file_indexer.get_index(Path("."))
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def test_watcher_failure_does_not_break_existing_index(
|
||||
tmp_path: Path, monkeypatch: pytest.MonkeyPatch, file_indexer: FileIndexer
|
||||
) -> None:
|
||||
monkeypatch.chdir(tmp_path)
|
||||
seed = tmp_path / "seed.py"
|
||||
seed.write_text("", encoding="utf-8")
|
||||
file_indexer.get_index(Path("."))
|
||||
|
||||
def boom(*_: object, **__: object) -> None:
|
||||
raise RuntimeError("boom")
|
||||
|
||||
monkeypatch.setattr(file_indexer._store, "apply_changes", boom)
|
||||
|
||||
(tmp_path / "new_file.py").write_text("", encoding="utf-8")
|
||||
|
||||
assert _wait_for(
|
||||
lambda: (
|
||||
entries := file_indexer.get_index(Path(".")),
|
||||
# new file was not added: watcher failed
|
||||
all(entry.rel != "new_file.py" for entry in entries)
|
||||
# but the existing index is still intact
|
||||
and all(entry.rel == "seed.py" for entry in entries),
|
||||
)[1]
|
||||
)
|
||||
|
||||
|
||||
def test_shutdown_cleans_up_resources(
|
||||
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
monkeypatch.chdir(tmp_path)
|
||||
(tmp_path / "test.txt").write_text("", encoding="utf-8")
|
||||
file_indexer = FileIndexer()
|
||||
file_indexer.get_index(Path("."))
|
||||
|
||||
file_indexer.shutdown()
|
||||
assert file_indexer.get_index(Path(".")) == []
|
||||
96
tests/autocompletion/test_fuzzy.py
Normal file
96
tests/autocompletion/test_fuzzy.py
Normal file
|
|
@ -0,0 +1,96 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from vibe.core.autocompletion.fuzzy import fuzzy_match
|
||||
|
||||
|
||||
def test_empty_pattern_matches_anything() -> None:
|
||||
result = fuzzy_match("", "any_text")
|
||||
|
||||
assert result.matched is True
|
||||
assert result.score == 0.0
|
||||
assert result.matched_indices == ()
|
||||
|
||||
|
||||
def test_matches_exact_prefix() -> None:
|
||||
result = fuzzy_match("src/", "src/main.py")
|
||||
|
||||
assert result.matched_indices == (0, 1, 2, 3)
|
||||
|
||||
|
||||
def test_no_match_when_characters_are_out_of_order() -> None:
|
||||
result = fuzzy_match("ms", "src/main.py")
|
||||
|
||||
assert result.matched is False
|
||||
|
||||
|
||||
def test_treats_consecutive_characters_as_subsequence() -> None:
|
||||
result = fuzzy_match("main", "src/main.py")
|
||||
|
||||
assert result.matched_indices == (4, 5, 6, 7)
|
||||
|
||||
|
||||
def test_ignores_case() -> None:
|
||||
result = fuzzy_match("SRC", "src/main.py")
|
||||
|
||||
assert result.matched_indices == (0, 1, 2)
|
||||
|
||||
|
||||
def test_treats_scattered_characters_as_subsequence() -> None:
|
||||
result = fuzzy_match("sm", "src/main.py")
|
||||
|
||||
assert result.matched_indices == (0, 4)
|
||||
|
||||
|
||||
def test_treats_path_separator_as_word_boundary() -> None:
|
||||
result = fuzzy_match("m", "src/main.py")
|
||||
|
||||
assert result.matched_indices == (4,)
|
||||
|
||||
|
||||
def test_prefers_word_boundary_matching_over_subsequence() -> None:
|
||||
boundary_result = fuzzy_match("ma", "src/main.py")
|
||||
subsequence_result = fuzzy_match("ma", "src/important.py")
|
||||
|
||||
assert boundary_result.score > subsequence_result.score
|
||||
|
||||
|
||||
def test_scores_exact_prefix_match_higher_than_consecutive_and_subsequence() -> None:
|
||||
prefix_result = fuzzy_match("src", "src/main.py")
|
||||
consecutive_result = fuzzy_match("main", "src/main.py")
|
||||
subsequence_result = fuzzy_match("sm", "src/main.py")
|
||||
|
||||
assert prefix_result.matched_indices == (0, 1, 2)
|
||||
assert prefix_result.score > consecutive_result.score
|
||||
assert prefix_result.score > subsequence_result.score
|
||||
|
||||
|
||||
def test_finds_no_match_when_pattern_is_longer_than_entry() -> None:
|
||||
result = fuzzy_match("very_long_pattern", "short")
|
||||
|
||||
assert result.matched is False
|
||||
|
||||
|
||||
def test_prefers_consecutive_match_over_subsequence() -> None:
|
||||
consecutive = fuzzy_match("main", "src/main.py")
|
||||
subsequence = fuzzy_match("mn", "src/main.py")
|
||||
|
||||
assert consecutive.score > subsequence.score
|
||||
|
||||
|
||||
def test_prefers_case_sensitive_match_over_case_insensitive() -> None:
|
||||
case_match = fuzzy_match("Main", "src/Main.py")
|
||||
case_insensitive_match = fuzzy_match("main", "src/Main.py")
|
||||
|
||||
assert case_match.score > case_insensitive_match.score
|
||||
|
||||
|
||||
def test_treats_uppercase_letter_as_word_boundary() -> None:
|
||||
result = fuzzy_match("MP", "src/MainPy.py")
|
||||
|
||||
assert result.matched_indices == (4, 8)
|
||||
|
||||
|
||||
def test_favors_earlier_positions() -> None:
|
||||
result = fuzzy_match("a", "banana")
|
||||
|
||||
assert result.matched_indices == (1,)
|
||||
122
tests/autocompletion/test_path_completer_fuzzy.py
Normal file
122
tests/autocompletion/test_path_completer_fuzzy.py
Normal file
|
|
@ -0,0 +1,122 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from vibe.core.autocompletion.completers import PathCompleter
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def file_tree(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path:
|
||||
(tmp_path / "src" / "utils").mkdir(parents=True)
|
||||
(tmp_path / "src" / "main.py").write_text("", encoding="utf-8")
|
||||
(tmp_path / "src" / "models.py").write_text("", encoding="utf-8")
|
||||
(tmp_path / "src" / "core").mkdir(parents=True)
|
||||
(tmp_path / "src" / "core" / "logger.py").write_text("", encoding="utf-8")
|
||||
(tmp_path / "src" / "core" / "models.py").write_text("", encoding="utf-8")
|
||||
(tmp_path / "src" / "core" / "ports.py").write_text("", encoding="utf-8")
|
||||
(tmp_path / "src" / "core" / "sanitize.py").write_text("", encoding="utf-8")
|
||||
(tmp_path / "src" / "core" / "use_cases.py").write_text("", encoding="utf-8")
|
||||
(tmp_path / "src" / "core" / "validate.py").write_text("", encoding="utf-8")
|
||||
(tmp_path / "README.md").write_text("", encoding="utf-8")
|
||||
(tmp_path / ".env").write_text("", encoding="utf-8")
|
||||
(tmp_path / "config").mkdir(parents=True)
|
||||
(tmp_path / "config" / "settings.py").write_text("", encoding="utf-8")
|
||||
(tmp_path / "config" / "database.py").write_text("", encoding="utf-8")
|
||||
monkeypatch.chdir(tmp_path)
|
||||
return tmp_path
|
||||
|
||||
|
||||
def test_fuzzy_matches_subsequence_characters(file_tree: Path) -> None:
|
||||
results = PathCompleter().get_completions("@sr", cursor_pos=3)
|
||||
|
||||
assert "@src/" in results
|
||||
|
||||
|
||||
def test_fuzzy_matches_consecutive_characters_higher(file_tree: Path) -> None:
|
||||
results = PathCompleter().get_completions("@src/main", cursor_pos=9)
|
||||
|
||||
assert "@src/main.py" in results
|
||||
|
||||
|
||||
def test_fuzzy_matches_prefix_highest(file_tree: Path) -> None:
|
||||
results = PathCompleter().get_completions("@src", cursor_pos=4)
|
||||
|
||||
assert results[0].startswith("@src")
|
||||
|
||||
|
||||
def test_fuzzy_matches_across_directory_boundaries(file_tree: Path) -> None:
|
||||
results = PathCompleter().get_completions("@src/main", cursor_pos=9)
|
||||
|
||||
assert "@src/main.py" in results
|
||||
|
||||
|
||||
def test_fuzzy_matches_case_insensitive(file_tree: Path) -> None:
|
||||
completer = PathCompleter()
|
||||
assert "@README.md" in completer.get_completions("@readme", cursor_pos=7)
|
||||
assert "@README.md" in completer.get_completions("@README", cursor_pos=7)
|
||||
|
||||
|
||||
def test_fuzzy_matches_word_boundaries_preferred(file_tree: Path) -> None:
|
||||
results = PathCompleter().get_completions("@src/mp", cursor_pos=7)
|
||||
|
||||
assert "@src/models.py" in results
|
||||
|
||||
|
||||
def test_fuzzy_matches_empty_pattern_shows_all(file_tree: Path) -> None:
|
||||
results = PathCompleter().get_completions("@", cursor_pos=1)
|
||||
|
||||
assert "@README.md" in results
|
||||
assert "@src/" in results
|
||||
|
||||
|
||||
def test_fuzzy_matches_hidden_files_only_with_dot(file_tree: Path) -> None:
|
||||
completer = PathCompleter()
|
||||
assert "@.env" not in completer.get_completions("@e", cursor_pos=2)
|
||||
assert "@.env" in completer.get_completions("@.", cursor_pos=2)
|
||||
|
||||
|
||||
def test_fuzzy_matches_directories_and_files(file_tree: Path) -> None:
|
||||
results = PathCompleter().get_completions("@src/", cursor_pos=5)
|
||||
|
||||
assert any(r.endswith("/") for r in results)
|
||||
assert any(not r.endswith("/") for r in results)
|
||||
|
||||
|
||||
def test_fuzzy_matches_sorted_by_score(file_tree: Path) -> None:
|
||||
results = PathCompleter().get_completions("@src/main", cursor_pos=9)
|
||||
|
||||
assert results[0] == "@src/main.py"
|
||||
|
||||
|
||||
def test_fuzzy_matches_nested_directories(file_tree: Path) -> None:
|
||||
results = PathCompleter().get_completions("@src/core/l", cursor_pos=11)
|
||||
|
||||
assert "@src/core/logger.py" in results
|
||||
|
||||
|
||||
def test_fuzzy_matches_partial_filename(file_tree: Path) -> None:
|
||||
results = PathCompleter().get_completions("@src/mo", cursor_pos=7)
|
||||
|
||||
assert "@src/models.py" in results
|
||||
|
||||
|
||||
def test_fuzzy_matches_multiple_files_with_same_pattern(file_tree: Path) -> None:
|
||||
results = PathCompleter().get_completions("@src/m", cursor_pos=6)
|
||||
|
||||
assert "@src/main.py" in results
|
||||
assert "@src/models.py" in results
|
||||
|
||||
|
||||
def test_fuzzy_matches_no_results_when_no_match(file_tree: Path) -> None:
|
||||
completer = PathCompleter()
|
||||
assert completer.get_completions("@xyz123", cursor_pos=7) == []
|
||||
|
||||
|
||||
def test_fuzzy_matches_directory_traversal(file_tree: Path) -> None:
|
||||
results = PathCompleter().get_completions("@src/", cursor_pos=5)
|
||||
|
||||
assert "@src/main.py" in results
|
||||
assert "@src/core/" in results
|
||||
assert "@src/utils/" in results
|
||||
69
tests/autocompletion/test_path_completer_recursive.py
Normal file
69
tests/autocompletion/test_path_completer_recursive.py
Normal file
|
|
@ -0,0 +1,69 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from vibe.core.autocompletion.completers import PathCompleter
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def file_tree(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path:
|
||||
(tmp_path / "vibe" / "acp").mkdir(parents=True)
|
||||
(tmp_path / "vibe" / "acp" / "entrypoint.py").write_text("")
|
||||
(tmp_path / "vibe" / "acp" / "agent.py").write_text("")
|
||||
(tmp_path / "vibe" / "cli" / "autocompletion").mkdir(parents=True)
|
||||
(tmp_path / "vibe" / "cli" / "autocompletion" / "fuzzy.py").write_text("")
|
||||
(tmp_path / "vibe" / "cli" / "autocompletion" / "completers.py").write_text("")
|
||||
(tmp_path / "tests" / "autocompletion").mkdir(parents=True)
|
||||
(tmp_path / "tests" / "autocompletion" / "test_fuzzy.py").write_text("")
|
||||
(tmp_path / "README.md").write_text("")
|
||||
monkeypatch.chdir(tmp_path)
|
||||
return tmp_path
|
||||
|
||||
|
||||
def test_finds_files_recursively_by_filename(file_tree: Path) -> None:
|
||||
results = PathCompleter().get_completions("@entryp", cursor_pos=7)
|
||||
|
||||
assert results[0] == "@vibe/acp/entrypoint.py"
|
||||
|
||||
|
||||
def test_finds_files_recursively_by_partial_path(file_tree: Path) -> None:
|
||||
results = PathCompleter().get_completions("@acp/entry", cursor_pos=10)
|
||||
|
||||
assert results[0] == "@vibe/acp/entrypoint.py"
|
||||
|
||||
|
||||
def test_finds_files_recursively_with_subsequence(file_tree: Path) -> None:
|
||||
results = PathCompleter().get_completions("@acp/ent", cursor_pos=9)
|
||||
|
||||
assert results[0] == "@vibe/acp/entrypoint.py"
|
||||
|
||||
|
||||
def test_finds_multiple_matches_recursively(file_tree: Path) -> None:
|
||||
results = PathCompleter().get_completions("@fuzzy", cursor_pos=6)
|
||||
|
||||
vibe_index = results.index("@vibe/cli/autocompletion/fuzzy.py")
|
||||
test_index = results.index("@tests/autocompletion/test_fuzzy.py")
|
||||
assert vibe_index < test_index
|
||||
|
||||
|
||||
def test_prioritizes_exact_path_matches(file_tree: Path) -> None:
|
||||
results = PathCompleter().get_completions("@vibe/acp/entrypoint", cursor_pos=20)
|
||||
|
||||
assert results[0] == "@vibe/acp/entrypoint.py"
|
||||
|
||||
|
||||
def test_finds_files_when_pattern_matches_directory_name(file_tree: Path) -> None:
|
||||
results = PathCompleter().get_completions("@acp", cursor_pos=4)
|
||||
|
||||
assert results == [
|
||||
"@vibe/acp/",
|
||||
"@vibe/acp/agent.py",
|
||||
"@vibe/acp/entrypoint.py",
|
||||
"@vibe/cli/autocompletion/completers.py",
|
||||
"@tests/autocompletion/",
|
||||
"@tests/autocompletion/test_fuzzy.py",
|
||||
"@vibe/cli/autocompletion/",
|
||||
"@vibe/cli/autocompletion/fuzzy.py",
|
||||
]
|
||||
258
tests/autocompletion/test_path_completion_controller.py
Normal file
258
tests/autocompletion/test_path_completion_controller.py
Normal file
|
|
@ -0,0 +1,258 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
from textual import events
|
||||
|
||||
from vibe.cli.autocompletion.base import CompletionResult, CompletionView
|
||||
from vibe.cli.autocompletion.path_completion import PathCompletionController
|
||||
from vibe.core.autocompletion.completers import PathCompleter
|
||||
|
||||
|
||||
class StubView(CompletionView):
|
||||
def __init__(self) -> None:
|
||||
self.suggestions: list[tuple[list[tuple[str, str]], int]] = []
|
||||
self.clears = 0
|
||||
self.replacements: list[tuple[int, int, str]] = []
|
||||
|
||||
def render_completion_suggestions(
|
||||
self, suggestions: list[tuple[str, str]], selected_index: int
|
||||
) -> None:
|
||||
self.suggestions.append((suggestions, selected_index))
|
||||
|
||||
def clear_completion_suggestions(self) -> None:
|
||||
self.clears += 1
|
||||
|
||||
def replace_completion_range(self, start: int, end: int, replacement: str) -> None:
|
||||
self.replacements.append((start, end, replacement))
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def file_tree(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path:
|
||||
(tmp_path / "src" / "utils").mkdir(parents=True)
|
||||
(tmp_path / "src" / "main.py").write_text("", encoding="utf-8")
|
||||
(tmp_path / "src" / "core").mkdir(parents=True)
|
||||
(tmp_path / "src" / "core" / "logger.py").write_text("", encoding="utf-8")
|
||||
(tmp_path / "src" / "core" / "models.py").write_text("", encoding="utf-8")
|
||||
(tmp_path / "src" / "core" / "ports.py").write_text("", encoding="utf-8")
|
||||
(tmp_path / "src" / "core" / "sanitize.py").write_text("", encoding="utf-8")
|
||||
(tmp_path / "src" / "core" / "use_cases.py").write_text("", encoding="utf-8")
|
||||
(tmp_path / "src" / "core" / "validate.py").write_text("", encoding="utf-8")
|
||||
(tmp_path / "README.md").write_text("", encoding="utf-8")
|
||||
(tmp_path / ".env").write_text("", encoding="utf-8")
|
||||
monkeypatch.chdir(tmp_path)
|
||||
return tmp_path
|
||||
|
||||
|
||||
def make_controller(
|
||||
max_entries_to_process: int | None = None, target_matches: int | None = None
|
||||
) -> tuple[PathCompletionController, StubView]:
|
||||
completer_kwargs = {}
|
||||
if max_entries_to_process is not None:
|
||||
completer_kwargs["max_entries_to_process"] = max_entries_to_process
|
||||
if target_matches is not None:
|
||||
completer_kwargs["target_matches"] = target_matches
|
||||
|
||||
completer = PathCompleter(**completer_kwargs)
|
||||
view = StubView()
|
||||
controller = PathCompletionController(completer, view)
|
||||
return controller, view
|
||||
|
||||
|
||||
def test_lists_root_entries(file_tree: Path) -> None:
|
||||
controller, view = make_controller()
|
||||
|
||||
controller.on_text_changed("@", cursor_index=1)
|
||||
|
||||
suggestions, selected = view.suggestions[-1]
|
||||
assert selected == 0
|
||||
assert [alias for alias, _ in suggestions] == ["@README.md", "@src/"]
|
||||
|
||||
|
||||
def test_suggests_hidden_entries_only_with_dot_prefix(file_tree: Path) -> None:
|
||||
controller, view = make_controller()
|
||||
|
||||
controller.on_text_changed("@.", cursor_index=2)
|
||||
|
||||
suggestions, _ = view.suggestions[-1]
|
||||
assert suggestions[0][0] == "@.env"
|
||||
|
||||
|
||||
def test_lists_nested_entries_when_prefixing_with_folder_name(file_tree: Path) -> None:
|
||||
controller, view = make_controller()
|
||||
|
||||
controller.on_text_changed("@src/", cursor_index=5)
|
||||
|
||||
suggestions, _ = view.suggestions[-1]
|
||||
assert [alias for alias, _ in suggestions] == [
|
||||
"@src/core/",
|
||||
"@src/main.py",
|
||||
"@src/utils/",
|
||||
]
|
||||
|
||||
|
||||
def test_resets_when_fragment_invalid(file_tree: Path) -> None:
|
||||
controller, view = make_controller()
|
||||
|
||||
controller.on_text_changed("@src", cursor_index=4)
|
||||
assert view.suggestions
|
||||
|
||||
controller.on_text_changed("@src foo", cursor_index=8)
|
||||
assert view.clears == 1
|
||||
assert (
|
||||
controller.on_key(events.Key("tab", None), "@src foo", 8)
|
||||
is CompletionResult.IGNORED
|
||||
)
|
||||
|
||||
|
||||
def test_applies_selected_completion_on_tab_keycode(file_tree: Path) -> None:
|
||||
controller, view = make_controller()
|
||||
|
||||
controller.on_text_changed("@R", cursor_index=2)
|
||||
result = controller.on_key(events.Key("tab", None), "@R", 2)
|
||||
|
||||
assert result is CompletionResult.HANDLED
|
||||
assert view.replacements == [(0, 2, "@README.md")]
|
||||
assert view.clears == 1
|
||||
|
||||
|
||||
def test_applies_selected_completion_on_enter_keycode(file_tree: Path) -> None:
|
||||
controller, view = make_controller()
|
||||
controller.on_text_changed("@src/", cursor_index=5)
|
||||
controller.on_key(events.Key("down", None), "@src/", 5)
|
||||
|
||||
result = controller.on_key(events.Key("enter", None), "@src/", 5)
|
||||
|
||||
assert result is CompletionResult.HANDLED
|
||||
assert view.replacements == [(0, 5, "@src/main.py")]
|
||||
assert view.clears == 1
|
||||
|
||||
|
||||
def test_navigates_and_cycles_across_suggestions(file_tree: Path) -> None:
|
||||
controller, view = make_controller()
|
||||
|
||||
controller.on_text_changed("@src/", cursor_index=5)
|
||||
controller.on_key(events.Key("down", None), "@src/", 5)
|
||||
suggestions, selected_index = view.suggestions[-1]
|
||||
assert [alias for alias, _ in suggestions] == [
|
||||
"@src/core/",
|
||||
"@src/main.py",
|
||||
"@src/utils/",
|
||||
]
|
||||
assert selected_index == 1
|
||||
controller.on_key(events.Key("up", None), "@src/", 5)
|
||||
suggestions, selected_index = view.suggestions[-1]
|
||||
assert selected_index == 0
|
||||
|
||||
controller.on_key(events.Key("down", None), "@src/", 5)
|
||||
controller.on_key(events.Key("down", None), "@src/", 5)
|
||||
suggestions, selected_index = view.suggestions[-1]
|
||||
assert selected_index == 2
|
||||
|
||||
controller.on_key(events.Key("down", None), "@src/", 5)
|
||||
suggestions, selected_index = view.suggestions[-1]
|
||||
assert selected_index == 0
|
||||
|
||||
|
||||
def test_limits_suggestions_to_ten(file_tree: Path) -> None:
|
||||
(file_tree / "src" / "core" / "extra").mkdir(parents=True)
|
||||
[
|
||||
(file_tree / "src" / "core" / "extra" / f"extra_file_{i}.py").write_text(
|
||||
"", encoding="utf-8"
|
||||
)
|
||||
for i in range(1, 13)
|
||||
]
|
||||
controller, view = make_controller()
|
||||
|
||||
controller.on_text_changed("@src/core/extra/", cursor_index=16)
|
||||
suggestions, selected_index = view.suggestions[-1]
|
||||
assert len(suggestions) == 10
|
||||
assert [alias for alias, _ in suggestions] == [
|
||||
"@src/core/extra/extra_file_1.py",
|
||||
"@src/core/extra/extra_file_10.py",
|
||||
"@src/core/extra/extra_file_11.py",
|
||||
"@src/core/extra/extra_file_12.py",
|
||||
"@src/core/extra/extra_file_2.py",
|
||||
"@src/core/extra/extra_file_3.py",
|
||||
"@src/core/extra/extra_file_4.py",
|
||||
"@src/core/extra/extra_file_5.py",
|
||||
"@src/core/extra/extra_file_6.py",
|
||||
"@src/core/extra/extra_file_7.py",
|
||||
]
|
||||
assert selected_index == 0
|
||||
|
||||
|
||||
def test_does_not_handle_when_cursor_at_beginning_of_input(file_tree: Path) -> None:
|
||||
controller, _ = make_controller()
|
||||
|
||||
assert not controller.can_handle("@file", cursor_index=0)
|
||||
assert not controller.can_handle("", cursor_index=0)
|
||||
assert not controller.can_handle("hello@file", cursor_index=0)
|
||||
|
||||
|
||||
def test_does_not_handle_when_cursor_before_or_at_the_at_symbol(
|
||||
file_tree: Path,
|
||||
) -> None:
|
||||
controller, _ = make_controller()
|
||||
|
||||
assert not controller.can_handle("@file", cursor_index=0)
|
||||
assert not controller.can_handle("hello@file", cursor_index=5)
|
||||
|
||||
|
||||
def test_does_handle_when_cursor_after_the_at_symbol_even_in_the_middle_of_the_input(
|
||||
file_tree: Path,
|
||||
) -> None:
|
||||
controller, _ = make_controller()
|
||||
|
||||
assert controller.can_handle("@file", cursor_index=1)
|
||||
assert controller.can_handle("hello @file", cursor_index=7)
|
||||
|
||||
|
||||
def test_lists_immediate_children_when_path_ends_with_slash(file_tree: Path) -> None:
|
||||
controller, view = make_controller()
|
||||
|
||||
controller.on_text_changed("@src/", cursor_index=5)
|
||||
|
||||
suggestions, _ = view.suggestions[-1]
|
||||
assert [alias for alias, _ in suggestions] == [
|
||||
"@src/core/",
|
||||
"@src/main.py",
|
||||
"@src/utils/",
|
||||
]
|
||||
|
||||
|
||||
def test_respects_max_entries_to_process_limit(file_tree: Path) -> None:
|
||||
for i in range(30):
|
||||
(file_tree / f"file_{i:03d}.txt").write_text("", encoding="utf-8")
|
||||
|
||||
controller, view = make_controller(max_entries_to_process=10)
|
||||
|
||||
controller.on_text_changed("@", cursor_index=1)
|
||||
|
||||
suggestions, _ = view.suggestions[-1]
|
||||
assert len(suggestions) <= 10
|
||||
|
||||
|
||||
def test_respects_target_matches_limit_for_listing(file_tree: Path) -> None:
|
||||
for i in range(30):
|
||||
(file_tree / f"item_{i:03d}.txt").write_text("", encoding="utf-8")
|
||||
|
||||
controller, view = make_controller(target_matches=5)
|
||||
|
||||
controller.on_text_changed("@", cursor_index=1)
|
||||
|
||||
suggestions, _ = view.suggestions[-1]
|
||||
assert len(suggestions) <= 5
|
||||
|
||||
|
||||
def test_respects_target_matches_limit_for_fuzzy_search(file_tree: Path) -> None:
|
||||
for i in range(30):
|
||||
(file_tree / f"test_file_{i:03d}.py").write_text("", encoding="utf-8")
|
||||
|
||||
controller, view = make_controller(target_matches=5)
|
||||
|
||||
controller.on_text_changed("@test", cursor_index=5)
|
||||
|
||||
suggestions, _ = view.suggestions[-1]
|
||||
assert len(suggestions) <= 5
|
||||
142
tests/autocompletion/test_path_prompt_transformer.py
Normal file
142
tests/autocompletion/test_path_prompt_transformer.py
Normal file
|
|
@ -0,0 +1,142 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from vibe.core.autocompletion.path_prompt_adapter import (
|
||||
DEFAULT_MAX_EMBED_BYTES,
|
||||
render_path_prompt,
|
||||
)
|
||||
|
||||
|
||||
def test_treats_paths_to_files_as_embedded_resources(tmp_path: Path) -> None:
|
||||
readme = tmp_path / "README.md"
|
||||
readme.write_text("hello", encoding="utf-8")
|
||||
src_dir = tmp_path / "src"
|
||||
src_dir.mkdir()
|
||||
main_py = src_dir / "main.py"
|
||||
main_py.write_text("print('hi')", encoding="utf-8")
|
||||
|
||||
rendered = render_path_prompt(
|
||||
"Please review @README.md and @src/main.py",
|
||||
base_dir=tmp_path,
|
||||
max_embed_bytes=DEFAULT_MAX_EMBED_BYTES,
|
||||
)
|
||||
|
||||
expected = (
|
||||
f"Please review README.md and src/main.py\n\n"
|
||||
f"{readme.as_uri()}\n```\nhello\n```\n\n"
|
||||
f"{main_py.as_uri()}\n```\nprint('hi')\n```"
|
||||
)
|
||||
assert rendered == expected
|
||||
|
||||
|
||||
def test_treats_path_to_directory_as_resource_links(tmp_path: Path) -> None:
|
||||
docs_dir = tmp_path / "docs"
|
||||
docs_dir.mkdir()
|
||||
|
||||
rendered = render_path_prompt(
|
||||
"See @docs/ for details",
|
||||
base_dir=tmp_path,
|
||||
max_embed_bytes=DEFAULT_MAX_EMBED_BYTES,
|
||||
)
|
||||
|
||||
expected = f"See docs/ for details\n\nuri: {docs_dir.as_uri()}\nname: docs/"
|
||||
assert rendered == expected
|
||||
|
||||
|
||||
def test_keeps_emails_and_embeds_paths(tmp_path: Path) -> None:
|
||||
readme = tmp_path / "README.md"
|
||||
readme.write_text("hello", encoding="utf-8")
|
||||
|
||||
rendered = render_path_prompt(
|
||||
"Contact user@example.com about @README.md",
|
||||
base_dir=tmp_path,
|
||||
max_embed_bytes=DEFAULT_MAX_EMBED_BYTES,
|
||||
)
|
||||
|
||||
expected = (
|
||||
f"Contact user@example.com about README.md\n\n"
|
||||
f"{readme.as_uri()}\n```\nhello\n```"
|
||||
)
|
||||
assert rendered == expected
|
||||
|
||||
|
||||
def test_ignores_nonexistent_paths(tmp_path: Path) -> None:
|
||||
rendered = render_path_prompt(
|
||||
"Missing @nope.txt here",
|
||||
base_dir=tmp_path,
|
||||
max_embed_bytes=DEFAULT_MAX_EMBED_BYTES,
|
||||
)
|
||||
|
||||
assert rendered == "Missing @nope.txt here"
|
||||
|
||||
|
||||
def test_falls_back_to_link_for_binary_files(tmp_path: Path) -> None:
|
||||
binary_path = tmp_path / "image.bin"
|
||||
binary_path.write_bytes(b"\x00\x01\x02")
|
||||
|
||||
rendered = render_path_prompt(
|
||||
"Inspect @image.bin", base_dir=tmp_path, max_embed_bytes=DEFAULT_MAX_EMBED_BYTES
|
||||
)
|
||||
|
||||
assert (
|
||||
rendered == f"Inspect image.bin\n\nuri: {binary_path.as_uri()}\nname: image.bin"
|
||||
)
|
||||
|
||||
|
||||
def test_excludes_supposed_binary_files_quickly_before_reading_content(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
zip_like = tmp_path / "archive.zip"
|
||||
zip_like.write_text("text content inside but treated as binary", encoding="utf-8")
|
||||
|
||||
rendered = render_path_prompt(
|
||||
"Inspect @archive.zip",
|
||||
base_dir=tmp_path,
|
||||
max_embed_bytes=DEFAULT_MAX_EMBED_BYTES,
|
||||
)
|
||||
|
||||
assert (
|
||||
rendered
|
||||
== f"Inspect archive.zip\n\nuri: {zip_like.as_uri()}\nname: archive.zip"
|
||||
)
|
||||
|
||||
|
||||
def test_applies_max_embed_size_guard(tmp_path: Path) -> None:
|
||||
large_file = tmp_path / "big.txt"
|
||||
large_file.write_text("a" * 50, encoding="utf-8")
|
||||
|
||||
rendered = render_path_prompt(
|
||||
"Review @big.txt", base_dir=tmp_path, max_embed_bytes=10
|
||||
)
|
||||
|
||||
assert rendered == f"Review big.txt\n\nuri: {large_file.as_uri()}\nname: big.txt"
|
||||
|
||||
|
||||
def test_parses_paths_with_special_characters_when_quoted(tmp_path: Path) -> None:
|
||||
weird = tmp_path / "weird name(1).txt"
|
||||
weird.write_text("odd", encoding="utf-8")
|
||||
|
||||
rendered = render_path_prompt(
|
||||
'Open @"weird name(1).txt"',
|
||||
base_dir=tmp_path,
|
||||
max_embed_bytes=DEFAULT_MAX_EMBED_BYTES,
|
||||
)
|
||||
|
||||
assert rendered == f"Open weird name(1).txt\n\n{weird.as_uri()}\n```\nodd\n```"
|
||||
|
||||
|
||||
def test_deduplicates_identical_paths(tmp_path: Path) -> None:
|
||||
readme = tmp_path / "README.md"
|
||||
readme.write_text("hello", encoding="utf-8")
|
||||
|
||||
rendered = render_path_prompt(
|
||||
"See @README.md and again @README.md",
|
||||
base_dir=tmp_path,
|
||||
max_embed_bytes=DEFAULT_MAX_EMBED_BYTES,
|
||||
)
|
||||
|
||||
assert (
|
||||
rendered
|
||||
== f"See README.md and again README.md\n\n{readme.as_uri()}\n```\nhello\n```"
|
||||
)
|
||||
162
tests/autocompletion/test_slash_command_controller.py
Normal file
162
tests/autocompletion/test_slash_command_controller.py
Normal file
|
|
@ -0,0 +1,162 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from typing import NamedTuple
|
||||
|
||||
from textual import events
|
||||
|
||||
from vibe.cli.autocompletion.base import CompletionResult, CompletionView
|
||||
from vibe.cli.autocompletion.slash_command import SlashCommandController
|
||||
from vibe.core.autocompletion.completers import CommandCompleter
|
||||
|
||||
|
||||
class Suggestion(NamedTuple):
|
||||
alias: str
|
||||
description: str
|
||||
|
||||
|
||||
class SuggestionEvent(NamedTuple):
|
||||
suggestions: list[Suggestion]
|
||||
selected_index: int
|
||||
|
||||
|
||||
class Replacement(NamedTuple):
|
||||
start: int
|
||||
end: int
|
||||
replacement: str
|
||||
|
||||
|
||||
class StubView(CompletionView):
|
||||
def __init__(self) -> None:
|
||||
self.suggestion_events: list[SuggestionEvent] = []
|
||||
self.reset_count = 0
|
||||
self.replacements: list[Replacement] = []
|
||||
|
||||
def render_completion_suggestions(
|
||||
self, suggestions: list[tuple[str, str]], selected_index: int
|
||||
) -> None:
|
||||
typed = [Suggestion(alias, description) for alias, description in suggestions]
|
||||
self.suggestion_events.append(SuggestionEvent(typed, selected_index))
|
||||
|
||||
def clear_completion_suggestions(self) -> None:
|
||||
self.reset_count += 1
|
||||
|
||||
def replace_completion_range(self, start: int, end: int, replacement: str) -> None:
|
||||
self.replacements.append(Replacement(start, end, replacement))
|
||||
|
||||
|
||||
def key_event(key: str) -> events.Key:
|
||||
return events.Key(key, character=None)
|
||||
|
||||
|
||||
def make_controller(
|
||||
*, prefix: str | None = None
|
||||
) -> tuple[SlashCommandController, StubView]:
|
||||
commands = [
|
||||
("/config", "Show current configuration"),
|
||||
("/compact", "Compact history"),
|
||||
("/help", "Display help"),
|
||||
("/config", "Override description"),
|
||||
("/summarize", "Summarize history"),
|
||||
("/logpath", "Show log path"),
|
||||
("/exit", "Exit application"),
|
||||
("/vim", "Toggle vim keybindings"),
|
||||
]
|
||||
completer = CommandCompleter(commands)
|
||||
view = StubView()
|
||||
controller = SlashCommandController(completer, view)
|
||||
|
||||
if prefix is not None:
|
||||
controller.on_text_changed(prefix, cursor_index=len(prefix))
|
||||
view.suggestion_events.clear()
|
||||
|
||||
return controller, view
|
||||
|
||||
|
||||
def test_on_text_change_emits_matching_suggestions_in_insertion_order_and_ignores_duplicates() -> (
|
||||
None
|
||||
):
|
||||
controller, view = make_controller(prefix="/c")
|
||||
|
||||
controller.on_text_changed("/c", cursor_index=2)
|
||||
|
||||
suggestions, selected = view.suggestion_events[-1]
|
||||
assert suggestions == [
|
||||
Suggestion("/config", "Override description"),
|
||||
Suggestion("/compact", "Compact history"),
|
||||
]
|
||||
assert selected == 0
|
||||
|
||||
|
||||
def test_on_text_change_filters_suggestions_case_insensitively() -> None:
|
||||
controller, view = make_controller(prefix="/c")
|
||||
|
||||
controller.on_text_changed("/CO", cursor_index=3)
|
||||
|
||||
suggestions, _ = view.suggestion_events[-1]
|
||||
assert [suggestion.alias for suggestion in suggestions] == ["/config", "/compact"]
|
||||
|
||||
|
||||
def test_on_text_change_clears_suggestions_when_no_matches() -> None:
|
||||
controller, view = make_controller(prefix="/c")
|
||||
|
||||
controller.on_text_changed("/c", cursor_index=2)
|
||||
controller.on_text_changed("config", cursor_index=6)
|
||||
|
||||
assert view.reset_count >= 1
|
||||
|
||||
|
||||
def test_on_text_change_limits_the_number_of_results_to_five_and_preserve_insertion_order() -> (
|
||||
None
|
||||
):
|
||||
controller, view = make_controller(prefix="/")
|
||||
|
||||
controller.on_text_changed("/", cursor_index=1)
|
||||
|
||||
suggestions, selected_index = view.suggestion_events[-1]
|
||||
assert len(suggestions) == 5
|
||||
assert [suggestion.alias for suggestion in suggestions] == [
|
||||
"/config",
|
||||
"/compact",
|
||||
"/help",
|
||||
"/summarize",
|
||||
"/logpath",
|
||||
]
|
||||
|
||||
|
||||
def test_on_key_tab_applies_selected_completion() -> None:
|
||||
controller, view = make_controller(prefix="/c")
|
||||
|
||||
result = controller.on_key(key_event("tab"), text="/c", cursor_index=2)
|
||||
|
||||
assert result is CompletionResult.HANDLED
|
||||
assert view.replacements == [Replacement(0, 2, "/config")]
|
||||
assert view.reset_count == 1
|
||||
|
||||
|
||||
def test_on_key_down_and_up_cycle_selection() -> None:
|
||||
controller, view = make_controller(prefix="/c")
|
||||
|
||||
controller.on_key(key_event("down"), text="/c", cursor_index=2)
|
||||
suggestions, selected_index = view.suggestion_events[-1]
|
||||
assert selected_index == 1
|
||||
|
||||
controller.on_key(key_event("down"), text="/c", cursor_index=2)
|
||||
suggestions, selected_index = view.suggestion_events[-1]
|
||||
assert selected_index == 0
|
||||
|
||||
controller.on_key(key_event("up"), text="/c", cursor_index=2)
|
||||
suggestions, selected_index = view.suggestion_events[-1]
|
||||
assert selected_index == 1
|
||||
assert [suggestion.alias for suggestion in suggestions] == ["/config", "/compact"]
|
||||
|
||||
|
||||
def test_on_key_enter_submits_selected_completion() -> None:
|
||||
controller, view = make_controller(prefix="/c")
|
||||
|
||||
controller.on_key(key_event("down"), text="/c", cursor_index=2)
|
||||
|
||||
result = controller.on_key(key_event("enter"), text="/c", cursor_index=2)
|
||||
|
||||
assert result is CompletionResult.SUBMIT
|
||||
assert view.replacements == [Replacement(0, 2, "/compact")]
|
||||
assert view.reset_count == 1
|
||||
306
tests/autocompletion/test_ui_chat_autocompletion.py
Normal file
306
tests/autocompletion/test_ui_chat_autocompletion.py
Normal file
|
|
@ -0,0 +1,306 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
from textual.content import Content
|
||||
from textual.style import Style
|
||||
from textual.widgets import Markdown
|
||||
|
||||
from vibe.cli.textual_ui.app import VibeApp
|
||||
from vibe.cli.textual_ui.widgets.chat_input.completion_popup import CompletionPopup
|
||||
from vibe.cli.textual_ui.widgets.chat_input.container import ChatInputContainer
|
||||
from vibe.core.config import SessionLoggingConfig, VibeConfig
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def vibe_config() -> VibeConfig:
|
||||
return VibeConfig(session_logging=SessionLoggingConfig(enabled=False))
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def vibe_app(vibe_config: VibeConfig) -> VibeApp:
|
||||
return VibeApp(config=vibe_config)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_popup_appears_with_matching_suggestions(vibe_app: VibeApp) -> None:
|
||||
async with vibe_app.run_test() as pilot:
|
||||
chat_input = vibe_app.query_one(ChatInputContainer)
|
||||
popup = vibe_app.query_one(CompletionPopup)
|
||||
|
||||
await pilot.press(*"/sum")
|
||||
|
||||
popup_content = str(popup.render())
|
||||
assert popup.styles.display == "block"
|
||||
assert "/summarize" in popup_content
|
||||
assert "Compact conversation history by summarizing" in popup_content
|
||||
assert chat_input.value == "/sum"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_popup_hides_when_input_cleared(vibe_app: VibeApp) -> None:
|
||||
async with vibe_app.run_test() as pilot:
|
||||
popup = vibe_app.query_one(CompletionPopup)
|
||||
|
||||
await pilot.press(*"/c")
|
||||
await pilot.press("backspace", "backspace")
|
||||
|
||||
assert popup.styles.display == "none"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_pressing_tab_writes_selected_command_and_keeps_popup_visible(
|
||||
vibe_app: VibeApp,
|
||||
) -> None:
|
||||
async with vibe_app.run_test() as pilot:
|
||||
chat_input = vibe_app.query_one(ChatInputContainer)
|
||||
popup = vibe_app.query_one(CompletionPopup)
|
||||
|
||||
await pilot.press(*"/co")
|
||||
await pilot.press("tab")
|
||||
|
||||
assert chat_input.value == "/config"
|
||||
assert popup.styles.display == "block"
|
||||
|
||||
|
||||
def ensure_selected_command(popup: CompletionPopup, expected_alias: str) -> None:
|
||||
renderable = popup.render()
|
||||
assert isinstance(renderable, Content)
|
||||
content = str(renderable)
|
||||
|
||||
selected_aliases: list[str] = []
|
||||
for span in renderable.spans:
|
||||
style = span.style
|
||||
if isinstance(style, Style) and style.reverse:
|
||||
alias_text = content[span.start : span.end].strip()
|
||||
alias = alias_text.split()[0] if alias_text else ""
|
||||
selected_aliases.append(alias)
|
||||
|
||||
assert len(selected_aliases) == 1
|
||||
assert selected_aliases[0] == expected_alias
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_arrow_navigation_updates_selected_suggestion(vibe_app: VibeApp) -> None:
|
||||
async with vibe_app.run_test() as pilot:
|
||||
popup = vibe_app.query_one(CompletionPopup)
|
||||
|
||||
await pilot.press(*"/c")
|
||||
|
||||
ensure_selected_command(popup, "/cfg")
|
||||
await pilot.press("down")
|
||||
ensure_selected_command(popup, "/config")
|
||||
await pilot.press("up")
|
||||
ensure_selected_command(popup, "/cfg")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_arrow_navigation_cycles_through_suggestions(vibe_app: VibeApp) -> None:
|
||||
async with vibe_app.run_test() as pilot:
|
||||
popup = vibe_app.query_one(CompletionPopup)
|
||||
|
||||
await pilot.press(*"/st")
|
||||
|
||||
ensure_selected_command(popup, "/stats")
|
||||
await pilot.press("down")
|
||||
ensure_selected_command(popup, "/status")
|
||||
await pilot.press("up")
|
||||
ensure_selected_command(popup, "/stats")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_pressing_enter_submits_selected_command_and_hides_popup(
|
||||
vibe_app: VibeApp,
|
||||
) -> None:
|
||||
async with vibe_app.run_test() as pilot:
|
||||
chat_input = vibe_app.query_one(ChatInputContainer)
|
||||
popup = vibe_app.query_one(CompletionPopup)
|
||||
|
||||
await pilot.press(*"/hel") # typos:disable-line
|
||||
await pilot.press("enter")
|
||||
|
||||
assert chat_input.value == ""
|
||||
assert popup.styles.display == "none"
|
||||
message = vibe_app.query_one(".user-command-message")
|
||||
message_content = message.query_one(Markdown)
|
||||
assert "Show help message" in message_content.source
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def file_tree(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path:
|
||||
(tmp_path / "src" / "utils").mkdir(parents=True)
|
||||
(tmp_path / "src" / "utils" / "config.py").write_text("", encoding="utf-8")
|
||||
(tmp_path / "src" / "utils" / "database.py").write_text("", encoding="utf-8")
|
||||
(tmp_path / "src" / "utils" / "error_handling.py").write_text("", encoding="utf-8")
|
||||
(tmp_path / "src" / "utils" / "logger.py").write_text("", encoding="utf-8")
|
||||
(tmp_path / "src" / "utils" / "sanitize.py").write_text("", encoding="utf-8")
|
||||
(tmp_path / "src" / "utils" / "validate.py").write_text("", encoding="utf-8")
|
||||
(tmp_path / "src" / "main.py").write_text("", encoding="utf-8")
|
||||
(tmp_path / "vibe" / "acp").mkdir(parents=True)
|
||||
(tmp_path / "vibe" / "acp" / "entrypoint.py").write_text("", encoding="utf-8")
|
||||
(tmp_path / "vibe" / "acp" / "agent.py").write_text("", encoding="utf-8")
|
||||
(tmp_path / "README.md").write_text("", encoding="utf-8")
|
||||
(tmp_path / ".env").write_text("", encoding="utf-8")
|
||||
monkeypatch.chdir(tmp_path)
|
||||
return tmp_path
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_path_completion_popup_lists_files_and_directories(
|
||||
vibe_app: VibeApp, file_tree: Path
|
||||
) -> None:
|
||||
async with vibe_app.run_test() as pilot:
|
||||
popup = vibe_app.query_one(CompletionPopup)
|
||||
|
||||
await pilot.press(*"@s")
|
||||
|
||||
popup_content = str(popup.render())
|
||||
assert "@src/" in popup_content
|
||||
assert popup.styles.display == "block"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_path_completion_popup_shows_up_to_ten_results(
|
||||
vibe_app: VibeApp, file_tree: Path
|
||||
) -> None:
|
||||
async with vibe_app.run_test() as pilot:
|
||||
(file_tree / "src" / "core" / "extra").mkdir(parents=True)
|
||||
[
|
||||
(file_tree / "src" / "core" / "extra" / f"extra_file_{i}.py").write_text(
|
||||
"", encoding="utf-8"
|
||||
)
|
||||
for i in range(1, 13)
|
||||
]
|
||||
popup = vibe_app.query_one(CompletionPopup)
|
||||
|
||||
await pilot.press(*"@src/core/extra/")
|
||||
|
||||
popup_content = str(popup.render())
|
||||
assert "@src/core/extra/extra_file_1.py" in popup_content
|
||||
assert "@src/core/extra/extra_file_10.py" in popup_content
|
||||
assert "@src/core/extra/extra_file_11.py" in popup_content
|
||||
assert "@src/core/extra/extra_file_12.py" in popup_content
|
||||
assert "@src/core/extra/extra_file_2.py" in popup_content
|
||||
assert "@src/core/extra/extra_file_3.py" in popup_content
|
||||
assert "@src/core/extra/extra_file_4.py" in popup_content
|
||||
assert "@src/core/extra/extra_file_5.py" in popup_content
|
||||
assert "@src/core/extra/extra_file_6.py" in popup_content
|
||||
assert "@src/core/extra/extra_file_7.py" in popup_content
|
||||
assert popup.styles.display == "block"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_pressing_tab_writes_selected_path_name_and_hides_popup(
|
||||
vibe_app: VibeApp, file_tree: Path
|
||||
) -> None:
|
||||
async with vibe_app.run_test() as pilot:
|
||||
chat_input = vibe_app.query_one(ChatInputContainer)
|
||||
popup = vibe_app.query_one(CompletionPopup)
|
||||
|
||||
await pilot.press(*"Print @REA")
|
||||
await pilot.press("tab")
|
||||
|
||||
assert chat_input.value == "Print @README.md "
|
||||
assert popup.styles.display == "none"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_pressing_enter_writes_selected_path_name_and_hides_popup(
|
||||
vibe_app: VibeApp, file_tree: Path
|
||||
) -> None:
|
||||
async with vibe_app.run_test() as pilot:
|
||||
chat_input = vibe_app.query_one(ChatInputContainer)
|
||||
popup = vibe_app.query_one(CompletionPopup)
|
||||
|
||||
await pilot.press(*"Print @src/m")
|
||||
await pilot.press("enter")
|
||||
|
||||
assert chat_input.value == "Print @src/main.py "
|
||||
assert popup.styles.display == "none"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_fuzzy_matches_subsequence_characters(
|
||||
file_tree: Path, vibe_app: VibeApp
|
||||
) -> None:
|
||||
async with vibe_app.run_test() as pilot:
|
||||
popup = vibe_app.query_one(CompletionPopup)
|
||||
|
||||
await pilot.press(*"@src/utils/handling")
|
||||
|
||||
popup_content = str(popup.render())
|
||||
assert "@src/utils/error_handling.py" in popup_content
|
||||
assert popup.styles.display == "block"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_fuzzy_matches_word_boundaries(
|
||||
file_tree: Path, vibe_app: VibeApp
|
||||
) -> None:
|
||||
async with vibe_app.run_test() as pilot:
|
||||
popup = vibe_app.query_one(CompletionPopup)
|
||||
|
||||
await pilot.press(*"@src/utils/eh")
|
||||
|
||||
popup_content = str(popup.render())
|
||||
assert "@src/utils/error_handling.py" in popup_content
|
||||
assert popup.styles.display == "block"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_finds_files_recursively_by_filename(
|
||||
file_tree: Path, vibe_app: VibeApp
|
||||
) -> None:
|
||||
async with vibe_app.run_test() as pilot:
|
||||
popup = vibe_app.query_one(CompletionPopup)
|
||||
|
||||
await pilot.press(*"@entryp")
|
||||
|
||||
popup_content = str(popup.render())
|
||||
assert "@vibe/acp/entrypoint.py" in popup_content
|
||||
assert popup.styles.display == "block"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_finds_files_recursively_with_partial_path(
|
||||
file_tree: Path, vibe_app: VibeApp
|
||||
) -> None:
|
||||
async with vibe_app.run_test() as pilot:
|
||||
popup = vibe_app.query_one(CompletionPopup)
|
||||
|
||||
await pilot.press(*"@acp/entry")
|
||||
|
||||
popup_content = str(popup.render())
|
||||
assert "@vibe/acp/entrypoint.py" in popup_content
|
||||
assert popup.styles.display == "block"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_does_not_trigger_completion_when_navigating_history(
|
||||
file_tree: Path, vibe_app: VibeApp
|
||||
) -> None:
|
||||
async with vibe_app.run_test() as pilot:
|
||||
chat_input = vibe_app.query_one(ChatInputContainer)
|
||||
popup = vibe_app.query_one(CompletionPopup)
|
||||
message_with_path = "Check @src/m"
|
||||
message_to_fill_history = "Yet another message to fill history"
|
||||
|
||||
await pilot.press(*message_with_path)
|
||||
await pilot.press("tab", "enter")
|
||||
await pilot.press(*message_to_fill_history)
|
||||
await pilot.press("enter")
|
||||
await pilot.press("up", "up")
|
||||
assert chat_input.value == "Check @src/main.py"
|
||||
await pilot.pause(0.2)
|
||||
# ensure popup is hidden - user was navigating history: we don't want to interrupt
|
||||
assert popup.styles.display == "none"
|
||||
await pilot.press("down")
|
||||
await pilot.pause(0.1)
|
||||
assert popup.styles.display == "none"
|
||||
# get back to the message with path completion; ensure again
|
||||
await pilot.press("up")
|
||||
await pilot.pause(0.1)
|
||||
assert chat_input.value == "Check @src/main.py"
|
||||
await pilot.pause(0.2)
|
||||
assert popup.styles.display == "none"
|
||||
Loading…
Add table
Add a link
Reference in a new issue