/ tests / hermes_cli / test_path_completion.py
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") == ""