mirror of
https://github.com/pocketpaw/pocketpaw.git
synced 2026-05-13 21:21:53 +00:00
Complete the package rename: src/pocketclaw/ → src/pocketpaw/, all imports, pyproject.toml entry point, docs code examples, installer references, and test patch targets updated. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
266 lines
9.3 KiB
Python
266 lines
9.3 KiB
Python
# Tests for SKIPPED task status — scheduler, manager progress, API
|
|
# Created: 2026-02-12
|
|
#
|
|
# Covers:
|
|
# - SKIPPED status unblocks dependent tasks (same as DONE)
|
|
# - SKIPPED tasks count in project completion check
|
|
# - Manager progress includes skipped in percent numerator
|
|
# - API skip endpoint sets status, cascades, returns progress
|
|
|
|
import tempfile
|
|
from pathlib import Path
|
|
from unittest.mock import AsyncMock, MagicMock
|
|
|
|
import pytest
|
|
|
|
from pocketpaw.deep_work.models import Project, ProjectStatus
|
|
from pocketpaw.deep_work.scheduler import DependencyScheduler
|
|
from pocketpaw.mission_control import (
|
|
FileMissionControlStore,
|
|
MissionControlManager,
|
|
reset_mission_control_manager,
|
|
reset_mission_control_store,
|
|
)
|
|
from pocketpaw.mission_control.models import Task, TaskStatus
|
|
|
|
# ============================================================================
|
|
# Fixtures — Scheduler (mocked)
|
|
# ============================================================================
|
|
|
|
|
|
@pytest.fixture
|
|
def mock_manager():
|
|
"""Mock MissionControlManager for scheduler tests."""
|
|
manager = AsyncMock()
|
|
manager.list_tasks = AsyncMock(return_value=[])
|
|
manager.get_project_tasks = AsyncMock(return_value=[])
|
|
manager.get_task = AsyncMock(return_value=None)
|
|
manager.get_project = AsyncMock(return_value=None)
|
|
manager.update_project = AsyncMock()
|
|
return manager
|
|
|
|
|
|
@pytest.fixture
|
|
def mock_executor():
|
|
"""Mock MCTaskExecutor."""
|
|
executor = AsyncMock()
|
|
executor.execute_task_background = AsyncMock()
|
|
executor.is_task_running = MagicMock(return_value=False)
|
|
return executor
|
|
|
|
|
|
@pytest.fixture
|
|
def mock_human_router():
|
|
"""Mock HumanTaskRouter."""
|
|
router = AsyncMock()
|
|
router.notify_human_task = AsyncMock()
|
|
router.notify_review_task = AsyncMock()
|
|
return router
|
|
|
|
|
|
@pytest.fixture
|
|
def scheduler(mock_manager, mock_executor, mock_human_router):
|
|
"""DependencyScheduler with mocked deps."""
|
|
return DependencyScheduler(mock_manager, mock_executor, mock_human_router)
|
|
|
|
|
|
def _make_task(
|
|
task_id: str,
|
|
status: TaskStatus = TaskStatus.INBOX,
|
|
project_id: str = "proj-1",
|
|
blocked_by: list[str] | None = None,
|
|
task_type: str = "agent",
|
|
assignee_ids: list[str] | None = None,
|
|
title: str = "",
|
|
) -> Task:
|
|
"""Helper to create a Task with specific fields."""
|
|
return Task(
|
|
id=task_id,
|
|
title=title or f"Task {task_id}",
|
|
status=status,
|
|
project_id=project_id,
|
|
blocked_by=blocked_by or [],
|
|
task_type=task_type,
|
|
assignee_ids=assignee_ids or [],
|
|
)
|
|
|
|
|
|
# ============================================================================
|
|
# Scheduler: SKIPPED unblocks dependents
|
|
# ============================================================================
|
|
|
|
|
|
class TestSkippedUnblocksDependents:
|
|
async def test_skipped_blocker_unblocks_dependent(self, scheduler, mock_manager):
|
|
"""A SKIPPED blocker should satisfy the blocked_by check."""
|
|
tasks = [
|
|
_make_task("t1", status=TaskStatus.SKIPPED),
|
|
_make_task("t2", status=TaskStatus.INBOX, blocked_by=["t1"]),
|
|
]
|
|
mock_manager.get_project_tasks.return_value = tasks
|
|
|
|
ready = await scheduler.get_ready_tasks("proj-1")
|
|
|
|
assert len(ready) == 1
|
|
assert ready[0].id == "t2"
|
|
|
|
async def test_mixed_done_and_skipped_unblocks(self, scheduler, mock_manager):
|
|
"""When blockers are a mix of DONE and SKIPPED, dependents should be ready."""
|
|
tasks = [
|
|
_make_task("t1", status=TaskStatus.DONE),
|
|
_make_task("t2", status=TaskStatus.SKIPPED),
|
|
_make_task("t3", status=TaskStatus.INBOX, blocked_by=["t1", "t2"]),
|
|
]
|
|
mock_manager.get_project_tasks.return_value = tasks
|
|
|
|
ready = await scheduler.get_ready_tasks("proj-1")
|
|
|
|
assert len(ready) == 1
|
|
assert ready[0].id == "t3"
|
|
|
|
async def test_partial_skip_partial_pending_blocks(self, scheduler, mock_manager):
|
|
"""If one blocker is SKIPPED but another is IN_PROGRESS, task is NOT ready."""
|
|
tasks = [
|
|
_make_task("t1", status=TaskStatus.SKIPPED),
|
|
_make_task("t2", status=TaskStatus.IN_PROGRESS),
|
|
_make_task("t3", status=TaskStatus.INBOX, blocked_by=["t1", "t2"]),
|
|
]
|
|
mock_manager.get_project_tasks.return_value = tasks
|
|
|
|
ready = await scheduler.get_ready_tasks("proj-1")
|
|
|
|
assert len(ready) == 0
|
|
|
|
|
|
# ============================================================================
|
|
# Scheduler: SKIPPED counts toward project completion
|
|
# ============================================================================
|
|
|
|
|
|
class TestSkippedProjectCompletion:
|
|
async def test_all_skipped_completes_project(self, scheduler, mock_manager):
|
|
"""Project with all tasks SKIPPED should be marked COMPLETED."""
|
|
project = Project(id="proj-1", title="All Skipped", status=ProjectStatus.EXECUTING)
|
|
tasks = [
|
|
_make_task("t1", status=TaskStatus.SKIPPED),
|
|
_make_task("t2", status=TaskStatus.SKIPPED),
|
|
]
|
|
|
|
mock_manager.get_task.return_value = tasks[0]
|
|
mock_manager.get_project_tasks.return_value = tasks
|
|
mock_manager.get_project.return_value = project
|
|
|
|
await scheduler.on_task_completed("t1")
|
|
|
|
mock_manager.update_project.assert_awaited_once()
|
|
updated = mock_manager.update_project.call_args[0][0]
|
|
assert updated.status == ProjectStatus.COMPLETED
|
|
|
|
async def test_mixed_done_skipped_completes_project(self, scheduler, mock_manager):
|
|
"""Project with DONE + SKIPPED tasks should be marked COMPLETED."""
|
|
project = Project(id="proj-1", title="Mixed", status=ProjectStatus.EXECUTING)
|
|
tasks = [
|
|
_make_task("t1", status=TaskStatus.DONE),
|
|
_make_task("t2", status=TaskStatus.SKIPPED),
|
|
]
|
|
|
|
mock_manager.get_task.return_value = tasks[0]
|
|
mock_manager.get_project_tasks.return_value = tasks
|
|
mock_manager.get_project.return_value = project
|
|
|
|
await scheduler.on_task_completed("t1")
|
|
|
|
mock_manager.update_project.assert_awaited_once()
|
|
updated = mock_manager.update_project.call_args[0][0]
|
|
assert updated.status == ProjectStatus.COMPLETED
|
|
|
|
|
|
# ============================================================================
|
|
# Manager: Progress includes skipped
|
|
# ============================================================================
|
|
|
|
# Fixtures for real store/manager
|
|
|
|
|
|
@pytest.fixture
|
|
def temp_store_path():
|
|
with tempfile.TemporaryDirectory() as tmpdir:
|
|
yield Path(tmpdir)
|
|
|
|
|
|
@pytest.fixture
|
|
def real_manager(temp_store_path):
|
|
"""Create a real manager with file store for progress tests."""
|
|
reset_mission_control_store()
|
|
reset_mission_control_manager()
|
|
store = FileMissionControlStore(temp_store_path)
|
|
return MissionControlManager(store)
|
|
|
|
|
|
class TestProgressWithSkipped:
|
|
@pytest.mark.asyncio
|
|
async def test_skipped_in_progress_count(self, real_manager):
|
|
"""Skipped tasks appear in progress.skipped and contribute to percent."""
|
|
project = await real_manager.create_project(title="Progress Test")
|
|
|
|
# 1: done agent
|
|
t1 = await real_manager.create_task(title="Done Task")
|
|
t1.project_id = project.id
|
|
t1.status = TaskStatus.DONE
|
|
await real_manager._store.save_task(t1)
|
|
|
|
# 2: skipped agent
|
|
t2 = await real_manager.create_task(title="Skipped Task")
|
|
t2.project_id = project.id
|
|
t2.status = TaskStatus.SKIPPED
|
|
await real_manager._store.save_task(t2)
|
|
|
|
# 3: inbox agent
|
|
t3 = await real_manager.create_task(title="Pending Task")
|
|
t3.project_id = project.id
|
|
t3.status = TaskStatus.INBOX
|
|
await real_manager._store.save_task(t3)
|
|
|
|
progress = await real_manager.get_project_progress(project.id)
|
|
|
|
assert progress["total"] == 3
|
|
assert progress["completed"] == 1
|
|
assert progress["skipped"] == 1
|
|
assert progress["percent"] == pytest.approx(66.7, abs=0.1) # (1+1)/3 * 100
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_skipped_human_not_in_pending(self, real_manager):
|
|
"""A skipped human task should NOT count in human_pending."""
|
|
project = await real_manager.create_project(title="Human Skip Test")
|
|
|
|
t1 = await real_manager.create_task(title="Human Skipped")
|
|
t1.project_id = project.id
|
|
t1.task_type = "human"
|
|
t1.status = TaskStatus.SKIPPED
|
|
await real_manager._store.save_task(t1)
|
|
|
|
progress = await real_manager.get_project_progress(project.id)
|
|
|
|
assert progress["human_pending"] == 0
|
|
assert progress["skipped"] == 1
|
|
|
|
|
|
# ============================================================================
|
|
# TaskStatus enum: SKIPPED value
|
|
# ============================================================================
|
|
|
|
|
|
class TestSkippedEnum:
|
|
def test_skipped_status_exists(self):
|
|
"""TaskStatus.SKIPPED should have value 'skipped'."""
|
|
assert TaskStatus.SKIPPED.value == "skipped"
|
|
|
|
def test_skipped_round_trip(self):
|
|
"""Task with SKIPPED status should serialize and deserialize correctly."""
|
|
task = Task(id="skip-test", title="Skip Me", status=TaskStatus.SKIPPED)
|
|
data = task.to_dict()
|
|
assert data["status"] == "skipped"
|
|
|
|
restored = Task.from_dict(data)
|
|
assert restored.status == TaskStatus.SKIPPED
|