fix(prax): slim devpulse dashboard — drop duplicated todos[] bodies, keep count (startup-context fix)
This commit is contained in:
@@ -13,6 +13,14 @@ PyPI version — not the changelog header.
|
||||
|
||||
### Changed
|
||||
|
||||
- **Devpulse dashboard slimmed — todos no longer duplicated (startup-context
|
||||
fix).** `DASHBOARD.local.json` was embedding the full `todos[]` bodies that
|
||||
already live in `.trinity/local.json`; since both files are read at every
|
||||
startup, that was pure duplication. The dashboard now emits `todo_count` only
|
||||
(the glance value) — the bodies are commented out in the prax
|
||||
`devpulse_dashboard` plugin's `todo_section.py` (revivable). Dashboard
|
||||
`DASHBOARD.local.json` 6.8 KB → 3.0 KB. Devpulse-only (plugin, not templated).
|
||||
Verified: seedgo 100%, 17/17 plugin tests.
|
||||
- **`.backupignore` is now a true `.gitignore` for the backup system — a single
|
||||
source of truth (FPLAN-0269).** Replaced the hand-rolled `fnmatch`+part-loop
|
||||
matcher (which broke leading-slash anchoring, `*`-crossing-`/`, dir-only `foo/`,
|
||||
|
||||
@@ -10,11 +10,12 @@
|
||||
#
|
||||
# CODE STANDARDS:
|
||||
# - Error handling: Use error handler system (apps/handlers/error/)
|
||||
# =============================================
|
||||
#
|
||||
#@Meta header not seedgo standards
|
||||
# =============================================
|
||||
#
|
||||
# @Meta header not seedgo standards
|
||||
|
||||
"""Shared pytest fixtures for daemon tests"""
|
||||
|
||||
import os
|
||||
import tempfile
|
||||
|
||||
@@ -45,10 +46,7 @@ def sample_test_data() -> dict:
|
||||
|
||||
Customize this fixture for your module's needs
|
||||
"""
|
||||
return {
|
||||
"test_key": "test_value",
|
||||
"sample_data": "example"
|
||||
}
|
||||
return {"test_key": "test_value", "sample_data": "example"}
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
|
||||
@@ -50,6 +50,7 @@ def clean_registry(tmp_path):
|
||||
# CRUD TESTS
|
||||
# =============================================
|
||||
|
||||
|
||||
class TestCreate:
|
||||
def test_create_action_basic(self, clean_registry):
|
||||
"""Create a simple schedule action and verify fields."""
|
||||
@@ -128,8 +129,7 @@ class TestList:
|
||||
|
||||
def test_list_excludes_completed(self, clean_registry):
|
||||
"""Completed reminders should be excluded by default."""
|
||||
create_action(name="done", action_type="reminder", schedule_type="once",
|
||||
due_date="2026-01-01")
|
||||
create_action(name="done", action_type="reminder", schedule_type="once", due_date="2026-01-01")
|
||||
mark_reminder_completed("0001")
|
||||
assert len(list_actions()) == 0
|
||||
assert len(list_actions(include_completed=True)) == 1
|
||||
@@ -146,8 +146,7 @@ class TestToggle:
|
||||
|
||||
def test_toggle_on(self, clean_registry):
|
||||
"""Toggle an action back on."""
|
||||
create_action(name="toggleme", action_type="schedule", schedule_type="daily",
|
||||
enabled=False)
|
||||
create_action(name="toggleme", action_type="schedule", schedule_type="daily", enabled=False)
|
||||
assert toggle_action("0001", True) is True
|
||||
action = get_action("0001")
|
||||
assert action is not None
|
||||
@@ -174,6 +173,7 @@ class TestDelete:
|
||||
# DUE CHECKING TESTS
|
||||
# =============================================
|
||||
|
||||
|
||||
class TestIsDue:
|
||||
def test_daily_due_at_correct_time(self, clean_registry):
|
||||
"""Daily action is due when current time matches."""
|
||||
@@ -190,6 +190,7 @@ class TestIsDue:
|
||||
def test_daily_not_due_wrong_time(self, clean_registry):
|
||||
"""Daily action is not due at wrong time (12 hours away from now)."""
|
||||
from datetime import datetime
|
||||
|
||||
now = datetime.now()
|
||||
# Pick a time 12 hours away — always outside the 15-min fuzzy window
|
||||
far_hour = (now.hour + 12) % 24
|
||||
@@ -295,6 +296,7 @@ class TestIsDue:
|
||||
# NEXT RUN TESTS
|
||||
# =============================================
|
||||
|
||||
|
||||
class TestCalcNextRun:
|
||||
def test_daily_next_run(self, clean_registry):
|
||||
"""Daily action calculates next run correctly."""
|
||||
@@ -334,11 +336,11 @@ class TestNextDueStr:
|
||||
# UPDATE TESTS
|
||||
# =============================================
|
||||
|
||||
|
||||
class TestUpdateLastRun:
|
||||
def test_update_last_run(self, clean_registry):
|
||||
"""Update last_run sets timestamp and recalculates next_run."""
|
||||
create_action(name="test", action_type="schedule", schedule_type="interval",
|
||||
interval_minutes=60)
|
||||
create_action(name="test", action_type="schedule", schedule_type="interval", interval_minutes=60)
|
||||
ts = "2026-03-02T12:00:00"
|
||||
assert update_last_run("0001", ts) is True
|
||||
action = get_action("0001")
|
||||
@@ -350,8 +352,7 @@ class TestUpdateLastRun:
|
||||
class TestMarkCompleted:
|
||||
def test_mark_reminder_completed(self, clean_registry):
|
||||
"""Marking a reminder completed sets completed timestamp and disables it."""
|
||||
create_action(name="reminder", action_type="reminder", schedule_type="once",
|
||||
due_date="2026-03-01")
|
||||
create_action(name="reminder", action_type="reminder", schedule_type="once", due_date="2026-03-01")
|
||||
assert mark_reminder_completed("0001") is True
|
||||
action = get_action("0001")
|
||||
assert action is not None
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
|
||||
"""Tests for the activity_report CLI module (apps/modules/activity_report.py)."""
|
||||
|
||||
from unittest.mock import patch, MagicMock
|
||||
from unittest.mock import patch
|
||||
|
||||
MODULE = "aipass.daemon.apps.modules.activity_report"
|
||||
|
||||
@@ -17,6 +17,7 @@ MODULE = "aipass.daemon.apps.modules.activity_report"
|
||||
# handle_command -- routing basics
|
||||
# =============================================
|
||||
|
||||
|
||||
@patch(f"{MODULE}.json_handler")
|
||||
@patch(f"{MODULE}.console")
|
||||
@patch(f"{MODULE}.error")
|
||||
@@ -64,6 +65,7 @@ class TestHandleCommandRouting:
|
||||
# handle_command -- activity-report
|
||||
# =============================================
|
||||
|
||||
|
||||
@patch(f"{MODULE}.json_handler")
|
||||
@patch(f"{MODULE}.console")
|
||||
@patch(f"{MODULE}.error")
|
||||
@@ -114,6 +116,7 @@ class TestActivityReportCommand:
|
||||
# handle_command -- activity_report alias
|
||||
# =============================================
|
||||
|
||||
|
||||
@patch(f"{MODULE}.json_handler")
|
||||
@patch(f"{MODULE}.console")
|
||||
@patch(f"{MODULE}.error")
|
||||
@@ -144,6 +147,7 @@ class TestActivityReportAlias:
|
||||
# handle_command -- branch-health
|
||||
# =============================================
|
||||
|
||||
|
||||
@patch(f"{MODULE}.json_handler")
|
||||
@patch(f"{MODULE}.console")
|
||||
@patch(f"{MODULE}.error")
|
||||
@@ -198,6 +202,7 @@ class TestBranchHealthCommand:
|
||||
# _parse_hours_arg
|
||||
# =============================================
|
||||
|
||||
|
||||
@patch(f"{MODULE}.logger")
|
||||
class TestParseHoursArg:
|
||||
"""Tests for _parse_hours_arg helper."""
|
||||
@@ -229,6 +234,7 @@ class TestParseHoursArg:
|
||||
# _extract_branch_name
|
||||
# =============================================
|
||||
|
||||
|
||||
class TestExtractBranchName:
|
||||
"""Tests for _extract_branch_name helper."""
|
||||
|
||||
|
||||
@@ -25,7 +25,7 @@ Covers 9 tests:
|
||||
"""
|
||||
|
||||
import sys
|
||||
from unittest.mock import patch, MagicMock
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
|
||||
@@ -49,6 +49,7 @@ def _mock_log_operation():
|
||||
# CLI Routing Tests
|
||||
# ============================================================================
|
||||
|
||||
|
||||
def test_help_flag() -> None:
|
||||
"""--help flag triggers help and returns exit code 0."""
|
||||
with patch.object(sys, "argv", ["daemon", "--help"]):
|
||||
|
||||
@@ -42,9 +42,7 @@ _json_mod_path = f"aipass.{BRANCH_MODULE}.apps.handlers.json.json_handler"
|
||||
|
||||
if _handler_pkg not in sys.modules:
|
||||
_stub = types.ModuleType(_handler_pkg)
|
||||
_handlers_dir = (
|
||||
Path(__file__).resolve().parents[3] / "aipass" / BRANCH_MODULE / "apps" / "handlers"
|
||||
)
|
||||
_handlers_dir = Path(__file__).resolve().parents[3] / "aipass" / BRANCH_MODULE / "apps" / "handlers"
|
||||
_stub.__path__ = [str(_handlers_dir)]
|
||||
sys.modules[_handler_pkg] = _stub
|
||||
|
||||
@@ -72,8 +70,7 @@ for _candidate in _JSON_DIR_CANDIDATES:
|
||||
|
||||
if _JSON_DIR_ATTR is None:
|
||||
pytest.skip(
|
||||
f"Cannot find JSON_DIR attribute on {BRANCH_MODULE}.json_handler -- "
|
||||
f"tried: {_JSON_DIR_CANDIDATES}",
|
||||
f"Cannot find JSON_DIR attribute on {BRANCH_MODULE}.json_handler -- tried: {_JSON_DIR_CANDIDATES}",
|
||||
allow_module_level=True,
|
||||
)
|
||||
|
||||
@@ -82,6 +79,7 @@ if _JSON_DIR_ATTR is None:
|
||||
# Isolation fixture
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def isolate_json_dir(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path:
|
||||
"""Redirect JSON operations to tmp_path for test isolation."""
|
||||
@@ -98,6 +96,7 @@ def isolate_json_dir(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path:
|
||||
# Default factory helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _get_default_for_type(json_type: str, module_name: str = "test_mod") -> Any:
|
||||
"""Call whichever default factory the branch exposes."""
|
||||
for fn_name in (
|
||||
@@ -145,6 +144,7 @@ def _default_factory_raises_on_unknown() -> bool:
|
||||
# Group 1 -- Return type contracts (4 tests)
|
||||
# ============================================================================
|
||||
|
||||
|
||||
def test_command_returns_bool() -> None:
|
||||
"""command_returns_bool: route_command returns a bool."""
|
||||
from aipass.daemon.apps import daemon as _daemon_mod
|
||||
@@ -167,40 +167,33 @@ def test_command_returns_bool() -> None:
|
||||
def test_paths_return_path() -> None:
|
||||
"""paths_return_path: get_json_path returns a Path."""
|
||||
result = json_handler.get_json_path("contract_mod", "config")
|
||||
assert isinstance(result, Path), (
|
||||
f"get_json_path must return Path, got {type(result)}"
|
||||
)
|
||||
assert isinstance(result, Path), f"get_json_path must return Path, got {type(result)}"
|
||||
|
||||
|
||||
def test_paths_return_path_for_data() -> None:
|
||||
"""paths_return_path: get_json_path returns Path for data type too."""
|
||||
result = json_handler.get_json_path("contract_mod", "data")
|
||||
assert isinstance(result, Path), (
|
||||
f"get_json_path('data') must return Path, got {type(result)}"
|
||||
)
|
||||
assert isinstance(result, Path), f"get_json_path('data') must return Path, got {type(result)}"
|
||||
|
||||
|
||||
def test_ensure_json_exists_returns_bool(tmp_path: Path) -> None:
|
||||
"""ensure_json_exists must return a bool."""
|
||||
result = json_handler.ensure_json_exists("contract_mod", "data")
|
||||
assert isinstance(result, bool), (
|
||||
f"ensure_json_exists must return bool, got {type(result)}"
|
||||
)
|
||||
assert isinstance(result, bool), f"ensure_json_exists must return bool, got {type(result)}"
|
||||
assert result is True
|
||||
|
||||
|
||||
def test_load_json_returns_dict_for_config(tmp_path: Path) -> None:
|
||||
"""load_json for config type must return a dict."""
|
||||
result = json_handler.load_json("contract_mod", "config")
|
||||
assert isinstance(result, dict), (
|
||||
f"load_json('...', 'config') must return dict, got {type(result)}"
|
||||
)
|
||||
assert isinstance(result, dict), f"load_json('...', 'config') must return dict, got {type(result)}"
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Group 2 -- Data structure contracts (3 tests)
|
||||
# ============================================================================
|
||||
|
||||
|
||||
def test_config_keys(tmp_path: Path) -> None:
|
||||
"""config_keys: config data structure contains module_name and version."""
|
||||
json_handler.ensure_json_exists("struct_mod", "config")
|
||||
@@ -227,9 +220,7 @@ def test_log_entry_has_operation(tmp_path: Path) -> None:
|
||||
val = getattr(_mod, _JSON_DIR_ATTR)
|
||||
json_dir = Path(val) if isinstance(val, str) else val
|
||||
|
||||
log = json.loads(
|
||||
(json_dir / "struct_mod_log.json").read_text(encoding="utf-8")
|
||||
)
|
||||
log = json.loads((json_dir / "struct_mod_log.json").read_text(encoding="utf-8"))
|
||||
assert len(log) >= 1, "log_operation must append at least one entry"
|
||||
assert "operation" in log[-1], "Log entry must have 'operation' key"
|
||||
assert log[-1]["operation"] == "contract_test"
|
||||
@@ -239,6 +230,7 @@ def test_log_entry_has_operation(tmp_path: Path) -> None:
|
||||
# Group 3 -- Success/failure paths (4 tests)
|
||||
# ============================================================================
|
||||
|
||||
|
||||
def test_known_routes_true() -> None:
|
||||
"""known_routes_true: a module that handles a command causes route_command to return True."""
|
||||
from aipass.daemon.apps import daemon as _daemon_mod
|
||||
@@ -287,6 +279,7 @@ def test_no_args_triggers() -> None:
|
||||
# Group 4 -- Infrastructure mocking (3 tests)
|
||||
# ============================================================================
|
||||
|
||||
|
||||
def test_log_operation_mocked(tmp_path: Path) -> None:
|
||||
"""Infrastructure: log_operation can be mocked without side effects."""
|
||||
with patch.object(_mod, "log_operation", return_value=True) as mock_log:
|
||||
@@ -306,8 +299,6 @@ def test_sys_modules_mock() -> None:
|
||||
|
||||
def test_reimport_after_mock(tmp_path: Path) -> None:
|
||||
"""reimport_after_mock: module can be reloaded cleanly."""
|
||||
handler_module = sys.modules.get(
|
||||
f"aipass.{BRANCH_MODULE}.apps.handlers.json.json_handler"
|
||||
)
|
||||
handler_module = sys.modules.get(f"aipass.{BRANCH_MODULE}.apps.handlers.json.json_handler")
|
||||
if handler_module:
|
||||
importlib.reload(handler_module)
|
||||
|
||||
@@ -33,6 +33,7 @@ get_escalations = _dl_mod.get_escalations
|
||||
# FIXTURES
|
||||
# =============================================
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def isolate_paths(tmp_path, monkeypatch):
|
||||
"""Redirect INBOX_PATH and LOCAL_PATH to tmp_path for every test."""
|
||||
@@ -79,6 +80,7 @@ def _write_json(path: Path, data: object) -> None:
|
||||
# LOAD INBOX TESTS
|
||||
# =============================================
|
||||
|
||||
|
||||
class TestLoadInbox:
|
||||
def test_load_valid_inbox(self, isolate_paths, sample_inbox_data, monkeypatch):
|
||||
"""Loading a well-formed inbox.json returns its full contents."""
|
||||
@@ -116,6 +118,7 @@ class TestLoadInbox:
|
||||
# LOAD LOCAL TESTS
|
||||
# =============================================
|
||||
|
||||
|
||||
class TestLoadLocal:
|
||||
def test_load_valid_local(self, isolate_paths, sample_local_data):
|
||||
"""Loading a well-formed local.json returns its full contents."""
|
||||
@@ -149,6 +152,7 @@ class TestLoadLocal:
|
||||
# CATEGORIZE MESSAGES TESTS
|
||||
# =============================================
|
||||
|
||||
|
||||
class TestCategorizeMessages:
|
||||
def test_new_and_opened_split(self):
|
||||
"""Messages are split into new and opened buckets by status."""
|
||||
@@ -218,6 +222,7 @@ class TestCategorizeMessages:
|
||||
# GET SESSION SUMMARY TESTS
|
||||
# =============================================
|
||||
|
||||
|
||||
class TestGetSessionSummary:
|
||||
def test_summary_with_sessions(self, sample_local_data):
|
||||
"""Session summary extracts totals and latest session."""
|
||||
@@ -260,6 +265,7 @@ class TestGetSessionSummary:
|
||||
# GET ESCALATIONS TESTS
|
||||
# =============================================
|
||||
|
||||
|
||||
class TestGetEscalations:
|
||||
def test_urgent_message_detected(self):
|
||||
"""Messages with URGENT in subject are escalated."""
|
||||
|
||||
@@ -38,9 +38,7 @@ _json_mod_path = f"aipass.{BRANCH_MODULE}.apps.handlers.json.json_handler"
|
||||
|
||||
if _handler_pkg not in sys.modules:
|
||||
_stub = types.ModuleType(_handler_pkg)
|
||||
_handlers_dir = (
|
||||
Path(__file__).resolve().parents[3] / "aipass" / BRANCH_MODULE / "apps" / "handlers"
|
||||
)
|
||||
_handlers_dir = Path(__file__).resolve().parents[3] / "aipass" / BRANCH_MODULE / "apps" / "handlers"
|
||||
_stub.__path__ = [str(_handlers_dir)]
|
||||
sys.modules[_handler_pkg] = _stub
|
||||
|
||||
@@ -68,8 +66,7 @@ for _candidate in _JSON_DIR_CANDIDATES:
|
||||
|
||||
if _JSON_DIR_ATTR is None:
|
||||
pytest.skip(
|
||||
f"Cannot find JSON_DIR attribute on {BRANCH_MODULE}.json_handler -- "
|
||||
f"tried: {_JSON_DIR_CANDIDATES}",
|
||||
f"Cannot find JSON_DIR attribute on {BRANCH_MODULE}.json_handler -- tried: {_JSON_DIR_CANDIDATES}",
|
||||
allow_module_level=True,
|
||||
)
|
||||
|
||||
@@ -78,6 +75,7 @@ if _JSON_DIR_ATTR is None:
|
||||
# Isolation fixture
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def isolate_json_dir(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path:
|
||||
"""Redirect JSON operations to tmp_path for test isolation."""
|
||||
@@ -103,6 +101,7 @@ def _json_dir_as_path(tmp_path: Path) -> Path:
|
||||
# Error Resilience Tests (4 tests)
|
||||
# ============================================================================
|
||||
|
||||
|
||||
def test_missing_file(tmp_path: Path) -> None:
|
||||
"""Loading a non-existent file returns a graceful default, not a crash."""
|
||||
json_dir = _json_dir_as_path(tmp_path)
|
||||
|
||||
@@ -44,9 +44,7 @@ _json_mod_path = f"aipass.{BRANCH_MODULE}.apps.handlers.json.json_handler"
|
||||
|
||||
if _handler_pkg not in sys.modules:
|
||||
_stub = types.ModuleType(_handler_pkg)
|
||||
_handlers_dir = (
|
||||
Path(__file__).resolve().parents[3] / "aipass" / BRANCH_MODULE / "apps" / "handlers"
|
||||
)
|
||||
_handlers_dir = Path(__file__).resolve().parents[3] / "aipass" / BRANCH_MODULE / "apps" / "handlers"
|
||||
_stub.__path__ = [str(_handlers_dir)]
|
||||
sys.modules[_handler_pkg] = _stub
|
||||
|
||||
@@ -74,8 +72,7 @@ for _candidate in _JSON_DIR_CANDIDATES:
|
||||
|
||||
if _JSON_DIR_ATTR is None:
|
||||
pytest.skip(
|
||||
f"Cannot find JSON_DIR attribute on {BRANCH_MODULE}.json_handler -- "
|
||||
f"tried: {_JSON_DIR_CANDIDATES}",
|
||||
f"Cannot find JSON_DIR attribute on {BRANCH_MODULE}.json_handler -- tried: {_JSON_DIR_CANDIDATES}",
|
||||
allow_module_level=True,
|
||||
)
|
||||
|
||||
@@ -84,6 +81,7 @@ if _JSON_DIR_ATTR is None:
|
||||
# Default factory discovery
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _get_default_for_type(json_type: str, module_name: str = "test_mod") -> Any:
|
||||
"""Call whichever default factory the branch exposes."""
|
||||
for fn_name in (
|
||||
@@ -146,6 +144,7 @@ def _default_factory_raises_on_unknown() -> bool:
|
||||
# Isolation fixture
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def isolate_json_dir(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path:
|
||||
"""Redirect JSON operations to tmp_path for test isolation."""
|
||||
@@ -171,6 +170,7 @@ def _json_dir_as_path(tmp_path: Path) -> Path:
|
||||
# Group 1 -- default_factory (4 tests)
|
||||
# ============================================================================
|
||||
|
||||
|
||||
def test_default_factory_config_returns_dict() -> None:
|
||||
"""default_factory: config template returns a dict with required keys."""
|
||||
if not _has_default_factory():
|
||||
@@ -213,6 +213,7 @@ def test_default_factory_unknown_type_raises() -> None:
|
||||
# Group 2 -- validate (10 tests)
|
||||
# ============================================================================
|
||||
|
||||
|
||||
def test_validate_valid_config() -> None:
|
||||
"""validate: valid config structure passes."""
|
||||
data = {"module_name": "x", "version": "1.0.0", "config": {}}
|
||||
@@ -274,6 +275,7 @@ def test_validate_none_input_returns_false() -> None:
|
||||
# Group 3 -- get_path (3 tests)
|
||||
# ============================================================================
|
||||
|
||||
|
||||
def test_get_path_returns_path_type(tmp_path: Path) -> None:
|
||||
"""get_path: returns Path or str."""
|
||||
result = json_handler.get_json_path("mymod", "config")
|
||||
@@ -298,6 +300,7 @@ def test_get_path_different_combos_differ(tmp_path: Path) -> None:
|
||||
# Group 4 -- ensure_exists (5 tests)
|
||||
# ============================================================================
|
||||
|
||||
|
||||
def test_ensure_exists_creates_file(tmp_path: Path) -> None:
|
||||
"""ensure_exists: creates file when missing."""
|
||||
result = json_handler.ensure_json_exists("ens_mod", "config")
|
||||
@@ -364,6 +367,7 @@ def test_ensure_exists_returns_bool(tmp_path: Path) -> None:
|
||||
# Group 5 -- load (4 tests)
|
||||
# ============================================================================
|
||||
|
||||
|
||||
def test_load_creates_default_when_missing(tmp_path: Path) -> None:
|
||||
"""load: auto-creates default when file is missing."""
|
||||
result = json_handler.load_json("fresh_mod", "log")
|
||||
@@ -400,6 +404,7 @@ def test_load_returns_list_for_log(tmp_path: Path) -> None:
|
||||
# Group 6 -- save (5 tests)
|
||||
# ============================================================================
|
||||
|
||||
|
||||
def test_save_roundtrip(tmp_path: Path) -> None:
|
||||
"""save: data survives save-then-load roundtrip."""
|
||||
json_dir = _json_dir_as_path(tmp_path)
|
||||
@@ -437,9 +442,7 @@ def test_save_data_updates_last_updated(tmp_path: Path) -> None:
|
||||
data = {"created": "2025-01-01", "last_updated": "2025-01-01"}
|
||||
json_handler.save_json("ts", "data", data)
|
||||
|
||||
on_disk = json.loads(
|
||||
(json_dir / "ts_data.json").read_text(encoding="utf-8")
|
||||
)
|
||||
on_disk = json.loads((json_dir / "ts_data.json").read_text(encoding="utf-8"))
|
||||
assert on_disk["last_updated"] == today, "Saving data type must auto-stamp last_updated"
|
||||
|
||||
|
||||
@@ -460,13 +463,12 @@ def test_save_writes_valid_json(tmp_path: Path) -> None:
|
||||
# Group 7 -- log_operation (7 tests)
|
||||
# ============================================================================
|
||||
|
||||
|
||||
def test_log_operation_appends_entry(tmp_path: Path) -> None:
|
||||
"""log_operation appends an entry to the log file."""
|
||||
json_handler.log_operation("deploy", module_name="logmod")
|
||||
json_dir = _json_dir_as_path(tmp_path)
|
||||
log = json.loads(
|
||||
(json_dir / "logmod_log.json").read_text(encoding="utf-8")
|
||||
)
|
||||
log = json.loads((json_dir / "logmod_log.json").read_text(encoding="utf-8"))
|
||||
assert len(log) >= 1, "log_operation must append at least one entry"
|
||||
assert log[-1]["operation"] == "deploy"
|
||||
|
||||
@@ -482,21 +484,15 @@ def test_log_operation_entry_has_timestamp(tmp_path: Path) -> None:
|
||||
"""log_operation entries include a timestamp field."""
|
||||
json_handler.log_operation("check_ts", module_name="tsmod")
|
||||
json_dir = _json_dir_as_path(tmp_path)
|
||||
log = json.loads(
|
||||
(json_dir / "tsmod_log.json").read_text(encoding="utf-8")
|
||||
)
|
||||
log = json.loads((json_dir / "tsmod_log.json").read_text(encoding="utf-8"))
|
||||
assert "timestamp" in log[-1], "Log entry must have a timestamp field"
|
||||
|
||||
|
||||
def test_log_operation_includes_data(tmp_path: Path) -> None:
|
||||
"""log_operation includes data dict when provided."""
|
||||
json_handler.log_operation(
|
||||
"with_data", data={"count": 5}, module_name="datamod"
|
||||
)
|
||||
json_handler.log_operation("with_data", data={"count": 5}, module_name="datamod")
|
||||
json_dir = _json_dir_as_path(tmp_path)
|
||||
log = json.loads(
|
||||
(json_dir / "datamod_log.json").read_text(encoding="utf-8")
|
||||
)
|
||||
log = json.loads((json_dir / "datamod_log.json").read_text(encoding="utf-8"))
|
||||
assert "data" in log[-1], "Log entry must include data dict when provided"
|
||||
assert log[-1]["data"]["count"] == 5
|
||||
|
||||
@@ -507,9 +503,7 @@ def test_log_operation_multiple_calls_accumulate(tmp_path: Path) -> None:
|
||||
json_handler.log_operation("second", module_name="accmod")
|
||||
json_handler.log_operation("third", module_name="accmod")
|
||||
json_dir = _json_dir_as_path(tmp_path)
|
||||
log = json.loads(
|
||||
(json_dir / "accmod_log.json").read_text(encoding="utf-8")
|
||||
)
|
||||
log = json.loads((json_dir / "accmod_log.json").read_text(encoding="utf-8"))
|
||||
assert len(log) >= 3, "Multiple log_operation calls must accumulate entries"
|
||||
ops = [e["operation"] for e in log[-3:]]
|
||||
assert ops == ["first", "second", "third"]
|
||||
@@ -530,9 +524,7 @@ def test_log_operation_fifo_rotation(tmp_path: Path) -> None:
|
||||
json_handler.log_operation(f"op_{i}", module_name="fifomod")
|
||||
|
||||
json_dir = _json_dir_as_path(tmp_path)
|
||||
log = json.loads(
|
||||
(json_dir / "fifomod_log.json").read_text(encoding="utf-8")
|
||||
)
|
||||
log = json.loads((json_dir / "fifomod_log.json").read_text(encoding="utf-8"))
|
||||
assert len(log) <= max_entries, f"Log must not exceed {max_entries} entries"
|
||||
assert log[-1]["operation"] == f"op_{max_entries + 4}", "Most recent entry must be last"
|
||||
|
||||
@@ -541,9 +533,7 @@ def test_log_operation_empty_dict_not_attached(tmp_path: Path) -> None:
|
||||
"""log_operation: empty dict data should not create non-empty data field."""
|
||||
json_handler.log_operation("no_data", data={}, module_name="emptymod")
|
||||
json_dir = _json_dir_as_path(tmp_path)
|
||||
log = json.loads(
|
||||
(json_dir / "emptymod_log.json").read_text(encoding="utf-8")
|
||||
)
|
||||
log = json.loads((json_dir / "emptymod_log.json").read_text(encoding="utf-8"))
|
||||
entry = log[-1]
|
||||
if "data" in entry:
|
||||
assert entry["data"] == {} or entry["data"] is None
|
||||
@@ -553,6 +543,7 @@ def test_log_operation_empty_dict_not_attached(tmp_path: Path) -> None:
|
||||
# Group 8 -- ensure_module (5 tests)
|
||||
# ============================================================================
|
||||
|
||||
|
||||
def test_ensure_module_creates_all_three(tmp_path: Path) -> None:
|
||||
"""ensure_module: creates config, data, and log files."""
|
||||
if not hasattr(json_handler, "ensure_module_jsons"):
|
||||
@@ -579,19 +570,13 @@ def test_ensure_module_files_pass_validation(tmp_path: Path) -> None:
|
||||
json_handler.ensure_module_jsons("valid_mod")
|
||||
json_dir = _json_dir_as_path(tmp_path)
|
||||
|
||||
config = json.loads(
|
||||
(json_dir / "valid_mod_config.json").read_text(encoding="utf-8")
|
||||
)
|
||||
config = json.loads((json_dir / "valid_mod_config.json").read_text(encoding="utf-8"))
|
||||
assert json_handler.validate_json_structure(config, "config") is True
|
||||
|
||||
data = json.loads(
|
||||
(json_dir / "valid_mod_data.json").read_text(encoding="utf-8")
|
||||
)
|
||||
data = json.loads((json_dir / "valid_mod_data.json").read_text(encoding="utf-8"))
|
||||
assert json_handler.validate_json_structure(data, "data") is True
|
||||
|
||||
log = json.loads(
|
||||
(json_dir / "valid_mod_log.json").read_text(encoding="utf-8")
|
||||
)
|
||||
log = json.loads((json_dir / "valid_mod_log.json").read_text(encoding="utf-8"))
|
||||
assert json_handler.validate_json_structure(log, "log") is True
|
||||
|
||||
|
||||
@@ -601,9 +586,7 @@ def test_ensure_module_data_has_correct_keys(tmp_path: Path) -> None:
|
||||
pytest.skip("Branch does not have ensure_module_jsons")
|
||||
json_handler.ensure_module_jsons("keymod")
|
||||
json_dir = _json_dir_as_path(tmp_path)
|
||||
data = json.loads(
|
||||
(json_dir / "keymod_data.json").read_text(encoding="utf-8")
|
||||
)
|
||||
data = json.loads((json_dir / "keymod_data.json").read_text(encoding="utf-8"))
|
||||
assert "created" in data, "Data file must have 'created' key"
|
||||
assert "last_updated" in data, "Data file must have 'last_updated' key"
|
||||
|
||||
@@ -614,8 +597,6 @@ def test_ensure_module_log_is_empty_list(tmp_path: Path) -> None:
|
||||
pytest.skip("Branch does not have ensure_module_jsons")
|
||||
json_handler.ensure_module_jsons("listmod")
|
||||
json_dir = _json_dir_as_path(tmp_path)
|
||||
log = json.loads(
|
||||
(json_dir / "listmod_log.json").read_text(encoding="utf-8")
|
||||
)
|
||||
log = json.loads((json_dir / "listmod_log.json").read_text(encoding="utf-8"))
|
||||
assert isinstance(log, list), "Log file must be a list"
|
||||
assert len(log) == 0, "Initial log file must be an empty list"
|
||||
|
||||
@@ -30,10 +30,11 @@ from aipass.daemon.apps.handlers.monitoring import memory_health as mh
|
||||
# HELPERS
|
||||
# =============================================
|
||||
|
||||
|
||||
def _write_json(path: Path, data: dict) -> None:
|
||||
"""Write a dict to a JSON file, creating parent dirs."""
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
with open(path, 'w', encoding='utf-8') as f:
|
||||
with open(path, "w", encoding="utf-8") as f:
|
||||
json.dump(data, f)
|
||||
|
||||
|
||||
@@ -67,6 +68,7 @@ def _setup_full_branch(tmp_path: Path) -> Path:
|
||||
# FILE EXISTENCE TESTS
|
||||
# =============================================
|
||||
|
||||
|
||||
class TestCheckMemoryFilesExist:
|
||||
"""Tests for check_memory_files_exist()."""
|
||||
|
||||
@@ -157,6 +159,7 @@ class TestCheckMemoryFilesExist:
|
||||
# STRUCTURE VALIDATION TESTS
|
||||
# =============================================
|
||||
|
||||
|
||||
class TestValidateMemoryStructure:
|
||||
"""Tests for validate_memory_structure()."""
|
||||
|
||||
@@ -177,12 +180,15 @@ class TestValidateMemoryStructure:
|
||||
def test_valid_structure_with_metadata_key(self, tmp_path: Path) -> None:
|
||||
"""File using 'metadata' key (instead of 'document_metadata') is valid."""
|
||||
f = tmp_path / "alt_meta.json"
|
||||
_write_json(f, {
|
||||
"metadata": {
|
||||
"version": "1.0.0",
|
||||
"limits": {"max_entries": 100},
|
||||
_write_json(
|
||||
f,
|
||||
{
|
||||
"metadata": {
|
||||
"version": "1.0.0",
|
||||
"limits": {"max_entries": 100},
|
||||
},
|
||||
},
|
||||
})
|
||||
)
|
||||
|
||||
result = mh.validate_memory_structure(str(f))
|
||||
|
||||
@@ -193,12 +199,15 @@ class TestValidateMemoryStructure:
|
||||
def test_missing_limits_field(self, tmp_path: Path) -> None:
|
||||
"""Metadata present but no limits field should report issue."""
|
||||
f = tmp_path / "no_limits.json"
|
||||
_write_json(f, {
|
||||
"document_metadata": {
|
||||
"document_type": "session_history",
|
||||
"version": "1.0.0",
|
||||
_write_json(
|
||||
f,
|
||||
{
|
||||
"document_metadata": {
|
||||
"document_type": "session_history",
|
||||
"version": "1.0.0",
|
||||
},
|
||||
},
|
||||
})
|
||||
)
|
||||
|
||||
result = mh.validate_memory_structure(str(f))
|
||||
|
||||
@@ -253,6 +262,7 @@ class TestValidateMemoryStructure:
|
||||
# FRESHNESS TESTS
|
||||
# =============================================
|
||||
|
||||
|
||||
class TestCheckFreshness:
|
||||
"""Tests for check_freshness()."""
|
||||
|
||||
@@ -346,6 +356,7 @@ class TestCheckFreshness:
|
||||
# OVERALL HEALTH STATUS TESTS
|
||||
# =============================================
|
||||
|
||||
|
||||
class TestGetMemoryHealthStatus:
|
||||
"""Tests for get_memory_health_status()."""
|
||||
|
||||
@@ -468,9 +479,14 @@ class TestGetMemoryHealthStatus:
|
||||
result = mh.get_memory_health_status(str(branch), "KEYS")
|
||||
|
||||
expected_keys = {
|
||||
"branch_name", "branch_path", "overall_status",
|
||||
"file_check", "structure_checks", "freshness_checks",
|
||||
"issues", "check_time",
|
||||
"branch_name",
|
||||
"branch_path",
|
||||
"overall_status",
|
||||
"file_check",
|
||||
"structure_checks",
|
||||
"freshness_checks",
|
||||
"issues",
|
||||
"check_time",
|
||||
}
|
||||
assert expected_keys == set(result.keys())
|
||||
assert isinstance(result["overall_status"], str)
|
||||
|
||||
@@ -15,12 +15,10 @@
|
||||
|
||||
"""Tests for the red flag detection engine."""
|
||||
|
||||
from datetime import datetime, timedelta
|
||||
from unittest.mock import patch, MagicMock
|
||||
from datetime import datetime
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
|
||||
from aipass.daemon.apps.handlers.monitoring import red_flag_detector
|
||||
from aipass.daemon.apps.handlers.monitoring.red_flag_detector import (
|
||||
_parse_iso_datetime,
|
||||
get_branch_status,
|
||||
@@ -68,6 +66,7 @@ def _make_activity(
|
||||
# _parse_iso_datetime TESTS
|
||||
# =============================================
|
||||
|
||||
|
||||
class TestParseIsoDatetime:
|
||||
"""Tests for ISO datetime string parsing."""
|
||||
|
||||
@@ -116,6 +115,7 @@ class TestParseIsoDatetime:
|
||||
# get_branch_status TESTS
|
||||
# =============================================
|
||||
|
||||
|
||||
class TestGetBranchStatus:
|
||||
"""Tests for single-branch status detection."""
|
||||
|
||||
@@ -239,10 +239,18 @@ class TestGetBranchStatus:
|
||||
mock_scan.return_value = _make_activity(code_files=[], memory_files=[])
|
||||
result = get_branch_status("TEST", "/fake/path/test")
|
||||
required_keys = {
|
||||
"branch_name", "branch_path", "status", "code_changes",
|
||||
"code_change_count", "latest_code_change", "memory_files_modified",
|
||||
"memory_last_update", "hours_since_code", "threshold_hours",
|
||||
"reason", "check_time",
|
||||
"branch_name",
|
||||
"branch_path",
|
||||
"status",
|
||||
"code_changes",
|
||||
"code_change_count",
|
||||
"latest_code_change",
|
||||
"memory_files_modified",
|
||||
"memory_last_update",
|
||||
"hours_since_code",
|
||||
"threshold_hours",
|
||||
"reason",
|
||||
"check_time",
|
||||
}
|
||||
assert required_keys.issubset(result.keys())
|
||||
|
||||
@@ -285,6 +293,7 @@ class TestGetBranchStatus:
|
||||
# detect_red_flags (scan all branches) TESTS
|
||||
# =============================================
|
||||
|
||||
|
||||
class TestScanAllBranches:
|
||||
"""Tests for multi-branch scanning and sorting via detect_red_flags."""
|
||||
|
||||
@@ -383,6 +392,7 @@ class TestScanAllBranches:
|
||||
# get_red_flag_summary TESTS
|
||||
# =============================================
|
||||
|
||||
|
||||
class TestGetRedFlagSummary:
|
||||
"""Tests for the get_red_flag_summary aggregation function."""
|
||||
|
||||
@@ -477,9 +487,16 @@ class TestGetRedFlagSummary:
|
||||
summary = get_red_flag_summary(since_timestamp=datetime(2026, 3, 1))
|
||||
|
||||
expected_keys = {
|
||||
"total_branches", "red_flags", "ok", "no_activity",
|
||||
"violations", "scan_time", "threshold_hours",
|
||||
"time_window_hours", "errors", "all_branches",
|
||||
"total_branches",
|
||||
"red_flags",
|
||||
"ok",
|
||||
"no_activity",
|
||||
"violations",
|
||||
"scan_time",
|
||||
"threshold_hours",
|
||||
"time_window_hours",
|
||||
"errors",
|
||||
"all_branches",
|
||||
}
|
||||
assert expected_keys.issubset(set(summary.keys()))
|
||||
|
||||
|
||||
@@ -28,6 +28,7 @@ MODULE = "aipass.daemon.apps.scheduler_cron"
|
||||
# FIXTURES
|
||||
# =============================================
|
||||
|
||||
|
||||
def _make_task(
|
||||
task_id: str = "abc12345-6789",
|
||||
recipient: str = "@devpulse",
|
||||
@@ -58,6 +59,7 @@ def _silence_logging():
|
||||
# _send_email_via_drone
|
||||
# =============================================
|
||||
|
||||
|
||||
class TestSendEmailViaDrone:
|
||||
"""Tests for _send_email_via_drone subprocess wrapper."""
|
||||
|
||||
@@ -109,6 +111,7 @@ class TestSendEmailViaDrone:
|
||||
# _next_cron_run
|
||||
# =============================================
|
||||
|
||||
|
||||
class TestNextCronRun:
|
||||
"""Tests for next cron run time calculation."""
|
||||
|
||||
@@ -147,6 +150,7 @@ class TestNextCronRun:
|
||||
# _process_single_task
|
||||
# =============================================
|
||||
|
||||
|
||||
class TestProcessSingleTask:
|
||||
"""Tests for the single-task dispatch function."""
|
||||
|
||||
@@ -242,6 +246,7 @@ class TestProcessSingleTask:
|
||||
# process_due_tasks
|
||||
# =============================================
|
||||
|
||||
|
||||
class TestProcessDueTasks:
|
||||
"""Tests for the top-level due-task processor."""
|
||||
|
||||
@@ -349,6 +354,7 @@ class TestProcessDueTasks:
|
||||
# _run_locked
|
||||
# =============================================
|
||||
|
||||
|
||||
class TestRunLocked:
|
||||
"""Tests for the locked orchestration function."""
|
||||
|
||||
@@ -357,8 +363,13 @@ class TestRunLocked:
|
||||
|
||||
task_results = {"due": 0, "success": 0, "failed": 0, "recovered": 0, "errors": []}
|
||||
action_results = {
|
||||
"total": 0, "enabled": 0, "executed": 0, "failed": 0,
|
||||
"errors": [], "executed_actions": [], "skipped_actions": [],
|
||||
"total": 0,
|
||||
"enabled": 0,
|
||||
"executed": 0,
|
||||
"failed": 0,
|
||||
"errors": [],
|
||||
"executed_actions": [],
|
||||
"skipped_actions": [],
|
||||
}
|
||||
|
||||
with (
|
||||
@@ -375,8 +386,13 @@ class TestRunLocked:
|
||||
|
||||
task_results = {"due": 1, "success": 0, "failed": 1, "recovered": 0, "errors": ["fail"]}
|
||||
action_results = {
|
||||
"total": 0, "enabled": 0, "executed": 0, "failed": 0,
|
||||
"errors": [], "executed_actions": [], "skipped_actions": [],
|
||||
"total": 0,
|
||||
"enabled": 0,
|
||||
"executed": 0,
|
||||
"failed": 0,
|
||||
"errors": [],
|
||||
"executed_actions": [],
|
||||
"skipped_actions": [],
|
||||
}
|
||||
|
||||
with (
|
||||
@@ -416,6 +432,7 @@ class TestRunLocked:
|
||||
# main
|
||||
# =============================================
|
||||
|
||||
|
||||
class TestMain:
|
||||
"""Tests for the main entry point."""
|
||||
|
||||
|
||||
@@ -17,6 +17,7 @@ MODULE = "aipass.daemon.apps.modules.scheduler_ops"
|
||||
# handle_command — routing
|
||||
# =============================================
|
||||
|
||||
|
||||
@patch(f"{MODULE}.json_handler")
|
||||
@patch(f"{MODULE}.console")
|
||||
@patch(f"{MODULE}.logger")
|
||||
@@ -84,6 +85,7 @@ class TestHandleCommand:
|
||||
# Module-level availability flags
|
||||
# =============================================
|
||||
|
||||
|
||||
class TestRegistryAvailability:
|
||||
"""Verify that registry imports succeed in the test environment."""
|
||||
|
||||
@@ -102,6 +104,7 @@ class TestRegistryAvailability:
|
||||
# print_introspection
|
||||
# =============================================
|
||||
|
||||
|
||||
@patch(f"{MODULE}.console")
|
||||
class TestPrintIntrospection:
|
||||
"""Tests for print_introspection output."""
|
||||
|
||||
@@ -52,6 +52,7 @@ def isolate_registry(tmp_path):
|
||||
# DATE PARSING TESTS
|
||||
# =============================================
|
||||
|
||||
|
||||
class TestParseDueDate:
|
||||
def test_days_format(self):
|
||||
"""'7d' should resolve to 7 days from today."""
|
||||
@@ -126,6 +127,7 @@ class TestParseDueDate:
|
||||
# LOAD / SAVE TESTS
|
||||
# =============================================
|
||||
|
||||
|
||||
class TestLoadSave:
|
||||
def test_load_creates_file_if_missing(self, isolate_registry):
|
||||
"""load_tasks should create schedule.json if it does not exist."""
|
||||
@@ -167,6 +169,7 @@ class TestLoadSave:
|
||||
# CREATE TASK TESTS
|
||||
# =============================================
|
||||
|
||||
|
||||
class TestCreateTask:
|
||||
@patch.object(_mod.json_handler, "log_operation")
|
||||
def test_create_basic(self, mock_log):
|
||||
@@ -219,16 +222,21 @@ class TestCreateTask:
|
||||
# DUE TASKS TESTS
|
||||
# =============================================
|
||||
|
||||
|
||||
class TestDueTasks:
|
||||
def test_overdue_task_returned(self, isolate_registry):
|
||||
"""A pending task with a past due_date should be returned."""
|
||||
yesterday = (datetime.now().date() - timedelta(days=1)).isoformat()
|
||||
save_tasks([{
|
||||
"id": "past01",
|
||||
"due_date": yesterday,
|
||||
"status": "pending",
|
||||
"task": "overdue",
|
||||
}])
|
||||
save_tasks(
|
||||
[
|
||||
{
|
||||
"id": "past01",
|
||||
"due_date": yesterday,
|
||||
"status": "pending",
|
||||
"task": "overdue",
|
||||
}
|
||||
]
|
||||
)
|
||||
due = get_due_tasks()
|
||||
assert len(due) == 1
|
||||
assert due[0]["id"] == "past01"
|
||||
@@ -236,12 +244,16 @@ class TestDueTasks:
|
||||
def test_today_task_returned(self, isolate_registry):
|
||||
"""A pending task due today should be returned."""
|
||||
today = datetime.now().date().isoformat()
|
||||
save_tasks([{
|
||||
"id": "today01",
|
||||
"due_date": today,
|
||||
"status": "pending",
|
||||
"task": "due today",
|
||||
}])
|
||||
save_tasks(
|
||||
[
|
||||
{
|
||||
"id": "today01",
|
||||
"due_date": today,
|
||||
"status": "pending",
|
||||
"task": "due today",
|
||||
}
|
||||
]
|
||||
)
|
||||
due = get_due_tasks()
|
||||
assert len(due) == 1
|
||||
assert due[0]["id"] == "today01"
|
||||
@@ -249,36 +261,48 @@ class TestDueTasks:
|
||||
def test_future_task_not_returned(self, isolate_registry):
|
||||
"""A pending task with a future due_date should not be returned."""
|
||||
future = (datetime.now().date() + timedelta(days=30)).isoformat()
|
||||
save_tasks([{
|
||||
"id": "future01",
|
||||
"due_date": future,
|
||||
"status": "pending",
|
||||
"task": "future task",
|
||||
}])
|
||||
save_tasks(
|
||||
[
|
||||
{
|
||||
"id": "future01",
|
||||
"due_date": future,
|
||||
"status": "pending",
|
||||
"task": "future task",
|
||||
}
|
||||
]
|
||||
)
|
||||
due = get_due_tasks()
|
||||
assert len(due) == 0
|
||||
|
||||
def test_dispatching_task_excluded(self, isolate_registry):
|
||||
"""Tasks with status 'dispatching' should not be returned."""
|
||||
yesterday = (datetime.now().date() - timedelta(days=1)).isoformat()
|
||||
save_tasks([{
|
||||
"id": "disp01",
|
||||
"due_date": yesterday,
|
||||
"status": "dispatching",
|
||||
"task": "already dispatching",
|
||||
}])
|
||||
save_tasks(
|
||||
[
|
||||
{
|
||||
"id": "disp01",
|
||||
"due_date": yesterday,
|
||||
"status": "dispatching",
|
||||
"task": "already dispatching",
|
||||
}
|
||||
]
|
||||
)
|
||||
due = get_due_tasks()
|
||||
assert len(due) == 0
|
||||
|
||||
def test_completed_task_excluded(self, isolate_registry):
|
||||
"""Tasks with status 'completed' should not be returned."""
|
||||
yesterday = (datetime.now().date() - timedelta(days=1)).isoformat()
|
||||
save_tasks([{
|
||||
"id": "done01",
|
||||
"due_date": yesterday,
|
||||
"status": "completed",
|
||||
"task": "done",
|
||||
}])
|
||||
save_tasks(
|
||||
[
|
||||
{
|
||||
"id": "done01",
|
||||
"due_date": yesterday,
|
||||
"status": "completed",
|
||||
"task": "done",
|
||||
}
|
||||
]
|
||||
)
|
||||
due = get_due_tasks()
|
||||
assert len(due) == 0
|
||||
|
||||
@@ -292,15 +316,20 @@ class TestDueTasks:
|
||||
# STATUS TRANSITION TESTS
|
||||
# =============================================
|
||||
|
||||
|
||||
class TestStatusTransitions:
|
||||
def _seed_task(self, task_id: str = "abc12345abcd1234", status: str = "pending"):
|
||||
"""Helper to seed a single task."""
|
||||
save_tasks([{
|
||||
"id": task_id,
|
||||
"task": "test",
|
||||
"status": status,
|
||||
"due_date": "2026-01-01",
|
||||
}])
|
||||
save_tasks(
|
||||
[
|
||||
{
|
||||
"id": task_id,
|
||||
"task": "test",
|
||||
"status": status,
|
||||
"due_date": "2026-01-01",
|
||||
}
|
||||
]
|
||||
)
|
||||
return task_id
|
||||
|
||||
def test_mark_dispatching_success(self):
|
||||
@@ -369,17 +398,22 @@ class TestStatusTransitions:
|
||||
# RECOVER STALE DISPATCHES TESTS
|
||||
# =============================================
|
||||
|
||||
|
||||
class TestRecoverStale:
|
||||
def test_recovers_stale_task(self, isolate_registry):
|
||||
"""Task stuck in dispatching beyond max_age should be reset."""
|
||||
stale_time = (datetime.now() - timedelta(minutes=10)).isoformat()
|
||||
save_tasks([{
|
||||
"id": "stale01",
|
||||
"task": "stale dispatch",
|
||||
"status": "dispatching",
|
||||
"dispatch_started": stale_time,
|
||||
"due_date": "2026-01-01",
|
||||
}])
|
||||
save_tasks(
|
||||
[
|
||||
{
|
||||
"id": "stale01",
|
||||
"task": "stale dispatch",
|
||||
"status": "dispatching",
|
||||
"dispatch_started": stale_time,
|
||||
"due_date": "2026-01-01",
|
||||
}
|
||||
]
|
||||
)
|
||||
recovered = recover_stale_dispatches(max_age_minutes=5)
|
||||
assert recovered == 1
|
||||
task = get_task_by_id("stale01")
|
||||
@@ -390,13 +424,17 @@ class TestRecoverStale:
|
||||
def test_does_not_recover_recent_dispatch(self, isolate_registry):
|
||||
"""Task dispatching within max_age should not be recovered."""
|
||||
recent_time = (datetime.now() - timedelta(minutes=1)).isoformat()
|
||||
save_tasks([{
|
||||
"id": "recent01",
|
||||
"task": "recent dispatch",
|
||||
"status": "dispatching",
|
||||
"dispatch_started": recent_time,
|
||||
"due_date": "2026-01-01",
|
||||
}])
|
||||
save_tasks(
|
||||
[
|
||||
{
|
||||
"id": "recent01",
|
||||
"task": "recent dispatch",
|
||||
"status": "dispatching",
|
||||
"dispatch_started": recent_time,
|
||||
"due_date": "2026-01-01",
|
||||
}
|
||||
]
|
||||
)
|
||||
recovered = recover_stale_dispatches(max_age_minutes=5)
|
||||
assert recovered == 0
|
||||
task = get_task_by_id("recent01")
|
||||
@@ -405,13 +443,17 @@ class TestRecoverStale:
|
||||
|
||||
def test_recovers_invalid_timestamp(self, isolate_registry):
|
||||
"""Task with unparseable dispatch_started should be recovered."""
|
||||
save_tasks([{
|
||||
"id": "bad_ts01",
|
||||
"task": "bad timestamp",
|
||||
"status": "dispatching",
|
||||
"dispatch_started": "not-a-date",
|
||||
"due_date": "2026-01-01",
|
||||
}])
|
||||
save_tasks(
|
||||
[
|
||||
{
|
||||
"id": "bad_ts01",
|
||||
"task": "bad timestamp",
|
||||
"status": "dispatching",
|
||||
"dispatch_started": "not-a-date",
|
||||
"due_date": "2026-01-01",
|
||||
}
|
||||
]
|
||||
)
|
||||
recovered = recover_stale_dispatches(max_age_minutes=5)
|
||||
assert recovered == 1
|
||||
task = get_task_by_id("bad_ts01")
|
||||
@@ -420,12 +462,16 @@ class TestRecoverStale:
|
||||
|
||||
def test_pending_tasks_untouched(self, isolate_registry):
|
||||
"""Pending tasks should not be affected by recovery."""
|
||||
save_tasks([{
|
||||
"id": "ok01",
|
||||
"task": "normal pending",
|
||||
"status": "pending",
|
||||
"due_date": "2026-01-01",
|
||||
}])
|
||||
save_tasks(
|
||||
[
|
||||
{
|
||||
"id": "ok01",
|
||||
"task": "normal pending",
|
||||
"status": "pending",
|
||||
"due_date": "2026-01-01",
|
||||
}
|
||||
]
|
||||
)
|
||||
recovered = recover_stale_dispatches(max_age_minutes=5)
|
||||
assert recovered == 0
|
||||
task = get_task_by_id("ok01")
|
||||
@@ -441,6 +487,7 @@ class TestRecoverStale:
|
||||
# DELETE TASK TESTS
|
||||
# =============================================
|
||||
|
||||
|
||||
class TestDeleteTask:
|
||||
def test_delete_existing(self, isolate_registry):
|
||||
"""Deleting an existing task returns True and removes it."""
|
||||
@@ -455,10 +502,12 @@ class TestDeleteTask:
|
||||
|
||||
def test_delete_preserves_other_tasks(self, isolate_registry):
|
||||
"""Deleting one task should leave others intact."""
|
||||
save_tasks([
|
||||
{"id": "keep01", "task": "keep this", "status": "pending"},
|
||||
{"id": "del02", "task": "delete this", "status": "pending"},
|
||||
])
|
||||
save_tasks(
|
||||
[
|
||||
{"id": "keep01", "task": "keep this", "status": "pending"},
|
||||
{"id": "del02", "task": "delete this", "status": "pending"},
|
||||
]
|
||||
)
|
||||
delete_task("del02")
|
||||
remaining = load_tasks()
|
||||
assert len(remaining) == 1
|
||||
@@ -473,27 +522,32 @@ class TestDeleteTask:
|
||||
# GET PENDING TASKS TESTS
|
||||
# =============================================
|
||||
|
||||
|
||||
class TestGetPendingTasks:
|
||||
"""Tests for get_pending_tasks()."""
|
||||
|
||||
def test_returns_only_pending(self, isolate_registry):
|
||||
"""Only tasks with status 'pending' are returned."""
|
||||
save_tasks([
|
||||
{"id": "pend01", "task": "pending one", "status": "pending"},
|
||||
{"id": "pend02", "task": "pending two", "status": "pending"},
|
||||
{"id": "done01", "task": "done", "status": "completed"},
|
||||
])
|
||||
save_tasks(
|
||||
[
|
||||
{"id": "pend01", "task": "pending one", "status": "pending"},
|
||||
{"id": "pend02", "task": "pending two", "status": "pending"},
|
||||
{"id": "done01", "task": "done", "status": "completed"},
|
||||
]
|
||||
)
|
||||
result = get_pending_tasks()
|
||||
assert len(result) == 2
|
||||
assert all(t["status"] == "pending" for t in result)
|
||||
|
||||
def test_excludes_dispatching_and_completed(self, isolate_registry):
|
||||
"""Tasks with dispatching or completed status are excluded."""
|
||||
save_tasks([
|
||||
{"id": "disp01", "task": "dispatching", "status": "dispatching"},
|
||||
{"id": "done01", "task": "completed", "status": "completed"},
|
||||
{"id": "pend01", "task": "pending", "status": "pending"},
|
||||
])
|
||||
save_tasks(
|
||||
[
|
||||
{"id": "disp01", "task": "dispatching", "status": "dispatching"},
|
||||
{"id": "done01", "task": "completed", "status": "completed"},
|
||||
{"id": "pend01", "task": "pending", "status": "pending"},
|
||||
]
|
||||
)
|
||||
result = get_pending_tasks()
|
||||
assert len(result) == 1
|
||||
assert result[0]["id"] == "pend01"
|
||||
@@ -508,6 +562,7 @@ class TestGetPendingTasks:
|
||||
# ENSURE LOCK DIR TESTS
|
||||
# =============================================
|
||||
|
||||
|
||||
class TestEnsureLockDir:
|
||||
"""Tests for ensure_lock_dir()."""
|
||||
|
||||
@@ -516,6 +571,7 @@ class TestEnsureLockDir:
|
||||
lock_dir = isolate_registry.parent
|
||||
if lock_dir.exists():
|
||||
import shutil
|
||||
|
||||
shutil.rmtree(lock_dir)
|
||||
assert not lock_dir.exists()
|
||||
|
||||
|
||||
@@ -48,16 +48,20 @@ class TestUpdateCommand:
|
||||
|
||||
def test_update_no_args_runs_digest(self) -> None:
|
||||
"""update with no args should run the digest, not show introspection."""
|
||||
with patch.object(_update_mod, "load_inbox", return_value={"messages": [], "total_messages": 0}), \
|
||||
patch.object(_update_mod, "load_local", return_value={}):
|
||||
with (
|
||||
patch.object(_update_mod, "load_inbox", return_value={"messages": [], "total_messages": 0}),
|
||||
patch.object(_update_mod, "load_local", return_value={}),
|
||||
):
|
||||
result = _update_mod.handle_command("update", [])
|
||||
assert result is True
|
||||
|
||||
def test_update_no_args_calls_load_inbox(self) -> None:
|
||||
"""update with no args should call load_inbox (proving it runs the digest)."""
|
||||
mock_inbox = MagicMock(return_value={"messages": [], "total_messages": 0})
|
||||
with patch.object(_update_mod, "load_inbox", mock_inbox), \
|
||||
patch.object(_update_mod, "load_local", return_value={}):
|
||||
with (
|
||||
patch.object(_update_mod, "load_inbox", mock_inbox),
|
||||
patch.object(_update_mod, "load_local", return_value={}),
|
||||
):
|
||||
_update_mod.handle_command("update", [])
|
||||
mock_inbox.assert_called_once()
|
||||
|
||||
|
||||
@@ -17,6 +17,7 @@ MODULE = "aipass.daemon.apps.modules.wakeup_ops"
|
||||
# handle_command — routing
|
||||
# =============================================
|
||||
|
||||
|
||||
@patch(f"{MODULE}.json_handler")
|
||||
@patch(f"{MODULE}.console")
|
||||
@patch(f"{MODULE}.logger")
|
||||
@@ -82,6 +83,7 @@ class TestHandleCommand:
|
||||
# print_introspection
|
||||
# =============================================
|
||||
|
||||
|
||||
@patch(f"{MODULE}.console")
|
||||
class TestPrintIntrospection:
|
||||
"""Tests for print_introspection output."""
|
||||
|
||||
@@ -35,7 +35,6 @@ def build_todo_section(branch_path: Path) -> bool:
|
||||
section_data: Dict = {
|
||||
"managed_by": "devpulse",
|
||||
"todo_count": 0,
|
||||
"todos": [],
|
||||
}
|
||||
return write_section(branch_path, "todo", section_data)
|
||||
|
||||
@@ -46,7 +45,6 @@ def build_todo_section(branch_path: Path) -> bool:
|
||||
section_data = {
|
||||
"managed_by": "devpulse",
|
||||
"todo_count": 0,
|
||||
"todos": [],
|
||||
}
|
||||
return write_section(branch_path, "todo", section_data)
|
||||
|
||||
@@ -55,7 +53,7 @@ def build_todo_section(branch_path: Path) -> bool:
|
||||
section_data = {
|
||||
"managed_by": "devpulse",
|
||||
"todo_count": len(todos),
|
||||
"todos": todos,
|
||||
# "todos": todos, # bodies live in .trinity/local.json — count-only here
|
||||
}
|
||||
|
||||
return write_section(branch_path, "todo", section_data)
|
||||
|
||||
@@ -243,8 +243,7 @@ class TestTodoSection:
|
||||
todo = dash["sections"]["todo"]
|
||||
assert todo["managed_by"] == "devpulse"
|
||||
assert todo["todo_count"] == 2
|
||||
assert len(todo["todos"]) == 2
|
||||
assert todo["todos"][0]["id"] == "t1"
|
||||
assert "todos" not in todo
|
||||
|
||||
def test_build_todo_section_empty_todos(self, branch_path):
|
||||
"""Test todo section with empty todos list."""
|
||||
@@ -260,7 +259,7 @@ class TestTodoSection:
|
||||
dash = json.loads((branch_path / "DASHBOARD.local.json").read_text())
|
||||
todo = dash["sections"]["todo"]
|
||||
assert todo["todo_count"] == 0
|
||||
assert todo["todos"] == []
|
||||
assert "todos" not in todo
|
||||
|
||||
def test_build_todo_section_no_local_json(self, branch_path):
|
||||
"""Test todo section when local.json doesn't exist."""
|
||||
@@ -272,7 +271,7 @@ class TestTodoSection:
|
||||
dash = json.loads((branch_path / "DASHBOARD.local.json").read_text())
|
||||
todo = dash["sections"]["todo"]
|
||||
assert todo["todo_count"] == 0
|
||||
assert todo["todos"] == []
|
||||
assert "todos" not in todo
|
||||
|
||||
def test_build_todo_section_corrupt_json(self, branch_path):
|
||||
"""Test todo section handles corrupt local.json."""
|
||||
@@ -303,7 +302,7 @@ class TestTodoSection:
|
||||
dash = json.loads((branch_path / "DASHBOARD.local.json").read_text())
|
||||
todo = dash["sections"]["todo"]
|
||||
assert todo["todo_count"] == 0
|
||||
assert todo["todos"] == []
|
||||
assert "todos" not in todo
|
||||
|
||||
|
||||
class TestRefresh:
|
||||
|
||||
Reference in New Issue
Block a user