"""Tests for timmy.workspace — Workspace heartbeat monitoring.""" from timmy.workspace import WorkspaceMonitor # --------------------------------------------------------------------------- # Helpers # --------------------------------------------------------------------------- def _make_monitor(tmp_path, monkeypatch): """Create a WorkspaceMonitor with tmp_path as the repo_root.""" # Mock repo_root to use tmp_path monkeypatch.setattr( "timmy.workspace.settings", type("obj", (object,), {"repo_root": str(tmp_path)})() ) state_path = tmp_path / "workspace_state.json" return WorkspaceMonitor(state_path=state_path) def _setup_workspace(tmp_path): """Create the workspace directory structure.""" workspace = tmp_path / "workspace" workspace.mkdir() (workspace / "inbox").mkdir() (workspace / "outbox").mkdir() return workspace # --------------------------------------------------------------------------- # Basic monitoring # --------------------------------------------------------------------------- def test_no_updates_when_empty(tmp_path, monkeypatch): """Fresh monitor with no workspace files should report no updates.""" # Don't create workspace dir — monitor should handle gracefully monitor = _make_monitor(tmp_path, monkeypatch) updates = monitor.get_pending_updates() assert updates["new_correspondence"] is None assert updates["new_inbox_files"] == [] def test_detects_new_correspondence(tmp_path, monkeypatch): """Writing to correspondence.md should be detected as new entries.""" workspace = _setup_workspace(tmp_path) correspondence = workspace / "correspondence.md" # Pre-populate correspondence file correspondence.write_text("Entry 1\nEntry 2\nEntry 3\n") monitor = _make_monitor(tmp_path, monkeypatch) # Should detect all 3 lines as new updates = monitor.get_pending_updates() assert updates["new_correspondence"] == "Entry 1\nEntry 2\nEntry 3" assert updates["new_inbox_files"] == [] def test_detects_new_inbox_file(tmp_path, monkeypatch): """Creating a file in inbox/ should be detected as new.""" workspace = _setup_workspace(tmp_path) inbox = workspace / "inbox" # Create a file in inbox (inbox / "message_1.md").write_text("Hello Timmy") monitor = _make_monitor(tmp_path, monkeypatch) updates = monitor.get_pending_updates() assert updates["new_correspondence"] is None assert updates["new_inbox_files"] == ["message_1.md"] def test_detects_multiple_inbox_files(tmp_path, monkeypatch): """Multiple new inbox files should all be detected.""" workspace = _setup_workspace(tmp_path) inbox = workspace / "inbox" # Create multiple files (inbox / "message_2.md").write_text("Hello again") (inbox / "task_1.md").write_text("Do something") (inbox / "note.txt").write_text("A note") monitor = _make_monitor(tmp_path, monkeypatch) updates = monitor.get_pending_updates() assert updates["new_inbox_files"] == ["message_2.md", "note.txt", "task_1.md"] def test_detects_both_correspondence_and_inbox(tmp_path, monkeypatch): """Monitor should detect both correspondence and inbox updates together.""" workspace = _setup_workspace(tmp_path) correspondence = workspace / "correspondence.md" correspondence.write_text("New journal entry\n") inbox = workspace / "inbox" (inbox / "urgent.md").write_text("Urgent message") monitor = _make_monitor(tmp_path, monkeypatch) updates = monitor.get_pending_updates() assert updates["new_correspondence"] == "New journal entry" assert updates["new_inbox_files"] == ["urgent.md"] # --------------------------------------------------------------------------- # Marking as seen # --------------------------------------------------------------------------- def test_mark_seen_clears_pending(tmp_path, monkeypatch): """After mark_seen, get_pending_updates should return empty.""" workspace = _setup_workspace(tmp_path) correspondence = workspace / "correspondence.md" correspondence.write_text("Line 1\nLine 2\n") inbox = workspace / "inbox" (inbox / "file.md").write_text("Content") monitor = _make_monitor(tmp_path, monkeypatch) # First check — should have updates updates = monitor.get_pending_updates() assert updates["new_correspondence"] is not None assert len(updates["new_inbox_files"]) == 1 # Mark as seen monitor.mark_seen() # Second check — should be empty updates = monitor.get_pending_updates() assert updates["new_correspondence"] is None assert updates["new_inbox_files"] == [] def test_mark_seen_persists_line_count(tmp_path, monkeypatch): """mark_seen should remember how many lines we've seen.""" workspace = _setup_workspace(tmp_path) correspondence = workspace / "correspondence.md" correspondence.write_text("Line 1\nLine 2\nLine 3\n") monitor = _make_monitor(tmp_path, monkeypatch) monitor.mark_seen() # Add more lines correspondence.write_text("Line 1\nLine 2\nLine 3\nLine 4\nLine 5\n") # Should only see the new lines updates = monitor.get_pending_updates() assert updates["new_correspondence"] == "Line 4\nLine 5" # --------------------------------------------------------------------------- # State persistence # --------------------------------------------------------------------------- def test_state_persists_across_instances(tmp_path, monkeypatch): """State should be saved and loaded when creating a new monitor instance.""" workspace = _setup_workspace(tmp_path) correspondence = workspace / "correspondence.md" correspondence.write_text("First entry\n") inbox = workspace / "inbox" (inbox / "first.md").write_text("First") # First monitor instance monitor1 = _make_monitor(tmp_path, monkeypatch) monitor1.mark_seen() # Add new content correspondence.write_text("First entry\nSecond entry\n") (inbox / "second.md").write_text("Second") # Second monitor instance (should load state from file) monitor2 = _make_monitor(tmp_path, monkeypatch) updates = monitor2.get_pending_updates() assert updates["new_correspondence"] == "Second entry" assert updates["new_inbox_files"] == ["second.md"] def test_state_survives_missing_files(tmp_path, monkeypatch): """Monitor should handle missing correspondence file gracefully.""" workspace = _setup_workspace(tmp_path) # Create and mark as seen correspondence = workspace / "correspondence.md" correspondence.write_text("Entry\n") monitor = _make_monitor(tmp_path, monkeypatch) monitor.mark_seen() # Delete the file correspondence.unlink() # Should return None gracefully updates = monitor.get_pending_updates() assert updates["new_correspondence"] is None # --------------------------------------------------------------------------- # Edge cases # --------------------------------------------------------------------------- def test_empty_correspondence_file(tmp_path, monkeypatch): """Empty correspondence file should return None.""" workspace = _setup_workspace(tmp_path) (workspace / "correspondence.md").write_text("") monitor = _make_monitor(tmp_path, monkeypatch) updates = monitor.get_pending_updates() assert updates["new_correspondence"] is None def test_empty_inbox_dir(tmp_path, monkeypatch): """Empty inbox directory should return empty list.""" _setup_workspace(tmp_path) monitor = _make_monitor(tmp_path, monkeypatch) updates = monitor.get_pending_updates() assert updates["new_inbox_files"] == [] def test_missing_inbox_dir(tmp_path, monkeypatch): """Missing inbox directory should return empty list.""" workspace = tmp_path / "workspace" workspace.mkdir() # No inbox subdir monitor = _make_monitor(tmp_path, monkeypatch) updates = monitor.get_pending_updates() assert updates["new_inbox_files"] == [] def test_missing_workspace_dir(tmp_path, monkeypatch): """Missing workspace directory should return empty results.""" # No workspace dir at all monitor = _make_monitor(tmp_path, monkeypatch) updates = monitor.get_pending_updates() assert updates["new_correspondence"] is None assert updates["new_inbox_files"] == [] def test_correspondence_with_blank_lines(tmp_path, monkeypatch): """Correspondence with blank lines should be handled correctly.""" workspace = _setup_workspace(tmp_path) correspondence = workspace / "correspondence.md" correspondence.write_text("Entry 1\n\nEntry 2\n\n\nEntry 3\n") monitor = _make_monitor(tmp_path, monkeypatch) updates = monitor.get_pending_updates() assert updates["new_correspondence"] == "Entry 1\n\nEntry 2\n\n\nEntry 3" def test_inbox_ignores_subdirectories(tmp_path, monkeypatch): """Inbox should only list files, not subdirectories.""" workspace = _setup_workspace(tmp_path) inbox = workspace / "inbox" (inbox / "file.md").write_text("Content") (inbox / "subdir").mkdir() monitor = _make_monitor(tmp_path, monkeypatch) updates = monitor.get_pending_updates() assert updates["new_inbox_files"] == ["file.md"] def test_deleted_inbox_files_removed_from_state(tmp_path, monkeypatch): """When inbox files are deleted, they should be removed from seen list.""" workspace = _setup_workspace(tmp_path) inbox = workspace / "inbox" # Create and see a file (inbox / "temp.md").write_text("Temp") monitor = _make_monitor(tmp_path, monkeypatch) monitor.mark_seen() # Delete the file (inbox / "temp.md").unlink() # mark_seen should update seen list to remove deleted files monitor.mark_seen() # State should now have empty seen list assert monitor._state["seen_inbox_files"] == [] def test_correspondence_append_only(tmp_path, monkeypatch): """Correspondence is append-only; modifying existing content doesn't re-notify.""" workspace = _setup_workspace(tmp_path) correspondence = workspace / "correspondence.md" correspondence.write_text("Line 1\nLine 2\n") monitor = _make_monitor(tmp_path, monkeypatch) monitor.mark_seen() # Modify the file (truncate and rewrite) — this resets line count # but correspondence.md should be append-only in practice correspondence.write_text("Modified Line 1\nModified Line 2\nLine 3\n") # Line 3 is the only truly new line (we've now seen 2, file has 3) updates = monitor.get_pending_updates() assert updates["new_correspondence"] == "Line 3"