personal memory agent
0

Configure Feed

Select the types of activity you want to include in your feed.

solstone / tests / test_screen_formatter.py
21 kB 688 lines
1# SPDX-License-Identifier: AGPL-3.0-only 2# Copyright (c) 2026 sol pbc 3 4"""Tests for observe.screen formatter module.""" 5 6from pathlib import Path 7 8from solstone.observe.screen import ( 9 CATEGORIES, 10 _load_category_formatter, 11 format_screen, 12) 13 14 15def test_format_screen_extracts_segment_from_directory(): 16 """Test that format_screen correctly extracts base time from segment directory.""" 17 # Mock frames with relative timestamps (seconds from segment start) 18 frames = [ 19 { 20 "timestamp": 0, 21 "analysis": { 22 "primary": "code", 23 "visual_description": "Editing Python", 24 }, 25 }, 26 { 27 "timestamp": 30, 28 "analysis": { 29 "primary": "terminal", 30 "visual_description": "Running tests", 31 }, 32 }, 33 { 34 "timestamp": 120, 35 "analysis": { 36 "primary": "browser", 37 "visual_description": "Reading docs", 38 }, 39 }, 40 ] 41 42 # Simulate path structure: YYYYMMDD/HHMMSS_LEN/screen.jsonl 43 context = { 44 "file_path": Path("20240101/default/143022_300/screen.jsonl"), 45 "include_entity_context": False, 46 } 47 48 chunks, meta = format_screen(frames, context) 49 markdown = "\n".join([meta.get("header", "")] + [c["markdown"] for c in chunks]) 50 51 # Verify absolute times are calculated correctly from segment (14:30:22) 52 assert "14:30:22" in markdown # Base time from segment 53 assert "14:30:52" in markdown # Base + 30 seconds 54 assert "14:32:22" in markdown # Base + 120 seconds (2 minutes) 55 56 # Verify frame content is included 57 assert "Editing Python" in markdown 58 assert "Running tests" in markdown 59 assert "Reading docs" in markdown 60 61 62def test_format_screen_handles_segment_with_duration_suffix(): 63 """Test that format_screen handles HHMMSS_LEN segment format.""" 64 frames = [ 65 { 66 "timestamp": 0, 67 "analysis": {"primary": "code", "visual_description": "Code"}, 68 }, 69 { 70 "timestamp": 60, 71 "analysis": { 72 "primary": "terminal", 73 "visual_description": "Terminal", 74 }, 75 }, 76 ] 77 78 # Segment with duration suffix: 143022_300 (5 minutes) 79 context = { 80 "file_path": Path("20240101/default/143022_300/screen.jsonl"), 81 "include_entity_context": False, 82 } 83 84 chunks, meta = format_screen(frames, context) 85 markdown = "\n".join([meta.get("header", "")] + [c["markdown"] for c in chunks]) 86 87 # Should still extract base time correctly 88 assert "14:30:22" in markdown # Base time 89 assert "14:31:22" in markdown # Base + 60 seconds 90 assert "Code" in markdown 91 assert "Terminal" in markdown 92 93 94def test_format_screen_handles_no_file_path(): 95 """Test that format_screen works when file_path is None (defaults to midnight).""" 96 frames = [ 97 { 98 "timestamp": 0, 99 "analysis": {"primary": "code", "visual_description": "Code"}, 100 }, 101 { 102 "timestamp": 3600, 103 "analysis": { 104 "primary": "browser", 105 "visual_description": "Browser", 106 }, 107 }, 108 ] 109 110 chunks, meta = format_screen(frames, {"include_entity_context": False}) 111 markdown = "\n".join([meta.get("header", "")] + [c["markdown"] for c in chunks]) 112 113 # Should default to 00:00:00 base time 114 assert "00:00:00" in markdown 115 assert "01:00:00" in markdown # 3600 seconds = 1 hour 116 assert "Code" in markdown 117 assert "Browser" in markdown 118 119 120def test_format_screen_header_includes_monitor_info(): 121 """Test that monitor info is included in header for per-monitor files.""" 122 frames = [ 123 { 124 "timestamp": 0, 125 "analysis": { 126 "primary": "code", 127 "visual_description": "Editing code", 128 }, 129 }, 130 { 131 "timestamp": 30, 132 "analysis": { 133 "primary": "browser", 134 "visual_description": "Documentation", 135 }, 136 }, 137 ] 138 139 # Per-monitor file with position/connector in filename 140 context = { 141 "file_path": Path("20240101/default/120000_300/center_DP-3_screen.jsonl"), 142 "include_entity_context": False, 143 } 144 145 chunks, meta = format_screen(frames, context) 146 147 # Should include monitor info in header 148 assert "(center - DP-3)" in meta.get("header", "") 149 assert "Editing code" in chunks[0]["markdown"] 150 assert "Documentation" in chunks[1]["markdown"] 151 152 153def test_format_screen_plain_screen_no_monitor_info(): 154 """Test that plain screen.jsonl has no monitor info in header.""" 155 frames = [ 156 { 157 "timestamp": 0, 158 "analysis": { 159 "primary": "code", 160 "visual_description": "Editing code", 161 }, 162 }, 163 ] 164 165 context = { 166 "file_path": Path("20240101/default/120000_300/screen.jsonl"), 167 "include_entity_context": False, 168 } 169 170 chunks, meta = format_screen(frames, context) 171 172 # Plain screen.jsonl should not have monitor info 173 assert "(center" not in meta.get("header", "") 174 assert "# Frame Analyses" in meta.get("header", "") 175 176 177def test_format_screen_includes_entity_context(): 178 """Test that entity context is included when requested.""" 179 frames = [ 180 { 181 "timestamp": 0, 182 "analysis": {"primary": "code", "visual_description": "Code"}, 183 }, 184 ] 185 186 context = { 187 "file_path": Path("20240101/default/120000/screen.jsonl"), 188 "entity_names": "Alice, Bob, ProjectX", 189 "include_entity_context": True, 190 } 191 192 chunks, meta = format_screen(frames, context) 193 markdown = "\n".join([meta.get("header", "")] + [c["markdown"] for c in chunks]) 194 195 # Should include entity context header 196 assert "# Entity Context" in markdown 197 assert "Alice, Bob, ProjectX" in markdown 198 199 200def test_format_screen_includes_category_content(): 201 """Test that category-specific content is included in output.""" 202 frames = [ 203 { 204 "timestamp": 0, 205 "analysis": { 206 "primary": "productivity", 207 "visual_description": "Spreadsheet view", 208 }, 209 "content": { 210 "productivity": "| Name | Value |\n|------|-------|\n| Test | 123 |", 211 }, 212 }, 213 ] 214 215 context = { 216 "file_path": Path("20240101/default/120000/screen.jsonl"), 217 "include_entity_context": False, 218 } 219 220 chunks, meta = format_screen(frames, context) 221 markdown = "\n".join([meta.get("header", "")] + [c["markdown"] for c in chunks]) 222 223 # Should include category content 224 assert "**Productivity:**" in markdown 225 assert "| Name | Value |" in markdown 226 assert "| Test | 123 |" in markdown 227 228 229def test_format_screen_renders_qualified_detection_tags(): 230 """Test that qualified detector objects render as counted tags.""" 231 frames = [ 232 { 233 "timestamp": 0, 234 "analysis": { 235 "primary": "media", 236 "visual_description": "Video frame with objects", 237 }, 238 "detections": { 239 "source": "screen", 240 "objects": [ 241 {"class_name": "person", "score": 0.61}, 242 {"class_name": "person", "score": 0.61}, 243 {"class_name": "car", "score": 0.50}, 244 {"class_name": "laptop", "score": 0.90}, 245 {"class_name": "hot dog", "score": 0.50}, 246 {"class_name": "person", "score": 0.35}, 247 ], 248 }, 249 }, 250 ] 251 252 context = {"include_entity_context": False} 253 254 chunks, meta = format_screen(frames, context) 255 markdown = "\n".join([meta.get("header", "")] + [c["markdown"] for c in chunks]) 256 257 assert "**Tags:** person ×2, car" in markdown 258 259 260def test_format_screen_no_detections_renders_no_tags(): 261 """Test that frames without detector output do not render tags.""" 262 frames = [ 263 { 264 "timestamp": 0, 265 "analysis": { 266 "primary": "media", 267 "visual_description": "Video frame without detections", 268 }, 269 }, 270 ] 271 272 context = {"include_entity_context": False} 273 274 chunks, meta = format_screen(frames, context) 275 markdown = "\n".join([meta.get("header", "")] + [c["markdown"] for c in chunks]) 276 277 assert "**Tags:**" not in markdown 278 279 280def test_format_screen_all_unqualified_renders_no_tags(): 281 """Test that unqualified detector objects do not render tags.""" 282 frames = [ 283 { 284 "timestamp": 0, 285 "detections": { 286 "source": "screen", 287 "objects": [ 288 {"class_name": "laptop", "score": 0.9}, 289 {"class_name": "person", "score": 0.35}, 290 {"class_name": "sandwich", "score": 0.9}, 291 ], 292 }, 293 }, 294 ] 295 296 context = {"include_entity_context": False} 297 298 chunks, meta = format_screen(frames, context) 299 markdown = "\n".join([meta.get("header", "")] + [c["markdown"] for c in chunks]) 300 301 assert "**Tags:**" not in markdown 302 303 304def test_format_screen_returns_chunks_with_timestamps(): 305 """Test that format_screen returns chunks with timestamp metadata.""" 306 frames = [ 307 { 308 "timestamp": 0, 309 "analysis": { 310 "primary": "code", 311 "visual_description": "Frame 1", 312 }, 313 }, 314 { 315 "timestamp": 30, 316 "analysis": { 317 "primary": "terminal", 318 "visual_description": "Frame 2", 319 }, 320 }, 321 ] 322 323 chunks, meta = format_screen(frames) 324 325 assert len(chunks) == 2 326 assert chunks[0]["timestamp"] == 0 # 0 seconds = 0ms 327 assert chunks[1]["timestamp"] == 30000 # 30 seconds = 30000ms 328 assert "Frame 1" in chunks[0]["markdown"] 329 assert "Frame 2" in chunks[1]["markdown"] 330 331 332def test_format_screen_returns_indexer_metadata(): 333 """Test that format_screen returns indexer metadata with agent.""" 334 frames = [ 335 { 336 "timestamp": 0, 337 "analysis": {"primary": "code", "visual_description": "Test"}, 338 }, 339 ] 340 341 chunks, meta = format_screen(frames) 342 343 assert "indexer" in meta 344 assert meta["indexer"]["agent"] == "screen" 345 346 347def test_load_category_formatter_finds_meeting(): 348 """Test that meeting formatter can be loaded from describe/.""" 349 formatter = _load_category_formatter("meeting") 350 assert formatter is not None 351 assert callable(formatter) 352 353 354def test_load_category_formatter_returns_none_for_missing(): 355 """Test that missing formatter returns None without error.""" 356 formatter = _load_category_formatter("nonexistent_category") 357 assert formatter is None 358 359 360def test_load_category_formatter_caches_result(): 361 """Test that formatter loading is cached.""" 362 # Clear cache first 363 from solstone.observe.screen import _formatter_cache 364 365 _formatter_cache.clear() 366 367 # First call loads 368 formatter1 = _load_category_formatter("meeting") 369 # Second call should return cached 370 formatter2 = _load_category_formatter("meeting") 371 372 assert formatter1 is formatter2 373 assert "meeting" in _formatter_cache 374 375 376def test_meeting_formatter_output(): 377 """Test that meeting formatter produces expected markdown.""" 378 from solstone.observe.categories.meeting import format as meeting_format 379 380 content = { 381 "platform": "zoom", 382 "participants": [ 383 {"name": "Alice", "status": "speaking", "video": True}, 384 {"name": "Bob", "status": "muted", "video": False}, 385 ], 386 "screen_share": { 387 "presenter": "Alice", 388 "description": "Showing slides", 389 "formatted_text": "# Slide Title\n\nBullet points...", 390 }, 391 } 392 393 result = meeting_format(content, {}) 394 395 assert "**Meeting** (zoom)" in result 396 assert "📹 Alice (speaking)" in result 397 assert "🔇 Bob (muted)" in result 398 assert "**Screen Share by Alice:**" in result 399 assert "*Showing slides*" in result 400 assert "# Slide Title" in result 401 402 403def test_format_screen_uses_meeting_formatter(): 404 """Test that format_screen uses the meeting formatter for meeting content.""" 405 frames = [ 406 { 407 "timestamp": 0, 408 "analysis": { 409 "primary": "meeting", 410 "visual_description": "Video call", 411 }, 412 "content": { 413 "meeting": { 414 "platform": "meet", 415 "participants": [ 416 {"name": "Test User", "status": "active", "video": True}, 417 ], 418 "screen_share": None, 419 }, 420 }, 421 }, 422 ] 423 424 context = { 425 "file_path": Path("20240101/default/120000/screen.jsonl"), 426 "include_entity_context": False, 427 } 428 429 chunks, meta = format_screen(frames, context) 430 markdown = chunks[0]["markdown"] 431 432 # Should use meeting formatter, not JSON dump 433 assert "**Meeting** (meet)" in markdown 434 assert "📹 Test User (active)" in markdown 435 # Should NOT have JSON code block (that was the old format) 436 assert "```json" not in markdown 437 438 439def test_format_screen_falls_back_for_missing_formatter(): 440 """Test that categories without .py formatter use default formatting.""" 441 category = "nonexistent_category" 442 frames = [ 443 { 444 "timestamp": 0, 445 "analysis": { 446 "primary": category, 447 "visual_description": "Synthetic category content", 448 }, 449 "content": { 450 # Synthetic category ensures no real category can grow a formatter 451 # and accidentally stop exercising _load_category_formatter -> None. 452 category: "# Example Page\n\nVisible text", 453 }, 454 }, 455 ] 456 457 context = {"include_entity_context": False} 458 459 chunks, meta = format_screen(frames, context) 460 markdown = chunks[0]["markdown"] 461 462 # Should use default text formatting 463 assert "**Nonexistent_Category:**" in markdown 464 assert "# Example Page" in markdown 465 466 467def test_format_screen_preserves_legacy_and_json_messaging_content(): 468 """Test that legacy messaging strings and JSON dicts both render.""" 469 legacy_frames = [ 470 { 471 "timestamp": 0, 472 "analysis": { 473 "primary": "messaging", 474 "visual_description": "Chat app", 475 }, 476 "content": { 477 "messaging": "**Alice**: Hello!\n**Bob**: Hi there!", 478 }, 479 }, 480 ] 481 json_frames = [ 482 { 483 "timestamp": 0, 484 "analysis": { 485 "primary": "messaging", 486 "visual_description": "Chat app", 487 }, 488 "content": { 489 "messaging": { 490 "app": "Signal", 491 "thread": "Bluesky Board ++", 492 "view": "conversation", 493 "messages": [ 494 { 495 "sender": "Alice", 496 "timestamp": "2:34 PM", 497 "subject": None, 498 "text": "Hello!", 499 } 500 ], 501 }, 502 }, 503 }, 504 ] 505 506 legacy_chunks, _meta = format_screen( 507 legacy_frames, {"include_entity_context": False} 508 ) 509 json_chunks, _meta = format_screen(json_frames, {"include_entity_context": False}) 510 511 legacy_markdown = legacy_chunks[0]["markdown"] 512 json_markdown = json_chunks[0]["markdown"] 513 assert "**Messaging:**" in legacy_markdown 514 assert "**Alice**: Hello!" in legacy_markdown 515 assert "```json" not in legacy_markdown 516 assert "**Messaging** (Signal - Bluesky Board ++)" in json_markdown 517 assert "**Alice** (2:34 PM): Hello!" in json_markdown 518 assert "```json" not in json_markdown 519 520 521def test_format_screen_preserves_legacy_and_json_calendar_content(): 522 """Test that legacy calendar strings and JSON dicts both render.""" 523 legacy_frames = [ 524 { 525 "timestamp": 0, 526 "analysis": { 527 "primary": "calendar", 528 "visual_description": "Calendar app", 529 }, 530 "content": { 531 "calendar": "# [Google Calendar - Week]\n**Monday**: Planning", 532 }, 533 }, 534 ] 535 json_frames = [ 536 { 537 "timestamp": 0, 538 "analysis": { 539 "primary": "calendar", 540 "visual_description": "Calendar app", 541 }, 542 "content": { 543 "calendar": { 544 "app": "Google Calendar", 545 "view": "week", 546 "range": "Apr 13 - Apr 19, 2026", 547 "events": [ 548 { 549 "title": "Planning", 550 "start": "Mon 9:00 AM", 551 "end": "10:00 AM", 552 "location": None, 553 "conferencing": None, 554 "guests": [], 555 "status": "unknown", 556 "recurrence": None, 557 "calendar": "Work", 558 "description": None, 559 } 560 ], 561 "availability": [], 562 "notes": None, 563 }, 564 }, 565 }, 566 ] 567 568 legacy_chunks, _meta = format_screen( 569 legacy_frames, {"include_entity_context": False} 570 ) 571 json_chunks, _meta = format_screen(json_frames, {"include_entity_context": False}) 572 573 legacy_markdown = legacy_chunks[0]["markdown"] 574 json_markdown = json_chunks[0]["markdown"] 575 assert "**Calendar:**" in legacy_markdown 576 assert "# [Google Calendar - Week]" in legacy_markdown 577 assert "```json" not in legacy_markdown 578 assert "**Calendar** (Google Calendar - week)" in json_markdown 579 assert "- **Planning** (Mon 9:00 AM - 10:00 AM)" in json_markdown 580 assert "```json" not in json_markdown 581 582 583def test_format_screen_handles_multiple_categories(): 584 """Test that both primary and secondary categories are formatted.""" 585 frames = [ 586 { 587 "timestamp": 0, 588 "analysis": { 589 "primary": "meeting", 590 "secondary": "productivity", 591 "overlap": False, 592 "visual_description": "Meeting with shared doc", 593 }, 594 "content": { 595 "meeting": { 596 "platform": "teams", 597 "participants": [ 598 {"name": "User", "status": "active", "video": True} 599 ], 600 "screen_share": None, 601 }, 602 "productivity": "| Task | Status |\n|------|--------|\n| Review | Done |", 603 }, 604 }, 605 ] 606 607 context = {"include_entity_context": False} 608 609 chunks, meta = format_screen(frames, context) 610 markdown = chunks[0]["markdown"] 611 612 # Both categories should be present 613 assert "**Meeting** (teams)" in markdown 614 assert "**Productivity:**" in markdown 615 assert "| Task | Status |" in markdown 616 617 618def test_categories_includes_all_expected(): 619 """Test that CATEGORIES includes all expected values. 620 621 Note: tmux is NOT a describe category - it has a formatter (tmux.py) 622 but no metadata (tmux.json) because tmux frames are generated directly 623 by the observer, not by the describe process. 624 """ 625 expected = [ 626 "terminal", 627 "calendar", 628 "code", 629 "messaging", 630 "meeting", 631 "browsing", 632 "reading", 633 "social", 634 "media", 635 "gaming", 636 "productivity", 637 ] 638 for cat in expected: 639 assert cat in CATEGORIES, f"Expected category {cat} not found" 640 assert len(CATEGORIES) == 11 641 642 643def test_tmux_formatter_output(): 644 """Test that tmux formatter produces expected markdown.""" 645 from solstone.observe.categories.tmux import format as tmux_format 646 647 content = { 648 "session": "main", 649 "window": {"id": "@1", "index": 0, "name": "bash"}, 650 "panes": [ 651 { 652 "id": "%1", 653 "index": 0, 654 "active": True, 655 "content": "$ ls -la\n\x1b[32mtotal 42\x1b[0m\ndrwxr-xr-x 2 user", 656 }, 657 ], 658 } 659 660 result = tmux_format(content, {}) 661 662 assert "**Tmux** (main:bash)" in result 663 assert "```" in result 664 # ANSI codes should be stripped 665 assert "\x1b[32m" not in result 666 assert "total 42" in result 667 assert "$ ls -la" in result 668 669 670def test_tmux_formatter_multiple_panes(): 671 """Test tmux formatter labels multiple panes.""" 672 from solstone.observe.categories.tmux import format as tmux_format 673 674 content = { 675 "session": "dev", 676 "window": {"name": "work"}, 677 "panes": [ 678 {"index": 0, "active": True, "content": "pane zero"}, 679 {"index": 1, "active": False, "content": "pane one"}, 680 ], 681 } 682 683 result = tmux_format(content, {}) 684 685 assert "**Pane 0 (active):**" in result 686 assert "**Pane 1:**" in result 687 assert "pane zero" in result 688 assert "pane one" in result