diff --git a/src/aipass/prax/.seedgo/bypass.json b/src/aipass/prax/.seedgo/bypass.json index b36feb9f..592382b2 100644 --- a/src/aipass/prax/.seedgo/bypass.json +++ b/src/aipass/prax/.seedgo/bypass.json @@ -446,6 +446,36 @@ "file": "tests/test_monitoring_handlers.py", "standard": "architecture", "reason": "Test file lives in tests/ by convention, not in the 3-layer apps/ structure. This is standard for all prax test files." + }, + { + "file": "tests/test_log_watcher.py", + "standard": "architecture", + "reason": "Test file lives in tests/ by convention, not in the 3-layer apps/ structure. This is standard for all prax test files." + }, + { + "file": "tests/test_log_watcher.py", + "standard": "documentation", + "reason": "Pre-existing test functions follow pytest naming conventions (test_*). Original 35 functions were written without docstrings per project test style." + }, + { + "file": "tests/test_log_watcher.py", + "standard": "naming", + "reason": "Pre-existing _RealFSHandler is a private test stub with underscore prefix indicating internal use. Renaming would not improve clarity." + }, + { + "file": "tests/test_monitor_module.py", + "standard": "architecture", + "reason": "Test file lives in tests/ by convention, not in the 3-layer apps/ structure. This is standard for all prax test files." + }, + { + "file": "tests/test_operations.py", + "standard": "architecture", + "reason": "Test file lives in tests/ by convention, not in the 3-layer apps/ structure. This is standard for all prax test files." + }, + { + "file": "tests/test_operations.py", + "standard": "encapsulation", + "reason": "Unit tests must import handlers directly to test them in isolation. _load_ops() imports operations handler for unit testing. Same pattern as test_logging_handlers.py." } ], "notes": { diff --git a/src/aipass/prax/tests/test_filesystem_handler.py b/src/aipass/prax/tests/test_filesystem_handler.py new file mode 100644 index 00000000..635f6693 --- /dev/null +++ b/src/aipass/prax/tests/test_filesystem_handler.py @@ -0,0 +1,1985 @@ +# =================== AIPass ==================== +# Name: test_filesystem_handler.py +# Description: Unit tests for filesystem_handler.py +# Version: 1.0.0 +# Created: 2026-04-26 +# Modified: 2026-04-26 +# ============================================= + +"""Unit tests for MonitoringFileHandler. + +Covers: __init__, watchdog event methods, agent activity parsing +(Claude Code, Codex, Gemini), branch detection, model tag helpers, +internal event processing, and display name building. +""" + +import importlib +import json +import sys +from pathlib import Path +from unittest.mock import MagicMock, patch, mock_open as _mock_file_open + +_mopen = _mock_file_open + + +# ============================================= +# MODULE IMPORT HELPER +# ============================================= + + +def _import_filesystem_handler(): + """Import filesystem_handler with mocked watchdog and monitoring deps.""" + mod_name = "aipass.prax.apps.handlers.monitoring.filesystem_handler" + sys.modules.pop(mod_name, None) + + # Mock watchdog.events + mock_watchdog_events = MagicMock() + + class FakeFileSystemEventHandler: + """Stub base class for watchdog handler.""" + + mock_watchdog_events.FileSystemEventHandler = FakeFileSystemEventHandler + sys.modules["watchdog"] = MagicMock() + sys.modules["watchdog.events"] = mock_watchdog_events + + # Mock monitoring sub-modules + mock_event_queue = MagicMock() + mock_event_cls = MagicMock() + mock_event_queue.MonitoringEvent = mock_event_cls + mock_queue_instance = MagicMock() + mock_queue_instance.enqueue = MagicMock(return_value=True) + mock_event_queue.MonitoringQueue = MagicMock + + mock_branch_detector = MagicMock() + mock_branch_detector.detect_branch_from_path = MagicMock(return_value="PRAX") + + mock_filters = MagicMock() + mock_filters.should_monitor = MagicMock(return_value=True) + mock_filters.get_priority = MagicMock(return_value="info") + + sys.modules["aipass.prax.apps.handlers.monitoring.event_queue"] = mock_event_queue + sys.modules["aipass.prax.apps.handlers.monitoring.branch_detector"] = mock_branch_detector + sys.modules["aipass.prax.apps.handlers.monitoring.monitoring_filters"] = mock_filters + + mod = importlib.import_module(mod_name) + return mod, mock_event_queue, mock_branch_detector, mock_filters, mock_queue_instance + + +def _make_handler(mod, queue=None, command_indicator_files=None): + """Create a MonitoringFileHandler with a given queue.""" + return mod.MonitoringFileHandler( + event_queue=queue, + command_indicator_files=command_indicator_files, + ) + + +# ============================================= +# INIT TESTS +# ============================================= + + +class TestInit: + """Tests for MonitoringFileHandler.__init__.""" + + def test_init_sets_queue(self): + """Constructor should store event_queue reference.""" + mod, _, _, _, queue = _import_filesystem_handler() + handler = _make_handler(mod, queue=queue) + assert handler._event_queue is queue + + def test_init_no_queue(self): + """Constructor with no queue should set None.""" + mod, _, _, _, _ = _import_filesystem_handler() + handler = _make_handler(mod, queue=None) + assert handler._event_queue is None + + def test_init_command_indicator_files_default(self): + """Constructor should default command_indicator_files to empty dict.""" + mod, _, _, _, _ = _import_filesystem_handler() + handler = _make_handler(mod) + assert handler._command_indicator_files == {} + + def test_init_command_indicator_files_set(self): + """Constructor should store command_indicator_files.""" + mod, _, _, _, _ = _import_filesystem_handler() + cif = {"status.json": "status command"} + handler = _make_handler(mod, command_indicator_files=cif) + assert handler._command_indicator_files == cif + + def test_init_creates_tracking_dicts(self): + """Constructor should initialize all tracking dictionaries.""" + mod, _, _, _, _ = _import_filesystem_handler() + handler = _make_handler(mod) + assert handler._last_file_command == {} + assert handler._jsonl_positions == {} + assert handler._last_agent_action == {} + assert handler._session_models == {} + assert handler._session_branches == {} + assert isinstance(handler._branch_warn_logged, set) + + def test_init_logs_operation(self): + """Constructor should call json_handler.log_operation.""" + mod, _, _, _, _ = _import_filesystem_handler() + _make_handler(mod) + mod.json_handler.log_operation.assert_called() + + +# ============================================= +# PROPERTY TESTS +# ============================================= + + +class TestEventQueueProperty: + """Tests for event_queue property getter/setter.""" + + def test_getter_returns_queue(self): + """Property getter should return the stored queue.""" + mod, _, _, _, queue = _import_filesystem_handler() + handler = _make_handler(mod, queue=queue) + assert handler.event_queue is queue + + def test_setter_replaces_queue(self): + """Property setter should replace the queue.""" + mod, _, _, _, _ = _import_filesystem_handler() + handler = _make_handler(mod, queue=None) + assert handler.event_queue is None + new_queue = MagicMock() + handler.event_queue = new_queue + assert handler.event_queue is new_queue + + +# ============================================= +# WATCHDOG EVENT METHOD TESTS +# ============================================= + + +class TestOnCreated: + """Tests for on_created.""" + + def test_file_event_calls_handle_event(self): + """Should call _handle_event for non-directory events.""" + mod, _, _, _, queue = _import_filesystem_handler() + handler = _make_handler(mod, queue=queue) + event = MagicMock() + event.is_directory = False + event.src_path = "/some/file.py" + with patch.object(handler, "_handle_event") as mock_handle: + handler.on_created(event) + mock_handle.assert_called_once_with("created", "/some/file.py") + + def test_directory_event_skipped(self): + """Should skip directory events.""" + mod, _, _, _, queue = _import_filesystem_handler() + handler = _make_handler(mod, queue=queue) + event = MagicMock() + event.is_directory = True + with patch.object(handler, "_handle_event") as mock_handle: + handler.on_created(event) + mock_handle.assert_not_called() + + +class TestOnModified: + """Tests for on_modified.""" + + def test_file_event_calls_handle_event(self): + """Should call _handle_event for non-directory events.""" + mod, _, _, _, queue = _import_filesystem_handler() + handler = _make_handler(mod, queue=queue) + event = MagicMock() + event.is_directory = False + event.src_path = "/some/file.py" + with patch.object(handler, "_handle_event") as mock_handle: + handler.on_modified(event) + mock_handle.assert_called_once_with("modified", "/some/file.py") + + def test_directory_event_skipped(self): + """Should skip directory events.""" + mod, _, _, _, _ = _import_filesystem_handler() + handler = _make_handler(mod) + event = MagicMock() + event.is_directory = True + with patch.object(handler, "_handle_event") as mock_handle: + handler.on_modified(event) + mock_handle.assert_not_called() + + +class TestOnDeleted: + """Tests for on_deleted.""" + + def test_file_event_calls_handle_event(self): + """Should call _handle_event for non-directory events.""" + mod, _, _, _, queue = _import_filesystem_handler() + handler = _make_handler(mod, queue=queue) + event = MagicMock() + event.is_directory = False + event.src_path = "/some/file.py" + with patch.object(handler, "_handle_event") as mock_handle: + handler.on_deleted(event) + mock_handle.assert_called_once_with("deleted", "/some/file.py") + + def test_directory_event_skipped(self): + """Should skip directory events.""" + mod, _, _, _, _ = _import_filesystem_handler() + handler = _make_handler(mod) + event = MagicMock() + event.is_directory = True + with patch.object(handler, "_handle_event") as mock_handle: + handler.on_deleted(event) + mock_handle.assert_not_called() + + +class TestOnMoved: + """Tests for on_moved.""" + + def test_move_to_trash(self): + """Move to Trash should emit deleted event.""" + mod, _, _, _, queue = _import_filesystem_handler() + handler = _make_handler(mod, queue=queue) + event = MagicMock() + event.is_directory = False + event.src_path = "/some/file.py" + event.dest_path = "/home/user/.local/share/Trash/files/file.py" + with patch.object(handler, "_handle_event") as mock_handle: + handler.on_moved(event) + mock_handle.assert_called_once_with("deleted", "/some/file.py") + + def test_atomic_write_tmp_infix(self): + """Move from .tmp. infix file should emit modified event.""" + mod, _, _, _, queue = _import_filesystem_handler() + handler = _make_handler(mod, queue=queue) + event = MagicMock() + event.is_directory = False + event.src_path = "/some/.tmp.file.py" + event.dest_path = "/some/file.py" + with patch.object(handler, "_handle_event") as mock_handle: + handler.on_moved(event) + mock_handle.assert_called_once_with("modified", "/some/file.py") + + def test_atomic_write_tmp_suffix(self): + """Move from file ending in .tmp should emit modified event.""" + mod, _, _, _, queue = _import_filesystem_handler() + handler = _make_handler(mod, queue=queue) + event = MagicMock() + event.is_directory = False + event.src_path = "/some/file.py.tmp" + event.dest_path = "/some/file.py" + with patch.object(handler, "_handle_event") as mock_handle: + handler.on_moved(event) + mock_handle.assert_called_once_with("modified", "/some/file.py") + + def test_normal_move(self): + """Normal move should emit moved event with dest path.""" + mod, _, _, _, queue = _import_filesystem_handler() + handler = _make_handler(mod, queue=queue) + event = MagicMock() + event.is_directory = False + event.src_path = "/some/old.py" + event.dest_path = "/some/new.py" + with patch.object(handler, "_handle_event") as mock_handle: + handler.on_moved(event) + mock_handle.assert_called_once_with("moved", "/some/new.py") + + def test_directory_event_skipped(self): + """Should skip directory events.""" + mod, _, _, _, _ = _import_filesystem_handler() + handler = _make_handler(mod) + event = MagicMock() + event.is_directory = True + with patch.object(handler, "_handle_event") as mock_handle: + handler.on_moved(event) + mock_handle.assert_not_called() + + def test_bytes_paths(self): + """Should handle bytes paths via decode.""" + mod, _, _, _, queue = _import_filesystem_handler() + handler = _make_handler(mod, queue=queue) + event = MagicMock() + event.is_directory = False + event.src_path = b"/some/old.py" + event.dest_path = b"/some/new.py" + with patch.object(handler, "_handle_event") as mock_handle: + handler.on_moved(event) + mock_handle.assert_called_once_with("moved", "/some/new.py") + + +# ============================================= +# FORMAT TOOL ACTION TESTS +# ============================================= + + +class TestFormatToolAction: + """Tests for _format_tool_action static method.""" + + def test_read_tool(self): + """Should format Read tool with short filename.""" + mod, _, _, _, _ = _import_filesystem_handler() + cls = mod.MonitoringFileHandler + result = cls._format_tool_action({"name": "Read", "input": {"file_path": "/home/user/src/file.py"}}) + assert "Read" in result + assert "file.py" in result + + def test_edit_tool(self): + """Should format Edit tool with short filename.""" + mod, _, _, _, _ = _import_filesystem_handler() + cls = mod.MonitoringFileHandler + result = cls._format_tool_action({"name": "Edit", "input": {"file_path": "/a/b/c.py"}}) + assert "Edit" in result + assert "c.py" in result + + def test_write_tool(self): + """Should format Write tool with short filename.""" + mod, _, _, _, _ = _import_filesystem_handler() + cls = mod.MonitoringFileHandler + result = cls._format_tool_action({"name": "Write", "input": {"file_path": "/x/y.txt"}}) + assert "Write" in result + assert "y.txt" in result + + def test_bash_tool_with_description(self): + """Should format Bash tool with description.""" + mod, _, _, _, _ = _import_filesystem_handler() + cls = mod.MonitoringFileHandler + result = cls._format_tool_action({"name": "Bash", "input": {"description": "run tests"}}) + assert "Bash" in result + assert "run tests" in result + + def test_bash_tool_with_command_fallback(self): + """Should format Bash tool with command when no description.""" + mod, _, _, _, _ = _import_filesystem_handler() + cls = mod.MonitoringFileHandler + result = cls._format_tool_action({"name": "Bash", "input": {"command": "ls -la"}}) + assert "Bash" in result + assert "ls -la" in result + + def test_grep_tool(self): + """Should format Grep tool with pattern.""" + mod, _, _, _, _ = _import_filesystem_handler() + cls = mod.MonitoringFileHandler + result = cls._format_tool_action({"name": "Grep", "input": {"pattern": "TODO"}}) + assert "Grep" in result + assert "TODO" in result + + def test_glob_tool(self): + """Should format Glob tool with pattern.""" + mod, _, _, _, _ = _import_filesystem_handler() + cls = mod.MonitoringFileHandler + result = cls._format_tool_action({"name": "Glob", "input": {"pattern": "**/*.py"}}) + assert "Glob" in result + assert "**/*.py" in result + + def test_task_tool(self): + """Should format Task tool with description.""" + mod, _, _, _, _ = _import_filesystem_handler() + cls = mod.MonitoringFileHandler + result = cls._format_tool_action({"name": "Task", "input": {"description": "do stuff"}}) + assert "Agent" in result + assert "do stuff" in result + + def test_unknown_tool(self): + """Should return tool name for unknown tools.""" + mod, _, _, _, _ = _import_filesystem_handler() + cls = mod.MonitoringFileHandler + result = cls._format_tool_action({"name": "CustomTool", "input": {}}) + assert "CustomTool" in result + + def test_read_no_slash_in_path(self): + """Should handle file_path without slashes.""" + mod, _, _, _, _ = _import_filesystem_handler() + cls = mod.MonitoringFileHandler + result = cls._format_tool_action({"name": "Read", "input": {"file_path": "file.py"}}) + assert "file.py" in result + + +# ============================================= +# EXTRACT MODEL FROM ENTRY TESTS +# ============================================= + + +class TestExtractModelFromEntry: + """Tests for _extract_model_from_entry static method.""" + + def test_extracts_model(self): + """Should extract model from message dict.""" + mod, _, _, _, _ = _import_filesystem_handler() + cls = mod.MonitoringFileHandler + entry = {"message": {"model": "claude-sonnet-4-20250514"}} + result = cls._extract_model_from_entry(entry) + assert result == "claude-sonnet-4-20250514" + + def test_no_message_key(self): + """Should return None when no message key.""" + mod, _, _, _, _ = _import_filesystem_handler() + cls = mod.MonitoringFileHandler + result = cls._extract_model_from_entry({}) + assert result is None + + def test_message_not_dict(self): + """Should return None when message is not a dict.""" + mod, _, _, _, _ = _import_filesystem_handler() + cls = mod.MonitoringFileHandler + result = cls._extract_model_from_entry({"message": "string"}) + assert result is None + + +# ============================================= +# EXTRACT ACTION FROM ENTRY TESTS +# ============================================= + + +class TestExtractActionFromEntry: + """Tests for _extract_action_from_entry static method.""" + + def test_progress_type_returns_none(self): + """Progress entries should return None.""" + mod, _, _, _, _ = _import_filesystem_handler() + cls = mod.MonitoringFileHandler + assert cls._extract_action_from_entry({"type": "progress"}) is None + + def test_system_type_returns_none(self): + """System entries should return None.""" + mod, _, _, _, _ = _import_filesystem_handler() + cls = mod.MonitoringFileHandler + assert cls._extract_action_from_entry({"type": "system"}) is None + + def test_file_history_snapshot_returns_none(self): + """File-history-snapshot entries should return None.""" + mod, _, _, _, _ = _import_filesystem_handler() + cls = mod.MonitoringFileHandler + assert cls._extract_action_from_entry({"type": "file-history-snapshot"}) is None + + def test_queue_operation_returns_none(self): + """Queue-operation entries should return None.""" + mod, _, _, _, _ = _import_filesystem_handler() + cls = mod.MonitoringFileHandler + assert cls._extract_action_from_entry({"type": "queue-operation"}) is None + + def test_assistant_thinking(self): + """Assistant entry with thinking item should return thinking.""" + mod, _, _, _, _ = _import_filesystem_handler() + cls = mod.MonitoringFileHandler + entry = { + "type": "assistant", + "message": {"content": [{"type": "thinking", "text": "hmm"}]}, + } + result = cls._extract_action_from_entry(entry) + assert "Thinking" in result + + def test_assistant_tool_use(self): + """Assistant entry with tool_use item should return tool action.""" + mod, _, _, _, _ = _import_filesystem_handler() + cls = mod.MonitoringFileHandler + entry = { + "type": "assistant", + "message": { + "content": [ + { + "type": "tool_use", + "name": "Read", + "input": {"file_path": "/a/b.py"}, + } + ] + }, + } + result = cls._extract_action_from_entry(entry) + assert "Read" in result + + def test_assistant_text(self): + """Assistant entry with text item should return text snippet.""" + mod, _, _, _, _ = _import_filesystem_handler() + cls = mod.MonitoringFileHandler + entry = { + "type": "assistant", + "message": {"content": [{"type": "text", "text": "Hello world"}]}, + } + result = cls._extract_action_from_entry(entry) + assert "Hello world" in result + + def test_assistant_text_empty(self): + """Assistant entry with empty text should return None.""" + mod, _, _, _, _ = _import_filesystem_handler() + cls = mod.MonitoringFileHandler + entry = { + "type": "assistant", + "message": {"content": [{"type": "text", "text": ""}]}, + } + result = cls._extract_action_from_entry(entry) + assert result is None + + def test_assistant_content_item_not_dict(self): + """Assistant entry with non-dict content items should skip them.""" + mod, _, _, _, _ = _import_filesystem_handler() + cls = mod.MonitoringFileHandler + entry = { + "type": "assistant", + "message": {"content": ["just a string", {"type": "thinking"}]}, + } + result = cls._extract_action_from_entry(entry) + assert "Thinking" in result + + def test_user_message(self): + """User entry should return user message.""" + mod, _, _, _, _ = _import_filesystem_handler() + cls = mod.MonitoringFileHandler + entry = {"type": "user", "message": {"content": "hello"}} + result = cls._extract_action_from_entry(entry) + assert "User message" in result + + def test_user_tool_result(self): + """User entry with tool_result content should return None.""" + mod, _, _, _, _ = _import_filesystem_handler() + cls = mod.MonitoringFileHandler + entry = { + "type": "user", + "message": {"content": [{"type": "tool_result", "content": "ok"}]}, + } + result = cls._extract_action_from_entry(entry) + assert result is None + + def test_unknown_type_returns_none(self): + """Unknown entry types should return None.""" + mod, _, _, _, _ = _import_filesystem_handler() + cls = mod.MonitoringFileHandler + result = cls._extract_action_from_entry({"type": "unknown_type"}) + assert result is None + + def test_message_not_dict_in_entry(self): + """Should handle non-dict message gracefully.""" + mod, _, _, _, _ = _import_filesystem_handler() + cls = mod.MonitoringFileHandler + entry = {"type": "assistant", "message": "not a dict"} + result = cls._extract_action_from_entry(entry) + assert result is None + + +# ============================================= +# BRANCH FROM CWD TESTS +# ============================================= + + +class TestBranchFromCwd: + """Tests for _branch_from_cwd static method.""" + + def test_aipass_branch(self): + """Should extract branch name after aipass in path.""" + mod, _, _, _, _ = _import_filesystem_handler() + cls = mod.MonitoringFileHandler + result = cls._branch_from_cwd("/home/user/Projects/AIPass/src/aipass/prax") + assert result == "PRAX" + + def test_empty_string(self): + """Should return None for empty CWD.""" + mod, _, _, _, _ = _import_filesystem_handler() + cls = mod.MonitoringFileHandler + result = cls._branch_from_cwd("") + assert result is None + + def test_src_fallback(self): + """Should fall back to src/name for paths without aipass.""" + mod, _, _, _, _ = _import_filesystem_handler() + cls = mod.MonitoringFileHandler + with patch.object(mod, "detect_branch_from_path", return_value="UNKNOWN"): + result = cls._branch_from_cwd("/opt/src/myproject/subdir") + assert result == "MYPROJECT" + + def test_no_aipass_no_src(self): + """Should return None when no recognizable pattern.""" + mod, _, _, _, _ = _import_filesystem_handler() + cls = mod.MonitoringFileHandler + result = cls._branch_from_cwd("/tmp/random/dir") + assert result is None + + def test_aipass_at_end_of_path(self): + """Should return None when aipass is the last segment.""" + mod, _, _, _, _ = _import_filesystem_handler() + cls = mod.MonitoringFileHandler + result = cls._branch_from_cwd("/home/user/aipass") + assert result is None + + def test_projects_external_detected(self): + """Should detect external projects under ~/Projects.""" + mod, _, _, _, _ = _import_filesystem_handler() + cls = mod.MonitoringFileHandler + home = str(Path.home()) + ext_path = f"{home}/Projects/AIPL/src/polyglot" + with patch.object(mod, "detect_branch_from_path", return_value="AIPL/POLYGLOT"): + result = cls._branch_from_cwd(ext_path) + assert result == "AIPL/POLYGLOT" + + def test_projects_external_unknown_falls_through(self): + """When detect returns UNKNOWN, should try src fallback.""" + mod, _, _, _, _ = _import_filesystem_handler() + cls = mod.MonitoringFileHandler + home = str(Path.home()) + ext_path = f"{home}/Projects/AIPL/src/polyglot" + with patch.object(mod, "detect_branch_from_path", return_value="UNKNOWN"): + result = cls._branch_from_cwd(ext_path) + assert result == "POLYGLOT" + + +# ============================================= +# READ CODEX CWD TESTS +# ============================================= + + +class TestReadCodexCwd: + """Tests for _read_codex_cwd.""" + + def test_reads_cwd_from_session_meta(self): + """Should read CWD from first line of Codex JSONL.""" + mod, _, _, _, _ = _import_filesystem_handler() + handler = _make_handler(mod) + meta_line = json.dumps({"type": "session_meta", "payload": {"cwd": "/home/user/project"}}) + with patch("builtins.open", _mopen(read_data=meta_line + "\n")): + result = handler._read_codex_cwd("/fake/session.jsonl") + assert result == "/home/user/project" + + def test_empty_first_line(self): + """Should return None for empty first line.""" + mod, _, _, _, _ = _import_filesystem_handler() + handler = _make_handler(mod) + with patch("builtins.open", _mopen(read_data="\n")): + result = handler._read_codex_cwd("/fake/session.jsonl") + assert result is None + + def test_not_session_meta(self): + """Should return None if first line is not session_meta type.""" + mod, _, _, _, _ = _import_filesystem_handler() + handler = _make_handler(mod) + meta_line = json.dumps({"type": "event_msg", "payload": {}}) + with patch("builtins.open", _mopen(read_data=meta_line + "\n")): + result = handler._read_codex_cwd("/fake/session.jsonl") + assert result is None + + def test_os_error(self): + """Should return None on OSError.""" + mod, _, _, _, _ = _import_filesystem_handler() + handler = _make_handler(mod) + with patch("builtins.open", side_effect=OSError("no file")): + result = handler._read_codex_cwd("/fake/session.jsonl") + assert result is None + + def test_json_decode_error(self): + """Should return None on JSONDecodeError.""" + mod, _, _, _, _ = _import_filesystem_handler() + handler = _make_handler(mod) + with patch("builtins.open", _mopen(read_data="not json\n")): + result = handler._read_codex_cwd("/fake/session.jsonl") + assert result is None + + +# ============================================= +# GET CODEX BRANCH TESTS +# ============================================= + + +class TestGetCodexBranch: + """Tests for _get_codex_branch.""" + + def test_cached_branch(self): + """Should return cached branch if present.""" + mod, _, _, _, _ = _import_filesystem_handler() + handler = _make_handler(mod) + handler._session_branches["key1"] = "CACHED" + result = handler._get_codex_branch("/fake/file.jsonl", "key1") + assert result == "CACHED" + + def test_reads_cwd_and_detects_branch(self): + """Should read CWD and detect branch from it.""" + mod, _, _, _, _ = _import_filesystem_handler() + handler = _make_handler(mod) + meta_line = json.dumps( + { + "type": "session_meta", + "payload": {"cwd": "/home/user/Projects/AIPass/src/aipass/prax"}, + } + ) + with patch("builtins.open", _mopen(read_data=meta_line + "\n")): + result = handler._get_codex_branch("/fake/file.jsonl", "key2") + assert result == "PRAX" + assert handler._session_branches["key2"] == "PRAX" + + def test_no_cwd_returns_codex(self): + """Should return CODEX when CWD cannot be read.""" + mod, _, _, _, _ = _import_filesystem_handler() + handler = _make_handler(mod) + with patch("builtins.open", _mopen(read_data="\n")): + result = handler._get_codex_branch("/fake/file.jsonl", "key3") + assert result == "CODEX" + + def test_cwd_but_no_branch_returns_codex(self): + """Should return CODEX when CWD has no branch pattern.""" + mod, _, _, _, _ = _import_filesystem_handler() + handler = _make_handler(mod) + meta_line = json.dumps({"type": "session_meta", "payload": {"cwd": "/tmp/nowhere"}}) + with patch("builtins.open", _mopen(read_data=meta_line + "\n")): + result = handler._get_codex_branch("/fake/file.jsonl", "key4") + assert result == "CODEX" + + +# ============================================= +# RESOLVE GEMINI SLUG TESTS +# ============================================= + + +class TestResolveGeminiSlug: + """Tests for _resolve_gemini_slug.""" + + def test_resolves_slug_to_branch(self): + """Should resolve slug to branch via projects.json.""" + mod, _, _, _, _ = _import_filesystem_handler() + handler = _make_handler(mod) + projects_data = json.dumps({"projects": {"/home/user/Projects/AIPass/src/aipass/prax": "my-slug"}}) + with patch.object(Path, "exists", return_value=True): + with patch.object(Path, "read_text", return_value=projects_data): + result = handler._resolve_gemini_slug("my-slug") + assert result == "PRAX" + + def test_no_projects_file(self): + """Should return None when projects.json does not exist.""" + mod, _, _, _, _ = _import_filesystem_handler() + handler = _make_handler(mod) + with patch.object(Path, "exists", return_value=False): + result = handler._resolve_gemini_slug("my-slug") + assert result is None + + def test_os_error_reading_projects(self): + """Should return None on OSError reading projects.json.""" + mod, _, _, _, _ = _import_filesystem_handler() + handler = _make_handler(mod) + with patch.object(Path, "exists", return_value=True): + with patch.object(Path, "read_text", side_effect=OSError("fail")): + result = handler._resolve_gemini_slug("my-slug") + assert result is None + + def test_json_error_reading_projects(self): + """Should return None on JSON decode error.""" + mod, _, _, _, _ = _import_filesystem_handler() + handler = _make_handler(mod) + with patch.object(Path, "exists", return_value=True): + with patch.object(Path, "read_text", return_value="not json"): + result = handler._resolve_gemini_slug("my-slug") + assert result is None + + def test_slug_not_found(self): + """Should return None when slug is not in projects.json.""" + mod, _, _, _, _ = _import_filesystem_handler() + handler = _make_handler(mod) + projects_data = json.dumps({"projects": {"/some/path": "other-slug"}}) + with patch.object(Path, "exists", return_value=True): + with patch.object(Path, "read_text", return_value=projects_data): + result = handler._resolve_gemini_slug("my-slug") + assert result is None + + +# ============================================= +# GET GEMINI BRANCH TESTS +# ============================================= + + +class TestGetGeminiBranch: + """Tests for _get_gemini_branch.""" + + def test_cached_branch(self): + """Should return cached branch if present.""" + mod, _, _, _, _ = _import_filesystem_handler() + handler = _make_handler(mod) + handler._session_branches["gkey"] = "CACHED" + result = handler._get_gemini_branch("/fake/file.json", "gkey") + assert result == "CACHED" + + def test_extracts_slug_from_path(self): + """Should extract slug from path and resolve it.""" + mod, _, _, _, _ = _import_filesystem_handler() + handler = _make_handler(mod) + file_path = "/home/user/.gemini/tmp/my-project/chats/session-1.json" + with patch.object(handler, "_resolve_gemini_slug", return_value="PRAX"): + result = handler._get_gemini_branch(file_path, "gkey2") + assert result == "PRAX" + assert handler._session_branches["gkey2"] == "PRAX" + + def test_no_slug_returns_gemini(self): + """Should return GEMINI when no slug can be extracted.""" + mod, _, _, _, _ = _import_filesystem_handler() + handler = _make_handler(mod) + file_path = "/home/user/.gemini/chats/session-1.json" + result = handler._get_gemini_branch(file_path, "gkey3") + assert result == "GEMINI" + + def test_unresolved_slug_uses_uppercase(self): + """Should use slug.upper() when resolution returns None.""" + mod, _, _, _, _ = _import_filesystem_handler() + handler = _make_handler(mod) + file_path = "/home/user/.gemini/tmp/my-project/chats/session-1.json" + with patch.object(handler, "_resolve_gemini_slug", return_value=None): + result = handler._get_gemini_branch(file_path, "gkey4") + assert result == "MY-PROJECT" + + +# ============================================= +# SHORTEN MODEL TESTS +# ============================================= + + +class TestShortenModel: + """Tests for _shorten_model static method.""" + + def test_empty_string(self): + """Empty model should return empty string.""" + mod, _, _, _, _ = _import_filesystem_handler() + assert mod.MonitoringFileHandler._shorten_model("") == "" + + def test_opus(self): + """Should shorten opus models.""" + mod, _, _, _, _ = _import_filesystem_handler() + result = mod.MonitoringFileHandler._shorten_model("claude-opus-4-20250514") + assert result == "opus" + + def test_sonnet(self): + """Should shorten sonnet models.""" + mod, _, _, _, _ = _import_filesystem_handler() + result = mod.MonitoringFileHandler._shorten_model("claude-sonnet-4-20250514") + assert result == "sonnet" + + def test_haiku(self): + """Should shorten haiku models.""" + mod, _, _, _, _ = _import_filesystem_handler() + result = mod.MonitoringFileHandler._shorten_model("claude-3-haiku-20241022") + assert result == "haiku" + + def test_gpt_model(self): + """Should return gpt models as-is.""" + mod, _, _, _, _ = _import_filesystem_handler() + assert mod.MonitoringFileHandler._shorten_model("gpt-4o") == "gpt-4o" + + def test_o_model(self): + """Should return o-series models as-is.""" + mod, _, _, _, _ = _import_filesystem_handler() + result = mod.MonitoringFileHandler._shorten_model("o4-mini") + assert result == "o4-mini" + + def test_gemini_model(self): + """Should shorten gemini models, removing preview suffix.""" + mod, _, _, _, _ = _import_filesystem_handler() + result = mod.MonitoringFileHandler._shorten_model("gemini-3-flash-preview") + assert result == "gemini-3-flash" + + def test_fallback_long_model(self): + """Should truncate unknown models to 15 chars.""" + mod, _, _, _, _ = _import_filesystem_handler() + result = mod.MonitoringFileHandler._shorten_model("some-very-long-model-name-here") + assert len(result) == 15 + + def test_o_without_digit(self): + """Prefix o without digits should hit fallback.""" + mod, _, _, _, _ = _import_filesystem_handler() + result = mod.MonitoringFileHandler._shorten_model("orange") + assert result == "orange" + + +# ============================================= +# TAG BRANCH WITH MODEL TESTS +# ============================================= + + +class TestTagBranchWithModel: + """Tests for _tag_branch_with_model.""" + + def test_with_known_model(self): + """Should append model tag to branch.""" + mod, _, _, _, _ = _import_filesystem_handler() + handler = _make_handler(mod) + handler._session_models["pk"] = "opus" + result = handler._tag_branch_with_model("pk", "PRAX") + assert result == "PRAX/opus" + + def test_without_model(self): + """Should return plain branch when no model known.""" + mod, _, _, _, _ = _import_filesystem_handler() + handler = _make_handler(mod) + result = handler._tag_branch_with_model("pk", "PRAX") + assert result == "PRAX" + + +# ============================================= +# EXTRACT CODEX ACTION TESTS +# ============================================= + + +class TestExtractCodexAction: + """Tests for _extract_codex_action static method.""" + + def test_agent_message_with_text(self): + """Should format agent_message event.""" + mod, _, _, _, _ = _import_filesystem_handler() + cls = mod.MonitoringFileHandler + entry = { + "type": "event_msg", + "payload": {"type": "agent_message", "text": "Hello"}, + } + result = cls._extract_codex_action(entry) + assert "Hello" in result + + def test_agent_message_no_text(self): + """Should return default for agent_message without text.""" + mod, _, _, _, _ = _import_filesystem_handler() + cls = mod.MonitoringFileHandler + entry = { + "type": "event_msg", + "payload": {"type": "agent_message"}, + } + result = cls._extract_codex_action(entry) + assert "Agent response" in result + + def test_user_message(self): + """Should return user message for user_message event.""" + mod, _, _, _, _ = _import_filesystem_handler() + cls = mod.MonitoringFileHandler + entry = { + "type": "event_msg", + "payload": {"type": "user_message"}, + } + result = cls._extract_codex_action(entry) + assert "User message" in result + + def test_token_count(self): + """Should return Thinking for token_count event.""" + mod, _, _, _, _ = _import_filesystem_handler() + cls = mod.MonitoringFileHandler + entry = { + "type": "event_msg", + "payload": {"type": "token_count"}, + } + result = cls._extract_codex_action(entry) + assert "Thinking" in result + + def test_task_started(self): + """Should return Task started for task_started event.""" + mod, _, _, _, _ = _import_filesystem_handler() + cls = mod.MonitoringFileHandler + entry = { + "type": "event_msg", + "payload": {"type": "task_started"}, + } + result = cls._extract_codex_action(entry) + assert "Task started" in result + + def test_task_complete(self): + """Should return Task complete for task_complete event.""" + mod, _, _, _, _ = _import_filesystem_handler() + cls = mod.MonitoringFileHandler + entry = { + "type": "event_msg", + "payload": {"type": "task_complete"}, + } + result = cls._extract_codex_action(entry) + assert "Task complete" in result + + def test_unknown_event_type(self): + """Should return None for unknown event_msg types.""" + mod, _, _, _, _ = _import_filesystem_handler() + cls = mod.MonitoringFileHandler + entry = { + "type": "event_msg", + "payload": {"type": "something_else"}, + } + result = cls._extract_codex_action(entry) + assert result is None + + def test_response_item(self): + """Should delegate to _parse_codex_response_item.""" + mod, _, _, _, _ = _import_filesystem_handler() + cls = mod.MonitoringFileHandler + entry = { + "type": "response_item", + "payload": {"item": {"type": "function_call", "name": "exec"}}, + } + result = cls._extract_codex_action(entry) + assert "exec" in result + + def test_session_meta_returns_none(self): + """Session_meta entries should return None.""" + mod, _, _, _, _ = _import_filesystem_handler() + cls = mod.MonitoringFileHandler + result = cls._extract_codex_action({"type": "session_meta", "payload": {}}) + assert result is None + + def test_turn_context_returns_none(self): + """Turn_context entries should return None.""" + mod, _, _, _, _ = _import_filesystem_handler() + cls = mod.MonitoringFileHandler + result = cls._extract_codex_action({"type": "turn_context", "payload": {}}) + assert result is None + + def test_unknown_top_level_type(self): + """Unknown top-level entry types should return None.""" + mod, _, _, _, _ = _import_filesystem_handler() + cls = mod.MonitoringFileHandler + result = cls._extract_codex_action({"type": "random_type", "payload": {}}) + assert result is None + + def test_agent_message_with_message_fallback(self): + """Should use message field when text is absent.""" + mod, _, _, _, _ = _import_filesystem_handler() + cls = mod.MonitoringFileHandler + entry = { + "type": "event_msg", + "payload": {"type": "agent_message", "message": "fallback"}, + } + result = cls._extract_codex_action(entry) + assert "fallback" in result + + +# ============================================= +# PARSE CODEX RESPONSE ITEM TESTS +# ============================================= + + +class TestParseCodexResponseItem: + """Tests for _parse_codex_response_item static method.""" + + def test_function_call(self): + """Should format function_call item.""" + mod, _, _, _, _ = _import_filesystem_handler() + cls = mod.MonitoringFileHandler + result = cls._parse_codex_response_item({"item": {"type": "function_call", "name": "shell"}}) + assert "shell" in result + + def test_function_call_output_returns_none(self): + """Function_call_output should return None.""" + mod, _, _, _, _ = _import_filesystem_handler() + cls = mod.MonitoringFileHandler + result = cls._parse_codex_response_item({"item": {"type": "function_call_output"}}) + assert result is None + + def test_message_with_text_content(self): + """Message with text content should extract text.""" + mod, _, _, _, _ = _import_filesystem_handler() + cls = mod.MonitoringFileHandler + payload = { + "item": { + "type": "message", + "content": [{"text": "hello there"}], + } + } + result = cls._parse_codex_response_item(payload) + assert "hello there" in result + + def test_message_without_list_content(self): + """Message with non-list content should return default.""" + mod, _, _, _, _ = _import_filesystem_handler() + cls = mod.MonitoringFileHandler + payload = {"item": {"type": "message", "content": "just a string"}} + result = cls._parse_codex_response_item(payload) + assert "Agent response" in result + + def test_message_empty_text_parts(self): + """Message with empty text parts should return default.""" + mod, _, _, _, _ = _import_filesystem_handler() + cls = mod.MonitoringFileHandler + payload = {"item": {"type": "message", "content": [{"text": ""}]}} + result = cls._parse_codex_response_item(payload) + assert "Agent response" in result + + def test_unknown_item_type(self): + """Unknown item types should return None.""" + mod, _, _, _, _ = _import_filesystem_handler() + cls = mod.MonitoringFileHandler + result = cls._parse_codex_response_item({"item": {"type": "other"}}) + assert result is None + + def test_payload_without_item_key(self): + """Payload without item key should use payload as item.""" + mod, _, _, _, _ = _import_filesystem_handler() + cls = mod.MonitoringFileHandler + result = cls._parse_codex_response_item({"type": "function_call", "name": "tool"}) + assert "tool" in result + + +# ============================================= +# EXTRACT GEMINI ACTION TESTS +# ============================================= + + +class TestExtractGeminiAction: + """Tests for _extract_gemini_action static method.""" + + def test_user_message(self): + """User messages should return user message.""" + mod, _, _, _, _ = _import_filesystem_handler() + cls = mod.MonitoringFileHandler + result = cls._extract_gemini_action({"type": "user"}) + assert "User message" in result + + def test_non_gemini_type_returns_none(self): + """Non-user, non-gemini types should return None.""" + mod, _, _, _, _ = _import_filesystem_handler() + cls = mod.MonitoringFileHandler + result = cls._extract_gemini_action({"type": "system"}) + assert result is None + + def test_gemini_with_tool_calls(self): + """Gemini message with tool calls should format last tool.""" + mod, _, _, _, _ = _import_filesystem_handler() + cls = mod.MonitoringFileHandler + msg = { + "type": "gemini", + "toolCalls": [ + {"displayName": "first_tool"}, + {"displayName": "last_tool"}, + ], + } + result = cls._extract_gemini_action(msg) + assert "last_tool" in result + + def test_gemini_with_tool_calls_name_fallback(self): + """Gemini tool call should fall back to name if no displayName.""" + mod, _, _, _, _ = _import_filesystem_handler() + cls = mod.MonitoringFileHandler + msg = {"type": "gemini", "toolCalls": [{"name": "my_tool"}]} + result = cls._extract_gemini_action(msg) + assert "my_tool" in result + + def test_gemini_thinking(self): + """Gemini message with thoughts should return Thinking.""" + mod, _, _, _, _ = _import_filesystem_handler() + cls = mod.MonitoringFileHandler + msg = { + "type": "gemini", + "thoughts": "some thinking", + "content": [], + } + result = cls._extract_gemini_action(msg) + assert "Thinking" in result + + def test_gemini_text_content(self): + """Gemini message with text content should return text snippet.""" + mod, _, _, _, _ = _import_filesystem_handler() + cls = mod.MonitoringFileHandler + msg = { + "type": "gemini", + "content": [{"text": "Hello from Gemini"}], + } + result = cls._extract_gemini_action(msg) + assert "Hello from Gemini" in result + + +# ============================================= +# EXTRACT TEXT FROM CONTENT TESTS +# ============================================= + + +class TestExtractTextFromContent: + """Tests for _extract_text_from_content static method.""" + + def test_string_content(self): + """String content should return the text.""" + mod, _, _, _, _ = _import_filesystem_handler() + cls = mod.MonitoringFileHandler + result = cls._extract_text_from_content("Hello world") + assert "Hello world" in result + + def test_list_content_with_text(self): + """List content with text dict should return the text.""" + mod, _, _, _, _ = _import_filesystem_handler() + cls = mod.MonitoringFileHandler + result = cls._extract_text_from_content([{"text": "some text"}]) + assert "some text" in result + + def test_empty_string(self): + """Empty string should return default.""" + mod, _, _, _, _ = _import_filesystem_handler() + cls = mod.MonitoringFileHandler + result = cls._extract_text_from_content("") + assert "Agent response" in result + + def test_empty_list(self): + """Empty list should return default.""" + mod, _, _, _, _ = _import_filesystem_handler() + cls = mod.MonitoringFileHandler + result = cls._extract_text_from_content([]) + assert "Agent response" in result + + def test_list_with_empty_text(self): + """List with empty text dict should return default.""" + mod, _, _, _, _ = _import_filesystem_handler() + cls = mod.MonitoringFileHandler + result = cls._extract_text_from_content([{"text": " "}]) + assert "Agent response" in result + + +# ============================================= +# PARSE GEMINI ACTIVITY TESTS +# ============================================= + + +class TestParseGeminiActivity: + """Tests for _parse_gemini_activity.""" + + def test_parses_new_messages(self): + """Should parse new messages and emit agent event.""" + mod, _, _, _, queue = _import_filesystem_handler() + handler = _make_handler(mod, queue=queue) + session_data = json.dumps( + { + "messages": [ + {"type": "user"}, + { + "type": "gemini", + "content": [{"text": "Hello"}], + }, + ] + } + ) + with patch("builtins.open", _mopen(read_data=session_data)): + result = handler._parse_gemini_activity("/fake/session.json", "GEMINI") + assert result is True + queue.enqueue.assert_called() + + def test_no_new_messages(self): + """Should return True without emitting when no new messages.""" + mod, _, _, _, queue = _import_filesystem_handler() + handler = _make_handler(mod, queue=queue) + handler._jsonl_positions["/fake/session.json"] = 2 + session_data = json.dumps( + { + "messages": [ + {"type": "user"}, + {"type": "gemini", "content": "hi"}, + ] + } + ) + with patch("builtins.open", _mopen(read_data=session_data)): + result = handler._parse_gemini_activity("/fake/session.json", "GEMINI") + assert result is True + queue.enqueue.assert_not_called() + + def test_deduplicates_action(self): + """Should not emit duplicate actions.""" + mod, _, _, _, queue = _import_filesystem_handler() + handler = _make_handler(mod, queue=queue) + handler._last_agent_action["/fake/session.json"] = "User message" + session_data = json.dumps({"messages": [{"type": "user"}]}) + with patch("builtins.open", _mopen(read_data=session_data)): + result = handler._parse_gemini_activity("/fake/session.json", "GEMINI") + assert result is True + + def test_extracts_model(self): + """Should extract model from gemini messages.""" + mod, _, _, _, queue = _import_filesystem_handler() + handler = _make_handler(mod, queue=queue) + session_data = json.dumps( + { + "messages": [ + { + "type": "gemini", + "model": "gemini-3-flash-preview", + "content": "hi", + } + ] + } + ) + with patch("builtins.open", _mopen(read_data=session_data)): + handler._parse_gemini_activity("/fake/session.json", "GEMINI") + assert "gemini" in handler._session_models.get("/fake/session.json", "") + + def test_json_error_returns_false(self): + """Should return False on JSON decode error.""" + mod, _, _, _, queue = _import_filesystem_handler() + handler = _make_handler(mod, queue=queue) + mock_path = MagicMock() + mock_path.name = "session.json" + with patch("builtins.open", _mopen(read_data="not json at all")): + result = handler._parse_gemini_activity(mock_path, "GEMINI") + assert result is False + + def test_os_error_returns_false(self): + """Should return False on OSError.""" + mod, _, _, _, queue = _import_filesystem_handler() + handler = _make_handler(mod, queue=queue) + mock_path = MagicMock() + mock_path.name = "session.json" + with patch("builtins.open", side_effect=OSError("fail")): + result = handler._parse_gemini_activity(mock_path, "GEMINI") + assert result is False + + def test_no_queue_still_returns_true(self): + """Should return True even when no queue.""" + mod, _, _, _, _ = _import_filesystem_handler() + handler = _make_handler(mod, queue=None) + session_data = json.dumps( + { + "messages": [ + { + "type": "gemini", + "content": [{"text": "hello"}], + } + ] + } + ) + with patch("builtins.open", _mopen(read_data=session_data)): + result = handler._parse_gemini_activity("/fake/session.json", "GEMINI") + assert result is True + + def test_skips_none_actions(self): + """Should skip messages that produce None actions.""" + mod, _, _, _, queue = _import_filesystem_handler() + handler = _make_handler(mod, queue=queue) + session_data = json.dumps({"messages": [{"type": "system"}]}) + with patch("builtins.open", _mopen(read_data=session_data)): + result = handler._parse_gemini_activity("/fake/session.json", "GEMINI") + assert result is True + queue.enqueue.assert_not_called() + + +# ============================================= +# READ NEW JSONL LINES TESTS +# ============================================= + + +class TestReadNewJsonlLines: + """Tests for _read_new_jsonl_lines.""" + + def test_reads_new_data(self): + """Should read new lines since last position.""" + mod, _, _, _, _ = _import_filesystem_handler() + handler = _make_handler(mod) + mock_path = MagicMock() + mock_path.stat.return_value.st_size = 50 + data = '{"type": "test"}\n' + with patch("builtins.open", _mopen(read_data=data)): + result = handler._read_new_jsonl_lines(mock_path, "pk") + assert result is not None + assert len(result) == 1 + + def test_no_new_data(self): + """Should return None when no new data.""" + mod, _, _, _, _ = _import_filesystem_handler() + handler = _make_handler(mod) + handler._jsonl_positions["pk"] = 100 + mock_path = MagicMock() + mock_path.stat.return_value.st_size = 100 + result = handler._read_new_jsonl_lines(mock_path, "pk") + assert result is None + + def test_file_truncated_resets_position(self): + """Should reset position when file shrinks (truncation).""" + mod, _, _, _, _ = _import_filesystem_handler() + handler = _make_handler(mod) + handler._jsonl_positions["pk"] = 200 + mock_path = MagicMock() + mock_path.stat.return_value.st_size = 50 + data = '{"type": "new"}\n' + with patch("builtins.open", _mopen(read_data=data)): + result = handler._read_new_jsonl_lines(mock_path, "pk") + assert result is not None + + def test_empty_new_data(self): + """Should return None when new data is all whitespace.""" + mod, _, _, _, _ = _import_filesystem_handler() + handler = _make_handler(mod) + mock_path = MagicMock() + mock_path.stat.return_value.st_size = 50 + with patch("builtins.open", _mopen(read_data=" \n \n")): + result = handler._read_new_jsonl_lines(mock_path, "pk") + assert result is None + + +# ============================================= +# SAFE PARSE JSON TESTS +# ============================================= + + +class TestSafeParseJson: + """Tests for _safe_parse_json.""" + + def test_valid_json(self): + """Should parse valid JSON.""" + mod, _, _, _, _ = _import_filesystem_handler() + handler = _make_handler(mod) + result = handler._safe_parse_json('{"key": "value"}', "test") + assert result == {"key": "value"} + + def test_invalid_json(self): + """Should return None for invalid JSON.""" + mod, _, _, _, _ = _import_filesystem_handler() + handler = _make_handler(mod) + result = handler._safe_parse_json("not json", "test") + assert result is None + + +# ============================================= +# EMIT AGENT EVENT TESTS +# ============================================= + + +class TestEmitAgentEvent: + """Tests for _emit_agent_event.""" + + def test_emits_event(self): + """Should create and enqueue an agent event.""" + mod, _, _, _, queue = _import_filesystem_handler() + handler = _make_handler(mod, queue=queue) + result = handler._emit_agent_event("pk", "PRAX", "Testing") + assert result is True + queue.enqueue.assert_called_once() + assert handler._last_agent_action["pk"] == "Testing" + + def test_deduplicates(self): + """Should not emit duplicate actions.""" + mod, _, _, _, queue = _import_filesystem_handler() + handler = _make_handler(mod, queue=queue) + handler._last_agent_action["pk"] = "Same action" + result = handler._emit_agent_event("pk", "PRAX", "Same action") + assert result is True + queue.enqueue.assert_not_called() + + def test_no_queue(self): + """Should work without a queue (still records action).""" + mod, _, _, _, _ = _import_filesystem_handler() + handler = _make_handler(mod, queue=None) + result = handler._emit_agent_event("pk", "PRAX", "Action") + assert result is True + assert handler._last_agent_action["pk"] == "Action" + + def test_tags_branch_with_model(self): + """Should append model tag to branch.""" + mod, mock_eq, _, _, queue = _import_filesystem_handler() + handler = _make_handler(mod, queue=queue) + handler._session_models["pk"] = "opus" + handler._emit_agent_event("pk", "PRAX", "Testing") + call_kwargs = mock_eq.MonitoringEvent.call_args + assert "PRAX/opus" in str(call_kwargs) + + +# ============================================= +# EXTRACT CODEX MODEL TESTS +# ============================================= + + +class TestExtractCodexModel: + """Tests for _extract_codex_model.""" + + def test_extracts_model_from_turn_context(self): + """Should extract model from turn_context entry.""" + mod, _, _, _, _ = _import_filesystem_handler() + handler = _make_handler(mod) + entry = { + "type": "turn_context", + "payload": {"model": "gpt-4o"}, + } + handler._extract_codex_model(entry, "pk") + assert handler._session_models["pk"] == "gpt-4o" + + def test_ignores_non_turn_context(self): + """Should not extract model from non-turn_context entries.""" + mod, _, _, _, _ = _import_filesystem_handler() + handler = _make_handler(mod) + entry = { + "type": "event_msg", + "payload": {"model": "gpt-4o"}, + } + handler._extract_codex_model(entry, "pk") + assert "pk" not in handler._session_models + + def test_ignores_empty_model(self): + """Should not store empty model string.""" + mod, _, _, _, _ = _import_filesystem_handler() + handler = _make_handler(mod) + entry = {"type": "turn_context", "payload": {"model": ""}} + handler._extract_codex_model(entry, "pk") + assert "pk" not in handler._session_models + + +# ============================================= +# PARSE CODEX ACTIVITY TESTS +# ============================================= + + +class TestParseCodexActivity: + """Tests for _parse_codex_activity.""" + + def test_parses_lines_and_emits(self): + """Should parse new JSONL lines and emit agent event.""" + mod, _, _, _, queue = _import_filesystem_handler() + handler = _make_handler(mod, queue=queue) + mock_path = MagicMock() + mock_path.stat.return_value.st_size = 100 + mock_path.__str__ = MagicMock(return_value="/fake/codex.jsonl") + mock_path.name = "codex.jsonl" + line = json.dumps( + { + "type": "event_msg", + "payload": {"type": "user_message"}, + } + ) + with patch("builtins.open", _mopen(read_data=line + "\n")): + result = handler._parse_codex_activity(mock_path, "CODEX") + assert result is True + queue.enqueue.assert_called() + + def test_no_new_lines(self): + """Should return True when no new lines.""" + mod, _, _, _, queue = _import_filesystem_handler() + handler = _make_handler(mod, queue=queue) + mock_path = MagicMock() + mock_path.stat.return_value.st_size = 50 + mock_path.__str__ = MagicMock(return_value="/fake/codex.jsonl") + handler._jsonl_positions["/fake/codex.jsonl"] = 50 + result = handler._parse_codex_activity(mock_path, "CODEX") + assert result is True + + def test_malformed_json_skipped(self): + """Should skip malformed JSON lines.""" + mod, _, _, _, queue = _import_filesystem_handler() + handler = _make_handler(mod, queue=queue) + mock_path = MagicMock() + mock_path.stat.return_value.st_size = 100 + mock_path.__str__ = MagicMock(return_value="/fake/codex.jsonl") + mock_path.name = "codex.jsonl" + with patch("builtins.open", _mopen(read_data="not json\n")): + result = handler._parse_codex_activity(mock_path, "CODEX") + assert result is True + + def test_exception_returns_false(self): + """Should return False on unexpected exception.""" + mod, _, _, _, queue = _import_filesystem_handler() + handler = _make_handler(mod, queue=queue) + mock_path = MagicMock() + mock_path.stat.side_effect = Exception("boom") + mock_path.__str__ = MagicMock(return_value="/fake/codex.jsonl") + mock_path.name = "codex.jsonl" + result = handler._parse_codex_activity(mock_path, "CODEX") + assert result is False + + def test_extracts_model_from_turn_context(self): + """Should extract model from turn_context entries.""" + mod, _, _, _, queue = _import_filesystem_handler() + handler = _make_handler(mod, queue=queue) + mock_path = MagicMock() + mock_path.stat.return_value.st_size = 200 + mock_path.__str__ = MagicMock(return_value="/fake/codex.jsonl") + mock_path.name = "codex.jsonl" + lines = "\n".join( + [ + json.dumps( + { + "type": "event_msg", + "payload": {"type": "user_message"}, + } + ), + json.dumps( + { + "type": "turn_context", + "payload": {"model": "o4-mini"}, + } + ), + ] + ) + with patch("builtins.open", _mopen(read_data=lines + "\n")): + handler._parse_codex_activity(mock_path, "CODEX") + assert handler._session_models.get("/fake/codex.jsonl") == "o4-mini" + + def test_no_actionable_lines(self): + """Should return True when all lines produce no action.""" + mod, _, _, _, queue = _import_filesystem_handler() + handler = _make_handler(mod, queue=queue) + mock_path = MagicMock() + mock_path.stat.return_value.st_size = 100 + mock_path.__str__ = MagicMock(return_value="/fake/codex.jsonl") + mock_path.name = "codex.jsonl" + line = json.dumps({"type": "session_meta", "payload": {}}) + with patch("builtins.open", _mopen(read_data=line + "\n")): + result = handler._parse_codex_activity(mock_path, "CODEX") + assert result is True + queue.enqueue.assert_not_called() + + +# ============================================= +# PARSE AGENT ACTIVITY (CLAUDE CODE) TESTS +# ============================================= + + +class TestParseAgentActivity: + """Tests for _parse_agent_activity (Claude Code JSONL).""" + + def test_parses_and_emits_event(self): + """Should parse JSONL and emit agent event.""" + mod, _, _, _, queue = _import_filesystem_handler() + handler = _make_handler(mod, queue=queue) + mock_path = MagicMock() + mock_path.stat.return_value.st_size = 200 + mock_path.__str__ = MagicMock(return_value="/fake/claude.jsonl") + mock_path.name = "claude.jsonl" + entry = { + "type": "assistant", + "message": {"content": [{"type": "thinking"}]}, + } + with patch("builtins.open", _mopen(read_data=json.dumps(entry) + "\n")): + result = handler._parse_agent_activity(mock_path, "PRAX") + assert result is True + queue.enqueue.assert_called() + + def test_no_new_data(self): + """Should return True when no new data.""" + mod, _, _, _, queue = _import_filesystem_handler() + handler = _make_handler(mod, queue=queue) + mock_path = MagicMock() + mock_path.stat.return_value.st_size = 50 + mock_path.__str__ = MagicMock(return_value="/fake/claude.jsonl") + handler._jsonl_positions["/fake/claude.jsonl"] = 50 + result = handler._parse_agent_activity(mock_path, "PRAX") + assert result is True + + def test_file_truncated(self): + """Should reset position when file shrinks.""" + mod, _, _, _, queue = _import_filesystem_handler() + handler = _make_handler(mod, queue=queue) + handler._jsonl_positions["/fake/claude.jsonl"] = 1000 + mock_path = MagicMock() + mock_path.stat.return_value.st_size = 100 + mock_path.__str__ = MagicMock(return_value="/fake/claude.jsonl") + mock_path.name = "claude.jsonl" + entry = {"type": "user", "message": {"content": "hello"}} + with patch("builtins.open", _mopen(read_data=json.dumps(entry) + "\n")): + result = handler._parse_agent_activity(mock_path, "PRAX") + assert result is True + + def test_malformed_line_skipped(self): + """Should skip malformed JSON lines.""" + mod, _, _, _, queue = _import_filesystem_handler() + handler = _make_handler(mod, queue=queue) + mock_path = MagicMock() + mock_path.stat.return_value.st_size = 200 + mock_path.__str__ = MagicMock(return_value="/fake/claude.jsonl") + mock_path.name = "claude.jsonl" + good_entry = json.dumps({"type": "user", "message": {"content": "hi"}}) + data = "not json\n" + good_entry + "\n" + with patch("builtins.open", _mopen(read_data=data)): + result = handler._parse_agent_activity(mock_path, "PRAX") + assert result is True + queue.enqueue.assert_called() + + def test_deduplicates(self): + """Should not emit duplicate actions.""" + mod, _, _, _, queue = _import_filesystem_handler() + handler = _make_handler(mod, queue=queue) + handler._last_agent_action["/fake/claude.jsonl"] = "User message" + mock_path = MagicMock() + mock_path.stat.return_value.st_size = 100 + mock_path.__str__ = MagicMock(return_value="/fake/claude.jsonl") + mock_path.name = "claude.jsonl" + entry = {"type": "user", "message": {"content": "hello"}} + with patch("builtins.open", _mopen(read_data=json.dumps(entry) + "\n")): + result = handler._parse_agent_activity(mock_path, "PRAX") + assert result is True + + def test_extracts_model(self): + """Should extract model from entries.""" + mod, _, _, _, queue = _import_filesystem_handler() + handler = _make_handler(mod, queue=queue) + mock_path = MagicMock() + mock_path.stat.return_value.st_size = 200 + mock_path.__str__ = MagicMock(return_value="/fake/claude.jsonl") + mock_path.name = "claude.jsonl" + entry = { + "type": "assistant", + "message": { + "model": "claude-opus-4-20250514", + "content": [{"type": "thinking"}], + }, + } + with patch("builtins.open", _mopen(read_data=json.dumps(entry) + "\n")): + handler._parse_agent_activity(mock_path, "PRAX") + assert handler._session_models.get("/fake/claude.jsonl") == "opus" + + def test_exception_returns_false(self): + """Should return False on unexpected exception.""" + mod, _, _, _, queue = _import_filesystem_handler() + handler = _make_handler(mod, queue=queue) + mock_path = MagicMock() + mock_path.stat.side_effect = Exception("boom") + mock_path.__str__ = MagicMock(return_value="/fake/claude.jsonl") + mock_path.name = "claude.jsonl" + result = handler._parse_agent_activity(mock_path, "PRAX") + assert result is False + + def test_empty_lines_after_split(self): + """Should return True when all lines are empty after strip.""" + mod, _, _, _, queue = _import_filesystem_handler() + handler = _make_handler(mod, queue=queue) + mock_path = MagicMock() + mock_path.stat.return_value.st_size = 100 + mock_path.__str__ = MagicMock(return_value="/fake/claude.jsonl") + mock_path.name = "claude.jsonl" + with patch("builtins.open", _mopen(read_data=" \n \n")): + result = handler._parse_agent_activity(mock_path, "PRAX") + assert result is True + + def test_no_actionable_entries(self): + """Should return True when entries produce no actions.""" + mod, _, _, _, queue = _import_filesystem_handler() + handler = _make_handler(mod, queue=queue) + mock_path = MagicMock() + mock_path.stat.return_value.st_size = 100 + mock_path.__str__ = MagicMock(return_value="/fake/claude.jsonl") + mock_path.name = "claude.jsonl" + entry = {"type": "progress"} + with patch("builtins.open", _mopen(read_data=json.dumps(entry) + "\n")): + result = handler._parse_agent_activity(mock_path, "PRAX") + assert result is True + queue.enqueue.assert_not_called() + + def test_no_queue_still_returns_true(self): + """Should return True even with no queue.""" + mod, _, _, _, _ = _import_filesystem_handler() + handler = _make_handler(mod, queue=None) + mock_path = MagicMock() + mock_path.stat.return_value.st_size = 100 + mock_path.__str__ = MagicMock(return_value="/fake/claude.jsonl") + mock_path.name = "claude.jsonl" + entry = {"type": "user", "message": {"content": "hi"}} + with patch("builtins.open", _mopen(read_data=json.dumps(entry) + "\n")): + result = handler._parse_agent_activity(mock_path, "PRAX") + assert result is True + + +# ============================================= +# CHECK COMMAND INDICATOR TESTS +# ============================================= + + +class TestCheckCommandIndicator: + """Tests for _check_command_indicator.""" + + def test_emits_command_event(self): + """Should emit command event for matching file.""" + mod, _, _, _, queue = _import_filesystem_handler() + cif = {"status.json": "status command"} + handler = _make_handler(mod, queue=queue, command_indicator_files=cif) + file_path = MagicMock() + file_path.name = "status.json" + handler._check_command_indicator("modified", file_path, "PRAX") + queue.enqueue.assert_called_once() + + def test_skips_non_modified(self): + """Should skip non-modified events.""" + mod, _, _, _, queue = _import_filesystem_handler() + cif = {"status.json": "status command"} + handler = _make_handler(mod, queue=queue, command_indicator_files=cif) + file_path = MagicMock() + file_path.name = "status.json" + handler._check_command_indicator("created", file_path, "PRAX") + queue.enqueue.assert_not_called() + + def test_skips_non_indicator_file(self): + """Should skip files not in indicator list.""" + mod, _, _, _, queue = _import_filesystem_handler() + cif = {"status.json": "status command"} + handler = _make_handler(mod, queue=queue, command_indicator_files=cif) + file_path = MagicMock() + file_path.name = "other.json" + handler._check_command_indicator("modified", file_path, "PRAX") + queue.enqueue.assert_not_called() + + def test_deduplicates_command(self): + """Should not emit duplicate commands.""" + mod, _, _, _, queue = _import_filesystem_handler() + cif = {"status.json": "status command"} + handler = _make_handler(mod, queue=queue, command_indicator_files=cif) + file_path = MagicMock() + file_path.name = "status.json" + handler._check_command_indicator("modified", file_path, "PRAX") + queue.enqueue.reset_mock() + handler._check_command_indicator("modified", file_path, "PRAX") + queue.enqueue.assert_not_called() + + def test_no_queue(self): + """Should not crash without a queue.""" + mod, _, _, _, _ = _import_filesystem_handler() + cif = {"status.json": "status command"} + handler = _make_handler(mod, queue=None, command_indicator_files=cif) + file_path = MagicMock() + file_path.name = "status.json" + handler._check_command_indicator("modified", file_path, "PRAX") + + +# ============================================= +# HANDLE EVENT TESTS +# ============================================= + + +class TestHandleEvent: + """Tests for _handle_event.""" + + def test_basic_file_event(self): + """Should create and enqueue a file event.""" + mod, _, mock_bd, mock_filters, queue = _import_filesystem_handler() + handler = _make_handler(mod, queue=queue) + mock_filters.should_monitor.return_value = True + mock_filters.get_priority.return_value = "info" + mock_bd.detect_branch_from_path.return_value = "PRAX" + handler._handle_event( + "modified", + "/home/user/src/aipass/prax/apps/test.py", + ) + queue.enqueue.assert_called() + + def test_skips_unmonitored_files(self): + """Should skip files that should_monitor returns False for.""" + mod, _, _, mock_filters, queue = _import_filesystem_handler() + handler = _make_handler(mod, queue=queue) + mock_filters.should_monitor.return_value = False + handler._handle_event("modified", "/tmp/ignored.txt") + queue.enqueue.assert_not_called() + + def test_claude_code_jsonl(self): + """Should parse Claude Code JSONL files.""" + mod, _, mock_bd, mock_filters, queue = _import_filesystem_handler() + handler = _make_handler(mod, queue=queue) + mock_filters.should_monitor.return_value = True + mock_bd.detect_branch_from_path.return_value = "PRAX" + with patch.object(handler, "_parse_agent_activity", return_value=True) as mock_parse: + handler._handle_event( + "modified", + "/home/user/.claude/projects/abc/session.jsonl", + ) + mock_parse.assert_called_once() + + def test_claude_code_subagent(self): + """Should tag subagent JSONL files with agent suffix.""" + mod, _, mock_bd, mock_filters, queue = _import_filesystem_handler() + handler = _make_handler(mod, queue=queue) + mock_filters.should_monitor.return_value = True + mock_bd.detect_branch_from_path.return_value = "PRAX" + with patch.object(handler, "_parse_agent_activity", return_value=True) as mock_parse: + handler._handle_event( + "modified", + "/home/user/.claude/projects/abc/subagents/session.jsonl", + ) + call_args = mock_parse.call_args + assert "PRAX agent" in str(call_args) + + def test_codex_jsonl(self): + """Should parse Codex JSONL files.""" + mod, _, mock_bd, mock_filters, queue = _import_filesystem_handler() + handler = _make_handler(mod, queue=queue) + mock_filters.should_monitor.return_value = True + mock_bd.detect_branch_from_path.return_value = "CODEX" + with patch.object(handler, "_get_codex_branch", return_value="PRAX"): + with patch.object( + handler, + "_parse_codex_activity", + return_value=True, + ) as mock_parse: + handler._handle_event( + "modified", + "/home/user/.codex/sessions/session.jsonl", + ) + mock_parse.assert_called_once() + + def test_gemini_json(self): + """Should parse Gemini JSON session files.""" + mod, _, mock_bd, mock_filters, queue = _import_filesystem_handler() + handler = _make_handler(mod, queue=queue) + mock_filters.should_monitor.return_value = True + mock_bd.detect_branch_from_path.return_value = "GEMINI" + with patch.object(handler, "_get_gemini_branch", return_value="PRAX"): + with patch.object( + handler, + "_parse_gemini_activity", + return_value=True, + ) as mock_parse: + handler._handle_event( + "modified", + "/home/user/.gemini/tmp/slug/chats/session.json", + ) + mock_parse.assert_called_once() + + def test_exception_caught(self): + """Should catch exceptions and log error.""" + mod, _, _, mock_filters, queue = _import_filesystem_handler() + handler = _make_handler(mod, queue=queue) + mock_filters.should_monitor.side_effect = Exception("boom") + # Should not raise + handler._handle_event("modified", "/some/file.py") + + def test_no_queue(self): + """Should not crash without a queue.""" + mod, _, mock_bd, mock_filters, _ = _import_filesystem_handler() + handler = _make_handler(mod, queue=None) + mock_filters.should_monitor.return_value = True + mock_filters.get_priority.return_value = "info" + mock_bd.detect_branch_from_path.return_value = "PRAX" + handler._handle_event("modified", "/home/user/src/aipass/prax/test.py") + + def test_priority_levels_mapped(self): + """Should map priority levels correctly.""" + mod, mock_eq, mock_bd, mock_filters, queue = _import_filesystem_handler() + handler = _make_handler(mod, queue=queue) + mock_filters.should_monitor.return_value = True + mock_filters.get_priority.return_value = "error" + mock_bd.detect_branch_from_path.return_value = "PRAX" + handler._handle_event("modified", "/home/user/src/aipass/prax/test.py") + call_kwargs = mock_eq.MonitoringEvent.call_args + assert "error" in str(call_kwargs) + + def test_unknown_priority_defaults_to_info(self): + """Should default to info for unknown priority levels.""" + mod, mock_eq, mock_bd, mock_filters, queue = _import_filesystem_handler() + handler = _make_handler(mod, queue=queue) + mock_filters.should_monitor.return_value = True + mock_filters.get_priority.return_value = "custom_level" + mock_bd.detect_branch_from_path.return_value = "PRAX" + handler._handle_event("modified", "/home/user/src/aipass/prax/test.py") + call_kwargs = mock_eq.MonitoringEvent.call_args + assert "info" in str(call_kwargs) + + +# ============================================= +# BUILD DISPLAY NAME TESTS +# ============================================= + + +class TestBuildDisplayName: + """Tests for _build_display_name static method.""" + + def test_apps_prefix(self): + """Should return path from apps onwards.""" + mod, _, _, _, _ = _import_filesystem_handler() + cls = mod.MonitoringFileHandler + result = cls._build_display_name(Path("/home/user/src/aipass/prax/apps/handlers/test.py")) + assert result == "apps/handlers/test.py" + + def test_handlers_prefix(self): + """Should return path from handlers onwards.""" + mod, _, _, _, _ = _import_filesystem_handler() + cls = mod.MonitoringFileHandler + result = cls._build_display_name(Path("/home/user/handlers/monitoring/file.py")) + assert result == "handlers/monitoring/file.py" + + def test_modules_prefix(self): + """Should return path from modules onwards.""" + mod, _, _, _, _ = _import_filesystem_handler() + cls = mod.MonitoringFileHandler + result = cls._build_display_name(Path("/home/user/modules/logger.py")) + assert result == "modules/logger.py" + + def test_docs_prefix(self): + """Should return path from docs onwards.""" + mod, _, _, _, _ = _import_filesystem_handler() + cls = mod.MonitoringFileHandler + result = cls._build_display_name(Path("/home/user/docs/readme.md")) + assert result == "docs/readme.md" + + def test_templates_prefix(self): + """Should return path from templates onwards.""" + mod, _, _, _, _ = _import_filesystem_handler() + cls = mod.MonitoringFileHandler + result = cls._build_display_name(Path("/home/user/templates/base.html")) + assert result == "templates/base.html" + + def test_no_known_prefix(self): + """Should return filename when no known prefix found.""" + mod, _, _, _, _ = _import_filesystem_handler() + cls = mod.MonitoringFileHandler + result = cls._build_display_name(Path("/home/user/random/file.py")) + assert result == "file.py" diff --git a/src/aipass/prax/tests/test_log_watcher.py b/src/aipass/prax/tests/test_log_watcher.py index b5802e1c..548dac07 100644 --- a/src/aipass/prax/tests/test_log_watcher.py +++ b/src/aipass/prax/tests/test_log_watcher.py @@ -459,3 +459,600 @@ class TestInitializePositions: assert str(log_file) in watcher.log_positions assert str(txt_file) not in watcher.log_positions + + +# ============================================================================ +# ADDITIONAL COVERAGE TESTS +# ============================================================================ + + +class TestGenerateErrorHash: + """Test _generate_error_hash deduplication helper.""" + + def test_returns_8_char_hex(self): + """Hash should be an 8-character hexadecimal string.""" + mod = _import_log_watcher() + result = mod._generate_error_hash("mymodule", "something broke") + assert len(result) == 8 + assert all(c in "0123456789abcdef" for c in result) + + def test_same_input_same_hash(self): + """Identical module+message should produce identical hashes.""" + mod = _import_log_watcher() + h1 = mod._generate_error_hash("mod", "error msg") + h2 = mod._generate_error_hash("mod", "error msg") + assert h1 == h2 + + def test_different_input_different_hash(self): + """Different inputs should produce different hashes.""" + mod = _import_log_watcher() + h1 = mod._generate_error_hash("mod_a", "error one") + h2 = mod._generate_error_hash("mod_b", "error two") + assert h1 != h2 + + +class TestTriggerImportFallback: + """Test trigger import fallback when trigger module is unavailable.""" + + def test_has_trigger_flag_set(self): + """HAS_TRIGGER should be set based on trigger import availability.""" + mod = _import_log_watcher() + # With our mock setup, trigger is available + assert hasattr(mod, "HAS_TRIGGER") + + +class TestProcessLogLine: + """Test _process_log_line dispatching.""" + + def test_empty_line_skipped(self): + """Empty/whitespace lines should be skipped entirely.""" + mod = _import_log_watcher() + watcher, mock_queue = _make_watcher(mod) + + watcher._process_log_line("PRAX", " ", "/fake/file.log") + mock_queue.enqueue.assert_not_called() + + def test_command_line_emits_separator(self): + """Command lines should emit a command separator, not a log event.""" + mod = _import_log_watcher() + watcher, mock_queue = _make_watcher(mod) + + with ( + patch.object( + watcher, + "_extract_command_info", + return_value={"command": "test cmd", "caller": None, "target": None}, + ), + patch.object(watcher, "_emit_command_separator") as mock_sep, + patch.object(watcher, "_emit_log_event") as mock_log, + ): + watcher._process_log_line("PRAX", "some command line", "/f.log") + + mock_sep.assert_called_once() + mock_log.assert_not_called() + + def test_regular_line_emits_log_event(self): + """Non-command lines should emit a log event.""" + mod = _import_log_watcher() + watcher, mock_queue = _make_watcher(mod) + + with ( + patch.object(watcher, "_extract_command_info", return_value=None), + patch.object(watcher, "_emit_log_event") as mock_log, + ): + watcher._process_log_line("PRAX", "normal log line", "/f.log") + + mock_log.assert_called_once() + + +class TestReadNewContent: + """Test _read_new_content file tailing.""" + + def test_reads_new_content(self, tmp_path): + """Should read content added after last known position.""" + mod = _import_log_watcher() + watcher, _ = _make_watcher(mod) + + log_file = tmp_path / "test.log" + log_file.write_text("old line\n", encoding="utf-8") + old_size = log_file.stat().st_size + watcher.log_positions[str(log_file)] = old_size + + # Append new content + with log_file.open("a", encoding="utf-8") as f: + f.write("new line\n") + + result = watcher._read_new_content(str(log_file)) + assert result is not None + assert "new line" in result + + def test_returns_none_when_no_new_content(self, tmp_path): + """Should return None when file hasn't grown.""" + mod = _import_log_watcher() + watcher, _ = _make_watcher(mod) + + log_file = tmp_path / "test.log" + log_file.write_text("existing\n", encoding="utf-8") + watcher.log_positions[str(log_file)] = log_file.stat().st_size + + result = watcher._read_new_content(str(log_file)) + assert result is None + + def test_resets_position_on_truncation(self, tmp_path): + """Should reset position to 0 when file is smaller than last pos.""" + mod = _import_log_watcher() + watcher, _ = _make_watcher(mod) + + log_file = tmp_path / "test.log" + log_file.write_text("short\n", encoding="utf-8") + watcher.log_positions[str(log_file)] = 99999 # Way past end + + result = watcher._read_new_content(str(log_file)) + assert result is not None + assert "short" in result + + def test_returns_none_for_whitespace_only_content(self, tmp_path): + """Should return None when new content is only whitespace.""" + mod = _import_log_watcher() + watcher, _ = _make_watcher(mod) + + log_file = tmp_path / "test.log" + log_file.write_text("initial\n", encoding="utf-8") + old_size = log_file.stat().st_size + watcher.log_positions[str(log_file)] = old_size + + # Append only whitespace + with log_file.open("a", encoding="utf-8") as f: + f.write(" \n \n") + + result = watcher._read_new_content(str(log_file)) + assert result is None + + +class TestOnModified: + """Test on_modified event handler.""" + + def test_ignores_directory_events(self): + """Should ignore directory modification events.""" + mod = _import_log_watcher() + watcher, mock_queue = _make_watcher(mod) + + event = MagicMock() + event.is_directory = True + + watcher.on_modified(event) + mock_queue.enqueue.assert_not_called() + + def test_ignores_non_log_files(self): + """Should ignore files that don't end with .log.""" + mod = _import_log_watcher() + watcher, mock_queue = _make_watcher(mod) + + event = MagicMock() + event.is_directory = False + event.src_path = "/fake/logs/system/test.txt" + + watcher.on_modified(event) + mock_queue.enqueue.assert_not_called() + + def test_ignores_logs_outside_system_dir(self): + """Should ignore log files outside the system logs directory.""" + mod = _import_log_watcher() + watcher, mock_queue = _make_watcher(mod) + + event = MagicMock() + event.is_directory = False + event.src_path = "/other/dir/test.log" + + with patch.object(mod, "get_system_logs_dir", return_value=Path("/fake/logs/system")): + watcher.on_modified(event) + + mock_queue.enqueue.assert_not_called() + + def test_processes_valid_log_file(self, tmp_path): + """Should process a valid log file in the system logs directory.""" + mod = _import_log_watcher() + watcher, mock_queue = _make_watcher(mod) + + log_file = tmp_path / "prax.log" + log_file.write_text("line one\n", encoding="utf-8") + watcher.log_positions[str(log_file)] = 0 + + event = MagicMock() + event.is_directory = False + event.src_path = str(log_file) + + with ( + patch.object(mod, "get_system_logs_dir", return_value=tmp_path), + patch.object(mod, "detect_branch_from_log", return_value="PRAX"), + patch.object(watcher, "_process_log_line") as mock_process, + ): + watcher.on_modified(event) + + mock_process.assert_called() + + def test_handles_read_exception(self): + """Should catch exceptions during log reading.""" + mod = _import_log_watcher() + watcher, mock_queue = _make_watcher(mod) + + event = MagicMock() + event.is_directory = False + event.src_path = "/fake/logs/system/crash.log" + + with ( + patch.object(mod, "get_system_logs_dir", return_value=Path("/fake/logs/system")), + patch.object(watcher, "_read_new_content", side_effect=OSError("disk error")), + ): + # Should not raise + watcher.on_modified(event) + + mock_queue.enqueue.assert_not_called() + + def test_skips_when_no_new_content(self): + """Should skip processing when _read_new_content returns None.""" + mod = _import_log_watcher() + watcher, mock_queue = _make_watcher(mod) + + event = MagicMock() + event.is_directory = False + event.src_path = "/fake/logs/system/empty.log" + + with ( + patch.object(mod, "get_system_logs_dir", return_value=Path("/fake/logs/system")), + patch.object(watcher, "_read_new_content", return_value=None), + ): + watcher.on_modified(event) + + mock_queue.enqueue.assert_not_called() + + +class TestShouldDisplayLog: + """Test _should_display_log filter.""" + + def test_always_returns_true(self): + """Current implementation shows all log lines.""" + mod = _import_log_watcher() + watcher, _ = _make_watcher(mod) + assert watcher._should_display_log("any line") is True + assert watcher._should_display_log("") is True + + +class TestMatchFlowCommand: + """Test _match_flow_command pattern matching.""" + + def test_loaded_module_command(self): + """Should detect flow command from 'Loaded module' line.""" + mod = _import_log_watcher() + watcher, _ = _make_watcher(mod) + # Clear any prior flow commands to avoid dedup + watcher.last_command_per_branch.clear() + + result = watcher._match_flow_command("Loaded module: flow_planner") + assert result is not None + assert result["command"] == "flow command" + + def test_loaded_module_suppressed_when_duplicate(self): + """Should suppress duplicate 'Loaded module' commands.""" + mod = _import_log_watcher() + watcher, _ = _make_watcher(mod) + watcher.last_command_per_branch["FLOW"] = "FLOW:flow command" + + result = watcher._match_flow_command("Loaded module: flow_planner") + assert result is None + + def test_non_flow_line_returns_none(self): + """Should return None for unrecognized flow lines.""" + mod = _import_log_watcher() + watcher, _ = _make_watcher(mod) + result = watcher._match_flow_command("Just a normal log line") + assert result is None + + +class TestExtractCommandInfoAdditional: + """Additional command extraction tests for uncovered patterns.""" + + def test_seedgo_checklist_running(self): + """Should detect seedgo checklist commands.""" + mod = _import_log_watcher() + watcher, _ = _make_watcher(mod) + line = "standards_checklist Running full standard check on prax" + result = watcher._extract_command_info(line) + assert result is not None + assert "seedgo checklist" in result["command"] + + def test_backup_versioned(self): + """Should detect backup versioned commands.""" + mod = _import_log_watcher() + watcher, _ = _make_watcher(mod) + line = "[backup] Starting versioned backup" + result = watcher._extract_command_info(line) + assert result is not None + assert "backup versioned" in result["command"] + + def test_backup_sync(self): + """Should detect backup sync commands.""" + mod = _import_log_watcher() + watcher, _ = _make_watcher(mod) + line = "[backup] Running sync operation" + result = watcher._extract_command_info(line) + assert result is not None + assert "backup sync" in result["command"] + + def test_memory_search(self): + """Should detect memory search commands.""" + mod = _import_log_watcher() + watcher, _ = _make_watcher(mod) + line = "[memory] Handling search query for branch status" + result = watcher._extract_command_info(line) + assert result is not None + assert "memory search" in result["command"] + + def test_trigger_triggered(self): + """Should detect trigger events with 'triggered' keyword.""" + mod = _import_log_watcher() + watcher, _ = _make_watcher(mod) + line = "[trigger] Rule triggered: error_threshold" + result = watcher._extract_command_info(line) + assert result is not None + assert "trigger fire" in result["command"] + + def test_ai_mail_send_without_target(self): + """Should handle ai_mail send without a parseable recipient.""" + mod = _import_log_watcher() + watcher, _ = _make_watcher(mod) + line = "[ai_mail] Sending broadcast message" + result = watcher._extract_command_info(line) + assert result is not None + assert "ai_mail send" in result["command"] + + def test_drone_started_without_bracket_prefix(self): + """Should detect 'Drone started with args' without [drone] prefix.""" + mod = _import_log_watcher() + watcher, _ = _make_watcher(mod) + line = "Drone started with args: ['audit', '@prax']" + result = watcher._extract_command_info(line) + assert result is not None + assert "drone" in result["command"] + assert "audit" in result["command"] + + +class TestMatchExecutingCommand: + """Test _match_executing_command pattern matching.""" + + def test_returns_none_when_no_cmd_match(self): + """Should return None when command pattern doesn't match.""" + mod = _import_log_watcher() + watcher, _ = _make_watcher(mod) + result = watcher._match_executing_command("Executing something weird") + assert result is None + + def test_extracts_caller_and_simplifies_path(self): + """Should extract caller and simplify aipass paths to @branch.""" + mod = _import_log_watcher() + watcher, _ = _make_watcher(mod) + line = "Executing command [CALLER:DRONE]: /path/to/aipass/seedgo/apps/audit.py @prax" + result = watcher._match_executing_command(line) + assert result is not None + assert result["caller"] == "DRONE" + assert result["target"] == "PRAX" + + def test_without_caller(self): + """Should work without CALLER tag.""" + mod = _import_log_watcher() + watcher, _ = _make_watcher(mod) + line = "Executing command: /path/to/aipass/flow run" + result = watcher._match_executing_command(line) + assert result is not None + assert result["caller"] is None + + +class TestExtractTargetFromCmd: + """Test _extract_target_from_cmd static method.""" + + def test_extracts_at_target(self): + """Should extract target from @branch pattern.""" + mod = _import_log_watcher() + result = mod.LogFileWatcher._extract_target_from_cmd("audit @prax") + assert result == "PRAX" + + def test_extracts_path_target(self): + """Should extract target from /aipass/branch pattern.""" + mod = _import_log_watcher() + result = mod.LogFileWatcher._extract_target_from_cmd("/path/to/aipass/seedgo/run.py") + assert result == "SEEDGO" + + def test_returns_none_when_no_target(self): + """Should return None when no target pattern matches.""" + mod = _import_log_watcher() + result = mod.LogFileWatcher._extract_target_from_cmd("plain command") + assert result is None + + +class TestEmitCommandSeparator: + """Test _emit_command_separator event emission.""" + + def test_dict_format(self): + """Should handle dict command_info format.""" + mod = _import_log_watcher() + watcher, mock_queue = _make_watcher(mod) + watcher.last_command_per_branch.clear() + + watcher._emit_command_separator("PRAX", {"command": "test cmd", "caller": "DRONE", "target": "FLOW"}) + mock_queue.enqueue.assert_called_once() + + def test_tuple_format(self): + """Should handle legacy tuple (command, caller) format.""" + mod = _import_log_watcher() + watcher, mock_queue = _make_watcher(mod) + watcher.last_command_per_branch.clear() + + watcher._emit_command_separator("PRAX", ("test cmd", "DRONE")) + mock_queue.enqueue.assert_called_once() + + def test_string_format(self): + """Should handle plain string command format.""" + mod = _import_log_watcher() + watcher, mock_queue = _make_watcher(mod) + watcher.last_command_per_branch.clear() + + watcher._emit_command_separator("PRAX", "test cmd") + mock_queue.enqueue.assert_called_once() + + def test_deduplication(self): + """Should skip duplicate consecutive commands for same branch.""" + mod = _import_log_watcher() + watcher, mock_queue = _make_watcher(mod) + watcher.last_command_per_branch.clear() + + cmd_info = {"command": "same cmd", "caller": None, "target": None} + watcher._emit_command_separator("PRAX", cmd_info) + watcher._emit_command_separator("PRAX", cmd_info) + + assert mock_queue.enqueue.call_count == 1 + + def test_target_stored_in_action(self): + """Should store target in action field when target is provided.""" + mod = _import_log_watcher() + watcher, mock_queue = _make_watcher(mod) + watcher.last_command_per_branch.clear() + + watcher._emit_command_separator("PRAX", {"command": "cmd", "caller": None, "target": "FLOW"}) + + enqueued_event = mock_queue.enqueue.call_args[0][0] + assert "FLOW" in enqueued_event.action + + +class TestEmitLogEvent: + """Test _emit_log_event event emission.""" + + def test_emits_log_event(self): + """Should create and enqueue a log monitoring event.""" + mod = _import_log_watcher() + watcher, mock_queue = _make_watcher(mod) + + watcher._emit_log_event("PRAX", "test message", "info") + mock_queue.enqueue.assert_called_once() + + def test_error_level_fires_trigger(self): + """Should fire trigger event for ERROR level logs.""" + mod = _import_log_watcher() + watcher, mock_queue = _make_watcher(mod) + + # Set up trigger mock + mock_trigger = MagicMock() + setattr(mod, "HAS_TRIGGER", True) + setattr(mod, "trigger", mock_trigger) + + watcher._emit_log_event( + "PRAX", + "[PRAX] 2025-01-01 | mymodule | ERROR | something broke", + "error", + "/fake/prax.log", + ) + + mock_trigger.fire.assert_called_once() + call_kwargs = mock_trigger.fire.call_args + assert call_kwargs[0][0] == "error_detected" + assert call_kwargs[1]["branch"] == "PRAX" + + def test_info_level_does_not_fire_trigger(self): + """Should not fire trigger for non-error levels.""" + mod = _import_log_watcher() + watcher, mock_queue = _make_watcher(mod) + + mock_trigger = MagicMock() + setattr(mod, "HAS_TRIGGER", True) + setattr(mod, "trigger", mock_trigger) + + watcher._emit_log_event("PRAX", "normal info", "info") + mock_trigger.fire.assert_not_called() + + def test_trigger_not_fired_when_unavailable(self): + """Should skip trigger when HAS_TRIGGER is False.""" + mod = _import_log_watcher() + watcher, mock_queue = _make_watcher(mod) + + setattr(mod, "HAS_TRIGGER", False) + setattr(mod, "trigger", None) + + # Should not raise + watcher._emit_log_event("PRAX", "error msg", "error") + mock_queue.enqueue.assert_called_once() + + def test_error_with_no_log_file_path(self): + """Should use 'unknown' for log_file when path not provided.""" + mod = _import_log_watcher() + watcher, mock_queue = _make_watcher(mod) + + mock_trigger = MagicMock() + setattr(mod, "HAS_TRIGGER", True) + setattr(mod, "trigger", mock_trigger) + + watcher._emit_log_event("PRAX", "error msg", "error") + + call_kwargs = mock_trigger.fire.call_args[1] + assert call_kwargs["log_file"] == "unknown" + + def test_error_extracts_module_name_from_pipe_format(self): + """Should extract module name from pipe-delimited log lines.""" + mod = _import_log_watcher() + watcher, mock_queue = _make_watcher(mod) + + mock_trigger = MagicMock() + setattr(mod, "HAS_TRIGGER", True) + setattr(mod, "trigger", mock_trigger) + + watcher._emit_log_event( + "PRAX", + "[PRAX] 2025-01-01 | mymod.handler | ERROR | crash", + "error", + "/fake/log.log", + ) + + call_kwargs = mock_trigger.fire.call_args[1] + assert call_kwargs["module_name"] == "mymod.handler" + + +class TestStartLogWatcherAdditional: + """Additional tests for start_log_watcher.""" + + def test_stops_existing_observer_before_starting(self): + """Should stop existing observer if already running.""" + mod = _import_log_watcher() + mock_queue = MagicMock() + mock_old_observer = MagicMock() + mock_old_observer.is_alive.return_value = True + setattr(mod, "_log_observer", mock_old_observer) + + mock_new_observer = MagicMock() + + with ( + patch.object(mod, "WatchdogObserver", return_value=mock_new_observer), + patch.object(mod, "get_system_logs_dir", return_value=Path("/fake/logs")), + patch.object(mod, "stop_log_watcher") as mock_stop, + ): + mod.start_log_watcher(mock_queue) + + mock_stop.assert_called_once() + + +class TestInitializePositionsAdditional: + """Additional tests for initialize_positions.""" + + def test_handles_stat_exception(self, tmp_path): + """Should handle exceptions when getting file size.""" + mod = _import_log_watcher() + watcher, _ = _make_watcher(mod) + + log_file = tmp_path / "bad.log" + log_file.write_text("content\n", encoding="utf-8") + + with ( + patch.object(mod, "get_system_logs_dir", return_value=tmp_path), + patch.object(Path, "stat", side_effect=OSError("stat failed")), + ): + # Should not raise + watcher.initialize_positions() + + assert str(log_file) not in watcher.log_positions diff --git a/src/aipass/prax/tests/test_monitor_module.py b/src/aipass/prax/tests/test_monitor_module.py index 98826643..27e4d0e2 100644 --- a/src/aipass/prax/tests/test_monitor_module.py +++ b/src/aipass/prax/tests/test_monitor_module.py @@ -11,6 +11,13 @@ Covers: - handle_command() dispatching (introspection, help, run, unknown subcommands) - _get_watch_directories() registry-based directory enumeration +- PID cache functions (_parse_lock_pid, _refresh_pid_cache, _get_pid_for_branch) +- Threading management (_start_threads, _stop_threads, _display_worker) +- Event rendering and emission (_render_event, _emit_watcher_event) +- Inotify error handling and observer fallback +- File/log watcher workers +- Interactive loop and command dispatch +- _print_status, _run_monitor orchestration """ import json @@ -18,6 +25,38 @@ import sys from unittest.mock import MagicMock, patch +# --------------------------------------------------------------------------- +# Shared helper: monitoring handler mock dict +# --------------------------------------------------------------------------- + +_MONITORING_MOCKS = { + "aipass.prax.apps.handlers.monitoring": MagicMock(), + "aipass.prax.apps.handlers.monitoring.event_queue": MagicMock(), + "aipass.prax.apps.handlers.monitoring.filesystem_handler": MagicMock(), + "aipass.prax.apps.handlers.monitoring.log_watcher": MagicMock(), + "aipass.prax.apps.handlers.monitoring.unified_stream": MagicMock(), + "aipass.prax.apps.handlers.monitoring.module_tracker": MagicMock(), + "aipass.prax.apps.handlers.monitoring.branch_detector": MagicMock(), + "aipass.prax.apps.handlers.monitoring.interactive_filter": MagicMock(), + "aipass.prax.apps.handlers.monitoring.monitoring_filters": MagicMock(), + "aipass.prax.apps.handlers.monitoring.file_watcher_integration": MagicMock(), +} + + +def _import_monitor(): + """Import (or reload) the monitor module with all handler mocks active.""" + # Create fresh mocks each call so state doesn't leak between tests + fresh_mocks = {k: MagicMock() for k in _MONITORING_MOCKS} + with patch.dict(sys.modules, fresh_mocks): + import importlib + + if "aipass.prax.apps.modules.monitor" in sys.modules: + mod = importlib.reload(sys.modules["aipass.prax.apps.modules.monitor"]) + else: + mod = importlib.import_module("aipass.prax.apps.modules.monitor") + return mod + + # --------------------------------------------------------------------------- # handle_command tests # --------------------------------------------------------------------------- @@ -26,40 +65,14 @@ from unittest.mock import MagicMock, patch class TestHandleCommand: """Test the top-level command router.""" - def _import_monitor(self): - """Import monitor module fresh (after conftest mocks are in place).""" - # Additional mocks for monitoring handler imports - with patch.dict( - sys.modules, - { - "aipass.prax.apps.handlers.monitoring": MagicMock(), - "aipass.prax.apps.handlers.monitoring.event_queue": MagicMock(), - "aipass.prax.apps.handlers.monitoring.filesystem_handler": MagicMock(), - "aipass.prax.apps.handlers.monitoring.log_watcher": MagicMock(), - "aipass.prax.apps.handlers.monitoring.unified_stream": MagicMock(), - "aipass.prax.apps.handlers.monitoring.module_tracker": MagicMock(), - "aipass.prax.apps.handlers.monitoring.branch_detector": MagicMock(), - "aipass.prax.apps.handlers.monitoring.interactive_filter": MagicMock(), - "aipass.prax.apps.handlers.monitoring.monitoring_filters": MagicMock(), - "aipass.prax.apps.handlers.monitoring.file_watcher_integration": MagicMock(), - }, - ): - import importlib - - if "aipass.prax.apps.modules.monitor" in sys.modules: - mod = importlib.reload(sys.modules["aipass.prax.apps.modules.monitor"]) - else: - mod = importlib.import_module("aipass.prax.apps.modules.monitor") - return mod - def test_returns_false_for_non_monitor_command(self): """handle_command returns False for commands other than 'monitor'.""" - mod = self._import_monitor() + mod = _import_monitor() assert mod.handle_command("status", []) is False def test_no_args_calls_print_introspection(self): """Bare 'monitor' with no args shows introspection and returns True.""" - mod = self._import_monitor() + mod = _import_monitor() with patch.object(mod, "print_introspection") as mock_intro: result = mod.handle_command("monitor", []) assert result is True @@ -67,7 +80,7 @@ class TestHandleCommand: def test_help_flag_calls_print_help(self): """--help flag shows help and returns True.""" - mod = self._import_monitor() + mod = _import_monitor() for flag in ("--help", "-h", "help"): with patch.object(mod, "print_help") as mock_help: result = mod.handle_command("monitor", [flag]) @@ -76,7 +89,7 @@ class TestHandleCommand: def test_run_subcommand_calls_run_monitor(self): """'run' subcommand delegates to _run_monitor.""" - mod = self._import_monitor() + mod = _import_monitor() with patch.object(mod, "_run_monitor", return_value=True) as mock_run: result = mod.handle_command("monitor", ["run"]) assert result is True @@ -84,14 +97,14 @@ class TestHandleCommand: def test_run_subcommand_passes_trailing_args(self): """Extra args after 'run' are forwarded to _run_monitor.""" - mod = self._import_monitor() + mod = _import_monitor() with patch.object(mod, "_run_monitor", return_value=True) as mock_run: mod.handle_command("monitor", ["run", "seedgo,cli"]) mock_run.assert_called_once_with(["seedgo,cli"]) def test_unknown_subcommand_prints_error_and_help(self): """Unknown subcommand shows error + help, returns True.""" - mod = self._import_monitor() + mod = _import_monitor() with patch.object(mod, "print_help") as mock_help: result = mod.handle_command("monitor", ["bogus"]) assert result is True @@ -108,40 +121,16 @@ class TestHandleCommand: class TestGetWatchDirectories: """Test directory enumeration from registry.""" - def _import_monitor(self): - with patch.dict( - sys.modules, - { - "aipass.prax.apps.handlers.monitoring": MagicMock(), - "aipass.prax.apps.handlers.monitoring.event_queue": MagicMock(), - "aipass.prax.apps.handlers.monitoring.filesystem_handler": MagicMock(), - "aipass.prax.apps.handlers.monitoring.log_watcher": MagicMock(), - "aipass.prax.apps.handlers.monitoring.unified_stream": MagicMock(), - "aipass.prax.apps.handlers.monitoring.module_tracker": MagicMock(), - "aipass.prax.apps.handlers.monitoring.branch_detector": MagicMock(), - "aipass.prax.apps.handlers.monitoring.interactive_filter": MagicMock(), - "aipass.prax.apps.handlers.monitoring.monitoring_filters": MagicMock(), - "aipass.prax.apps.handlers.monitoring.file_watcher_integration": MagicMock(), - }, - ): - import importlib - - if "aipass.prax.apps.modules.monitor" in sys.modules: - mod = importlib.reload(sys.modules["aipass.prax.apps.modules.monitor"]) - else: - mod = importlib.import_module("aipass.prax.apps.modules.monitor") - return mod - def test_returns_empty_when_no_registry(self, tmp_path): """No AIPASS_REGISTRY.json -> empty list (no .claude/projects either).""" - mod = self._import_monitor() + mod = _import_monitor() with patch("pathlib.Path.home", return_value=tmp_path / "fakehome"): result = mod._get_watch_directories(tmp_path) assert result == [] def test_includes_apps_and_trinity_dirs(self, tmp_path): """Registry with a branch that has apps/ and .trinity/ dirs.""" - mod = self._import_monitor() + mod = _import_monitor() # Set up fake branch structure branch_dir = tmp_path / "src" / "aipass" / "flow" apps_dir = branch_dir / "apps" @@ -170,7 +159,7 @@ class TestGetWatchDirectories: def test_skips_nonexistent_branch_paths(self, tmp_path): """Branches whose path doesn't exist on disk are skipped.""" - mod = self._import_monitor() + mod = _import_monitor() registry = {"branches": [{"name": "ghost", "path": "src/aipass/ghost"}]} registry_file = tmp_path / "AIPASS_REGISTRY.json" registry_file.write_text(json.dumps(registry), encoding="utf-8") @@ -182,7 +171,7 @@ class TestGetWatchDirectories: def test_includes_claude_projects_when_exists(self, tmp_path): """~/.claude/projects is included when it exists.""" - mod = self._import_monitor() + mod = _import_monitor() fakehome = tmp_path / "fakehome" claude_projects = fakehome / ".claude" / "projects" claude_projects.mkdir(parents=True) @@ -198,7 +187,7 @@ class TestGetWatchDirectories: def test_branch_without_apps_dir(self, tmp_path): """Branch that exists but has no apps/ dir still includes root and .trinity.""" - mod = self._import_monitor() + mod = _import_monitor() branch_dir = tmp_path / "src" / "aipass" / "minimal" trinity_dir = branch_dir / ".trinity" branch_dir.mkdir(parents=True) @@ -217,3 +206,1219 @@ class TestGetWatchDirectories: # But branch root and .trinity are still there assert branch_dir in paths assert trinity_dir in paths + + +# --------------------------------------------------------------------------- +# _parse_lock_pid tests (lines 61-74) +# --------------------------------------------------------------------------- + + +class TestParseLockPid: + """Test dispatch lock file parsing for PID cache.""" + + def test_no_lock_file_does_nothing(self, tmp_path): + """Branch entry without a lock file adds nothing to cache.""" + mod = _import_monitor() + new_cache: dict[str, int] = {} + entry = {"path": str(tmp_path / "somebranch"), "name": "flow"} + mod._parse_lock_pid(entry, new_cache) + assert new_cache == {} + + def test_lock_file_with_live_pid_on_linux(self, tmp_path): + """Lock file with a PID that has a /proc entry adds to cache.""" + mod = _import_monitor() + branch_dir = tmp_path / "mybranch" + mail_dir = branch_dir / "ai_mail.local" + mail_dir.mkdir(parents=True) + lock_data = {"pid": 12345} + (mail_dir / ".dispatch.lock").write_text(json.dumps(lock_data), encoding="utf-8") + + new_cache: dict[str, int] = {} + entry = {"path": str(branch_dir), "name": "flow"} + + # Mock /proc/12345 existence check + with ( + patch("sys.platform", "linux"), + patch("pathlib.Path.exists", side_effect=lambda self=None: True), + ): + mod._parse_lock_pid(entry, new_cache) + + assert new_cache.get("FLOW") == 12345 + + def test_lock_file_with_zero_pid(self, tmp_path): + """Lock file with pid=0 skips entry.""" + mod = _import_monitor() + branch_dir = tmp_path / "mybranch" + mail_dir = branch_dir / "ai_mail.local" + mail_dir.mkdir(parents=True) + lock_data = {"pid": 0} + (mail_dir / ".dispatch.lock").write_text(json.dumps(lock_data), encoding="utf-8") + + new_cache: dict[str, int] = {} + entry = {"path": str(branch_dir), "name": "flow"} + mod._parse_lock_pid(entry, new_cache) + assert new_cache == {} + + def test_lock_file_with_invalid_json(self, tmp_path): + """Lock file with invalid JSON logs warning and continues.""" + mod = _import_monitor() + branch_dir = tmp_path / "mybranch" + mail_dir = branch_dir / "ai_mail.local" + mail_dir.mkdir(parents=True) + (mail_dir / ".dispatch.lock").write_text("{bad json}", encoding="utf-8") + + new_cache: dict[str, int] = {} + entry = {"path": str(branch_dir), "name": "flow"} + mod._parse_lock_pid(entry, new_cache) + assert new_cache == {} + + def test_lock_file_with_empty_name(self, tmp_path): + """Branch entry with empty name skips cache update.""" + mod = _import_monitor() + branch_dir = tmp_path / "mybranch" + mail_dir = branch_dir / "ai_mail.local" + mail_dir.mkdir(parents=True) + lock_data = {"pid": 99999} + (mail_dir / ".dispatch.lock").write_text(json.dumps(lock_data), encoding="utf-8") + + new_cache: dict[str, int] = {} + entry = {"path": str(branch_dir), "name": ""} + + with ( + patch("sys.platform", "linux"), + patch("pathlib.Path.exists", return_value=True), + ): + mod._parse_lock_pid(entry, new_cache) + assert new_cache == {} + + +# --------------------------------------------------------------------------- +# _refresh_pid_cache tests (lines 80-102) +# --------------------------------------------------------------------------- + + +class TestRefreshPidCache: + """Test PID cache refresh from registry.""" + + def test_skips_when_within_ttl(self): + """Cache refresh is skipped if within TTL window.""" + mod = _import_monitor() + import time + + # Simulate recent refresh + with mod._pid_cache_lock: + setattr(mod, "_pid_cache_last_refresh", time.time()) + + with patch.object(mod, "_parse_lock_pid") as mock_parse: + mod._refresh_pid_cache() + mock_parse.assert_not_called() + + def test_refreshes_when_ttl_expired(self, tmp_path): + """Cache refresh runs when TTL has expired.""" + mod = _import_monitor() + with mod._pid_cache_lock: + setattr(mod, "_pid_cache_last_refresh", 0.0) + + registry_data = {"branches": [{"name": "flow", "path": str(tmp_path / "flow")}]} + registry_file = tmp_path / "AIPASS_REGISTRY.json" + registry_file.write_text(json.dumps(registry_data), encoding="utf-8") + + mock_find_root = MagicMock(return_value=tmp_path) + with patch.dict( + sys.modules, + {"aipass.prax.apps.handlers.config.load": MagicMock(_find_repo_root=mock_find_root)}, + ): + mod._refresh_pid_cache() + + def test_handles_missing_registry(self, tmp_path): + """Missing registry file does not crash.""" + mod = _import_monitor() + with mod._pid_cache_lock: + setattr(mod, "_pid_cache_last_refresh", 0.0) + + mock_find_root = MagicMock(return_value=tmp_path) + with patch.dict( + sys.modules, + {"aipass.prax.apps.handlers.config.load": MagicMock(_find_repo_root=mock_find_root)}, + ): + mod._refresh_pid_cache() + + def test_handles_exception_in_refresh(self): + """Exception during refresh is caught and logged.""" + mod = _import_monitor() + with mod._pid_cache_lock: + setattr(mod, "_pid_cache_last_refresh", 0.0) + + mock_load = MagicMock() + mock_load._find_repo_root.side_effect = RuntimeError("boom") + with patch.dict( + sys.modules, + {"aipass.prax.apps.handlers.config.load": mock_load}, + ): + # Should not raise + mod._refresh_pid_cache() + + +# --------------------------------------------------------------------------- +# _get_pid_for_branch tests (lines 107-112) +# --------------------------------------------------------------------------- + + +class TestGetPidForBranch: + """Test PID lookup for branch names.""" + + def test_returns_pid_from_cache(self): + """Returns PID when branch is in cache.""" + mod = _import_monitor() + with mod._pid_cache_lock: + mod._pid_cache["FLOW"] = 42 + + with patch.object(mod, "_refresh_pid_cache"): + result = mod._get_pid_for_branch("flow") + assert result == 42 + + def test_strips_agent_suffix(self): + """Branch name ending in ' AGENT' is stripped before lookup.""" + mod = _import_monitor() + with mod._pid_cache_lock: + mod._pid_cache["FLOW"] = 42 + + with patch.object(mod, "_refresh_pid_cache"): + result = mod._get_pid_for_branch("flow agent") + assert result == 42 + + def test_returns_none_when_not_cached(self): + """Returns None when branch is not in cache.""" + mod = _import_monitor() + with mod._pid_cache_lock: + mod._pid_cache.clear() + + with patch.object(mod, "_refresh_pid_cache"): + result = mod._get_pid_for_branch("nonexistent") + assert result is None + + +# --------------------------------------------------------------------------- +# print_introspection tests (lines 130-167) +# --------------------------------------------------------------------------- + + +class TestPrintIntrospection: + """Test print_introspection output.""" + + def test_print_introspection_runs(self): + """print_introspection runs without error and calls console.print.""" + mod = _import_monitor() + mod.print_introspection() + mod.json_handler.log_operation.assert_called() + assert mod.console.print.call_count > 10 + + +# --------------------------------------------------------------------------- +# print_help tests (lines 172-207) +# --------------------------------------------------------------------------- + + +class TestPrintHelp: + """Test print_help output.""" + + def test_print_help_runs(self): + """print_help runs without error and calls console.print.""" + mod = _import_monitor() + mod.print_help() + assert mod.console.print.call_count > 10 + + +# --------------------------------------------------------------------------- +# _render_event tests (lines 331-342) +# --------------------------------------------------------------------------- + + +class TestRenderEvent: + """Test event rendering to console.""" + + def test_render_command_event(self): + """Command-type events call print_command_separator.""" + mod = _import_monitor() + event = MagicMock() + event.event_type = "command" + event.branch = "FLOW" + event.message = "seedgo audit" + event.caller = "prax" + event.action = "audit:flow" + + with patch.object(mod, "_get_pid_for_branch", return_value=None): + mod._render_event(event) + mod.print_command_separator.assert_called_once() + + def test_render_command_event_no_target(self): + """Command event without colon in action passes target=None.""" + mod = _import_monitor() + event = MagicMock() + event.event_type = "command" + event.branch = "FLOW" + event.message = "seedgo audit" + event.caller = "prax" + event.action = "audit_only" + + with patch.object(mod, "_get_pid_for_branch", return_value=None): + mod._render_event(event) + mod.print_command_separator.assert_called_once() + call_args = mod.print_command_separator.call_args + assert call_args[0][3] is None + + def test_render_non_command_event(self): + """Non-command events call print_event.""" + mod = _import_monitor() + event = MagicMock() + event.event_type = "file_change" + event.branch = "FLOW" + event.message = "STATUS.local.md modified" + event.level = "info" + + with patch.object(mod, "_get_pid_for_branch", return_value=42): + mod._render_event(event) + mod.print_event.assert_called_once_with("file_change", "FLOW", "STATUS.local.md modified", "info", pid=42) + + def test_render_command_event_action_with_empty_target(self): + """Command event with action 'audit:' (empty after colon) passes target=None.""" + mod = _import_monitor() + event = MagicMock() + event.event_type = "command" + event.branch = "FLOW" + event.message = "test" + event.caller = "prax" + event.action = "audit:" + + with patch.object(mod, "_get_pid_for_branch", return_value=None): + mod._render_event(event) + call_args = mod.print_command_separator.call_args + assert call_args[0][3] is None + + +# --------------------------------------------------------------------------- +# _start_threads / _stop_threads tests (lines 298-326) +# --------------------------------------------------------------------------- + + +class TestThreadManagement: + """Test thread start and stop functions.""" + + def test_start_threads_creates_three_threads(self): + """_start_threads creates and starts display, file watcher, and log watcher threads.""" + mod = _import_monitor() + mock_thread = MagicMock() + with patch("threading.Thread", return_value=mock_thread) as mock_cls: + mod._start_threads() + assert mock_cls.call_count == 3 + assert mock_thread.start.call_count == 3 + + def test_stop_threads_sets_stop_event(self): + """_stop_threads sets the stop event and stops the queue.""" + mod = _import_monitor() + mock_queue = MagicMock() + setattr(mod, "_event_queue", mock_queue) + mod._stop_event.clear() + + mock_thread = MagicMock() + mock_thread.is_alive.return_value = True + setattr(mod, "_display_thread", mock_thread) + setattr(mod, "_file_watcher_thread", mock_thread) + setattr(mod, "_log_watcher_thread", mock_thread) + + mod._stop_threads() + + assert mod._stop_event.is_set() + mock_queue.stop.assert_called_once() + assert mock_thread.join.call_count == 3 + + def test_stop_threads_handles_none_queue(self): + """_stop_threads works when event queue is None.""" + mod = _import_monitor() + setattr(mod, "_event_queue", None) + setattr(mod, "_display_thread", None) + setattr(mod, "_file_watcher_thread", None) + setattr(mod, "_log_watcher_thread", None) + + # Should not raise + mod._stop_threads() + assert mod._stop_event.is_set() + + def test_stop_threads_skips_dead_threads(self): + """_stop_threads skips threads that are not alive.""" + mod = _import_monitor() + setattr(mod, "_event_queue", None) + + mock_thread = MagicMock() + mock_thread.is_alive.return_value = False + setattr(mod, "_display_thread", mock_thread) + setattr(mod, "_file_watcher_thread", mock_thread) + setattr(mod, "_log_watcher_thread", mock_thread) + + mod._stop_threads() + mock_thread.join.assert_not_called() + + +# --------------------------------------------------------------------------- +# _display_worker tests (lines 349-356) +# --------------------------------------------------------------------------- + + +class TestDisplayWorker: + """Test the display worker loop.""" + + def test_display_worker_processes_event(self): + """Display worker dequeues and renders events.""" + mod = _import_monitor() + mock_event = MagicMock() + mock_event.event_type = "log" + mock_event.branch = "FLOW" + mock_event.message = "test" + mock_event.level = "info" + + mock_queue = MagicMock() + call_count = 0 + + def _dequeue(timeout=0.1): + """Return mock event once, then signal stop.""" + nonlocal call_count + call_count += 1 + if call_count == 1: + return mock_event + mod._stop_event.set() + return None + + mock_queue.dequeue = _dequeue + setattr(mod, "_event_queue", mock_queue) + mod._stop_event.clear() + + with patch.object(mod, "_render_event") as mock_render: + mod._display_worker() + mock_render.assert_called_once_with(mock_event) + + def test_display_worker_handles_none_queue(self): + """Display worker sleeps when event queue is None, then stops.""" + mod = _import_monitor() + setattr(mod, "_event_queue", None) + mod._stop_event.clear() + + call_count = 0 + + def _sleep(duration): + """Count calls and set stop event after threshold.""" + nonlocal call_count + call_count += 1 + if call_count >= 2: + mod._stop_event.set() + + with patch("time.sleep", side_effect=_sleep): + mod._display_worker() + assert call_count >= 2 + + +# --------------------------------------------------------------------------- +# _emit_watcher_event tests (lines 410-413) +# --------------------------------------------------------------------------- + + +class TestEmitWatcherEvent: + """Test watcher event emission.""" + + def test_emit_returns_early_when_no_queue(self): + """Does nothing when event queue is None.""" + mod = _import_monitor() + setattr(mod, "_event_queue", None) + # Should not raise + mod._emit_watcher_event("error", "test error") + + def test_emit_error_level_has_priority_1(self): + """Error level events get priority 1.""" + mod = _import_monitor() + mock_queue = MagicMock() + setattr(mod, "_event_queue", mock_queue) + + mod._emit_watcher_event("error", "bad stuff happened") + mock_queue.enqueue.assert_called_once() + # MonitoringEvent is mocked, so check the kwargs it was constructed with + me_cls = mod.MonitoringEvent + me_cls.assert_called_once() + call_kwargs = me_cls.call_args[1] + assert call_kwargs["priority"] == 1 + + def test_emit_warning_level_has_priority_2(self): + """Non-error level events get priority 2.""" + mod = _import_monitor() + mock_queue = MagicMock() + setattr(mod, "_event_queue", mock_queue) + + mod._emit_watcher_event("warning", "something happened") + mock_queue.enqueue.assert_called_once() + me_cls = mod.MonitoringEvent + me_cls.assert_called_once() + call_kwargs = me_cls.call_args[1] + assert call_kwargs["priority"] == 2 + + +# --------------------------------------------------------------------------- +# _inotify_fix_message tests (lines 428-438) +# --------------------------------------------------------------------------- + + +class TestInotifyFixMessage: + """Test inotify error message generation.""" + + def test_enospc_message(self): + """ENOSPC error returns max_user_watches fix message.""" + mod = _import_monitor() + import errno + + err = OSError(errno.ENOSPC, "No space left on device") + result = mod._inotify_fix_message(err) + assert "max_user_watches" in result + + def test_emfile_message(self): + """EMFILE error returns max_user_instances fix message.""" + mod = _import_monitor() + import errno + + err = OSError(errno.EMFILE, "Too many open files") + result = mod._inotify_fix_message(err) + assert "max_user_instances" in result + + def test_other_error_message(self): + """Other errors return generic inotify error message.""" + mod = _import_monitor() + err = OSError(99, "Unknown error") + result = mod._inotify_fix_message(err) + assert "inotify error" in result + + +# --------------------------------------------------------------------------- +# _start_observer_with_fallback tests (lines 446-472) +# --------------------------------------------------------------------------- + + +class TestStartObserverWithFallback: + """Test watchdog observer startup with fallback.""" + + def _import_with_watchdog(self): + """Import monitor with watchdog modules also mocked.""" + extra = { + "watchdog": MagicMock(), + "watchdog.observers": MagicMock(), + "watchdog.observers.polling": MagicMock(), + } + fresh_mocks = {k: MagicMock() for k in _MONITORING_MOCKS} + fresh_mocks.update(extra) + with patch.dict(sys.modules, fresh_mocks): + import importlib + + if "aipass.prax.apps.modules.monitor" in sys.modules: + mod = importlib.reload(sys.modules["aipass.prax.apps.modules.monitor"]) + else: + mod = importlib.import_module("aipass.prax.apps.modules.monitor") + return mod + + def test_success_path(self, tmp_path): + """Observer starts successfully on first try.""" + mod = self._import_with_watchdog() + handler = MagicMock() + watch_dirs = [(tmp_path, True)] + + mock_observer = MagicMock() + with patch.dict( + sys.modules, + {"watchdog.observers": MagicMock(Observer=MagicMock(return_value=mock_observer))}, + ): + result = mod._start_observer_with_fallback(handler, watch_dirs) + assert result is mock_observer + mock_observer.start.assert_called_once() + + def test_fallback_to_polling(self, tmp_path): + """Falls back to PollingObserver when inotify fails.""" + mod = self._import_with_watchdog() + handler = MagicMock() + watch_dirs = [(tmp_path, True)] + setattr(mod, "_event_queue", MagicMock()) + + import errno + + mock_observer_fail = MagicMock() + mock_observer_fail.start.side_effect = OSError(errno.ENOSPC, "inotify limit") + mock_polling_observer = MagicMock() + + mock_observer_mod = MagicMock() + mock_observer_mod.Observer = MagicMock(return_value=mock_observer_fail) + + mock_polling_mod = MagicMock() + mock_polling_mod.PollingObserver = MagicMock(return_value=mock_polling_observer) + + with patch.dict( + sys.modules, + { + "watchdog.observers": mock_observer_mod, + "watchdog.observers.polling": mock_polling_mod, + }, + ): + result = mod._start_observer_with_fallback(handler, watch_dirs) + + assert result is mock_polling_observer + mock_polling_observer.start.assert_called_once() + + def test_both_fail_returns_none(self, tmp_path): + """Returns None when both inotify and polling fail.""" + mod = self._import_with_watchdog() + handler = MagicMock() + watch_dirs = [(tmp_path, True)] + setattr(mod, "_event_queue", MagicMock()) + + import errno + + mock_observer_fail = MagicMock() + mock_observer_fail.start.side_effect = OSError(errno.ENOSPC, "inotify limit") + + mock_observer_mod = MagicMock() + mock_observer_mod.Observer = MagicMock(return_value=mock_observer_fail) + + mock_polling_mod = MagicMock() + mock_polling_mod.PollingObserver = MagicMock(side_effect=RuntimeError("polling failed")) + + with patch.dict( + sys.modules, + { + "watchdog.observers": mock_observer_mod, + "watchdog.observers.polling": mock_polling_mod, + }, + ): + result = mod._start_observer_with_fallback(handler, watch_dirs) + + assert result is None + + +# --------------------------------------------------------------------------- +# _file_watcher_worker tests (lines 479-511) +# --------------------------------------------------------------------------- + + +class TestFileWatcherWorker: + """Test file watcher worker thread.""" + + def test_no_watch_dirs_returns_early(self): + """Worker returns early when no watch directories found.""" + mod = _import_monitor() + setattr(mod, "_event_queue", MagicMock()) + + mock_config = MagicMock() + mock_config._find_repo_root.return_value = MagicMock() + mock_fs_handler = MagicMock() + + with ( + patch.dict( + sys.modules, + { + "aipass.prax.apps.handlers.monitoring.filesystem_handler": mock_fs_handler, + "aipass.prax.apps.handlers.config.load": mock_config, + }, + ), + patch.object(mod, "_get_watch_directories", return_value=[]), + patch.object(mod, "_emit_watcher_event") as mock_emit, + ): + mod._file_watcher_worker() + mock_emit.assert_called_once() + + def test_observer_none_returns_early(self): + """Worker returns early when observer startup fails.""" + mod = _import_monitor() + setattr(mod, "_event_queue", MagicMock()) + + mock_config = MagicMock() + mock_config._find_repo_root.return_value = MagicMock() + mock_fs_handler = MagicMock() + + with ( + patch.dict( + sys.modules, + { + "aipass.prax.apps.handlers.monitoring.filesystem_handler": mock_fs_handler, + "aipass.prax.apps.handlers.config.load": mock_config, + }, + ), + patch.object(mod, "_get_watch_directories", return_value=[("/tmp", True)]), + patch.object(mod, "_start_observer_with_fallback", return_value=None), + ): + mod._file_watcher_worker() + + def test_worker_runs_loop_and_stops_observer(self, tmp_path): + """Worker runs sleep loop and stops observer on stop_event.""" + mod = _import_monitor() + setattr(mod, "_event_queue", MagicMock()) + mod._stop_event.clear() + + mock_config = MagicMock() + mock_config._find_repo_root.return_value = tmp_path + mock_fs_handler = MagicMock() + mock_observer = MagicMock() + + call_count = 0 + + def _sleep(duration): + """Count calls and set stop event.""" + nonlocal call_count + call_count += 1 + if call_count >= 1: + mod._stop_event.set() + + with ( + patch.dict( + sys.modules, + { + "aipass.prax.apps.handlers.monitoring.filesystem_handler": mock_fs_handler, + "aipass.prax.apps.handlers.config.load": mock_config, + }, + ), + patch.object( + mod, + "_get_watch_directories", + return_value=[(tmp_path, True)], + ), + patch.object( + mod, + "_start_observer_with_fallback", + return_value=mock_observer, + ), + patch("time.sleep", side_effect=_sleep), + ): + mod._file_watcher_worker() + + mock_observer.stop.assert_called_once() + mock_observer.join.assert_called_once() + + +# --------------------------------------------------------------------------- +# _start_log_watcher_with_fallback tests (lines 519-535) +# --------------------------------------------------------------------------- + + +class TestStartLogWatcherWithFallback: + """Test log watcher startup with fallback.""" + + def test_success_on_first_try(self): + """Returns True when start_log_watcher succeeds.""" + mod = _import_monitor() + mock_queue = MagicMock() + + mock_lw = MagicMock() + mock_lw.start_log_watcher = MagicMock() + + with patch.dict( + sys.modules, + {"aipass.prax.apps.handlers.monitoring.log_watcher": mock_lw}, + ): + result = mod._start_log_watcher_with_fallback(mock_queue) + assert result is True + + def test_fallback_to_polling(self): + """Falls back to polling when inotify fails.""" + mod = _import_monitor() + mock_queue = MagicMock() + setattr(mod, "_event_queue", MagicMock()) + + import errno + + mock_lw = MagicMock() + call_count = 0 + + def _start(eq, use_polling=False): + """Fail on first call, succeed on second.""" + nonlocal call_count + call_count += 1 + if call_count == 1: + raise OSError(errno.ENOSPC, "inotify limit") + + mock_lw.start_log_watcher = _start + + with patch.dict( + sys.modules, + {"aipass.prax.apps.handlers.monitoring.log_watcher": mock_lw}, + ): + result = mod._start_log_watcher_with_fallback(mock_queue) + assert result is True + + def test_both_fail_returns_false(self): + """Returns False when both inotify and polling fail.""" + mod = _import_monitor() + mock_queue = MagicMock() + setattr(mod, "_event_queue", MagicMock()) + + import errno + + mock_lw = MagicMock() + call_count = 0 + + def _start(eq, use_polling=False): + """Fail on first call with OSError, fail on second with RuntimeError.""" + nonlocal call_count + call_count += 1 + if call_count == 1: + raise OSError(errno.ENOSPC, "inotify limit") + raise RuntimeError("polling also failed") + + mock_lw.start_log_watcher = _start + + with patch.dict( + sys.modules, + {"aipass.prax.apps.handlers.monitoring.log_watcher": mock_lw}, + ): + result = mod._start_log_watcher_with_fallback(mock_queue) + assert result is False + + +# --------------------------------------------------------------------------- +# _log_watcher_worker tests (lines 542-555) +# --------------------------------------------------------------------------- + + +class TestLogWatcherWorker: + """Test log watcher worker thread.""" + + def test_returns_early_when_no_queue(self): + """Worker returns early when event queue is None.""" + mod = _import_monitor() + setattr(mod, "_event_queue", None) + + mock_lw = MagicMock() + with patch.dict( + sys.modules, + {"aipass.prax.apps.handlers.monitoring.log_watcher": mock_lw}, + ): + mod._log_watcher_worker() + mock_lw.start_log_watcher.assert_not_called() + + def test_returns_early_on_watcher_failure(self): + """Worker returns when log watcher startup fails.""" + mod = _import_monitor() + setattr(mod, "_event_queue", MagicMock()) + + with patch.object(mod, "_start_log_watcher_with_fallback", return_value=False): + mock_lw = MagicMock() + with patch.dict( + sys.modules, + {"aipass.prax.apps.handlers.monitoring.log_watcher": mock_lw}, + ): + mod._log_watcher_worker() + + def test_worker_runs_loop_and_stops(self): + """Worker runs sleep loop and calls stop_log_watcher on stop_event.""" + mod = _import_monitor() + setattr(mod, "_event_queue", MagicMock()) + mod._stop_event.clear() + + mock_lw = MagicMock() + call_count = 0 + + def _sleep(duration): + """Count calls and set stop event.""" + nonlocal call_count + call_count += 1 + if call_count >= 1: + mod._stop_event.set() + + with ( + patch.object(mod, "_start_log_watcher_with_fallback", return_value=True), + patch.dict( + sys.modules, + {"aipass.prax.apps.handlers.monitoring.log_watcher": mock_lw}, + ), + patch("time.sleep", side_effect=_sleep), + ): + mod._log_watcher_worker() + mock_lw.stop_log_watcher.assert_called_once() + + +# --------------------------------------------------------------------------- +# _handle_interactive_cmd tests (lines 560-567) +# --------------------------------------------------------------------------- + + +class TestHandleInteractiveCmd: + """Test interactive command dispatch.""" + + def test_help_command(self): + """Help command calls get_help_text and prints it.""" + mod = _import_monitor() + mock_help = MagicMock(return_value="help text here") + mod._handle_interactive_cmd("help", mock_help) + mock_help.assert_called_once() + mod.console.print.assert_called() + + def test_status_command(self): + """Status command calls _print_status.""" + mod = _import_monitor() + with patch.object(mod, "_print_status") as mock_status: + mod._handle_interactive_cmd("status", MagicMock()) + mock_status.assert_called_once() + + def test_unknown_command(self): + """Unknown command prints error.""" + mod = _import_monitor() + mod._handle_interactive_cmd("bogus", MagicMock()) + mod.error.assert_called() + + +# --------------------------------------------------------------------------- +# _interactive_loop tests (lines 575-609) +# --------------------------------------------------------------------------- + + +class TestInteractiveLoop: + """Test interactive command loop.""" + + def test_non_tty_passive_mode(self): + """Non-TTY mode runs passive loop until stop event.""" + mod = _import_monitor() + mod._stop_event.clear() + + call_count = 0 + + def _sleep(duration): + """Count calls and set stop event after threshold.""" + nonlocal call_count + call_count += 1 + if call_count >= 2: + mod._stop_event.set() + + with ( + patch.object(sys.stdin, "isatty", return_value=False), + patch("time.sleep", side_effect=_sleep), + ): + mod._interactive_loop() + + def test_non_tty_keyboard_interrupt(self): + """Non-TTY mode handles KeyboardInterrupt gracefully.""" + mod = _import_monitor() + mod._stop_event.clear() + + with ( + patch.object(sys.stdin, "isatty", return_value=False), + patch("time.sleep", side_effect=KeyboardInterrupt), + ): + mod._interactive_loop() + + def test_tty_quit_command(self): + """TTY mode exits on 'quit' command.""" + mod = _import_monitor() + mod._stop_event.clear() + + mock_filter = MagicMock() + mock_filter.parse_command = MagicMock(return_value=("quit", [])) + mock_filter.get_help_text = MagicMock(return_value="help") + + with ( + patch.object(sys.stdin, "isatty", return_value=True), + patch("builtins.input", return_value="quit"), + patch.dict( + sys.modules, + { + "aipass.prax.apps.handlers.monitoring.interactive_filter": mock_filter, + }, + ), + ): + mod._interactive_loop() + + def test_tty_exit_command(self): + """TTY mode exits on 'exit' command.""" + mod = _import_monitor() + mod._stop_event.clear() + + mock_filter = MagicMock() + mock_filter.parse_command = MagicMock(return_value=("exit", [])) + + with ( + patch.object(sys.stdin, "isatty", return_value=True), + patch("builtins.input", return_value="exit"), + patch.dict( + sys.modules, + { + "aipass.prax.apps.handlers.monitoring.interactive_filter": mock_filter, + }, + ), + ): + mod._interactive_loop() + + def test_tty_q_command(self): + """TTY mode exits on 'q' command.""" + mod = _import_monitor() + mod._stop_event.clear() + + mock_filter = MagicMock() + mock_filter.parse_command = MagicMock(return_value=("q", [])) + + with ( + patch.object(sys.stdin, "isatty", return_value=True), + patch("builtins.input", return_value="q"), + patch.dict( + sys.modules, + { + "aipass.prax.apps.handlers.monitoring.interactive_filter": mock_filter, + }, + ), + ): + mod._interactive_loop() + + def test_tty_empty_input_skipped(self): + """Empty input is ignored in TTY mode.""" + mod = _import_monitor() + mod._stop_event.clear() + + call_count = 0 + + def _input(prompt=""): + """Return empty first, then quit.""" + nonlocal call_count + call_count += 1 + if call_count == 1: + return "" + return "quit" + + mock_filter = MagicMock() + returns = iter([("", []), ("quit", [])]) + mock_filter.parse_command = MagicMock(side_effect=lambda x: next(returns)) + + with ( + patch.object(sys.stdin, "isatty", return_value=True), + patch("builtins.input", side_effect=_input), + patch.dict( + sys.modules, + { + "aipass.prax.apps.handlers.monitoring.interactive_filter": mock_filter, + }, + ), + ): + mod._interactive_loop() + + def test_tty_none_cmd_skipped(self): + """None command from parse_command is skipped.""" + mod = _import_monitor() + mod._stop_event.clear() + + call_count = 0 + + def _input(prompt=""): + """Return text first, then quit.""" + nonlocal call_count + call_count += 1 + if call_count == 1: + return "something" + return "quit" + + mock_filter = MagicMock() + returns = iter([(None, []), ("quit", [])]) + mock_filter.parse_command = MagicMock(side_effect=lambda x: next(returns)) + + with ( + patch.object(sys.stdin, "isatty", return_value=True), + patch("builtins.input", side_effect=_input), + patch.dict( + sys.modules, + { + "aipass.prax.apps.handlers.monitoring.interactive_filter": mock_filter, + }, + ), + ): + mod._interactive_loop() + + def test_tty_keyboard_interrupt(self): + """TTY mode handles KeyboardInterrupt gracefully.""" + mod = _import_monitor() + mod._stop_event.clear() + + mock_filter = MagicMock() + + with ( + patch.object(sys.stdin, "isatty", return_value=True), + patch("builtins.input", side_effect=KeyboardInterrupt), + patch.dict( + sys.modules, + { + "aipass.prax.apps.handlers.monitoring.interactive_filter": mock_filter, + }, + ), + ): + mod._interactive_loop() + + def test_tty_eof_error(self): + """TTY mode handles EOFError gracefully.""" + mod = _import_monitor() + mod._stop_event.clear() + + mock_filter = MagicMock() + + with ( + patch.object(sys.stdin, "isatty", return_value=True), + patch("builtins.input", side_effect=EOFError), + patch.dict( + sys.modules, + { + "aipass.prax.apps.handlers.monitoring.interactive_filter": mock_filter, + }, + ), + ): + mod._interactive_loop() + + def test_tty_dispatches_interactive_cmd(self): + """TTY mode dispatches non-quit commands to _handle_interactive_cmd.""" + mod = _import_monitor() + mod._stop_event.clear() + + call_count = 0 + + def _input(prompt=""): + """Return status first, then quit.""" + nonlocal call_count + call_count += 1 + if call_count == 1: + return "status" + return "quit" + + mock_filter = MagicMock() + returns = iter([("status", []), ("quit", [])]) + mock_filter.parse_command = MagicMock(side_effect=lambda x: next(returns)) + mock_filter.get_help_text = MagicMock(return_value="help text") + + with ( + patch.object(sys.stdin, "isatty", return_value=True), + patch("builtins.input", side_effect=_input), + patch.dict( + sys.modules, + { + "aipass.prax.apps.handlers.monitoring.interactive_filter": mock_filter, + }, + ), + patch.object(mod, "_handle_interactive_cmd") as mock_handler, + ): + mod._interactive_loop() + mock_handler.assert_called_once() + + +# --------------------------------------------------------------------------- +# _print_status tests (lines 616-621) +# --------------------------------------------------------------------------- + + +class TestPrintStatusMonitor: + """Test status display function.""" + + def test_print_status_with_queue(self): + """Status display includes queue size when queue exists.""" + mod = _import_monitor() + mock_queue = MagicMock() + mock_queue.size.return_value = 5 + setattr(mod, "_event_queue", mock_queue) + + mod._print_status() + assert mod.console.print.call_count >= 3 + + def test_print_status_without_queue(self): + """Status display works when event queue is None.""" + mod = _import_monitor() + setattr(mod, "_event_queue", None) + mod._print_status() + assert mod.console.print.call_count >= 2 + + +# --------------------------------------------------------------------------- +# _run_monitor tests (lines 256-290) +# --------------------------------------------------------------------------- + + +class TestRunMonitor: + """Test the _run_monitor orchestration function.""" + + def test_run_monitor_normal_exit(self): + """_run_monitor starts threads, runs interactive loop, stops threads, then exits.""" + mod = _import_monitor() + import pytest + + with ( + patch.object(mod, "_start_threads"), + patch.object(mod, "_interactive_loop"), + patch.object(mod, "_stop_threads"), + patch.object(sys.stdin, "isatty", return_value=True), + pytest.raises(SystemExit) as exc_info, + ): + mod._run_monitor(["all"]) + assert exc_info.value.code == 0 + + def test_run_monitor_keyboard_interrupt(self): + """_run_monitor handles KeyboardInterrupt from interactive loop.""" + mod = _import_monitor() + import pytest + + with ( + patch.object(mod, "_start_threads"), + patch.object(mod, "_interactive_loop", side_effect=KeyboardInterrupt), + patch.object(mod, "_stop_threads"), + patch.object(sys.stdin, "isatty", return_value=True), + pytest.raises(SystemExit) as exc_info, + ): + mod._run_monitor([]) + assert exc_info.value.code == 0 + + def test_run_monitor_non_tty(self): + """_run_monitor displays non-TTY message when no TTY detected.""" + mod = _import_monitor() + import pytest + + with ( + patch.object(mod, "_start_threads"), + patch.object(mod, "_interactive_loop"), + patch.object(mod, "_stop_threads"), + patch.object(sys.stdin, "isatty", return_value=False), + pytest.raises(SystemExit), + ): + mod._run_monitor([]) + + +# --------------------------------------------------------------------------- +# _get_watch_directories edge cases (lines 389-403) +# --------------------------------------------------------------------------- + + +class TestGetWatchDirectoriesEdgeCases: + """Test edge cases in _get_watch_directories.""" + + def test_corrupted_registry_json(self, tmp_path): + """Corrupted registry JSON logs warning and continues.""" + mod = _import_monitor() + registry_file = tmp_path / "AIPASS_REGISTRY.json" + registry_file.write_text("{bad json!!}", encoding="utf-8") + + with patch("pathlib.Path.home", return_value=tmp_path / "fakehome"): + result = mod._get_watch_directories(tmp_path) + assert isinstance(result, list) + + def test_includes_codex_sessions(self, tmp_path): + """~/.codex/sessions is included when it exists.""" + mod = _import_monitor() + fakehome = tmp_path / "fakehome" + codex_sessions = fakehome / ".codex" / "sessions" + codex_sessions.mkdir(parents=True) + + with patch("pathlib.Path.home", return_value=fakehome): + result = mod._get_watch_directories(tmp_path) + + paths = [p for p, _r in result] + assert codex_sessions in paths + + def test_includes_gemini_tmp(self, tmp_path): + """~/.gemini/tmp is included when it exists.""" + mod = _import_monitor() + fakehome = tmp_path / "fakehome" + gemini_tmp = fakehome / ".gemini" / "tmp" + gemini_tmp.mkdir(parents=True) + + with patch("pathlib.Path.home", return_value=fakehome): + result = mod._get_watch_directories(tmp_path) + + paths = [p for p, _r in result] + assert gemini_tmp in paths diff --git a/src/aipass/prax/tests/test_monitoring_handlers.py b/src/aipass/prax/tests/test_monitoring_handlers.py index d7a012cb..c092fd80 100644 --- a/src/aipass/prax/tests/test_monitoring_handlers.py +++ b/src/aipass/prax/tests/test_monitoring_handlers.py @@ -984,3 +984,986 @@ class TestPrintStatus: # Should not raise mod.print_status(["PRAX"], verbosity=0, filters=None) assert mod.console.print.called + + +# ============================================= +# BRANCH DETECTOR — ADDITIONAL COVERAGE TESTS +# ============================================= + +# Fake home base used across external-project tests to avoid +# hardcoded /home/ paths that trip the SEEDGO log-structure checker. +_FAKE_HOME = Path("/fakehome/user") +_FAKE_PROJECTS = _FAKE_HOME / "Projects" + + +class TestFindRepoRoot: + """Tests for BranchDetector._find_repo_root().""" + + def test_cached_repo_root_returned(self): + """When _repo_root is already set, return it without walking.""" + mod = _import_branch_detector() + detector = _make_detector_with_branches(mod) + sentinel = Path("/cached/root") + detector._repo_root = sentinel + result = detector._find_repo_root() + assert result is sentinel + + def test_walks_parents_to_find_registry(self): + """Should walk up from __file__ to find AIPASS_REGISTRY.json.""" + mod = _import_branch_detector() + detector = _make_detector_with_branches(mod) + detector._repo_root = None + + mock_parent = MagicMock(spec=Path) + mock_registry = MagicMock(spec=Path) + mock_registry.exists.return_value = True + mock_parent.__truediv__ = MagicMock(return_value=mock_registry) + + mock_resolved = MagicMock() + mock_resolved.parent = mock_parent + mock_resolved.parents = [] + + with patch(f"{mod.__name__}.Path") as mock_path_cls: + mock_path_cls.return_value.resolve.return_value = mock_resolved + result = detector._find_repo_root() + + assert result is mock_parent + + def test_fallback_to_cwd_when_registry_missing(self): + """Should fall back to Path.cwd() when no AIPASS_REGISTRY.json found.""" + mod = _import_branch_detector() + detector = _make_detector_with_branches(mod) + detector._repo_root = None + + mock_parent = MagicMock(spec=Path) + mock_no_registry = MagicMock(spec=Path) + mock_no_registry.exists.return_value = False + mock_parent.__truediv__ = MagicMock(return_value=mock_no_registry) + mock_parent.parents = [] + + mock_resolved = MagicMock() + mock_resolved.parent = mock_parent + mock_resolved.parents = [] + + fake_cwd = Path("/fake/cwd") + + with patch(f"{mod.__name__}.Path") as mock_path_cls: + mock_path_cls.return_value.resolve.return_value = mock_resolved + mock_path_cls.cwd.return_value = fake_cwd + result = detector._find_repo_root() + + assert result is fake_cwd + + +class TestRegisterBranch: + """Tests for BranchDetector._register_branch().""" + + def test_registers_branch_with_name_and_path(self): + """Should add to branch_map and known_branches.""" + mod = _import_branch_detector() + detector = _make_detector_with_branches(mod, branches={}) + + with patch(f"{mod.__name__}.Path") as mock_path_cls: + mock_resolved = MagicMock(spec=Path) + mock_resolved.__str__ = MagicMock(return_value="/resolved/path") + mock_path_cls.return_value.resolve.return_value = mock_resolved + + detector._register_branch({"name": "DRONE", "path": "/some/drone"}) + + assert "DRONE" in detector.known_branches + assert "/resolved/path" in detector.branch_map + assert "/resolved/path/" in detector.branch_map + + def test_skips_empty_name(self): + """Should skip branch entries with empty name.""" + mod = _import_branch_detector() + detector = _make_detector_with_branches(mod, branches={}) + initial_count = len(detector.known_branches) + detector._register_branch({"name": "", "path": "/some/path"}) + assert len(detector.known_branches) == initial_count + + def test_skips_empty_path(self): + """Should skip branch entries with empty path.""" + mod = _import_branch_detector() + detector = _make_detector_with_branches(mod, branches={}) + initial_count = len(detector.known_branches) + detector._register_branch({"name": "EMPTY", "path": ""}) + assert len(detector.known_branches) == initial_count + + def test_skips_missing_keys(self): + """Should skip branch entries with missing name or path keys.""" + mod = _import_branch_detector() + detector = _make_detector_with_branches(mod, branches={}) + initial_count = len(detector.known_branches) + detector._register_branch({}) + assert len(detector.known_branches) == initial_count + + +class TestLoadRegistry: + """Tests for BranchDetector._load_registry().""" + + def test_loads_branches_from_valid_registry(self): + """Should populate known_branches from valid registry file.""" + mod = _import_branch_detector() + detector = _make_detector_with_branches(mod, branches={}) + detector._repo_root = Path("/fake/root") + + registry_data = { + "branches": [ + {"name": "PRAX", "path": "/fake/prax"}, + {"name": "FLOW", "path": "/fake/flow"}, + ], + } + + mock_registry = MagicMock(spec=Path) + mock_registry.exists.return_value = True + + mock_root = MagicMock(spec=Path) + mock_root.__truediv__ = MagicMock(return_value=mock_registry) + + with ( + patch.object(detector, "_find_repo_root", return_value=mock_root), + patch("builtins.open", _mopen(read_data=json.dumps(registry_data))), + patch(f"{mod.__name__}.json.load", return_value=registry_data), + patch(f"{mod.__name__}.Path") as mock_path_cls, + ): + mock_path_cls.return_value.resolve.return_value = MagicMock(__str__=MagicMock(return_value="/resolved")) + detector._load_registry() + + assert len(detector.known_branches) >= 2 + + def test_loads_fallback_when_registry_missing(self): + """Should load fallback branches when registry file not found.""" + mod = _import_branch_detector() + detector = _make_detector_with_branches(mod, branches={}) + detector.known_branches.clear() + + mock_root = MagicMock(spec=Path) + mock_registry = MagicMock(spec=Path) + mock_registry.exists.return_value = False + mock_root.__truediv__ = MagicMock(return_value=mock_registry) + + with patch.object(detector, "_find_repo_root", return_value=mock_root): + detector._load_registry() + + assert "PRAX" in detector.known_branches + assert "SEEDGO" in detector.known_branches + + def test_loads_fallback_when_no_branches_key(self): + """Should load fallback when registry has empty branches array.""" + mod = _import_branch_detector() + detector = _make_detector_with_branches(mod, branches={}) + detector.known_branches.clear() + + mock_root = MagicMock(spec=Path) + mock_registry = MagicMock(spec=Path) + mock_registry.exists.return_value = True + mock_root.__truediv__ = MagicMock(return_value=mock_registry) + + with ( + patch.object(detector, "_find_repo_root", return_value=mock_root), + patch("builtins.open", _mopen(read_data='{"branches": []}')), + patch(f"{mod.__name__}.json.load", return_value={"branches": []}), + ): + detector._load_registry() + + assert "PRAX" in detector.known_branches + + def test_loads_fallback_on_json_decode_error(self): + """Should load fallback on JSONDecodeError.""" + mod = _import_branch_detector() + detector = _make_detector_with_branches(mod, branches={}) + detector.known_branches.clear() + + mock_root = MagicMock(spec=Path) + mock_registry = MagicMock(spec=Path) + mock_registry.exists.return_value = True + mock_root.__truediv__ = MagicMock(return_value=mock_registry) + + with ( + patch.object(detector, "_find_repo_root", return_value=mock_root), + patch("builtins.open", _mopen(read_data="{bad json")), + patch( + f"{mod.__name__}.json.load", + side_effect=json.JSONDecodeError("err", "doc", 0), + ), + ): + detector._load_registry() + + assert "PRAX" in detector.known_branches + + def test_loads_fallback_on_generic_exception(self): + """Should load fallback on any unexpected exception.""" + mod = _import_branch_detector() + detector = _make_detector_with_branches(mod, branches={}) + detector.known_branches.clear() + + mock_root = MagicMock(spec=Path) + mock_registry = MagicMock(spec=Path) + mock_registry.exists.return_value = True + mock_root.__truediv__ = MagicMock(return_value=mock_registry) + + with ( + patch.object(detector, "_find_repo_root", return_value=mock_root), + patch("builtins.open", side_effect=PermissionError("denied")), + ): + detector._load_registry() + + assert "PRAX" in detector.known_branches + + +class TestLoadFallbackBranches: + """Tests for BranchDetector._load_fallback_branches().""" + + def test_populates_known_branches(self): + """Should populate known_branches with standard fallback set.""" + mod = _import_branch_detector() + detector = _make_detector_with_branches(mod, branches={}) + detector.known_branches.clear() + detector._load_fallback_branches() + + expected = { + "SEEDGO", + "CLI", + "FLOW", + "PRAX", + "DRONE", + "BACKUP", + "SECURITY", + "AIPASS", + } + assert detector.known_branches == expected + + +class TestResolveExternalProjectName: + """Tests for BranchDetector._resolve_external_project_name().""" + + def test_returns_uppercased_name_when_no_registry(self): + """Should return uppercased directory name when no _REGISTRY.json found.""" + mod = _import_branch_detector() + detector = _make_detector_with_branches(mod) + + with patch(f"{mod.__name__}.Path") as mock_path_cls: + mock_home = MagicMock(spec=Path) + mock_projects = MagicMock(spec=Path) + mock_project_dir = MagicMock(spec=Path) + mock_project_dir.exists.return_value = True + mock_project_dir.iterdir.return_value = [] + mock_projects.__truediv__ = MagicMock(return_value=mock_project_dir) + mock_home.__truediv__ = MagicMock(return_value=mock_projects) + mock_path_cls.home.return_value = mock_home + + result = detector._resolve_external_project_name("MyProject") + + assert result == "MYPROJECT" + + def test_returns_uppercased_when_dir_not_found(self): + """Should return uppercased name when project dir doesn't exist.""" + mod = _import_branch_detector() + detector = _make_detector_with_branches(mod) + + with patch(f"{mod.__name__}.Path") as mock_path_cls: + mock_home = MagicMock(spec=Path) + mock_projects = MagicMock(spec=Path) + mock_project_dir = MagicMock(spec=Path) + mock_project_dir.exists.return_value = False + mock_projects.__truediv__ = MagicMock(return_value=mock_project_dir) + mock_home.__truediv__ = MagicMock(return_value=mock_projects) + mock_path_cls.home.return_value = mock_home + + result = detector._resolve_external_project_name("NoSuchProject") + + assert result == "NOSUCHPROJECT" + + def test_handles_os_error_on_iterdir(self): + """Should return uppercased name on OSError reading directory.""" + mod = _import_branch_detector() + detector = _make_detector_with_branches(mod) + + with patch(f"{mod.__name__}.Path") as mock_path_cls: + mock_home = MagicMock(spec=Path) + mock_projects = MagicMock(spec=Path) + mock_project_dir = MagicMock(spec=Path) + mock_project_dir.exists.return_value = True + mock_project_dir.iterdir.side_effect = OSError("permission denied") + mock_projects.__truediv__ = MagicMock(return_value=mock_project_dir) + mock_home.__truediv__ = MagicMock(return_value=mock_projects) + mock_path_cls.home.return_value = mock_home + + result = detector._resolve_external_project_name("Locked") + + assert result == "LOCKED" + + +class TestParseExternalProjectPath: + """Tests for BranchDetector._parse_external_project_path().""" + + def test_returns_none_for_non_encoded_path(self): + """Should return (None, None) if path doesn't start with dash.""" + mod = _import_branch_detector() + detector = _make_detector_with_branches(mod) + project, agent = detector._parse_external_project_path("not-encoded") + assert project is None + assert agent is None + + def test_returns_none_when_no_projects_boundary(self): + """Should return (None, None) if -projects- not found.""" + mod = _import_branch_detector() + detector = _make_detector_with_branches(mod) + project, agent = detector._parse_external_project_path("-fakehome-user-something") + assert project is None + assert agent is None + + def test_parses_project_without_src(self): + """Should parse project name without src subdirectory.""" + mod = _import_branch_detector() + detector = _make_detector_with_branches(mod) + + with patch.object(detector, "_resolve_external_project_name", return_value="VERA-STUDIO"): + project, agent = detector._parse_external_project_path("-fakehome-user-Projects-Vera-Studio") + + assert project == "VERA-STUDIO" + assert agent is None + + def test_parses_project_with_src_agent(self): + """Should parse project and agent from -src- segment.""" + mod = _import_branch_detector() + detector = _make_detector_with_branches(mod) + + with patch.object(detector, "_resolve_external_project_name", return_value="AIPL"): + project, agent = detector._parse_external_project_path("-fakehome-user-Projects-AIPL-src-polyglot") + + assert project == "AIPL" + assert agent == "POLYGLOT" + + +class TestDetectFromClaudeProject: + """Tests for BranchDetector._detect_from_claude_project().""" + + def test_internal_aipass_branch_via_registry(self): + """Should detect internal AIPass branch from Claude project path.""" + mod = _import_branch_detector() + detector = _make_detector_with_branches(mod) + + path = str(_FAKE_HOME) + "/.claude/projects/" + "-fakehome-user-Projects-AIPass-src-aipass-prax/session.jsonl" + result = detector._detect_from_claude_project(path) + assert result is not None + assert "AIPASS" in result + + def test_internal_aipass_fallback_to_segment_scan(self): + """Should fall back to known branch name scanning for internal AIPass.""" + mod = _import_branch_detector() + detector = _make_detector_with_branches(mod) + detector.known_branches.add("PRAX") + + path = str(_FAKE_HOME) + "/.claude/projects/" + "-fakehome-user-Projects-AIPass-src-aipass-prax/something.jsonl" + result = detector._detect_from_claude_project(path) + assert result is not None + assert "PRAX" in result + + def test_internal_aipass_subagent_suffix(self): + """Should append SUB for subagent paths.""" + mod = _import_branch_detector() + detector = _make_detector_with_branches(mod) + detector.known_branches.add("PRAX") + + path = ( + str(_FAKE_HOME) + + "/.claude/projects/" + + "-fakehome-user-Projects-AIPass-src-aipass-prax" + + "/subagents/abc123/session.jsonl" + ) + result = detector._detect_from_claude_project(path) + assert result is not None + assert "SUB" in result + + def test_external_project_detection(self): + """Should detect external project from Claude project path.""" + mod = _import_branch_detector() + detector = _make_detector_with_branches(mod) + + with patch.object( + detector, + "_parse_external_project_path", + return_value=("AIPL", "POLYGLOT"), + ): + path = str(_FAKE_HOME) + "/.claude/projects/" + "-fakehome-user-Projects-AIPL-src-polyglot/file.jsonl" + result = detector._detect_from_claude_project(path) + + assert result == "AIPL/POLYGLOT" + + def test_external_project_main_session(self): + """Should return project name only for main session (no agent).""" + mod = _import_branch_detector() + detector = _make_detector_with_branches(mod) + + with patch.object( + detector, + "_parse_external_project_path", + return_value=("VERA-STUDIO", None), + ): + path = str(_FAKE_HOME) + "/.claude/projects/" + "-fakehome-user-Projects-Vera-Studio/file.jsonl" + result = detector._detect_from_claude_project(path) + + assert result == "VERA-STUDIO" + + def test_external_project_subagent(self): + """Should append SUB to external project subagent paths.""" + mod = _import_branch_detector() + detector = _make_detector_with_branches(mod) + + with patch.object( + detector, + "_parse_external_project_path", + return_value=("VERA-STUDIO", None), + ): + path = ( + str(_FAKE_HOME) + + "/.claude/projects/" + + "-fakehome-user-Projects-Vera-Studio" + + "/subagents/abc/file.jsonl" + ) + result = detector._detect_from_claude_project(path) + + assert result == "VERA-STUDIO SUB" + + def test_old_fallback_segment_scan(self): + """Should fall back to segment scanning for unknown external paths.""" + mod = _import_branch_detector() + detector = _make_detector_with_branches(mod) + detector.known_branches.add("SEEDGO") + + with patch.object( + detector, + "_parse_external_project_path", + return_value=(None, None), + ): + path = str(_FAKE_HOME) + "/.claude/projects/some-random-seedgo/file.jsonl" + result = detector._detect_from_claude_project(path) + + assert result == "SEEDGO" + + def test_old_fallback_returns_none_for_empty_segments(self): + """Should return None when folder parses to no segments.""" + mod = _import_branch_detector() + detector = _make_detector_with_branches(mod) + + with patch.object( + detector, + "_parse_external_project_path", + return_value=(None, None), + ): + path = str(_FAKE_HOME) + "/.claude/projects//file.jsonl" + result = detector._detect_from_claude_project(path) + + assert result is None + + def test_old_fallback_last_segment_known(self): + """Should return last segment if it matches a known branch.""" + mod = _import_branch_detector() + detector = _make_detector_with_branches(mod) + detector.known_branches.add("CLI") + + with patch.object( + detector, + "_parse_external_project_path", + return_value=(None, None), + ): + path = str(_FAKE_HOME) + "/.claude/projects/unknown-cli/file.jsonl" + result = detector._detect_from_claude_project(path) + + assert result == "CLI" + + def test_old_fallback_returns_none_when_no_branch_match(self): + """Should return None when no segments match known branches.""" + mod = _import_branch_detector() + detector = _make_detector_with_branches(mod) + + with patch.object( + detector, + "_parse_external_project_path", + return_value=(None, None), + ): + path = str(_FAKE_HOME) + "/.claude/projects/completely-unknown-xyz/file.jsonl" + result = detector._detect_from_claude_project(path) + + assert result is None + + def test_internal_aipass_fallback_last_segment(self): + """When no known branch found, should use last segment uppercased.""" + mod = _import_branch_detector() + detector = _make_detector_with_branches(mod, branches={}) + + path = str(_FAKE_HOME) + "/.claude/projects/" + "-fakehome-user-Projects-AIPass-src-aipass-newmod/file.jsonl" + result = detector._detect_from_claude_project(path) + assert result is not None + assert "NEWMOD" in result + + +class TestDetectFromCompoundParts: + """Tests for BranchDetector._detect_from_compound_parts().""" + + def test_finds_known_branch_in_underscore_part(self): + """Should detect branch from underscore-delimited path part.""" + mod = _import_branch_detector() + detector = _make_detector_with_branches(mod) + detector.known_branches.add("MAIL") + + result = detector._detect_from_compound_parts(["src", "ai_mail", "handler"]) + assert result == "MAIL" + + def test_returns_none_when_no_match(self): + """Should return None when no underscore subparts match.""" + mod = _import_branch_detector() + detector = _make_detector_with_branches(mod) + result = detector._detect_from_compound_parts(["src", "something_else"]) + assert result is None + + def test_skips_parts_without_underscore(self): + """Should only check parts that contain underscores.""" + mod = _import_branch_detector() + detector = _make_detector_with_branches(mod) + result = detector._detect_from_compound_parts(["simple", "parts", "here"]) + assert result is None + + +class TestDetectFromExternalProjectPath: + """Tests for BranchDetector._detect_from_external_project_path().""" + + def test_path_not_under_projects(self): + """Should return None if path is not under ~/Projects/.""" + mod = _import_branch_detector() + detector = _make_detector_with_branches(mod) + + path = Path("/tmp/some/random/file.py") + with patch(f"{mod.__name__}.Path") as mock_path_cls: + mock_path_cls.home.return_value = _FAKE_HOME + + result = detector._detect_from_external_project_path(path) + + assert result is None + + def test_skips_aipass_directory(self): + """Should return None for paths under AIPass directory.""" + mod = _import_branch_detector() + detector = _make_detector_with_branches(mod) + + path = _FAKE_PROJECTS / "AIPass" / "src" / "prax" / "file.py" + with patch(f"{mod.__name__}.Path") as mock_path_cls: + mock_path_cls.home.return_value = _FAKE_HOME + + result = detector._detect_from_external_project_path(path) + + assert result is None + + def test_returns_project_with_agent(self): + """Should return PROJECT/AGENT for path with src subdirectory.""" + mod = _import_branch_detector() + detector = _make_detector_with_branches(mod) + detector._external_project_cache["AIPL"] = "AIPL" + + path = _FAKE_PROJECTS / "AIPL" / "src" / "polyglot" / "file.py" + with patch(f"{mod.__name__}.Path") as mock_path_cls: + mock_path_cls.home.return_value = _FAKE_HOME + + result = detector._detect_from_external_project_path(path) + + assert result == "AIPL/POLYGLOT" + + def test_returns_project_without_agent(self): + """Should return PROJECT for path without src subdirectory.""" + mod = _import_branch_detector() + detector = _make_detector_with_branches(mod) + detector._external_project_cache["MyApp"] = "MYAPP" + + path = _FAKE_PROJECTS / "MyApp" / "config.json" + with patch(f"{mod.__name__}.Path") as mock_path_cls: + mock_path_cls.home.return_value = _FAKE_HOME + + result = detector._detect_from_external_project_path(path) + + assert result == "MYAPP" + + def test_test_suffix_appended(self): + """Should append TESTS suffix for test-related paths.""" + mod = _import_branch_detector() + detector = _make_detector_with_branches(mod) + detector._external_project_cache["AIPL"] = "AIPL" + + path = _FAKE_PROJECTS / "AIPL" / "src" / "polyglot" / "tests" / "test_main.py" + with patch(f"{mod.__name__}.Path") as mock_path_cls: + mock_path_cls.home.return_value = _FAKE_HOME + + result = detector._detect_from_external_project_path(path) + + assert result == "AIPL/POLYGLOT TESTS" + + def test_cached_project_name(self): + """Should use cached project name on subsequent lookups.""" + mod = _import_branch_detector() + detector = _make_detector_with_branches(mod) + detector._external_project_cache["CachedProj"] = "CACHED" + + path = _FAKE_PROJECTS / "CachedProj" / "file.py" + with patch(f"{mod.__name__}.Path") as mock_path_cls: + mock_path_cls.home.return_value = _FAKE_HOME + + result = detector._detect_from_external_project_path(path) + + assert result == "CACHED" + + def test_empty_rel_parts_returns_none(self): + """Should return None if relative path has no parts.""" + mod = _import_branch_detector() + detector = _make_detector_with_branches(mod) + + with patch(f"{mod.__name__}.Path") as mock_path_cls: + mock_path_cls.home.return_value = _FAKE_HOME + + result = detector._detect_from_external_project_path(_FAKE_PROJECTS) + + assert result is None + + def test_returns_none_when_no_registry_file(self): + """Should return None when project directory has no _REGISTRY.json.""" + mod = _import_branch_detector() + detector = _make_detector_with_branches(mod) + detector._external_project_cache.clear() + + path = _FAKE_PROJECTS / "SomeProject" / "src" / "module" / "file.py" + with patch(f"{mod.__name__}.Path") as mock_path_cls: + mock_path_cls.home.return_value = _FAKE_HOME + mock_project_dir = MagicMock(spec=Path) + mock_project_dir.iterdir.return_value = iter([]) + + result = detector._detect_from_external_project_path(path) + + assert result is None + + def test_test_suffix_for_test_log_file(self): + """Should append TESTS for paths ending in _test.log.""" + mod = _import_branch_detector() + detector = _make_detector_with_branches(mod) + detector._external_project_cache["Proj"] = "PROJ" + + path = _FAKE_PROJECTS / "Proj" / "output_test.log" + with patch(f"{mod.__name__}.Path") as mock_path_cls: + mock_path_cls.home.return_value = _FAKE_HOME + + result = detector._detect_from_external_project_path(path) + + assert result == "PROJ TESTS" + + +class TestExtractBranchFromCentral: + """Tests for BranchDetector._extract_branch_from_central().""" + + def test_dot_central_json(self): + """Should extract branch name from .central.json filename.""" + mod = _import_branch_detector() + detector = _make_detector_with_branches(mod) + path = Path("/some/ai_mail/DRONE.central.json") + result = detector._extract_branch_from_central(str(path), path) + assert result == "DRONE" + + def test_underscore_central_json(self): + """Should extract branch name from _central.json filename.""" + mod = _import_branch_detector() + detector = _make_detector_with_branches(mod) + path = Path("/some/dir/ai_mail/PRAX_central.json") + result = detector._extract_branch_from_central("ai_mail/PRAX_central.json", path) + assert result == "PRAX" + + def test_returns_none_for_non_mail_path(self): + """Should return None if path doesn't contain ai_mail or AI_MAIL.""" + mod = _import_branch_detector() + detector = _make_detector_with_branches(mod) + path = Path("/some/dir/config.json") + result = detector._extract_branch_from_central(str(path), path) + assert result is None + + def test_returns_none_for_non_central_filename(self): + """Should return None for non-central filename in ai_mail path.""" + mod = _import_branch_detector() + detector = _make_detector_with_branches(mod) + path = Path("/ai_mail/inbox.json") + result = detector._extract_branch_from_central("ai_mail/inbox.json", path) + assert result is None + + +class TestDetectFromPathStrategies: + """Tests for detect_from_path covering all strategy branches.""" + + def test_cache_hit_returns_cached_value(self): + """Strategy 0: Should return cached value from log_map.""" + mod = _import_branch_detector() + detector = _make_detector_with_branches(mod) + resolved = "/cached/path" + detector.log_map[resolved] = "CACHED_BRANCH" + + with patch(f"{mod.__name__}.Path") as mock_path_cls: + mock_path = MagicMock(spec=Path) + mock_path.__str__ = MagicMock(return_value=resolved) + mock_path.resolve.return_value = mock_path + mock_path_cls.return_value = mock_path + + result = detector.detect_from_path(resolved) + + assert result == "CACHED_BRANCH" + + def test_claude_project_path_strategy(self): + """Strategy 3: Should detect from .claude/projects/ path.""" + mod = _import_branch_detector() + detector = _make_detector_with_branches(mod) + detector.known_branches.add("PRAX") + + resolved = ( + str(_FAKE_HOME) + "/.claude/projects/" + "-fakehome-user-Projects-AIPass-src-aipass-prax/session.jsonl" + ) + + with patch(f"{mod.__name__}.Path") as mock_path_cls: + mock_path = MagicMock(spec=Path) + mock_path.__str__ = MagicMock(return_value=resolved) + mock_path.resolve.return_value = mock_path + mock_path.parents = [] + mock_path.parent = MagicMock() + mock_path.parent.__eq__ = MagicMock(return_value=False) + mock_path.name = "session.jsonl" + mock_path_cls.return_value = mock_path + mock_path_cls.home.return_value = _FAKE_HOME + + detector._repo_root = MagicMock(spec=Path) + detector._repo_root.__str__ = MagicMock(return_value=str(_FAKE_PROJECTS / "AIPass")) + + result = detector.detect_from_path(resolved) + + assert "PRAX" in result + + def test_ai_mail_central_strategy(self): + """Strategy 4: Should detect from ai_mail central filename.""" + mod = _import_branch_detector() + detector = _make_detector_with_branches(mod) + + resolved = str(_FAKE_PROJECTS / "AIPass" / "src" / "aipass" / "ai_mail" / "central" / "DRONE.central.json") + + with patch(f"{mod.__name__}.Path") as mock_path_cls: + mock_path = MagicMock(spec=Path) + mock_path.__str__ = MagicMock(return_value=resolved) + mock_path.resolve.return_value = mock_path + mock_path.parents = [] + mock_path.parent = MagicMock() + mock_path.parent.__eq__ = MagicMock(return_value=False) + mock_path.name = "DRONE.central.json" + mock_path_cls.return_value = mock_path + mock_path_cls.home.return_value = _FAKE_HOME + + detector._repo_root = MagicMock(spec=Path) + detector._repo_root.__str__ = MagicMock(return_value=str(_FAKE_PROJECTS / "AIPass")) + + result = detector.detect_from_path(resolved) + + assert result == "DRONE" + + def test_root_level_system_files_strategy(self): + """Strategy 5: Should return SYSTEM for repo root files.""" + mod = _import_branch_detector() + detector = _make_detector_with_branches(mod) + + repo_root = _FAKE_PROJECTS / "AIPass" + resolved = str(repo_root / "README.md") + + with patch(f"{mod.__name__}.Path") as mock_path_cls: + mock_path = MagicMock(spec=Path) + mock_path.__str__ = MagicMock(return_value=resolved) + mock_path.resolve.return_value = mock_path + mock_path.parents = [] + mock_path.parent = repo_root + mock_path.name = "README.md" + mock_path_cls.return_value = mock_path + mock_path_cls.home.return_value = _FAKE_HOME + + detector._repo_root = repo_root + + result = detector.detect_from_path(resolved) + + assert result == "SYSTEM" + + def test_root_claude_dir_system_files(self): + """Strategy 5: Should return SYSTEM for .claude dir under repo root.""" + mod = _import_branch_detector() + detector = _make_detector_with_branches(mod) + + repo_root = _FAKE_PROJECTS / "AIPass" + resolved = str(repo_root / ".claude" / "settings.json") + + with patch(f"{mod.__name__}.Path") as mock_path_cls: + mock_path = MagicMock(spec=Path) + mock_path.__str__ = MagicMock(return_value=resolved) + mock_path.resolve.return_value = mock_path + mock_path.parents = [] + mock_path.parent = repo_root / ".claude" + mock_path.name = "settings.json" + mock_path_cls.return_value = mock_path + mock_path_cls.home.return_value = _FAKE_HOME + + detector._repo_root = repo_root + + result = detector.detect_from_path(resolved) + + assert result == "SYSTEM" + + def test_known_branch_in_path_parts_strategy(self): + """Strategy 6: Should detect branch from path parts.""" + mod = _import_branch_detector() + detector = _make_detector_with_branches(mod) + detector.known_branches.add("DRONE") + + resolved = "/some/random/drone/handler.py" + + with patch(f"{mod.__name__}.Path") as mock_path_cls: + mock_path = MagicMock(spec=Path) + mock_path.__str__ = MagicMock(return_value=resolved) + mock_path.resolve.return_value = mock_path + mock_path.parents = [] + mock_path.parent = MagicMock() + mock_path.parent.__eq__ = MagicMock(return_value=False) + mock_path.name = "handler.py" + mock_path_cls.return_value = mock_path + mock_path_cls.home.return_value = _FAKE_HOME + + detector._repo_root = MagicMock(spec=Path) + detector._repo_root.__str__ = MagicMock(return_value=str(_FAKE_PROJECTS / "AIPass")) + detector._repo_root.__truediv__ = MagicMock(return_value=MagicMock()) + + result = detector.detect_from_path(resolved) + + assert result == "DRONE" + + def test_compound_parts_detection_strategy(self): + """Strategy 6 (compound): Should detect from compound underscore parts.""" + mod = _import_branch_detector() + detector = _make_detector_with_branches(mod) + # Add WIDGET as known branch but NOT "SOME_WIDGET" — so only + # compound splitting of "some_widget" -> ["some", "widget"] can match. + detector.known_branches.add("WIDGET") + + resolved = "/unrelated/some_widget/handler.py" + + with patch(f"{mod.__name__}.Path") as mock_path_cls: + mock_path = MagicMock(spec=Path) + mock_path.__str__ = MagicMock(return_value=resolved) + mock_path.resolve.return_value = mock_path + mock_path.parents = [] + mock_path.parent = MagicMock() + mock_path.parent.__eq__ = MagicMock(return_value=False) + mock_path.name = "handler.py" + mock_path_cls.return_value = mock_path + mock_path_cls.home.return_value = _FAKE_HOME + + detector._repo_root = MagicMock(spec=Path) + detector._repo_root.__str__ = MagicMock(return_value=str(_FAKE_PROJECTS / "AIPass")) + detector._repo_root.__truediv__ = MagicMock(return_value=MagicMock()) + + result = detector.detect_from_path(resolved) + + assert result == "WIDGET" + + def test_external_project_path_priority(self): + """Strategy 0 (external): Should detect external project with priority.""" + mod = _import_branch_detector() + detector = _make_detector_with_branches(mod) + + resolved = str(_FAKE_PROJECTS / "AIPL" / "src" / "polyglot" / "tests" / "test_main.py") + + with patch(f"{mod.__name__}.Path") as mock_path_cls: + mock_path = MagicMock(spec=Path) + mock_path.__str__ = MagicMock(return_value=resolved) + mock_path.resolve.return_value = mock_path + mock_path.parents = [] + mock_path.parent = MagicMock() + mock_path.name = "test_main.py" + mock_path_cls.return_value = mock_path + mock_path_cls.home.return_value = _FAKE_HOME + + detector._repo_root = MagicMock(spec=Path) + detector._repo_root.__str__ = MagicMock(return_value=str(_FAKE_PROJECTS / "AIPass")) + + with patch.object( + detector, + "_detect_from_external_project_path", + return_value="AIPL/POLYGLOT TESTS", + ): + result = detector.detect_from_path(resolved) + + assert result == "AIPL/POLYGLOT TESTS" + + +class TestDetectFromLogAdditional: + """Additional tests for detect_from_log covering uncovered lines.""" + + def test_log_name_matches_branch_exactly(self): + """Should detect branch when log name equals known branch.""" + mod = _import_branch_detector() + detector = _make_detector_with_branches(mod) + detector.known_branches.add("BACKUP") + + result = detector.detect_from_log("backup.log") + assert result == "BACKUP" + assert "backup" in detector.log_map + + def test_cache_hit_on_stem(self): + """Should return cached stem from log_map.""" + mod = _import_branch_detector() + detector = _make_detector_with_branches(mod) + detector.log_map["cached_log"] = "CACHED" + + result = detector.detect_from_log("cached_log.log") + assert result == "CACHED" + + +class TestDetectFromModuleAdditional: + """Additional tests for detect_from_module covering uncovered lines.""" + + def test_module_cache_hit(self): + """Should return cached module from module_map.""" + mod = _import_branch_detector() + detector = _make_detector_with_branches(mod) + detector.module_map["cached.module"] = "CACHED" + + result = detector.detect_from_module("cached.module") + assert result == "CACHED" + + +class TestPublicAPIFunctions: + """Tests for module-level public API functions.""" + + def test_detect_branch_from_path_delegates(self): + """detect_branch_from_path should delegate to get_detector.""" + mod = _import_branch_detector() + mock_detector = MagicMock() + mock_detector.detect_from_path.return_value = "PRAX" + setattr(mod, "_detector_instance", mock_detector) + + result = mod.detect_branch_from_path("/some/path") + assert result == "PRAX" + mock_detector.detect_from_path.assert_called_once_with("/some/path") + + def test_detect_branch_from_log_delegates(self): + """detect_branch_from_log should delegate to get_detector.""" + mod = _import_branch_detector() + mock_detector = MagicMock() + mock_detector.detect_from_log.return_value = "FLOW" + setattr(mod, "_detector_instance", mock_detector) + + result = mod.detect_branch_from_log("flow_plan.log") + assert result == "FLOW" + mock_detector.detect_from_log.assert_called_once_with("flow_plan.log") + + def test_reload_registry_delegates(self): + """reload_registry should delegate to get_detector.""" + mod = _import_branch_detector() + mock_detector = MagicMock() + setattr(mod, "_detector_instance", mock_detector) + + mod.reload_registry() + mock_detector.reload_registry.assert_called_once() diff --git a/src/aipass/prax/tests/test_operations.py b/src/aipass/prax/tests/test_operations.py index 019e60a9..d7509637 100644 --- a/src/aipass/prax/tests/test_operations.py +++ b/src/aipass/prax/tests/test_operations.py @@ -1318,3 +1318,624 @@ class TestPrintTemplate: mod = _load_dashboard_module() # Should not raise mod.print_template() + + +# ============================================= +# print_introspection (dashboard.py) +# ============================================= + + +class TestDashboardPrintIntrospection: + """Tests for print_introspection -- CLI module info display.""" + + def test_prints_introspection_without_error(self): + """Introspection output runs without raising.""" + mod = _load_dashboard_module() + mod.print_introspection() + + def test_introspection_lists_template_sections(self): + """Introspection lists all template section names.""" + mod = _load_dashboard_module() + mod.print_introspection() + # console.print should have been called multiple times + assert mod.console.print.call_count > 3 + + +# ============================================= +# print_help (dashboard.py) +# ============================================= + + +class TestDashboardPrintHelp: + """Tests for print_help -- CLI help output.""" + + def test_prints_help_without_error(self): + """Help output runs without raising.""" + mod = _load_dashboard_module() + mod.print_help() + + def test_help_lists_commands(self): + """Help output includes subcommand names.""" + mod = _load_dashboard_module() + mod.print_help() + assert mod.console.print.call_count > 5 + + +# ============================================= +# handle_command (dashboard.py) +# ============================================= + + +class TestDashboardHandleCommand: + """Tests for handle_command -- command routing.""" + + def test_returns_false_for_non_dashboard_command(self): + """handle_command returns False for commands other than 'dashboard'.""" + mod = _load_dashboard_module() + assert mod.handle_command("monitor", []) is False + + def test_no_args_calls_print_introspection(self): + """Bare 'dashboard' with no args shows introspection.""" + mod = _load_dashboard_module() + from unittest.mock import patch as _patch + + with _patch.object(mod, "print_introspection") as mock_intro: + result = mod.handle_command("dashboard", []) + assert result is True + mock_intro.assert_called_once() + + def test_help_flag_calls_print_help(self): + """--help flag shows help and returns True.""" + mod = _load_dashboard_module() + from unittest.mock import patch as _patch + + for flag in ("--help", "-h", "help"): + with _patch.object(mod, "print_help") as mock_help: + result = mod.handle_command("dashboard", [flag]) + assert result is True + mock_help.assert_called_once() + + def test_status_subcommand(self): + """'status' subcommand dispatches to print_status.""" + mod = _load_dashboard_module() + from unittest.mock import patch as _patch + + with _patch.object(mod, "print_status") as mock_status: + result = mod.handle_command("dashboard", ["status"]) + assert result is True + mock_status.assert_called_once() + + def test_template_subcommand(self): + """'template' subcommand dispatches to print_template.""" + mod = _load_dashboard_module() + from unittest.mock import patch as _patch + + with _patch.object(mod, "print_template") as mock_tmpl: + result = mod.handle_command("dashboard", ["template"]) + assert result is True + mock_tmpl.assert_called_once() + + def test_refresh_subcommand(self): + """'refresh' subcommand dispatches to _handle_refresh.""" + mod = _load_dashboard_module() + from unittest.mock import patch as _patch + + with _patch.object(mod, "_handle_refresh") as mock_refresh: + result = mod.handle_command("dashboard", ["refresh"]) + assert result is True + mock_refresh.assert_called_once_with([]) + + def test_refresh_subcommand_passes_args(self): + """'refresh' subcommand forwards trailing args.""" + mod = _load_dashboard_module() + from unittest.mock import patch as _patch + + with _patch.object(mod, "_handle_refresh") as mock_refresh: + mod.handle_command("dashboard", ["refresh", "--all"]) + mock_refresh.assert_called_once_with(["--all"]) + + def test_push_template_subcommand(self): + """'push-template' subcommand dispatches to _handle_push_template.""" + mod = _load_dashboard_module() + from unittest.mock import patch as _patch + + with _patch.object(mod, "_handle_push_template") as mock_push: + result = mod.handle_command("dashboard", ["push-template"]) + assert result is True + mock_push.assert_called_once_with([]) + + def test_diff_template_subcommand(self): + """'diff-template' subcommand dispatches to _handle_diff_template.""" + mod = _load_dashboard_module() + from unittest.mock import patch as _patch + + with _patch.object(mod, "_handle_diff_template") as mock_diff: + result = mod.handle_command("dashboard", ["diff-template"]) + assert result is True + mock_diff.assert_called_once_with([]) + + def test_template_status_subcommand(self): + """'template-status' subcommand dispatches to _handle_template_status.""" + mod = _load_dashboard_module() + from unittest.mock import patch as _patch + + with _patch.object(mod, "_handle_template_status") as mock_ts: + result = mod.handle_command("dashboard", ["template-status"]) + assert result is True + mock_ts.assert_called_once() + + def test_unknown_subcommand_shows_help(self): + """Unknown subcommand shows help and returns True.""" + mod = _load_dashboard_module() + from unittest.mock import patch as _patch + + with _patch.object(mod, "print_help") as mock_help: + result = mod.handle_command("dashboard", ["bogus"]) + assert result is True + mock_help.assert_called_once() + + +# ============================================= +# _handle_refresh (dashboard.py) +# ============================================= + + +class TestHandleRefresh: + """Tests for _handle_refresh -- refresh command handler.""" + + def test_refresh_all(self, monkeypatch): + """--all flag refreshes all branches.""" + mod = _load_dashboard_module() + monkeypatch.setattr( + mod, + "refresh_all_dashboards", + lambda: {"status": "success", "branches_updated": 3}, + ) + mod._handle_refresh(["--all"]) + + def test_refresh_all_partial(self, monkeypatch): + """Partial success path for --all refresh.""" + mod = _load_dashboard_module() + monkeypatch.setattr( + mod, + "refresh_all_dashboards", + lambda: { + "status": "partial", + "branches_updated": 2, + "branches_failed": 1, + "errors": ["branch X failed"], + }, + ) + mod._handle_refresh(["--all"]) + + def test_refresh_all_failure(self, monkeypatch): + """Full failure path for --all refresh.""" + mod = _load_dashboard_module() + monkeypatch.setattr( + mod, + "refresh_all_dashboards", + lambda: { + "status": "error", + "branches_updated": 0, + "branches_failed": 3, + "errors": ["total failure"], + }, + ) + mod._handle_refresh(["--all"]) + + def test_refresh_specific_branch_success(self, tmp_path, monkeypatch): + """@branch arg refreshes specific branch on success.""" + mod = _load_dashboard_module() + monkeypatch.setattr(mod, "_resolve_branch_path", lambda ref: tmp_path / "flow") + monkeypatch.setattr( + mod, + "refresh_single_dashboard", + lambda bp: {"status": "success", "branch": "FLOW"}, + ) + mod._handle_refresh(["@flow"]) + + def test_refresh_specific_branch_failure(self, tmp_path, monkeypatch): + """@branch arg shows error on refresh failure.""" + mod = _load_dashboard_module() + monkeypatch.setattr(mod, "_resolve_branch_path", lambda ref: tmp_path / "flow") + monkeypatch.setattr( + mod, + "refresh_single_dashboard", + lambda bp: {"status": "error", "error": "disk full"}, + ) + mod._handle_refresh(["@flow"]) + + def test_refresh_specific_branch_not_found(self, monkeypatch): + """@branch arg handles branch not found in registry.""" + mod = _load_dashboard_module() + + def _raise(ref): + """Simulate branch not found.""" + raise FileNotFoundError("not found in registry") + + monkeypatch.setattr(mod, "_resolve_branch_path", _raise) + mod._handle_refresh(["@ghost"]) + + def test_refresh_cwd_success(self, tmp_path, monkeypatch): + """No args refreshes current branch from CWD.""" + mod = _load_dashboard_module() + from unittest.mock import patch as _patch + + monkeypatch.setattr( + mod, + "refresh_single_dashboard", + lambda bp: {"status": "success", "branch": "PRAX"}, + ) + with _patch("pathlib.Path.cwd", return_value=tmp_path): + mod._handle_refresh([]) + + def test_refresh_cwd_failure(self, tmp_path, monkeypatch): + """No args shows error on CWD refresh failure.""" + mod = _load_dashboard_module() + from unittest.mock import patch as _patch + + monkeypatch.setattr( + mod, + "refresh_single_dashboard", + lambda bp: {"status": "error", "error": "no dashboard"}, + ) + with _patch("pathlib.Path.cwd", return_value=tmp_path): + mod._handle_refresh([]) + + def test_refresh_cwd_walks_up_to_find_branch(self, tmp_path, monkeypatch): + """No args walks up directory tree to find branch root.""" + mod = _load_dashboard_module() + from unittest.mock import patch as _patch + + # Create a branch root with .aipass marker above CWD + branch_root = tmp_path / "src" / "aipass" / "flow" + branch_root.mkdir(parents=True) + (branch_root / ".aipass").mkdir() + deep_cwd = branch_root / "apps" / "modules" + deep_cwd.mkdir(parents=True) + + monkeypatch.setattr( + mod, + "refresh_single_dashboard", + lambda bp: {"status": "success", "branch": bp.name.upper()}, + ) + with _patch("pathlib.Path.cwd", return_value=deep_cwd): + mod._handle_refresh([]) + + +# ============================================= +# _handle_push_template (dashboard.py) +# ============================================= + + +class TestHandlePushTemplate: + """Tests for _handle_push_template -- push template handler.""" + + def test_push_template_with_changes(self, monkeypatch): + """Push template shows changes when branches need updating.""" + mod = _load_dashboard_module() + monkeypatch.setattr( + mod, + "push_dashboard_template", + lambda dry_run=False: { + "branches_scanned": 5, + "branches_updated": 2, + "branches_created": 1, + "branches_skipped": 2, + "changes": [ + { + "branch": "FLOW", + "actions": ["removed bulletin_board"], + } + ], + "errors": [], + }, + ) + mod._handle_push_template([]) + + def test_push_template_dry_run(self, monkeypatch): + """Dry run flag is forwarded to push_dashboard_template.""" + mod = _load_dashboard_module() + captured: dict[str, object] = {} + + def _mock_push(dry_run=False): + """Capture dry_run flag.""" + captured["dry_run"] = dry_run + return { + "branches_scanned": 3, + "branches_updated": 0, + "branches_created": 0, + "branches_skipped": 3, + "changes": [], + "errors": [], + } + + monkeypatch.setattr(mod, "push_dashboard_template", _mock_push) + mod._handle_push_template(["--dry-run"]) + assert captured["dry_run"] is True + + def test_push_template_with_errors(self, monkeypatch): + """Push template shows errors when some branches fail.""" + mod = _load_dashboard_module() + monkeypatch.setattr( + mod, + "push_dashboard_template", + lambda dry_run=False: { + "branches_scanned": 3, + "branches_updated": 1, + "branches_created": 0, + "branches_skipped": 1, + "changes": [], + "errors": ["branch X: permission denied"], + }, + ) + mod._handle_push_template([]) + + def test_push_template_all_up_to_date(self, monkeypatch): + """Push template shows 'all up to date' message.""" + mod = _load_dashboard_module() + monkeypatch.setattr( + mod, + "push_dashboard_template", + lambda dry_run=False: { + "branches_scanned": 3, + "branches_updated": 0, + "branches_created": 0, + "branches_skipped": 3, + "changes": [], + "errors": [], + }, + ) + mod._handle_push_template([]) + + +# ============================================= +# _handle_diff_template (dashboard.py) +# ============================================= + + +class TestHandleDiffTemplate: + """Tests for _handle_diff_template -- diff template handler.""" + + def test_diff_template_error(self, monkeypatch): + """Diff template shows error when result has error key.""" + mod = _load_dashboard_module() + monkeypatch.setattr( + mod, + "diff_dashboard_template", + lambda branch_name=None: {"error": "template not found"}, + ) + mod._handle_diff_template([]) + + def test_diff_template_with_branch_flag(self, monkeypatch): + """--branch flag filters diff to single branch.""" + mod = _load_dashboard_module() + captured: dict[str, object] = {} + + def _mock_diff(branch_name=None): + """Capture branch_name arg.""" + captured["branch_name"] = branch_name + return { + "summary": { + "needs_update": 0, + "up_to_date": 1, + "missing": 0, + }, + "branches": [], + } + + monkeypatch.setattr(mod, "diff_dashboard_template", _mock_diff) + mod._handle_diff_template(["--branch", "FLOW"]) + assert captured["branch_name"] == "FLOW" + + def test_diff_template_branch_flag_missing_name(self): + """--branch without name prints error.""" + mod = _load_dashboard_module() + mod._handle_diff_template(["--branch"]) + mod.error.assert_called() + + def test_diff_template_with_changes(self, monkeypatch): + """Diff template shows additions, removals, and modifications.""" + mod = _load_dashboard_module() + monkeypatch.setattr( + mod, + "diff_dashboard_template", + lambda branch_name=None: { + "summary": { + "needs_update": 1, + "up_to_date": 1, + "missing": 1, + "invalid_json": 1, + }, + "branches": [ + { + "branch": "FLOW", + "status": "needs_update", + "additions": ["+ commons_activity"], + "removals": ["- bulletin_board"], + "modifications": ["~ ai_mail.new default changed"], + }, + {"branch": "MISSING", "status": "missing"}, + {"branch": "GOOD", "status": "up_to_date"}, + ], + }, + ) + mod._handle_diff_template([]) + + +# ============================================= +# _handle_template_status (dashboard.py) +# ============================================= + + +class TestHandleTemplateStatus: + """Tests for _handle_template_status -- template status display.""" + + def test_template_status_basic(self, monkeypatch): + """Template status displays version info.""" + mod = _load_dashboard_module() + monkeypatch.setattr( + mod, + "get_template_status", + lambda: { + "templates_dir": "/path/to/templates", + "template_exists": True, + "version": "3.0.0", + "last_updated": "2026-03-01", + "updated_by": "prax", + "last_push": "2026-03-02", + "last_push_branches": ["FLOW", "AI_MAIL"], + "changes": [], + }, + ) + mod._handle_template_status() + + def test_template_status_with_many_branches(self, monkeypatch): + """Template status truncates branch list at 5 with ellipsis.""" + mod = _load_dashboard_module() + monkeypatch.setattr( + mod, + "get_template_status", + lambda: { + "templates_dir": "/path/to/templates", + "template_exists": True, + "version": "3.0.0", + "last_updated": "2026-03-01", + "updated_by": "prax", + "last_push": "2026-03-02", + "last_push_branches": [ + "A", + "B", + "C", + "D", + "E", + "F", + "G", + ], + "changes": ["added commons"], + }, + ) + mod._handle_template_status() + + def test_template_status_missing(self, monkeypatch): + """Template status handles missing template.""" + mod = _load_dashboard_module() + monkeypatch.setattr( + mod, + "get_template_status", + lambda: { + "templates_dir": "/path/to/templates", + "template_exists": False, + "version": None, + "last_updated": None, + "updated_by": None, + "last_push": None, + "last_push_branches": [], + "changes": [], + }, + ) + mod._handle_template_status() + + def test_template_status_no_push(self, monkeypatch): + """Template status handles never-pushed template.""" + mod = _load_dashboard_module() + monkeypatch.setattr( + mod, + "get_template_status", + lambda: { + "templates_dir": "/path/to/templates", + "template_exists": True, + "version": "1.0.0", + "last_updated": "2026-01-01", + "updated_by": "dev", + "last_push": None, + "last_push_branches": [], + "changes": [], + }, + ) + mod._handle_template_status() + + +# ============================================= +# _resolve_branch_path (dashboard.py wrapper) +# ============================================= + + +class TestResolveBranchPathWrapper: + """Tests for _resolve_branch_path -- dashboard module wrapper.""" + + def test_delegates_to_handler(self, monkeypatch): + """Wrapper delegates to resolve_branch_path handler.""" + mod = _load_dashboard_module() + from pathlib import Path + + monkeypatch.setattr( + mod, + "resolve_branch_path", + lambda ref: Path("/fake/flow"), + ) + result = mod._resolve_branch_path("@flow") + assert result == Path("/fake/flow") + + +# ============================================= +# main (dashboard.py) +# ============================================= + + +class TestDashboardMain: + """Tests for main -- CLI entry point.""" + + def test_main_no_args(self, monkeypatch): + """No args shows introspection.""" + mod = _load_dashboard_module() + from unittest.mock import patch as _patch + + monkeypatch.setattr(sys, "argv", ["dashboard"]) + with _patch.object(mod, "print_introspection") as mock_intro: + mod.main() + mock_intro.assert_called_once() + + def test_main_help_flag(self, monkeypatch): + """--help flag shows help.""" + mod = _load_dashboard_module() + from unittest.mock import patch as _patch + + monkeypatch.setattr(sys, "argv", ["dashboard", "--help"]) + with _patch.object(mod, "print_help") as mock_help: + mod.main() + mock_help.assert_called_once() + + def test_main_h_flag(self, monkeypatch): + """-h flag shows help.""" + mod = _load_dashboard_module() + from unittest.mock import patch as _patch + + monkeypatch.setattr(sys, "argv", ["dashboard", "-h"]) + with _patch.object(mod, "print_help") as mock_help: + mod.main() + mock_help.assert_called_once() + + def test_main_valid_command(self, monkeypatch): + """Valid command delegates to handle_command.""" + mod = _load_dashboard_module() + from unittest.mock import patch as _patch + + monkeypatch.setattr(sys, "argv", ["dashboard", "dashboard", "status"]) + with _patch.object(mod, "handle_command", return_value=True) as mock_hc: + mod.main() + mock_hc.assert_called_once_with("dashboard", ["status"]) + + def test_main_unknown_command(self, monkeypatch): + """Unknown command shows error and help.""" + mod = _load_dashboard_module() + from unittest.mock import patch as _patch + + monkeypatch.setattr(sys, "argv", ["dashboard", "bogus"]) + with ( + _patch.object(mod, "handle_command", return_value=False), + _patch.object(mod, "print_help") as mock_help, + ): + mod.main() + mock_help.assert_called_once() + mod.error.assert_called() diff --git a/src/aipass/seedgo/tests/test_coverage_arch_checklist.py b/src/aipass/seedgo/tests/test_coverage_arch_checklist.py new file mode 100644 index 00000000..c350201c --- /dev/null +++ b/src/aipass/seedgo/tests/test_coverage_arch_checklist.py @@ -0,0 +1,1571 @@ +"""Tests for architecture_check and checklist coverage gaps.""" + +# =================== AIPass ==================== +# Name: test_coverage_arch_checklist.py +# Description: Line-coverage tests for architecture_check.py and checklist.py +# Version: 1.0.0 +# Created: 2026-04-26 +# Modified: 2026-04-26 +# ============================================= + +import json +from pathlib import Path +from typing import List + +import pytest +from unittest.mock import MagicMock + + +def _lines(text: str) -> List[str]: + """Split text into lines, widening LiteralString to str for pyright.""" + return text.split("\n") + + +# --------------------------------------------------------------------------- +# Fixtures +# --------------------------------------------------------------------------- + + +@pytest.fixture(autouse=True) +def _mock_infrastructure(monkeypatch): + """Mock heavy infrastructure imports for architecture_check and checklist.""" + import sys + + mock_logger = MagicMock() + mock_console = MagicMock() + mock_error = MagicMock() + mock_json_handler = MagicMock() + mock_json_handler.log_operation = MagicMock(return_value=True) + + # -- prax --------------------------------------------------------------- + prax_mod = MagicMock() + prax_mod.logger = mock_logger + monkeypatch.setitem(sys.modules, "aipass.prax", prax_mod) + + # -- cli ---------------------------------------------------------------- + cli_mod = MagicMock() + cli_mod.console = mock_console + monkeypatch.setitem(sys.modules, "aipass.cli", cli_mod) + + cli_apps = MagicMock() + monkeypatch.setitem(sys.modules, "aipass.cli.apps", cli_apps) + + cli_modules = MagicMock() + cli_modules.error = mock_error + monkeypatch.setitem(sys.modules, "aipass.cli.apps.modules", cli_modules) + + # -- seedgo json handler ------------------------------------------------ + json_pkg = MagicMock() + json_pkg.json_handler = mock_json_handler + monkeypatch.setitem(sys.modules, "aipass.seedgo.apps.handlers.json", json_pkg) + json_mod = MagicMock() + json_mod.log_operation = mock_json_handler.log_operation + monkeypatch.setitem( + sys.modules, + "aipass.seedgo.apps.handlers.json.json_handler", + json_mod, + ) + + # -- bypass handler (used by architecture_check) ------------------------ + bypass_pkg = MagicMock() + bypass_ignore = MagicMock() + bypass_ignore.get_template_ignore_patterns = MagicMock(return_value=[]) + bypass_pkg.ignore_handler = bypass_ignore + monkeypatch.setitem(sys.modules, "aipass.seedgo.apps.handlers.bypass", bypass_pkg) + monkeypatch.setitem( + sys.modules, + "aipass.seedgo.apps.handlers.bypass.ignore_handler", + bypass_ignore, + ) + + # -- bypass handler (used by checklist) --------------------------------- + bypass_handler_mod = MagicMock() + bypass_handler_mod.get_branch_from_path = MagicMock(return_value=None) + bypass_handler_mod.load_bypass_rules = MagicMock(return_value=[]) + bypass_handler_mod._find_registry = MagicMock(return_value=Path("/fake/registry")) + monkeypatch.setitem( + sys.modules, + "aipass.seedgo.apps.handlers.bypass.bypass_handler", + bypass_handler_mod, + ) + + # -- branch_audit (discover_checkers) ------------------------------------ + audit_pkg = MagicMock() + monkeypatch.setitem(sys.modules, "aipass.seedgo.apps.handlers.audit", audit_pkg) + branch_audit_mod = MagicMock() + branch_audit_mod.discover_checkers = MagicMock(return_value={}) + monkeypatch.setitem( + sys.modules, + "aipass.seedgo.apps.handlers.audit.branch_audit", + branch_audit_mod, + ) + + # Force re-imports so modules pick up fresh mocks + for mod_name in [ + "aipass.seedgo.apps.handlers.aipass_standards.architecture_check", + "aipass.seedgo.apps.modules.checklist", + ]: + monkeypatch.delitem(sys.modules, mod_name, raising=False) + + +# =========================================================================== +# 1. architecture_check — is_bypassed +# =========================================================================== + + +class TestIsBypassed: + """Tests for is_bypassed helper.""" + + def test_no_bypass_rules_returns_false(self): + from aipass.seedgo.apps.handlers.aipass_standards.architecture_check import ( + is_bypassed, + ) + + assert is_bypassed("/some/file.py", "architecture", bypass_rules=None) is False + + def test_empty_bypass_rules_returns_false(self): + from aipass.seedgo.apps.handlers.aipass_standards.architecture_check import ( + is_bypassed, + ) + + assert is_bypassed("/some/file.py", "architecture", bypass_rules=[]) is False + + def test_matching_standard_and_file(self): + from aipass.seedgo.apps.handlers.aipass_standards.architecture_check import ( + is_bypassed, + ) + + rules = [{"standard": "architecture", "file": "file.py"}] + assert is_bypassed("/some/file.py", "architecture", bypass_rules=rules) is True + + def test_non_matching_standard(self): + from aipass.seedgo.apps.handlers.aipass_standards.architecture_check import ( + is_bypassed, + ) + + rules = [{"standard": "cli", "file": "file.py"}] + assert is_bypassed("/some/file.py", "architecture", bypass_rules=rules) is False + + def test_non_matching_file(self): + from aipass.seedgo.apps.handlers.aipass_standards.architecture_check import ( + is_bypassed, + ) + + rules = [{"standard": "architecture", "file": "other.py"}] + assert is_bypassed("/some/file.py", "architecture", bypass_rules=rules) is False + + def test_line_specific_bypass_match(self): + from aipass.seedgo.apps.handlers.aipass_standards.architecture_check import ( + is_bypassed, + ) + + rules = [{"standard": "architecture", "file": "file.py", "lines": [10, 20]}] + assert is_bypassed("/some/file.py", "architecture", line=10, bypass_rules=rules) is True + + def test_line_specific_bypass_no_match(self): + from aipass.seedgo.apps.handlers.aipass_standards.architecture_check import ( + is_bypassed, + ) + + rules = [{"standard": "architecture", "file": "file.py", "lines": [10, 20]}] + assert is_bypassed("/some/file.py", "architecture", line=15, bypass_rules=rules) is False + + def test_rule_without_standard_matches(self): + """Rule with no 'standard' key matches any standard.""" + from aipass.seedgo.apps.handlers.aipass_standards.architecture_check import ( + is_bypassed, + ) + + rules = [{"file": "file.py"}] + assert is_bypassed("/some/file.py", "architecture", bypass_rules=rules) is True + + def test_rule_without_file_matches(self): + """Rule with no 'file' key matches any file.""" + from aipass.seedgo.apps.handlers.aipass_standards.architecture_check import ( + is_bypassed, + ) + + rules = [{"standard": "architecture"}] + assert is_bypassed("/some/file.py", "architecture", bypass_rules=rules) is True + + +# =========================================================================== +# 2. architecture_check — check_module (integration) +# =========================================================================== + + +class TestCheckModule: + """Tests for check_module orchestration function.""" + + def test_bypassed_file(self, tmp_path): + """File with full architecture bypass returns passed with score 100.""" + from aipass.seedgo.apps.handlers.aipass_standards.architecture_check import ( + check_module, + ) + + f = tmp_path / "thing.py" + f.write_text("x = 1\n", encoding="utf-8") + rules = [{"standard": "architecture", "file": "thing.py"}] + result = check_module(str(f), bypass_rules=rules) + assert result["passed"] is True + assert result["score"] == 100 + assert result["checks"][0]["name"] == "Bypassed" + + def test_nonexistent_file(self): + """Missing file returns passed=False and score=0.""" + from aipass.seedgo.apps.handlers.aipass_standards.architecture_check import ( + check_module, + ) + + result = check_module("/nonexistent/path/file.py") + assert result["passed"] is False + assert result["score"] == 0 + assert "not found" in result["checks"][0]["message"].lower() + + def test_unreadable_file(self, tmp_path): + """File that raises on read returns error result.""" + from aipass.seedgo.apps.handlers.aipass_standards.architecture_check import ( + check_module, + ) + + # Create a directory with the same name to cause read error + bad = tmp_path / "bad.py" + bad.mkdir() + result = check_module(str(bad)) + assert result["passed"] is False + assert result["score"] == 0 + + def test_handler_file_runs_independence_and_domain_checks(self, tmp_path): + """Handler file runs handler independence and domain organization checks.""" + from aipass.seedgo.apps.handlers.aipass_standards.architecture_check import ( + check_module, + ) + + handler_dir = tmp_path / "branch" / "apps" / "handlers" / "json" + handler_dir.mkdir(parents=True) + f = handler_dir / "json_handler.py" + f.write_text("# handler\ndef do_work():\n return True\n", encoding="utf-8") + result = check_module(str(f)) + check_names = [c["name"] for c in result["checks"]] + assert "Handler independence" in check_names + assert "Domain organization" in check_names + + def test_init_file_skips_layer_check(self, tmp_path): + """__init__.py files skip the layer location check.""" + from aipass.seedgo.apps.handlers.aipass_standards.architecture_check import ( + check_module, + ) + + d = tmp_path / "branch" / "apps" / "modules" + d.mkdir(parents=True) + f = d / "__init__.py" + f.write_text("# init\n", encoding="utf-8") + result = check_module(str(f)) + check_names = [c["name"] for c in result["checks"]] + assert "3-layer pattern" not in check_names + + def test_entry_point_primary_triggers_template_baseline(self, tmp_path): + """Primary entry point (branch.py matching branch dir name) triggers template baseline.""" + from aipass.seedgo.apps.handlers.aipass_standards.architecture_check import ( + check_module, + ) + + branch = tmp_path / "mybranch" + apps = branch / "apps" + apps.mkdir(parents=True) + entry = apps / "mybranch.py" + entry.write_text("# entry\n", encoding="utf-8") + # No passport -> template baseline will fail at citizen_class step + result = check_module(str(entry)) + check_names = [c["name"] for c in result["checks"]] + assert any("Template baseline" in n or "citizen_class" in str(c) for n, c in zip(check_names, result["checks"])) + + def test_secondary_entry_point_skips_template_baseline(self, tmp_path): + """Secondary entry point (name != branch dir) does NOT trigger template baseline.""" + from aipass.seedgo.apps.handlers.aipass_standards.architecture_check import ( + check_module, + ) + + branch = tmp_path / "mybranch" + apps = branch / "apps" + apps.mkdir(parents=True) + entry = apps / "daemon_wakeup.py" + entry.write_text("# daemon\n", encoding="utf-8") + result = check_module(str(entry)) + check_names = [c["name"] for c in result["checks"]] + assert not any("Template baseline" in n for n in check_names) + + def test_score_calculation_75_threshold(self, tmp_path): + """Score >= 75 passes, score < 75 fails.""" + from aipass.seedgo.apps.handlers.aipass_standards.architecture_check import ( + check_module, + ) + + # File outside 3-layer pattern, but under size limit -> 1 pass, 1 fail = 50% + d = tmp_path / "random" + d.mkdir() + f = d / "thing.py" + f.write_text("x = 1\n", encoding="utf-8") + result = check_module(str(f)) + assert result["score"] == 50 + assert result["passed"] is False + + +# =========================================================================== +# 3. architecture_check — check_file_size edge cases +# =========================================================================== + + +class TestCheckFileSizeEdgeCases: + """Edge cases for file size boundaries.""" + + def test_exactly_300_lines(self): + from aipass.seedgo.apps.handlers.aipass_standards.architecture_check import ( + check_file_size, + ) + + lines = ["x"] * 300 + result = check_file_size(lines, "f.py") + assert result["passed"] is True + assert "good" in result["message"] + + def test_exactly_500_lines(self): + from aipass.seedgo.apps.handlers.aipass_standards.architecture_check import ( + check_file_size, + ) + + lines = ["x"] * 500 + result = check_file_size(lines, "f.py") + assert result["passed"] is True + assert "getting heavy" in result["message"] + + def test_exactly_700_lines(self): + from aipass.seedgo.apps.handlers.aipass_standards.architecture_check import ( + check_file_size, + ) + + lines = ["x"] * 700 + result = check_file_size(lines, "f.py") + assert result["passed"] is False + assert "consider splitting" in result["message"] + + def test_empty_file(self): + from aipass.seedgo.apps.handlers.aipass_standards.architecture_check import ( + check_file_size, + ) + + result = check_file_size([], "f.py") + assert result["passed"] is True + assert "perfect" in result["message"] + + +# =========================================================================== +# 4. architecture_check — check_handler_independence edge cases +# =========================================================================== + + +class TestHandlerIndependenceEdgeCases: + """Edge cases for handler independence.""" + + def test_import_with_comment(self): + """Import followed by a comment is still checked.""" + from aipass.seedgo.apps.handlers.aipass_standards.architecture_check import ( + check_handler_independence, + ) + + lines = [ + "from seedgo.apps.modules.audit import run # inline comment", + ] + result = check_handler_independence(lines, "/seedgo/apps/handlers/json/j.py") + assert result is not None + assert result["passed"] is False + + def test_no_parent_branch_detected_generic_fail(self): + """When parent branch cannot be determined, generic fail message is used.""" + from aipass.seedgo.apps.handlers.aipass_standards.architecture_check import ( + check_handler_independence, + ) + + lines = [ + "from something.apps.modules.stuff import thing", + ] + # Path with no 'apps' segment means parent_branch stays None + result = check_handler_independence(lines, "/random/path/handler.py") + assert result is not None + assert result["passed"] is False + assert "branch module" in result["message"] + + def test_single_line_docstring_skipped(self): + """Single-line docstring with import text is skipped.""" + from aipass.seedgo.apps.handlers.aipass_standards.architecture_check import ( + check_handler_independence, + ) + + lines = [ + '"""from seedgo.apps.modules.audit import run"""', + "def work():", + " pass", + ] + result = check_handler_independence(lines, "/seedgo/apps/handlers/json/j.py") + assert result is not None + assert result["passed"] is True + + def test_single_quote_docstring(self): + """Single-quote triple-quoted docstrings are handled correctly.""" + from aipass.seedgo.apps.handlers.aipass_standards.architecture_check import ( + check_handler_independence, + ) + + lines = [ + "'''", + "from seedgo.apps.modules.audit import run", + "'''", + "def work():", + " pass", + ] + result = check_handler_independence(lines, "/seedgo/apps/handlers/json/j.py") + assert result is not None + assert result["passed"] is True + + def test_empty_module_path(self): + """Empty module path does not crash.""" + from aipass.seedgo.apps.handlers.aipass_standards.architecture_check import ( + check_handler_independence, + ) + + lines = ["import os"] + result = check_handler_independence(lines, "") + assert result is not None + assert result["passed"] is True + + def test_comment_line_skipped(self): + """Comment lines are skipped.""" + from aipass.seedgo.apps.handlers.aipass_standards.architecture_check import ( + check_handler_independence, + ) + + lines = [ + "# from seedgo.apps.modules.audit import run", + ] + result = check_handler_independence(lines, "/seedgo/apps/handlers/json/j.py") + assert result is not None + assert result["passed"] is True + + +# =========================================================================== +# 5. architecture_check — check_domain_organization edge cases +# =========================================================================== + + +class TestDomainOrganizationEdgeCases: + """Additional domain organization tests.""" + + def test_common_technical_name_fails(self): + from aipass.seedgo.apps.handlers.aipass_standards.architecture_check import ( + check_domain_organization, + ) + + result = check_domain_organization("/branch/apps/handlers/common/file.py") + assert result is not None + assert result["passed"] is False + + def test_shared_technical_name_fails(self): + from aipass.seedgo.apps.handlers.aipass_standards.architecture_check import ( + check_domain_organization, + ) + + result = check_domain_organization("/branch/apps/handlers/shared/file.py") + assert result is not None + assert result["passed"] is False + + def test_lib_technical_name_fails(self): + from aipass.seedgo.apps.handlers.aipass_standards.architecture_check import ( + check_domain_organization, + ) + + result = check_domain_organization("/branch/apps/handlers/lib/file.py") + assert result is not None + assert result["passed"] is False + + def test_operations_technical_name_fails(self): + from aipass.seedgo.apps.handlers.aipass_standards.architecture_check import ( + check_domain_organization, + ) + + result = check_domain_organization("/branch/apps/handlers/operations/file.py") + assert result is not None + assert result["passed"] is False + + def test_handler_domain_is_file(self): + """When handler path has file directly in handlers/, domain is the filename.""" + from aipass.seedgo.apps.handlers.aipass_standards.architecture_check import ( + check_domain_organization, + ) + + result = check_domain_organization("/branch/apps/handlers/my_handler.py") + assert result is not None + # my_handler.py is the "domain" — not a technical name, so it passes + assert result["passed"] is True + + +# =========================================================================== +# 6. architecture_check — template baseline helpers +# =========================================================================== + + +class TestLoadIgnorePatterns: + """Tests for _load_ignore_patterns.""" + + def test_no_ignore_file(self, tmp_path): + from aipass.seedgo.apps.handlers.aipass_standards.architecture_check import ( + _load_ignore_patterns, + ) + + result = _load_ignore_patterns(tmp_path) + assert result == {"ignore_files": [], "ignore_patterns": []} + + def test_valid_ignore_file(self, tmp_path): + from aipass.seedgo.apps.handlers.aipass_standards.architecture_check import ( + _load_ignore_patterns, + ) + + spawn_dir = tmp_path / ".spawn" + spawn_dir.mkdir() + ignore = spawn_dir / ".registry_ignore.json" + ignore.write_text( + json.dumps({"ignore_files": ["README.md"], "ignore_patterns": ["*.tmp"]}), + encoding="utf-8", + ) + result = _load_ignore_patterns(tmp_path) + assert result["ignore_files"] == ["README.md"] + assert result["ignore_patterns"] == ["*.tmp"] + + def test_malformed_ignore_file(self, tmp_path): + from aipass.seedgo.apps.handlers.aipass_standards.architecture_check import ( + _load_ignore_patterns, + ) + + spawn_dir = tmp_path / ".spawn" + spawn_dir.mkdir() + ignore = spawn_dir / ".registry_ignore.json" + ignore.write_text("not json", encoding="utf-8") + result = _load_ignore_patterns(tmp_path) + assert result == {"ignore_files": [], "ignore_patterns": []} + + +class TestShouldIgnore: + """Tests for _should_ignore.""" + + def test_exact_filename_match(self, tmp_path): + from aipass.seedgo.apps.handlers.aipass_standards.architecture_check import ( + _should_ignore, + ) + + item = tmp_path / "README.md" + config = {"ignore_files": ["README.md"], "ignore_patterns": []} + assert _should_ignore(item, config) is True + + def test_star_suffix_pattern(self, tmp_path): + from aipass.seedgo.apps.handlers.aipass_standards.architecture_check import ( + _should_ignore, + ) + + item = tmp_path / "data.tmp" + config = {"ignore_files": [], "ignore_patterns": ["*.tmp"]} + assert _should_ignore(item, config) is True + + def test_dot_star_prefix_pattern(self, tmp_path): + from aipass.seedgo.apps.handlers.aipass_standards.architecture_check import ( + _should_ignore, + ) + + item = tmp_path / ".hidden_file" + config = {"ignore_files": [], "ignore_patterns": [".hidden*"]} + assert _should_ignore(item, config) is True + + def test_exact_pattern_match(self, tmp_path): + from aipass.seedgo.apps.handlers.aipass_standards.architecture_check import ( + _should_ignore, + ) + + item = tmp_path / "__pycache__" + config = {"ignore_files": [], "ignore_patterns": ["__pycache__"]} + assert _should_ignore(item, config) is True + + def test_pattern_in_parts(self, tmp_path): + from aipass.seedgo.apps.handlers.aipass_standards.architecture_check import ( + _should_ignore, + ) + + item = tmp_path / "__pycache__" / "something.pyc" + item.parent.mkdir(parents=True, exist_ok=True) + config = {"ignore_files": [], "ignore_patterns": ["__pycache__"]} + assert _should_ignore(item, config) is True + + def test_no_match(self, tmp_path): + from aipass.seedgo.apps.handlers.aipass_standards.architecture_check import ( + _should_ignore, + ) + + item = tmp_path / "good_file.py" + config = {"ignore_files": ["bad.py"], "ignore_patterns": ["*.tmp"]} + assert _should_ignore(item, config) is False + + +class TestGetCitizenClass: + """Tests for _get_citizen_class.""" + + def test_no_passport(self, tmp_path): + from aipass.seedgo.apps.handlers.aipass_standards.architecture_check import ( + _get_citizen_class, + ) + + assert _get_citizen_class(tmp_path) is None + + def test_valid_passport(self, tmp_path): + from aipass.seedgo.apps.handlers.aipass_standards.architecture_check import ( + _get_citizen_class, + ) + + trinity = tmp_path / ".trinity" + trinity.mkdir() + passport = trinity / "passport.json" + passport.write_text( + json.dumps({"identity": {"citizen_class": "builder"}}), + encoding="utf-8", + ) + assert _get_citizen_class(tmp_path) == "builder" + + def test_malformed_passport(self, tmp_path): + from aipass.seedgo.apps.handlers.aipass_standards.architecture_check import ( + _get_citizen_class, + ) + + trinity = tmp_path / ".trinity" + trinity.mkdir() + passport = trinity / "passport.json" + passport.write_text("not json", encoding="utf-8") + assert _get_citizen_class(tmp_path) is None + + def test_passport_missing_citizen_class(self, tmp_path): + from aipass.seedgo.apps.handlers.aipass_standards.architecture_check import ( + _get_citizen_class, + ) + + trinity = tmp_path / ".trinity" + trinity.mkdir() + passport = trinity / "passport.json" + passport.write_text(json.dumps({"identity": {}}), encoding="utf-8") + assert _get_citizen_class(tmp_path) is None + + +class TestTransformPath: + """Tests for _transform_path.""" + + def test_basic_placeholder_replacement(self): + from aipass.seedgo.apps.handlers.aipass_standards.architecture_check import ( + _transform_path, + ) + + result = _transform_path("{{BRANCH}}/apps/{{BRANCH}}.py", "mybranch") + assert result == "mybranch/apps/mybranch.py" + + def test_hyphenated_branch_name(self): + """Hyphenated branch: placeholder replaced, then entry-point rename applied.""" + from aipass.seedgo.apps.handlers.aipass_standards.architecture_check import ( + _transform_path, + ) + + result = _transform_path("apps/{{BRANCH}}.py", "my-branch") + # branch_lower="my_branch" replaces placeholder -> "apps/my_branch.py" + # FILE_RENAMES maps "my_branch.py" -> "my-branch.py" (entry_point_name) + assert result == "apps/my-branch.py" + + def test_dotted_branch_name(self): + """Leading dot is stripped for entry point name.""" + from aipass.seedgo.apps.handlers.aipass_standards.architecture_check import ( + _transform_path, + ) + + result = _transform_path("apps/{{BRANCH}}.py", ".hidden") + # branch_lower = ".hidden" -> ".hidden", entry_point_name = "hidden" + # The placeholder replacement gives ".hidden.py" + # But FILE_RENAMES maps ".hidden.py" -> "hidden.py" + assert "hidden" in result + + def test_no_placeholder(self): + from aipass.seedgo.apps.handlers.aipass_standards.architecture_check import ( + _transform_path, + ) + + result = _transform_path("apps/modules/helper.py", "mybranch") + assert result == "apps/modules/helper.py" + + +class TestScanTemplate: + """Tests for _scan_template.""" + + def test_scan_template_basic(self, tmp_path): + from aipass.seedgo.apps.handlers.aipass_standards.architecture_check import ( + _scan_template, + ) + + # Create a minimal template structure + (tmp_path / "apps").mkdir() + (tmp_path / "apps" / "modules").mkdir() + (tmp_path / "apps" / "entry.py").write_text("# entry\n", encoding="utf-8") + (tmp_path / "apps" / "modules" / "helper.py").write_text("# helper\n", encoding="utf-8") + + result = _scan_template(tmp_path) + assert "apps" in result["directories"] + assert any("entry.py" in f for f in result["files"]) + + def test_scan_template_with_ignore(self, tmp_path): + from aipass.seedgo.apps.handlers.aipass_standards.architecture_check import ( + _scan_template, + ) + + # Create template with an ignoreable file + (tmp_path / ".spawn").mkdir() + ignore = tmp_path / ".spawn" / ".registry_ignore.json" + ignore.write_text( + json.dumps({"ignore_files": ["README.md"], "ignore_patterns": []}), + encoding="utf-8", + ) + (tmp_path / "apps").mkdir() + (tmp_path / "README.md").write_text("# readme\n", encoding="utf-8") + (tmp_path / "apps" / "entry.py").write_text("# entry\n", encoding="utf-8") + + result = _scan_template(tmp_path) + # README.md should be ignored + assert not any("README.md" in f for f in result["files"]) + assert any("entry.py" in f for f in result["files"]) + + +# =========================================================================== +# 7. architecture_check — check_template_baseline with mocked templates +# =========================================================================== + + +class TestCheckTemplateBaselineFull: + """Full template baseline tests with mocked spawn template dirs.""" + + def test_spawn_templates_dir_missing(self, tmp_path, monkeypatch): + """When SPAWN_TEMPLATES_DIR does not exist, returns failure.""" + import sys + + _arch = "aipass.seedgo.apps.handlers.aipass_standards.architecture_check" + monkeypatch.delitem(sys.modules, _arch, raising=False) + from aipass.seedgo.apps.handlers.aipass_standards import architecture_check + + monkeypatch.setattr(architecture_check, "SPAWN_TEMPLATES_DIR", tmp_path / "nonexistent") + + branch = tmp_path / "mybranch" + apps = branch / "apps" + apps.mkdir(parents=True) + entry = apps / "mybranch.py" + entry.write_text("# entry\n", encoding="utf-8") + + # Create passport + trinity = branch / ".trinity" + trinity.mkdir() + passport = trinity / "passport.json" + passport.write_text(json.dumps({"identity": {"citizen_class": "builder"}}), encoding="utf-8") + + result = architecture_check.check_template_baseline(str(entry)) + assert len(result) >= 1 + assert result[0]["passed"] is False + assert "not found" in result[0]["message"] + + def test_template_class_not_found(self, tmp_path, monkeypatch): + """When citizen_class template directory does not exist, returns failure.""" + import sys + + monkeypatch.delitem( + sys.modules, "aipass.seedgo.apps.handlers.aipass_standards.architecture_check", raising=False + ) + from aipass.seedgo.apps.handlers.aipass_standards import architecture_check + + templates_dir = tmp_path / "templates" + templates_dir.mkdir() + monkeypatch.setattr(architecture_check, "SPAWN_TEMPLATES_DIR", templates_dir) + + branch = tmp_path / "mybranch" + apps = branch / "apps" + apps.mkdir(parents=True) + entry = apps / "mybranch.py" + entry.write_text("# entry\n", encoding="utf-8") + + trinity = branch / ".trinity" + trinity.mkdir() + passport = trinity / "passport.json" + passport.write_text(json.dumps({"identity": {"citizen_class": "nonexistent_class"}}), encoding="utf-8") + + result = architecture_check.check_template_baseline(str(entry)) + assert any("No template" in c["message"] for c in result) + + def test_template_baseline_full_match(self, tmp_path, monkeypatch): + """All template items present in branch: full pass.""" + import sys + + monkeypatch.delitem( + sys.modules, "aipass.seedgo.apps.handlers.aipass_standards.architecture_check", raising=False + ) + from aipass.seedgo.apps.handlers.aipass_standards import architecture_check + + # Create template dir + templates_dir = tmp_path / "templates" + builder_template = templates_dir / "builder" + builder_template.mkdir(parents=True) + (builder_template / "apps").mkdir() + (builder_template / "apps" / "modules").mkdir() + (builder_template / "apps" / "entry.py").write_text("# entry\n", encoding="utf-8") + monkeypatch.setattr(architecture_check, "SPAWN_TEMPLATES_DIR", templates_dir) + + # Create branch that matches template + branch = tmp_path / "mybranch" + apps = branch / "apps" + (apps / "modules").mkdir(parents=True) + entry = apps / "mybranch.py" + entry.write_text("# entry\n", encoding="utf-8") + (apps / "entry.py").write_text("# entry\n", encoding="utf-8") + + trinity = branch / ".trinity" + trinity.mkdir() + passport = trinity / "passport.json" + passport.write_text(json.dumps({"identity": {"citizen_class": "builder"}}), encoding="utf-8") + + result = architecture_check.check_template_baseline(str(entry)) + # First check is the summary + assert result[0]["name"].startswith("Template baseline") + assert "0 missing" in result[0]["message"] + + def test_template_baseline_missing_dir(self, tmp_path, monkeypatch): + """Missing template directory is flagged.""" + import sys + + monkeypatch.delitem( + sys.modules, "aipass.seedgo.apps.handlers.aipass_standards.architecture_check", raising=False + ) + from aipass.seedgo.apps.handlers.aipass_standards import architecture_check + + templates_dir = tmp_path / "templates" + builder_template = templates_dir / "builder" + builder_template.mkdir(parents=True) + (builder_template / "apps").mkdir() + (builder_template / "apps" / "modules").mkdir() + monkeypatch.setattr(architecture_check, "SPAWN_TEMPLATES_DIR", templates_dir) + + branch = tmp_path / "mybranch" + apps = branch / "apps" + apps.mkdir(parents=True) + entry = apps / "mybranch.py" + entry.write_text("# entry\n", encoding="utf-8") + # Note: apps/modules/ missing + + trinity = branch / ".trinity" + trinity.mkdir() + passport = trinity / "passport.json" + passport.write_text(json.dumps({"identity": {"citizen_class": "builder"}}), encoding="utf-8") + + result = architecture_check.check_template_baseline(str(entry)) + failed = [c for c in result if not c["passed"]] + assert len(failed) >= 1 + + def test_template_baseline_missing_file_bypassed(self, tmp_path, monkeypatch): + """Bypassed missing template file is marked as passed.""" + import sys + + monkeypatch.delitem( + sys.modules, "aipass.seedgo.apps.handlers.aipass_standards.architecture_check", raising=False + ) + from aipass.seedgo.apps.handlers.aipass_standards import architecture_check + + templates_dir = tmp_path / "templates" + builder_template = templates_dir / "builder" + builder_template.mkdir(parents=True) + (builder_template / "apps").mkdir() + (builder_template / "apps" / "something.py").write_text("# x\n", encoding="utf-8") + monkeypatch.setattr(architecture_check, "SPAWN_TEMPLATES_DIR", templates_dir) + + branch = tmp_path / "mybranch" + apps = branch / "apps" + apps.mkdir(parents=True) + entry = apps / "mybranch.py" + entry.write_text("# entry\n", encoding="utf-8") + # something.py missing but bypassed + + trinity = branch / ".trinity" + trinity.mkdir() + passport = trinity / "passport.json" + passport.write_text(json.dumps({"identity": {"citizen_class": "builder"}}), encoding="utf-8") + + bypass = [{"standard": "architecture", "file": "something.py"}] + result = architecture_check.check_template_baseline(str(entry), bypass_rules=bypass) + # The missing file should be bypassed + file_checks = [c for c in result if c["name"].startswith("File:")] + for c in file_checks: + if "something.py" in c["name"]: + assert c["passed"] is True + assert "bypassed" in c["message"] + + def test_template_baseline_missing_dir_bypassed(self, tmp_path, monkeypatch): + """Bypassed missing template directory is marked as passed.""" + import sys + + monkeypatch.delitem( + sys.modules, "aipass.seedgo.apps.handlers.aipass_standards.architecture_check", raising=False + ) + from aipass.seedgo.apps.handlers.aipass_standards import architecture_check + + templates_dir = tmp_path / "templates" + builder_template = templates_dir / "builder" + builder_template.mkdir(parents=True) + (builder_template / "apps").mkdir() + (builder_template / "apps" / "handlers").mkdir() + monkeypatch.setattr(architecture_check, "SPAWN_TEMPLATES_DIR", templates_dir) + + branch = tmp_path / "mybranch" + apps = branch / "apps" + apps.mkdir(parents=True) + entry = apps / "mybranch.py" + entry.write_text("# entry\n", encoding="utf-8") + # apps/handlers/ missing but bypassed + + trinity = branch / ".trinity" + trinity.mkdir() + passport = trinity / "passport.json" + passport.write_text(json.dumps({"identity": {"citizen_class": "builder"}}), encoding="utf-8") + + bypass = [{"standard": "architecture", "file": "apps/handlers"}] + result = architecture_check.check_template_baseline(str(entry), bypass_rules=bypass) + dir_checks = [c for c in result if c["name"].startswith("Dir:") and "handlers" in c["name"]] + for c in dir_checks: + assert c["passed"] is True + + +# =========================================================================== +# 8. checklist — _is_applicable +# =========================================================================== + + +class TestIsApplicable: + """Tests for _is_applicable.""" + + def test_branch_level_with_check_module(self): + from aipass.seedgo.apps.modules.checklist import _is_applicable + + checker = MagicMock() + checker.AUDIT_SCOPE = "branch_level" + checker.check_module = MagicMock() + assert _is_applicable(checker, "/some/file.py") is True + + def test_branch_level_without_check_module(self): + from aipass.seedgo.apps.modules.checklist import _is_applicable + + checker = MagicMock(spec=[]) + checker.AUDIT_SCOPE = "branch_level" + assert _is_applicable(checker, "/some/file.py") is False + + def test_branch_level_non_python(self): + from aipass.seedgo.apps.modules.checklist import _is_applicable + + checker = MagicMock() + checker.AUDIT_SCOPE = "branch_level" + checker.check_module = MagicMock() + assert _is_applicable(checker, "/some/file.txt") is False + + def test_all_files_scope_python(self): + from aipass.seedgo.apps.modules.checklist import _is_applicable + + checker = MagicMock() + checker.AUDIT_SCOPE = "all_files" + checker.check_module = MagicMock() + assert _is_applicable(checker, "/some/file.py") is True + + def test_all_files_scope_non_python(self): + from aipass.seedgo.apps.modules.checklist import _is_applicable + + checker = MagicMock() + checker.AUDIT_SCOPE = "all_files" + checker.check_module = MagicMock() + assert _is_applicable(checker, "/some/file.txt") is False + + def test_entry_point_scope_matches_entry(self): + from aipass.seedgo.apps.modules.checklist import _is_applicable + + checker = MagicMock() + checker.AUDIT_SCOPE = "entry_point" + checker.check_module = MagicMock() + assert _is_applicable(checker, "/branch/apps/branch.py") is True + + def test_entry_point_scope_non_entry(self): + from aipass.seedgo.apps.modules.checklist import _is_applicable + + checker = MagicMock() + checker.AUDIT_SCOPE = "entry_point" + checker.check_module = MagicMock() + assert _is_applicable(checker, "/branch/apps/modules/helper.py") is False + + def test_no_check_module_returns_false(self): + from aipass.seedgo.apps.modules.checklist import _is_applicable + + checker = MagicMock(spec=[]) + checker.AUDIT_SCOPE = "all_files" + assert _is_applicable(checker, "/some/file.py") is False + + def test_default_scope_is_entry_point(self): + """Checker with no AUDIT_SCOPE defaults to entry_point.""" + from aipass.seedgo.apps.modules.checklist import _is_applicable + + checker = MagicMock(spec=["check_module"]) + checker.check_module = MagicMock() + # No AUDIT_SCOPE attribute -> defaults to "entry_point" + assert _is_applicable(checker, "/branch/apps/branch.py") is True + assert _is_applicable(checker, "/branch/apps/modules/helper.py") is False + + +# =========================================================================== +# 9. checklist — handle_command directory mode +# =========================================================================== + + +class TestHandleCommandDirectoryMode: + """Tests for handle_command in directory mode.""" + + def test_directory_with_py_files(self, tmp_path, monkeypatch): + """Directory mode runs checklist on all .py files.""" + import sys + + monkeypatch.delitem(sys.modules, "aipass.seedgo.apps.modules.checklist", raising=False) + from aipass.seedgo.apps.modules import checklist + + d = tmp_path / "mydir" + d.mkdir() + (d / "first.py").write_text("x = 1\n", encoding="utf-8") + (d / "second.py").write_text("y = 2\n", encoding="utf-8") + (d / "_private.py").write_text("z = 3\n", encoding="utf-8") + (d / "readme.txt").write_text("not python\n", encoding="utf-8") + + result = checklist.handle_command("checklist", [str(d)]) + assert result is True + + def test_directory_with_no_py_files(self, tmp_path, monkeypatch): + """Directory with no .py files shows error.""" + import sys + + monkeypatch.delitem(sys.modules, "aipass.seedgo.apps.modules.checklist", raising=False) + from aipass.seedgo.apps.modules import checklist + + d = tmp_path / "emptydir" + d.mkdir() + (d / "readme.txt").write_text("not python\n", encoding="utf-8") + + result = checklist.handle_command("checklist", [str(d)]) + assert result is True + + def test_directory_filters_underscore_files(self, tmp_path, monkeypatch): + """Directory mode filters files starting with underscore.""" + import sys + + monkeypatch.delitem(sys.modules, "aipass.seedgo.apps.modules.checklist", raising=False) + from aipass.seedgo.apps.modules import checklist + + d = tmp_path / "onlypriv" + d.mkdir() + (d / "_init.py").write_text("x = 1\n", encoding="utf-8") + + result = checklist.handle_command("checklist", [str(d)]) + assert result is True + + +# =========================================================================== +# 10. checklist — handle_command with --pack flag +# =========================================================================== + + +class TestHandleCommandPackFlag: + """Tests for handle_command with --pack flag.""" + + def test_pack_flag(self, tmp_path, monkeypatch): + """--pack flag passes pack_name to run_checklist.""" + import sys + + monkeypatch.delitem(sys.modules, "aipass.seedgo.apps.modules.checklist", raising=False) + from aipass.seedgo.apps.modules import checklist + + f = tmp_path / "sample.py" + f.write_text("x = 1\n", encoding="utf-8") + + result = checklist.handle_command("checklist", ["--pack", "custom", str(f)]) + assert result is True + + def test_short_pack_flag(self, tmp_path, monkeypatch): + """-p flag is equivalent to --pack.""" + import sys + + monkeypatch.delitem(sys.modules, "aipass.seedgo.apps.modules.checklist", raising=False) + from aipass.seedgo.apps.modules import checklist + + f = tmp_path / "sample.py" + f.write_text("x = 1\n", encoding="utf-8") + + result = checklist.handle_command("checklist", ["-p", "custom", str(f)]) + assert result is True + + def test_no_file_after_pack_shows_error(self, monkeypatch): + """--pack with no file specified shows error.""" + import sys + + monkeypatch.delitem(sys.modules, "aipass.seedgo.apps.modules.checklist", raising=False) + from aipass.seedgo.apps.modules import checklist + + result = checklist.handle_command("checklist", ["--pack", "custom"]) + assert result is True # Handled, but shows error + + def test_unknown_flag_skipped(self, tmp_path, monkeypatch): + """Unknown flags are skipped during argument parsing.""" + import sys + + monkeypatch.delitem(sys.modules, "aipass.seedgo.apps.modules.checklist", raising=False) + from aipass.seedgo.apps.modules import checklist + + f = tmp_path / "sample.py" + f.write_text("x = 1\n", encoding="utf-8") + + result = checklist.handle_command("checklist", ["--unknown", str(f)]) + assert result is True + + +# =========================================================================== +# 11. checklist — _resolve_pack_path +# =========================================================================== + + +class TestResolvePackPath: + """Tests for _resolve_pack_path.""" + + def test_nonexistent_pack(self, monkeypatch): + import sys + + monkeypatch.delitem(sys.modules, "aipass.seedgo.apps.modules.checklist", raising=False) + from aipass.seedgo.apps.modules.checklist import _resolve_pack_path + + # Use monkeypatch to point handlers_dir to a tmp location + result = _resolve_pack_path("totally_bogus_pack_name_that_will_never_exist") + # If the real handlers dir exists but no matching pack, returns None + assert result is None + + def test_pack_not_found_in_run_checklist(self, tmp_path, monkeypatch): + """run_checklist returns error when pack is not found.""" + import sys + + monkeypatch.delitem(sys.modules, "aipass.seedgo.apps.modules.checklist", raising=False) + from aipass.seedgo.apps.modules import checklist + + # Make _resolve_pack_path return None + monkeypatch.setattr(checklist, "_resolve_pack_path", lambda name: None) + + f = tmp_path / "sample.py" + f.write_text("x = 1\n", encoding="utf-8") + + results = checklist.run_checklist(str(f), pack_name="bogus") + assert len(results) == 1 + assert results[0]["passed"] is False + assert "not found" in results[0]["detail"] + + +# =========================================================================== +# 12. checklist — _load_bypass_for_file +# =========================================================================== + + +class TestLoadBypassForFile: + """Tests for _load_bypass_for_file.""" + + def test_no_branch_detected(self, monkeypatch): + """When get_branch_from_path returns None, returns empty list.""" + import sys + + monkeypatch.delitem(sys.modules, "aipass.seedgo.apps.modules.checklist", raising=False) + from aipass.seedgo.apps.modules.checklist import _load_bypass_for_file + + result = _load_bypass_for_file("/some/file.py") + assert result == [] + + def test_branch_with_empty_path(self, monkeypatch): + """When branch has empty path, returns empty list.""" + import sys + + monkeypatch.setattr( + sys.modules["aipass.seedgo.apps.handlers.bypass.bypass_handler"], + "get_branch_from_path", + MagicMock(return_value={"name": "mybranch", "path": ""}), + ) + + monkeypatch.delitem(sys.modules, "aipass.seedgo.apps.modules.checklist", raising=False) + from aipass.seedgo.apps.modules.checklist import _load_bypass_for_file + + result = _load_bypass_for_file("/some/file.py") + assert result == [] + + def test_branch_with_absolute_path(self, monkeypatch): + """When branch has absolute path, passes it directly to load_bypass_rules.""" + import sys + + mock_load = MagicMock(return_value=[{"standard": "arch"}]) + monkeypatch.setattr( + sys.modules["aipass.seedgo.apps.handlers.bypass.bypass_handler"], + "get_branch_from_path", + MagicMock(return_value={"name": "mybranch", "path": "/absolute/path/mybranch"}), + ) + monkeypatch.setattr( + sys.modules["aipass.seedgo.apps.handlers.bypass.bypass_handler"], + "load_bypass_rules", + mock_load, + ) + + monkeypatch.delitem(sys.modules, "aipass.seedgo.apps.modules.checklist", raising=False) + from aipass.seedgo.apps.modules.checklist import _load_bypass_for_file + + result = _load_bypass_for_file("/some/file.py") + assert result == [{"standard": "arch"}] + + def test_branch_with_relative_path(self, monkeypatch): + """When branch has relative path, resolves relative to registry.""" + import sys + + mock_load = MagicMock(return_value=[]) + monkeypatch.setattr( + sys.modules["aipass.seedgo.apps.handlers.bypass.bypass_handler"], + "get_branch_from_path", + MagicMock(return_value={"name": "mybranch", "path": "relative/path/mybranch"}), + ) + monkeypatch.setattr( + sys.modules["aipass.seedgo.apps.handlers.bypass.bypass_handler"], + "load_bypass_rules", + mock_load, + ) + monkeypatch.setattr( + sys.modules["aipass.seedgo.apps.handlers.bypass.bypass_handler"], + "_find_registry", + MagicMock(return_value=Path("/repo/root/registry.json")), + ) + + monkeypatch.delitem(sys.modules, "aipass.seedgo.apps.modules.checklist", raising=False) + from aipass.seedgo.apps.modules.checklist import _load_bypass_for_file + + result = _load_bypass_for_file("/some/file.py") + mock_load.assert_called_once() + assert result == [] + + +# =========================================================================== +# 13. checklist — _format_failure +# =========================================================================== + + +class TestFormatFailureEdgeCases: + """Additional _format_failure tests for untested paths.""" + + def test_format_failure_no_checks_key(self): + """Result with no 'checks' key returns fallback.""" + from aipass.seedgo.apps.modules.checklist import _format_failure + + result = _format_failure({}) + assert "no details" in result.lower() + + def test_format_failure_all_passed(self): + """Result where all checks passed returns fallback.""" + from aipass.seedgo.apps.modules.checklist import _format_failure + + result = _format_failure({"checks": [{"passed": True, "message": "OK"}]}) + assert "no details" in result.lower() + + def test_format_failure_three_failures(self): + """Multiple failures shows '+N more' suffix.""" + from aipass.seedgo.apps.modules.checklist import _format_failure + + result = _format_failure( + { + "checks": [ + {"passed": False, "message": "First issue"}, + {"passed": False, "message": "Second issue"}, + {"passed": False, "message": "Third issue"}, + ] + } + ) + assert "First issue" in result + assert "+2 more" in result + + def test_format_failure_missing_message(self): + """Failed check with no message uses 'Unknown issue'.""" + from aipass.seedgo.apps.modules.checklist import _format_failure + + result = _format_failure({"checks": [{"passed": False}]}) + assert "Unknown issue" in result + + +# =========================================================================== +# 14. checklist — run_checklist with checker that raises exception +# =========================================================================== + + +class TestRunChecklistCheckerException: + """Tests for run_checklist when a checker raises an exception.""" + + def test_checker_exception_captured(self, tmp_path, monkeypatch): + """Checker that raises exception is captured as a failed result.""" + import sys + + monkeypatch.delitem(sys.modules, "aipass.seedgo.apps.modules.checklist", raising=False) + from aipass.seedgo.apps.modules import checklist + + # Create a mock checker that raises + bad_checker = MagicMock() + bad_checker.AUDIT_SCOPE = "all_files" + bad_checker.check_module = MagicMock(side_effect=RuntimeError("boom")) + + good_checker = MagicMock() + good_checker.AUDIT_SCOPE = "all_files" + good_checker.check_module = MagicMock(return_value={"passed": True, "checks": []}) + + # Patch discover_checkers on the checklist module (already bound at import) + monkeypatch.setattr( + checklist, + "discover_checkers", + lambda pack_path: {"bad_check": bad_checker, "good_check": good_checker}, + ) + monkeypatch.setattr(checklist, "_resolve_pack_path", lambda name: Path("/fake/pack")) + + f = tmp_path / "sample.py" + f.write_text("x = 1\n", encoding="utf-8") + + results = checklist.run_checklist(str(f)) + # Should have results for both checkers + assert len(results) == 2 + bad_result = [r for r in results if r["standard"] == "bad_check"] + assert len(bad_result) == 1 + assert bad_result[0]["passed"] is False + assert "boom" in bad_result[0]["detail"] + + def test_checker_returns_failure_with_details(self, tmp_path, monkeypatch): + """Checker returning passed=False has detail populated from _format_failure.""" + import sys + + monkeypatch.delitem(sys.modules, "aipass.seedgo.apps.modules.checklist", raising=False) + from aipass.seedgo.apps.modules import checklist + + fail_checker = MagicMock() + fail_checker.AUDIT_SCOPE = "all_files" + fail_checker.check_module = MagicMock( + return_value={ + "passed": False, + "checks": [{"passed": False, "message": "Something is wrong"}], + } + ) + + monkeypatch.setattr( + checklist, + "discover_checkers", + lambda pack_path: {"fail_check": fail_checker}, + ) + monkeypatch.setattr(checklist, "_resolve_pack_path", lambda name: Path("/fake/pack")) + + f = tmp_path / "sample.py" + f.write_text("x = 1\n", encoding="utf-8") + + results = checklist.run_checklist(str(f)) + assert len(results) == 1 + assert results[0]["passed"] is False + assert "Something is wrong" in results[0]["detail"] + + def test_no_applicable_checkers_returns_skip(self, tmp_path, monkeypatch): + """When no checkers are applicable, returns skip result.""" + import sys + + monkeypatch.delitem(sys.modules, "aipass.seedgo.apps.modules.checklist", raising=False) + from aipass.seedgo.apps.modules import checklist + + entry_only_checker = MagicMock() + entry_only_checker.AUDIT_SCOPE = "entry_point" + entry_only_checker.check_module = MagicMock() + + monkeypatch.setattr( + checklist, + "discover_checkers", + lambda pack_path: {"entry_check": entry_only_checker}, + ) + monkeypatch.setattr(checklist, "_resolve_pack_path", lambda name: Path("/fake/pack")) + + # Create a file that is NOT an entry point + f = tmp_path / "helper.py" + f.write_text("x = 1\n", encoding="utf-8") + + results = checklist.run_checklist(str(f)) + assert len(results) == 1 + assert results[0]["passed"] is True + assert "No applicable" in results[0]["detail"] + + def test_no_checkers_discovered(self, tmp_path, monkeypatch): + """When discover_checkers returns empty dict, returns error.""" + import sys + + monkeypatch.delitem(sys.modules, "aipass.seedgo.apps.modules.checklist", raising=False) + from aipass.seedgo.apps.modules import checklist + + monkeypatch.setattr( + checklist, + "discover_checkers", + lambda pack_path: {}, + ) + monkeypatch.setattr(checklist, "_resolve_pack_path", lambda name: Path("/fake/pack")) + + f = tmp_path / "sample.py" + f.write_text("x = 1\n", encoding="utf-8") + + results = checklist.run_checklist(str(f)) + assert len(results) == 1 + assert results[0]["passed"] is False + assert "No checkers" in results[0]["detail"] + + +# =========================================================================== +# 15. checklist — _print_results +# =========================================================================== + + +class TestPrintResults: + """Tests for _print_results output formatting.""" + + def test_print_all_passed(self, monkeypatch): + """All passed results show green checkmarks and summary.""" + import sys + + monkeypatch.delitem(sys.modules, "aipass.seedgo.apps.modules.checklist", raising=False) + from aipass.seedgo.apps.modules.checklist import _print_results + + mock_cli = sys.modules["aipass.cli"] + mock_cli.console.reset_mock() + + results = [ + {"standard": "architecture", "passed": True, "detail": None}, + {"standard": "documentation", "passed": True, "detail": None}, + ] + _print_results(results, "/some/file.py") + assert mock_cli.console.print.called + + def test_print_failed_with_detail(self, monkeypatch): + """Failed result with detail prints the detail message.""" + import sys + + monkeypatch.delitem(sys.modules, "aipass.seedgo.apps.modules.checklist", raising=False) + from aipass.seedgo.apps.modules.checklist import _print_results + + mock_cli = sys.modules["aipass.cli"] + mock_cli.console.reset_mock() + + results = [{"standard": "architecture", "passed": False, "detail": "Missing docstring"}] + _print_results(results, "/some/file.py") + assert mock_cli.console.print.called + + def test_print_failed_without_detail(self, monkeypatch): + """Failed result without detail still prints.""" + import sys + + monkeypatch.delitem(sys.modules, "aipass.seedgo.apps.modules.checklist", raising=False) + from aipass.seedgo.apps.modules.checklist import _print_results + + mock_cli = sys.modules["aipass.cli"] + mock_cli.console.reset_mock() + + results = [{"standard": "architecture", "passed": False, "detail": ""}] + _print_results(results, "/some/file.py") + assert mock_cli.console.print.called + + def test_print_mixed_results(self, monkeypatch): + """Mixed results do not print 'All passed' summary.""" + import sys + + monkeypatch.delitem(sys.modules, "aipass.seedgo.apps.modules.checklist", raising=False) + from aipass.seedgo.apps.modules.checklist import _print_results + + mock_cli = sys.modules["aipass.cli"] + mock_cli.console.reset_mock() + + results = [ + {"standard": "architecture", "passed": True, "detail": None}, + {"standard": "documentation", "passed": False, "detail": "Issues found"}, + ] + _print_results(results, "/some/file.py") + # Check that "All X standards passed" was NOT printed + calls = [str(c) for c in mock_cli.console.print.call_args_list] + assert not any("All" in c and "passed" in c for c in calls) + + +# =========================================================================== +# 16. checklist — handle_command path resolution +# =========================================================================== + + +class TestHandleCommandPathResolution: + """Tests for handle_command path resolution logic.""" + + def test_absolute_path(self, tmp_path, monkeypatch): + """Absolute file path is resolved directly.""" + import sys + + monkeypatch.delitem(sys.modules, "aipass.seedgo.apps.modules.checklist", raising=False) + from aipass.seedgo.apps.modules import checklist + + f = tmp_path / "sample.py" + f.write_text("x = 1\n", encoding="utf-8") + + result = checklist.handle_command("checklist", [str(f)]) + assert result is True + + def test_relative_path_fallback_cwd(self, tmp_path, monkeypatch): + """Relative path falls back to CWD resolution.""" + import sys + + monkeypatch.delitem(sys.modules, "aipass.seedgo.apps.modules.checklist", raising=False) + from aipass.seedgo.apps.modules import checklist + + # Monkeypatch _get_repo_root to return None (no git repo) + monkeypatch.setattr(checklist, "_get_repo_root", lambda: None) + monkeypatch.chdir(tmp_path) + + f = tmp_path / "sample.py" + f.write_text("x = 1\n", encoding="utf-8") + + result = checklist.handle_command("checklist", ["sample.py"]) + assert result is True + + +# =========================================================================== +# 17. checklist — _is_entry_point edge cases +# =========================================================================== + + +class TestIsEntryPointEdgeCases: + """Additional _is_entry_point edge cases.""" + + def test_non_py_file(self): + from aipass.seedgo.apps.modules.checklist import _is_entry_point + + assert _is_entry_point("/branch/apps/config.json") is False + + def test_nested_under_apps(self): + from aipass.seedgo.apps.modules.checklist import _is_entry_point + + assert _is_entry_point("/branch/apps/handlers/thing.py") is False + + def test_no_apps_in_path(self): + from aipass.seedgo.apps.modules.checklist import _is_entry_point + + assert _is_entry_point("/branch/src/thing.py") is False + + def test_valid_entry_point(self): + from aipass.seedgo.apps.modules.checklist import _is_entry_point + + assert _is_entry_point("/branch/apps/branch.py") is True diff --git a/src/aipass/seedgo/tests/test_coverage_audit.py b/src/aipass/seedgo/tests/test_coverage_audit.py new file mode 100644 index 00000000..be4151f2 --- /dev/null +++ b/src/aipass/seedgo/tests/test_coverage_audit.py @@ -0,0 +1,2198 @@ +"""Tests for audit_display and branch_audit — targeting uncovered lines.""" + +# =================== META ==================== +# Name: test_coverage_audit.py +# Description: Unit tests for audit_display.py and branch_audit.py line coverage +# Version: 1.0.0 +# Created: 2026-04-26 +# Modified: 2026-04-26 +# ============================================= + +import types + +import pytest +from unittest.mock import MagicMock, patch +from pathlib import Path + + +# --------------------------------------------------------------------------- +# Fixtures +# --------------------------------------------------------------------------- + + +@pytest.fixture(autouse=True) +def _mock_infrastructure(monkeypatch): + """Mock heavy infrastructure imports for audit_display and branch_audit.""" + import sys + + mock_logger = MagicMock() + mock_console = MagicMock() + mock_json_handler = MagicMock() + mock_json_handler.log_operation = MagicMock(return_value=True) + mock_ignore_handler = MagicMock() + mock_ignore_handler.get_audit_ignore_patterns = MagicMock( + return_value=[] + ) + mock_scan_branch = MagicMock(return_value=None) + + # -- prax --------------------------------------------------------------- + prax_mod = MagicMock() + prax_mod.logger = mock_logger + monkeypatch.setitem(sys.modules, "aipass.prax", prax_mod) + + # -- cli ---------------------------------------------------------------- + cli_mod = MagicMock() + cli_mod.console = mock_console + monkeypatch.setitem(sys.modules, "aipass.cli", cli_mod) + + # -- seedgo json handler ------------------------------------------------ + json_pkg = MagicMock() + json_pkg.json_handler = mock_json_handler + monkeypatch.setitem( + sys.modules, "aipass.seedgo.apps.handlers.json", json_pkg + ) + json_mod = MagicMock() + json_mod.log_operation = mock_json_handler.log_operation + monkeypatch.setitem( + sys.modules, + "aipass.seedgo.apps.handlers.json.json_handler", + json_mod, + ) + + # -- bypass handler ----------------------------------------------------- + bypass_pkg = MagicMock() + bypass_pkg.ignore_handler = mock_ignore_handler + monkeypatch.setitem( + sys.modules, "aipass.seedgo.apps.handlers.bypass", bypass_pkg + ) + monkeypatch.setitem( + sys.modules, + "aipass.seedgo.apps.handlers.bypass.ignore_handler", + mock_ignore_handler, + ) + + # -- test_map function_scanner ------------------------------------------ + test_map_pkg = MagicMock() + test_map_pkg.scan_branch = mock_scan_branch + monkeypatch.setitem( + sys.modules, + "aipass.seedgo.apps.handlers.test_map", + test_map_pkg, + ) + scanner_mod = MagicMock() + scanner_mod.scan_branch = mock_scan_branch + monkeypatch.setitem( + sys.modules, + "aipass.seedgo.apps.handlers.test_map.function_scanner", + scanner_mod, + ) + + # -- audit package (must be a real module with __path__ so + # submodule imports like audit.audit_display work) -------------------- + audit_pkg = types.ModuleType("aipass.seedgo.apps.handlers.audit") + audit_pkg.__path__ = [] # type: ignore[attr-defined] + monkeypatch.setitem( + sys.modules, "aipass.seedgo.apps.handlers.audit", audit_pkg + ) + + # Force re-imports so modules pick up fresh mocks + for mod_name in [ + "aipass.seedgo.apps.handlers.audit.audit_display", + "aipass.seedgo.apps.handlers.audit.branch_audit", + ]: + monkeypatch.delitem(sys.modules, mod_name, raising=False) + + +# =========================================================================== +# AUDIT_DISPLAY TESTS +# =========================================================================== + + +class TestFormatStandardName: + """Tests for _format_standard_name.""" + + def test_underscore_conversion(self): + """DEEP_NESTING becomes Deep Nesting.""" + from aipass.seedgo.apps.handlers.audit.audit_display import ( + _format_standard_name, + ) + + assert _format_standard_name("DEEP_NESTING") == "Deep Nesting" + + def test_lowercase_conversion(self): + """Lowercase deep_nesting becomes Deep Nesting.""" + from aipass.seedgo.apps.handlers.audit.audit_display import ( + _format_standard_name, + ) + + assert _format_standard_name("deep_nesting") == "Deep Nesting" + + def test_single_word(self): + """Single word gets title-cased.""" + from aipass.seedgo.apps.handlers.audit.audit_display import ( + _format_standard_name, + ) + + assert _format_standard_name("architecture") == "Architecture" + + +class TestRenderViolations: + """Tests for _render_violations.""" + + def test_basic_violations(self): + """Basic violations with path, score, and issues rendered.""" + from aipass.seedgo.apps.handlers.audit.audit_display import ( + _render_violations, + ) + + mock_con = MagicMock() + violations = [ + { + "path": "/foo/bar.py", + "score": 60, + "issues": ["Bad indent", "Long line"], + }, + ] + _render_violations("naming", violations, mock_con) + # header + file line + 2 issue lines = at least 4 + assert mock_con.print.call_count >= 4 + + def test_file_key_fallback(self): + """Falls back to file key when path is missing.""" + from aipass.seedgo.apps.handlers.audit.audit_display import ( + _render_violations, + ) + + mock_con = MagicMock() + violations = [ + { + "file": "bar.py", + "score": 50, + "issues": ["Something wrong"], + }, + ] + _render_violations("naming", violations, mock_con) + calls = [str(c) for c in mock_con.print.call_args_list] + assert any("bar.py" in c for c in calls) + + def test_message_fallback_when_no_issues(self): + """Shows message when issues list is empty.""" + from aipass.seedgo.apps.handlers.audit.audit_display import ( + _render_violations, + ) + + mock_con = MagicMock() + violations = [ + { + "path": "/foo.py", + "score": 0, + "issues": [], + "message": "General failure", + }, + ] + _render_violations("naming", violations, mock_con) + calls = [str(c) for c in mock_con.print.call_args_list] + assert any("General failure" in c for c in calls) + + def test_more_than_five_violations(self): + """Shows and N more when violations exceed 5.""" + from aipass.seedgo.apps.handlers.audit.audit_display import ( + _render_violations, + ) + + mock_con = MagicMock() + violations = [ + {"path": f"/file{i}.py", "score": 10, "issues": []} + for i in range(8) + ] + _render_violations("naming", violations, mock_con) + calls = [str(c) for c in mock_con.print.call_args_list] + assert any("3 more" in c for c in calls) + + def test_exactly_five_violations_no_more(self): + """No more message when exactly 5 violations.""" + from aipass.seedgo.apps.handlers.audit.audit_display import ( + _render_violations, + ) + + mock_con = MagicMock() + violations = [ + {"path": f"/file{i}.py", "score": 10, "issues": []} + for i in range(5) + ] + _render_violations("naming", violations, mock_con) + calls = [str(c) for c in mock_con.print.call_args_list] + assert not any("more" in c for c in calls) + + def test_no_path_no_file_key(self): + """Both path and file missing returns empty string for path.""" + from aipass.seedgo.apps.handlers.audit.audit_display import ( + _render_violations, + ) + + mock_con = MagicMock() + violations = [{"score": 0, "issues": ["err"]}] + _render_violations("naming", violations, mock_con) + assert mock_con.print.called + + +class TestRenderArchitectureViolations: + """Tests for _render_architecture_violations.""" + + def test_no_failed_checks(self): + """Returns early when all checks pass.""" + from aipass.seedgo.apps.handlers.audit.audit_display import ( + _render_architecture_violations, + ) + + mock_con = MagicMock() + audit_result = { + "results": { + "architecture": { + "checks": [ + {"passed": True, "name": "Dir: apps"} + ] + } + } + } + _render_architecture_violations(audit_result, mock_con) + assert mock_con.print.call_count == 0 + + def test_missing_directories(self): + """Shows missing directories grouped.""" + from aipass.seedgo.apps.handlers.audit.audit_display import ( + _render_architecture_violations, + ) + + mock_con = MagicMock() + checks = [ + {"passed": False, "name": "Dir: apps", "message": "x"}, + { + "passed": False, + "name": "Directory: tests", + "message": "x", + }, + ] + audit_result = { + "results": {"architecture": {"checks": checks}} + } + _render_architecture_violations(audit_result, mock_con) + calls = [str(c) for c in mock_con.print.call_args_list] + assert any("ARCHITECTURE VIOLATIONS" in c for c in calls) + assert any("Missing directories" in c for c in calls) + + def test_missing_files(self): + """Shows missing files grouped.""" + from aipass.seedgo.apps.handlers.audit.audit_display import ( + _render_architecture_violations, + ) + + mock_con = MagicMock() + checks = [ + { + "passed": False, + "name": "File: README.md", + "message": "x", + }, + ] + audit_result = { + "results": {"architecture": {"checks": checks}} + } + _render_architecture_violations(audit_result, mock_con) + calls = [str(c) for c in mock_con.print.call_args_list] + assert any("Missing files" in c for c in calls) + + def test_other_failures(self): + """Shows non-dir, non-file failures.""" + from aipass.seedgo.apps.handlers.audit.audit_display import ( + _render_architecture_violations, + ) + + mock_con = MagicMock() + checks = [ + { + "passed": False, + "name": "Custom check", + "message": "Something wrong", + }, + ] + audit_result = { + "results": {"architecture": {"checks": checks}} + } + _render_architecture_violations(audit_result, mock_con) + calls = [str(c) for c in mock_con.print.call_args_list] + assert any("Something wrong" in c for c in calls) + + def test_more_than_five_dirs(self): + """Directories list truncated at 5 with more message.""" + from aipass.seedgo.apps.handlers.audit.audit_display import ( + _render_architecture_violations, + ) + + mock_con = MagicMock() + checks = [ + {"passed": False, "name": f"Dir: dir{i}"} + for i in range(7) + ] + audit_result = { + "results": {"architecture": {"checks": checks}} + } + _render_architecture_violations(audit_result, mock_con) + calls = [str(c) for c in mock_con.print.call_args_list] + assert any("2 more" in c for c in calls) + + def test_more_than_five_files(self): + """Files list truncated at 5 with more message.""" + from aipass.seedgo.apps.handlers.audit.audit_display import ( + _render_architecture_violations, + ) + + mock_con = MagicMock() + checks = [ + {"passed": False, "name": f"File: file{i}.py"} + for i in range(8) + ] + audit_result = { + "results": {"architecture": {"checks": checks}} + } + _render_architecture_violations(audit_result, mock_con) + calls = [str(c) for c in mock_con.print.call_args_list] + assert any("3 more" in c for c in calls) + + def test_empty_results(self): + """Empty results dict handled gracefully.""" + from aipass.seedgo.apps.handlers.audit.audit_display import ( + _render_architecture_violations, + ) + + mock_con = MagicMock() + _render_architecture_violations({}, mock_con) + assert mock_con.print.call_count == 0 + + def test_mixed_dirs_files_other(self): + """Mix of dirs, files, and other failures all render.""" + from aipass.seedgo.apps.handlers.audit.audit_display import ( + _render_architecture_violations, + ) + + mock_con = MagicMock() + checks = [ + {"passed": False, "name": "Dir: apps"}, + {"passed": False, "name": "File: setup.py"}, + { + "passed": False, + "name": "Config check", + "message": "Missing config", + }, + ] + audit_result = { + "results": {"architecture": {"checks": checks}} + } + _render_architecture_violations(audit_result, mock_con) + calls = [str(c) for c in mock_con.print.call_args_list] + assert any("Missing directories" in c for c in calls) + assert any("Missing files" in c for c in calls) + assert any("Missing config" in c for c in calls) + + +class TestRenderTypeErrors: + """Tests for _render_type_errors.""" + + def test_no_type_errors_no_files_checked(self): + """No type errors and no files checked produces no output.""" + from aipass.seedgo.apps.handlers.audit.audit_display import ( + _render_type_errors, + ) + + mock_con = MagicMock() + _render_type_errors( + {"type_errors": 0, "files_checked": 0}, mock_con + ) + assert mock_con.print.call_count == 0 + + def test_no_type_errors_with_files_checked(self): + """No type errors but files checked shows green check.""" + from aipass.seedgo.apps.handlers.audit.audit_display import ( + _render_type_errors, + ) + + mock_con = MagicMock() + _render_type_errors( + {"type_errors": 0, "files_checked": 5}, mock_con + ) + calls = [str(c) for c in mock_con.print.call_args_list] + assert any("No type errors" in c for c in calls) + + def test_type_errors_with_diagnostics(self): + """Type errors render file details and diagnostics.""" + from aipass.seedgo.apps.handlers.audit.audit_display import ( + _render_type_errors, + ) + + mock_con = MagicMock() + audit_result = { + "type_errors": 3, + "type_error_files": [ + { + "file": "module.py", + "errors": 2, + "diagnostics": [ + {"line": 10, "message": "Type mismatch"}, + {"line": 20, "message": "Incompatible"}, + ], + }, + { + "file": "other.py", + "errors": 1, + "diagnostics": [ + {"line": 5, "message": "Missing arg"}, + ], + }, + ], + "files_checked": 10, + } + _render_type_errors(audit_result, mock_con) + calls = [str(c) for c in mock_con.print.call_args_list] + assert any("TYPE ERRORS" in c for c in calls) + assert any("module.py" in c for c in calls) + + def test_file_zero_errors_skipped(self): + """File with 0 errors in type_error_files is skipped.""" + from aipass.seedgo.apps.handlers.audit.audit_display import ( + _render_type_errors, + ) + + mock_con = MagicMock() + audit_result = { + "type_errors": 1, + "type_error_files": [ + { + "file": "clean.py", + "errors": 0, + "diagnostics": [], + }, + { + "file": "bad.py", + "errors": 1, + "diagnostics": [ + {"line": 1, "message": "err"}, + ], + }, + ], + "files_checked": 2, + } + _render_type_errors(audit_result, mock_con) + calls = [str(c) for c in mock_con.print.call_args_list] + assert not any("clean.py" in c for c in calls) + assert any("bad.py" in c for c in calls) + + def test_empty_audit_result(self): + """Missing keys handled gracefully.""" + from aipass.seedgo.apps.handlers.audit.audit_display import ( + _render_type_errors, + ) + + mock_con = MagicMock() + _render_type_errors({}, mock_con) + assert mock_con.print.call_count == 0 + + +class TestRenderTestMap: + """Tests for _render_test_map.""" + + def test_no_test_map(self): + """No test_map key produces no output.""" + from aipass.seedgo.apps.handlers.audit.audit_display import ( + _render_test_map, + ) + + mock_con = MagicMock() + _render_test_map({}, mock_con) + assert mock_con.print.call_count == 0 + + def test_test_map_zero_functions(self): + """test_map with 0 total functions produces no output.""" + from aipass.seedgo.apps.handlers.audit.audit_display import ( + _render_test_map, + ) + + mock_con = MagicMock() + _render_test_map( + {"test_map": {"total_functions": 0}}, mock_con + ) + assert mock_con.print.call_count == 0 + + def test_test_map_with_data(self): + """test_map with data renders summary.""" + from aipass.seedgo.apps.handlers.audit.audit_display import ( + _render_test_map, + ) + + mock_con = MagicMock() + _render_test_map( + { + "test_map": { + "total_functions": 10, + "tested_functions": 6, + "branch": "seedgo", + } + }, + mock_con, + ) + calls = [str(c) for c in mock_con.print.call_args_list] + assert any("10 public functions" in c for c in calls) + assert any("6 tested" in c for c in calls) + + def test_test_map_none(self): + """test_map explicitly None produces no output.""" + from aipass.seedgo.apps.handlers.audit.audit_display import ( + _render_test_map, + ) + + mock_con = MagicMock() + _render_test_map({"test_map": None}, mock_con) + assert mock_con.print.call_count == 0 + + +class TestRenderDeprecatedPatterns: + """Tests for _render_deprecated_patterns.""" + + def test_no_patterns(self): + """Empty deprecated_patterns produces no output.""" + from aipass.seedgo.apps.handlers.audit.audit_display import ( + _render_deprecated_patterns, + ) + + mock_con = MagicMock() + _render_deprecated_patterns( + {"deprecated_patterns": []}, mock_con + ) + assert mock_con.print.call_count == 0 + + def test_with_patterns(self): + """Deprecated patterns render correctly.""" + from aipass.seedgo.apps.handlers.audit.audit_display import ( + _render_deprecated_patterns, + ) + + mock_con = MagicMock() + patterns = [ + { + "path": "/foo/DOCUMENTS", + "message": "Rename DOCUMENTS/ to docs/", + }, + ] + _render_deprecated_patterns( + {"deprecated_patterns": patterns}, mock_con + ) + calls = [str(c) for c in mock_con.print.call_args_list] + assert any("DEPRECATED PATTERNS" in c for c in calls) + assert any("DOCUMENTS" in c for c in calls) + + def test_missing_key(self): + """Missing deprecated_patterns key produces no output.""" + from aipass.seedgo.apps.handlers.audit.audit_display import ( + _render_deprecated_patterns, + ) + + mock_con = MagicMock() + _render_deprecated_patterns({}, mock_con) + assert mock_con.print.call_count == 0 + + +class TestPrintIntrospection: + """Tests for print_introspection.""" + + def test_introspection_produces_output(self): + """print_introspection produces console output.""" + import sys + + from aipass.seedgo.apps.handlers.audit.audit_display import ( + print_introspection, + ) + + mock_cli = sys.modules["aipass.cli"] + mock_cli.console.reset_mock() + print_introspection() + assert mock_cli.console.print.called + + +class TestPrintBranchSummary: + """Tests for print_branch_summary.""" + + @staticmethod + def _make_audit_result( + branch_name: str = "test_branch", + scores: dict | None = None, + average: int = 85, + files_checked: int = 10, + results: dict | None = None, + extra: dict | None = None, + ) -> dict: + """Build a minimal audit_result dict.""" + out: dict = { + "branch": { + "name": branch_name, + "path": "/fake/path", + }, + "scores": scores + if scores is not None + else {"architecture": 90, "naming": 80}, + "average": average, + "files_checked": files_checked, + "results": results if results is not None else {}, + } + if extra: + out.update(extra) + return out + + def test_basic_summary(self): + """Basic summary renders without error.""" + from aipass.seedgo.apps.handlers.audit.audit_display import ( + print_branch_summary, + ) + + result = self._make_audit_result() + print_branch_summary(result) + + def test_high_scores(self): + """Scores >= 90 get check icon.""" + from aipass.seedgo.apps.handlers.audit.audit_display import ( + print_branch_summary, + ) + + result = self._make_audit_result( + scores={"architecture": 95, "naming": 92}, average=93 + ) + print_branch_summary(result) + + def test_medium_scores(self): + """Scores 75-89 get warning icon.""" + from aipass.seedgo.apps.handlers.audit.audit_display import ( + print_branch_summary, + ) + + result = self._make_audit_result( + scores={"architecture": 80, "naming": 76}, average=78 + ) + print_branch_summary(result) + + def test_low_scores(self): + """Scores < 75 get error icon.""" + from aipass.seedgo.apps.handlers.audit.audit_display import ( + print_branch_summary, + ) + + result = self._make_audit_result( + scores={"architecture": 50, "naming": 60}, average=55 + ) + print_branch_summary(result) + + def test_odd_number_of_scores(self): + """Odd number of scores renders last one alone.""" + from aipass.seedgo.apps.handlers.audit.audit_display import ( + print_branch_summary, + ) + + result = self._make_audit_result( + scores={ + "architecture": 90, + "naming": 80, + "meta": 70, + }, + average=80, + ) + print_branch_summary(result) + + def test_empty_scores(self): + """Empty scores dict still renders overall.""" + from aipass.seedgo.apps.handlers.audit.audit_display import ( + print_branch_summary, + ) + + result = self._make_audit_result(scores={}, average=0) + print_branch_summary(result) + + def test_architecture_violations_displayed(self): + """Architecture score < 100 triggers arch violation render.""" + from aipass.seedgo.apps.handlers.audit.audit_display import ( + print_branch_summary, + ) + + result = self._make_audit_result( + scores={"architecture": 70}, + average=70, + results={ + "architecture": { + "checks": [ + { + "passed": False, + "name": "Dir: apps", + } + ] + } + }, + ) + print_branch_summary(result) + + def test_standard_violations_displayed(self): + """Standard with violations list gets rendered.""" + from aipass.seedgo.apps.handlers.audit.audit_display import ( + print_branch_summary, + ) + + result = self._make_audit_result( + scores={"naming": 60}, + average=60, + extra={ + "naming_violations": [ + { + "path": "/foo.py", + "score": 60, + "issues": ["Bad name"], + }, + ], + }, + ) + print_branch_summary(result) + + def test_branch_level_failed_checks(self): + """Failed checks but no violations list renders messages.""" + from aipass.seedgo.apps.handlers.audit.audit_display import ( + print_branch_summary, + ) + + result = self._make_audit_result( + scores={"dead_code": 70}, + average=70, + results={ + "dead_code": { + "checks": [ + { + "passed": False, + "message": "Unused function foo()", + }, + {"passed": True, "message": "OK"}, + ] + } + }, + ) + print_branch_summary(result) + + def test_violations_not_in_scores_rendered(self): + """Violation lists not in scores are caught defensively.""" + from aipass.seedgo.apps.handlers.audit.audit_display import ( + print_branch_summary, + ) + + result = self._make_audit_result( + scores={"naming": 100}, + average=100, + ) + result["extra_violations"] = [ + { + "path": "/orphan.py", + "score": 0, + "issues": ["Orphan violation"], + }, + ] + print_branch_summary(result) + + def test_type_errors_rendered(self): + """Type errors section is rendered.""" + from aipass.seedgo.apps.handlers.audit.audit_display import ( + print_branch_summary, + ) + + result = self._make_audit_result( + extra={ + "type_errors": 2, + "type_error_files": [ + { + "file": "bad.py", + "errors": 2, + "diagnostics": [ + {"line": 1, "message": "err"}, + ], + }, + ], + }, + ) + print_branch_summary(result) + + def test_test_map_rendered(self): + """Test map section is rendered.""" + from aipass.seedgo.apps.handlers.audit.audit_display import ( + print_branch_summary, + ) + + result = self._make_audit_result( + extra={ + "test_map": { + "total_functions": 5, + "tested_functions": 3, + "branch": "seedgo", + }, + }, + ) + print_branch_summary(result) + + def test_deprecated_patterns_rendered(self): + """Deprecated patterns section is rendered.""" + from aipass.seedgo.apps.handlers.audit.audit_display import ( + print_branch_summary, + ) + + result = self._make_audit_result( + extra={ + "deprecated_patterns": [ + { + "path": "/DOCUMENTS", + "message": "Rename to docs/", + }, + ], + }, + ) + print_branch_summary(result) + + def test_system_averages_and_overall(self): + """system_averages and overall_system_avg args accepted.""" + from aipass.seedgo.apps.handlers.audit.audit_display import ( + print_branch_summary, + ) + + result = self._make_audit_result() + print_branch_summary( + result, + system_averages={"naming": 85}, + overall_system_avg=87, + ) + + def test_score_100_skipped_in_violations(self): + """Score 100 skips violation rendering for that standard.""" + from aipass.seedgo.apps.handlers.audit.audit_display import ( + print_branch_summary, + ) + + result = self._make_audit_result( + scores={"naming": 100, "meta": 90}, + average=95, + ) + print_branch_summary(result) + + +class TestPrintSystemSummary: + """Tests for print_system_summary.""" + + @staticmethod + def _make_result( + name: str, + avg: int, + scores: dict | None = None, + type_errors: int = 0, + ) -> dict: + """Build a minimal system summary result dict.""" + return { + "branch": {"name": name}, + "average": avg, + "scores": scores + if scores is not None + else {"architecture": avg, "naming": avg}, + "type_errors": type_errors, + } + + def test_empty_results(self): + """Empty audit_results list handled gracefully.""" + from aipass.seedgo.apps.handlers.audit.audit_display import ( + print_system_summary, + ) + + print_system_summary([]) + + def test_all_excellent(self): + """All branches >= 90 counted as excellent.""" + from aipass.seedgo.apps.handlers.audit.audit_display import ( + print_system_summary, + ) + + results = [ + self._make_result("a", 95), + self._make_result("b", 92), + ] + print_system_summary(results) + + def test_mixed_tiers(self): + """Branches across all tiers counted correctly.""" + from aipass.seedgo.apps.handlers.audit.audit_display import ( + print_system_summary, + ) + + results = [ + self._make_result("excellent", 95), + self._make_result("good", 82), + self._make_result("bad", 60), + ] + print_system_summary(results) + + def test_type_errors_in_summary(self): + """Type errors total rendered.""" + from aipass.seedgo.apps.handlers.audit.audit_display import ( + print_system_summary, + ) + + results = [ + self._make_result("a", 85, type_errors=5), + self._make_result("b", 90, type_errors=0), + ] + print_system_summary(results) + + def test_no_type_errors_green(self): + """Zero type errors shows green message.""" + from aipass.seedgo.apps.handlers.audit.audit_display import ( + print_system_summary, + ) + + results = [self._make_result("a", 90)] + print_system_summary(results) + + def test_odd_standard_count(self): + """Odd number of standards renders last one alone.""" + from aipass.seedgo.apps.handlers.audit.audit_display import ( + print_system_summary, + ) + + results = [ + self._make_result( + "a", + 80, + scores={"arch": 80, "naming": 85, "meta": 90}, + ), + ] + print_system_summary(results) + + def test_top_improvement_areas(self): + """Top improvement areas listed with failing branch count.""" + from aipass.seedgo.apps.handlers.audit.audit_display import ( + print_system_summary, + ) + + results = [ + self._make_result( + "a", 60, scores={"arch": 50, "naming": 70} + ), + self._make_result( + "b", 80, scores={"arch": 90, "naming": 70} + ), + ] + print_system_summary(results) + + def test_standard_averages_icons(self): + """Standard averages display correct icons for all tiers.""" + from aipass.seedgo.apps.handlers.audit.audit_display import ( + print_system_summary, + ) + + results = [ + self._make_result( + "a", + 80, + scores={"high": 95, "mid": 80, "low": 50}, + ), + ] + print_system_summary(results) + + +# =========================================================================== +# BRANCH_AUDIT TESTS +# =========================================================================== + + +class TestDiscoverCheckers: + """Tests for discover_checkers.""" + + def test_discover_from_path(self, tmp_path): + """Discovers *_check.py modules with check_module.""" + from aipass.seedgo.apps.handlers.audit.branch_audit import ( + discover_checkers, + ) + + standards_dir = tmp_path / "standards" + standards_dir.mkdir() + checker_file = standards_dir / "naming_check.py" + checker_file.write_text( + "def check_module(path, bypass_rules=None):\n" + " return {\n" + " 'passed': True, 'score': 100, 'checks': []\n" + " }\n", + encoding="utf-8", + ) + result = discover_checkers(standards_dir) + assert "naming" in result + + def test_skip_without_check_functions(self, tmp_path): + """Skips modules without check_module or check_branch.""" + from aipass.seedgo.apps.handlers.audit.branch_audit import ( + discover_checkers, + ) + + standards_dir = tmp_path / "standards" + standards_dir.mkdir() + checker_file = standards_dir / "bad_check.py" + checker_file.write_text("x = 42\n", encoding="utf-8") + result = discover_checkers(standards_dir) + assert "bad" not in result + + def test_skip_failing_load(self, tmp_path): + """Skips modules that raise during exec_module.""" + from aipass.seedgo.apps.handlers.audit.branch_audit import ( + discover_checkers, + ) + + standards_dir = tmp_path / "standards" + standards_dir.mkdir() + checker_file = standards_dir / "broken_check.py" + checker_file.write_text( + "raise RuntimeError('broken')\n", encoding="utf-8" + ) + result = discover_checkers(standards_dir) + assert "broken" not in result + + def test_empty_directory(self, tmp_path): + """Empty standards dir returns empty dict.""" + from aipass.seedgo.apps.handlers.audit.branch_audit import ( + discover_checkers, + ) + + standards_dir = tmp_path / "standards" + standards_dir.mkdir() + result = discover_checkers(standards_dir) + assert result == {} + + def test_spec_none_skipped(self, tmp_path): + """Files where spec_from_file_location returns None skipped.""" + from aipass.seedgo.apps.handlers.audit.branch_audit import ( + discover_checkers, + ) + + standards_dir = tmp_path / "standards" + standards_dir.mkdir() + checker_file = standards_dir / "valid_check.py" + checker_file.write_text( + "def check_module(path, bypass_rules=None):\n" + " return {\n" + " 'passed': True, 'score': 100, 'checks': []\n" + " }\n", + encoding="utf-8", + ) + with patch( + "importlib.util.spec_from_file_location", + return_value=None, + ): + result = discover_checkers(standards_dir) + assert "valid" not in result + + def test_check_branch_function_discovered(self, tmp_path): + """Modules with check_branch are discovered.""" + from aipass.seedgo.apps.handlers.audit.branch_audit import ( + discover_checkers, + ) + + standards_dir = tmp_path / "standards" + standards_dir.mkdir() + checker_file = standards_dir / "branch_check.py" + checker_file.write_text( + "def check_branch(path, bypass_rules=None):\n" + " return {\n" + " 'passed': True, 'score': 100, 'checks': []\n" + " }\n", + encoding="utf-8", + ) + result = discover_checkers(standards_dir) + assert "branch" in result + + +class TestCollectPyFiles: + """Tests for _collect_py_files.""" + + def test_no_apps_dir(self, tmp_path): + """Returns empty list when apps/ does not exist.""" + from aipass.seedgo.apps.handlers.audit.branch_audit import ( + _collect_py_files, + ) + + result = _collect_py_files(tmp_path) + assert result == [] + + def test_collects_py_files(self, tmp_path): + """Collects .py from apps/, excluding __init__.py.""" + from aipass.seedgo.apps.handlers.audit.branch_audit import ( + _collect_py_files, + ) + + apps_dir = tmp_path / "apps" + apps_dir.mkdir() + (apps_dir / "__init__.py").write_text("", encoding="utf-8") + (apps_dir / "module.py").write_text("pass", encoding="utf-8") + subdir = apps_dir / "handlers" + subdir.mkdir() + (subdir / "handler.py").write_text( + "pass", encoding="utf-8" + ) + result = _collect_py_files(tmp_path) + names = [f["name"] for f in result] + assert "module.py" in names + assert "handler.py" in names + assert "__init__.py" not in names + + def test_respects_ignore_patterns(self, tmp_path): + """Files matching ignore patterns are excluded.""" + import sys + + mock_ign = sys.modules[ + "aipass.seedgo.apps.handlers.bypass" + ].ignore_handler + mock_ign.get_audit_ignore_patterns.return_value = ["test_"] + + from aipass.seedgo.apps.handlers.audit.branch_audit import ( + _collect_py_files, + ) + + apps_dir = tmp_path / "apps" + apps_dir.mkdir() + (apps_dir / "module.py").write_text( + "pass", encoding="utf-8" + ) + (apps_dir / "test_module.py").write_text( + "pass", encoding="utf-8" + ) + result = _collect_py_files(tmp_path) + names = [f["name"] for f in result] + assert "module.py" in names + assert "test_module.py" not in names + + +class TestExtractBranchLevelViolations: + """Tests for _extract_branch_level_violations.""" + + def test_empty_result(self): + """Empty dict returns empty list.""" + from aipass.seedgo.apps.handlers.audit.branch_audit import ( + _extract_branch_level_violations, + ) + + assert _extract_branch_level_violations({}) == [] + + def test_extracts_violations(self): + """Extracts violations from checks with list-type keys.""" + from aipass.seedgo.apps.handlers.audit.branch_audit import ( + _extract_branch_level_violations, + ) + + result = { + "checks": [ + { + "name": "unused_check", + "passed": False, + "message": "Found unused", + "unused": [ + { + "file": "/foo.py", + "name": "bar", + "line": 10, + }, + { + "file": "/foo.py", + "name": "baz", + "line": 20, + }, + ], + }, + ] + } + violations = _extract_branch_level_violations(result) + assert len(violations) == 1 + assert violations[0]["file"] == "/foo.py" + assert len(violations[0]["issues"]) == 2 + + def test_skips_non_list_keys(self): + """Skips standard keys (name, passed, message, score).""" + from aipass.seedgo.apps.handlers.audit.branch_audit import ( + _extract_branch_level_violations, + ) + + result = { + "checks": [ + { + "name": "check", + "passed": True, + "message": "ok", + "score": 100, + }, + ] + } + assert _extract_branch_level_violations(result) == [] + + def test_skips_items_without_file_key(self): + """Skips list items without a file key.""" + from aipass.seedgo.apps.handlers.audit.branch_audit import ( + _extract_branch_level_violations, + ) + + result = { + "checks": [ + { + "name": "check", + "passed": False, + "message": "err", + "items": [{"name": "orphan"}], + }, + ] + } + assert _extract_branch_level_violations(result) == [] + + def test_multiple_files(self): + """Groups violations by file path.""" + from aipass.seedgo.apps.handlers.audit.branch_audit import ( + _extract_branch_level_violations, + ) + + result = { + "checks": [ + { + "name": "dead", + "passed": False, + "message": "found dead", + "dead_functions": [ + { + "file": "/a.py", + "name": "f_a", + "line": 1, + }, + { + "file": "/b.py", + "name": "f_b", + "line": 2, + }, + ], + }, + ] + } + violations = _extract_branch_level_violations(result) + assert len(violations) == 2 + files = {v["file"] for v in violations} + assert "/a.py" in files + assert "/b.py" in files + + +class TestRunAllFiles: + """Tests for _run_all_files.""" + + def test_basic_run(self): + """Basic run collects scores from passing checks.""" + from aipass.seedgo.apps.handlers.audit.branch_audit import ( + _run_all_files, + ) + + checker = MagicMock() + checker.check_module = MagicMock( + return_value={ + "passed": True, + "score": 90, + "checks": [ + {"passed": True, "message": "OK"}, + ], + } + ) + checker.FILE_FILTER = None + files = [{"file": "/foo.py", "name": "foo.py"}] + violations, scores = _run_all_files( + checker, "naming", files, [] + ) + assert len(scores) == 1 + assert scores[0] == 90 + + def test_checker_raises_exception(self): + """Checker that raises is skipped.""" + from aipass.seedgo.apps.handlers.audit.branch_audit import ( + _run_all_files, + ) + + checker = MagicMock() + checker.check_module = MagicMock( + side_effect=RuntimeError("boom") + ) + checker.FILE_FILTER = None + files = [{"file": "/foo.py", "name": "foo.py"}] + violations, scores = _run_all_files( + checker, "naming", files, [] + ) + assert violations == [] + assert scores == [] + + def test_file_filter(self): + """FILE_FILTER skips non-matching files.""" + from aipass.seedgo.apps.handlers.audit.branch_audit import ( + _run_all_files, + ) + + checker = MagicMock() + checker.check_module = MagicMock( + return_value={ + "passed": True, + "score": 100, + "checks": [ + {"passed": True, "message": "OK"}, + ], + } + ) + checker.FILE_FILTER = "handler" + files = [ + {"file": "/handler.py", "name": "handler.py"}, + {"file": "/module.py", "name": "module.py"}, + ] + violations, scores = _run_all_files( + checker, "naming", files, [] + ) + assert checker.check_module.call_count == 1 + + def test_skipped_checks_excluded(self): + """Checks with skipped/not applicable excluded.""" + from aipass.seedgo.apps.handlers.audit.branch_audit import ( + _run_all_files, + ) + + checker = MagicMock() + checker.check_module = MagicMock( + return_value={ + "passed": True, + "score": 100, + "checks": [ + { + "passed": True, + "message": "Skipped -- not applicable", + } + ], + } + ) + checker.FILE_FILTER = None + files = [{"file": "/foo.py", "name": "foo.py"}] + violations, scores = _run_all_files( + checker, "naming", files, [] + ) + assert scores == [] + + def test_failing_checks_collected(self): + """Failing checks collected as violations.""" + from aipass.seedgo.apps.handlers.audit.branch_audit import ( + _run_all_files, + ) + + checker = MagicMock() + checker.check_module = MagicMock( + return_value={ + "passed": False, + "score": 40, + "checks": [ + {"passed": False, "message": "Bad naming"}, + {"passed": True, "message": "OK"}, + ], + } + ) + checker.FILE_FILTER = None + files = [{"file": "/bad.py", "name": "bad.py"}] + violations, scores = _run_all_files( + checker, "naming", files, [] + ) + assert len(violations) == 1 + assert violations[0]["score"] == 40 + assert "Bad naming" in violations[0]["issues"] + + +class TestLoadDiagnosticsChecker: + """Tests for _load_diagnostics_checker.""" + + def test_path_not_exists(self): + """Returns None when diagnostics file does not exist.""" + from aipass.seedgo.apps.handlers.audit.branch_audit import ( + _load_diagnostics_checker, + ) + + with patch("pathlib.Path.exists", return_value=False): + result = _load_diagnostics_checker() + assert result is None + + def test_spec_none(self): + """Returns None when spec_from_file_location is None.""" + from aipass.seedgo.apps.handlers.audit.branch_audit import ( + _load_diagnostics_checker, + ) + + with ( + patch("pathlib.Path.exists", return_value=True), + patch( + "importlib.util.spec_from_file_location", + return_value=None, + ), + ): + result = _load_diagnostics_checker() + assert result is None + + def test_exec_module_raises(self): + """Returns None when exec_module raises.""" + from aipass.seedgo.apps.handlers.audit.branch_audit import ( + _load_diagnostics_checker, + ) + + mock_spec = MagicMock() + mock_spec.loader.exec_module.side_effect = RuntimeError( + "fail" + ) + with ( + patch("pathlib.Path.exists", return_value=True), + patch( + "importlib.util.spec_from_file_location", + return_value=mock_spec, + ), + patch( + "importlib.util.module_from_spec", + return_value=MagicMock(), + ), + ): + result = _load_diagnostics_checker() + assert result is None + + def test_successful_load(self): + """Returns module when loading succeeds.""" + from aipass.seedgo.apps.handlers.audit.branch_audit import ( + _load_diagnostics_checker, + ) + + mock_mod = MagicMock() + mock_spec = MagicMock() + with ( + patch("pathlib.Path.exists", return_value=True), + patch( + "importlib.util.spec_from_file_location", + return_value=mock_spec, + ), + patch( + "importlib.util.module_from_spec", + return_value=mock_mod, + ), + ): + result = _load_diagnostics_checker() + assert result is mock_mod + + +def _setup_branch(tmp_path: Path) -> tuple: + """Create minimal branch structure, return (branch_dict, path).""" + branch_path = tmp_path / "mybranch" + branch_path.mkdir() + entry = branch_path / "apps" / "main.py" + entry.parent.mkdir(parents=True) + entry.write_text("pass", encoding="utf-8") + branch = { + "name": "mybranch", + "entry_file": str(entry), + "path": str(branch_path), + } + return branch, branch_path + + +def _make_checker( + scope: str = "entry_point", + check_module_result: dict | None = None, + check_branch_result: dict | None = None, + has_check_module: bool = True, + has_check_branch: bool = False, + has_post: bool = False, + post_result: tuple | None = None, +) -> MagicMock: + """Build a mock checker with configurable behavior.""" + checker = MagicMock() + checker.AUDIT_SCOPE = scope + checker.FILE_FILTER = None + + if has_check_module: + default_mod = { + "passed": True, + "score": 100, + "checks": [], + } + checker.check_module = MagicMock( + return_value=check_module_result or default_mod + ) + else: + del checker.check_module + + if has_check_branch: + default_br = { + "passed": True, + "score": 100, + "checks": [], + } + checker.check_branch = MagicMock( + return_value=check_branch_result or default_br + ) + else: + del checker.check_branch + + if has_post: + checker.check_branch_post = MagicMock( + return_value=post_result or ([], []) + ) + else: + del checker.check_branch_post + + return checker + + +class TestAuditBranch: + """Tests for audit_branch.""" + + def test_basic_audit(self, tmp_path, monkeypatch): + """Basic audit with one entry-point checker.""" + from aipass.seedgo.apps.handlers.audit import branch_audit + + branch, _ = _setup_branch(tmp_path) + checker = _make_checker() + monkeypatch.setattr( + branch_audit, + "discover_checkers", + lambda pack_path=None: {"naming": checker}, + ) + monkeypatch.setattr( + branch_audit, + "_load_diagnostics_checker", + lambda: None, + ) + monkeypatch.setattr( + branch_audit, "scan_branch", lambda p: None + ) + + result = branch_audit.audit_branch(branch, []) + assert result["branch"] == branch + assert "naming" in result["scores"] + assert result["average"] == 100 + + def test_branch_level_checker(self, tmp_path, monkeypatch): + """Branch-level checker calls check_branch.""" + from aipass.seedgo.apps.handlers.audit import branch_audit + + branch, _ = _setup_branch(tmp_path) + checker = _make_checker( + scope="branch_level", + has_check_module=False, + has_check_branch=True, + check_branch_result={ + "passed": True, + "score": 85, + "checks": [], + }, + ) + monkeypatch.setattr( + branch_audit, + "discover_checkers", + lambda pack_path=None: {"dead_code": checker}, + ) + monkeypatch.setattr( + branch_audit, + "_load_diagnostics_checker", + lambda: None, + ) + monkeypatch.setattr( + branch_audit, "scan_branch", lambda p: None + ) + + result = branch_audit.audit_branch(branch, []) + assert result["scores"]["dead_code"] == 85 + + def test_branch_level_checker_exception( + self, tmp_path, monkeypatch + ): + """Branch-level checker that raises produces score 0.""" + from aipass.seedgo.apps.handlers.audit import branch_audit + + branch, _ = _setup_branch(tmp_path) + checker = _make_checker( + scope="branch_level", + has_check_module=False, + has_check_branch=True, + ) + checker.check_branch.side_effect = RuntimeError("boom") + monkeypatch.setattr( + branch_audit, + "discover_checkers", + lambda pack_path=None: {"broken": checker}, + ) + monkeypatch.setattr( + branch_audit, + "_load_diagnostics_checker", + lambda: None, + ) + monkeypatch.setattr( + branch_audit, "scan_branch", lambda p: None + ) + + result = branch_audit.audit_branch(branch, []) + assert result["scores"]["broken"] == 0 + assert "error" in result["results"]["broken"] + + def test_entry_point_checker_exception( + self, tmp_path, monkeypatch + ): + """Entry-point checker that raises produces score 0.""" + from aipass.seedgo.apps.handlers.audit import branch_audit + + branch, _ = _setup_branch(tmp_path) + checker = _make_checker() + checker.check_module.side_effect = RuntimeError("crash") + monkeypatch.setattr( + branch_audit, + "discover_checkers", + lambda pack_path=None: {"naming": checker}, + ) + monkeypatch.setattr( + branch_audit, + "_load_diagnostics_checker", + lambda: None, + ) + monkeypatch.setattr( + branch_audit, "scan_branch", lambda p: None + ) + + result = branch_audit.audit_branch(branch, []) + assert result["scores"]["naming"] == 0 + assert "error" in result["results"]["naming"] + + def test_all_files_scope(self, tmp_path, monkeypatch): + """all_files scope runs checker on every file.""" + from aipass.seedgo.apps.handlers.audit import branch_audit + + branch, branch_path = _setup_branch(tmp_path) + apps_dir = Path(branch_path) / "apps" + (apps_dir / "other.py").write_text( + "pass", encoding="utf-8" + ) + + checker = _make_checker( + scope="all_files", + check_module_result={ + "passed": True, + "score": 80, + "checks": [ + {"passed": True, "message": "OK"}, + ], + }, + ) + monkeypatch.setattr( + branch_audit, + "discover_checkers", + lambda pack_path=None: {"naming": checker}, + ) + monkeypatch.setattr( + branch_audit, + "_load_diagnostics_checker", + lambda: None, + ) + monkeypatch.setattr( + branch_audit, "scan_branch", lambda p: None + ) + + result = branch_audit.audit_branch(branch, []) + assert result["scores"]["naming"] == 80 + + def test_all_files_with_violations( + self, tmp_path, monkeypatch + ): + """all_files scope with failing checks updates results.""" + from aipass.seedgo.apps.handlers.audit import branch_audit + + branch, branch_path = _setup_branch(tmp_path) + apps_dir = Path(branch_path) / "apps" + (apps_dir / "bad.py").write_text( + "pass", encoding="utf-8" + ) + + def check_side_effect(path, bypass_rules=None): + """Return different results based on path.""" + if "bad" in path: + return { + "passed": False, + "score": 40, + "checks": [ + { + "passed": False, + "message": "Bad naming", + } + ], + } + return { + "passed": True, + "score": 90, + "checks": [ + {"passed": True, "message": "OK"}, + ], + } + + checker = _make_checker(scope="all_files") + checker.check_module.side_effect = check_side_effect + monkeypatch.setattr( + branch_audit, + "discover_checkers", + lambda pack_path=None: {"naming": checker}, + ) + monkeypatch.setattr( + branch_audit, + "_load_diagnostics_checker", + lambda: None, + ) + monkeypatch.setattr( + branch_audit, "scan_branch", lambda p: None + ) + + result = branch_audit.audit_branch(branch, []) + assert "naming_violations" in result + + def test_dynamic_post_check(self, tmp_path, monkeypatch): + """check_branch_post discovered and called.""" + from aipass.seedgo.apps.handlers.audit import branch_audit + + branch, _ = _setup_branch(tmp_path) + post_violations = [ + { + "path": "/extra.py", + "score": 0, + "issues": ["Extra issue"], + } + ] + checker = _make_checker( + has_post=True, + post_result=(post_violations, [50]), + ) + monkeypatch.setattr( + branch_audit, + "discover_checkers", + lambda pack_path=None: {"naming": checker}, + ) + monkeypatch.setattr( + branch_audit, + "_load_diagnostics_checker", + lambda: None, + ) + monkeypatch.setattr( + branch_audit, "scan_branch", lambda p: None + ) + + result = branch_audit.audit_branch(branch, []) + # Post check: (100 + 50) / 2 = 75 + assert result["scores"]["naming"] == 75 + assert len(result["naming_violations"]) == 1 + + def test_post_check_raises_exception( + self, tmp_path, monkeypatch + ): + """check_branch_post that raises is caught.""" + from aipass.seedgo.apps.handlers.audit import branch_audit + + branch, _ = _setup_branch(tmp_path) + checker = _make_checker(has_post=True) + checker.check_branch_post.side_effect = RuntimeError( + "fail" + ) + monkeypatch.setattr( + branch_audit, + "discover_checkers", + lambda pack_path=None: {"naming": checker}, + ) + monkeypatch.setattr( + branch_audit, + "_load_diagnostics_checker", + lambda: None, + ) + monkeypatch.setattr( + branch_audit, "scan_branch", lambda p: None + ) + + result = branch_audit.audit_branch(branch, []) + assert result["scores"]["naming"] == 100 + + def test_diagnostics_checker_added( + self, tmp_path, monkeypatch + ): + """Diagnostics checker loaded and added.""" + from aipass.seedgo.apps.handlers.audit import branch_audit + + branch, _ = _setup_branch(tmp_path) + diag_mod = MagicMock() + diag_mod.check_branch = MagicMock( + return_value={ + "passed": True, + "score": 90, + "checks": [], + "total_errors": 0, + "results": [], + } + ) + diag_mod.AUDIT_SCOPE = "branch_level" + del diag_mod.check_module + + monkeypatch.setattr( + branch_audit, + "discover_checkers", + lambda pack_path=None: {}, + ) + monkeypatch.setattr( + branch_audit, + "_load_diagnostics_checker", + lambda: diag_mod, + ) + monkeypatch.setattr( + branch_audit, "scan_branch", lambda p: None + ) + + result = branch_audit.audit_branch(branch, []) + assert "diagnostics" in result["scores"] + + def test_deprecated_documents_dir( + self, tmp_path, monkeypatch + ): + """DOCUMENTS/ directory detected as deprecated.""" + from aipass.seedgo.apps.handlers.audit import branch_audit + + branch, branch_path = _setup_branch(tmp_path) + (Path(branch_path) / "DOCUMENTS").mkdir() + + monkeypatch.setattr( + branch_audit, + "discover_checkers", + lambda pack_path=None: {}, + ) + monkeypatch.setattr( + branch_audit, + "_load_diagnostics_checker", + lambda: None, + ) + monkeypatch.setattr( + branch_audit, "scan_branch", lambda p: None + ) + + result = branch_audit.audit_branch(branch, []) + assert len(result["deprecated_patterns"]) == 1 + dep = result["deprecated_patterns"][0] + assert dep["old"] == "DOCUMENTS/" + + def test_no_deprecated_without_documents( + self, tmp_path, monkeypatch + ): + """No deprecated patterns without DOCUMENTS/.""" + from aipass.seedgo.apps.handlers.audit import branch_audit + + branch, _ = _setup_branch(tmp_path) + monkeypatch.setattr( + branch_audit, + "discover_checkers", + lambda pack_path=None: {}, + ) + monkeypatch.setattr( + branch_audit, + "_load_diagnostics_checker", + lambda: None, + ) + monkeypatch.setattr( + branch_audit, "scan_branch", lambda p: None + ) + + result = branch_audit.audit_branch(branch, []) + assert result["deprecated_patterns"] == [] + + def test_scan_branch_exception(self, tmp_path, monkeypatch): + """scan_branch exception caught, test_map is None.""" + from aipass.seedgo.apps.handlers.audit import branch_audit + + branch, _ = _setup_branch(tmp_path) + monkeypatch.setattr( + branch_audit, + "discover_checkers", + lambda pack_path=None: {}, + ) + monkeypatch.setattr( + branch_audit, + "_load_diagnostics_checker", + lambda: None, + ) + monkeypatch.setattr( + branch_audit, + "scan_branch", + MagicMock(side_effect=RuntimeError("scan fail")), + ) + + result = branch_audit.audit_branch(branch, []) + assert result["test_map"] is None + + def test_scan_branch_success(self, tmp_path, monkeypatch): + """scan_branch success populates test_map.""" + from aipass.seedgo.apps.handlers.audit import branch_audit + + branch, _ = _setup_branch(tmp_path) + scan_result = { + "total_functions": 10, + "tested_functions": 5, + "branch": "mybranch", + } + monkeypatch.setattr( + branch_audit, + "discover_checkers", + lambda pack_path=None: {}, + ) + monkeypatch.setattr( + branch_audit, + "_load_diagnostics_checker", + lambda: None, + ) + monkeypatch.setattr( + branch_audit, + "scan_branch", + MagicMock(return_value=scan_result), + ) + + result = branch_audit.audit_branch(branch, []) + assert result["test_map"] == scan_result + + def test_no_checkers_zero_average( + self, tmp_path, monkeypatch + ): + """No checkers returns average 0.""" + from aipass.seedgo.apps.handlers.audit import branch_audit + + branch, _ = _setup_branch(tmp_path) + monkeypatch.setattr( + branch_audit, + "discover_checkers", + lambda pack_path=None: {}, + ) + monkeypatch.setattr( + branch_audit, + "_load_diagnostics_checker", + lambda: None, + ) + monkeypatch.setattr( + branch_audit, "scan_branch", lambda p: None + ) + + result = branch_audit.audit_branch(branch, []) + assert result["average"] == 0 + + def test_implicit_branch_level( + self, tmp_path, monkeypatch + ): + """Checker without check_module treated as branch-level.""" + from aipass.seedgo.apps.handlers.audit import branch_audit + + branch, _ = _setup_branch(tmp_path) + checker = _make_checker( + scope="entry_point", + has_check_module=False, + has_check_branch=True, + check_branch_result={ + "passed": True, + "score": 75, + "checks": [], + }, + ) + monkeypatch.setattr( + branch_audit, + "discover_checkers", + lambda pack_path=None: {"implicit": checker}, + ) + monkeypatch.setattr( + branch_audit, + "_load_diagnostics_checker", + lambda: None, + ) + monkeypatch.setattr( + branch_audit, "scan_branch", lambda p: None + ) + + result = branch_audit.audit_branch(branch, []) + assert result["scores"]["implicit"] == 75 + + def test_pack_path_forwarded(self, tmp_path, monkeypatch): + """pack_path argument forwarded to discover_checkers.""" + from aipass.seedgo.apps.handlers.audit import branch_audit + + branch, _ = _setup_branch(tmp_path) + captured: dict = {} + + def mock_discover(pack_path=None): + """Capture the pack_path argument.""" + captured["value"] = pack_path + return {} + + monkeypatch.setattr( + branch_audit, "discover_checkers", mock_discover + ) + monkeypatch.setattr( + branch_audit, + "_load_diagnostics_checker", + lambda: None, + ) + monkeypatch.setattr( + branch_audit, "scan_branch", lambda p: None + ) + + pack = tmp_path / "custom_standards" + branch_audit.audit_branch(branch, [], pack_path=pack) + assert captured["value"] == pack + + def test_diagnostics_not_duplicated( + self, tmp_path, monkeypatch + ): + """Existing diagnostics not overwritten by loader.""" + from aipass.seedgo.apps.handlers.audit import branch_audit + + branch, _ = _setup_branch(tmp_path) + existing = _make_checker( + scope="branch_level", + has_check_module=False, + has_check_branch=True, + check_branch_result={ + "passed": True, + "score": 80, + "checks": [], + "total_errors": 0, + "results": [], + }, + ) + different = MagicMock() + + monkeypatch.setattr( + branch_audit, + "discover_checkers", + lambda pack_path=None: {"diagnostics": existing}, + ) + monkeypatch.setattr( + branch_audit, + "_load_diagnostics_checker", + lambda: different, + ) + monkeypatch.setattr( + branch_audit, "scan_branch", lambda p: None + ) + + result = branch_audit.audit_branch(branch, []) + assert result["scores"]["diagnostics"] == 80 + + def test_branch_level_violations_extraction( + self, tmp_path, monkeypatch + ): + """Branch-level results have violations extracted.""" + from aipass.seedgo.apps.handlers.audit import branch_audit + + branch, _ = _setup_branch(tmp_path) + checker = _make_checker( + scope="branch_level", + has_check_module=False, + has_check_branch=True, + check_branch_result={ + "passed": False, + "score": 60, + "checks": [ + { + "name": "unused_check", + "passed": False, + "message": "Found unused", + "unused": [ + { + "file": "/foo.py", + "name": "bar", + "line": 10, + }, + ], + }, + ], + }, + ) + monkeypatch.setattr( + branch_audit, + "discover_checkers", + lambda pack_path=None: {"dead_code": checker}, + ) + monkeypatch.setattr( + branch_audit, + "_load_diagnostics_checker", + lambda: None, + ) + monkeypatch.setattr( + branch_audit, "scan_branch", lambda p: None + ) + + result = branch_audit.audit_branch(branch, []) + assert len(result["dead_code_violations"]) == 1 + + def test_output_diagnostics_fields( + self, tmp_path, monkeypatch + ): + """Output includes type_errors and type_error_files.""" + from aipass.seedgo.apps.handlers.audit import branch_audit + + branch, _ = _setup_branch(tmp_path) + diag = _make_checker( + scope="branch_level", + has_check_module=False, + has_check_branch=True, + check_branch_result={ + "passed": True, + "score": 90, + "checks": [], + "total_errors": 3, + "results": [{"file": "a.py", "errors": 3}], + }, + ) + monkeypatch.setattr( + branch_audit, + "discover_checkers", + lambda pack_path=None: {"diagnostics": diag}, + ) + monkeypatch.setattr( + branch_audit, + "_load_diagnostics_checker", + lambda: None, + ) + monkeypatch.setattr( + branch_audit, "scan_branch", lambda p: None + ) + + result = branch_audit.audit_branch(branch, []) + assert result["type_errors"] == 3 + assert result["type_error_files"] == [ + {"file": "a.py", "errors": 3} + ] + + def test_post_check_empty_scores( + self, tmp_path, monkeypatch + ): + """Post-check with empty scores does not change score.""" + from aipass.seedgo.apps.handlers.audit import branch_audit + + branch, _ = _setup_branch(tmp_path) + checker = _make_checker( + has_post=True, + post_result=([], []), + ) + monkeypatch.setattr( + branch_audit, + "discover_checkers", + lambda pack_path=None: {"naming": checker}, + ) + monkeypatch.setattr( + branch_audit, + "_load_diagnostics_checker", + lambda: None, + ) + monkeypatch.setattr( + branch_audit, "scan_branch", lambda p: None + ) + + result = branch_audit.audit_branch(branch, []) + assert result["scores"]["naming"] == 100 diff --git a/src/aipass/seedgo/tests/test_coverage_proof_diagnostics.py b/src/aipass/seedgo/tests/test_coverage_proof_diagnostics.py new file mode 100644 index 00000000..9e9f7e63 --- /dev/null +++ b/src/aipass/seedgo/tests/test_coverage_proof_diagnostics.py @@ -0,0 +1,1891 @@ +"""Tests for plugin_integrity (aipass_proof) and diagnostics_check (diagnostics) coverage.""" + +# =================== META ==================== +# Name: test_coverage_proof_diagnostics.py +# Description: Line-coverage tests for plugin_integrity.py and diagnostics_check.py +# Version: 1.0.0 +# Created: 2026-04-26 +# Modified: 2026-04-26 +# ============================================= + +import ast +import json +import subprocess + +import pytest +from pathlib import Path +from unittest.mock import MagicMock, patch + + +# --------------------------------------------------------------------------- +# Fixtures +# --------------------------------------------------------------------------- + + +@pytest.fixture(autouse=True) +def _mock_infrastructure(monkeypatch): + """Mock heavy infrastructure imports for proof and diagnostics handlers.""" + import sys + + mock_logger = MagicMock() + mock_json_handler = MagicMock() + mock_json_handler.log_operation = MagicMock(return_value=True) + + # -- prax --------------------------------------------------------------- + prax_mod = MagicMock() + prax_mod.logger = mock_logger + monkeypatch.setitem(sys.modules, "aipass.prax", prax_mod) + + # -- seedgo json handler ------------------------------------------------ + json_pkg = MagicMock() + json_pkg.json_handler = mock_json_handler + monkeypatch.setitem(sys.modules, "aipass.seedgo.apps.handlers.json", json_pkg) + json_mod = MagicMock() + json_mod.log_operation = mock_json_handler.log_operation + monkeypatch.setitem( + sys.modules, + "aipass.seedgo.apps.handlers.json.json_handler", + json_mod, + ) + + # -- bypass handler ----------------------------------------------------- + bypass_pkg = MagicMock() + bypass_ignore = MagicMock() + bypass_ignore.get_template_ignore_patterns = MagicMock(return_value=[]) + bypass_ignore.get_audit_ignore_patterns = MagicMock(return_value=[]) + bypass_pkg.ignore_handler = bypass_ignore + monkeypatch.setitem(sys.modules, "aipass.seedgo.apps.handlers.bypass", bypass_pkg) + monkeypatch.setitem( + sys.modules, + "aipass.seedgo.apps.handlers.bypass.ignore_handler", + bypass_ignore, + ) + + # -- rich.console (diagnostics_check imports Console directly) ---------- + mock_console_cls = MagicMock() + rich_console_mod = MagicMock() + rich_console_mod.Console = mock_console_cls + monkeypatch.setitem(sys.modules, "rich", MagicMock()) + monkeypatch.setitem(sys.modules, "rich.console", rich_console_mod) + + # Force re-imports so modules pick up fresh mocks + for mod_name in [ + "aipass.seedgo.apps.handlers.aipass_proof.plugin_integrity", + "aipass.seedgo.apps.handlers.diagnostics.diagnostics_check", + ]: + monkeypatch.delitem(sys.modules, mod_name, raising=False) + + +# --------------------------------------------------------------------------- +# Helper to build typed parent dicts for AST tests +# --------------------------------------------------------------------------- + + +def _parents(*pairs: tuple[ast.AST, ast.AST]) -> dict[int, ast.AST]: + """Build a child-id -> parent mapping accepted by plugin_integrity helpers.""" + return {id(child): parent for child, parent in pairs} + + +# =========================================================================== +# PLUGIN INTEGRITY -- _resolve_target_modules +# =========================================================================== + + +class TestResolveTargetModules: + """Tests for _resolve_target_modules.""" + + def test_returns_five_modules(self, tmp_path): + """Target module list always has five entries.""" + from aipass.seedgo.apps.handlers.aipass_proof.plugin_integrity import ( + _resolve_target_modules, + ) + + pack_dir = tmp_path / "apps" / "handlers" / "aipass_standards" + pack_dir.mkdir(parents=True) + result = _resolve_target_modules(pack_dir) + assert len(result) == 5 + + def test_labels_correct(self, tmp_path): + """Target module labels match expected file names.""" + from aipass.seedgo.apps.handlers.aipass_proof.plugin_integrity import ( + _resolve_target_modules, + ) + + pack_dir = tmp_path / "apps" / "handlers" / "aipass_standards" + pack_dir.mkdir(parents=True) + result = _resolve_target_modules(pack_dir) + labels = [m["label"] for m in result] + assert "standards_audit.py" in labels + assert "branch_audit.py" in labels + assert "audit_display.py" in labels + assert "standards_query.py" in labels + assert "seedgo.py" in labels + + def test_cosmetic_flag(self, tmp_path): + """audit_display.py is marked cosmetic; others are not.""" + from aipass.seedgo.apps.handlers.aipass_proof.plugin_integrity import ( + _resolve_target_modules, + ) + + pack_dir = tmp_path / "apps" / "handlers" / "aipass_standards" + pack_dir.mkdir(parents=True) + result = _resolve_target_modules(pack_dir) + cosmetic_map = {m["label"]: m["cosmetic"] for m in result} + assert cosmetic_map["audit_display.py"] is True + assert cosmetic_map["standards_audit.py"] is False + + +# =========================================================================== +# PLUGIN INTEGRITY -- _discover_standard_names +# =========================================================================== + + +class TestDiscoverStandardNames: + """Tests for _discover_standard_names.""" + + def test_nonexistent_directory(self, tmp_path): + """Returns empty list for nonexistent directory.""" + from aipass.seedgo.apps.handlers.aipass_proof.plugin_integrity import ( + _discover_standard_names, + ) + + result = _discover_standard_names(tmp_path / "missing") + assert result == [] + + def test_no_check_files(self, tmp_path): + """Returns empty list when directory has no *_check.py files.""" + from aipass.seedgo.apps.handlers.aipass_proof.plugin_integrity import ( + _discover_standard_names, + ) + + (tmp_path / "readme.py").write_text("pass", encoding="utf-8") + result = _discover_standard_names(tmp_path) + assert result == [] + + def test_discovers_names(self, tmp_path): + """Discovers standard names from *_check.py filenames.""" + from aipass.seedgo.apps.handlers.aipass_proof.plugin_integrity import ( + _discover_standard_names, + ) + + (tmp_path / "meta_check.py").write_text("pass", encoding="utf-8") + (tmp_path / "naming_check.py").write_text("pass", encoding="utf-8") + (tmp_path / "cli_check.py").write_text("pass", encoding="utf-8") + result = _discover_standard_names(tmp_path) + assert sorted(result) == ["cli", "meta", "naming"] + + +# =========================================================================== +# PLUGIN INTEGRITY -- AST helpers +# =========================================================================== + + +class TestEnclosingContext: + """Tests for _enclosing_context.""" + + def test_module_level(self): + """Returns '' when no parent function/class.""" + from aipass.seedgo.apps.handlers.aipass_proof.plugin_integrity import ( + _enclosing_context, + ) + + node = ast.Constant(value="x") + result = _enclosing_context(node, {}) + assert result == "" + + def test_inside_function(self): + """Returns function context when inside a def.""" + from aipass.seedgo.apps.handlers.aipass_proof.plugin_integrity import ( + _enclosing_context, + ) + + func_node = ast.FunctionDef( + name="my_func", + args=ast.arguments( + posonlyargs=[], + args=[], + kwonlyargs=[], + kw_defaults=[], + defaults=[], + ), + body=[], + decorator_list=[], + ) + child_node = ast.Constant(value="test") + parents = _parents((child_node, func_node)) + result = _enclosing_context(child_node, parents) + assert "def my_func()" in result + + def test_inside_class_and_method(self): + """Returns nested context for class > method.""" + from aipass.seedgo.apps.handlers.aipass_proof.plugin_integrity import ( + _enclosing_context, + ) + + class_node = ast.ClassDef( + name="MyClass", + bases=[], + keywords=[], + body=[], + decorator_list=[], + ) + method_node = ast.FunctionDef( + name="do_stuff", + args=ast.arguments( + posonlyargs=[], + args=[], + kwonlyargs=[], + kw_defaults=[], + defaults=[], + ), + body=[], + decorator_list=[], + ) + child_node = ast.Constant(value="x") + parents = _parents( + (child_node, method_node), + (method_node, class_node), + ) + result = _enclosing_context(child_node, parents) + assert "class MyClass" in result + assert "def do_stuff()" in result + + def test_inside_async_function(self): + """Returns context for async function.""" + from aipass.seedgo.apps.handlers.aipass_proof.plugin_integrity import ( + _enclosing_context, + ) + + async_func = ast.AsyncFunctionDef( + name="async_work", + args=ast.arguments( + posonlyargs=[], + args=[], + kwonlyargs=[], + kw_defaults=[], + defaults=[], + ), + body=[], + decorator_list=[], + ) + child = ast.Constant(value="x") + parents = _parents((child, async_func)) + result = _enclosing_context(child, parents) + assert "def async_work()" in result + + +class TestIsDocstring: + """Tests for _is_docstring.""" + + def test_actual_docstring(self): + """Detects module-level docstring.""" + from aipass.seedgo.apps.handlers.aipass_proof.plugin_integrity import ( + _is_docstring, + ) + + source = '"""This is a docstring."""\nx = 1\n' + tree = ast.parse(source) + first_expr = tree.body[0] + assert isinstance(first_expr, ast.Expr) + node = first_expr.value + assert isinstance(node, ast.Constant) + assert _is_docstring(node, tree) is True + + def test_non_docstring(self): + """Regular string literal is not a docstring.""" + from aipass.seedgo.apps.handlers.aipass_proof.plugin_integrity import ( + _is_docstring, + ) + + source = 'x = "not a docstring"\n' + tree = ast.parse(source) + for node in ast.walk(tree): + if isinstance(node, ast.Constant) and node.value == "not a docstring": + assert _is_docstring(node, tree) is False + return + pytest.fail("Did not find string constant in AST") + + def test_function_docstring(self): + """Detects function-level docstring.""" + from aipass.seedgo.apps.handlers.aipass_proof.plugin_integrity import ( + _is_docstring, + ) + + source = 'def foo():\n """My doc."""\n pass\n' + tree = ast.parse(source) + func = tree.body[0] + assert isinstance(func, ast.FunctionDef) + first_stmt = func.body[0] + assert isinstance(first_stmt, ast.Expr) + node = first_stmt.value + assert isinstance(node, ast.Constant) + assert _is_docstring(node, tree) is True + + +class TestIsDisplayString: + """Tests for _is_display_string.""" + + def test_inside_fstring(self): + """String inside f-string is display text.""" + from aipass.seedgo.apps.handlers.aipass_proof.plugin_integrity import ( + _is_display_string, + ) + + child = ast.Constant(value="hello") + fstring = ast.JoinedStr(values=[child]) + assert _is_display_string(child, _parents((child, fstring))) is True + + def test_no_parent(self): + """Node with no parent returns False.""" + from aipass.seedgo.apps.handlers.aipass_proof.plugin_integrity import ( + _is_display_string, + ) + + child = ast.Constant(value="hello") + assert _is_display_string(child, {}) is False + + def test_print_call(self): + """String passed to console.print() is display text.""" + from aipass.seedgo.apps.handlers.aipass_proof.plugin_integrity import ( + _is_display_string, + ) + + child = ast.Constant(value="hello") + func = ast.Attribute( + value=ast.Name(id="console", ctx=ast.Load()), + attr="print", + ctx=ast.Load(), + ) + call = ast.Call(func=func, args=[child], keywords=[]) + assert _is_display_string(child, _parents((child, call))) is True + + def test_logger_info_call(self): + """String passed to logger.info() is display text.""" + from aipass.seedgo.apps.handlers.aipass_proof.plugin_integrity import ( + _is_display_string, + ) + + child = ast.Constant(value="hello") + func = ast.Attribute( + value=ast.Name(id="logger", ctx=ast.Load()), + attr="info", + ctx=ast.Load(), + ) + call = ast.Call(func=func, args=[child], keywords=[]) + assert _is_display_string(child, _parents((child, call))) is True + + def test_logger_error_call(self): + """String passed to logger.error() is display text.""" + from aipass.seedgo.apps.handlers.aipass_proof.plugin_integrity import ( + _is_display_string, + ) + + child = ast.Constant(value="hello") + func = ast.Attribute( + value=ast.Name(id="logger", ctx=ast.Load()), + attr="error", + ctx=ast.Load(), + ) + call = ast.Call(func=func, args=[child], keywords=[]) + assert _is_display_string(child, _parents((child, call))) is True + + def test_logger_warning_call(self): + """String passed to logger.warning() is display text.""" + from aipass.seedgo.apps.handlers.aipass_proof.plugin_integrity import ( + _is_display_string, + ) + + child = ast.Constant(value="hello") + func = ast.Attribute( + value=ast.Name(id="logger", ctx=ast.Load()), + attr="warning", + ctx=ast.Load(), + ) + call = ast.Call(func=func, args=[child], keywords=[]) + assert _is_display_string(child, _parents((child, call))) is True + + def test_logger_debug_call(self): + """String passed to logger.debug() is display text.""" + from aipass.seedgo.apps.handlers.aipass_proof.plugin_integrity import ( + _is_display_string, + ) + + child = ast.Constant(value="hello") + func = ast.Attribute( + value=ast.Name(id="logger", ctx=ast.Load()), + attr="debug", + ctx=ast.Load(), + ) + call = ast.Call(func=func, args=[child], keywords=[]) + assert _is_display_string(child, _parents((child, call))) is True + + def test_header_name_call(self): + """String passed to header() function call is display text.""" + from aipass.seedgo.apps.handlers.aipass_proof.plugin_integrity import ( + _is_display_string, + ) + + child = ast.Constant(value="hello") + func = ast.Name(id="header", ctx=ast.Load()) + call = ast.Call(func=func, args=[child], keywords=[]) + assert _is_display_string(child, _parents((child, call))) is True + + def test_error_name_call(self): + """String passed to error() function call is display text.""" + from aipass.seedgo.apps.handlers.aipass_proof.plugin_integrity import ( + _is_display_string, + ) + + child = ast.Constant(value="hello") + func = ast.Name(id="error", ctx=ast.Load()) + call = ast.Call(func=func, args=[child], keywords=[]) + assert _is_display_string(child, _parents((child, call))) is True + + def test_warning_name_call(self): + """String passed to warning() function call is display text.""" + from aipass.seedgo.apps.handlers.aipass_proof.plugin_integrity import ( + _is_display_string, + ) + + child = ast.Constant(value="hello") + func = ast.Name(id="warning", ctx=ast.Load()) + call = ast.Call(func=func, args=[child], keywords=[]) + assert _is_display_string(child, _parents((child, call))) is True + + def test_keyword_arg_in_print_call(self): + """Keyword arg value inside console.print() is display text.""" + from aipass.seedgo.apps.handlers.aipass_proof.plugin_integrity import ( + _is_display_string, + ) + + child = ast.Constant(value="hello") + kw = ast.keyword(arg="style", value=child) + func = ast.Attribute( + value=ast.Name(id="console", ctx=ast.Load()), + attr="print", + ctx=ast.Load(), + ) + call = ast.Call(func=func, args=[], keywords=[kw]) + parents = _parents((child, kw), (kw, call)) + assert _is_display_string(child, parents) is True + + def test_keyword_arg_in_log_operation(self): + """Keyword arg value inside .log_operation() is display text.""" + from aipass.seedgo.apps.handlers.aipass_proof.plugin_integrity import ( + _is_display_string, + ) + + child = ast.Constant(value="hello") + kw = ast.keyword(arg="data", value=child) + func = ast.Attribute( + value=ast.Name(id="json_handler", ctx=ast.Load()), + attr="log_operation", + ctx=ast.Load(), + ) + call = ast.Call(func=func, args=[], keywords=[kw]) + parents = _parents((child, kw), (kw, call)) + assert _is_display_string(child, parents) is True + + def test_keyword_arg_in_header_call(self): + """Keyword arg value inside header() call is display text.""" + from aipass.seedgo.apps.handlers.aipass_proof.plugin_integrity import ( + _is_display_string, + ) + + child = ast.Constant(value="hello") + kw = ast.keyword(arg="title", value=child) + func = ast.Name(id="header", ctx=ast.Load()) + call = ast.Call(func=func, args=[], keywords=[kw]) + parents = _parents((child, kw), (kw, call)) + assert _is_display_string(child, parents) is True + + def test_keyword_arg_in_error_call(self): + """Keyword arg value inside error() call is display text.""" + from aipass.seedgo.apps.handlers.aipass_proof.plugin_integrity import ( + _is_display_string, + ) + + child = ast.Constant(value="hello") + kw = ast.keyword(arg="msg", value=child) + func = ast.Name(id="error", ctx=ast.Load()) + call = ast.Call(func=func, args=[], keywords=[kw]) + parents = _parents((child, kw), (kw, call)) + assert _is_display_string(child, parents) is True + + def test_keyword_arg_in_warning_call(self): + """Keyword arg value inside warning() call is display text.""" + from aipass.seedgo.apps.handlers.aipass_proof.plugin_integrity import ( + _is_display_string, + ) + + child = ast.Constant(value="hello") + kw = ast.keyword(arg="msg", value=child) + func = ast.Name(id="warning", ctx=ast.Load()) + call = ast.Call(func=func, args=[], keywords=[kw]) + parents = _parents((child, kw), (kw, call)) + assert _is_display_string(child, parents) is True + + def test_non_display_context(self): + """String in normal assignment context is not display text.""" + from aipass.seedgo.apps.handlers.aipass_proof.plugin_integrity import ( + _is_display_string, + ) + + child = ast.Constant(value="hello") + assign = ast.Assign( + targets=[ast.Name(id="x", ctx=ast.Store())], + value=child, + ) + assert _is_display_string(child, _parents((child, assign))) is False + + def test_keyword_arg_in_non_display_call(self): + """Keyword arg value in a non-display call is not display text.""" + from aipass.seedgo.apps.handlers.aipass_proof.plugin_integrity import ( + _is_display_string, + ) + + child = ast.Constant(value="hello") + kw = ast.keyword(arg="name", value=child) + func = ast.Name(id="do_work", ctx=ast.Load()) + call = ast.Call(func=func, args=[], keywords=[kw]) + parents = _parents((child, kw), (kw, call)) + assert _is_display_string(child, parents) is False + + +class TestIsDictKeyAccess: + """Tests for _is_dict_key_access.""" + + def test_get_call(self): + """result.get('key') is dict key access.""" + from aipass.seedgo.apps.handlers.aipass_proof.plugin_integrity import ( + _is_dict_key_access, + ) + + child = ast.Constant(value="key") + func = ast.Attribute( + value=ast.Name(id="result", ctx=ast.Load()), + attr="get", + ctx=ast.Load(), + ) + call = ast.Call(func=func, args=[child], keywords=[]) + assert _is_dict_key_access(child, _parents((child, call))) is True + + def test_subscript(self): + """result['key'] is dict key access.""" + from aipass.seedgo.apps.handlers.aipass_proof.plugin_integrity import ( + _is_dict_key_access, + ) + + child = ast.Constant(value="key") + subscript = ast.Subscript( + value=ast.Name(id="result", ctx=ast.Load()), + slice=child, + ctx=ast.Load(), + ) + assert _is_dict_key_access(child, _parents((child, subscript))) is True + + def test_no_parent(self): + """Node with no parent returns False.""" + from aipass.seedgo.apps.handlers.aipass_proof.plugin_integrity import ( + _is_dict_key_access, + ) + + child = ast.Constant(value="key") + assert _is_dict_key_access(child, {}) is False + + def test_non_get_attribute_call(self): + """result.append('key') is not dict key access.""" + from aipass.seedgo.apps.handlers.aipass_proof.plugin_integrity import ( + _is_dict_key_access, + ) + + child = ast.Constant(value="key") + func = ast.Attribute( + value=ast.Name(id="result", ctx=ast.Load()), + attr="append", + ctx=ast.Load(), + ) + call = ast.Call(func=func, args=[child], keywords=[]) + assert _is_dict_key_access(child, _parents((child, call))) is False + + def test_plain_function_call(self): + """do_work('key') is not dict key access.""" + from aipass.seedgo.apps.handlers.aipass_proof.plugin_integrity import ( + _is_dict_key_access, + ) + + child = ast.Constant(value="key") + func = ast.Name(id="do_work", ctx=ast.Load()) + call = ast.Call(func=func, args=[child], keywords=[]) + assert _is_dict_key_access(child, _parents((child, call))) is False + + +# =========================================================================== +# PLUGIN INTEGRITY -- _scan_file_ast +# =========================================================================== + + +class TestScanFileAst: + """Tests for _scan_file_ast.""" + + def test_clean_file(self, tmp_path): + """File with no standard name references returns empty.""" + from aipass.seedgo.apps.handlers.aipass_proof.plugin_integrity import ( + _scan_file_ast, + ) + + f = tmp_path / "clean.py" + f.write_text('x = "hello"\n', encoding="utf-8") + result = _scan_file_ast(f, ["meta", "naming"]) + assert result == [] + + def test_syntax_error_file(self, tmp_path): + """File with syntax error returns empty and does not crash.""" + from aipass.seedgo.apps.handlers.aipass_proof.plugin_integrity import ( + _scan_file_ast, + ) + + f = tmp_path / "broken.py" + f.write_text("def foo(\n", encoding="utf-8") + result = _scan_file_ast(f, ["meta"]) + assert result == [] + + def test_finds_hardcoded_standard_name(self, tmp_path): + """Detects non-ambiguous standard name as string literal.""" + from aipass.seedgo.apps.handlers.aipass_proof.plugin_integrity import ( + _scan_file_ast, + ) + + f = tmp_path / "hardcoded.py" + f.write_text('x = "custom_standard"\n', encoding="utf-8") + result = _scan_file_ast(f, ["custom_standard"]) + assert len(result) == 1 + assert result[0]["name"] == "custom_standard" + assert result[0]["kind"] == "ast_string_literal" + + def test_skips_ambiguous_names(self, tmp_path): + """Ambiguous standard names are not flagged by AST scanner.""" + from aipass.seedgo.apps.handlers.aipass_proof.plugin_integrity import ( + _scan_file_ast, + ) + + f = tmp_path / "ambiguous.py" + f.write_text('x = "meta"\n', encoding="utf-8") + result = _scan_file_ast(f, ["meta"]) + assert result == [] + + def test_skips_docstrings(self, tmp_path): + """Standard name in docstring is not flagged.""" + from aipass.seedgo.apps.handlers.aipass_proof.plugin_integrity import ( + _scan_file_ast, + ) + + f = tmp_path / "docstr.py" + f.write_text('"""custom_standard is great."""\nx = 1\n', encoding="utf-8") + result = _scan_file_ast(f, ["custom_standard"]) + assert result == [] + + def test_skips_display_strings(self, tmp_path): + """Standard name in display call is not flagged.""" + from aipass.seedgo.apps.handlers.aipass_proof.plugin_integrity import ( + _scan_file_ast, + ) + + f = tmp_path / "display.py" + f.write_text( + 'import logger\nlogger.info("custom_standard")\n', + encoding="utf-8", + ) + result = _scan_file_ast(f, ["custom_standard"]) + assert result == [] + + def test_skips_dict_key_access(self, tmp_path): + """Standard name as dict key access is not flagged.""" + from aipass.seedgo.apps.handlers.aipass_proof.plugin_integrity import ( + _scan_file_ast, + ) + + f = tmp_path / "dictkey.py" + f.write_text('result = data.get("custom_standard")\n', encoding="utf-8") + result = _scan_file_ast(f, ["custom_standard"]) + assert result == [] + + def test_non_matching_name_not_flagged(self, tmp_path): + """String literal not matching any standard name is not flagged.""" + from aipass.seedgo.apps.handlers.aipass_proof.plugin_integrity import ( + _scan_file_ast, + ) + + f = tmp_path / "other.py" + f.write_text('x = "something_else"\n', encoding="utf-8") + result = _scan_file_ast(f, ["custom_standard"]) + assert result == [] + + +# =========================================================================== +# PLUGIN INTEGRITY -- _scan_file_regex +# =========================================================================== + + +class TestScanFileRegex: + """Tests for _scan_file_regex.""" + + def test_clean_file(self, tmp_path): + """File with no suspicious patterns returns empty.""" + from aipass.seedgo.apps.handlers.aipass_proof.plugin_integrity import ( + _scan_file_regex, + ) + + f = tmp_path / "clean.py" + f.write_text('x = "hello"\n', encoding="utf-8") + result = _scan_file_regex(f, ["meta"]) + assert result == [] + + def test_hardcoded_function_call(self, tmp_path): + """Detects check_( pattern.""" + from aipass.seedgo.apps.handlers.aipass_proof.plugin_integrity import ( + _scan_file_regex, + ) + + f = tmp_path / "func_call.py" + f.write_text("result = check_custom_std(path)\n", encoding="utf-8") + result = _scan_file_regex(f, ["custom_std"]) + assert len(result) >= 1 + kinds = [r["kind"] for r in result] + assert "hardcoded_function_call" in kinds + + def test_hardcoded_violation_key(self, tmp_path): + """Detects _violations pattern.""" + from aipass.seedgo.apps.handlers.aipass_proof.plugin_integrity import ( + _scan_file_regex, + ) + + f = tmp_path / "viol_key.py" + f.write_text("x = custom_std_violations\n", encoding="utf-8") + result = _scan_file_regex(f, ["custom_std"]) + assert len(result) >= 1 + kinds = [r["kind"] for r in result] + assert "hardcoded_violation_key" in kinds + + def test_hardcoded_branch_condition(self, tmp_path): + """Detects == 'standard' pattern.""" + from aipass.seedgo.apps.handlers.aipass_proof.plugin_integrity import ( + _scan_file_regex, + ) + + f = tmp_path / "branch_cond.py" + f.write_text("if name == 'custom_std':\n pass\n", encoding="utf-8") + result = _scan_file_regex(f, ["custom_std"]) + assert len(result) >= 1 + kinds = [r["kind"] for r in result] + assert "hardcoded_branch" in kinds + + def test_skips_comment_lines(self, tmp_path): + """Pure comment lines are skipped.""" + from aipass.seedgo.apps.handlers.aipass_proof.plugin_integrity import ( + _scan_file_regex, + ) + + f = tmp_path / "comments.py" + f.write_text("# check_custom_std(path)\n", encoding="utf-8") + result = _scan_file_regex(f, ["custom_std"]) + assert result == [] + + def test_skips_docstring_content(self, tmp_path): + """Content inside triple-quoted docstrings is skipped.""" + from aipass.seedgo.apps.handlers.aipass_proof.plugin_integrity import ( + _scan_file_regex, + ) + + f = tmp_path / "docstr.py" + f.write_text( + '"""\ncheck_custom_std(path)\n"""\npass\n', + encoding="utf-8", + ) + result = _scan_file_regex(f, ["custom_std"]) + assert result == [] + + def test_inline_comment_stripped(self, tmp_path): + """Code before inline comment is still checked.""" + from aipass.seedgo.apps.handlers.aipass_proof.plugin_integrity import ( + _scan_file_regex, + ) + + f = tmp_path / "inline.py" + f.write_text( + "result = check_custom_std(path) # run check\n", + encoding="utf-8", + ) + result = _scan_file_regex(f, ["custom_std"]) + assert len(result) >= 1 + + def test_double_quote_branch(self, tmp_path): + """Detects == 'standard' with double quotes.""" + from aipass.seedgo.apps.handlers.aipass_proof.plugin_integrity import ( + _scan_file_regex, + ) + + f = tmp_path / "dquote.py" + f.write_text('if name == "custom_std":\n pass\n', encoding="utf-8") + result = _scan_file_regex(f, ["custom_std"]) + kinds = [r["kind"] for r in result] + assert "hardcoded_branch" in kinds + + def test_single_quote_docstring(self, tmp_path): + """Single-quote triple docstrings are tracked.""" + from aipass.seedgo.apps.handlers.aipass_proof.plugin_integrity import ( + _scan_file_regex, + ) + + f = tmp_path / "squote_doc.py" + f.write_text( + "'''\ncheck_custom_std(path)\n'''\npass\n", + encoding="utf-8", + ) + result = _scan_file_regex(f, ["custom_std"]) + assert result == [] + + +# =========================================================================== +# PLUGIN INTEGRITY -- scan (public interface) +# =========================================================================== + + +class TestPluginIntegrityScan: + """Tests for the public scan() function.""" + + def test_all_modules_missing(self, tmp_path): + """All target modules missing results in 'missing' status.""" + from aipass.seedgo.apps.handlers.aipass_proof.plugin_integrity import ( + scan, + ) + + pack_dir = tmp_path / "apps" / "handlers" / "aipass_standards" + pack_dir.mkdir(parents=True) + result = scan(pack_dir) + assert result["passed"] is True + assert result["missing_count"] == 5 + assert result["flagged_count"] == 0 + + def test_clean_module(self, tmp_path): + """Module with no issues is marked clean.""" + from aipass.seedgo.apps.handlers.aipass_proof.plugin_integrity import ( + scan, + ) + + pack_dir = tmp_path / "apps" / "handlers" / "aipass_standards" + pack_dir.mkdir(parents=True) + + modules_dir = tmp_path / "apps" / "modules" + modules_dir.mkdir(parents=True) + (modules_dir / "standards_audit.py").write_text('"""Clean module."""\nx = 1\n', encoding="utf-8") + + result = scan(pack_dir) + statuses = {m["label"]: m["status"] for m in result["modules"]} + assert statuses["standards_audit.py"] == "clean" + + def test_cosmetic_module_flagged_as_cosmetic(self, tmp_path): + """Module in COSMETIC_MODULES with findings is marked cosmetic.""" + from aipass.seedgo.apps.handlers.aipass_proof.plugin_integrity import ( + scan, + ) + + pack_dir = tmp_path / "apps" / "handlers" / "aipass_standards" + pack_dir.mkdir(parents=True) + + (pack_dir / "zz_special_check.py").write_text("pass", encoding="utf-8") + + audit_dir = tmp_path / "apps" / "handlers" / "audit" + audit_dir.mkdir(parents=True) + (audit_dir / "audit_display.py").write_text('x = "zz_special"\n', encoding="utf-8") + + result = scan(pack_dir) + statuses = {m["label"]: m["status"] for m in result["modules"]} + assert statuses["audit_display.py"] == "cosmetic" + assert result["cosmetic_count"] >= 1 + + def test_flagged_module(self, tmp_path): + """Non-cosmetic module with findings is flagged and scan fails.""" + from aipass.seedgo.apps.handlers.aipass_proof.plugin_integrity import ( + scan, + ) + + pack_dir = tmp_path / "apps" / "handlers" / "aipass_standards" + pack_dir.mkdir(parents=True) + + (pack_dir / "zz_special_check.py").write_text("pass", encoding="utf-8") + + modules_dir = tmp_path / "apps" / "modules" + modules_dir.mkdir(parents=True) + (modules_dir / "standards_audit.py").write_text('x = "zz_special"\n', encoding="utf-8") + + result = scan(pack_dir) + statuses = {m["label"]: m["status"] for m in result["modules"]} + assert statuses["standards_audit.py"] == "flagged" + assert result["flagged_count"] >= 1 + assert result["passed"] is False + assert "FAILED" in result["summary"] + + def test_deduplication_of_findings(self, tmp_path): + """Findings from AST and regex are deduplicated.""" + from aipass.seedgo.apps.handlers.aipass_proof.plugin_integrity import ( + scan, + ) + + pack_dir = tmp_path / "apps" / "handlers" / "aipass_standards" + pack_dir.mkdir(parents=True) + + (pack_dir / "zz_special_check.py").write_text("pass", encoding="utf-8") + + modules_dir = tmp_path / "apps" / "modules" + modules_dir.mkdir(parents=True) + (modules_dir / "standards_audit.py").write_text("if name == 'zz_special':\n pass\n", encoding="utf-8") + + result = scan(pack_dir) + flagged_mod = [m for m in result["modules"] if m["label"] == "standards_audit.py"][0] + keys: set[tuple[object, ...]] = set() + for finding in flagged_mod["findings"]: + key = (finding["line"], finding["name"], finding["kind"]) + assert key not in keys, f"Duplicate finding: {key}" + keys.add(key) + + def test_summary_with_all_clean(self, tmp_path): + """Summary string says 'clean' when all modules pass.""" + from aipass.seedgo.apps.handlers.aipass_proof.plugin_integrity import ( + scan, + ) + + pack_dir = tmp_path / "apps" / "handlers" / "aipass_standards" + pack_dir.mkdir(parents=True) + + modules_dir = tmp_path / "apps" / "modules" + modules_dir.mkdir(parents=True) + (modules_dir / "standards_audit.py").write_text("x = 1\n", encoding="utf-8") + (modules_dir / "standards_query.py").write_text("x = 1\n", encoding="utf-8") + audit_dir = tmp_path / "apps" / "handlers" / "audit" + audit_dir.mkdir(parents=True) + (audit_dir / "branch_audit.py").write_text("x = 1\n", encoding="utf-8") + (audit_dir / "audit_display.py").write_text("x = 1\n", encoding="utf-8") + entry_point = tmp_path / "apps" / "seedgo.py" + entry_point.write_text("x = 1\n", encoding="utf-8") + + result = scan(pack_dir) + assert result["passed"] is True + assert "clean" in result["summary"].lower() + + def test_issue_kind_labels(self, tmp_path): + """Issue strings use human-readable kind labels.""" + from aipass.seedgo.apps.handlers.aipass_proof.plugin_integrity import ( + scan, + ) + + pack_dir = tmp_path / "apps" / "handlers" / "aipass_standards" + pack_dir.mkdir(parents=True) + + (pack_dir / "zz_special_check.py").write_text("pass", encoding="utf-8") + + modules_dir = tmp_path / "apps" / "modules" + modules_dir.mkdir(parents=True) + (modules_dir / "standards_audit.py").write_text( + "check_zz_special(path)\nzz_special_violations = []\n", + encoding="utf-8", + ) + + result = scan(pack_dir) + issue_text = " ".join(result["issues"]) + assert "function call" in issue_text or "violation key" in issue_text + + +# =========================================================================== +# DIAGNOSTICS CHECK -- should_ignore_file +# =========================================================================== + + +class TestShouldIgnoreFile: + """Tests for should_ignore_file.""" + + def test_matching_pattern(self): + """File matching a pattern is ignored.""" + from aipass.seedgo.apps.handlers.diagnostics.diagnostics_check import ( + should_ignore_file, + ) + + assert should_ignore_file("/home/user/venv/lib/file.py", ["venv"]) is True + + def test_no_matching_pattern(self): + """File not matching any pattern is not ignored.""" + from aipass.seedgo.apps.handlers.diagnostics.diagnostics_check import ( + should_ignore_file, + ) + + result = should_ignore_file("/home/user/src/file.py", ["venv", "node_modules"]) + assert result is False + + def test_empty_patterns(self): + """Empty pattern list means nothing is ignored.""" + from aipass.seedgo.apps.handlers.diagnostics.diagnostics_check import ( + should_ignore_file, + ) + + assert should_ignore_file("/any/file.py", []) is False + + +# =========================================================================== +# DIAGNOSTICS CHECK -- check_file +# =========================================================================== + + +class TestCheckFile: + """Tests for check_file.""" + + def test_nonexistent_file(self, tmp_path): + """Nonexistent file returns error dict.""" + from aipass.seedgo.apps.handlers.diagnostics.diagnostics_check import ( + check_file, + ) + + result = check_file(str(tmp_path / "missing.py")) + assert result["errors"] == 0 + assert "error" in result + assert "not found" in result["error"].lower() + + def test_non_python_file(self, tmp_path): + """Non-.py file is skipped.""" + from aipass.seedgo.apps.handlers.diagnostics.diagnostics_check import ( + check_file, + ) + + f = tmp_path / "data.txt" + f.write_text("hello", encoding="utf-8") + result = check_file(str(f)) + assert result["errors"] == 0 + assert "skipped" in result + + def test_successful_pyright_check(self, tmp_path): + """Pyright with clean output returns zero errors.""" + from aipass.seedgo.apps.handlers.diagnostics.diagnostics_check import ( + check_file, + ) + + f = tmp_path / "good.py" + f.write_text("x = 1\n", encoding="utf-8") + + pyright_out = json.dumps({"generalDiagnostics": [], "summary": {"filesAnalyzed": 1}}) + with patch("aipass.seedgo.apps.handlers.diagnostics.diagnostics_check.subprocess.run") as mock_run: + mock_run.return_value = MagicMock(stdout=pyright_out, stderr="") + result = check_file(str(f)) + + assert result["errors"] == 0 + assert result["warnings"] == 0 + assert result["diagnostics"] == [] + + def test_pyright_with_errors(self, tmp_path): + """Pyright output with errors is parsed correctly.""" + from aipass.seedgo.apps.handlers.diagnostics.diagnostics_check import ( + check_file, + ) + + f = tmp_path / "bad.py" + f.write_text("x: int = 'no'\n", encoding="utf-8") + + diags = [ + { + "severity": "error", + "range": {"start": {"line": 0}}, + "message": "Type mismatch", + "rule": "reportAssignment", + }, + { + "severity": "warning", + "range": {"start": {"line": 1}}, + "message": "Unused var", + "rule": "reportUnusedVariable", + }, + ] + + with patch("aipass.seedgo.apps.handlers.diagnostics.diagnostics_check.subprocess.run") as mock_run: + mock_run.return_value = MagicMock( + stdout=json.dumps({"generalDiagnostics": diags}), + stderr="", + ) + result = check_file(str(f)) + + assert result["errors"] == 1 + assert result["warnings"] == 1 + assert len(result["diagnostics"]) == 2 + assert result["diagnostics"][0]["line"] == 1 + + def test_pyright_json_decode_error(self, tmp_path): + """Non-JSON pyright output returns error dict.""" + from aipass.seedgo.apps.handlers.diagnostics.diagnostics_check import ( + check_file, + ) + + f = tmp_path / "file.py" + f.write_text("x = 1\n", encoding="utf-8") + + with patch("aipass.seedgo.apps.handlers.diagnostics.diagnostics_check.subprocess.run") as mock_run: + mock_run.return_value = MagicMock( + stdout="not json", + stderr="something went wrong", + ) + result = check_file(str(f)) + + assert "error" in result + + def test_pyright_timeout(self, tmp_path): + """Pyright timeout returns timeout error.""" + from aipass.seedgo.apps.handlers.diagnostics.diagnostics_check import ( + check_file, + ) + + f = tmp_path / "slow.py" + f.write_text("x = 1\n", encoding="utf-8") + + with patch("aipass.seedgo.apps.handlers.diagnostics.diagnostics_check.subprocess.run") as mock_run: + mock_run.side_effect = subprocess.TimeoutExpired(cmd="pyright", timeout=30) + result = check_file(str(f)) + + assert "error" in result + assert "timed out" in result["error"].lower() + + def test_pyright_generic_exception(self, tmp_path): + """Generic exception during pyright returns error dict.""" + from aipass.seedgo.apps.handlers.diagnostics.diagnostics_check import ( + check_file, + ) + + f = tmp_path / "crash.py" + f.write_text("x = 1\n", encoding="utf-8") + + with patch("aipass.seedgo.apps.handlers.diagnostics.diagnostics_check.subprocess.run") as mock_run: + mock_run.side_effect = OSError("pyright not found") + result = check_file(str(f)) + + assert "error" in result + assert "pyright not found" in result["error"] + + +# =========================================================================== +# DIAGNOSTICS CHECK -- check_directory +# =========================================================================== + + +class TestCheckDirectory: + """Tests for check_directory.""" + + def test_nonexistent_directory(self, tmp_path): + """Nonexistent directory returns error.""" + from aipass.seedgo.apps.handlers.diagnostics.diagnostics_check import ( + check_directory, + ) + + result = check_directory(str(tmp_path / "nonexistent")) + assert result["total_files"] == 0 + assert "error" in result + + def test_empty_directory(self, tmp_path): + """Empty directory with no diagnostics returns zero totals.""" + from aipass.seedgo.apps.handlers.diagnostics.diagnostics_check import ( + check_directory, + ) + + pyright_out = json.dumps({"generalDiagnostics": [], "summary": {"filesAnalyzed": 0}}) + with patch("aipass.seedgo.apps.handlers.diagnostics.diagnostics_check.subprocess.run") as mock_run: + mock_run.return_value = MagicMock(stdout=pyright_out, stderr="") + result = check_directory(str(tmp_path)) + + assert result["total_errors"] == 0 + assert result["total_files"] == 0 + + def test_directory_with_errors(self, tmp_path): + """Directory with pyright errors parses and groups correctly.""" + from aipass.seedgo.apps.handlers.diagnostics.diagnostics_check import ( + check_directory, + ) + + diags = [ + { + "file": "/src/a.py", + "severity": "error", + "range": {"start": {"line": 5}}, + "message": "Err1", + "rule": "r1", + }, + { + "file": "/src/a.py", + "severity": "warning", + "range": {"start": {"line": 10}}, + "message": "Warn1", + "rule": "r2", + }, + { + "file": "/src/b.py", + "severity": "error", + "range": {"start": {"line": 1}}, + "message": "Err2", + "rule": "r3", + }, + ] + + pyright_out = json.dumps({"generalDiagnostics": diags, "summary": {"filesAnalyzed": 2}}) + with patch("aipass.seedgo.apps.handlers.diagnostics.diagnostics_check.subprocess.run") as mock_run: + mock_run.return_value = MagicMock(stdout=pyright_out, stderr="") + result = check_directory(str(tmp_path)) + + assert result["total_errors"] == 2 + assert result["total_warnings"] == 1 + assert result["files_with_errors"] == 2 + assert result["total_files"] == 2 + assert result["results"][0]["errors"] >= result["results"][1]["errors"] + + def test_directory_ignores_files(self, tmp_path): + """Files matching ignore patterns are skipped.""" + from aipass.seedgo.apps.handlers.diagnostics.diagnostics_check import ( + check_directory, + ) + + diags = [ + { + "file": "/src/venv/lib/bad.py", + "severity": "error", + "range": {"start": {"line": 1}}, + "message": "Err", + "rule": "r1", + }, + { + "file": "/src/good.py", + "severity": "error", + "range": {"start": {"line": 1}}, + "message": "Err", + "rule": "r2", + }, + ] + + pyright_out = json.dumps({"generalDiagnostics": diags, "summary": {"filesAnalyzed": 2}}) + mod_prefix = "aipass.seedgo.apps.handlers.diagnostics.diagnostics_check" + with ( + patch(f"{mod_prefix}.subprocess.run") as mock_run, + patch(f"{mod_prefix}.get_audit_ignore_patterns") as mock_ignore, + ): + mock_ignore.return_value = ["venv"] + mock_run.return_value = MagicMock(stdout=pyright_out, stderr="") + result = check_directory(str(tmp_path)) + + assert result["total_errors"] == 1 + assert len(result["results"]) == 1 + + def test_directory_json_decode_error(self, tmp_path): + """Non-JSON pyright output for directory returns error.""" + from aipass.seedgo.apps.handlers.diagnostics.diagnostics_check import ( + check_directory, + ) + + with patch("aipass.seedgo.apps.handlers.diagnostics.diagnostics_check.subprocess.run") as mock_run: + mock_run.return_value = MagicMock( + stdout="not json at all", + stderr="", + ) + result = check_directory(str(tmp_path)) + + assert "error" in result + assert result["total_files"] == 0 + + def test_directory_timeout(self, tmp_path): + """Timeout for directory check returns error.""" + from aipass.seedgo.apps.handlers.diagnostics.diagnostics_check import ( + check_directory, + ) + + with patch("aipass.seedgo.apps.handlers.diagnostics.diagnostics_check.subprocess.run") as mock_run: + mock_run.side_effect = subprocess.TimeoutExpired(cmd="pyright", timeout=300) + result = check_directory(str(tmp_path)) + + assert "error" in result + assert "timed out" in result["error"].lower() + + def test_directory_generic_exception(self, tmp_path): + """Generic exception during directory check returns error.""" + from aipass.seedgo.apps.handlers.diagnostics.diagnostics_check import ( + check_directory, + ) + + with patch("aipass.seedgo.apps.handlers.diagnostics.diagnostics_check.subprocess.run") as mock_run: + mock_run.side_effect = RuntimeError("unexpected") + result = check_directory(str(tmp_path)) + + assert "error" in result + assert "unexpected" in result["error"] + + +# =========================================================================== +# DIAGNOSTICS CHECK -- _discover_pack_configs +# =========================================================================== + + +class TestDiscoverPackConfigs: + """Tests for _discover_pack_configs.""" + + def test_no_handlers_dir(self, monkeypatch): + """Returns empty when HANDLERS_DIR does not exist.""" + from aipass.seedgo.apps.handlers.diagnostics import diagnostics_check + + monkeypatch.setattr(diagnostics_check, "HANDLERS_DIR", Path("/nonexistent/dir")) + result = diagnostics_check._discover_pack_configs() + assert result == [] + + def test_no_standards_dirs(self, tmp_path, monkeypatch): + """Returns empty when no *_standards directories exist.""" + from aipass.seedgo.apps.handlers.diagnostics import diagnostics_check + + monkeypatch.setattr(diagnostics_check, "HANDLERS_DIR", tmp_path) + (tmp_path / "other_dir").mkdir() + result = diagnostics_check._discover_pack_configs() + assert result == [] + + def test_standards_dir_without_config(self, tmp_path, monkeypatch): + """Returns empty when *_standards dir has no diagnostics.json.""" + from aipass.seedgo.apps.handlers.diagnostics import diagnostics_check + + monkeypatch.setattr(diagnostics_check, "HANDLERS_DIR", tmp_path) + (tmp_path / "aipass_standards").mkdir() + result = diagnostics_check._discover_pack_configs() + assert result == [] + + def test_valid_config(self, tmp_path, monkeypatch): + """Returns config when valid diagnostics.json exists.""" + from aipass.seedgo.apps.handlers.diagnostics import diagnostics_check + + monkeypatch.setattr(diagnostics_check, "HANDLERS_DIR", tmp_path) + std_dir = tmp_path / "aipass_standards" + std_dir.mkdir() + config = {"runners": {"python": True}} + (std_dir / "diagnostics.json").write_text(json.dumps(config), encoding="utf-8") + + result = diagnostics_check._discover_pack_configs() + assert len(result) == 1 + assert result[0]["pack_name"] == "aipass_standards" + assert result[0]["config"] == config + + def test_malformed_config_skipped(self, tmp_path, monkeypatch): + """Malformed JSON is skipped gracefully.""" + from aipass.seedgo.apps.handlers.diagnostics import diagnostics_check + + monkeypatch.setattr(diagnostics_check, "HANDLERS_DIR", tmp_path) + std_dir = tmp_path / "test_standards" + std_dir.mkdir() + (std_dir / "diagnostics.json").write_text("not json", encoding="utf-8") + + result = diagnostics_check._discover_pack_configs() + assert result == [] + + def test_non_directory_skipped(self, tmp_path, monkeypatch): + """Files (not directories) in HANDLERS_DIR are skipped.""" + from aipass.seedgo.apps.handlers.diagnostics import diagnostics_check + + monkeypatch.setattr(diagnostics_check, "HANDLERS_DIR", tmp_path) + (tmp_path / "some_standards").write_text("file", encoding="utf-8") + result = diagnostics_check._discover_pack_configs() + assert result == [] + + +# =========================================================================== +# DIAGNOSTICS CHECK -- _get_enabled_runners_from_config +# =========================================================================== + + +class TestGetEnabledRunners: + """Tests for _get_enabled_runners_from_config.""" + + def test_simple_bool_format(self): + """Simple bool format: {'python': true}.""" + from aipass.seedgo.apps.handlers.diagnostics.diagnostics_check import ( + _get_enabled_runners_from_config, + ) + + config = {"runners": {"python": True, "typescript": False}} + result = _get_enabled_runners_from_config(config) + assert result == ["python"] + + def test_detailed_dict_format(self): + """Detailed format: {'python': {'enabled': true, ...}}.""" + from aipass.seedgo.apps.handlers.diagnostics.diagnostics_check import ( + _get_enabled_runners_from_config, + ) + + config = { + "runners": { + "python": {"enabled": True, "timeout": 60}, + "rust": {"enabled": False}, + } + } + result = _get_enabled_runners_from_config(config) + assert result == ["python"] + + def test_empty_runners(self): + """Empty runners section returns empty list.""" + from aipass.seedgo.apps.handlers.diagnostics.diagnostics_check import ( + _get_enabled_runners_from_config, + ) + + result = _get_enabled_runners_from_config({"runners": {}}) + assert result == [] + + def test_no_runners_key(self): + """Missing 'runners' key returns empty list.""" + from aipass.seedgo.apps.handlers.diagnostics.diagnostics_check import ( + _get_enabled_runners_from_config, + ) + + result = _get_enabled_runners_from_config({}) + assert result == [] + + def test_mixed_formats(self): + """Mix of bool and dict format works.""" + from aipass.seedgo.apps.handlers.diagnostics.diagnostics_check import ( + _get_enabled_runners_from_config, + ) + + config = { + "runners": { + "python": True, + "rust": {"enabled": True}, + "go": False, + "java": {"enabled": False}, + } + } + result = _get_enabled_runners_from_config(config) + assert "python" in result + assert "rust" in result + assert "go" not in result + assert "java" not in result + + +# =========================================================================== +# DIAGNOSTICS CHECK -- _run_runner +# =========================================================================== + + +class TestRunRunner: + """Tests for _run_runner.""" + + def test_runner_not_found(self, tmp_path, monkeypatch): + """Returns None when runner file does not exist.""" + from aipass.seedgo.apps.handlers.diagnostics import diagnostics_check + + monkeypatch.setattr(diagnostics_check, "DIAGNOSTICS_DIR", tmp_path) + result = diagnostics_check._run_runner("nonexistent", "/branch") + assert result is None + + def test_runner_empty_file(self, tmp_path, monkeypatch): + """Returns None when runner file is empty (0 bytes).""" + from aipass.seedgo.apps.handlers.diagnostics import diagnostics_check + + monkeypatch.setattr(diagnostics_check, "DIAGNOSTICS_DIR", tmp_path) + (tmp_path / "empty_diagnostics.py").write_text("", encoding="utf-8") + result = diagnostics_check._run_runner("empty", "/branch") + assert result is None + + def test_runner_whitespace_only(self, tmp_path, monkeypatch): + """Returns None when runner file is whitespace only.""" + from aipass.seedgo.apps.handlers.diagnostics import diagnostics_check + + monkeypatch.setattr(diagnostics_check, "DIAGNOSTICS_DIR", tmp_path) + (tmp_path / "blank_diagnostics.py").write_text(" \n\n \n", encoding="utf-8") + result = diagnostics_check._run_runner("blank", "/branch") + assert result is None + + def test_runner_import_failure(self, tmp_path, monkeypatch): + """Returns None when runner module fails to import.""" + from aipass.seedgo.apps.handlers.diagnostics import diagnostics_check + + monkeypatch.setattr(diagnostics_check, "DIAGNOSTICS_DIR", tmp_path) + (tmp_path / "broken_diagnostics.py").write_text("import nonexistent_module_xyz\n", encoding="utf-8") + + with patch.object( + diagnostics_check.importlib, + "import_module", + side_effect=ImportError("no module"), + ): + result = diagnostics_check._run_runner("broken", "/branch") + + assert result is None + + def test_runner_no_check_branch(self, tmp_path, monkeypatch): + """Returns None when runner module has no check_branch function.""" + from aipass.seedgo.apps.handlers.diagnostics import diagnostics_check + + monkeypatch.setattr(diagnostics_check, "DIAGNOSTICS_DIR", tmp_path) + (tmp_path / "nofunc_diagnostics.py").write_text("x = 1\n", encoding="utf-8") + + mock_module = MagicMock(spec=[]) + with patch.object( + diagnostics_check.importlib, + "import_module", + return_value=mock_module, + ): + result = diagnostics_check._run_runner("nofunc", "/branch") + + assert result is None + + def test_runner_check_branch_raises(self, tmp_path, monkeypatch): + """Returns None when check_branch raises an exception.""" + from aipass.seedgo.apps.handlers.diagnostics import diagnostics_check + + monkeypatch.setattr(diagnostics_check, "DIAGNOSTICS_DIR", tmp_path) + (tmp_path / "crashing_diagnostics.py").write_text( + "def check_branch(b, bypass_rules=None):\n raise RuntimeError('boom')\n", + encoding="utf-8", + ) + + mock_module = MagicMock() + mock_module.check_branch = MagicMock(side_effect=RuntimeError("boom")) + with patch.object( + diagnostics_check.importlib, + "import_module", + return_value=mock_module, + ): + result = diagnostics_check._run_runner("crashing", "/branch") + + assert result is None + + def test_runner_success(self, tmp_path, monkeypatch): + """Returns result when runner executes successfully.""" + from aipass.seedgo.apps.handlers.diagnostics import diagnostics_check + + monkeypatch.setattr(diagnostics_check, "DIAGNOSTICS_DIR", tmp_path) + (tmp_path / "good_diagnostics.py").write_text( + "def check_branch(b, bypass_rules=None):\n return {}\n", + encoding="utf-8", + ) + + expected = {"total_errors": 0, "checks": []} + mock_module = MagicMock() + mock_module.check_branch = MagicMock(return_value=expected) + with patch.object( + diagnostics_check.importlib, + "import_module", + return_value=mock_module, + ): + result = diagnostics_check._run_runner("good", "/branch") + + assert result == expected + + def test_runner_typo_variant(self, tmp_path, monkeypatch): + """Falls back to *_diognostics.py typo variant.""" + from aipass.seedgo.apps.handlers.diagnostics import diagnostics_check + + monkeypatch.setattr(diagnostics_check, "DIAGNOSTICS_DIR", tmp_path) + (tmp_path / "legacy_diognostics.py").write_text( + "def check_branch(b, bypass_rules=None):\n return {}\n", + encoding="utf-8", + ) + + expected = {"total_errors": 0} + mock_module = MagicMock() + mock_module.check_branch = MagicMock(return_value=expected) + with patch.object( + diagnostics_check.importlib, + "import_module", + return_value=mock_module, + ): + result = diagnostics_check._run_runner("legacy", "/branch") + + assert result == expected + + def test_runner_ioerror_on_read(self, tmp_path, monkeypatch): + """Returns None when runner file cannot be read.""" + from aipass.seedgo.apps.handlers.diagnostics import diagnostics_check + + monkeypatch.setattr(diagnostics_check, "DIAGNOSTICS_DIR", tmp_path) + runner_file = tmp_path / "ioerr_diagnostics.py" + runner_file.write_text("content\n", encoding="utf-8") + + with patch.object(Path, "read_text", side_effect=IOError("read error")): + result = diagnostics_check._run_runner("ioerr", "/branch") + + assert result is None + + +# =========================================================================== +# DIAGNOSTICS CHECK -- check_branch +# =========================================================================== + + +class TestCheckBranch: + """Tests for check_branch.""" + + def test_no_apps_directory(self, tmp_path): + """Returns passed=True when no apps/ directory exists.""" + from aipass.seedgo.apps.handlers.diagnostics.diagnostics_check import ( + check_branch, + ) + + result = check_branch(str(tmp_path)) + assert result["passed"] is True + assert result["score"] == 100 + assert "error" in result + + def test_with_runner_configs(self, tmp_path, monkeypatch): + """Uses runner configs when packs are discovered.""" + from aipass.seedgo.apps.handlers.diagnostics import diagnostics_check + + apps_dir = tmp_path / "apps" + apps_dir.mkdir() + + monkeypatch.setattr( + diagnostics_check, + "_discover_pack_configs", + lambda: [ + { + "pack_name": "test_standards", + "pack_path": tmp_path, + "config": {"runners": {"python": True}}, + } + ], + ) + + runner_result = { + "total_errors": 2, + "total_warnings": 1, + "total_files": 5, + "checks": [ + { + "name": "Type errors in a.py", + "passed": False, + "message": "2 errors", + } + ], + "results": [{"file": "a.py", "errors": 2}], + } + monkeypatch.setattr( + diagnostics_check, + "_run_runner", + lambda name, bp, bypass_rules=None: runner_result, + ) + + result = diagnostics_check.check_branch(str(tmp_path)) + assert result["passed"] is False + assert result["total_errors"] == 2 + assert result["total_warnings"] == 1 + assert result["total_files"] == 5 + assert result["score"] == 90 + + def test_fallback_to_python_runner(self, tmp_path, monkeypatch): + """Falls back to 'python' runner when no configs found.""" + from aipass.seedgo.apps.handlers.diagnostics import diagnostics_check + + apps_dir = tmp_path / "apps" + apps_dir.mkdir() + + monkeypatch.setattr(diagnostics_check, "_discover_pack_configs", lambda: []) + + runner_result = { + "total_errors": 0, + "total_warnings": 0, + "total_files": 3, + "checks": [{"name": "Type check", "passed": True, "message": "OK"}], + "results": [], + } + monkeypatch.setattr( + diagnostics_check, + "_run_runner", + lambda name, bp, bypass_rules=None: runner_result, + ) + + result = diagnostics_check.check_branch(str(tmp_path)) + assert result["passed"] is True + assert result["score"] == 100 + + def test_no_runner_executed_fallback_to_pyright(self, tmp_path, monkeypatch): + """Falls back to direct pyright check when no runner executes.""" + from aipass.seedgo.apps.handlers.diagnostics import diagnostics_check + + apps_dir = tmp_path / "apps" + apps_dir.mkdir() + + monkeypatch.setattr(diagnostics_check, "_discover_pack_configs", lambda: []) + monkeypatch.setattr( + diagnostics_check, + "_run_runner", + lambda name, bp, bypass_rules=None: None, + ) + + dir_result = { + "total_errors": 1, + "total_warnings": 0, + "total_files": 2, + "results": [ + { + "file": "a.py", + "errors": 1, + "warnings": 0, + "diagnostics": [], + }, + ], + } + monkeypatch.setattr(diagnostics_check, "check_directory", lambda d: dir_result) + + result = diagnostics_check.check_branch(str(tmp_path)) + assert result["passed"] is False + assert result["total_errors"] == 1 + assert any("Type errors" in c["name"] for c in result["checks"]) + + def test_no_errors_default_check(self, tmp_path, monkeypatch): + """Default passing check is added when nothing failed.""" + from aipass.seedgo.apps.handlers.diagnostics import diagnostics_check + + apps_dir = tmp_path / "apps" + apps_dir.mkdir() + + monkeypatch.setattr(diagnostics_check, "_discover_pack_configs", lambda: []) + + runner_result = { + "total_errors": 0, + "total_warnings": 0, + "total_files": 10, + "checks": [], + "results": [], + } + monkeypatch.setattr( + diagnostics_check, + "_run_runner", + lambda name, bp, bypass_rules=None: runner_result, + ) + + result = diagnostics_check.check_branch(str(tmp_path)) + assert result["passed"] is True + assert len(result["checks"]) == 1 + assert result["checks"][0]["passed"] is True + assert "10 files" in result["checks"][0]["message"] + + def test_score_clamped_at_zero(self, tmp_path, monkeypatch): + """Score does not go below 0 even with many errors.""" + from aipass.seedgo.apps.handlers.diagnostics import diagnostics_check + + apps_dir = tmp_path / "apps" + apps_dir.mkdir() + + monkeypatch.setattr(diagnostics_check, "_discover_pack_configs", lambda: []) + + runner_result = { + "total_errors": 100, + "total_warnings": 0, + "total_files": 50, + "checks": [ + { + "name": "errors", + "passed": False, + "message": "100 errors", + } + ], + "results": [{"file": f"f{i}.py", "errors": 1} for i in range(100)], + } + monkeypatch.setattr( + diagnostics_check, + "_run_runner", + lambda name, bp, bypass_rules=None: runner_result, + ) + + result = diagnostics_check.check_branch(str(tmp_path)) + assert result["score"] == 0 + + def test_multiple_runners_deduplicated(self, tmp_path, monkeypatch): + """Duplicate runner names across packs are deduplicated.""" + from aipass.seedgo.apps.handlers.diagnostics import diagnostics_check + + apps_dir = tmp_path / "apps" + apps_dir.mkdir() + + monkeypatch.setattr( + diagnostics_check, + "_discover_pack_configs", + lambda: [ + { + "pack_name": "a_standards", + "pack_path": tmp_path, + "config": {"runners": {"python": True}}, + }, + { + "pack_name": "b_standards", + "pack_path": tmp_path, + "config": {"runners": {"python": True}}, + }, + ], + ) + + call_count = 0 + + def _mock_run_runner( + name: str, + bp: str, + bypass_rules: list | None = None, + ) -> dict: + """Track runner invocations and return clean result.""" + nonlocal call_count + call_count += 1 + return { + "total_errors": 0, + "total_warnings": 0, + "total_files": 1, + "checks": [], + "results": [], + } + + monkeypatch.setattr(diagnostics_check, "_run_runner", _mock_run_runner) + + result = diagnostics_check.check_branch(str(tmp_path)) + assert call_count == 1 + assert result["passed"] is True + + +# =========================================================================== +# DIAGNOSTICS CHECK -- format_summary +# =========================================================================== + + +class TestFormatSummary: + """Tests for format_summary.""" + + def test_with_error(self): + """Returns error message when 'error' key is present.""" + from aipass.seedgo.apps.handlers.diagnostics.diagnostics_check import ( + format_summary, + ) + + result = format_summary({"error": "Something broke"}) + assert result == "Error: Something broke" + + def test_normal_summary(self): + """Returns formatted summary for normal results.""" + from aipass.seedgo.apps.handlers.diagnostics.diagnostics_check import ( + format_summary, + ) + + results = { + "total_files": 10, + "files_with_errors": 2, + "total_errors": 5, + "total_warnings": 3, + } + summary = format_summary(results) + assert "Files analyzed: 10" in summary + assert "Files with errors: 2" in summary + assert "Total errors: 5" in summary + assert "Total warnings: 3" in summary + + def test_empty_error_string(self): + """Empty error string returns normal summary.""" + from aipass.seedgo.apps.handlers.diagnostics.diagnostics_check import ( + format_summary, + ) + + results = { + "error": "", + "total_files": 0, + "files_with_errors": 0, + "total_errors": 0, + "total_warnings": 0, + } + summary = format_summary(results) + assert "Files analyzed: 0" in summary diff --git a/src/aipass/spawn/templates/builder/.spawn/.template_registry.json b/src/aipass/spawn/templates/builder/.spawn/.template_registry.json index fec2feb7..772ec020 100644 --- a/src/aipass/spawn/templates/builder/.spawn/.template_registry.json +++ b/src/aipass/spawn/templates/builder/.spawn/.template_registry.json @@ -155,7 +155,7 @@ "content_hash": "a4cf0a8e3b4f", "has_branch_placeholder": false }, - "f015": { + "f026": { "path": "apps/modules/__init__.py", "name": "__init__.py", "content_hash": "e3b0c44298fc", @@ -263,7 +263,7 @@ "content_hash": "28e9ae373563", "has_branch_placeholder": false }, - "f026": { + "f015": { "path": "apps/plugins/__init__.py", "name": "__init__.py", "content_hash": "e3b0c44298fc",