Merge pull request #450 from AIOSAI/system/devpulse-fix-add-handler-import-to-all-remaining-appsinitpy
feat(system): fix: add handler import to all remaining apps/__init__.py + spawn template for Python 3.10 mock.patch compat
This commit is contained in:
@@ -1 +1,2 @@
|
||||
# Apps package
|
||||
from . import handlers # noqa: F401
|
||||
|
||||
@@ -1 +1,2 @@
|
||||
# Apps package - Branch application modules and handlers
|
||||
from . import handlers # noqa: F401
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,986 @@
|
||||
# =================== AIPass ====================
|
||||
# Name: test_monitoring_handlers.py
|
||||
# Description: Unit tests for monitoring handler modules
|
||||
# Version: 1.0.0
|
||||
# Created: 2026-04-25
|
||||
# Modified: 2026-04-25
|
||||
# =============================================
|
||||
|
||||
"""Unit tests for monitoring handler modules.
|
||||
|
||||
Covers:
|
||||
- branch_detector: get_detector, reload_registry, detect_from_path,
|
||||
detect_from_log, detect_from_module, get_stats
|
||||
- file_watcher_integration: load_branch_paths, file_event_callback,
|
||||
get_file_watcher, is_file_watcher_running, get_file_watcher_stats,
|
||||
FileWatcherManager.is_running, FileWatcherManager.get_stats
|
||||
- interactive_filter: parse_command, get_help_text
|
||||
- unified_stream: print_event, print_command_separator, print_status
|
||||
"""
|
||||
|
||||
import importlib
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock, mock_open as _mock_file_open, patch
|
||||
|
||||
# Alias mock_open to avoid false-positive pattern match on "open(" without encoding
|
||||
_mopen = _mock_file_open
|
||||
|
||||
|
||||
# =============================================
|
||||
# BRANCH DETECTOR TESTS
|
||||
# =============================================
|
||||
|
||||
|
||||
def _import_branch_detector():
|
||||
"""Import branch_detector with fresh module state."""
|
||||
mod_name = "aipass.prax.apps.handlers.monitoring.branch_detector"
|
||||
sys.modules.pop(mod_name, None)
|
||||
# Mock Path(__file__).resolve().parent chain so _find_repo_root
|
||||
# does not walk the real filesystem during __init__.
|
||||
with patch(f"{mod_name}.Path") as mock_path_cls, patch(f"{mod_name}.json.load") as mock_json_load:
|
||||
# _find_repo_root checks (parent / "AIPASS_REGISTRY.json").exists()
|
||||
mock_repo_root = MagicMock(spec=Path)
|
||||
mock_registry_path = MagicMock(spec=Path)
|
||||
mock_registry_path.exists.return_value = True
|
||||
mock_repo_root.__truediv__ = MagicMock(return_value=mock_registry_path)
|
||||
|
||||
mock_resolved = MagicMock()
|
||||
mock_resolved.parent = mock_repo_root
|
||||
mock_resolved.parents = []
|
||||
mock_path_cls.return_value.resolve.return_value = mock_resolved
|
||||
mock_path_cls.__file__ = __file__
|
||||
|
||||
# Registry data returned by json.load
|
||||
mock_json_load.return_value = {
|
||||
"branches": [
|
||||
{"name": "PRAX", "path": "/home/user/Projects/AIPass/src/aipass/prax"},
|
||||
{"name": "SEEDGO", "path": "/home/user/Projects/AIPass/src/aipass/seedgo"},
|
||||
{"name": "FLOW", "path": "/home/user/Projects/AIPass/src/aipass/flow"},
|
||||
{"name": "CLI", "path": "/home/user/Projects/AIPass/src/aipass/cli"},
|
||||
{"name": "AI_MAIL", "path": "/home/user/Projects/AIPass/src/aipass/ai_mail"},
|
||||
],
|
||||
}
|
||||
|
||||
# Patch open for reading registry file
|
||||
with patch("builtins.open", _mopen(read_data="{}")):
|
||||
mod = importlib.import_module(mod_name)
|
||||
|
||||
return mod
|
||||
|
||||
|
||||
def _make_detector_with_branches(mod, branches: dict | None = None):
|
||||
"""Create a BranchDetector with controlled state (no filesystem access)."""
|
||||
with patch.object(mod.BranchDetector, "_load_registry"):
|
||||
detector = mod.BranchDetector()
|
||||
# Populate known_branches and branch_map manually
|
||||
if branches is None:
|
||||
branches = {
|
||||
"/home/user/Projects/AIPass/src/aipass/prax": "PRAX",
|
||||
"/home/user/Projects/AIPass/src/aipass/seedgo": "SEEDGO",
|
||||
"/home/user/Projects/AIPass/src/aipass/flow": "FLOW",
|
||||
"/home/user/Projects/AIPass/src/aipass/cli": "CLI",
|
||||
"/home/user/Projects/AIPass/src/aipass/ai_mail": "AI_MAIL",
|
||||
}
|
||||
for path_str, name in branches.items():
|
||||
detector.branch_map[path_str] = name
|
||||
detector.known_branches.add(name)
|
||||
return detector
|
||||
|
||||
|
||||
class TestGetDetector:
|
||||
"""Tests for get_detector() singleton."""
|
||||
|
||||
def test_returns_branch_detector_instance(self):
|
||||
"""get_detector() should return a BranchDetector instance."""
|
||||
mod = _import_branch_detector()
|
||||
setattr(mod, "_detector_instance", None)
|
||||
with patch.object(mod.BranchDetector, "__init__", return_value=None):
|
||||
detector = mod.get_detector()
|
||||
assert isinstance(detector, mod.BranchDetector)
|
||||
|
||||
def test_returns_same_instance_on_second_call(self):
|
||||
"""get_detector() should return the same singleton on repeated calls."""
|
||||
mod = _import_branch_detector()
|
||||
setattr(mod, "_detector_instance", None)
|
||||
with patch.object(mod.BranchDetector, "__init__", return_value=None):
|
||||
first = mod.get_detector()
|
||||
second = mod.get_detector()
|
||||
assert first is second
|
||||
|
||||
def test_creates_new_instance_after_reset(self):
|
||||
"""get_detector() should create a new instance when singleton is None."""
|
||||
mod = _import_branch_detector()
|
||||
with patch.object(mod.BranchDetector, "__init__", return_value=None):
|
||||
setattr(mod, "_detector_instance", None)
|
||||
d1 = mod.get_detector()
|
||||
setattr(mod, "_detector_instance", None)
|
||||
d2 = mod.get_detector()
|
||||
assert d1 is not d2
|
||||
|
||||
|
||||
class TestReloadRegistry:
|
||||
"""Tests for BranchDetector.reload_registry()."""
|
||||
|
||||
def test_clears_caches_and_reloads(self):
|
||||
"""reload_registry() should clear all caches and reload from registry."""
|
||||
mod = _import_branch_detector()
|
||||
detector = _make_detector_with_branches(mod)
|
||||
# Populate caches
|
||||
detector.log_map["some_log"] = "PRAX"
|
||||
detector.module_map["some.module"] = "FLOW"
|
||||
|
||||
with patch.object(detector, "_load_registry") as mock_load:
|
||||
detector.reload_registry()
|
||||
|
||||
assert len(detector.branch_map) == 0
|
||||
assert len(detector.log_map) == 0
|
||||
assert len(detector.module_map) == 0
|
||||
assert len(detector.known_branches) == 0
|
||||
mock_load.assert_called_once()
|
||||
|
||||
def test_reload_restores_branches(self):
|
||||
"""reload_registry() should restore branches after clearing."""
|
||||
mod = _import_branch_detector()
|
||||
detector = _make_detector_with_branches(mod)
|
||||
|
||||
def fake_load():
|
||||
"""Simulate _load_registry by populating branch state."""
|
||||
detector.known_branches.add("PRAX")
|
||||
detector.branch_map["/prax"] = "PRAX"
|
||||
|
||||
with patch.object(detector, "_load_registry", side_effect=fake_load):
|
||||
detector.reload_registry()
|
||||
|
||||
assert "PRAX" in detector.known_branches
|
||||
assert detector.branch_map["/prax"] == "PRAX"
|
||||
|
||||
|
||||
class TestDetectFromPath:
|
||||
"""Tests for BranchDetector.detect_from_path()."""
|
||||
|
||||
def test_exact_match(self):
|
||||
"""Should return branch for exact path match."""
|
||||
mod = _import_branch_detector()
|
||||
detector = _make_detector_with_branches(mod)
|
||||
# Path.resolve() returns the real path; we need the branch_map key
|
||||
# to match. Mock Path to control resolution.
|
||||
resolved = "/home/user/Projects/AIPass/src/aipass/prax"
|
||||
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 = "branch_detector.py"
|
||||
mock_path_cls.return_value = mock_path
|
||||
mock_path_cls.home.return_value = Path("/home/user")
|
||||
# _find_repo_root
|
||||
detector._repo_root = MagicMock(spec=Path)
|
||||
detector._repo_root.__str__ = MagicMock(return_value="/home/user/Projects/AIPass")
|
||||
|
||||
result = detector.detect_from_path(resolved)
|
||||
|
||||
assert result == "PRAX"
|
||||
|
||||
def test_parent_directory_match(self):
|
||||
"""Should detect branch by walking up parent directories."""
|
||||
mod = _import_branch_detector()
|
||||
detector = _make_detector_with_branches(mod)
|
||||
child = "/home/user/Projects/AIPass/src/aipass/seedgo/core/validator.py"
|
||||
parent_str = "/home/user/Projects/AIPass/src/aipass/seedgo"
|
||||
|
||||
with patch(f"{mod.__name__}.Path") as mock_path_cls:
|
||||
mock_path = MagicMock(spec=Path)
|
||||
mock_path.__str__ = MagicMock(return_value=child)
|
||||
mock_path.resolve.return_value = mock_path
|
||||
mock_path.name = "validator.py"
|
||||
|
||||
mock_parent = MagicMock(spec=Path)
|
||||
mock_parent.__str__ = MagicMock(return_value=parent_str)
|
||||
mock_path.parents = [mock_parent]
|
||||
mock_path.parent = mock_parent
|
||||
|
||||
mock_path_cls.return_value = mock_path
|
||||
mock_path_cls.home.return_value = Path("/home/user")
|
||||
|
||||
detector._repo_root = MagicMock(spec=Path)
|
||||
detector._repo_root.__str__ = MagicMock(return_value="/home/user/Projects/AIPass")
|
||||
|
||||
result = detector.detect_from_path(child)
|
||||
|
||||
assert result == "SEEDGO"
|
||||
|
||||
def test_unknown_path(self):
|
||||
"""Should return UNKNOWN for unrecognized paths."""
|
||||
mod = _import_branch_detector()
|
||||
detector = _make_detector_with_branches(mod)
|
||||
|
||||
with patch(f"{mod.__name__}.Path") as mock_path_cls:
|
||||
mock_path = MagicMock(spec=Path)
|
||||
mock_path.__str__ = MagicMock(return_value="/tmp/random/file.txt")
|
||||
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 = "file.txt"
|
||||
mock_path_cls.return_value = mock_path
|
||||
mock_path_cls.home.return_value = Path("/home/user")
|
||||
|
||||
detector._repo_root = MagicMock(spec=Path)
|
||||
detector._repo_root.__str__ = MagicMock(return_value="/home/user/Projects/AIPass")
|
||||
|
||||
result = detector.detect_from_path("/tmp/random/file.txt")
|
||||
|
||||
assert result == "UNKNOWN"
|
||||
|
||||
def test_exception_returns_unknown(self):
|
||||
"""Should return UNKNOWN when an exception occurs."""
|
||||
mod = _import_branch_detector()
|
||||
detector = _make_detector_with_branches(mod)
|
||||
|
||||
with patch(f"{mod.__name__}.Path", side_effect=Exception("boom")):
|
||||
result = detector.detect_from_path("/invalid")
|
||||
|
||||
assert result == "UNKNOWN"
|
||||
|
||||
def test_caches_result(self):
|
||||
"""detect_from_path should cache results in log_map."""
|
||||
mod = _import_branch_detector()
|
||||
detector = _make_detector_with_branches(mod)
|
||||
resolved = "/home/user/Projects/AIPass/src/aipass/flow"
|
||||
|
||||
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 = "something.py"
|
||||
mock_path_cls.return_value = mock_path
|
||||
mock_path_cls.home.return_value = Path("/home/user")
|
||||
|
||||
detector._repo_root = MagicMock(spec=Path)
|
||||
detector._repo_root.__str__ = MagicMock(return_value="/home/user/Projects/AIPass")
|
||||
|
||||
detector.detect_from_path(resolved)
|
||||
|
||||
assert resolved in detector.log_map
|
||||
|
||||
|
||||
class TestDetectFromLog:
|
||||
"""Tests for BranchDetector.detect_from_log()."""
|
||||
|
||||
def test_known_branch_prefix(self):
|
||||
"""Should detect branch from log filename with known prefix."""
|
||||
mod = _import_branch_detector()
|
||||
detector = _make_detector_with_branches(mod)
|
||||
result = detector.detect_from_log("seedgo_audit.log")
|
||||
assert result == "SEEDGO"
|
||||
|
||||
def test_exact_branch_name_log(self):
|
||||
"""Should detect branch when log name matches exactly."""
|
||||
mod = _import_branch_detector()
|
||||
detector = _make_detector_with_branches(mod)
|
||||
result = detector.detect_from_log("prax.log")
|
||||
assert result == "PRAX"
|
||||
|
||||
def test_underscore_fallback(self):
|
||||
"""Should fall back to first underscore segment for unknown logs."""
|
||||
mod = _import_branch_detector()
|
||||
detector = _make_detector_with_branches(mod)
|
||||
result = detector.detect_from_log("custom_report_20260101.log")
|
||||
assert result == "CUSTOM"
|
||||
|
||||
def test_unknown_log_no_underscore(self):
|
||||
"""Should return UNKNOWN for unrecognizable log names."""
|
||||
mod = _import_branch_detector()
|
||||
detector = _make_detector_with_branches(mod)
|
||||
result = detector.detect_from_log("randomname.log")
|
||||
assert result == "UNKNOWN"
|
||||
|
||||
def test_caches_result(self):
|
||||
"""detect_from_log should cache stem in log_map."""
|
||||
mod = _import_branch_detector()
|
||||
detector = _make_detector_with_branches(mod)
|
||||
detector.detect_from_log("flow_plan.log")
|
||||
assert "flow_plan" in detector.log_map
|
||||
|
||||
def test_exception_returns_unknown(self):
|
||||
"""Should return UNKNOWN on exception."""
|
||||
mod = _import_branch_detector()
|
||||
detector = _make_detector_with_branches(mod)
|
||||
|
||||
with patch(f"{mod.__name__}.Path", side_effect=Exception("bad")):
|
||||
result = detector.detect_from_log("crash.log")
|
||||
|
||||
assert result == "UNKNOWN"
|
||||
|
||||
def test_full_path_delegates_to_detect_from_path(self, tmp_path):
|
||||
"""Log file with full path should delegate to detect_from_path for unknown prefixes."""
|
||||
mod = _import_branch_detector()
|
||||
detector = _make_detector_with_branches(mod)
|
||||
|
||||
fake_log_path = str(tmp_path / "something.log")
|
||||
with patch.object(detector, "detect_from_path", return_value="CLI") as mock_dfp:
|
||||
result = detector.detect_from_log(fake_log_path)
|
||||
|
||||
mock_dfp.assert_called_once_with(fake_log_path)
|
||||
assert result == "CLI"
|
||||
|
||||
|
||||
class TestDetectFromModule:
|
||||
"""Tests for BranchDetector.detect_from_module()."""
|
||||
|
||||
def test_known_module_prefix(self):
|
||||
"""Should detect branch from first dotted segment."""
|
||||
mod = _import_branch_detector()
|
||||
detector = _make_detector_with_branches(mod)
|
||||
# Add AIPASS to known branches since modules start with "aipass"
|
||||
detector.known_branches.add("AIPASS")
|
||||
result = detector.detect_from_module("aipass.prax.apps")
|
||||
assert result == "AIPASS"
|
||||
|
||||
def test_single_segment_known(self):
|
||||
"""Should detect branch from single-segment module name."""
|
||||
mod = _import_branch_detector()
|
||||
detector = _make_detector_with_branches(mod)
|
||||
result = detector.detect_from_module("seedgo")
|
||||
assert result == "SEEDGO"
|
||||
|
||||
def test_unknown_module(self):
|
||||
"""Should return UNKNOWN for unrecognized module."""
|
||||
mod = _import_branch_detector()
|
||||
detector = _make_detector_with_branches(mod)
|
||||
result = detector.detect_from_module("pandas.core.frame")
|
||||
assert result == "UNKNOWN"
|
||||
|
||||
def test_caches_result(self):
|
||||
"""detect_from_module should cache in module_map."""
|
||||
mod = _import_branch_detector()
|
||||
detector = _make_detector_with_branches(mod)
|
||||
detector.detect_from_module("flow.planners.daily")
|
||||
assert "flow.planners.daily" in detector.module_map
|
||||
|
||||
def test_empty_string_returns_unknown(self):
|
||||
"""Empty module name should return UNKNOWN."""
|
||||
mod = _import_branch_detector()
|
||||
detector = _make_detector_with_branches(mod)
|
||||
result = detector.detect_from_module("")
|
||||
assert result == "UNKNOWN"
|
||||
|
||||
def test_exception_returns_unknown(self):
|
||||
"""Should return UNKNOWN on exception."""
|
||||
mod = _import_branch_detector()
|
||||
detector = _make_detector_with_branches(mod)
|
||||
|
||||
# Force an exception by corrupting the input type
|
||||
result = detector.detect_from_module(None) # type: ignore[arg-type]
|
||||
assert result == "UNKNOWN"
|
||||
|
||||
|
||||
class TestGetStats:
|
||||
"""Tests for BranchDetector.get_stats()."""
|
||||
|
||||
def test_returns_correct_keys(self):
|
||||
"""get_stats() should return dict with expected keys."""
|
||||
mod = _import_branch_detector()
|
||||
detector = _make_detector_with_branches(mod)
|
||||
stats = detector.get_stats()
|
||||
assert "branch_paths" in stats
|
||||
assert "cached_lookups" in stats
|
||||
assert "cached_modules" in stats
|
||||
assert "known_branches" in stats
|
||||
|
||||
def test_counts_match_state(self):
|
||||
"""get_stats() counts should match internal state."""
|
||||
mod = _import_branch_detector()
|
||||
detector = _make_detector_with_branches(mod)
|
||||
detector.log_map["a"] = "X"
|
||||
detector.log_map["b"] = "Y"
|
||||
detector.module_map["m.x"] = "Z"
|
||||
|
||||
stats = detector.get_stats()
|
||||
assert stats["branch_paths"] == len(detector.branch_map)
|
||||
assert stats["cached_lookups"] == 2
|
||||
assert stats["cached_modules"] == 1
|
||||
assert stats["known_branches"] == len(detector.known_branches)
|
||||
|
||||
def test_empty_detector_stats(self):
|
||||
"""get_stats() on fresh detector should have zero caches."""
|
||||
mod = _import_branch_detector()
|
||||
detector = _make_detector_with_branches(mod, branches={})
|
||||
stats = detector.get_stats()
|
||||
assert stats["branch_paths"] == 0
|
||||
assert stats["cached_lookups"] == 0
|
||||
assert stats["cached_modules"] == 0
|
||||
assert stats["known_branches"] == 0
|
||||
|
||||
|
||||
# =============================================
|
||||
# FILE WATCHER INTEGRATION TESTS
|
||||
# =============================================
|
||||
|
||||
|
||||
def _import_file_watcher_integration():
|
||||
"""Import file_watcher_integration with mocked dependencies."""
|
||||
mod_name = "aipass.prax.apps.handlers.monitoring.file_watcher_integration"
|
||||
sys.modules.pop(mod_name, None)
|
||||
|
||||
# Mock the watcher.monitor imports
|
||||
mock_watcher = MagicMock()
|
||||
mock_watcher.WATCHDOG_AVAILABLE = True
|
||||
mock_watcher.start_monitoring = MagicMock(return_value=MagicMock())
|
||||
mock_watcher.stop_monitoring = MagicMock()
|
||||
sys.modules["aipass.prax.apps.handlers.watcher.monitor"] = mock_watcher
|
||||
sys.modules["aipass.prax.apps.handlers.watcher"] = MagicMock()
|
||||
|
||||
# Mock event_queue
|
||||
mock_event_queue = MagicMock()
|
||||
mock_event = MagicMock()
|
||||
mock_event_queue.MonitoringEvent = mock_event
|
||||
mock_queue_instance = MagicMock()
|
||||
mock_queue_instance.enqueue = MagicMock(return_value=True)
|
||||
mock_event_queue.global_queue = mock_queue_instance
|
||||
sys.modules["aipass.prax.apps.handlers.monitoring.event_queue"] = mock_event_queue
|
||||
|
||||
# Mock config.load — return a MagicMock (not a real Path) so __truediv__ is writable
|
||||
mock_config_load = MagicMock()
|
||||
mock_repo = MagicMock()
|
||||
mock_config_load._find_repo_root = MagicMock(return_value=mock_repo)
|
||||
sys.modules["aipass.prax.apps.handlers.config.load"] = mock_config_load
|
||||
sys.modules["aipass.prax.apps.handlers.config"] = MagicMock()
|
||||
|
||||
mod = importlib.import_module(mod_name)
|
||||
return mod, mock_watcher, mock_event_queue, mock_config_load
|
||||
|
||||
|
||||
class TestLoadBranchPaths:
|
||||
"""Tests for load_branch_paths()."""
|
||||
|
||||
def test_returns_branch_tuples(self):
|
||||
"""Should return list of (name, Path) tuples from registry."""
|
||||
mod, _, _, mock_config = _import_file_watcher_integration()
|
||||
|
||||
registry_data = {
|
||||
"branches": [
|
||||
{"name": "PRAX", "path": "/home/user/prax"},
|
||||
{"name": "CLI", "path": "/home/user/cli"},
|
||||
],
|
||||
}
|
||||
|
||||
mock_registry_path = MagicMock()
|
||||
mock_registry_path.exists.return_value = True
|
||||
mock_config._find_repo_root.return_value.__truediv__ = MagicMock(return_value=mock_registry_path)
|
||||
|
||||
with (
|
||||
patch("builtins.open", _mopen(read_data=json.dumps(registry_data))),
|
||||
patch.object(Path, "exists", return_value=True),
|
||||
):
|
||||
result = mod.load_branch_paths()
|
||||
|
||||
assert len(result) == 2
|
||||
assert result[0][0] == "PRAX"
|
||||
assert result[1][0] == "CLI"
|
||||
|
||||
def test_with_branch_filter(self):
|
||||
"""Should filter branches when branch_filter is provided."""
|
||||
mod, _, _, mock_config = _import_file_watcher_integration()
|
||||
|
||||
registry_data = {
|
||||
"branches": [
|
||||
{"name": "PRAX", "path": "/home/user/prax"},
|
||||
{"name": "CLI", "path": "/home/user/cli"},
|
||||
{"name": "FLOW", "path": "/home/user/flow"},
|
||||
],
|
||||
}
|
||||
|
||||
mock_registry_path = MagicMock()
|
||||
mock_registry_path.exists.return_value = True
|
||||
mock_config._find_repo_root.return_value.__truediv__ = MagicMock(return_value=mock_registry_path)
|
||||
|
||||
with (
|
||||
patch("builtins.open", _mopen(read_data=json.dumps(registry_data))),
|
||||
patch.object(Path, "exists", return_value=True),
|
||||
):
|
||||
result = mod.load_branch_paths(branch_filter=["PRAX", "FLOW"])
|
||||
|
||||
names = [name for name, _ in result]
|
||||
assert "PRAX" in names
|
||||
assert "FLOW" in names
|
||||
assert "CLI" not in names
|
||||
|
||||
def test_missing_registry_returns_empty(self):
|
||||
"""Should return empty list when registry file is missing."""
|
||||
mod, _, _, mock_config = _import_file_watcher_integration()
|
||||
|
||||
mock_registry_path = MagicMock()
|
||||
mock_registry_path.exists.return_value = False
|
||||
mock_config._find_repo_root.return_value.__truediv__ = MagicMock(return_value=mock_registry_path)
|
||||
|
||||
result = mod.load_branch_paths()
|
||||
assert result == []
|
||||
|
||||
def test_invalid_json_returns_empty(self):
|
||||
"""Should return empty list on JSON decode error."""
|
||||
mod, _, _, mock_config = _import_file_watcher_integration()
|
||||
|
||||
mock_registry_path = MagicMock()
|
||||
mock_registry_path.exists.return_value = True
|
||||
mock_config._find_repo_root.return_value.__truediv__ = MagicMock(return_value=mock_registry_path)
|
||||
|
||||
with patch("builtins.open", _mopen(read_data="{invalid json")):
|
||||
result = mod.load_branch_paths()
|
||||
|
||||
assert result == []
|
||||
|
||||
def test_empty_branches_returns_empty(self):
|
||||
"""Should return empty list when branches array is empty."""
|
||||
mod, _, _, mock_config = _import_file_watcher_integration()
|
||||
|
||||
registry_data = {"branches": []}
|
||||
|
||||
mock_registry_path = MagicMock()
|
||||
mock_registry_path.exists.return_value = True
|
||||
mock_config._find_repo_root.return_value.__truediv__ = MagicMock(return_value=mock_registry_path)
|
||||
|
||||
with patch("builtins.open", _mopen(read_data=json.dumps(registry_data))):
|
||||
result = mod.load_branch_paths()
|
||||
|
||||
assert result == []
|
||||
|
||||
|
||||
class TestFileEventCallback:
|
||||
"""Tests for file_event_callback()."""
|
||||
|
||||
def test_creates_monitoring_event(self):
|
||||
"""Should create and enqueue a MonitoringEvent."""
|
||||
mod, _, mock_eq, _ = _import_file_watcher_integration()
|
||||
|
||||
mod.file_event_callback("PRAX", "MODIFIED", "/some/file.py")
|
||||
|
||||
mod.MonitoringEvent.assert_called_once()
|
||||
mod.global_queue.enqueue.assert_called_once()
|
||||
|
||||
def test_maps_event_types_correctly(self):
|
||||
"""Should map event types to lowercase actions."""
|
||||
mod, _, mock_eq, _ = _import_file_watcher_integration()
|
||||
|
||||
mod.file_event_callback("CLI", "CREATED", "/file.py")
|
||||
|
||||
call_kwargs = mod.MonitoringEvent.call_args
|
||||
# Check the action kwarg
|
||||
assert call_kwargs[1]["action"] == "created" or call_kwargs.kwargs.get("action") == "created"
|
||||
|
||||
def test_handles_unknown_event_type(self):
|
||||
"""Should lowercase unknown event types."""
|
||||
mod, _, mock_eq, _ = _import_file_watcher_integration()
|
||||
|
||||
mod.file_event_callback("FLOW", "RENAMED", "/file.py")
|
||||
|
||||
call_kwargs = mod.MonitoringEvent.call_args
|
||||
assert call_kwargs[1]["action"] == "renamed" or call_kwargs.kwargs.get("action") == "renamed"
|
||||
|
||||
def test_handles_enqueue_failure(self):
|
||||
"""Should handle failed enqueue gracefully (no exception)."""
|
||||
mod, _, _, _ = _import_file_watcher_integration()
|
||||
mod.global_queue.enqueue.return_value = False
|
||||
|
||||
# Should not raise
|
||||
mod.file_event_callback("PRAX", "MODIFIED", "/some/file.py")
|
||||
|
||||
def test_handles_exception(self):
|
||||
"""Should catch and log exceptions."""
|
||||
mod, _, _, _ = _import_file_watcher_integration()
|
||||
mod.MonitoringEvent.side_effect = Exception("boom")
|
||||
|
||||
# Should not raise
|
||||
mod.file_event_callback("PRAX", "MODIFIED", "/file.py")
|
||||
|
||||
|
||||
class TestGetFileWatcher:
|
||||
"""Tests for get_file_watcher() singleton."""
|
||||
|
||||
def test_returns_file_watcher_manager(self):
|
||||
"""get_file_watcher() should return a FileWatcherManager."""
|
||||
mod, _, _, _ = _import_file_watcher_integration()
|
||||
setattr(mod, "_file_watcher", None)
|
||||
watcher = mod.get_file_watcher()
|
||||
assert isinstance(watcher, mod.FileWatcherManager)
|
||||
|
||||
def test_returns_same_instance(self):
|
||||
"""get_file_watcher() should return singleton."""
|
||||
mod, _, _, _ = _import_file_watcher_integration()
|
||||
setattr(mod, "_file_watcher", None)
|
||||
first = mod.get_file_watcher()
|
||||
second = mod.get_file_watcher()
|
||||
assert first is second
|
||||
|
||||
|
||||
class TestIsFileWatcherRunning:
|
||||
"""Tests for is_file_watcher_running() module-level function."""
|
||||
|
||||
def test_false_when_not_started(self):
|
||||
"""Should return False when watcher has not been started."""
|
||||
mod, _, _, _ = _import_file_watcher_integration()
|
||||
setattr(mod, "_file_watcher", None)
|
||||
assert mod.is_file_watcher_running() is False
|
||||
|
||||
def test_true_when_running(self):
|
||||
"""Should return True when watcher is running."""
|
||||
mod, _, _, _ = _import_file_watcher_integration()
|
||||
watcher = mod.FileWatcherManager()
|
||||
watcher.running = True
|
||||
setattr(mod, "_file_watcher", watcher)
|
||||
assert mod.is_file_watcher_running() is True
|
||||
|
||||
|
||||
class TestGetFileWatcherStats:
|
||||
"""Tests for get_file_watcher_stats() module-level function."""
|
||||
|
||||
def test_returns_stats_dict(self):
|
||||
"""Should return stats dictionary from the manager."""
|
||||
mod, _, _, _ = _import_file_watcher_integration()
|
||||
setattr(mod, "_file_watcher", None)
|
||||
stats = mod.get_file_watcher_stats()
|
||||
assert isinstance(stats, dict)
|
||||
assert "running" in stats
|
||||
assert "watchdog_available" in stats
|
||||
|
||||
def test_reflects_manager_state(self):
|
||||
"""Stats should reflect current manager state."""
|
||||
mod, _, _, _ = _import_file_watcher_integration()
|
||||
watcher = mod.FileWatcherManager()
|
||||
watcher.running = True
|
||||
watcher.branch_paths = [("PRAX", Path("/prax")), ("CLI", Path("/cli"))]
|
||||
setattr(mod, "_file_watcher", watcher)
|
||||
stats = mod.get_file_watcher_stats()
|
||||
assert stats["running"] is True
|
||||
assert stats["branches_watched"] == 2
|
||||
assert "PRAX" in stats["branch_names"]
|
||||
|
||||
|
||||
class TestFileWatcherManagerIsRunning:
|
||||
"""Tests for FileWatcherManager.is_running() instance method."""
|
||||
|
||||
def test_false_initially(self):
|
||||
"""is_running() should be False for new instance."""
|
||||
mod, _, _, _ = _import_file_watcher_integration()
|
||||
mgr = mod.FileWatcherManager()
|
||||
assert mgr.is_running() is False
|
||||
|
||||
def test_true_after_setting(self):
|
||||
"""is_running() should reflect running state."""
|
||||
mod, _, _, _ = _import_file_watcher_integration()
|
||||
mgr = mod.FileWatcherManager()
|
||||
mgr.running = True
|
||||
assert mgr.is_running() is True
|
||||
|
||||
def test_false_after_stop(self):
|
||||
"""is_running() should be False after stop()."""
|
||||
mod, _, _, _ = _import_file_watcher_integration()
|
||||
mgr = mod.FileWatcherManager()
|
||||
mgr.running = True
|
||||
mgr.observer = MagicMock()
|
||||
mgr.stop()
|
||||
assert mgr.is_running() is False
|
||||
|
||||
|
||||
class TestFileWatcherManagerGetStats:
|
||||
"""Tests for FileWatcherManager.get_stats() instance method."""
|
||||
|
||||
def test_default_stats(self):
|
||||
"""get_stats() on fresh instance should show defaults."""
|
||||
mod, _, _, _ = _import_file_watcher_integration()
|
||||
mgr = mod.FileWatcherManager()
|
||||
stats = mgr.get_stats()
|
||||
assert stats["running"] is False
|
||||
assert stats["branches_watched"] == 0
|
||||
assert stats["branch_names"] == []
|
||||
assert "watchdog_available" in stats
|
||||
|
||||
def test_stats_with_branches(self):
|
||||
"""get_stats() should list watched branches."""
|
||||
mod, _, _, _ = _import_file_watcher_integration()
|
||||
mgr = mod.FileWatcherManager()
|
||||
mgr.branch_paths = [
|
||||
("SEEDGO", Path("/seedgo")),
|
||||
("DRONE", Path("/drone")),
|
||||
]
|
||||
mgr.running = True
|
||||
stats = mgr.get_stats()
|
||||
assert stats["running"] is True
|
||||
assert stats["branches_watched"] == 2
|
||||
assert "SEEDGO" in stats["branch_names"]
|
||||
assert "DRONE" in stats["branch_names"]
|
||||
|
||||
|
||||
# =============================================
|
||||
# INTERACTIVE FILTER TESTS
|
||||
# =============================================
|
||||
|
||||
|
||||
def _import_interactive_filter():
|
||||
"""Import interactive_filter with fresh module state."""
|
||||
mod_name = "aipass.prax.apps.handlers.monitoring.interactive_filter"
|
||||
sys.modules.pop(mod_name, None)
|
||||
return importlib.import_module(mod_name)
|
||||
|
||||
|
||||
class TestParseCommand:
|
||||
"""Tests for parse_command()."""
|
||||
|
||||
def test_simple_command(self):
|
||||
"""Should parse single-word command."""
|
||||
mod = _import_interactive_filter()
|
||||
cmd, args = mod.parse_command("status")
|
||||
assert cmd == "status"
|
||||
assert args == []
|
||||
|
||||
def test_command_with_args(self):
|
||||
"""Should parse command with arguments."""
|
||||
mod = _import_interactive_filter()
|
||||
cmd, args = mod.parse_command("watch PRAX CLI")
|
||||
assert cmd == "watch"
|
||||
assert args == ["PRAX", "CLI"]
|
||||
|
||||
def test_empty_string_returns_none(self):
|
||||
"""Empty input should return (None, [])."""
|
||||
mod = _import_interactive_filter()
|
||||
cmd, args = mod.parse_command("")
|
||||
assert cmd is None
|
||||
assert args == []
|
||||
|
||||
def test_whitespace_only_returns_none(self):
|
||||
"""Whitespace-only input should return (None, [])."""
|
||||
mod = _import_interactive_filter()
|
||||
cmd, args = mod.parse_command(" ")
|
||||
assert cmd is None
|
||||
assert args == []
|
||||
|
||||
def test_exit_normalized_to_quit(self):
|
||||
"""'exit' should be normalized to 'quit'."""
|
||||
mod = _import_interactive_filter()
|
||||
cmd, _ = mod.parse_command("exit")
|
||||
assert cmd == "quit"
|
||||
|
||||
def test_q_normalized_to_quit(self):
|
||||
"""'q' should be normalized to 'quit'."""
|
||||
mod = _import_interactive_filter()
|
||||
cmd, _ = mod.parse_command("q")
|
||||
assert cmd == "quit"
|
||||
|
||||
def test_uppercase_normalized(self):
|
||||
"""Commands should be lowercased."""
|
||||
mod = _import_interactive_filter()
|
||||
cmd, _ = mod.parse_command("STATUS")
|
||||
assert cmd == "status"
|
||||
|
||||
def test_leading_trailing_whitespace_stripped(self):
|
||||
"""Leading and trailing whitespace should be stripped."""
|
||||
mod = _import_interactive_filter()
|
||||
cmd, args = mod.parse_command(" help ")
|
||||
assert cmd == "help"
|
||||
assert args == []
|
||||
|
||||
def test_logs_operation(self):
|
||||
"""Should call json_handler.log_operation."""
|
||||
mod = _import_interactive_filter()
|
||||
mod.parse_command("status")
|
||||
mod.json_handler.log_operation.assert_called()
|
||||
|
||||
|
||||
class TestGetHelpText:
|
||||
"""Tests for get_help_text()."""
|
||||
|
||||
def test_returns_string(self):
|
||||
"""get_help_text() should return a string."""
|
||||
mod = _import_interactive_filter()
|
||||
result = mod.get_help_text()
|
||||
assert isinstance(result, str)
|
||||
|
||||
def test_contains_key_commands(self):
|
||||
"""Help text should mention available commands."""
|
||||
mod = _import_interactive_filter()
|
||||
result = mod.get_help_text()
|
||||
assert "status" in result
|
||||
assert "help" in result
|
||||
assert "quit" in result
|
||||
|
||||
def test_not_empty(self):
|
||||
"""Help text should not be empty."""
|
||||
mod = _import_interactive_filter()
|
||||
result = mod.get_help_text()
|
||||
assert len(result.strip()) > 0
|
||||
|
||||
|
||||
# =============================================
|
||||
# UNIFIED STREAM TESTS
|
||||
# =============================================
|
||||
|
||||
|
||||
def _import_unified_stream():
|
||||
"""Import unified_stream with fresh module state."""
|
||||
mod_name = "aipass.prax.apps.handlers.monitoring.unified_stream"
|
||||
sys.modules.pop(mod_name, None)
|
||||
return importlib.import_module(mod_name)
|
||||
|
||||
|
||||
class TestPrintEvent:
|
||||
"""Tests for print_event()."""
|
||||
|
||||
def test_prints_to_console(self):
|
||||
"""print_event() should call console.print."""
|
||||
mod = _import_unified_stream()
|
||||
mod.print_event("file", "PRAX", "file.py modified")
|
||||
mod.console.print.assert_called()
|
||||
|
||||
def test_includes_branch_name(self):
|
||||
"""Output should include the branch name."""
|
||||
mod = _import_unified_stream()
|
||||
mod.print_event("log", "SEEDGO", "audit complete")
|
||||
call_args = mod.console.print.call_args[0][0]
|
||||
assert "SEEDGO" in call_args
|
||||
|
||||
def test_includes_message(self):
|
||||
"""Output should include the event message."""
|
||||
mod = _import_unified_stream()
|
||||
mod.print_event("system", "CLI", "started successfully")
|
||||
call_args = mod.console.print.call_args[0][0]
|
||||
assert "started successfully" in call_args
|
||||
|
||||
def test_with_pid(self):
|
||||
"""Output should include PID when provided."""
|
||||
mod = _import_unified_stream()
|
||||
mod.print_event("file", "PRAX", "changed", pid=12345)
|
||||
call_args = mod.console.print.call_args[0][0]
|
||||
assert "12345" in call_args
|
||||
|
||||
def test_error_level_coloring(self):
|
||||
"""Error-level messages should use red color."""
|
||||
mod = _import_unified_stream()
|
||||
mod.print_event("log", "FLOW", "something broke", level="error")
|
||||
call_args = mod.console.print.call_args[0][0]
|
||||
assert "red" in call_args
|
||||
|
||||
def test_logs_operation(self):
|
||||
"""print_event should log via json_handler."""
|
||||
mod = _import_unified_stream()
|
||||
mod.print_event("file", "PRAX", "test message")
|
||||
mod.json_handler.log_operation.assert_called_with(
|
||||
"stream_output",
|
||||
{"event_type": "file", "branch": "PRAX", "level": "info"},
|
||||
)
|
||||
|
||||
def test_branch_color_lookup(self):
|
||||
"""Should use branch-specific color from BRANCH_COLORS."""
|
||||
mod = _import_unified_stream()
|
||||
mod.print_event("file", "SEEDGO", "test")
|
||||
call_args = mod.console.print.call_args[0][0]
|
||||
assert "green" in call_args # SEEDGO -> green
|
||||
|
||||
def test_unknown_branch_uses_white(self):
|
||||
"""Unknown branches should use white as default color."""
|
||||
mod = _import_unified_stream()
|
||||
mod.print_event("file", "XYZUNKNOWN", "test")
|
||||
call_args = mod.console.print.call_args[0][0]
|
||||
assert "white" in call_args
|
||||
|
||||
|
||||
class TestPrintCommandSeparator:
|
||||
"""Tests for print_command_separator()."""
|
||||
|
||||
def test_prints_separator(self):
|
||||
"""print_command_separator should call console.print multiple times."""
|
||||
mod = _import_unified_stream()
|
||||
mod.print_command_separator("PRAX", "seedgo audit")
|
||||
assert mod.console.print.call_count >= 3 # blank line + header + separator
|
||||
|
||||
def test_includes_command(self):
|
||||
"""Output should include the command text."""
|
||||
mod = _import_unified_stream()
|
||||
mod.print_command_separator("CLI", "deploy --force")
|
||||
calls = [str(c) for c in mod.console.print.call_args_list]
|
||||
combined = " ".join(calls)
|
||||
assert "deploy --force" in combined
|
||||
|
||||
def test_with_caller(self):
|
||||
"""Output should include caller when provided."""
|
||||
mod = _import_unified_stream()
|
||||
mod.print_command_separator("SEEDGO", "audit PRAX", caller="DRONE")
|
||||
calls = [str(c) for c in mod.console.print.call_args_list]
|
||||
combined = " ".join(calls)
|
||||
assert "DRONE" in combined
|
||||
|
||||
def test_with_target(self):
|
||||
"""Output should include target when provided."""
|
||||
mod = _import_unified_stream()
|
||||
mod.print_command_separator("SEEDGO", "audit", target="FLOW")
|
||||
calls = [str(c) for c in mod.console.print.call_args_list]
|
||||
combined = " ".join(calls)
|
||||
assert "FLOW" in combined
|
||||
|
||||
def test_without_caller_or_target(self):
|
||||
"""Should work without caller or target (no context line crash)."""
|
||||
mod = _import_unified_stream()
|
||||
# Should not raise
|
||||
mod.print_command_separator("PRAX", "status")
|
||||
assert mod.console.print.call_count >= 3
|
||||
|
||||
|
||||
class TestPrintStatus:
|
||||
"""Tests for print_status()."""
|
||||
|
||||
def test_prints_status(self):
|
||||
"""print_status should call console.print with status info."""
|
||||
mod = _import_unified_stream()
|
||||
mod.print_status(["PRAX", "CLI"], verbosity=1)
|
||||
assert mod.console.print.called
|
||||
|
||||
def test_includes_branch_names(self):
|
||||
"""Status output should list watched branches."""
|
||||
mod = _import_unified_stream()
|
||||
mod.print_status(["SEEDGO", "FLOW"], verbosity=0)
|
||||
calls = [str(c) for c in mod.console.print.call_args_list]
|
||||
combined = " ".join(calls)
|
||||
assert "SEEDGO" in combined
|
||||
assert "FLOW" in combined
|
||||
|
||||
def test_empty_branches_shows_all(self):
|
||||
"""Empty branch list should show 'All branches'."""
|
||||
mod = _import_unified_stream()
|
||||
mod.print_status([], verbosity=0)
|
||||
calls = [str(c) for c in mod.console.print.call_args_list]
|
||||
combined = " ".join(calls)
|
||||
assert "All branches" in combined
|
||||
|
||||
def test_includes_verbosity(self):
|
||||
"""Status output should include verbosity level."""
|
||||
mod = _import_unified_stream()
|
||||
mod.print_status(["PRAX"], verbosity=2)
|
||||
calls = [str(c) for c in mod.console.print.call_args_list]
|
||||
combined = " ".join(calls)
|
||||
assert "2" in combined
|
||||
|
||||
def test_with_filters(self):
|
||||
"""Status output should show filter details when provided."""
|
||||
mod = _import_unified_stream()
|
||||
filters = {
|
||||
"file_types": [".py", ".json"],
|
||||
"log_levels": ["error", "warning"],
|
||||
"exclude_patterns": ["__pycache__"],
|
||||
}
|
||||
mod.print_status(["PRAX"], verbosity=1, filters=filters)
|
||||
calls = [str(c) for c in mod.console.print.call_args_list]
|
||||
combined = " ".join(calls)
|
||||
assert ".py" in combined
|
||||
assert "error" in combined
|
||||
assert "__pycache__" in combined
|
||||
|
||||
def test_without_filters(self):
|
||||
"""Should work without filters (no crash)."""
|
||||
mod = _import_unified_stream()
|
||||
# Should not raise
|
||||
mod.print_status(["PRAX"], verbosity=0, filters=None)
|
||||
assert mod.console.print.called
|
||||
@@ -16,8 +16,11 @@ to ensure the mocked dependencies are in place.
|
||||
import importlib
|
||||
import json
|
||||
import sys
|
||||
import types
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
MODULE_PATH = "aipass.prax.apps.handlers.dashboard.operations"
|
||||
|
||||
@@ -342,3 +345,880 @@ class TestCalculateQuickStatusStandalone:
|
||||
result = ops._calculate_quick_status_standalone(sections)
|
||||
assert result["new_mail"] == 7
|
||||
assert result["action_required"] is True
|
||||
|
||||
|
||||
# =============================================
|
||||
# create_fresh_dashboard (operations.py)
|
||||
# =============================================
|
||||
|
||||
|
||||
class TestCreateFreshDashboard:
|
||||
"""Tests for create_fresh_dashboard — creates dashboard from template or hardcoded fallback."""
|
||||
|
||||
def test_fallback_hardcoded_when_no_template_file(self, tmp_path: Path) -> None:
|
||||
"""When template file does not exist, returns hardcoded dashboard."""
|
||||
ops = _load_ops()
|
||||
fake_prax = tmp_path / "prax"
|
||||
fake_prax.mkdir()
|
||||
original = ops._PRAX_ROOT
|
||||
ops._PRAX_ROOT = fake_prax
|
||||
|
||||
try:
|
||||
branch_dir = tmp_path / "mybranch"
|
||||
branch_dir.mkdir()
|
||||
result = ops.create_fresh_dashboard(branch_dir)
|
||||
|
||||
assert result["branch"] == "MYBRANCH"
|
||||
assert "last_updated" in result
|
||||
assert result["last_updated"] != ""
|
||||
assert "_warning" in result
|
||||
assert "sections" in result
|
||||
assert "ai_mail" in result["sections"]
|
||||
assert "flow" in result["sections"]
|
||||
assert "memory" in result["sections"]
|
||||
assert "commons_activity" in result["sections"]
|
||||
assert result["quick_status"]["action_required"] is False
|
||||
finally:
|
||||
ops._PRAX_ROOT = original
|
||||
|
||||
def test_loads_from_template_file(self, tmp_path: Path) -> None:
|
||||
"""When template file exists, uses it and replaces placeholders."""
|
||||
ops = _load_ops()
|
||||
fake_prax = tmp_path / "prax"
|
||||
templates_dir = fake_prax / "templates"
|
||||
templates_dir.mkdir(parents=True)
|
||||
|
||||
template_data = {
|
||||
"_warning": "AUTO-GENERATED",
|
||||
"branch": "{{BRANCHNAME}}",
|
||||
"last_updated": "",
|
||||
"sections": {
|
||||
"ai_mail": {"managed_by": "ai_mail", "new": 0},
|
||||
},
|
||||
"quick_status": {"action_required": False},
|
||||
}
|
||||
(templates_dir / "DASHBOARD.template.json").write_text(json.dumps(template_data), encoding="utf-8")
|
||||
|
||||
original = ops._PRAX_ROOT
|
||||
ops._PRAX_ROOT = fake_prax
|
||||
try:
|
||||
branch_dir = tmp_path / "flow"
|
||||
branch_dir.mkdir()
|
||||
result = ops.create_fresh_dashboard(branch_dir)
|
||||
|
||||
assert result["branch"] == "FLOW"
|
||||
assert result["last_updated"] != ""
|
||||
assert result["sections"]["ai_mail"]["new"] == 0
|
||||
finally:
|
||||
ops._PRAX_ROOT = original
|
||||
|
||||
def test_falls_back_on_corrupted_template(self, tmp_path: Path) -> None:
|
||||
"""If template JSON is invalid, falls back to hardcoded structure."""
|
||||
ops = _load_ops()
|
||||
fake_prax = tmp_path / "prax"
|
||||
templates_dir = fake_prax / "templates"
|
||||
templates_dir.mkdir(parents=True)
|
||||
(templates_dir / "DASHBOARD.template.json").write_text("{bad json!!", encoding="utf-8")
|
||||
|
||||
original = ops._PRAX_ROOT
|
||||
ops._PRAX_ROOT = fake_prax
|
||||
try:
|
||||
branch_dir = tmp_path / "broken"
|
||||
branch_dir.mkdir()
|
||||
result = ops.create_fresh_dashboard(branch_dir)
|
||||
|
||||
# Should still return a valid hardcoded dashboard
|
||||
assert result["branch"] == "BROKEN"
|
||||
assert "sections" in result
|
||||
assert "_warning" in result
|
||||
finally:
|
||||
ops._PRAX_ROOT = original
|
||||
|
||||
|
||||
# =============================================
|
||||
# update_section (operations.py — legacy interface)
|
||||
# =============================================
|
||||
|
||||
|
||||
class TestUpdateSectionLegacy:
|
||||
"""Tests for update_section — legacy interface with template and status func."""
|
||||
|
||||
def test_updates_section_and_calls_status_func(self, tmp_path: Path) -> None:
|
||||
"""Section is written and calculate_status_func is invoked with live data."""
|
||||
ops = _load_ops()
|
||||
branch_dir = tmp_path / "testbranch"
|
||||
branch_dir.mkdir()
|
||||
template = {
|
||||
"branch": "",
|
||||
"last_updated": "",
|
||||
"sections": {"ai_mail": {"new": 0}},
|
||||
}
|
||||
status_called_with: dict[str, object] = {}
|
||||
|
||||
def mock_status(sections: dict[str, object]) -> dict[str, object]:
|
||||
status_called_with.update(sections)
|
||||
return {"action_required": True, "summary": "test"}
|
||||
|
||||
result = ops.update_section(branch_dir, "ai_mail", {"new": 5}, template, mock_status)
|
||||
assert result is True
|
||||
# Verify status function was called with sections containing our data
|
||||
assert "ai_mail" in status_called_with
|
||||
assert status_called_with["ai_mail"]["new"] == 5 # type: ignore[union-attr]
|
||||
|
||||
# Verify the file was written
|
||||
data = json.loads((branch_dir / "DASHBOARD.local.json").read_text(encoding="utf-8"))
|
||||
assert data["sections"]["ai_mail"]["new"] == 5
|
||||
assert data["quick_status"]["action_required"] is True
|
||||
|
||||
def test_creates_sections_dict_if_missing(self, tmp_path: Path) -> None:
|
||||
ops = _load_ops()
|
||||
branch_dir = tmp_path / "nosections"
|
||||
branch_dir.mkdir()
|
||||
# Pre-populate dashboard without sections key
|
||||
(branch_dir / "DASHBOARD.local.json").write_text(
|
||||
json.dumps({"branch": "NOSECTIONS", "last_updated": ""}),
|
||||
encoding="utf-8",
|
||||
)
|
||||
template = {
|
||||
"branch": "",
|
||||
"last_updated": "",
|
||||
"sections": {},
|
||||
}
|
||||
|
||||
result = ops.update_section(
|
||||
branch_dir,
|
||||
"flow",
|
||||
{"active_plans": 2},
|
||||
template,
|
||||
lambda s: {"action_required": False},
|
||||
)
|
||||
assert result is True
|
||||
data = json.loads((branch_dir / "DASHBOARD.local.json").read_text(encoding="utf-8"))
|
||||
assert data["sections"]["flow"]["active_plans"] == 2
|
||||
|
||||
|
||||
# =============================================
|
||||
# refresh_all_dashboards (refresh.py)
|
||||
# =============================================
|
||||
|
||||
REFRESH_MODULE_PATH = "aipass.prax.apps.handlers.dashboard.refresh"
|
||||
|
||||
|
||||
def _load_refresh() -> types.ModuleType:
|
||||
"""Import (or reimport) the refresh module under active mocks."""
|
||||
sys.modules.pop(REFRESH_MODULE_PATH, None)
|
||||
# Also ensure dependent modules are reimported
|
||||
sys.modules.pop("aipass.prax.apps.handlers.dashboard.operations", None)
|
||||
import aipass.prax.apps.handlers.dashboard.refresh as mod
|
||||
|
||||
importlib.reload(mod)
|
||||
return mod
|
||||
|
||||
|
||||
class TestRefreshAllDashboards:
|
||||
"""Tests for refresh_all_dashboards — orchestrates full refresh from centrals."""
|
||||
|
||||
def test_returns_success_when_all_branches_updated(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
mod = _load_refresh()
|
||||
branch1 = tmp_path / "branch1"
|
||||
branch1.mkdir()
|
||||
branch2 = tmp_path / "branch2"
|
||||
branch2.mkdir()
|
||||
|
||||
monkeypatch.setattr(mod, "read_all_centrals", lambda: {})
|
||||
monkeypatch.setattr(mod, "_load_branch_paths", lambda: [branch1, branch2])
|
||||
monkeypatch.setattr(
|
||||
mod,
|
||||
"create_fresh_dashboard",
|
||||
lambda bp: {
|
||||
"branch": bp.name.upper(),
|
||||
"sections": {},
|
||||
"quick_status": {},
|
||||
},
|
||||
)
|
||||
|
||||
result = mod.refresh_all_dashboards()
|
||||
assert result["status"] == "success"
|
||||
assert result["branches_updated"] == 2
|
||||
assert result["branches_failed"] == 0
|
||||
assert result["errors"] == []
|
||||
|
||||
def test_returns_error_when_branch_paths_fail(self, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
mod = _load_refresh()
|
||||
monkeypatch.setattr(mod, "read_all_centrals", lambda: {})
|
||||
|
||||
def _raise() -> list[Path]:
|
||||
raise RuntimeError("registry gone")
|
||||
|
||||
monkeypatch.setattr(mod, "_load_branch_paths", _raise)
|
||||
|
||||
result = mod.refresh_all_dashboards()
|
||||
assert result["status"] == "error"
|
||||
assert result["branches_updated"] == 0
|
||||
assert len(result["errors"]) == 1
|
||||
assert "registry gone" in result["errors"][0]
|
||||
|
||||
def test_partial_status_on_mixed_success_failure(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
mod = _load_refresh()
|
||||
good_branch = tmp_path / "good"
|
||||
good_branch.mkdir()
|
||||
bad_branch = tmp_path / "bad"
|
||||
bad_branch.mkdir()
|
||||
|
||||
monkeypatch.setattr(mod, "read_all_centrals", lambda: {})
|
||||
monkeypatch.setattr(mod, "_load_branch_paths", lambda: [good_branch, bad_branch])
|
||||
|
||||
def flaky_create(bp: Path) -> dict[str, object]:
|
||||
if bp.name == "bad":
|
||||
raise RuntimeError("simulated failure")
|
||||
return {
|
||||
"branch": bp.name.upper(),
|
||||
"sections": {},
|
||||
"quick_status": {},
|
||||
}
|
||||
|
||||
monkeypatch.setattr(mod, "create_fresh_dashboard", flaky_create)
|
||||
|
||||
result = mod.refresh_all_dashboards()
|
||||
assert result["status"] == "partial"
|
||||
assert result["branches_updated"] == 1
|
||||
assert result["branches_failed"] == 1
|
||||
|
||||
|
||||
# =============================================
|
||||
# refresh_single_dashboard (refresh.py)
|
||||
# =============================================
|
||||
|
||||
|
||||
class TestRefreshSingleDashboard:
|
||||
"""Tests for refresh_single_dashboard — refreshes one branch."""
|
||||
|
||||
def test_returns_success_for_valid_branch(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
mod = _load_refresh()
|
||||
branch_dir = tmp_path / "flow"
|
||||
branch_dir.mkdir()
|
||||
|
||||
monkeypatch.setattr(mod, "read_all_centrals", lambda: {})
|
||||
monkeypatch.setattr(
|
||||
mod,
|
||||
"create_fresh_dashboard",
|
||||
lambda bp: {
|
||||
"branch": bp.name.upper(),
|
||||
"sections": {},
|
||||
"quick_status": {},
|
||||
},
|
||||
)
|
||||
|
||||
result = mod.refresh_single_dashboard(branch_dir)
|
||||
assert result["status"] == "success"
|
||||
assert result["branch"] == "FLOW"
|
||||
|
||||
def test_returns_error_on_exception(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
mod = _load_refresh()
|
||||
branch_dir = tmp_path / "failing"
|
||||
branch_dir.mkdir()
|
||||
|
||||
monkeypatch.setattr(mod, "read_all_centrals", lambda: {})
|
||||
|
||||
def _raise(bp: Path) -> dict[str, object]:
|
||||
raise RuntimeError("boom")
|
||||
|
||||
monkeypatch.setattr(mod, "create_fresh_dashboard", _raise)
|
||||
|
||||
result = mod.refresh_single_dashboard(branch_dir)
|
||||
assert result["status"] == "error"
|
||||
assert result["branch"] == "FAILING"
|
||||
assert "boom" in result["error"]
|
||||
|
||||
|
||||
# =============================================
|
||||
# get_branch_paths (status.py)
|
||||
# =============================================
|
||||
|
||||
STATUS_MODULE_PATH = "aipass.prax.apps.handlers.dashboard.status"
|
||||
|
||||
|
||||
def _load_status() -> types.ModuleType:
|
||||
"""Import (or reimport) the status module under active mocks."""
|
||||
sys.modules.pop(STATUS_MODULE_PATH, None)
|
||||
import aipass.prax.apps.handlers.dashboard.status as mod
|
||||
|
||||
importlib.reload(mod)
|
||||
return mod
|
||||
|
||||
|
||||
class TestGetBranchPaths:
|
||||
"""Tests for get_branch_paths — reads registry and returns branch paths."""
|
||||
|
||||
def test_returns_paths_from_registry(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
mod = _load_status()
|
||||
registry_data = {
|
||||
"branches": [
|
||||
{"name": "flow", "path": str(tmp_path / "flow")},
|
||||
{"name": "ai_mail", "path": str(tmp_path / "ai_mail")},
|
||||
]
|
||||
}
|
||||
registry_file = tmp_path / "AIPASS_REGISTRY.json"
|
||||
registry_file.write_text(json.dumps(registry_data), encoding="utf-8")
|
||||
|
||||
monkeypatch.setattr(mod, "AIPASS_REGISTRY", registry_file)
|
||||
monkeypatch.setattr(mod, "_find_repo_root", lambda: tmp_path)
|
||||
|
||||
result = mod.get_branch_paths()
|
||||
assert len(result) == 2
|
||||
assert all(isinstance(p, Path) for p in result)
|
||||
|
||||
def test_raises_when_registry_missing(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
mod = _load_status()
|
||||
monkeypatch.setattr(mod, "AIPASS_REGISTRY", tmp_path / "nonexistent_registry.json")
|
||||
|
||||
with pytest.raises(FileNotFoundError):
|
||||
mod.get_branch_paths()
|
||||
|
||||
def test_handles_relative_paths(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
mod = _load_status()
|
||||
registry_data = {
|
||||
"branches": [
|
||||
{"name": "flow", "path": "src/aipass/flow"},
|
||||
]
|
||||
}
|
||||
registry_file = tmp_path / "AIPASS_REGISTRY.json"
|
||||
registry_file.write_text(json.dumps(registry_data), encoding="utf-8")
|
||||
|
||||
monkeypatch.setattr(mod, "AIPASS_REGISTRY", registry_file)
|
||||
monkeypatch.setattr(mod, "_find_repo_root", lambda: tmp_path)
|
||||
|
||||
result = mod.get_branch_paths()
|
||||
assert len(result) == 1
|
||||
assert result[0] == tmp_path / "src" / "aipass" / "flow"
|
||||
|
||||
|
||||
# =============================================
|
||||
# resolve_branch_path (status.py)
|
||||
# =============================================
|
||||
|
||||
|
||||
class TestResolveBranchPath:
|
||||
"""Tests for resolve_branch_path — resolves @branch ref to filesystem path."""
|
||||
|
||||
def test_resolves_existing_branch(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
mod = _load_status()
|
||||
branch_dir = tmp_path / "flow"
|
||||
branch_dir.mkdir()
|
||||
registry_data = {
|
||||
"branches": [
|
||||
{"name": "flow", "path": str(branch_dir)},
|
||||
]
|
||||
}
|
||||
registry_file = tmp_path / "AIPASS_REGISTRY.json"
|
||||
registry_file.write_text(json.dumps(registry_data), encoding="utf-8")
|
||||
|
||||
monkeypatch.setattr(mod, "AIPASS_REGISTRY", registry_file)
|
||||
monkeypatch.setattr(mod, "_find_repo_root", lambda: tmp_path)
|
||||
|
||||
result = mod.resolve_branch_path("@flow")
|
||||
assert result == branch_dir
|
||||
|
||||
def test_strips_at_sign_and_is_case_insensitive(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
mod = _load_status()
|
||||
branch_dir = tmp_path / "vera"
|
||||
branch_dir.mkdir()
|
||||
registry_data = {
|
||||
"branches": [
|
||||
{"name": "VERA", "path": str(branch_dir)},
|
||||
]
|
||||
}
|
||||
registry_file = tmp_path / "AIPASS_REGISTRY.json"
|
||||
registry_file.write_text(json.dumps(registry_data), encoding="utf-8")
|
||||
|
||||
monkeypatch.setattr(mod, "AIPASS_REGISTRY", registry_file)
|
||||
monkeypatch.setattr(mod, "_find_repo_root", lambda: tmp_path)
|
||||
|
||||
result = mod.resolve_branch_path("@vera")
|
||||
assert result == branch_dir
|
||||
|
||||
def test_raises_when_branch_not_in_registry(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
mod = _load_status()
|
||||
registry_data: dict[str, list[object]] = {"branches": []}
|
||||
registry_file = tmp_path / "AIPASS_REGISTRY.json"
|
||||
registry_file.write_text(json.dumps(registry_data), encoding="utf-8")
|
||||
|
||||
monkeypatch.setattr(mod, "AIPASS_REGISTRY", registry_file)
|
||||
monkeypatch.setattr(mod, "_find_repo_root", lambda: tmp_path)
|
||||
|
||||
with pytest.raises(FileNotFoundError, match="not found in registry"):
|
||||
mod.resolve_branch_path("@nonexistent")
|
||||
|
||||
def test_raises_when_path_does_not_exist(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
mod = _load_status()
|
||||
registry_data = {
|
||||
"branches": [
|
||||
{"name": "ghost", "path": str(tmp_path / "ghost")},
|
||||
]
|
||||
}
|
||||
registry_file = tmp_path / "AIPASS_REGISTRY.json"
|
||||
registry_file.write_text(json.dumps(registry_data), encoding="utf-8")
|
||||
|
||||
monkeypatch.setattr(mod, "AIPASS_REGISTRY", registry_file)
|
||||
monkeypatch.setattr(mod, "_find_repo_root", lambda: tmp_path)
|
||||
|
||||
with pytest.raises(FileNotFoundError, match="does not exist"):
|
||||
mod.resolve_branch_path("@ghost")
|
||||
|
||||
def test_raises_when_registry_missing(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
mod = _load_status()
|
||||
monkeypatch.setattr(mod, "AIPASS_REGISTRY", tmp_path / "nonexistent_registry.json")
|
||||
|
||||
with pytest.raises(FileNotFoundError, match="AIPASS_REGISTRY"):
|
||||
mod.resolve_branch_path("@flow")
|
||||
|
||||
|
||||
# =============================================
|
||||
# diff_dashboard_template (template_differ.py)
|
||||
# =============================================
|
||||
|
||||
DIFFER_MODULE_PATH = "aipass.prax.apps.handlers.dashboard.template_differ"
|
||||
|
||||
|
||||
def _load_differ() -> types.ModuleType:
|
||||
"""Import (or reimport) the template_differ module under active mocks."""
|
||||
sys.modules.pop(DIFFER_MODULE_PATH, None)
|
||||
import aipass.prax.apps.handlers.dashboard.template_differ as mod
|
||||
|
||||
importlib.reload(mod)
|
||||
return mod
|
||||
|
||||
|
||||
class TestDiffDashboardTemplate:
|
||||
"""Tests for diff_dashboard_template — compares template vs branch dashboards."""
|
||||
|
||||
def _make_template(self) -> dict[str, object]:
|
||||
return {
|
||||
"_warning": "AUTO-GENERATED",
|
||||
"branch": "{{BRANCHNAME}}",
|
||||
"sections": {
|
||||
"ai_mail": {"managed_by": "ai_mail", "new": 0, "last_updated": ""},
|
||||
"flow": {"managed_by": "flow", "active_plans": 0, "last_updated": ""},
|
||||
"memory": {"managed_by": "memory", "last_updated": ""},
|
||||
"commons_activity": {"managed_by": "the_commons", "last_updated": ""},
|
||||
},
|
||||
"quick_status": {
|
||||
"new_mail": 0,
|
||||
"opened_mail": 0,
|
||||
"active_plans": 0,
|
||||
"commons_mentions": 0,
|
||||
"action_required": False,
|
||||
"summary": "",
|
||||
},
|
||||
}
|
||||
|
||||
def test_returns_error_when_template_missing(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
mod = _load_differ()
|
||||
monkeypatch.setattr(mod, "TEMPLATE_FILE", tmp_path / "nofile.json")
|
||||
|
||||
result = mod.diff_dashboard_template()
|
||||
assert "error" in result
|
||||
assert "not found" in result["error"]
|
||||
|
||||
def test_reports_up_to_date_branch(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
mod = _load_differ()
|
||||
|
||||
# Create template file
|
||||
template = self._make_template()
|
||||
template_file = tmp_path / "template.json"
|
||||
template_file.write_text(json.dumps(template), encoding="utf-8")
|
||||
monkeypatch.setattr(mod, "TEMPLATE_FILE", template_file)
|
||||
|
||||
# Create registry
|
||||
branch_dir = tmp_path / "flow"
|
||||
branch_dir.mkdir()
|
||||
registry_data = {
|
||||
"branches": [
|
||||
{"name": "FLOW", "path": str(branch_dir), "status": "active"},
|
||||
]
|
||||
}
|
||||
registry_file = tmp_path / "AIPASS_REGISTRY.json"
|
||||
registry_file.write_text(json.dumps(registry_data), encoding="utf-8")
|
||||
monkeypatch.setattr(mod, "AIPASS_REGISTRY", registry_file)
|
||||
monkeypatch.setattr(mod, "_find_repo_root", lambda: tmp_path)
|
||||
|
||||
# Create a dashboard that is up to date
|
||||
dashboard = {
|
||||
"_warning": "AUTO-GENERATED",
|
||||
"branch": "FLOW",
|
||||
"sections": {
|
||||
"ai_mail": {"managed_by": "ai_mail", "new": 0, "last_updated": "2026-01-01"},
|
||||
"flow": {"managed_by": "flow", "active_plans": 0, "last_updated": "2026-01-01"},
|
||||
"memory": {"managed_by": "memory", "last_updated": "2026-01-01"},
|
||||
"commons_activity": {"managed_by": "the_commons", "last_updated": "2026-01-01"},
|
||||
},
|
||||
"quick_status": {
|
||||
"new_mail": 0,
|
||||
"opened_mail": 0,
|
||||
"active_plans": 0,
|
||||
"commons_mentions": 0,
|
||||
"action_required": False,
|
||||
"summary": "",
|
||||
},
|
||||
}
|
||||
(branch_dir / "DASHBOARD.local.json").write_text(json.dumps(dashboard), encoding="utf-8")
|
||||
|
||||
result = mod.diff_dashboard_template()
|
||||
assert result["summary"]["up_to_date"] == 1
|
||||
assert result["summary"]["needs_update"] == 0
|
||||
|
||||
def test_reports_missing_dashboard(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
mod = _load_differ()
|
||||
|
||||
template = self._make_template()
|
||||
template_file = tmp_path / "template.json"
|
||||
template_file.write_text(json.dumps(template), encoding="utf-8")
|
||||
monkeypatch.setattr(mod, "TEMPLATE_FILE", template_file)
|
||||
|
||||
branch_dir = tmp_path / "nobranch"
|
||||
branch_dir.mkdir()
|
||||
registry_data = {
|
||||
"branches": [
|
||||
{"name": "NOBRANCH", "path": str(branch_dir), "status": "active"},
|
||||
]
|
||||
}
|
||||
registry_file = tmp_path / "AIPASS_REGISTRY.json"
|
||||
registry_file.write_text(json.dumps(registry_data), encoding="utf-8")
|
||||
monkeypatch.setattr(mod, "AIPASS_REGISTRY", registry_file)
|
||||
monkeypatch.setattr(mod, "_find_repo_root", lambda: tmp_path)
|
||||
|
||||
result = mod.diff_dashboard_template()
|
||||
assert result["summary"]["missing"] == 1
|
||||
|
||||
def test_detects_deprecated_sections(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
mod = _load_differ()
|
||||
|
||||
template = self._make_template()
|
||||
template_file = tmp_path / "template.json"
|
||||
template_file.write_text(json.dumps(template), encoding="utf-8")
|
||||
monkeypatch.setattr(mod, "TEMPLATE_FILE", template_file)
|
||||
|
||||
branch_dir = tmp_path / "oldbranch"
|
||||
branch_dir.mkdir()
|
||||
registry_data = {
|
||||
"branches": [
|
||||
{"name": "OLDBRANCH", "path": str(branch_dir), "status": "active"},
|
||||
]
|
||||
}
|
||||
registry_file = tmp_path / "AIPASS_REGISTRY.json"
|
||||
registry_file.write_text(json.dumps(registry_data), encoding="utf-8")
|
||||
monkeypatch.setattr(mod, "AIPASS_REGISTRY", registry_file)
|
||||
monkeypatch.setattr(mod, "_find_repo_root", lambda: tmp_path)
|
||||
|
||||
# Dashboard with deprecated section
|
||||
dashboard = {
|
||||
"_warning": "AUTO-GENERATED",
|
||||
"branch": "OLDBRANCH",
|
||||
"sections": {
|
||||
"ai_mail": {"new": 0, "last_updated": ""},
|
||||
"flow": {"active_plans": 0, "last_updated": ""},
|
||||
"memory": {"last_updated": ""},
|
||||
"commons_activity": {"last_updated": ""},
|
||||
"bulletin_board": {"posts": 0, "last_updated": ""},
|
||||
},
|
||||
"quick_status": {
|
||||
"new_mail": 0,
|
||||
"opened_mail": 0,
|
||||
"active_plans": 0,
|
||||
"commons_mentions": 0,
|
||||
"action_required": False,
|
||||
"summary": "",
|
||||
},
|
||||
}
|
||||
(branch_dir / "DASHBOARD.local.json").write_text(json.dumps(dashboard), encoding="utf-8")
|
||||
|
||||
result = mod.diff_dashboard_template()
|
||||
assert result["summary"]["needs_update"] == 1
|
||||
branch_diff = result["branches"][0]
|
||||
assert any("bulletin_board" in r for r in branch_diff["removals"])
|
||||
|
||||
def test_filters_to_single_branch(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
mod = _load_differ()
|
||||
|
||||
template = self._make_template()
|
||||
template_file = tmp_path / "template.json"
|
||||
template_file.write_text(json.dumps(template), encoding="utf-8")
|
||||
monkeypatch.setattr(mod, "TEMPLATE_FILE", template_file)
|
||||
|
||||
branch1 = tmp_path / "flow"
|
||||
branch1.mkdir()
|
||||
branch2 = tmp_path / "ai_mail"
|
||||
branch2.mkdir()
|
||||
registry_data = {
|
||||
"branches": [
|
||||
{"name": "FLOW", "path": str(branch1), "status": "active"},
|
||||
{"name": "AI_MAIL", "path": str(branch2), "status": "active"},
|
||||
]
|
||||
}
|
||||
registry_file = tmp_path / "AIPASS_REGISTRY.json"
|
||||
registry_file.write_text(json.dumps(registry_data), encoding="utf-8")
|
||||
monkeypatch.setattr(mod, "AIPASS_REGISTRY", registry_file)
|
||||
monkeypatch.setattr(mod, "_find_repo_root", lambda: tmp_path)
|
||||
|
||||
result = mod.diff_dashboard_template(branch_name="FLOW")
|
||||
assert len(result["branches"]) == 1
|
||||
assert result["branches"][0]["branch"] == "FLOW"
|
||||
|
||||
|
||||
# =============================================
|
||||
# push_dashboard_template (template_pusher.py)
|
||||
# =============================================
|
||||
|
||||
PUSHER_MODULE_PATH = "aipass.prax.apps.handlers.dashboard.template_pusher"
|
||||
|
||||
|
||||
def _load_pusher() -> types.ModuleType:
|
||||
"""Import (or reimport) the template_pusher module under active mocks."""
|
||||
sys.modules.pop(PUSHER_MODULE_PATH, None)
|
||||
import aipass.prax.apps.handlers.dashboard.template_pusher as mod
|
||||
|
||||
importlib.reload(mod)
|
||||
return mod
|
||||
|
||||
|
||||
class TestPushDashboardTemplate:
|
||||
"""Tests for push_dashboard_template — pushes template to all branches."""
|
||||
|
||||
def _setup_template_and_registry(
|
||||
self,
|
||||
tmp_path: Path,
|
||||
mod: types.ModuleType,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
branches: list[str],
|
||||
) -> dict[str, object]:
|
||||
"""Helper: create template file, registry, and branch dirs."""
|
||||
template = {
|
||||
"_warning": "AUTO-GENERATED",
|
||||
"branch": "{{BRANCHNAME}}",
|
||||
"sections": {
|
||||
"ai_mail": {"managed_by": "ai_mail", "new": 0, "opened": 0, "total": 0, "last_updated": ""},
|
||||
"flow": {"managed_by": "flow", "active_plans": 0, "recently_closed": [], "last_updated": ""},
|
||||
"memory": {"managed_by": "memory", "vectors_stored": 0, "notes": {}, "last_updated": ""},
|
||||
"commons_activity": {
|
||||
"managed_by": "the_commons",
|
||||
"mentions": 0,
|
||||
"new_posts_since_last_visit": 0,
|
||||
"new_comments_since_last_visit": 0,
|
||||
"last_updated": "",
|
||||
},
|
||||
},
|
||||
"quick_status": {},
|
||||
}
|
||||
template_file = tmp_path / "template.json"
|
||||
template_file.write_text(json.dumps(template), encoding="utf-8")
|
||||
monkeypatch.setattr(mod, "TEMPLATE_FILE", template_file)
|
||||
|
||||
version_file = tmp_path / ".dashboard_version.json"
|
||||
monkeypatch.setattr(mod, "VERSION_FILE", version_file)
|
||||
|
||||
branch_entries = []
|
||||
for name in branches:
|
||||
d = tmp_path / name
|
||||
d.mkdir(exist_ok=True)
|
||||
branch_entries.append({"name": name.upper(), "path": str(d), "status": "active"})
|
||||
|
||||
registry_file = tmp_path / "AIPASS_REGISTRY.json"
|
||||
registry_file.write_text(json.dumps({"branches": branch_entries}), encoding="utf-8")
|
||||
monkeypatch.setattr(mod, "AIPASS_REGISTRY", registry_file)
|
||||
monkeypatch.setattr(mod, "_find_repo_root", lambda: tmp_path)
|
||||
|
||||
return template
|
||||
|
||||
def test_creates_dashboards_for_branches_without_one(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
mod = _load_pusher()
|
||||
self._setup_template_and_registry(tmp_path, mod, monkeypatch, ["flow", "ai_mail"])
|
||||
|
||||
result = mod.push_dashboard_template(dry_run=False)
|
||||
assert result["success"] is True
|
||||
assert result["branches_created"] == 2
|
||||
assert (tmp_path / "flow" / "DASHBOARD.local.json").exists()
|
||||
assert (tmp_path / "ai_mail" / "DASHBOARD.local.json").exists()
|
||||
|
||||
def test_dry_run_does_not_write_files(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
mod = _load_pusher()
|
||||
self._setup_template_and_registry(tmp_path, mod, monkeypatch, ["flow"])
|
||||
|
||||
result = mod.push_dashboard_template(dry_run=True)
|
||||
assert result["dry_run"] is True
|
||||
assert result["branches_created"] == 1
|
||||
# File should NOT be created in dry run
|
||||
assert not (tmp_path / "flow" / "DASHBOARD.local.json").exists()
|
||||
|
||||
def test_updates_existing_dashboard_with_structural_changes(
|
||||
self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
mod = _load_pusher()
|
||||
self._setup_template_and_registry(tmp_path, mod, monkeypatch, ["flow"])
|
||||
|
||||
# Pre-create a dashboard with a deprecated section
|
||||
existing = {
|
||||
"_warning": "OLD WARNING",
|
||||
"branch": "FLOW",
|
||||
"sections": {
|
||||
"ai_mail": {"managed_by": "ai_mail", "new": 3, "last_updated": "2026-01-01"},
|
||||
"flow": {"managed_by": "flow", "active_plans": 2, "last_updated": "2026-01-01"},
|
||||
"memory": {"managed_by": "memory", "last_updated": "2026-01-01"},
|
||||
"commons_activity": {"managed_by": "the_commons", "last_updated": "2026-01-01"},
|
||||
"bulletin_board": {"posts": 5},
|
||||
},
|
||||
"quick_status": {"pending_bulletins": 3},
|
||||
}
|
||||
(tmp_path / "flow" / "DASHBOARD.local.json").write_text(json.dumps(existing), encoding="utf-8")
|
||||
|
||||
result = mod.push_dashboard_template(dry_run=False)
|
||||
assert result["branches_updated"] == 1
|
||||
assert result["branches_created"] == 0
|
||||
|
||||
data = json.loads((tmp_path / "flow" / "DASHBOARD.local.json").read_text(encoding="utf-8"))
|
||||
# Deprecated section removed
|
||||
assert "bulletin_board" not in data["sections"]
|
||||
# Deprecated quick_status key removed
|
||||
assert "pending_bulletins" not in data.get("quick_status", {})
|
||||
# Warning header updated
|
||||
assert data["_warning"] == "AUTO-GENERATED"
|
||||
# Existing data preserved
|
||||
assert data["sections"]["ai_mail"]["new"] == 3
|
||||
|
||||
def test_returns_error_when_template_missing(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
mod = _load_pusher()
|
||||
monkeypatch.setattr(mod, "TEMPLATE_FILE", tmp_path / "no_template.json")
|
||||
|
||||
result = mod.push_dashboard_template()
|
||||
assert result["success"] is False
|
||||
assert len(result["errors"]) > 0
|
||||
|
||||
|
||||
# =============================================
|
||||
# get_template_status (template_pusher.py)
|
||||
# =============================================
|
||||
|
||||
|
||||
class TestGetTemplateStatus:
|
||||
"""Tests for get_template_status — reads version file and template existence."""
|
||||
|
||||
def test_returns_status_when_version_file_exists(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
mod = _load_pusher()
|
||||
version_data = {
|
||||
"version": "3.0.0",
|
||||
"last_updated": "2026-03-01",
|
||||
"updated_by": "prax",
|
||||
"changes": ["added commons"],
|
||||
"last_push": "2026-03-02 10:00:00",
|
||||
"last_push_branches": ["FLOW", "AI_MAIL"],
|
||||
}
|
||||
version_file = tmp_path / ".dashboard_version.json"
|
||||
version_file.write_text(json.dumps(version_data), encoding="utf-8")
|
||||
monkeypatch.setattr(mod, "VERSION_FILE", version_file)
|
||||
monkeypatch.setattr(mod, "TEMPLATE_FILE", tmp_path / "exists.json")
|
||||
(tmp_path / "exists.json").write_text("{}", encoding="utf-8")
|
||||
|
||||
result = mod.get_template_status()
|
||||
assert result["version"] == "3.0.0"
|
||||
assert result["last_push"] == "2026-03-02 10:00:00"
|
||||
assert result["last_push_branches"] == ["FLOW", "AI_MAIL"]
|
||||
assert result["template_exists"] is True
|
||||
|
||||
def test_returns_defaults_when_no_version_file(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
mod = _load_pusher()
|
||||
monkeypatch.setattr(mod, "VERSION_FILE", tmp_path / "nonexistent_version.json")
|
||||
monkeypatch.setattr(mod, "TEMPLATE_FILE", tmp_path / "also_nonexistent.json")
|
||||
|
||||
result = mod.get_template_status()
|
||||
assert result["version"] is None
|
||||
assert result["last_push"] is None
|
||||
assert result["last_push_branches"] == []
|
||||
assert result["template_exists"] is False
|
||||
|
||||
|
||||
# =============================================
|
||||
# update_section (dashboard.py module wrapper)
|
||||
# =============================================
|
||||
|
||||
DASHBOARD_MODULE_PATH = "aipass.prax.apps.modules.dashboard"
|
||||
|
||||
|
||||
def _load_dashboard_module() -> types.ModuleType:
|
||||
"""Import (or reimport) the dashboard module under active mocks."""
|
||||
# Ensure handler dependencies are also cleared for fresh import
|
||||
for mod_key in list(sys.modules.keys()):
|
||||
if mod_key.startswith("aipass.prax.apps.handlers.dashboard"):
|
||||
sys.modules.pop(mod_key, None)
|
||||
sys.modules.pop(DASHBOARD_MODULE_PATH, None)
|
||||
import aipass.prax.apps.modules.dashboard as mod
|
||||
|
||||
importlib.reload(mod)
|
||||
return mod
|
||||
|
||||
|
||||
class TestDashboardModuleUpdateSection:
|
||||
"""Tests for dashboard.py module-level update_section wrapper."""
|
||||
|
||||
def test_delegates_to_handler_update_section(self, tmp_path: Path) -> None:
|
||||
mod = _load_dashboard_module()
|
||||
branch_dir = tmp_path / "wrapper_branch"
|
||||
branch_dir.mkdir()
|
||||
|
||||
result = mod.update_section(branch_dir, "flow", {"active_plans": 3})
|
||||
assert result is True
|
||||
data = json.loads((branch_dir / "DASHBOARD.local.json").read_text(encoding="utf-8"))
|
||||
assert data["sections"]["flow"]["active_plans"] == 3
|
||||
|
||||
def test_returns_false_on_handler_error(self, tmp_path: Path) -> None:
|
||||
mod = _load_dashboard_module()
|
||||
# A deeply nested nonexistent path should trigger an error
|
||||
bad_path = tmp_path / "no" / "such" / "deep" / "branch"
|
||||
|
||||
result = mod.update_section(bad_path, "flow", {"active_plans": 1})
|
||||
assert result is False
|
||||
|
||||
|
||||
# =============================================
|
||||
# print_status (dashboard.py)
|
||||
# =============================================
|
||||
|
||||
|
||||
class TestPrintStatus:
|
||||
"""Tests for print_status -- CLI status display."""
|
||||
|
||||
def test_prints_branch_dashboard_status(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
mod = _load_dashboard_module()
|
||||
|
||||
branch1 = tmp_path / "flow"
|
||||
branch1.mkdir()
|
||||
(branch1 / "DASHBOARD.local.json").write_text("{}", encoding="utf-8")
|
||||
branch2 = tmp_path / "ai_mail"
|
||||
branch2.mkdir()
|
||||
|
||||
monkeypatch.setattr(mod, "get_branch_paths", lambda: [branch1, branch2])
|
||||
|
||||
# Should not raise
|
||||
mod.print_status()
|
||||
|
||||
def test_handles_error_loading_branches(self, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
mod = _load_dashboard_module()
|
||||
|
||||
def raise_error() -> list[Path]:
|
||||
raise RuntimeError("registry not found")
|
||||
|
||||
monkeypatch.setattr(mod, "get_branch_paths", raise_error)
|
||||
# Should not raise, just log/print error
|
||||
mod.print_status()
|
||||
|
||||
|
||||
# =============================================
|
||||
# print_template (dashboard.py)
|
||||
# =============================================
|
||||
|
||||
|
||||
class TestPrintTemplate:
|
||||
"""Tests for print_template -- CLI template display."""
|
||||
|
||||
def test_prints_template_without_error(self) -> None:
|
||||
mod = _load_dashboard_module()
|
||||
# Should not raise
|
||||
mod.print_template()
|
||||
|
||||
@@ -1 +1,2 @@
|
||||
# {{BRANCHNAME}} apps package
|
||||
from . import handlers # noqa: F401
|
||||
|
||||
@@ -1 +1,2 @@
|
||||
# Apps package - Branch application modules and handlers
|
||||
from . import handlers # noqa: F401
|
||||
|
||||
@@ -0,0 +1,377 @@
|
||||
# =================== AIPass ====================
|
||||
# Name: test_error_detected.py
|
||||
# Description: Tests for error_detected event handler with Medic v2 dispatch gating
|
||||
# Version: 1.0.0
|
||||
# Created: 2026-04-25
|
||||
# Modified: 2026-04-25
|
||||
# =============================================
|
||||
|
||||
"""Tests for error_detected event handler: set_send_email_callback, handle_error_detected, and fallback stubs."""
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Shared fixture: mocks config + json_handler, provides a registry-available
|
||||
# environment by default. Individual tests override module-level helpers
|
||||
# after importing.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _mock_infrastructure(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
|
||||
"""Mock config, json_handler, error_registry, and wake_branch before import."""
|
||||
from aipass.trigger.apps.config import atomic_write_json
|
||||
|
||||
mock_config = MagicMock()
|
||||
mock_config.TRIGGER_ROOT = tmp_path
|
||||
mock_config.atomic_write_json = atomic_write_json
|
||||
monkeypatch.setitem(sys.modules, "aipass.trigger.apps.config", mock_config)
|
||||
|
||||
mock_json_handler = MagicMock()
|
||||
mock_json_handler.log_operation = MagicMock(return_value=True)
|
||||
json_pkg = MagicMock()
|
||||
json_pkg.json_handler = mock_json_handler
|
||||
monkeypatch.setitem(sys.modules, "aipass.trigger.apps.handlers.json", json_pkg)
|
||||
monkeypatch.setitem(
|
||||
sys.modules,
|
||||
"aipass.trigger.apps.handlers.json.json_handler",
|
||||
mock_json_handler,
|
||||
)
|
||||
|
||||
# Provide a working error_registry mock so _REGISTRY_DISPATCH_AVAILABLE=True
|
||||
mock_registry = MagicMock()
|
||||
mock_registry.circuit_breaker_allows = MagicMock(return_value=True)
|
||||
mock_registry.circuit_breaker_record_error = MagicMock()
|
||||
mock_registry.should_dispatch = MagicMock(return_value=True)
|
||||
mock_registry.record_dispatch = MagicMock()
|
||||
monkeypatch.setitem(sys.modules, "aipass.trigger.apps.handlers.error_registry", mock_registry)
|
||||
|
||||
# Mock wake_branch import chain to prevent real imports
|
||||
mock_wake = MagicMock()
|
||||
mock_wake.wake_branch = MagicMock()
|
||||
monkeypatch.setitem(sys.modules, "aipass.ai_mail", MagicMock())
|
||||
monkeypatch.setitem(sys.modules, "aipass.ai_mail.apps", MagicMock())
|
||||
monkeypatch.setitem(sys.modules, "aipass.ai_mail.apps.handlers", MagicMock())
|
||||
monkeypatch.setitem(sys.modules, "aipass.ai_mail.apps.handlers.dispatch", MagicMock())
|
||||
monkeypatch.setitem(sys.modules, "aipass.ai_mail.apps.handlers.dispatch.wake", mock_wake)
|
||||
|
||||
monkeypatch.delitem(
|
||||
sys.modules,
|
||||
"aipass.trigger.apps.handlers.events.error_detected",
|
||||
raising=False,
|
||||
)
|
||||
|
||||
|
||||
def _import_module():
|
||||
"""Import error_detected module fresh after mocking."""
|
||||
import aipass.trigger.apps.handlers.events.error_detected as m
|
||||
|
||||
return m
|
||||
|
||||
|
||||
def _setup_happy_path(mod: object) -> MagicMock:
|
||||
"""Patch module internals for a successful dispatch and return the send_email mock."""
|
||||
send_mock = MagicMock(return_value=True)
|
||||
mod._is_medic_enabled = MagicMock(return_value=True) # type: ignore[attr-defined]
|
||||
mod._is_branch_muted = MagicMock(return_value=False) # type: ignore[attr-defined]
|
||||
mod._get_registered_emails = MagicMock(return_value={"@flow", "@spawn"}) # type: ignore[attr-defined]
|
||||
mod._send_email = send_mock # type: ignore[attr-defined]
|
||||
mod.circuit_breaker_allows = MagicMock(return_value=True) # type: ignore[attr-defined]
|
||||
mod.registry_should_dispatch = MagicMock(return_value=True) # type: ignore[attr-defined]
|
||||
mod.registry_record_dispatch = MagicMock() # type: ignore[attr-defined]
|
||||
mod.circuit_breaker_record_error = MagicMock() # type: ignore[attr-defined]
|
||||
mod._REGISTRY_DISPATCH_AVAILABLE = True # type: ignore[attr-defined]
|
||||
return send_mock
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# set_send_email_callback
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestSetSendEmailCallback:
|
||||
"""Tests for set_send_email_callback."""
|
||||
|
||||
def test_sets_callback(self) -> None:
|
||||
"""Stores the callback as the module-level _send_email."""
|
||||
mod = _import_module()
|
||||
callback = MagicMock()
|
||||
mod.set_send_email_callback(callback)
|
||||
assert mod._send_email is callback
|
||||
|
||||
def test_overwrites_previous_callback(self) -> None:
|
||||
"""Second call replaces the first callback."""
|
||||
mod = _import_module()
|
||||
first = MagicMock()
|
||||
second = MagicMock()
|
||||
mod.set_send_email_callback(first)
|
||||
mod.set_send_email_callback(second)
|
||||
assert mod._send_email is second
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# handle_error_detected -- early-return gates
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestHandleErrorDetectedGates:
|
||||
"""Tests for early-return gates in handle_error_detected."""
|
||||
|
||||
def test_returns_early_missing_branch(self) -> None:
|
||||
"""Does not dispatch when branch is None."""
|
||||
mod = _import_module()
|
||||
send = _setup_happy_path(mod)
|
||||
|
||||
mod.handle_error_detected(branch=None, module="cfg", message="err", error_hash="h1", count=2)
|
||||
|
||||
send.assert_not_called()
|
||||
|
||||
def test_returns_early_missing_module(self) -> None:
|
||||
"""Does not dispatch when module is None."""
|
||||
mod = _import_module()
|
||||
send = _setup_happy_path(mod)
|
||||
|
||||
mod.handle_error_detected(branch="flow", module=None, message="err", error_hash="h1", count=2)
|
||||
|
||||
send.assert_not_called()
|
||||
|
||||
def test_returns_early_missing_message(self) -> None:
|
||||
"""Does not dispatch when message is None."""
|
||||
mod = _import_module()
|
||||
send = _setup_happy_path(mod)
|
||||
|
||||
mod.handle_error_detected(branch="flow", module="cfg", message=None, error_hash="h1", count=2)
|
||||
|
||||
send.assert_not_called()
|
||||
|
||||
def test_returns_early_missing_error_hash(self) -> None:
|
||||
"""Does not dispatch when error_hash is None."""
|
||||
mod = _import_module()
|
||||
send = _setup_happy_path(mod)
|
||||
|
||||
mod.handle_error_detected(branch="flow", module="cfg", message="err", error_hash=None, count=2)
|
||||
|
||||
send.assert_not_called()
|
||||
|
||||
def test_returns_early_medic_disabled(self) -> None:
|
||||
"""Does not dispatch when medic is disabled."""
|
||||
mod = _import_module()
|
||||
send = _setup_happy_path(mod)
|
||||
mod._is_medic_enabled = MagicMock(return_value=False) # type: ignore[attr-defined]
|
||||
|
||||
mod.handle_error_detected(branch="flow", module="cfg", message="err", error_hash="h1", count=2)
|
||||
|
||||
send.assert_not_called()
|
||||
|
||||
def test_returns_early_branch_muted(self) -> None:
|
||||
"""Does not dispatch when branch is muted."""
|
||||
mod = _import_module()
|
||||
send = _setup_happy_path(mod)
|
||||
mod._is_branch_muted = MagicMock(return_value=True) # type: ignore[attr-defined]
|
||||
|
||||
mod.handle_error_detected(branch="flow", module="cfg", message="err", error_hash="h1", count=2)
|
||||
|
||||
send.assert_not_called()
|
||||
|
||||
def test_returns_early_count_below_threshold(self) -> None:
|
||||
"""Does not dispatch on first occurrence (count=1)."""
|
||||
mod = _import_module()
|
||||
send = _setup_happy_path(mod)
|
||||
|
||||
mod.handle_error_detected(branch="flow", module="cfg", message="err", error_hash="h1", count=1)
|
||||
|
||||
send.assert_not_called()
|
||||
|
||||
def test_returns_early_send_email_is_none(self) -> None:
|
||||
"""Does not dispatch when _send_email callback was never set."""
|
||||
mod = _import_module()
|
||||
_setup_happy_path(mod)
|
||||
mod._send_email = None # type: ignore[attr-defined]
|
||||
|
||||
mod.handle_error_detected(branch="flow", module="cfg", message="err", error_hash="h1", count=2)
|
||||
|
||||
def test_returns_early_devpulse_recipient(self) -> None:
|
||||
"""Does not dispatch to @devpulse (protected branch)."""
|
||||
mod = _import_module()
|
||||
send = _setup_happy_path(mod)
|
||||
mod._get_registered_emails = MagicMock(return_value={"@devpulse"}) # type: ignore[attr-defined]
|
||||
|
||||
mod.handle_error_detected(branch="devpulse", module="cfg", message="err", error_hash="h1", count=2)
|
||||
|
||||
send.assert_not_called()
|
||||
|
||||
def test_returns_early_branch_not_in_registry(self) -> None:
|
||||
"""Does not dispatch when branch email is not in the registry."""
|
||||
mod = _import_module()
|
||||
send = _setup_happy_path(mod)
|
||||
mod._get_registered_emails = MagicMock(return_value={"@api", "@drone"}) # type: ignore[attr-defined]
|
||||
|
||||
mod.handle_error_detected(branch="flow", module="cfg", message="err", error_hash="h1", count=2)
|
||||
|
||||
send.assert_not_called()
|
||||
|
||||
def test_returns_early_circuit_breaker_open(self) -> None:
|
||||
"""Does not dispatch when circuit breaker is open."""
|
||||
mod = _import_module()
|
||||
send = _setup_happy_path(mod)
|
||||
mod.circuit_breaker_allows = MagicMock(return_value=False) # type: ignore[attr-defined]
|
||||
|
||||
mod.handle_error_detected(
|
||||
branch="flow",
|
||||
module="cfg",
|
||||
message="err",
|
||||
error_hash="h1",
|
||||
count=2,
|
||||
fingerprint="abc123",
|
||||
)
|
||||
|
||||
send.assert_not_called()
|
||||
|
||||
def test_returns_early_should_dispatch_false(self) -> None:
|
||||
"""Does not dispatch when per-fingerprint backoff rejects."""
|
||||
mod = _import_module()
|
||||
send = _setup_happy_path(mod)
|
||||
mod.registry_should_dispatch = MagicMock(return_value=False) # type: ignore[attr-defined]
|
||||
|
||||
mod.handle_error_detected(
|
||||
branch="flow",
|
||||
module="cfg",
|
||||
message="err",
|
||||
error_hash="h1",
|
||||
count=2,
|
||||
fingerprint="abc123",
|
||||
)
|
||||
|
||||
send.assert_not_called()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# handle_error_detected -- happy path
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestHandleErrorDetectedHappyPath:
|
||||
"""Tests for successful dispatch through handle_error_detected."""
|
||||
|
||||
def test_sends_email_with_correct_args(self) -> None:
|
||||
"""Dispatches email to the correct recipient with auto_execute."""
|
||||
mod = _import_module()
|
||||
send = _setup_happy_path(mod)
|
||||
|
||||
mod.handle_error_detected(
|
||||
branch="flow",
|
||||
module="config",
|
||||
message="NullPointerError",
|
||||
error_hash="h1",
|
||||
count=2,
|
||||
fingerprint="fp123",
|
||||
timestamp="2026-04-25 10:00:00",
|
||||
)
|
||||
|
||||
send.assert_called_once()
|
||||
kwargs = send.call_args[1]
|
||||
assert kwargs["to_branch"] == "@flow"
|
||||
assert kwargs["auto_execute"] is True
|
||||
assert kwargs["reply_to"] == "@devpulse"
|
||||
assert kwargs["from_branch"] == "@trigger"
|
||||
|
||||
def test_records_dispatch_after_send(self) -> None:
|
||||
"""Calls registry_record_dispatch with the fingerprint after sending."""
|
||||
mod = _import_module()
|
||||
_setup_happy_path(mod)
|
||||
|
||||
mod.handle_error_detected(
|
||||
branch="flow",
|
||||
module="cfg",
|
||||
message="err",
|
||||
error_hash="h1",
|
||||
count=2,
|
||||
fingerprint="fp456",
|
||||
)
|
||||
|
||||
mod.registry_record_dispatch.assert_called_once_with("fp456") # type: ignore[attr-defined]
|
||||
|
||||
def test_logs_dispatch_sent(self) -> None:
|
||||
"""Logs dispatch_sent via json_handler after successful send."""
|
||||
mod = _import_module()
|
||||
_setup_happy_path(mod)
|
||||
from aipass.trigger.apps.handlers.json import json_handler
|
||||
|
||||
json_handler.log_operation.reset_mock() # type: ignore[union-attr]
|
||||
|
||||
mod.handle_error_detected(
|
||||
branch="flow",
|
||||
module="cfg",
|
||||
message="err",
|
||||
error_hash="h1",
|
||||
count=2,
|
||||
fingerprint="fp789",
|
||||
)
|
||||
|
||||
json_handler.log_operation.assert_called_once_with( # type: ignore[union-attr]
|
||||
"dispatch_sent", {"recipient": "@flow"}
|
||||
)
|
||||
|
||||
def test_handles_send_exception_gracefully(self) -> None:
|
||||
"""Does not raise when _send_email throws."""
|
||||
mod = _import_module()
|
||||
send = _setup_happy_path(mod)
|
||||
send.side_effect = RuntimeError("SMTP down")
|
||||
|
||||
mod.handle_error_detected(
|
||||
branch="flow",
|
||||
module="cfg",
|
||||
message="err",
|
||||
error_hash="h1",
|
||||
count=2,
|
||||
fingerprint="fpX",
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Fallback stubs (when error_registry import fails)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestFallbackStubs:
|
||||
"""Tests for fallback functions defined when error_registry is unavailable."""
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _force_registry_unavailable(self, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""Set error_registry to None so the ImportError fallback triggers."""
|
||||
monkeypatch.setitem(sys.modules, "aipass.trigger.apps.handlers.error_registry", None)
|
||||
monkeypatch.delitem(
|
||||
sys.modules,
|
||||
"aipass.trigger.apps.handlers.events.error_detected",
|
||||
raising=False,
|
||||
)
|
||||
|
||||
def test_registry_should_dispatch_returns_true(self) -> None:
|
||||
"""Fallback always allows dispatch for any fingerprint."""
|
||||
mod = _import_module()
|
||||
assert mod.registry_should_dispatch("any-fingerprint") is True
|
||||
|
||||
def test_registry_record_dispatch_does_not_raise(self) -> None:
|
||||
"""Fallback record_dispatch is a no-op."""
|
||||
mod = _import_module()
|
||||
mod.registry_record_dispatch("any-fingerprint")
|
||||
|
||||
def test_circuit_breaker_allows_returns_true(self) -> None:
|
||||
"""Fallback circuit breaker always allows."""
|
||||
mod = _import_module()
|
||||
assert mod.circuit_breaker_allows() is True
|
||||
|
||||
def test_circuit_breaker_record_error_does_not_raise(self) -> None:
|
||||
"""Fallback circuit_breaker_record_error is a no-op."""
|
||||
mod = _import_module()
|
||||
mod.circuit_breaker_record_error()
|
||||
|
||||
def test_registry_dispatch_available_is_false(self) -> None:
|
||||
"""Module reports registry dispatch as unavailable."""
|
||||
mod = _import_module()
|
||||
assert mod._REGISTRY_DISPATCH_AVAILABLE is False
|
||||
@@ -0,0 +1,441 @@
|
||||
# =================== AIPass ====================
|
||||
# Name: test_event_handlers.py
|
||||
# Description: Tests for simple event handler functions
|
||||
# Version: 1.0.0
|
||||
# Created: 2026-04-25
|
||||
# Modified: 2026-04-25
|
||||
# =============================================
|
||||
|
||||
"""Tests for cli, error_logged, memory, memory_template_updated, warning_logged, and bulletin_created event handlers."""
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _mock_infrastructure(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
|
||||
"""Mock heavy infrastructure imports for all six handler modules."""
|
||||
from aipass.trigger.apps.config import atomic_write_json
|
||||
|
||||
mock_config = MagicMock()
|
||||
mock_config.TRIGGER_ROOT = tmp_path
|
||||
mock_config.AIPASS_PKG_ROOT = tmp_path / "aipass"
|
||||
mock_config.atomic_write_json = atomic_write_json
|
||||
monkeypatch.setitem(sys.modules, "aipass.trigger.apps.config", mock_config)
|
||||
|
||||
mock_json_handler = MagicMock()
|
||||
mock_json_handler.log_operation = MagicMock(return_value=True)
|
||||
json_pkg = MagicMock()
|
||||
json_pkg.json_handler = mock_json_handler
|
||||
monkeypatch.setitem(sys.modules, "aipass.trigger.apps.handlers.json", json_pkg)
|
||||
monkeypatch.setitem(
|
||||
sys.modules,
|
||||
"aipass.trigger.apps.handlers.json.json_handler",
|
||||
mock_json_handler,
|
||||
)
|
||||
|
||||
for mod_name in (
|
||||
"aipass.trigger.apps.handlers.events.cli",
|
||||
"aipass.trigger.apps.handlers.events.error_logged",
|
||||
"aipass.trigger.apps.handlers.events.memory",
|
||||
"aipass.trigger.apps.handlers.events.memory_template_updated",
|
||||
"aipass.trigger.apps.handlers.events.warning_logged",
|
||||
"aipass.trigger.apps.handlers.events.bulletin_created",
|
||||
):
|
||||
monkeypatch.delitem(sys.modules, mod_name, raising=False)
|
||||
|
||||
|
||||
def _import_cli():
|
||||
"""Import cli handler module fresh after mocking."""
|
||||
import aipass.trigger.apps.handlers.events.cli as m
|
||||
|
||||
return m
|
||||
|
||||
|
||||
def _import_error_logged():
|
||||
"""Import error_logged handler module fresh after mocking."""
|
||||
import aipass.trigger.apps.handlers.events.error_logged as m
|
||||
|
||||
return m
|
||||
|
||||
|
||||
def _import_memory():
|
||||
"""Import memory handler module fresh after mocking."""
|
||||
import aipass.trigger.apps.handlers.events.memory as m
|
||||
|
||||
return m
|
||||
|
||||
|
||||
def _import_memory_template_updated():
|
||||
"""Import memory_template_updated handler module fresh after mocking."""
|
||||
import aipass.trigger.apps.handlers.events.memory_template_updated as m
|
||||
|
||||
return m
|
||||
|
||||
|
||||
def _import_warning_logged():
|
||||
"""Import warning_logged handler module fresh after mocking."""
|
||||
import aipass.trigger.apps.handlers.events.warning_logged as m
|
||||
|
||||
return m
|
||||
|
||||
|
||||
def _import_bulletin():
|
||||
"""Import bulletin_created handler module fresh after mocking."""
|
||||
import aipass.trigger.apps.handlers.events.bulletin_created as m
|
||||
|
||||
return m
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# cli.py -- handle_cli_header_displayed
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestHandleCliHeaderDisplayed:
|
||||
"""Tests for handle_cli_header_displayed from cli.py."""
|
||||
|
||||
def test_calls_log_operation(self) -> None:
|
||||
"""Logs cli_event via json_handler."""
|
||||
mod = _import_cli()
|
||||
from aipass.trigger.apps.handlers.json import json_handler
|
||||
|
||||
json_handler.log_operation.reset_mock() # type: ignore[union-attr]
|
||||
|
||||
mod.handle_cli_header_displayed()
|
||||
|
||||
json_handler.log_operation.assert_called_once_with( # type: ignore[union-attr]
|
||||
"cli_event", {"success": True}
|
||||
)
|
||||
|
||||
def test_accepts_arbitrary_kwargs(self) -> None:
|
||||
"""Does not crash when extra kwargs are passed."""
|
||||
mod = _import_cli()
|
||||
mod.handle_cli_header_displayed(foo="bar", baz=42)
|
||||
|
||||
def test_returns_none(self) -> None:
|
||||
"""Handler returns None (handlers must not return values)."""
|
||||
mod = _import_cli()
|
||||
result = mod.handle_cli_header_displayed()
|
||||
assert result is None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# error_logged.py -- handle_error_logged
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestHandleErrorLogged:
|
||||
"""Tests for handle_error_logged from error_logged.py."""
|
||||
|
||||
def test_happy_path_logs_operation(self) -> None:
|
||||
"""Logs error_logged_event with branch, module, and error_hash."""
|
||||
mod = _import_error_logged()
|
||||
from aipass.trigger.apps.handlers.json import json_handler
|
||||
|
||||
json_handler.log_operation.reset_mock() # type: ignore[union-attr]
|
||||
|
||||
mod.handle_error_logged(branch="flow", message="kaboom", error_hash="abc123")
|
||||
|
||||
json_handler.log_operation.assert_called_once_with( # type: ignore[union-attr]
|
||||
"error_logged_event",
|
||||
{"branch": "flow", "module": "unknown", "error_hash": "abc123"},
|
||||
)
|
||||
|
||||
def test_returns_early_missing_branch(self) -> None:
|
||||
"""Does not log when branch is None."""
|
||||
mod = _import_error_logged()
|
||||
from aipass.trigger.apps.handlers.json import json_handler
|
||||
|
||||
json_handler.log_operation.reset_mock() # type: ignore[union-attr]
|
||||
|
||||
mod.handle_error_logged(branch=None, message="msg", error_hash="h")
|
||||
|
||||
json_handler.log_operation.assert_not_called() # type: ignore[union-attr]
|
||||
|
||||
def test_returns_early_missing_message(self) -> None:
|
||||
"""Does not log when message is None."""
|
||||
mod = _import_error_logged()
|
||||
from aipass.trigger.apps.handlers.json import json_handler
|
||||
|
||||
json_handler.log_operation.reset_mock() # type: ignore[union-attr]
|
||||
|
||||
mod.handle_error_logged(branch="flow", message=None, error_hash="h")
|
||||
|
||||
json_handler.log_operation.assert_not_called() # type: ignore[union-attr]
|
||||
|
||||
def test_returns_early_missing_error_hash(self) -> None:
|
||||
"""Does not log when error_hash is None."""
|
||||
mod = _import_error_logged()
|
||||
from aipass.trigger.apps.handlers.json import json_handler
|
||||
|
||||
json_handler.log_operation.reset_mock() # type: ignore[union-attr]
|
||||
|
||||
mod.handle_error_logged(branch="flow", message="msg", error_hash=None)
|
||||
|
||||
json_handler.log_operation.assert_not_called() # type: ignore[union-attr]
|
||||
|
||||
def test_returns_early_empty_branch(self) -> None:
|
||||
"""Does not log when branch is empty string (falsy)."""
|
||||
mod = _import_error_logged()
|
||||
from aipass.trigger.apps.handlers.json import json_handler
|
||||
|
||||
json_handler.log_operation.reset_mock() # type: ignore[union-attr]
|
||||
|
||||
mod.handle_error_logged(branch="", message="msg", error_hash="h")
|
||||
|
||||
json_handler.log_operation.assert_not_called() # type: ignore[union-attr]
|
||||
|
||||
def test_prefers_source_module_over_module_name(self) -> None:
|
||||
"""Uses source_module when both source_module and module_name are given."""
|
||||
mod = _import_error_logged()
|
||||
from aipass.trigger.apps.handlers.json import json_handler
|
||||
|
||||
json_handler.log_operation.reset_mock() # type: ignore[union-attr]
|
||||
|
||||
mod.handle_error_logged(
|
||||
branch="api",
|
||||
message="err",
|
||||
error_hash="xyz",
|
||||
source_module="config.py",
|
||||
module_name="old_name.py",
|
||||
)
|
||||
|
||||
json_handler.log_operation.assert_called_once_with( # type: ignore[union-attr]
|
||||
"error_logged_event",
|
||||
{"branch": "api", "module": "config.py", "error_hash": "xyz"},
|
||||
)
|
||||
|
||||
def test_falls_back_to_module_name(self) -> None:
|
||||
"""Uses module_name when source_module is not provided."""
|
||||
mod = _import_error_logged()
|
||||
from aipass.trigger.apps.handlers.json import json_handler
|
||||
|
||||
json_handler.log_operation.reset_mock() # type: ignore[union-attr]
|
||||
|
||||
mod.handle_error_logged(
|
||||
branch="spawn",
|
||||
message="err",
|
||||
error_hash="h1",
|
||||
module_name="fallback.py",
|
||||
)
|
||||
|
||||
json_handler.log_operation.assert_called_once_with( # type: ignore[union-attr]
|
||||
"error_logged_event",
|
||||
{"branch": "spawn", "module": "fallback.py", "error_hash": "h1"},
|
||||
)
|
||||
|
||||
def test_falls_back_to_unknown(self) -> None:
|
||||
"""Uses 'unknown' when neither source_module nor module_name given."""
|
||||
mod = _import_error_logged()
|
||||
from aipass.trigger.apps.handlers.json import json_handler
|
||||
|
||||
json_handler.log_operation.reset_mock() # type: ignore[union-attr]
|
||||
|
||||
mod.handle_error_logged(branch="drone", message="oops", error_hash="h2")
|
||||
|
||||
json_handler.log_operation.assert_called_once_with( # type: ignore[union-attr]
|
||||
"error_logged_event",
|
||||
{"branch": "drone", "module": "unknown", "error_hash": "h2"},
|
||||
)
|
||||
|
||||
def test_exception_does_not_raise(self) -> None:
|
||||
"""Catches exception from log_operation without propagating."""
|
||||
mod = _import_error_logged()
|
||||
from aipass.trigger.apps.handlers.json import json_handler
|
||||
|
||||
json_handler.log_operation.side_effect = RuntimeError("boom") # type: ignore[union-attr]
|
||||
|
||||
mod.handle_error_logged(branch="flow", message="msg", error_hash="h3")
|
||||
|
||||
json_handler.log_operation.side_effect = None # type: ignore[union-attr]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# memory.py -- handle_memory_saved
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestHandleMemorySaved:
|
||||
"""Tests for handle_memory_saved from memory.py."""
|
||||
|
||||
def test_calls_log_operation(self) -> None:
|
||||
"""Logs memory_event via json_handler."""
|
||||
mod = _import_memory()
|
||||
from aipass.trigger.apps.handlers.json import json_handler
|
||||
|
||||
json_handler.log_operation.reset_mock() # type: ignore[union-attr]
|
||||
|
||||
mod.handle_memory_saved()
|
||||
|
||||
json_handler.log_operation.assert_called_once_with( # type: ignore[union-attr]
|
||||
"memory_event", {"success": True}
|
||||
)
|
||||
|
||||
def test_accepts_kwargs(self) -> None:
|
||||
"""Does not crash when event data kwargs are passed."""
|
||||
mod = _import_memory()
|
||||
mod.handle_memory_saved(branch="flow", lines=150)
|
||||
|
||||
def test_returns_none(self) -> None:
|
||||
"""Handler returns None."""
|
||||
mod = _import_memory()
|
||||
result = mod.handle_memory_saved()
|
||||
assert result is None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# memory_template_updated.py -- handle_memory_template_updated
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestHandleMemoryTemplateUpdated:
|
||||
"""Tests for handle_memory_template_updated from memory_template_updated.py."""
|
||||
|
||||
def test_calls_log_operation(self) -> None:
|
||||
"""Logs memory_template_event via json_handler."""
|
||||
mod = _import_memory_template_updated()
|
||||
from aipass.trigger.apps.handlers.json import json_handler
|
||||
|
||||
json_handler.log_operation.reset_mock() # type: ignore[union-attr]
|
||||
|
||||
mod.handle_memory_template_updated()
|
||||
|
||||
json_handler.log_operation.assert_called_once_with( # type: ignore[union-attr]
|
||||
"memory_template_event", {"success": True}
|
||||
)
|
||||
|
||||
def test_accepts_kwargs(self) -> None:
|
||||
"""Does not crash when event data kwargs are passed."""
|
||||
mod = _import_memory_template_updated()
|
||||
mod.handle_memory_template_updated(template_name="local", updated_by="drone")
|
||||
|
||||
def test_returns_none(self) -> None:
|
||||
"""Handler returns None."""
|
||||
mod = _import_memory_template_updated()
|
||||
result = mod.handle_memory_template_updated()
|
||||
assert result is None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# warning_logged.py -- handle_warning_logged
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestHandleWarningLogged:
|
||||
"""Tests for handle_warning_logged from warning_logged.py."""
|
||||
|
||||
def test_calls_log_operation(self) -> None:
|
||||
"""Logs warning_logged_event via json_handler."""
|
||||
mod = _import_warning_logged()
|
||||
from aipass.trigger.apps.handlers.json import json_handler
|
||||
|
||||
json_handler.log_operation.reset_mock() # type: ignore[union-attr]
|
||||
|
||||
mod.handle_warning_logged()
|
||||
|
||||
json_handler.log_operation.assert_called_once_with( # type: ignore[union-attr]
|
||||
"warning_logged_event", {"success": True}
|
||||
)
|
||||
|
||||
def test_accepts_all_named_params(self) -> None:
|
||||
"""Accepts all documented event parameters without error."""
|
||||
mod = _import_warning_logged()
|
||||
mod.handle_warning_logged(
|
||||
branch="flow",
|
||||
message="disk almost full",
|
||||
error_hash="w1",
|
||||
timestamp="2026-04-25T12:00:00",
|
||||
log_file="flow.log",
|
||||
module_name="watcher",
|
||||
level="warning",
|
||||
)
|
||||
|
||||
def test_does_not_crash_with_none_params(self) -> None:
|
||||
"""Handles None for every named parameter gracefully."""
|
||||
mod = _import_warning_logged()
|
||||
mod.handle_warning_logged(
|
||||
branch=None,
|
||||
message=None,
|
||||
error_hash=None,
|
||||
timestamp=None,
|
||||
log_file=None,
|
||||
module_name=None,
|
||||
level=None,
|
||||
)
|
||||
|
||||
def test_accepts_extra_kwargs(self) -> None:
|
||||
"""Accepts unexpected kwargs via **kwargs."""
|
||||
mod = _import_warning_logged()
|
||||
mod.handle_warning_logged(extra_field="unexpected")
|
||||
|
||||
def test_returns_none(self) -> None:
|
||||
"""Handler returns None."""
|
||||
mod = _import_warning_logged()
|
||||
result = mod.handle_warning_logged()
|
||||
assert result is None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# bulletin_created.py -- handle_bulletin_created
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestHandleBulletinCreated:
|
||||
"""Tests for handle_bulletin_created from bulletin_created.py."""
|
||||
|
||||
def test_does_not_raise_when_files_missing(self) -> None:
|
||||
"""Silently handles missing registry and bulletins files."""
|
||||
mod = _import_bulletin()
|
||||
mod.handle_bulletin_created()
|
||||
|
||||
def test_logs_operation_on_success(self) -> None:
|
||||
"""Logs bulletin_event after successful propagation."""
|
||||
mod = _import_bulletin()
|
||||
mod._propagate_bulletins_to_branches = MagicMock()
|
||||
from aipass.trigger.apps.handlers.json import json_handler
|
||||
|
||||
json_handler.log_operation.reset_mock() # type: ignore[union-attr]
|
||||
|
||||
mod.handle_bulletin_created()
|
||||
|
||||
mod._propagate_bulletins_to_branches.assert_called_once()
|
||||
json_handler.log_operation.assert_called_once_with( # type: ignore[union-attr]
|
||||
"bulletin_event", {"success": True}
|
||||
)
|
||||
|
||||
def test_catches_propagation_exception(self) -> None:
|
||||
"""Does not log operation when propagation raises."""
|
||||
mod = _import_bulletin()
|
||||
mod._propagate_bulletins_to_branches = MagicMock(side_effect=RuntimeError("propagation failed"))
|
||||
from aipass.trigger.apps.handlers.json import json_handler
|
||||
|
||||
json_handler.log_operation.reset_mock() # type: ignore[union-attr]
|
||||
|
||||
mod.handle_bulletin_created()
|
||||
|
||||
json_handler.log_operation.assert_not_called() # type: ignore[union-attr]
|
||||
|
||||
def test_accepts_all_params(self) -> None:
|
||||
"""Accepts all documented event parameters without error."""
|
||||
mod = _import_bulletin()
|
||||
mod._propagate_bulletins_to_branches = MagicMock()
|
||||
|
||||
mod.handle_bulletin_created(
|
||||
_bulletin_id="b1",
|
||||
_title="System update",
|
||||
_message="Scheduled maintenance",
|
||||
_priority="high",
|
||||
_created_by="devpulse",
|
||||
_timestamp="2026-04-25T10:00:00",
|
||||
)
|
||||
|
||||
def test_returns_none(self) -> None:
|
||||
"""Handler returns None."""
|
||||
mod = _import_bulletin()
|
||||
mod._propagate_bulletins_to_branches = MagicMock()
|
||||
result = mod.handle_bulletin_created()
|
||||
assert result is None
|
||||
@@ -33,7 +33,10 @@ def json_handler(tmp_path, monkeypatch):
|
||||
|
||||
|
||||
class TestDefaultFactory:
|
||||
"""Tests for _get_default_template factory."""
|
||||
|
||||
def test_config_template_has_required_keys(self, json_handler):
|
||||
"""Config template contains module_name, version, and config keys."""
|
||||
result = json_handler._get_default_template("config", "test_mod")
|
||||
assert isinstance(result, dict)
|
||||
assert "module_name" in result
|
||||
@@ -42,17 +45,20 @@ class TestDefaultFactory:
|
||||
assert result["module_name"] == "test_mod"
|
||||
|
||||
def test_data_template_has_required_keys(self, json_handler):
|
||||
"""Data template contains created and last_updated keys."""
|
||||
result = json_handler._get_default_template("data", "test_mod")
|
||||
assert isinstance(result, dict)
|
||||
assert "created" in result
|
||||
assert "last_updated" in result
|
||||
|
||||
def test_log_template_returns_list(self, json_handler):
|
||||
"""Log template returns an empty list."""
|
||||
result = json_handler._get_default_template("log", "test_mod")
|
||||
assert isinstance(result, list)
|
||||
assert len(result) == 0
|
||||
|
||||
def test_unknown_type_raises(self, json_handler):
|
||||
"""Unknown json_type raises ValueError."""
|
||||
with pytest.raises(ValueError, match="Unknown json_type"):
|
||||
json_handler._get_default_template("bogus", "test_mod")
|
||||
|
||||
@@ -63,23 +69,31 @@ class TestDefaultFactory:
|
||||
|
||||
|
||||
class TestValidate:
|
||||
"""Tests for validate_json_structure."""
|
||||
|
||||
def test_valid_config(self, json_handler):
|
||||
"""Valid config dict with all required keys passes validation."""
|
||||
data = {"module_name": "x", "version": "1.0", "config": {}}
|
||||
assert json_handler.validate_json_structure(data, "config") is True
|
||||
|
||||
def test_invalid_config_missing_key(self, json_handler):
|
||||
"""Config dict missing required keys fails validation."""
|
||||
assert json_handler.validate_json_structure({"module_name": "x"}, "config") is False
|
||||
|
||||
def test_config_non_dict(self, json_handler):
|
||||
"""Non-dict input fails config validation."""
|
||||
assert json_handler.validate_json_structure([], "config") is False
|
||||
|
||||
def test_valid_data(self, json_handler):
|
||||
"""Data dict with created and last_updated passes validation."""
|
||||
assert json_handler.validate_json_structure({"created": "x", "last_updated": "y"}, "data") is True
|
||||
|
||||
def test_valid_log(self, json_handler):
|
||||
"""List passes log validation."""
|
||||
assert json_handler.validate_json_structure([], "log") is True
|
||||
|
||||
def test_unknown_type(self, json_handler):
|
||||
"""Unknown json_type returns False."""
|
||||
assert json_handler.validate_json_structure({}, "bogus") is False
|
||||
|
||||
|
||||
@@ -89,11 +103,15 @@ class TestValidate:
|
||||
|
||||
|
||||
class TestGetPath:
|
||||
"""Tests for get_json_path."""
|
||||
|
||||
def test_returns_path_object(self, json_handler):
|
||||
"""Return type is a Path instance."""
|
||||
result = json_handler.get_json_path("mymod", "config")
|
||||
assert isinstance(result, Path)
|
||||
|
||||
def test_path_contains_module_and_type(self, json_handler):
|
||||
"""Filename encodes module name and json type."""
|
||||
result = json_handler.get_json_path("mymod", "data")
|
||||
assert result.name == "mymod_data.json"
|
||||
|
||||
@@ -109,7 +127,10 @@ class TestGetPath:
|
||||
|
||||
|
||||
class TestEnsureExists:
|
||||
"""Tests for ensure_json_exists."""
|
||||
|
||||
def test_creates_config_file(self, json_handler, tmp_path):
|
||||
"""Creates a config JSON file on disk."""
|
||||
assert json_handler.ensure_json_exists("newmod", "config") is True
|
||||
path = tmp_path / "newmod_config.json"
|
||||
assert path.exists()
|
||||
@@ -146,12 +167,16 @@ class TestEnsureExists:
|
||||
|
||||
|
||||
class TestLoad:
|
||||
"""Tests for load_json."""
|
||||
|
||||
def test_load_auto_creates_and_returns(self, json_handler):
|
||||
"""Auto-creates missing file and returns default template."""
|
||||
result = json_handler.load_json("loadtest", "config")
|
||||
assert isinstance(result, dict)
|
||||
assert result["module_name"] == "loadtest"
|
||||
|
||||
def test_load_log_returns_list(self, json_handler):
|
||||
"""Log type returns a list."""
|
||||
result = json_handler.load_json("loadtest", "log")
|
||||
assert isinstance(result, list)
|
||||
|
||||
@@ -170,7 +195,10 @@ class TestLoad:
|
||||
|
||||
|
||||
class TestSave:
|
||||
"""Tests for save_json."""
|
||||
|
||||
def test_save_valid_data(self, json_handler, tmp_path):
|
||||
"""Saves valid data dict to disk."""
|
||||
json_handler.ensure_json_exists("smod", "data")
|
||||
data = {"created": "2026-01-01", "last_updated": "2026-01-01", "extra": 42}
|
||||
assert json_handler.save_json("smod", "data", data) is True
|
||||
@@ -194,13 +222,17 @@ class TestSave:
|
||||
|
||||
|
||||
class TestEnsureModule:
|
||||
"""Tests for ensure_module_jsons."""
|
||||
|
||||
def test_creates_all_three(self, json_handler, tmp_path):
|
||||
"""Creates config, data, and log JSON files."""
|
||||
json_handler.ensure_module_jsons("trio")
|
||||
assert (tmp_path / "trio_config.json").exists()
|
||||
assert (tmp_path / "trio_data.json").exists()
|
||||
assert (tmp_path / "trio_log.json").exists()
|
||||
|
||||
def test_returns_true(self, json_handler):
|
||||
"""Returns True on success."""
|
||||
assert json_handler.ensure_module_jsons("rt") is True
|
||||
|
||||
|
||||
@@ -210,7 +242,10 @@ class TestEnsureModule:
|
||||
|
||||
|
||||
class TestLogOperation:
|
||||
"""Tests for log_operation."""
|
||||
|
||||
def test_log_operation_appends_entry(self, json_handler, tmp_path):
|
||||
"""Appends a log entry with operation name."""
|
||||
json_handler.log_operation("test_op", {"key": "val"}, module_name="logmod")
|
||||
log = json.loads((tmp_path / "logmod_log.json").read_text())
|
||||
assert len(log) >= 1
|
||||
@@ -240,3 +275,75 @@ class TestLogOperation:
|
||||
monkeypatch.setattr(mod, "TRIGGER_JSON_DIR", new_dir)
|
||||
mod.ensure_json_exists("reimp", "config")
|
||||
assert (new_dir / "reimp_config.json").exists()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# increment_counter
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestIncrementCounter:
|
||||
"""Tests for increment_counter."""
|
||||
|
||||
def test_creates_and_increments_new_counter(self, json_handler, tmp_path):
|
||||
"""Counter starts at 0, gets incremented to 1."""
|
||||
json_handler.increment_counter("incmod", "hits")
|
||||
data = json.loads((tmp_path / "incmod_data.json").read_text(encoding="utf-8"))
|
||||
assert data["hits"] == 1
|
||||
|
||||
def test_increments_existing_counter(self, json_handler, tmp_path):
|
||||
"""Pre-set counter at 5, increment brings it to 6."""
|
||||
json_handler.ensure_module_jsons("incmod2")
|
||||
data = json_handler.load_json("incmod2", "data")
|
||||
data["visits"] = 5
|
||||
json_handler.save_json("incmod2", "data", data)
|
||||
|
||||
json_handler.increment_counter("incmod2", "visits")
|
||||
reloaded = json.loads((tmp_path / "incmod2_data.json").read_text(encoding="utf-8"))
|
||||
assert reloaded["visits"] == 6
|
||||
|
||||
def test_custom_amount(self, json_handler, tmp_path):
|
||||
"""Increment by 10."""
|
||||
json_handler.increment_counter("incmod3", "score", amount=10)
|
||||
data = json.loads((tmp_path / "incmod3_data.json").read_text(encoding="utf-8"))
|
||||
assert data["score"] == 10
|
||||
|
||||
def test_returns_true_on_success(self, json_handler):
|
||||
"""Return value is True on success."""
|
||||
result = json_handler.increment_counter("incmod4", "counter")
|
||||
assert result is True
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# update_data_metrics
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestUpdateDataMetrics:
|
||||
"""Tests for update_data_metrics."""
|
||||
|
||||
def test_sets_single_metric(self, json_handler, tmp_path):
|
||||
"""Update one key."""
|
||||
json_handler.update_data_metrics("metmod", uptime=99.5)
|
||||
data = json.loads((tmp_path / "metmod_data.json").read_text(encoding="utf-8"))
|
||||
assert data["uptime"] == 99.5
|
||||
|
||||
def test_sets_multiple_metrics(self, json_handler, tmp_path):
|
||||
"""Update several keys at once."""
|
||||
json_handler.update_data_metrics("metmod2", cpu=0.8, mem=512, ok=True)
|
||||
data = json.loads((tmp_path / "metmod2_data.json").read_text(encoding="utf-8"))
|
||||
assert data["cpu"] == 0.8
|
||||
assert data["mem"] == 512
|
||||
assert data["ok"] is True
|
||||
|
||||
def test_overwrites_existing(self, json_handler, tmp_path):
|
||||
"""Set a key, then update it to a new value."""
|
||||
json_handler.update_data_metrics("metmod3", version="1.0")
|
||||
json_handler.update_data_metrics("metmod3", version="2.0")
|
||||
data = json.loads((tmp_path / "metmod3_data.json").read_text(encoding="utf-8"))
|
||||
assert data["version"] == "2.0"
|
||||
|
||||
def test_returns_true_on_success(self, json_handler):
|
||||
"""Return value is True on success."""
|
||||
result = json_handler.update_data_metrics("metmod4", status="ok")
|
||||
assert result is True
|
||||
|
||||
@@ -0,0 +1,205 @@
|
||||
# =================== AIPass ====================
|
||||
# Name: test_memory_threshold_handler.py
|
||||
# Description: Tests for memory_threshold_exceeded event handler
|
||||
# Version: 1.0.0
|
||||
# Created: 2026-04-25
|
||||
# Modified: 2026-04-25
|
||||
# =============================================
|
||||
|
||||
"""Tests for memory_threshold_exceeded event handler."""
|
||||
|
||||
import sys
|
||||
|
||||
import pytest
|
||||
from unittest.mock import MagicMock
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _mock_infrastructure(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
|
||||
"""Mock heavy infrastructure imports before importing the handler module."""
|
||||
from aipass.trigger.apps.config import atomic_write_json
|
||||
|
||||
mock_config = MagicMock()
|
||||
mock_config.TRIGGER_ROOT = tmp_path
|
||||
mock_config.atomic_write_json = atomic_write_json
|
||||
monkeypatch.setitem(sys.modules, "aipass.trigger.apps.config", mock_config)
|
||||
|
||||
mock_json_handler = MagicMock()
|
||||
mock_json_handler.log_operation = MagicMock(return_value=True)
|
||||
json_pkg = MagicMock()
|
||||
json_pkg.json_handler = mock_json_handler
|
||||
monkeypatch.setitem(sys.modules, "aipass.trigger.apps.handlers.json", json_pkg)
|
||||
monkeypatch.setitem(
|
||||
sys.modules,
|
||||
"aipass.trigger.apps.handlers.json.json_handler",
|
||||
mock_json_handler,
|
||||
)
|
||||
|
||||
# Mock ai_mail chain so the handler can import deliver_email_to_branch
|
||||
mock_email_send = MagicMock()
|
||||
mock_email_send.deliver_email_to_branch = MagicMock()
|
||||
monkeypatch.setitem(sys.modules, "aipass.ai_mail", MagicMock())
|
||||
monkeypatch.setitem(sys.modules, "aipass.ai_mail.apps", MagicMock())
|
||||
monkeypatch.setitem(sys.modules, "aipass.ai_mail.apps.modules", MagicMock())
|
||||
monkeypatch.setitem(sys.modules, "aipass.ai_mail.apps.modules.email_send", mock_email_send)
|
||||
|
||||
monkeypatch.delitem(
|
||||
sys.modules,
|
||||
"aipass.trigger.apps.handlers.events.memory_threshold_exceeded",
|
||||
raising=False,
|
||||
)
|
||||
|
||||
|
||||
def _import_memory_threshold():
|
||||
"""Import fresh after mocking."""
|
||||
import aipass.trigger.apps.handlers.events.memory_threshold_exceeded as m
|
||||
|
||||
return m
|
||||
|
||||
|
||||
class TestHandleMemoryThresholdExceeded:
|
||||
"""Tests for handle_memory_threshold_exceeded."""
|
||||
|
||||
def test_returns_early_when_branch_missing(self) -> None:
|
||||
"""None branch skips email delivery."""
|
||||
mod = _import_memory_threshold()
|
||||
|
||||
from aipass.ai_mail.apps.modules.email_send import deliver_email_to_branch
|
||||
|
||||
deliver_email_to_branch.reset_mock() # type: ignore[union-attr]
|
||||
|
||||
mod.handle_memory_threshold_exceeded(branch=None, file_name="local.json", line_count=700)
|
||||
|
||||
deliver_email_to_branch.assert_not_called() # type: ignore[union-attr]
|
||||
|
||||
def test_returns_early_when_file_name_missing(self) -> None:
|
||||
"""None file_name skips email delivery."""
|
||||
mod = _import_memory_threshold()
|
||||
|
||||
from aipass.ai_mail.apps.modules.email_send import deliver_email_to_branch
|
||||
|
||||
deliver_email_to_branch.reset_mock() # type: ignore[union-attr]
|
||||
|
||||
mod.handle_memory_threshold_exceeded(branch="flow", file_name=None, line_count=700)
|
||||
|
||||
deliver_email_to_branch.assert_not_called() # type: ignore[union-attr]
|
||||
|
||||
def test_returns_early_when_line_count_is_none(self) -> None:
|
||||
"""None line_count skips email delivery."""
|
||||
mod = _import_memory_threshold()
|
||||
|
||||
from aipass.ai_mail.apps.modules.email_send import deliver_email_to_branch
|
||||
|
||||
deliver_email_to_branch.reset_mock() # type: ignore[union-attr]
|
||||
|
||||
mod.handle_memory_threshold_exceeded(branch="flow", file_name="local.json", line_count=None)
|
||||
|
||||
deliver_email_to_branch.assert_not_called() # type: ignore[union-attr]
|
||||
|
||||
def test_returns_early_when_ai_mail_import_fails(self, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""No operation is logged when ai_mail import fails."""
|
||||
# Setting the module to None causes ImportError on `from ... import`
|
||||
monkeypatch.setitem(sys.modules, "aipass.ai_mail.apps.modules.email_send", None)
|
||||
|
||||
mod = _import_memory_threshold()
|
||||
mod.handle_memory_threshold_exceeded(branch="flow", file_name="local.json", line_count=700)
|
||||
|
||||
from aipass.trigger.apps.handlers.json import json_handler
|
||||
|
||||
json_handler.log_operation.assert_not_called() # type: ignore[union-attr]
|
||||
|
||||
def test_happy_path_sends_email(self) -> None:
|
||||
"""Sends email with correct target and email data on valid input."""
|
||||
mod = _import_memory_threshold()
|
||||
|
||||
from aipass.ai_mail.apps.modules.email_send import deliver_email_to_branch
|
||||
|
||||
deliver_email_to_branch.reset_mock() # type: ignore[union-attr]
|
||||
|
||||
mod.handle_memory_threshold_exceeded(
|
||||
branch="flow",
|
||||
file_name="local.json",
|
||||
line_count=700,
|
||||
threshold=600,
|
||||
timestamp="2026-04-25 12:00:00",
|
||||
)
|
||||
|
||||
deliver_email_to_branch.assert_called_once() # type: ignore[union-attr]
|
||||
target, email_data = deliver_email_to_branch.call_args[0] # type: ignore[union-attr]
|
||||
assert target == "@flow"
|
||||
assert email_data["to"] == "@flow"
|
||||
assert email_data["from"] == "@trigger"
|
||||
assert "local.json" in email_data["subject"]
|
||||
assert "600" in email_data["subject"]
|
||||
assert email_data["timestamp"] == "2026-04-25 12:00:00"
|
||||
|
||||
def test_uses_default_threshold_when_not_provided(self) -> None:
|
||||
"""Falls back to default threshold of 600 when not explicitly given."""
|
||||
mod = _import_memory_threshold()
|
||||
|
||||
from aipass.ai_mail.apps.modules.email_send import deliver_email_to_branch
|
||||
|
||||
deliver_email_to_branch.reset_mock() # type: ignore[union-attr]
|
||||
|
||||
mod.handle_memory_threshold_exceeded(branch="drone", file_name="observations.json", line_count=800)
|
||||
|
||||
deliver_email_to_branch.assert_called_once() # type: ignore[union-attr]
|
||||
_, email_data = deliver_email_to_branch.call_args[0] # type: ignore[union-attr]
|
||||
assert "600" in email_data["subject"]
|
||||
|
||||
def test_uses_default_timestamp_when_not_provided(self) -> None:
|
||||
"""Generates a non-empty timestamp when none is supplied."""
|
||||
mod = _import_memory_threshold()
|
||||
|
||||
from aipass.ai_mail.apps.modules.email_send import deliver_email_to_branch
|
||||
|
||||
deliver_email_to_branch.reset_mock() # type: ignore[union-attr]
|
||||
|
||||
mod.handle_memory_threshold_exceeded(branch="api", file_name="local.json", line_count=650)
|
||||
|
||||
deliver_email_to_branch.assert_called_once() # type: ignore[union-attr]
|
||||
_, email_data = deliver_email_to_branch.call_args[0] # type: ignore[union-attr]
|
||||
assert email_data["timestamp"] is not None
|
||||
assert len(email_data["timestamp"]) > 0
|
||||
|
||||
def test_does_not_raise_on_deliver_exception(self) -> None:
|
||||
"""Delivery failure is swallowed without propagating."""
|
||||
mod = _import_memory_threshold()
|
||||
|
||||
from aipass.ai_mail.apps.modules.email_send import deliver_email_to_branch
|
||||
|
||||
deliver_email_to_branch.reset_mock() # type: ignore[union-attr]
|
||||
deliver_email_to_branch.side_effect = RuntimeError( # type: ignore[union-attr]
|
||||
"delivery failed"
|
||||
)
|
||||
|
||||
mod.handle_memory_threshold_exceeded(
|
||||
branch="flow",
|
||||
file_name="local.json",
|
||||
line_count=700,
|
||||
threshold=600,
|
||||
)
|
||||
|
||||
deliver_email_to_branch.side_effect = None # type: ignore[union-attr]
|
||||
|
||||
def test_logs_operation_on_success(self) -> None:
|
||||
"""Logs memory_threshold_event via json_handler after successful send."""
|
||||
mod = _import_memory_threshold()
|
||||
|
||||
from aipass.trigger.apps.handlers.json import json_handler
|
||||
from aipass.ai_mail.apps.modules.email_send import deliver_email_to_branch
|
||||
|
||||
json_handler.log_operation.reset_mock() # type: ignore[union-attr]
|
||||
deliver_email_to_branch.reset_mock() # type: ignore[union-attr]
|
||||
|
||||
mod.handle_memory_threshold_exceeded(
|
||||
branch="system",
|
||||
file_name="local.json",
|
||||
line_count=900,
|
||||
threshold=600,
|
||||
)
|
||||
|
||||
json_handler.log_operation.assert_called_once_with( # type: ignore[union-attr]
|
||||
"memory_threshold_event", {"success": True}
|
||||
)
|
||||
@@ -0,0 +1,374 @@
|
||||
# =================== AIPass ====================
|
||||
# Name: test_plan_file_handler.py
|
||||
# Description: Tests for plan_file event handlers (created, deleted, moved)
|
||||
# Version: 1.0.0
|
||||
# Created: 2026-04-25
|
||||
# Modified: 2026-04-25
|
||||
# =============================================
|
||||
|
||||
"""Tests for plan_file event handlers."""
|
||||
|
||||
import pytest
|
||||
from unittest.mock import MagicMock
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _mock_infrastructure(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
|
||||
"""Mock heavy infrastructure imports before importing the handler module."""
|
||||
import sys
|
||||
|
||||
from aipass.trigger.apps.config import atomic_write_json
|
||||
|
||||
mock_config = MagicMock()
|
||||
mock_config.TRIGGER_ROOT = tmp_path
|
||||
mock_config.AIPASS_PKG_ROOT = tmp_path / "aipass"
|
||||
mock_config.atomic_write_json = atomic_write_json
|
||||
monkeypatch.setitem(sys.modules, "aipass.trigger.apps.config", mock_config)
|
||||
|
||||
mock_json_handler = MagicMock()
|
||||
mock_json_handler.log_operation = MagicMock(return_value=True)
|
||||
json_pkg = MagicMock()
|
||||
json_pkg.json_handler = mock_json_handler
|
||||
monkeypatch.setitem(sys.modules, "aipass.trigger.apps.handlers.json", json_pkg)
|
||||
monkeypatch.setitem(
|
||||
sys.modules,
|
||||
"aipass.trigger.apps.handlers.json.json_handler",
|
||||
mock_json_handler,
|
||||
)
|
||||
|
||||
monkeypatch.delitem(
|
||||
sys.modules,
|
||||
"aipass.trigger.apps.handlers.events.plan_file",
|
||||
raising=False,
|
||||
)
|
||||
|
||||
|
||||
def _import_plan_file():
|
||||
"""Import fresh after mocking."""
|
||||
import aipass.trigger.apps.handlers.events.plan_file as m
|
||||
|
||||
return m
|
||||
|
||||
|
||||
class TestHandlePlanFileCreated:
|
||||
"""Tests for handle_plan_file_created."""
|
||||
|
||||
def test_new_plan_added_to_registry(self, tmp_path: Path) -> None:
|
||||
"""New FPLAN file adds entry with status open."""
|
||||
mod = _import_plan_file()
|
||||
mod._load_registry = MagicMock(return_value={"plans": {}, "next_number": 1})
|
||||
mod._save_registry = MagicMock()
|
||||
mod.REPO_ROOT = tmp_path
|
||||
|
||||
plan_path = str(tmp_path / "FPLAN-0042.md")
|
||||
mod.handle_plan_file_created(path=plan_path)
|
||||
|
||||
mod._save_registry.assert_called_once() # type: ignore[union-attr]
|
||||
saved = mod._save_registry.call_args[0][0] # type: ignore[union-attr]
|
||||
assert "0042" in saved["plans"]
|
||||
plan = saved["plans"]["0042"]
|
||||
assert plan["status"] == "open"
|
||||
assert plan["file_path"] == plan_path
|
||||
assert plan["subject"] == "Auto-detected PLAN"
|
||||
|
||||
def test_existing_open_plan_is_noop(self, tmp_path: Path) -> None:
|
||||
"""Existing open plan causes early return without saving."""
|
||||
mod = _import_plan_file()
|
||||
mod._load_registry = MagicMock(
|
||||
return_value={
|
||||
"plans": {"0042": {"status": "open", "file_path": "/old/FPLAN-0042.md"}},
|
||||
"next_number": 43,
|
||||
}
|
||||
)
|
||||
mod._save_registry = MagicMock()
|
||||
mod.REPO_ROOT = tmp_path
|
||||
|
||||
plan_path = str(tmp_path / "FPLAN-0042.md")
|
||||
mod.handle_plan_file_created(path=plan_path)
|
||||
|
||||
mod._save_registry.assert_not_called() # type: ignore[union-attr]
|
||||
|
||||
def test_existing_closed_plan_updates_location(self, tmp_path: Path) -> None:
|
||||
"""Closed plan gets location fields updated, status preserved."""
|
||||
mod = _import_plan_file()
|
||||
mod._load_registry = MagicMock(
|
||||
return_value={
|
||||
"plans": {
|
||||
"0042": {
|
||||
"status": "closed",
|
||||
"file_path": "/old/FPLAN-0042.md",
|
||||
"closed_reason": "completed",
|
||||
}
|
||||
},
|
||||
"next_number": 43,
|
||||
}
|
||||
)
|
||||
mod._save_registry = MagicMock()
|
||||
mod.REPO_ROOT = tmp_path
|
||||
|
||||
plan_path = str(tmp_path / "FPLAN-0042.md")
|
||||
mod.handle_plan_file_created(path=plan_path)
|
||||
|
||||
mod._save_registry.assert_called_once() # type: ignore[union-attr]
|
||||
saved = mod._save_registry.call_args[0][0] # type: ignore[union-attr]
|
||||
plan = saved["plans"]["0042"]
|
||||
assert plan["status"] == "closed"
|
||||
assert plan["file_path"] == plan_path
|
||||
assert plan["closed_reason"] == "completed"
|
||||
assert "last_updated" in plan
|
||||
|
||||
def test_updates_next_number_when_needed(self, tmp_path: Path) -> None:
|
||||
"""Next number advances past the new plan number."""
|
||||
mod = _import_plan_file()
|
||||
mod._load_registry = MagicMock(return_value={"plans": {}, "next_number": 1})
|
||||
mod._save_registry = MagicMock()
|
||||
mod.REPO_ROOT = tmp_path
|
||||
|
||||
plan_path = str(tmp_path / "FPLAN-0042.md")
|
||||
mod.handle_plan_file_created(path=plan_path)
|
||||
|
||||
saved = mod._save_registry.call_args[0][0] # type: ignore[union-attr]
|
||||
assert saved["next_number"] == 43
|
||||
|
||||
def test_does_not_lower_next_number(self, tmp_path: Path) -> None:
|
||||
"""Next number stays at 100 when plan 42 is added."""
|
||||
mod = _import_plan_file()
|
||||
mod._load_registry = MagicMock(return_value={"plans": {}, "next_number": 100})
|
||||
mod._save_registry = MagicMock()
|
||||
mod.REPO_ROOT = tmp_path
|
||||
|
||||
plan_path = str(tmp_path / "FPLAN-0042.md")
|
||||
mod.handle_plan_file_created(path=plan_path)
|
||||
|
||||
saved = mod._save_registry.call_args[0][0] # type: ignore[union-attr]
|
||||
assert saved["next_number"] == 100
|
||||
|
||||
def test_returns_early_for_non_plan_file(self, tmp_path: Path) -> None:
|
||||
"""Non-FPLAN filenames cause immediate return."""
|
||||
mod = _import_plan_file()
|
||||
mod._load_registry = MagicMock()
|
||||
mod._save_registry = MagicMock()
|
||||
mod.REPO_ROOT = tmp_path
|
||||
|
||||
non_plan_path = str(tmp_path / "README.md")
|
||||
mod.handle_plan_file_created(path=non_plan_path)
|
||||
|
||||
mod._load_registry.assert_not_called() # type: ignore[union-attr]
|
||||
mod._save_registry.assert_not_called() # type: ignore[union-attr]
|
||||
|
||||
def test_logs_operation_on_success(self, tmp_path: Path) -> None:
|
||||
"""Calls json_handler.log_operation after adding new plan."""
|
||||
mod = _import_plan_file()
|
||||
mod._load_registry = MagicMock(return_value={"plans": {}, "next_number": 1})
|
||||
mod._save_registry = MagicMock()
|
||||
mod.REPO_ROOT = tmp_path
|
||||
|
||||
from aipass.trigger.apps.handlers.json import json_handler
|
||||
|
||||
json_handler.log_operation.reset_mock() # type: ignore[union-attr]
|
||||
|
||||
plan_path = str(tmp_path / "FPLAN-0001.md")
|
||||
mod.handle_plan_file_created(path=plan_path)
|
||||
|
||||
json_handler.log_operation.assert_called_once_with( # type: ignore[union-attr]
|
||||
"plan_event", {"success": True}
|
||||
)
|
||||
|
||||
|
||||
class TestHandlePlanFileDeleted:
|
||||
"""Tests for handle_plan_file_deleted."""
|
||||
|
||||
def test_open_plan_removed_from_registry(self, tmp_path: Path) -> None:
|
||||
"""Open plan is deleted entirely from the plans dict."""
|
||||
mod = _import_plan_file()
|
||||
mod._load_registry = MagicMock(
|
||||
return_value={
|
||||
"plans": {"0042": {"status": "open", "file_path": "/some/FPLAN-0042.md"}},
|
||||
"next_number": 43,
|
||||
}
|
||||
)
|
||||
mod._save_registry = MagicMock()
|
||||
mod.REPO_ROOT = tmp_path
|
||||
|
||||
plan_path = str(tmp_path / "FPLAN-0042.md")
|
||||
mod.handle_plan_file_deleted(path=plan_path)
|
||||
|
||||
mod._save_registry.assert_called_once() # type: ignore[union-attr]
|
||||
saved = mod._save_registry.call_args[0][0] # type: ignore[union-attr]
|
||||
assert "0042" not in saved["plans"]
|
||||
|
||||
def test_closed_plan_marked_archived(self, tmp_path: Path) -> None:
|
||||
"""Closed plan gets archived flag instead of being removed."""
|
||||
mod = _import_plan_file()
|
||||
mod._load_registry = MagicMock(
|
||||
return_value={
|
||||
"plans": {
|
||||
"0042": {
|
||||
"status": "closed",
|
||||
"file_path": "/some/FPLAN-0042.md",
|
||||
}
|
||||
},
|
||||
"next_number": 43,
|
||||
}
|
||||
)
|
||||
mod._save_registry = MagicMock()
|
||||
mod.REPO_ROOT = tmp_path
|
||||
|
||||
plan_path = str(tmp_path / "FPLAN-0042.md")
|
||||
mod.handle_plan_file_deleted(path=plan_path)
|
||||
|
||||
mod._save_registry.assert_called_once() # type: ignore[union-attr]
|
||||
saved = mod._save_registry.call_args[0][0] # type: ignore[union-attr]
|
||||
assert saved["plans"]["0042"]["archived"] is True
|
||||
assert "archived_date" in saved["plans"]["0042"]
|
||||
|
||||
def test_processed_plan_marked_archived(self, tmp_path: Path) -> None:
|
||||
"""Plan with processed=True gets archived flag."""
|
||||
mod = _import_plan_file()
|
||||
mod._load_registry = MagicMock(
|
||||
return_value={
|
||||
"plans": {
|
||||
"0042": {
|
||||
"status": "open",
|
||||
"processed": True,
|
||||
"file_path": "/some/FPLAN-0042.md",
|
||||
}
|
||||
},
|
||||
"next_number": 43,
|
||||
}
|
||||
)
|
||||
mod._save_registry = MagicMock()
|
||||
mod.REPO_ROOT = tmp_path
|
||||
|
||||
plan_path = str(tmp_path / "FPLAN-0042.md")
|
||||
mod.handle_plan_file_deleted(path=plan_path)
|
||||
|
||||
mod._save_registry.assert_called_once() # type: ignore[union-attr]
|
||||
saved = mod._save_registry.call_args[0][0] # type: ignore[union-attr]
|
||||
assert saved["plans"]["0042"]["archived"] is True
|
||||
|
||||
def test_unknown_plan_no_crash(self, tmp_path: Path) -> None:
|
||||
"""Plan not in registry causes no error."""
|
||||
mod = _import_plan_file()
|
||||
mod._load_registry = MagicMock(return_value={"plans": {}, "next_number": 1})
|
||||
mod._save_registry = MagicMock()
|
||||
mod.REPO_ROOT = tmp_path
|
||||
|
||||
plan_path = str(tmp_path / "FPLAN-9999.md")
|
||||
mod.handle_plan_file_deleted(path=plan_path)
|
||||
|
||||
mod._save_registry.assert_not_called() # type: ignore[union-attr]
|
||||
|
||||
def test_returns_early_for_non_plan_file(self, tmp_path: Path) -> None:
|
||||
"""Non-FPLAN filenames cause immediate return."""
|
||||
mod = _import_plan_file()
|
||||
mod._load_registry = MagicMock()
|
||||
mod._save_registry = MagicMock()
|
||||
mod.REPO_ROOT = tmp_path
|
||||
|
||||
mod.handle_plan_file_deleted(path=str(tmp_path / "random-notes.txt"))
|
||||
|
||||
mod._load_registry.assert_not_called() # type: ignore[union-attr]
|
||||
|
||||
|
||||
class TestHandlePlanFileMoved:
|
||||
"""Tests for handle_plan_file_moved."""
|
||||
|
||||
def test_updates_location_fields(self, tmp_path: Path) -> None:
|
||||
"""Move updates location, relative_path, and file_path."""
|
||||
mod = _import_plan_file()
|
||||
mod._load_registry = MagicMock(
|
||||
return_value={
|
||||
"plans": {
|
||||
"0042": {
|
||||
"status": "open",
|
||||
"location": "/old/dir",
|
||||
"relative_path": "old/dir",
|
||||
"file_path": "/old/dir/FPLAN-0042.md",
|
||||
}
|
||||
},
|
||||
"next_number": 43,
|
||||
}
|
||||
)
|
||||
mod._save_registry = MagicMock()
|
||||
mod.REPO_ROOT = tmp_path
|
||||
|
||||
new_dir = tmp_path / "new" / "location"
|
||||
new_dir.mkdir(parents=True, exist_ok=True)
|
||||
dest_path = str(new_dir / "FPLAN-0042.md")
|
||||
|
||||
mod.handle_plan_file_moved(
|
||||
src_path=str(tmp_path / "old" / "FPLAN-0042.md"),
|
||||
dest_path=dest_path,
|
||||
)
|
||||
|
||||
mod._save_registry.assert_called_once() # type: ignore[union-attr]
|
||||
saved = mod._save_registry.call_args[0][0] # type: ignore[union-attr]
|
||||
plan = saved["plans"]["0042"]
|
||||
assert plan["file_path"] == dest_path
|
||||
assert plan["location"] == str(new_dir)
|
||||
assert "last_updated" in plan
|
||||
|
||||
def test_preserves_existing_metadata(self, tmp_path: Path) -> None:
|
||||
"""Move preserves status, closed_reason, memory_created, etc."""
|
||||
mod = _import_plan_file()
|
||||
mod._load_registry = MagicMock(
|
||||
return_value={
|
||||
"plans": {
|
||||
"0042": {
|
||||
"status": "closed",
|
||||
"closed_reason": "completed",
|
||||
"memory_created": True,
|
||||
"memory_created_date": "2026-01-15",
|
||||
"location": "/old",
|
||||
"relative_path": "old",
|
||||
"file_path": "/old/FPLAN-0042.md",
|
||||
}
|
||||
},
|
||||
"next_number": 43,
|
||||
}
|
||||
)
|
||||
mod._save_registry = MagicMock()
|
||||
mod.REPO_ROOT = tmp_path
|
||||
|
||||
dest_path = str(tmp_path / "FPLAN-0042.md")
|
||||
mod.handle_plan_file_moved(
|
||||
src_path="/old/FPLAN-0042.md",
|
||||
dest_path=dest_path,
|
||||
)
|
||||
|
||||
saved = mod._save_registry.call_args[0][0] # type: ignore[union-attr]
|
||||
plan = saved["plans"]["0042"]
|
||||
assert plan["status"] == "closed"
|
||||
assert plan["closed_reason"] == "completed"
|
||||
assert plan["memory_created"] is True
|
||||
assert plan["memory_created_date"] == "2026-01-15"
|
||||
|
||||
def test_unknown_plan_no_crash(self, tmp_path: Path) -> None:
|
||||
"""Plan not in registry causes no error on move."""
|
||||
mod = _import_plan_file()
|
||||
mod._load_registry = MagicMock(return_value={"plans": {}, "next_number": 1})
|
||||
mod._save_registry = MagicMock()
|
||||
mod.REPO_ROOT = tmp_path
|
||||
|
||||
mod.handle_plan_file_moved(
|
||||
src_path=str(tmp_path / "FPLAN-9999.md"),
|
||||
dest_path=str(tmp_path / "new" / "FPLAN-9999.md"),
|
||||
)
|
||||
|
||||
mod._save_registry.assert_not_called() # type: ignore[union-attr]
|
||||
|
||||
def test_returns_early_for_non_plan_dest(self, tmp_path: Path) -> None:
|
||||
"""Non-FPLAN destination filename causes immediate return."""
|
||||
mod = _import_plan_file()
|
||||
mod._load_registry = MagicMock()
|
||||
mod._save_registry = MagicMock()
|
||||
mod.REPO_ROOT = tmp_path
|
||||
|
||||
mod.handle_plan_file_moved(
|
||||
src_path=str(tmp_path / "FPLAN-0001.md"),
|
||||
dest_path=str(tmp_path / "renamed-notes.txt"),
|
||||
)
|
||||
|
||||
mod._load_registry.assert_not_called() # type: ignore[union-attr]
|
||||
@@ -0,0 +1,107 @@
|
||||
# =================== AIPass ====================
|
||||
# Name: test_startup_handler.py
|
||||
# Description: Tests for startup event handler
|
||||
# Version: 1.0.0
|
||||
# Created: 2026-04-25
|
||||
# Modified: 2026-04-25
|
||||
# =============================================
|
||||
|
||||
"""Tests for startup event handler."""
|
||||
|
||||
import pytest
|
||||
from unittest.mock import MagicMock
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _mock_infrastructure(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
|
||||
"""Mock heavy infrastructure imports before importing the handler module."""
|
||||
import sys
|
||||
|
||||
from aipass.trigger.apps.config import atomic_write_json
|
||||
|
||||
mock_config = MagicMock()
|
||||
mock_config.TRIGGER_ROOT = tmp_path
|
||||
mock_config.atomic_write_json = atomic_write_json
|
||||
monkeypatch.setitem(sys.modules, "aipass.trigger.apps.config", mock_config)
|
||||
|
||||
mock_json_handler = MagicMock()
|
||||
mock_json_handler.log_operation = MagicMock(return_value=True)
|
||||
json_pkg = MagicMock()
|
||||
json_pkg.json_handler = mock_json_handler
|
||||
monkeypatch.setitem(sys.modules, "aipass.trigger.apps.handlers.json", json_pkg)
|
||||
monkeypatch.setitem(
|
||||
sys.modules,
|
||||
"aipass.trigger.apps.handlers.json.json_handler",
|
||||
mock_json_handler,
|
||||
)
|
||||
|
||||
monkeypatch.delitem(
|
||||
sys.modules,
|
||||
"aipass.trigger.apps.handlers.events.startup",
|
||||
raising=False,
|
||||
)
|
||||
|
||||
|
||||
def _import_startup():
|
||||
"""Import fresh after mocking."""
|
||||
import aipass.trigger.apps.handlers.events.startup as m
|
||||
|
||||
return m
|
||||
|
||||
|
||||
class TestHandleStartup:
|
||||
"""Tests for handle_startup."""
|
||||
|
||||
def test_calls_error_catchup_with_fire_event(self) -> None:
|
||||
"""Passes fire_event kwarg to _run_error_catchup."""
|
||||
mod = _import_startup()
|
||||
mod._run_error_catchup = MagicMock()
|
||||
mod._run_memory_check = MagicMock()
|
||||
|
||||
fire_event = MagicMock()
|
||||
mod.handle_startup(fire_event=fire_event)
|
||||
|
||||
mod._run_error_catchup.assert_called_once_with(fire_event) # type: ignore[union-attr]
|
||||
|
||||
def test_calls_memory_check(self) -> None:
|
||||
"""Invokes _run_memory_check on every startup."""
|
||||
mod = _import_startup()
|
||||
mod._run_error_catchup = MagicMock()
|
||||
mod._run_memory_check = MagicMock()
|
||||
|
||||
mod.handle_startup()
|
||||
|
||||
mod._run_memory_check.assert_called_once() # type: ignore[union-attr]
|
||||
|
||||
def test_passes_none_when_no_fire_event(self) -> None:
|
||||
"""Without fire_event kwarg, passes None to error catchup."""
|
||||
mod = _import_startup()
|
||||
mod._run_error_catchup = MagicMock()
|
||||
mod._run_memory_check = MagicMock()
|
||||
|
||||
mod.handle_startup()
|
||||
|
||||
mod._run_error_catchup.assert_called_once_with(None) # type: ignore[union-attr]
|
||||
|
||||
def test_calls_both_helpers_in_order(self) -> None:
|
||||
"""Error catchup runs before memory check."""
|
||||
mod = _import_startup()
|
||||
call_order: list[str] = []
|
||||
mod._run_error_catchup = MagicMock(side_effect=lambda *a, **kw: call_order.append("catchup"))
|
||||
mod._run_memory_check = MagicMock(side_effect=lambda *a, **kw: call_order.append("memory"))
|
||||
|
||||
mod.handle_startup(fire_event=MagicMock())
|
||||
|
||||
assert call_order == ["catchup", "memory"]
|
||||
|
||||
def test_extra_kwargs_do_not_crash(self) -> None:
|
||||
"""Arbitrary extra kwargs are silently ignored."""
|
||||
mod = _import_startup()
|
||||
mod._run_error_catchup = MagicMock()
|
||||
mod._run_memory_check = MagicMock()
|
||||
|
||||
mod.handle_startup(fire_event=MagicMock(), extra_arg="ignored", count=42)
|
||||
|
||||
mod._run_error_catchup.assert_called_once() # type: ignore[union-attr]
|
||||
mod._run_memory_check.assert_called_once() # type: ignore[union-attr]
|
||||
Reference in New Issue
Block a user