test_path_completion.py
1 """Tests for file path autocomplete in the CLI completer.""" 2 3 import os 4 from unittest.mock import MagicMock 5 6 import pytest 7 from prompt_toolkit.document import Document 8 from prompt_toolkit.formatted_text import to_plain_text 9 10 from hermes_cli.commands import SlashCommandCompleter, _file_size_label 11 12 13 def _display_names(completions): 14 """Extract plain-text display names from a list of Completion objects.""" 15 return [to_plain_text(c.display) for c in completions] 16 17 18 def _display_metas(completions): 19 """Extract plain-text display_meta from a list of Completion objects.""" 20 return [to_plain_text(c.display_meta) if c.display_meta else "" for c in completions] 21 22 23 @pytest.fixture 24 def completer(): 25 return SlashCommandCompleter() 26 27 28 class TestExtractPathWord: 29 def test_relative_path(self): 30 assert SlashCommandCompleter._extract_path_word("look at ./src/main.py") == "./src/main.py" 31 32 def test_home_path(self): 33 assert SlashCommandCompleter._extract_path_word("edit ~/docs/") == "~/docs/" 34 35 def test_absolute_path(self): 36 assert SlashCommandCompleter._extract_path_word("read /etc/hosts") == "/etc/hosts" 37 38 def test_parent_path(self): 39 assert SlashCommandCompleter._extract_path_word("check ../config.yaml") == "../config.yaml" 40 41 def test_path_with_slash_in_middle(self): 42 assert SlashCommandCompleter._extract_path_word("open src/utils/helpers.py") == "src/utils/helpers.py" 43 44 def test_plain_word_not_path(self): 45 assert SlashCommandCompleter._extract_path_word("hello world") is None 46 47 def test_empty_string(self): 48 assert SlashCommandCompleter._extract_path_word("") is None 49 50 def test_single_word_no_slash(self): 51 assert SlashCommandCompleter._extract_path_word("README.md") is None 52 53 def test_word_after_space(self): 54 assert SlashCommandCompleter._extract_path_word("fix the bug in ./tools/") == "./tools/" 55 56 def test_just_dot_slash(self): 57 assert SlashCommandCompleter._extract_path_word("./") == "./" 58 59 def test_just_tilde_slash(self): 60 assert SlashCommandCompleter._extract_path_word("~/") == "~/" 61 62 63 class TestPathCompletions: 64 def test_lists_current_directory(self, tmp_path): 65 (tmp_path / "file_a.py").touch() 66 (tmp_path / "file_b.txt").touch() 67 (tmp_path / "subdir").mkdir() 68 69 old_cwd = os.getcwd() 70 os.chdir(tmp_path) 71 try: 72 completions = list(SlashCommandCompleter._path_completions("./")) 73 names = _display_names(completions) 74 assert "file_a.py" in names 75 assert "file_b.txt" in names 76 assert "subdir/" in names 77 finally: 78 os.chdir(old_cwd) 79 80 def test_filters_by_prefix(self, tmp_path): 81 (tmp_path / "alpha.py").touch() 82 (tmp_path / "beta.py").touch() 83 (tmp_path / "alpha_test.py").touch() 84 85 completions = list(SlashCommandCompleter._path_completions(f"{tmp_path}/alpha")) 86 names = _display_names(completions) 87 assert "alpha.py" in names 88 assert "alpha_test.py" in names 89 assert "beta.py" not in names 90 91 def test_directories_have_trailing_slash(self, tmp_path): 92 (tmp_path / "mydir").mkdir() 93 (tmp_path / "myfile.txt").touch() 94 95 completions = list(SlashCommandCompleter._path_completions(f"{tmp_path}/")) 96 names = _display_names(completions) 97 metas = _display_metas(completions) 98 assert "mydir/" in names 99 idx = names.index("mydir/") 100 assert metas[idx] == "dir" 101 102 def test_home_expansion(self, tmp_path, monkeypatch): 103 monkeypatch.setenv("HOME", str(tmp_path)) 104 (tmp_path / "testfile.md").touch() 105 106 completions = list(SlashCommandCompleter._path_completions("~/test")) 107 names = _display_names(completions) 108 assert "testfile.md" in names 109 110 def test_nonexistent_dir_returns_empty(self): 111 completions = list(SlashCommandCompleter._path_completions("/nonexistent_dir_xyz/")) 112 assert completions == [] 113 114 def test_respects_limit(self, tmp_path): 115 for i in range(50): 116 (tmp_path / f"file_{i:03d}.txt").touch() 117 118 completions = list(SlashCommandCompleter._path_completions(f"{tmp_path}/", limit=10)) 119 assert len(completions) == 10 120 121 def test_case_insensitive_prefix(self, tmp_path): 122 (tmp_path / "README.md").touch() 123 124 completions = list(SlashCommandCompleter._path_completions(f"{tmp_path}/read")) 125 names = _display_names(completions) 126 assert "README.md" in names 127 128 129 class TestIntegration: 130 """Test the completer produces path completions via the prompt_toolkit API.""" 131 132 def test_slash_commands_still_work(self, completer): 133 doc = Document("/hel", cursor_position=4) 134 event = MagicMock() 135 completions = list(completer.get_completions(doc, event)) 136 names = _display_names(completions) 137 assert "/help" in names 138 139 def test_path_completion_triggers_on_dot_slash(self, completer, tmp_path): 140 (tmp_path / "test.py").touch() 141 old_cwd = os.getcwd() 142 os.chdir(tmp_path) 143 try: 144 doc = Document("edit ./te", cursor_position=9) 145 event = MagicMock() 146 completions = list(completer.get_completions(doc, event)) 147 names = _display_names(completions) 148 assert "test.py" in names 149 finally: 150 os.chdir(old_cwd) 151 152 def test_no_completion_for_plain_words(self, completer): 153 doc = Document("hello world", cursor_position=11) 154 event = MagicMock() 155 completions = list(completer.get_completions(doc, event)) 156 assert completions == [] 157 158 def test_absolute_path_triggers_completion(self, completer): 159 doc = Document("check /etc/hos", cursor_position=14) 160 event = MagicMock() 161 completions = list(completer.get_completions(doc, event)) 162 names = _display_names(completions) 163 # /etc/hosts should exist on Linux 164 assert any("host" in n.lower() for n in names) 165 166 167 class TestFileSizeLabel: 168 def test_bytes(self, tmp_path): 169 f = tmp_path / "small.txt" 170 f.write_text("hi") 171 assert _file_size_label(str(f)) == "2B" 172 173 def test_kilobytes(self, tmp_path): 174 f = tmp_path / "medium.txt" 175 f.write_bytes(b"x" * 2048) 176 assert _file_size_label(str(f)) == "2K" 177 178 def test_megabytes(self, tmp_path): 179 f = tmp_path / "large.bin" 180 f.write_bytes(b"x" * (2 * 1024 * 1024)) 181 assert _file_size_label(str(f)) == "2.0M" 182 183 def test_nonexistent(self): 184 assert _file_size_label("/nonexistent_xyz") == ""