personal memory agent
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