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