/ tests / hermes_cli / test_session_browse.py
test_session_browse.py
  1  """Tests for the interactive session browser (`hermes sessions browse`).
  2  
  3  Covers:
  4  - _session_browse_picker logic (curses mocked, fallback tested)
  5  - cmd_sessions 'browse' action integration
  6  - Argument parser registration
  7  """
  8  
  9  import os
 10  import time
 11  from unittest.mock import MagicMock, patch, call
 12  
 13  import pytest
 14  
 15  from hermes_cli.main import _session_browse_picker
 16  
 17  
 18  # ─── Sample session data ──────────────────────────────────────────────────────
 19  
 20  def _make_sessions(n=5):
 21      """Generate a list of fake rich-session dicts."""
 22      now = time.time()
 23      sessions = []
 24      for i in range(n):
 25          sessions.append({
 26              "id": f"20260308_{i:06d}_abcdef",
 27              "source": "cli" if i % 2 == 0 else "telegram",
 28              "model": "test/model",
 29              "title": f"Session {i}" if i % 3 != 0 else None,
 30              "preview": f"Hello from session {i}",
 31              "last_active": now - i * 3600,
 32              "started_at": now - i * 3600 - 60,
 33              "message_count": (i + 1) * 5,
 34          })
 35      return sessions
 36  
 37  
 38  SAMPLE_SESSIONS = _make_sessions(5)
 39  
 40  
 41  # ─── _session_browse_picker ──────────────────────────────────────────────────
 42  
 43  class TestSessionBrowsePicker:
 44      """Tests for the _session_browse_picker function."""
 45  
 46      def test_empty_sessions_returns_none(self, capsys):
 47          result = _session_browse_picker([])
 48          assert result is None
 49          assert "No sessions found" in capsys.readouterr().out
 50  
 51      def test_returns_none_when_no_sessions(self, capsys):
 52          result = _session_browse_picker([])
 53          assert result is None
 54  
 55      def test_fallback_mode_valid_selection(self):
 56          """When curses is unavailable, fallback numbered list should work."""
 57          sessions = _make_sessions(3)
 58  
 59          # Mock curses import to fail, forcing fallback
 60          import builtins
 61          original_import = builtins.__import__
 62  
 63          def mock_import(name, *args, **kwargs):
 64              if name == "curses":
 65                  raise ImportError("no curses")
 66              return original_import(name, *args, **kwargs)
 67  
 68          with patch.object(builtins, "__import__", side_effect=mock_import):
 69              with patch("builtins.input", return_value="2"):
 70                  result = _session_browse_picker(sessions)
 71  
 72          assert result == sessions[1]["id"]
 73  
 74      def test_fallback_mode_cancel_q(self):
 75          """Entering 'q' in fallback mode cancels."""
 76          sessions = _make_sessions(3)
 77  
 78          import builtins
 79          original_import = builtins.__import__
 80  
 81          def mock_import(name, *args, **kwargs):
 82              if name == "curses":
 83                  raise ImportError("no curses")
 84              return original_import(name, *args, **kwargs)
 85  
 86          with patch.object(builtins, "__import__", side_effect=mock_import):
 87              with patch("builtins.input", return_value="q"):
 88                  result = _session_browse_picker(sessions)
 89  
 90          assert result is None
 91  
 92      def test_fallback_mode_cancel_empty(self):
 93          """Entering empty string in fallback mode cancels."""
 94          sessions = _make_sessions(3)
 95  
 96          import builtins
 97          original_import = builtins.__import__
 98  
 99          def mock_import(name, *args, **kwargs):
100              if name == "curses":
101                  raise ImportError("no curses")
102              return original_import(name, *args, **kwargs)
103  
104          with patch.object(builtins, "__import__", side_effect=mock_import):
105              with patch("builtins.input", return_value=""):
106                  result = _session_browse_picker(sessions)
107  
108          assert result is None
109  
110      def test_fallback_mode_invalid_then_valid(self):
111          """Invalid selection followed by valid one works."""
112          sessions = _make_sessions(3)
113  
114          import builtins
115          original_import = builtins.__import__
116  
117          def mock_import(name, *args, **kwargs):
118              if name == "curses":
119                  raise ImportError("no curses")
120              return original_import(name, *args, **kwargs)
121  
122          with patch.object(builtins, "__import__", side_effect=mock_import):
123              with patch("builtins.input", side_effect=["99", "1"]):
124                  result = _session_browse_picker(sessions)
125  
126          assert result == sessions[0]["id"]
127  
128      def test_fallback_mode_keyboard_interrupt(self):
129          """KeyboardInterrupt in fallback mode returns None."""
130          sessions = _make_sessions(3)
131  
132          import builtins
133          original_import = builtins.__import__
134  
135          def mock_import(name, *args, **kwargs):
136              if name == "curses":
137                  raise ImportError("no curses")
138              return original_import(name, *args, **kwargs)
139  
140          with patch.object(builtins, "__import__", side_effect=mock_import):
141              with patch("builtins.input", side_effect=KeyboardInterrupt):
142                  result = _session_browse_picker(sessions)
143  
144          assert result is None
145  
146      def test_fallback_displays_all_sessions(self, capsys):
147          """Fallback mode should display all session entries."""
148          sessions = _make_sessions(4)
149  
150          import builtins
151          original_import = builtins.__import__
152  
153          def mock_import(name, *args, **kwargs):
154              if name == "curses":
155                  raise ImportError("no curses")
156              return original_import(name, *args, **kwargs)
157  
158          with patch.object(builtins, "__import__", side_effect=mock_import):
159              with patch("builtins.input", return_value="q"):
160                  _session_browse_picker(sessions)
161  
162          output = capsys.readouterr().out
163          # All 4 entries should be shown
164          assert "1." in output
165          assert "2." in output
166          assert "3." in output
167          assert "4." in output
168  
169      def test_fallback_shows_title_over_preview(self, capsys):
170          """When a session has a title, show it instead of the preview."""
171          sessions = [{
172              "id": "test_001",
173              "source": "cli",
174              "title": "My Cool Project",
175              "preview": "some preview text",
176              "last_active": time.time(),
177          }]
178  
179          import builtins
180          original_import = builtins.__import__
181  
182          def mock_import(name, *args, **kwargs):
183              if name == "curses":
184                  raise ImportError("no curses")
185              return original_import(name, *args, **kwargs)
186  
187          with patch.object(builtins, "__import__", side_effect=mock_import):
188              with patch("builtins.input", return_value="q"):
189                  _session_browse_picker(sessions)
190  
191          output = capsys.readouterr().out
192          assert "My Cool Project" in output
193  
194      def test_fallback_shows_preview_when_no_title(self, capsys):
195          """When no title, show preview."""
196          sessions = [{
197              "id": "test_002",
198              "source": "cli",
199              "title": None,
200              "preview": "Hello world test message",
201              "last_active": time.time(),
202          }]
203  
204          import builtins
205          original_import = builtins.__import__
206  
207          def mock_import(name, *args, **kwargs):
208              if name == "curses":
209                  raise ImportError("no curses")
210              return original_import(name, *args, **kwargs)
211  
212          with patch.object(builtins, "__import__", side_effect=mock_import):
213              with patch("builtins.input", return_value="q"):
214                  _session_browse_picker(sessions)
215  
216          output = capsys.readouterr().out
217          assert "Hello world test message" in output
218  
219      def test_fallback_shows_id_when_no_title_or_preview(self, capsys):
220          """When neither title nor preview, show session ID."""
221          sessions = [{
222              "id": "test_003_fallback",
223              "source": "cli",
224              "title": None,
225              "preview": "",
226              "last_active": time.time(),
227          }]
228  
229          import builtins
230          original_import = builtins.__import__
231  
232          def mock_import(name, *args, **kwargs):
233              if name == "curses":
234                  raise ImportError("no curses")
235              return original_import(name, *args, **kwargs)
236  
237          with patch.object(builtins, "__import__", side_effect=mock_import):
238              with patch("builtins.input", return_value="q"):
239                  _session_browse_picker(sessions)
240  
241          output = capsys.readouterr().out
242          assert "test_003_fallback" in output
243  
244  
245  # ─── Curses-based picker (mocked curses) ────────────────────────────────────
246  
247  class TestCursesBrowse:
248      """Tests for the curses-based interactive picker via simulated key sequences."""
249  
250      def _run_with_keys(self, sessions, key_sequence):
251          """Simulate running the curses picker with a given key sequence."""
252          import curses
253  
254          # Build a mock stdscr that returns keys from the sequence
255          mock_stdscr = MagicMock()
256          mock_stdscr.getmaxyx.return_value = (30, 120)
257          mock_stdscr.getch.side_effect = key_sequence
258  
259          # Capture what curses.wrapper receives and call it with our mock
260          with patch("curses.wrapper") as mock_wrapper:
261              # When wrapper is called, invoke the function with our mock stdscr
262              def run_inner(func):
263                  try:
264                      func(mock_stdscr)
265                  except StopIteration:
266                      pass  # key sequence exhausted
267  
268              mock_wrapper.side_effect = run_inner
269              with patch("curses.curs_set"):
270                  with patch("curses.has_colors", return_value=False):
271                      return _session_browse_picker(sessions)
272  
273      def test_enter_selects_first_session(self):
274          sessions = _make_sessions(3)
275          result = self._run_with_keys(sessions, [10])  # Enter key
276          assert result == sessions[0]["id"]
277  
278      def test_down_then_enter_selects_second(self):
279          import curses
280          sessions = _make_sessions(3)
281          result = self._run_with_keys(sessions, [curses.KEY_DOWN, 10])
282          assert result == sessions[1]["id"]
283  
284      def test_down_down_enter_selects_third(self):
285          import curses
286          sessions = _make_sessions(5)
287          result = self._run_with_keys(sessions, [curses.KEY_DOWN, curses.KEY_DOWN, 10])
288          assert result == sessions[2]["id"]
289  
290      def test_up_wraps_to_last(self):
291          import curses
292          sessions = _make_sessions(3)
293          result = self._run_with_keys(sessions, [curses.KEY_UP, 10])
294          assert result == sessions[2]["id"]
295  
296      def test_escape_cancels(self):
297          sessions = _make_sessions(3)
298          result = self._run_with_keys(sessions, [27])  # Esc
299          assert result is None
300  
301      def test_q_cancels(self):
302          sessions = _make_sessions(3)
303          result = self._run_with_keys(sessions, [ord('q')])
304          assert result is None
305  
306      def test_type_to_filter_then_enter(self):
307          """Typing characters filters the list, Enter selects from filtered."""
308          import curses
309          sessions = [
310              {"id": "s1", "source": "cli", "title": "Alpha project", "preview": "", "last_active": time.time()},
311              {"id": "s2", "source": "cli", "title": "Beta project", "preview": "", "last_active": time.time()},
312              {"id": "s3", "source": "cli", "title": "Gamma project", "preview": "", "last_active": time.time()},
313          ]
314          # Type "Beta" then Enter — should select s2
315          keys = [ord(c) for c in "Beta"] + [10]
316          result = self._run_with_keys(sessions, keys)
317          assert result == "s2"
318  
319      def test_filter_no_match_enter_does_nothing(self):
320          """When filter produces no results, Enter shouldn't select."""
321          sessions = _make_sessions(3)
322          keys = [ord(c) for c in "zzzznonexistent"] + [10]
323          result = self._run_with_keys(sessions, keys)
324          assert result is None
325  
326      def test_backspace_removes_filter_char(self):
327          """Backspace removes the last character from the filter."""
328          import curses
329          sessions = [
330              {"id": "s1", "source": "cli", "title": "Alpha", "preview": "", "last_active": time.time()},
331              {"id": "s2", "source": "cli", "title": "Beta", "preview": "", "last_active": time.time()},
332          ]
333          # Type "Bet", backspace, backspace, backspace (clears filter), then Enter (selects first)
334          keys = [ord('B'), ord('e'), ord('t'), 127, 127, 127, 10]
335          result = self._run_with_keys(sessions, keys)
336          assert result == "s1"
337  
338      def test_escape_clears_filter_first(self):
339          """First Esc clears the search text, second Esc exits."""
340          import curses
341          sessions = _make_sessions(3)
342          # Type "ab" then Esc (clears filter) then Enter (selects first)
343          keys = [ord('a'), ord('b'), 27, 10]
344          result = self._run_with_keys(sessions, keys)
345          assert result == sessions[0]["id"]
346  
347      def test_filter_matches_preview(self):
348          """Typing should match against session preview text."""
349          sessions = [
350              {"id": "s1", "source": "cli", "title": None, "preview": "Set up Minecraft server", "last_active": time.time()},
351              {"id": "s2", "source": "cli", "title": None, "preview": "Review PR 438", "last_active": time.time()},
352          ]
353          keys = [ord(c) for c in "Mine"] + [10]
354          result = self._run_with_keys(sessions, keys)
355          assert result == "s1"
356  
357      def test_filter_matches_source(self):
358          """Typing a source name should filter by source."""
359          sessions = [
360              {"id": "s1", "source": "telegram", "title": "TG session", "preview": "", "last_active": time.time()},
361              {"id": "s2", "source": "cli", "title": "CLI session", "preview": "", "last_active": time.time()},
362          ]
363          keys = [ord(c) for c in "telegram"] + [10]
364          result = self._run_with_keys(sessions, keys)
365          assert result == "s1"
366  
367      def test_q_quits_when_no_filter_active(self):
368          """When no search text is active, 'q' should quit (not filter)."""
369          sessions = _make_sessions(3)
370          result = self._run_with_keys(sessions, [ord('q')])
371          assert result is None
372  
373      def test_q_types_into_filter_when_filter_active(self):
374          """When search text is already active, 'q' should add to filter, not quit."""
375          sessions = [
376              {"id": "s1", "source": "cli", "title": "the sequel", "preview": "", "last_active": time.time()},
377              {"id": "s2", "source": "cli", "title": "other thing", "preview": "", "last_active": time.time()},
378          ]
379          # Type "se" first (activates filter, matches "the sequel")
380          # Then type "q" — should add 'q' to filter (filter="seq"), NOT quit
381          # "seq" still matches "the sequel" → Enter selects it
382          keys = [ord('s'), ord('e'), ord('q'), 10]
383          result = self._run_with_keys(sessions, keys)
384          assert result == "s1"  # "the sequel" matches "seq"
385  
386  
387  # ─── Argument parser registration ──────────────────────────────────────────
388  
389  class TestSessionBrowseArgparse:
390      """Verify the 'browse' subcommand is properly registered."""
391  
392      def test_browse_subcommand_exists(self):
393          """hermes sessions browse should be parseable."""
394          from hermes_cli.main import main as _main_entry
395  
396          # We can't run main(), but we can import and test the parser setup
397          # by checking that argparse doesn't error on "sessions browse"
398          import argparse
399          # Re-create the parser portion
400          # Instead, let's just verify the import works and the function exists
401          from hermes_cli.main import _session_browse_picker
402          assert callable(_session_browse_picker)
403  
404      def test_browse_default_limit_is_500(self):
405          """The default --limit for browse should be 500."""
406          # Build the same argparse tree cmd_sessions uses and verify the default.
407          import argparse
408          parser = argparse.ArgumentParser()
409          subparsers = parser.add_subparsers(dest="sessions_action")
410          browse = subparsers.add_parser("browse")
411          browse.add_argument("--source")
412          browse.add_argument("--limit", type=int, default=500)
413  
414          args = parser.parse_args(["browse"])
415          assert args.limit == 500
416  
417          args = parser.parse_args(["browse", "--limit", "42"])
418          assert args.limit == 42
419  
420  
421  # ─── Integration: cmd_sessions browse action ────────────────────────────────
422  
423  class TestCmdSessionsBrowse:
424      """Integration tests for the 'browse' action in cmd_sessions."""
425  
426      def test_browse_no_sessions_prints_message(self, capsys):
427          """When no sessions exist, _session_browse_picker returns None and prints message."""
428          result = _session_browse_picker([])
429          assert result is None
430          output = capsys.readouterr().out
431          assert "No sessions found" in output
432  
433      def test_browse_with_source_filter(self):
434          """The --source flag should be passed to list_sessions_rich."""
435          sessions = [
436              {"id": "s1", "source": "cli", "title": "CLI only", "preview": "", "last_active": time.time()},
437          ]
438  
439          import builtins
440          original_import = builtins.__import__
441  
442          def mock_import(name, *args, **kwargs):
443              if name == "curses":
444                  raise ImportError("no curses")
445              return original_import(name, *args, **kwargs)
446  
447          with patch.object(builtins, "__import__", side_effect=mock_import):
448              with patch("builtins.input", return_value="1"):
449                  result = _session_browse_picker(sessions)
450  
451          assert result == "s1"
452  
453  
454  # ─── Edge cases ──────────────────────────────────────────────────────────────
455  
456  class TestEdgeCases:
457      """Edge case handling for the session browser."""
458  
459      def test_sessions_with_missing_fields(self):
460          """Sessions with missing optional fields should not crash."""
461          sessions = [
462              {"id": "minimal_001", "source": "cli"},  # No title, preview, last_active
463          ]
464  
465          import builtins
466          original_import = builtins.__import__
467  
468          def mock_import(name, *args, **kwargs):
469              if name == "curses":
470                  raise ImportError("no curses")
471              return original_import(name, *args, **kwargs)
472  
473          with patch.object(builtins, "__import__", side_effect=mock_import):
474              with patch("builtins.input", return_value="1"):
475                  result = _session_browse_picker(sessions)
476  
477          assert result == "minimal_001"
478  
479      def test_single_session(self):
480          """A single session in the list should work fine."""
481          sessions = [
482              {"id": "only_one", "source": "cli", "title": "Solo", "preview": "", "last_active": time.time()},
483          ]
484  
485          import builtins
486          original_import = builtins.__import__
487  
488          def mock_import(name, *args, **kwargs):
489              if name == "curses":
490                  raise ImportError("no curses")
491              return original_import(name, *args, **kwargs)
492  
493          with patch.object(builtins, "__import__", side_effect=mock_import):
494              with patch("builtins.input", return_value="1"):
495                  result = _session_browse_picker(sessions)
496  
497          assert result == "only_one"
498  
499      def test_long_title_truncated_in_fallback(self, capsys):
500          """Very long titles should be truncated in fallback mode."""
501          sessions = [{
502              "id": "long_title_001",
503              "source": "cli",
504              "title": "A" * 100,
505              "preview": "",
506              "last_active": time.time(),
507          }]
508  
509          import builtins
510          original_import = builtins.__import__
511  
512          def mock_import(name, *args, **kwargs):
513              if name == "curses":
514                  raise ImportError("no curses")
515              return original_import(name, *args, **kwargs)
516  
517          with patch.object(builtins, "__import__", side_effect=mock_import):
518              with patch("builtins.input", return_value="q"):
519                  _session_browse_picker(sessions)
520  
521          output = capsys.readouterr().out
522          # Title should be truncated to 50 chars with "..."
523          assert "..." in output
524  
525      def test_relative_time_formatting(self, capsys):
526          """Verify various time deltas format correctly."""
527          now = time.time()
528          sessions = [
529              {"id": "recent", "source": "cli", "title": None, "preview": "just now test", "last_active": now},
530              {"id": "hour_ago", "source": "cli", "title": None, "preview": "hour ago test", "last_active": now - 7200},
531              {"id": "days_ago", "source": "cli", "title": None, "preview": "days ago test", "last_active": now - 259200},
532          ]
533  
534          import builtins
535          original_import = builtins.__import__
536  
537          def mock_import(name, *args, **kwargs):
538              if name == "curses":
539                  raise ImportError("no curses")
540              return original_import(name, *args, **kwargs)
541  
542          with patch.object(builtins, "__import__", side_effect=mock_import):
543              with patch("builtins.input", return_value="q"):
544                  _session_browse_picker(sessions)
545  
546          output = capsys.readouterr().out
547          assert "just now" in output
548          assert "2h ago" in output
549          assert "3d ago" in output