auto-sync: 2026-06-05 11:03:30
This commit is contained in:
@@ -0,0 +1,202 @@
|
||||
"""F5 测试:API 层"""
|
||||
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from src.blackboard.operations import Blackboard
|
||||
from src.blackboard.models import Task
|
||||
from src.blackboard.registry import ProjectRegistry
|
||||
from src.main import app
|
||||
|
||||
pytestmark = pytest.mark.integration
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def project_env(tmp_path):
|
||||
"""创建临时项目环境"""
|
||||
project_root = tmp_path / "projects"
|
||||
project_root.mkdir()
|
||||
os.environ["BLACKBOARD_ROOT"] = str(project_root)
|
||||
|
||||
# Create a test project with registry + DB
|
||||
reg = ProjectRegistry(project_root)
|
||||
reg.create_project("test-proj", "Test Project", agents=["agent1"])
|
||||
|
||||
bb = Blackboard(project_root / "test-proj" / "blackboard.db")
|
||||
bb.create_task(Task(id="t1", title="Existing Task", task_type="coding"))
|
||||
|
||||
yield project_root
|
||||
del os.environ["BLACKBOARD_ROOT"]
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def client():
|
||||
return TestClient(app)
|
||||
|
||||
|
||||
# ===================================================================
|
||||
# Daemon API
|
||||
# ===================================================================
|
||||
|
||||
class TestDaemonAPI:
|
||||
def test_status(self, client):
|
||||
resp = client.get("/api/daemon/status")
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["status"] == "running"
|
||||
|
||||
def test_manual_tick(self, client):
|
||||
resp = client.post("/api/daemon/tick")
|
||||
assert resp.status_code == 200
|
||||
|
||||
|
||||
# ===================================================================
|
||||
# Project API
|
||||
# ===================================================================
|
||||
|
||||
class TestProjectAPI:
|
||||
def test_list_projects(self, client, project_env):
|
||||
resp = client.get("/api/projects")
|
||||
assert resp.status_code == 200
|
||||
assert "test-proj" in resp.json()["projects"]
|
||||
|
||||
def test_create_project(self, client, tmp_path):
|
||||
root = tmp_path / "new_root"
|
||||
root.mkdir()
|
||||
os.environ["BLACKBOARD_ROOT"] = str(root)
|
||||
try:
|
||||
resp = client.post("/api/projects", json={
|
||||
"id": "new-proj", "name": "New",
|
||||
"agents": ["a1"], "description": "test",
|
||||
})
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["ok"]
|
||||
finally:
|
||||
del os.environ["BLACKBOARD_ROOT"]
|
||||
|
||||
def test_create_duplicate(self, client, project_env):
|
||||
resp = client.post("/api/projects", json={
|
||||
"id": "test-proj", "name": "Dup",
|
||||
})
|
||||
assert resp.status_code == 409
|
||||
|
||||
def test_get_project(self, client, project_env):
|
||||
resp = client.get("/api/projects/test-proj")
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["name"] == "Test Project"
|
||||
|
||||
def test_get_nonexistent(self, client, project_env):
|
||||
resp = client.get("/api/projects/nope")
|
||||
assert resp.status_code == 404
|
||||
|
||||
|
||||
# ===================================================================
|
||||
# Blackboard API
|
||||
# ===================================================================
|
||||
|
||||
class TestBlackboardAPI:
|
||||
def test_list_tasks(self, client, project_env):
|
||||
resp = client.get("/api/projects/test-proj/tasks")
|
||||
assert resp.status_code == 200
|
||||
assert len(resp.json()["tasks"]) == 1
|
||||
|
||||
def test_get_task(self, client, project_env):
|
||||
resp = client.get("/api/projects/test-proj/tasks/t1")
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["title"] == "Existing Task"
|
||||
|
||||
def test_get_task_404(self, client, project_env):
|
||||
resp = client.get("/api/projects/test-proj/tasks/nope")
|
||||
assert resp.status_code == 404
|
||||
|
||||
def test_create_task(self, client, project_env):
|
||||
resp = client.post("/api/projects/test-proj/tasks", json={
|
||||
"id": "t2", "title": "New Task", "task_type": "review",
|
||||
})
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["task_id"] == "t2"
|
||||
|
||||
def test_claim_task(self, client, project_env):
|
||||
resp = client.post("/api/projects/test-proj/tasks/t1/claim",
|
||||
json={"agent": "agent1"})
|
||||
assert resp.status_code == 200
|
||||
|
||||
def test_update_status(self, client, project_env):
|
||||
# Claim first
|
||||
client.post("/api/projects/test-proj/tasks/t1/claim",
|
||||
json={"agent": "agent1"})
|
||||
resp = client.post("/api/projects/test-proj/tasks/t1/status",
|
||||
json={"status": "working", "agent": "agent1"})
|
||||
assert resp.status_code == 200
|
||||
|
||||
def test_invalid_status_transition(self, client, project_env):
|
||||
resp = client.post("/api/projects/test-proj/tasks/t1/status",
|
||||
json={"status": "done"})
|
||||
assert resp.status_code == 409
|
||||
|
||||
def test_add_comment(self, client, project_env):
|
||||
resp = client.post("/api/projects/test-proj/tasks/t1/comments", json={
|
||||
"author": "pangtong", "body": "Nice", "comment_type": "general",
|
||||
"mentions": ["agent1"],
|
||||
})
|
||||
assert resp.status_code == 200
|
||||
|
||||
def test_get_comments(self, client, project_env):
|
||||
client.post("/api/projects/test-proj/tasks/t1/comments", json={
|
||||
"author": "a", "body": "Hello",
|
||||
})
|
||||
resp = client.get("/api/projects/test-proj/tasks/t1/comments")
|
||||
assert resp.status_code == 200
|
||||
assert len(resp.json()["comments"]) == 1
|
||||
|
||||
def test_write_output(self, client, project_env):
|
||||
resp = client.post("/api/projects/test-proj/tasks/t1/outputs", json={
|
||||
"agent": "agent1", "type": "code", "title": "main.py",
|
||||
})
|
||||
assert resp.status_code == 200
|
||||
|
||||
def test_add_decision(self, client, project_env):
|
||||
resp = client.post("/api/projects/test-proj/tasks/t1/decisions", json={
|
||||
"decider": "pangtong", "decision": "Use X",
|
||||
"rationale": "Better",
|
||||
})
|
||||
assert resp.status_code == 200
|
||||
|
||||
def test_add_observation(self, client, project_env):
|
||||
resp = client.post("/api/projects/test-proj/tasks/t1/observations", json={
|
||||
"observer": "simayi", "body": "Warning!", "severity": "warning",
|
||||
})
|
||||
assert resp.status_code == 200
|
||||
|
||||
def test_add_review(self, client, project_env):
|
||||
resp = client.post("/api/projects/test-proj/tasks/t1/reviews", json={
|
||||
"id": "rev-1", "reviewer": "simayi",
|
||||
"review_type": "output_review", "verdict": "approved",
|
||||
"summary": "LGTM", "confidence": 0.9,
|
||||
})
|
||||
assert resp.status_code == 200
|
||||
|
||||
def test_get_events(self, client, project_env):
|
||||
resp = client.get("/api/projects/test-proj/events")
|
||||
assert resp.status_code == 200
|
||||
assert "events" in resp.json()
|
||||
|
||||
def test_summary(self, client, project_env):
|
||||
resp = client.get("/api/projects/test-proj/summary")
|
||||
assert resp.status_code == 200
|
||||
assert "pending" in resp.json()["summary"]
|
||||
|
||||
|
||||
# ===================================================================
|
||||
# SSE
|
||||
# ===================================================================
|
||||
|
||||
class TestSSE:
|
||||
def test_sse_endpoint_registered(self):
|
||||
"""SSE 路由已注册(详细测试见 test_sse.py)"""
|
||||
from src.main import app
|
||||
routes = [r.path for r in app.routes]
|
||||
assert "/api/events" in routes
|
||||
@@ -0,0 +1,128 @@
|
||||
"""F9 Agent 调度器集成测试 — rollback/on_complete DB 交互"""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import pytest
|
||||
from pathlib import Path
|
||||
|
||||
from src.blackboard.models import Task
|
||||
from src.blackboard.operations import Blackboard
|
||||
from src.daemon.dispatcher import Dispatcher
|
||||
|
||||
pytestmark = pytest.mark.integration
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 司马懿评审补充:_rollback_current_agent + on_complete 统一(v2.8 #07.2)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestRollbackAndOnComplete:
|
||||
"""司马懿评审遗漏 #3 + #4: crash 后 current_agent 回退 + on_complete 统一路径"""
|
||||
|
||||
def test_rollback_current_agent_on_crash(self, tmp_path):
|
||||
"""executor crash → _rollback_current_agent 回退 current_agent → assignee"""
|
||||
db_path = tmp_path / "blackboard.db"
|
||||
bb = Blackboard(db_path)
|
||||
bb.create_task(Task(
|
||||
id="t1", title="T", status="working",
|
||||
assigned_by="daemon", assignee="zhangfei-dev",
|
||||
))
|
||||
|
||||
conn = bb._conn()
|
||||
try:
|
||||
conn.execute("UPDATE tasks SET current_agent=? WHERE id=?", ("zhangfei-dev", "t1"))
|
||||
conn.commit()
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
dispatcher = Dispatcher(registered_agents=["zhangfei-dev"])
|
||||
dispatcher._rollback_current_agent(db_path, "t1", "zhangfei-dev")
|
||||
|
||||
import sqlite3
|
||||
conn2 = sqlite3.connect(str(db_path))
|
||||
conn2.row_factory = sqlite3.Row
|
||||
row = conn2.execute("SELECT current_agent, assignee FROM tasks WHERE id=?", ("t1",)).fetchone()
|
||||
conn2.close()
|
||||
|
||||
assert row["current_agent"] == row["assignee"] == "zhangfei-dev"
|
||||
|
||||
def test_rollback_different_agent(self, tmp_path):
|
||||
"""current_agent ≠ agent_id → 不回退(安全检查)"""
|
||||
db_path = tmp_path / "blackboard.db"
|
||||
bb = Blackboard(db_path)
|
||||
bb.create_task(Task(
|
||||
id="t1", title="T", status="working",
|
||||
assigned_by="daemon", assignee="zhangfei-dev",
|
||||
))
|
||||
|
||||
conn = bb._conn()
|
||||
try:
|
||||
conn.execute("UPDATE tasks SET current_agent=? WHERE id=?", ("simayi-challenger", "t1"))
|
||||
conn.commit()
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
dispatcher = Dispatcher(registered_agents=["zhangfei-dev"])
|
||||
dispatcher._rollback_current_agent(db_path, "t1", "wrong-agent")
|
||||
|
||||
import sqlite3
|
||||
conn2 = sqlite3.connect(str(db_path))
|
||||
conn2.row_factory = sqlite3.Row
|
||||
row = conn2.execute("SELECT current_agent FROM tasks WHERE id=?", ("t1",)).fetchone()
|
||||
conn2.close()
|
||||
|
||||
assert row["current_agent"] == "simayi-challenger"
|
||||
|
||||
def test_on_complete_crash_rollback_executor(self, tmp_path):
|
||||
"""executor crash → rollback current_agent + _task_auto_complete(标 review)"""
|
||||
db_path = tmp_path / "blackboard.db"
|
||||
bb = Blackboard(db_path)
|
||||
bb.create_task(Task(
|
||||
id="t1", title="T", status="working",
|
||||
assigned_by="daemon", assignee="zhangfei-dev",
|
||||
))
|
||||
|
||||
conn = bb._conn()
|
||||
try:
|
||||
conn.execute("UPDATE tasks SET current_agent=? WHERE id=?", ("zhangfei-dev", "t1"))
|
||||
conn.commit()
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
dispatcher = Dispatcher(registered_agents=["zhangfei-dev"])
|
||||
dispatcher._rollback_current_agent(db_path, "t1", "zhangfei-dev")
|
||||
|
||||
import sqlite3
|
||||
conn2 = sqlite3.connect(str(db_path))
|
||||
conn2.row_factory = sqlite3.Row
|
||||
row = conn2.execute("SELECT current_agent FROM tasks WHERE id=?", ("t1",)).fetchone()
|
||||
conn2.close()
|
||||
assert row["current_agent"] == "zhangfei-dev"
|
||||
|
||||
def test_on_complete_crash_rollback_review(self, tmp_path):
|
||||
"""review crash → rollback current_agent + 保持 review 状态"""
|
||||
db_path = tmp_path / "blackboard.db"
|
||||
bb = Blackboard(db_path)
|
||||
bb.create_task(Task(
|
||||
id="t1", title="T", status="review",
|
||||
assigned_by="daemon", assignee="zhangfei-dev",
|
||||
))
|
||||
|
||||
conn = bb._conn()
|
||||
try:
|
||||
conn.execute("UPDATE tasks SET current_agent=? WHERE id=?", ("simayi-challenger", "t1"))
|
||||
conn.commit()
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
dispatcher = Dispatcher(registered_agents=["simayi-challenger"])
|
||||
dispatcher._rollback_current_agent(db_path, "t1", "simayi-challenger")
|
||||
|
||||
import sqlite3
|
||||
conn2 = sqlite3.connect(str(db_path))
|
||||
conn2.row_factory = sqlite3.Row
|
||||
row = conn2.execute("SELECT current_agent, status FROM tasks WHERE id=?", ("t1",)).fetchone()
|
||||
conn2.close()
|
||||
|
||||
assert row["current_agent"] == "zhangfei-dev"
|
||||
assert row["status"] == "review"
|
||||
@@ -0,0 +1,74 @@
|
||||
"""F1 测试:项目骨架 + 配置体系"""
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from src.main import app, config, load_config
|
||||
|
||||
pytestmark = pytest.mark.integration
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def client():
|
||||
return TestClient(app)
|
||||
|
||||
|
||||
class TestHealthEndpoint:
|
||||
"""健康端点"""
|
||||
|
||||
def test_status_returns_200(self, client):
|
||||
resp = client.get("/api/daemon/status")
|
||||
assert resp.status_code == 200
|
||||
|
||||
def test_status_has_required_fields(self, client):
|
||||
data = client.get("/api/daemon/status").json()
|
||||
assert data["status"] == "running"
|
||||
assert data["version"] == "3.0.0"
|
||||
assert "ticker_running" in data
|
||||
assert "config" in data
|
||||
|
||||
def test_status_config_values(self, client):
|
||||
data = client.get("/api/daemon/status").json()
|
||||
assert data["config"]["tick_interval"] == 30
|
||||
assert data["config"]["max_global_agents"] == 5
|
||||
|
||||
|
||||
class TestProjectsEndpoint:
|
||||
"""项目列表端点(占位)"""
|
||||
|
||||
def test_list_projects_returns_200(self, client):
|
||||
resp = client.get("/api/projects")
|
||||
assert resp.status_code == 200
|
||||
assert "projects" in resp.json()
|
||||
|
||||
|
||||
class TestConfig:
|
||||
"""配置加载"""
|
||||
|
||||
def test_config_loaded(self):
|
||||
assert isinstance(config, dict)
|
||||
|
||||
def test_config_has_daemon_section(self):
|
||||
assert "daemon" in config
|
||||
assert config["daemon"]["tick_interval"] == 30
|
||||
|
||||
def test_config_has_inbox_section(self):
|
||||
assert "inbox" in config
|
||||
|
||||
def test_load_config_returns_dict(self):
|
||||
cfg = load_config()
|
||||
assert isinstance(cfg, dict)
|
||||
|
||||
|
||||
class TestOpenAPI:
|
||||
"""Swagger 文档"""
|
||||
|
||||
def test_openapi_json_accessible(self, client):
|
||||
resp = client.get("/openapi.json")
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["info"]["title"] == "Sanguo MoziPlus v2"
|
||||
|
||||
def test_docs_endpoint_accessible(self, client):
|
||||
resp = client.get("/docs")
|
||||
assert resp.status_code == 200
|
||||
@@ -0,0 +1,178 @@
|
||||
"""F12 Review Pipeline + F13 Guardrail 集成测试
|
||||
|
||||
按 test-plan-v2.6.md §F12-F13:
|
||||
- F12 T1: 验证流水线四步(P0)
|
||||
- F12 T2: 评分计算(P0)
|
||||
- F13 T1: Guardrail 门控(P0)
|
||||
"""
|
||||
|
||||
import json
|
||||
import pytest
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
from src.blackboard.models import Task
|
||||
from src.blackboard.operations import Blackboard
|
||||
from src.daemon.review import (
|
||||
ReviewPipeline,
|
||||
ReviewResult,
|
||||
ReviewVerdict,
|
||||
)
|
||||
|
||||
pytestmark = pytest.mark.integration
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def db_path(tmp_path):
|
||||
return tmp_path / "blackboard.db"
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def bb(db_path):
|
||||
return Blackboard(db_path)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def pipeline():
|
||||
return ReviewPipeline()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def pipeline_with_bb(bb):
|
||||
return ReviewPipeline(bb=bb)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def low_risk_task():
|
||||
return Task(id="t1", title="Low Risk", status="pending",
|
||||
assigned_by="d", risk_level="low")
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def high_risk_task():
|
||||
return Task(id="t2", title="High Risk", status="pending",
|
||||
assigned_by="d", risk_level="high")
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def critical_task():
|
||||
return Task(id="t3", title="Critical", status="pending",
|
||||
assigned_by="d", risk_level="critical")
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def task_with_outputs(tmp_path):
|
||||
"""有真实产出的 task"""
|
||||
out_md = tmp_path / "output.md"
|
||||
out_md.write_text("# Result\n\nThis is a valid output with enough content.")
|
||||
out_json = tmp_path / "result.json"
|
||||
out_json.write_text(json.dumps({"status": "ok", "value": 42}))
|
||||
return [
|
||||
{"path": str(out_md), "type": "markdown"},
|
||||
{"path": str(out_json), "type": "json"},
|
||||
]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# F12 T1: 验证流水线四步
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestReviewPipeline:
|
||||
def test_no_outputs_fails(self, pipeline, low_risk_task):
|
||||
result = pipeline.run_review(low_risk_task, outputs=None)
|
||||
assert result["verdict"] == "fail"
|
||||
assert result["score"] == 0.0
|
||||
|
||||
def test_empty_outputs_fails(self, pipeline, low_risk_task):
|
||||
result = pipeline.run_review(low_risk_task, outputs=[])
|
||||
assert result["verdict"] == "fail"
|
||||
|
||||
def test_existing_outputs_pass(self, pipeline, low_risk_task, task_with_outputs):
|
||||
result = pipeline.run_review(low_risk_task, outputs=task_with_outputs)
|
||||
assert result["verdict"] == "pass"
|
||||
|
||||
def test_missing_file_fails(self, pipeline, low_risk_task):
|
||||
outputs = [{"path": "/nonexistent/file.md", "type": "markdown"}]
|
||||
result = pipeline.run_review(low_risk_task, outputs=outputs)
|
||||
assert result["verdict"] == "fail"
|
||||
|
||||
def test_results_have_all_steps(self, pipeline, low_risk_task, task_with_outputs):
|
||||
result = pipeline.run_review(low_risk_task, outputs=task_with_outputs)
|
||||
steps = {r["step"] for r in result["results"]}
|
||||
assert "existence" in steps
|
||||
assert "format" in steps
|
||||
assert "quality" in steps
|
||||
|
||||
def test_observation_recorded(self, pipeline_with_bb, low_risk_task, task_with_outputs):
|
||||
pipeline_with_bb.bb.create_task(low_risk_task)
|
||||
pipeline_with_bb.run_review(low_risk_task, outputs=task_with_outputs)
|
||||
obs = pipeline_with_bb.bb.get_observations(task_id=low_risk_task.id)
|
||||
assert any("review-pipeline" in (o.observer or "") for o in obs)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# F12 T2: 评分计算
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestScoring:
|
||||
def test_perfect_score(self, pipeline, low_risk_task, task_with_outputs):
|
||||
result = pipeline.run_review(low_risk_task, outputs=task_with_outputs)
|
||||
assert result["score"] >= 0.9
|
||||
|
||||
def test_custom_check_boosts_score(self, low_risk_task, task_with_outputs):
|
||||
def good_check(task, outputs):
|
||||
return {"score": 1.0, "suggestions": []}
|
||||
|
||||
p = ReviewPipeline(custom_checks={"custom": good_check})
|
||||
result = p.run_review(low_risk_task, outputs=task_with_outputs)
|
||||
assert result["score"] >= 0.9
|
||||
|
||||
def test_custom_check_lowers_score(self, low_risk_task, task_with_outputs):
|
||||
def bad_check(task, outputs):
|
||||
return {"score": 0.2, "suggestions": ["Improve X"]}
|
||||
|
||||
p = ReviewPipeline(custom_checks={"custom": bad_check})
|
||||
result = p.run_review(low_risk_task, outputs=task_with_outputs)
|
||||
assert result["score"] < 1.0
|
||||
|
||||
def test_custom_check_exception_handled(self, low_risk_task, task_with_outputs):
|
||||
def error_check(task, outputs):
|
||||
raise ValueError("test error")
|
||||
|
||||
p = ReviewPipeline(custom_checks={"bad": error_check})
|
||||
result = p.run_review(low_risk_task, outputs=task_with_outputs)
|
||||
assert result["verdict"] in ("pass", "fail")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# F13 T1: Guardrail 门控
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestGuardrail:
|
||||
def test_low_risk_auto(self, pipeline, low_risk_task, task_with_outputs):
|
||||
result = pipeline.run_review(low_risk_task, outputs=task_with_outputs)
|
||||
assert result["gate"] == "auto"
|
||||
assert result["needs_human"] is False
|
||||
|
||||
def test_high_risk_mandatory(self, pipeline, high_risk_task, task_with_outputs):
|
||||
result = pipeline.run_review(high_risk_task, outputs=task_with_outputs)
|
||||
assert result["gate"] == "mandatory"
|
||||
assert result["needs_human"] is True
|
||||
|
||||
def test_critical_dual(self, pipeline, critical_task, task_with_outputs):
|
||||
result = pipeline.run_review(critical_task, outputs=task_with_outputs)
|
||||
assert result["gate"] == "dual"
|
||||
assert result["needs_human"] is True
|
||||
|
||||
def test_fail_makes_mandatory(self, pipeline, low_risk_task):
|
||||
"""低风险但验证失败 → mandatory"""
|
||||
result = pipeline.run_review(low_risk_task, outputs=None)
|
||||
assert result["gate"] == "mandatory"
|
||||
|
||||
def test_medium_risk_optional(self):
|
||||
task = Task(id="t", title="T", status="pending",
|
||||
assigned_by="d", risk_level="medium")
|
||||
p = ReviewPipeline()
|
||||
outputs = [{"content": "valid output here", "type": "text"}]
|
||||
result = p.run_review(task, outputs=outputs)
|
||||
assert result["gate"] == "optional"
|
||||
@@ -0,0 +1,73 @@
|
||||
"""F9 Agent Spawner 集成测试 — 超时/失败/spawn 真实流程"""
|
||||
|
||||
import asyncio
|
||||
import pytest
|
||||
from pathlib import Path
|
||||
|
||||
from src.blackboard.operations import Blackboard
|
||||
from src.daemon.spawner import AgentSpawner
|
||||
|
||||
pytestmark = pytest.mark.integration
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def db_path(tmp_path):
|
||||
return tmp_path / "blackboard.db"
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def bb(db_path):
|
||||
return Blackboard(db_path)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def real_spawner(db_path):
|
||||
return AgentSpawner(db_path=db_path, dry_run=False, agent_timeout=2.0)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# T2: 超时处理
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestTimeout:
|
||||
def test_timeout_kills_process(self, tmp_path):
|
||||
"""超时后 kill 进程"""
|
||||
db_path = tmp_path / "blackboard.db"
|
||||
Blackboard(db_path) # init
|
||||
spawner = AgentSpawner(db_path=db_path, dry_run=False, agent_timeout=0.5)
|
||||
|
||||
# Spawn a long-running process (sleep 10)
|
||||
session_id = asyncio.run(
|
||||
spawner.spawn_full_agent(
|
||||
"test-agent",
|
||||
"sleep 10",
|
||||
task_id=None,
|
||||
)
|
||||
)
|
||||
# Wait for timeout
|
||||
asyncio.run(asyncio.sleep(1.0))
|
||||
|
||||
session = spawner.get_session(session_id)
|
||||
if session:
|
||||
assert session["status"] in ("timed_out", "running", "completed")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# T3: spawn 失败
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestSpawnFailure:
|
||||
def test_nonexistent_command(self, real_spawner, db_path, bb):
|
||||
"""命令不存在 → spawn_failed"""
|
||||
bb.create_task(
|
||||
__import__("src.blackboard.models", fromlist=["Task"]).Task(
|
||||
id="t1", title="T", status="pending", assigned_by="d"
|
||||
)
|
||||
)
|
||||
|
||||
try:
|
||||
asyncio.run(
|
||||
real_spawner.spawn_full_agent("test", "msg", task_id="t1")
|
||||
)
|
||||
except Exception:
|
||||
pass # Expected - command may fail
|
||||
@@ -0,0 +1,268 @@
|
||||
"""F17 SSE + Hook 单元测试
|
||||
|
||||
按 test-plan-v2.6.md §F17:
|
||||
- T1: SSE 事件推送(P0)
|
||||
- T2: Hook 注册/触发(P0)
|
||||
- T3: 回调 Hook(P0)
|
||||
- T4: 错误处理(P1)
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import pytest
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
|
||||
pytestmark = pytest.mark.integration
|
||||
|
||||
from src.daemon.sse import (
|
||||
Hook,
|
||||
HookManager,
|
||||
HookType,
|
||||
SSEBroker,
|
||||
SSEEvent,
|
||||
SSEEventType,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# SSE
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestSSEEvent:
|
||||
def test_to_sse_format(self):
|
||||
event = SSEEvent("task_created", {"task_id": "t1"})
|
||||
sse = event.to_sse()
|
||||
assert sse.startswith("id: ")
|
||||
assert "event: task_created" in sse
|
||||
assert '"task_id": "t1"' in sse
|
||||
assert sse.endswith("\n\n")
|
||||
|
||||
def test_custom_event_id(self):
|
||||
event = SSEEvent("test", {}, event_id="my-id")
|
||||
assert event.id == "my-id"
|
||||
assert "id: my-id" in event.to_sse()
|
||||
|
||||
|
||||
class TestSSEBroker:
|
||||
def test_subscribe_returns_queue(self):
|
||||
broker = SSEBroker()
|
||||
async def _test():
|
||||
cid, queue = broker.subscribe()
|
||||
assert cid
|
||||
assert isinstance(queue, asyncio.Queue)
|
||||
asyncio.run(_test())
|
||||
|
||||
def test_publish_to_subscriber(self):
|
||||
broker = SSEBroker()
|
||||
async def _test():
|
||||
cid, queue = broker.subscribe()
|
||||
delivered = await broker.publish("task_created", {"id": "t1"})
|
||||
assert delivered == 1
|
||||
event = queue.get_nowait()
|
||||
assert event.event_type == "task_created"
|
||||
assert event.data["id"] == "t1"
|
||||
asyncio.run(_test())
|
||||
|
||||
def test_unsubscribe(self):
|
||||
broker = SSEBroker()
|
||||
async def _test():
|
||||
cid, _ = broker.subscribe()
|
||||
assert broker.subscriber_count == 1
|
||||
broker.unsubscribe(cid)
|
||||
assert broker.subscriber_count == 0
|
||||
asyncio.run(_test())
|
||||
|
||||
def test_publish_no_subscribers(self):
|
||||
broker = SSEBroker()
|
||||
delivered = asyncio.run(broker.publish("test", {}))
|
||||
assert delivered == 0
|
||||
|
||||
def test_history_kept(self):
|
||||
broker = SSEBroker()
|
||||
asyncio.run(broker.publish("e1", {"a": 1}))
|
||||
asyncio.run(broker.publish("e2", {"b": 2}))
|
||||
|
||||
assert len(broker.history) == 2
|
||||
assert broker.history[0].event_type == "e1"
|
||||
|
||||
def test_history_replays_to_new_subscriber(self):
|
||||
broker = SSEBroker()
|
||||
async def _test():
|
||||
await broker.publish("e1", {"x": 1})
|
||||
cid, queue = broker.subscribe()
|
||||
event = queue.get_nowait()
|
||||
assert event.event_type == "e1"
|
||||
asyncio.run(_test())
|
||||
|
||||
def test_history_max(self):
|
||||
broker = SSEBroker()
|
||||
broker._max_history = 3
|
||||
for i in range(5):
|
||||
asyncio.run(broker.publish(f"e{i}", {}))
|
||||
assert len(broker.history) == 3
|
||||
|
||||
def test_publish_sync(self):
|
||||
broker = SSEBroker()
|
||||
async def _test():
|
||||
cid, queue = broker.subscribe()
|
||||
delivered = broker.publish_sync("tick", {"n": 1})
|
||||
assert delivered == 1
|
||||
event = queue.get_nowait()
|
||||
assert event.data["n"] == 1
|
||||
asyncio.run(_test())
|
||||
|
||||
def test_multiple_subscribers(self):
|
||||
broker = SSEBroker()
|
||||
async def _test():
|
||||
c1, q1 = broker.subscribe()
|
||||
c2, q2 = broker.subscribe()
|
||||
await broker.publish("test", {"v": 42})
|
||||
assert q1.get_nowait().data["v"] == 42
|
||||
assert q2.get_nowait().data["v"] == 42
|
||||
asyncio.run(_test())
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Hook
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestHookManager:
|
||||
def test_register_and_get(self):
|
||||
hm = HookManager()
|
||||
hook = Hook("h1", "task_created", HookType.WEBHOOK.value,
|
||||
{"url": "http://example.com"})
|
||||
hm.register(hook)
|
||||
assert hm.get("h1") is not None
|
||||
assert hm.hook_count == 1
|
||||
|
||||
def test_unregister(self):
|
||||
hm = HookManager()
|
||||
hm.register(Hook("h1", "*", HookType.CALLBACK.value, {}))
|
||||
assert hm.unregister("h1") is True
|
||||
assert hm.hook_count == 0
|
||||
|
||||
def test_list_hooks_by_event(self):
|
||||
hm = HookManager()
|
||||
hm.register(Hook("h1", "task_created", HookType.WEBHOOK.value, {}))
|
||||
hm.register(Hook("h2", "task_updated", HookType.WEBHOOK.value, {}))
|
||||
|
||||
created = hm.list_hooks(event_type="task_created")
|
||||
assert len(created) == 1
|
||||
assert created[0].hook_id == "h1"
|
||||
|
||||
def test_fire_matching_hook(self):
|
||||
results = []
|
||||
|
||||
async def callback(data):
|
||||
results.append(data)
|
||||
return "ok"
|
||||
|
||||
hm = HookManager()
|
||||
hm.register(Hook("h1", "task_created", HookType.CALLBACK.value,
|
||||
{"callback": callback}))
|
||||
|
||||
fire_results = asyncio.run(hm.fire("task_created", {"task_id": "t1"}))
|
||||
assert len(fire_results) == 1
|
||||
assert fire_results[0]["status"] == "success"
|
||||
assert len(results) == 1
|
||||
|
||||
def test_fire_wildcard_hook(self):
|
||||
results = []
|
||||
|
||||
async def callback(data):
|
||||
results.append(data)
|
||||
|
||||
hm = HookManager()
|
||||
hm.register(Hook("h1", "*", HookType.CALLBACK.value,
|
||||
{"callback": callback}))
|
||||
|
||||
asyncio.run(hm.fire("any_event", {"x": 1}))
|
||||
assert len(results) == 1
|
||||
|
||||
def test_fire_no_match(self):
|
||||
hm = HookManager()
|
||||
hm.register(Hook("h1", "task_created", HookType.CALLBACK.value,
|
||||
{"callback": lambda d: None}))
|
||||
|
||||
results = asyncio.run(hm.fire("task_updated", {}))
|
||||
assert len(results) == 0
|
||||
|
||||
def test_fire_disabled_hook(self):
|
||||
hm = HookManager()
|
||||
hm.register(Hook("h1", "*", HookType.CALLBACK.value,
|
||||
{"callback": lambda d: d}, enabled=False))
|
||||
|
||||
results = asyncio.run(hm.fire("test", {}))
|
||||
assert len(results) == 0
|
||||
|
||||
def test_sync_callback(self):
|
||||
results = []
|
||||
|
||||
def sync_callback(data):
|
||||
results.append(data)
|
||||
return "sync_ok"
|
||||
|
||||
hm = HookManager()
|
||||
hm.register(Hook("h1", "test", HookType.CALLBACK.value,
|
||||
{"callback": sync_callback}))
|
||||
|
||||
fire_results = asyncio.run(hm.fire("test", {"v": 1}))
|
||||
assert fire_results[0]["status"] == "success"
|
||||
assert results[0]["v"] == 1
|
||||
|
||||
def test_hook_fire_count(self):
|
||||
async def cb(data):
|
||||
pass
|
||||
|
||||
hm = HookManager()
|
||||
hm.register(Hook("h1", "test", HookType.CALLBACK.value,
|
||||
{"callback": cb}))
|
||||
|
||||
asyncio.run(hm.fire("test", {}))
|
||||
asyncio.run(hm.fire("test", {}))
|
||||
assert hm.get("h1").fire_count == 2
|
||||
assert hm.get("h1").last_fired is not None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# T4: 错误处理
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestHookErrors:
|
||||
def test_webhook_error_handled(self):
|
||||
hm = HookManager()
|
||||
hm.register(Hook("h1", "test", HookType.WEBHOOK.value,
|
||||
{"url": "http://nonexistent.invalid/hook"}))
|
||||
|
||||
results = asyncio.run(hm.fire("test", {}))
|
||||
assert len(results) == 1
|
||||
assert results[0]["status"] == "error"
|
||||
|
||||
def test_script_error_handled(self):
|
||||
hm = HookManager()
|
||||
hm.register(Hook("h1", "test", HookType.SCRIPT.value,
|
||||
{"script": "/nonexistent/script.sh"}))
|
||||
|
||||
results = asyncio.run(hm.fire("test", {}))
|
||||
assert len(results) == 1
|
||||
assert results[0]["status"] == "error"
|
||||
|
||||
def test_callback_error_handled(self):
|
||||
def bad_callback(data):
|
||||
raise RuntimeError("callback error")
|
||||
|
||||
hm = HookManager()
|
||||
hm.register(Hook("h1", "test", HookType.CALLBACK.value,
|
||||
{"callback": bad_callback}))
|
||||
|
||||
results = asyncio.run(hm.fire("test", {}))
|
||||
assert results[0]["status"] == "error"
|
||||
assert "callback error" in results[0]["error"]
|
||||
|
||||
def test_no_callable_callback(self):
|
||||
hm = HookManager()
|
||||
hm.register(Hook("h1", "test", HookType.CALLBACK.value,
|
||||
{"callback": None}))
|
||||
|
||||
results = asyncio.run(hm.fire("test", {}))
|
||||
assert results[0]["status"] == "error"
|
||||
@@ -0,0 +1,545 @@
|
||||
"""F6 Daemon Ticker 单元测试
|
||||
|
||||
按司马懿测试计划 test-plan-v2.6.md §F6:
|
||||
- T1: tick 循环正常运行(P0)
|
||||
- T2: scan_tasks 检测 pending(P0)
|
||||
- T3: 依赖推进(P0)
|
||||
- T4: events 写入(P0)
|
||||
- T5: 多项目轮询(P0)
|
||||
- T6: tick 异常不中断(P1)
|
||||
- T7: 手动 tick 端点(P1)
|
||||
|
||||
v2.8 新增(#07.2 _check_timeouts 统一 + #07.3 ACT-1 updated_at fallback):
|
||||
- E12: _check_timeouts 统一超时(4 个测试)
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import pytest
|
||||
from pathlib import Path
|
||||
|
||||
from src.blackboard.operations import Blackboard
|
||||
from src.blackboard.models import Task
|
||||
from src.blackboard.registry import ProjectRegistry
|
||||
from src.blackboard.queries import Queries
|
||||
from src.daemon.ticker import Ticker
|
||||
|
||||
pytestmark = pytest.mark.integration
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Fixtures
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@pytest.fixture
|
||||
def data_root(tmp_path):
|
||||
return tmp_path / "projects"
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def registry(data_root):
|
||||
return ProjectRegistry(data_root)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def project_with_tasks(registry, data_root):
|
||||
"""创建一个项目并添加几个任务"""
|
||||
registry.create_project("test-proj", "Test Project", agents=["agent-a"])
|
||||
db_path = data_root / "test-proj" / "blackboard.db"
|
||||
bb = Blackboard(db_path)
|
||||
|
||||
# pending 任务
|
||||
bb.create_task(Task(
|
||||
id="t1", title="Task 1", status="pending",
|
||||
assigned_by="daemon", task_type="coding",
|
||||
))
|
||||
# blocked 任务(依赖 t1)
|
||||
bb.create_task(Task(
|
||||
id="t2", title="Task 2", status="blocked",
|
||||
assigned_by="daemon", task_type="coding",
|
||||
depends_on=json.dumps(["t1"]),
|
||||
))
|
||||
return registry, db_path, bb
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# T1: tick 循环正常运行
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestTickLoop:
|
||||
def test_ticker_runs(self, registry):
|
||||
"""Ticker 可以启动和停止"""
|
||||
ticker = Ticker(registry, tick_interval=0.1, max_ticks=3)
|
||||
assert not ticker.is_running
|
||||
|
||||
async def run():
|
||||
await ticker.start()
|
||||
# 等待几个 tick
|
||||
await asyncio.sleep(0.5)
|
||||
await ticker.stop()
|
||||
|
||||
asyncio.run(run())
|
||||
assert ticker.tick_count >= 1
|
||||
|
||||
def test_max_ticks_respected(self, registry):
|
||||
"""max_ticks 限制 tick 次数"""
|
||||
ticker = Ticker(registry, tick_interval=0.05, max_ticks=3)
|
||||
|
||||
async def run():
|
||||
await ticker.start()
|
||||
await asyncio.sleep(1.0)
|
||||
# max_ticks 达到后应自动停止
|
||||
|
||||
asyncio.run(run())
|
||||
assert ticker.tick_count <= 4 # 允许少量误差
|
||||
|
||||
def test_tick_count_increments(self, registry):
|
||||
"""tick_count 随 tick 递增"""
|
||||
ticker = Ticker(registry, tick_interval=0.05, max_ticks=2)
|
||||
|
||||
async def run():
|
||||
await ticker.start()
|
||||
await asyncio.sleep(0.3)
|
||||
|
||||
asyncio.run(run())
|
||||
assert ticker.tick_count >= 2
|
||||
|
||||
def test_ticker_no_projects(self, registry):
|
||||
"""无项目时 tick 正常运行不报错"""
|
||||
ticker = Ticker(registry, tick_interval=0.05, max_ticks=1)
|
||||
|
||||
async def run():
|
||||
result = await ticker.tick()
|
||||
assert result["tick"] == 1
|
||||
assert result["projects"] == {}
|
||||
|
||||
asyncio.run(run())
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# T2: scan_tasks 检测 pending
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestScanTasks:
|
||||
def test_scan_finds_pending(self, project_with_tasks):
|
||||
registry, db_path, bb = project_with_tasks
|
||||
ticker = Ticker(registry, tick_interval=30)
|
||||
|
||||
async def run():
|
||||
result = await ticker.tick()
|
||||
proj_result = result["projects"]["test-proj"]
|
||||
assert proj_result["status"] == "ok"
|
||||
assert proj_result["summary_before"]["pending"] == 1
|
||||
assert proj_result["summary_before"]["blocked"] == 1
|
||||
|
||||
asyncio.run(run())
|
||||
|
||||
def test_scan_empty_project(self, registry, data_root):
|
||||
"""有 DB 但无任务的项目 tick 返回 ok + 空状态"""
|
||||
registry.create_project("empty-proj", "Empty")
|
||||
# Init DB (empty)
|
||||
db_path = data_root / "empty-proj" / "blackboard.db"
|
||||
Blackboard(db_path) # creates tables
|
||||
ticker = Ticker(registry, tick_interval=30)
|
||||
|
||||
async def run():
|
||||
result = await ticker.tick()
|
||||
proj_result = result["projects"]["empty-proj"]
|
||||
assert proj_result["status"] == "ok"
|
||||
assert proj_result["summary_before"] == {}
|
||||
|
||||
asyncio.run(run())
|
||||
|
||||
def test_scan_project_no_db(self, registry, data_root):
|
||||
"""项目目录存在但无 DB 时返回 no_db"""
|
||||
registry.create_project("no-db-proj", "No DB")
|
||||
ticker = Ticker(registry, tick_interval=30)
|
||||
|
||||
async def run():
|
||||
result = await ticker.tick()
|
||||
proj_result = result["projects"]["no-db-proj"]
|
||||
assert proj_result["status"] == "no_db"
|
||||
|
||||
asyncio.run(run())
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# T3: 依赖推进
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestDependencyAdvance:
|
||||
def test_blocked_advances_when_deps_done(self, project_with_tasks):
|
||||
"""依赖完成后 blocked → pending"""
|
||||
registry, db_path, bb = project_with_tasks
|
||||
|
||||
# 把 t1 标记为 done
|
||||
bb.update_task_status("t1", "claimed", agent="agent-a")
|
||||
bb.update_task_status("t1", "working", agent="agent-a")
|
||||
bb.update_task_status("t1", "review", agent="agent-a")
|
||||
bb.update_task_status("t1", "done", agent="agent-a")
|
||||
|
||||
ticker = Ticker(registry, tick_interval=30)
|
||||
|
||||
async def run():
|
||||
result = await ticker.tick()
|
||||
proj_result = result["projects"]["test-proj"]
|
||||
assert "t2" in proj_result["advanced"]
|
||||
|
||||
# 验证 t2 状态变为 pending
|
||||
from src.blackboard.queries import Queries
|
||||
queries = Queries(db_path)
|
||||
summary = queries.task_summary()
|
||||
assert summary.get("pending", 0) >= 1 # t2 现在 pending
|
||||
assert summary.get("blocked", 0) == 0
|
||||
|
||||
asyncio.run(run())
|
||||
|
||||
def test_blocked_stays_when_deps_not_done(self, project_with_tasks):
|
||||
"""依赖未完成时 blocked 不推进"""
|
||||
registry, db_path, bb = project_with_tasks
|
||||
ticker = Ticker(registry, tick_interval=30)
|
||||
|
||||
async def run():
|
||||
result = await ticker.tick()
|
||||
proj_result = result["projects"]["test-proj"]
|
||||
assert len(proj_result["advanced"]) == 0
|
||||
|
||||
from src.blackboard.queries import Queries
|
||||
queries = Queries(db_path)
|
||||
summary = queries.task_summary()
|
||||
assert summary.get("blocked", 0) == 1
|
||||
|
||||
asyncio.run(run())
|
||||
|
||||
def test_chain_advance(self, registry, data_root):
|
||||
"""链式依赖:t1 done → t2 unblock → t3 unblock"""
|
||||
registry.create_project("chain", "Chain")
|
||||
db_path = data_root / "chain" / "blackboard.db"
|
||||
bb = Blackboard(db_path)
|
||||
|
||||
bb.create_task(Task(id="t1", title="T1", status="pending", assigned_by="d"))
|
||||
bb.create_task(Task(
|
||||
id="t2", title="T2", status="blocked",
|
||||
assigned_by="d", depends_on=json.dumps(["t1"]),
|
||||
))
|
||||
bb.create_task(Task(
|
||||
id="t3", title="T3", status="blocked",
|
||||
assigned_by="d", depends_on=json.dumps(["t2"]),
|
||||
))
|
||||
|
||||
# t1 done
|
||||
for s in ("claimed", "working", "review", "done"):
|
||||
bb.update_task_status("t1", s, agent="a")
|
||||
|
||||
ticker = Ticker(registry, tick_interval=30)
|
||||
|
||||
async def run():
|
||||
# First tick: t1 done → t2 unblock
|
||||
r1 = await ticker.tick()
|
||||
assert "t2" in r1["projects"]["chain"]["advanced"]
|
||||
assert "t3" not in r1["projects"]["chain"]["advanced"]
|
||||
|
||||
# Second tick: t2 pending (just unblocked), but not done yet
|
||||
r2 = await ticker.tick()
|
||||
# t3 still blocked because t2 is pending not done
|
||||
assert "t3" not in r2["projects"]["chain"]["advanced"]
|
||||
|
||||
asyncio.run(run())
|
||||
|
||||
def test_multi_dep_all_done(self, registry, data_root):
|
||||
"""多依赖全部完成后才推进"""
|
||||
registry.create_project("multi", "Multi")
|
||||
db_path = data_root / "multi" / "blackboard.db"
|
||||
bb = Blackboard(db_path)
|
||||
|
||||
bb.create_task(Task(id="t1", title="T1", status="pending", assigned_by="d"))
|
||||
bb.create_task(Task(id="t2", title="T2", status="pending", assigned_by="d"))
|
||||
bb.create_task(Task(
|
||||
id="t3", title="T3", status="blocked",
|
||||
assigned_by="d", depends_on=json.dumps(["t1", "t2"]),
|
||||
))
|
||||
|
||||
# Only t1 done
|
||||
for s in ("claimed", "working", "review", "done"):
|
||||
bb.update_task_status("t1", s, agent="a")
|
||||
|
||||
ticker = Ticker(registry, tick_interval=30)
|
||||
|
||||
async def run():
|
||||
r1 = await ticker.tick()
|
||||
assert "t3" not in r1["projects"]["multi"]["advanced"]
|
||||
|
||||
# Now t2 also done
|
||||
for s in ("claimed", "working", "review", "done"):
|
||||
bb.update_task_status("t2", s, agent="a")
|
||||
|
||||
r2 = await ticker.tick()
|
||||
assert "t3" in r2["projects"]["multi"]["advanced"]
|
||||
|
||||
asyncio.run(run())
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# T4: events 写入
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestEvents:
|
||||
def test_daemon_tick_event_written(self, project_with_tasks):
|
||||
registry, db_path, bb = project_with_tasks
|
||||
ticker = Ticker(registry, tick_interval=30)
|
||||
|
||||
async def run():
|
||||
await ticker.tick()
|
||||
from src.blackboard.queries import Queries
|
||||
queries = Queries(db_path)
|
||||
events = queries.recent_events(limit=5)
|
||||
tick_events = [e for e in events if e["event_type"] == "daemon_tick"]
|
||||
assert len(tick_events) >= 1
|
||||
detail = json.loads(tick_events[0]["detail"])
|
||||
assert detail["tick"] == 1
|
||||
|
||||
asyncio.run(run())
|
||||
|
||||
def test_advance_event_in_detail(self, project_with_tasks):
|
||||
registry, db_path, bb = project_with_tasks
|
||||
# Make t1 done so t2 can advance
|
||||
for s in ("claimed", "working", "review", "done"):
|
||||
bb.update_task_status("t1", s, agent="a")
|
||||
|
||||
ticker = Ticker(registry, tick_interval=30)
|
||||
|
||||
async def run():
|
||||
await ticker.tick()
|
||||
from src.blackboard.queries import Queries
|
||||
queries = Queries(db_path)
|
||||
events = queries.recent_events(limit=10)
|
||||
tick_events = [e for e in events if e["event_type"] == "daemon_tick"]
|
||||
detail = json.loads(tick_events[0]["detail"])
|
||||
assert detail["advanced_count"] == 1
|
||||
|
||||
asyncio.run(run())
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# T5: 多项目轮询
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestMultiProject:
|
||||
def test_ticks_all_active_projects(self, registry, data_root):
|
||||
"""tick 遍历所有 active 项目"""
|
||||
for pid in ("proj-a", "proj-b", "proj-c"):
|
||||
registry.create_project(pid, f"Project {pid}")
|
||||
db_path = data_root / pid / "blackboard.db"
|
||||
bb = Blackboard(db_path)
|
||||
bb.create_task(Task(
|
||||
id=f"{pid}-t1", title=f"Task {pid}",
|
||||
status="pending", assigned_by="d",
|
||||
))
|
||||
|
||||
ticker = Ticker(registry, tick_interval=30)
|
||||
|
||||
async def run():
|
||||
result = await ticker.tick()
|
||||
assert len(result["projects"]) == 3
|
||||
for pid in ("proj-a", "proj-b", "proj-c"):
|
||||
assert pid in result["projects"]
|
||||
assert result["projects"][pid]["status"] == "ok"
|
||||
|
||||
asyncio.run(run())
|
||||
|
||||
def test_skips_archived_projects(self, registry, data_root):
|
||||
"""归档项目不参与 tick"""
|
||||
registry.create_project("active", "Active")
|
||||
registry.create_project("archived", "Archived")
|
||||
registry.archive_project("archived")
|
||||
|
||||
# Add DB to both
|
||||
for pid in ("active", "archived"):
|
||||
db_path = data_root / pid / "blackboard.db"
|
||||
Blackboard(db_path)
|
||||
|
||||
ticker = Ticker(registry, tick_interval=30)
|
||||
|
||||
async def run():
|
||||
result = await ticker.tick()
|
||||
# archived 项目被 rename 到 _archived/,但 registry 仍记录它
|
||||
# ticker 应跳过 status != "active" 的项目
|
||||
for pid, pr in result["projects"].items():
|
||||
assert pid != "archived"
|
||||
|
||||
asyncio.run(run())
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# T6: tick 异常不中断(P1)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestTickResilience:
|
||||
def test_tick_continues_after_error(self, registry, data_root):
|
||||
"""单次 tick 异常不影响后续 tick"""
|
||||
registry.create_project("good", "Good")
|
||||
db_path = data_root / "good" / "blackboard.db"
|
||||
Blackboard(db_path)
|
||||
|
||||
ticker = Ticker(registry, tick_interval=0.05, max_ticks=3)
|
||||
tick_count = [0]
|
||||
original_tick_project = ticker._tick_project
|
||||
|
||||
async def failing_tick(project_id, project_info):
|
||||
tick_count[0] += 1
|
||||
if tick_count[0] == 1:
|
||||
raise RuntimeError("Simulated failure")
|
||||
return await original_tick_project(project_id, project_info)
|
||||
|
||||
ticker._tick_project = failing_tick
|
||||
|
||||
async def run():
|
||||
await ticker.start()
|
||||
await asyncio.sleep(0.3)
|
||||
|
||||
asyncio.run(run())
|
||||
assert ticker.tick_count >= 2 # First tick had error but continued
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# T7: 手动 tick 端点(P1)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestManualTick:
|
||||
def test_manual_tick(self, project_with_tasks):
|
||||
registry, db_path, bb = project_with_tasks
|
||||
ticker = Ticker(registry, tick_interval=30)
|
||||
|
||||
async def run():
|
||||
result = await ticker.manual_tick()
|
||||
assert result["manual"] is True
|
||||
assert result["tick"] == 1
|
||||
assert "test-proj" in result["projects"]
|
||||
|
||||
asyncio.run(run())
|
||||
|
||||
def test_manual_tick_increments_count(self, project_with_tasks):
|
||||
registry, db_path, bb = project_with_tasks
|
||||
ticker = Ticker(registry, tick_interval=30)
|
||||
|
||||
async def run():
|
||||
await ticker.manual_tick()
|
||||
await ticker.manual_tick()
|
||||
assert ticker.tick_count == 2
|
||||
|
||||
asyncio.run(run())
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# E12: _check_timeouts 统一超时(v2.8 #07.2/#07.3 新增)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestCheckTimeoutsUnified:
|
||||
"""E12: #07.2 _check_timeouts 统一检查 + #07.3 ACT-1 updated_at fallback"""
|
||||
|
||||
@pytest.fixture
|
||||
def timeout_project(self, tmp_path):
|
||||
"""创建项目 + 添加可超时任务"""
|
||||
data_root = tmp_path / "projects"
|
||||
registry = ProjectRegistry(data_root)
|
||||
registry.create_project("timeout-proj", "Timeout Test", agents=["agent-a"])
|
||||
db_path = data_root / "timeout-proj" / "blackboard.db"
|
||||
bb = Blackboard(db_path)
|
||||
return registry, db_path, bb
|
||||
|
||||
def test_crash_limit_working(self, timeout_project):
|
||||
"""E12.1: executor crash 3 次/30min → _check_timeouts 标 failed"""
|
||||
|
||||
registry, db_path, bb = timeout_project
|
||||
|
||||
bb.create_task(Task(
|
||||
id="t-crash", title="Crash Task", status="working",
|
||||
assigned_by="daemon", current_agent="agent-a",
|
||||
))
|
||||
|
||||
from datetime import datetime, timedelta
|
||||
conn = bb._conn()
|
||||
try:
|
||||
for i in range(3):
|
||||
attempt_time = datetime.utcnow() - timedelta(minutes=25 - i * 5)
|
||||
conn.execute(
|
||||
"INSERT INTO task_attempts (task_id, attempt_number, agent, outcome, started_at) "
|
||||
"VALUES (?, ?, ?, ?, ?)",
|
||||
("t-crash", i + 1, "agent-a", "crashed",
|
||||
attempt_time.isoformat()),
|
||||
)
|
||||
conn.commit()
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
ticker = Ticker(registry, tick_interval=30)
|
||||
result = ticker._check_timeouts(db_path)
|
||||
assert isinstance(result, list)
|
||||
|
||||
def test_crash_limit_review(self, timeout_project):
|
||||
"""E12.2: reviewer crash 3 次/30min → _check_timeouts 标 failed"""
|
||||
registry, db_path, bb = timeout_project
|
||||
|
||||
bb.create_task(Task(
|
||||
id="t-review-crash", title="Review Crash Task", status="review",
|
||||
assigned_by="daemon", current_agent="simayi-challenger",
|
||||
))
|
||||
|
||||
ticker = Ticker(registry, tick_interval=30)
|
||||
result = ticker._check_timeouts(db_path)
|
||||
assert isinstance(result, list)
|
||||
|
||||
def test_updated_at_fallback(self, timeout_project):
|
||||
"""E12.3: mail auto-working 无 started_at/claimed_at → updated_at fallback"""
|
||||
registry, db_path, bb = timeout_project
|
||||
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
old_time = (datetime.utcnow() - timedelta(minutes=60)).isoformat()
|
||||
bb.create_task(Task(
|
||||
id="t-mail-orphan", title="Mail Orphan", status="working",
|
||||
assigned_by="daemon", current_agent="pangtong-fujunshi",
|
||||
))
|
||||
conn = bb._conn()
|
||||
try:
|
||||
conn.execute(
|
||||
"UPDATE tasks SET updated_at = ? WHERE id = ?",
|
||||
(old_time, "t-mail-orphan"),
|
||||
)
|
||||
conn.execute(
|
||||
"UPDATE tasks SET started_at = NULL, claimed_at = NULL WHERE id = ?",
|
||||
("t-mail-orphan",),
|
||||
)
|
||||
conn.commit()
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
ticker = Ticker(registry, tick_interval=30, default_task_timeout_minutes=30)
|
||||
reclaimed = ticker._check_timeouts(db_path)
|
||||
assert "t-mail-orphan" in reclaimed, \
|
||||
"Mail orphan with only updated_at should be reclaimed via fallback"
|
||||
|
||||
def test_process_dead_keeps_review_status(self, timeout_project):
|
||||
"""E12.4: review agent 进程死 → 保持 review 状态(不推 pending)"""
|
||||
registry, db_path, bb = timeout_project
|
||||
|
||||
bb.create_task(Task(
|
||||
id="t-review-dead", title="Review Dead Process", status="review",
|
||||
assigned_by="daemon", current_agent="simayi-challenger",
|
||||
))
|
||||
|
||||
from datetime import datetime
|
||||
conn = bb._conn()
|
||||
try:
|
||||
conn.execute(
|
||||
"UPDATE tasks SET updated_at = ? WHERE id = ?",
|
||||
(datetime.utcnow().isoformat(), "t-review-dead"),
|
||||
)
|
||||
conn.commit()
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
ticker = Ticker(registry, tick_interval=30, default_task_timeout_minutes=30)
|
||||
reclaimed = ticker._check_timeouts(db_path)
|
||||
|
||||
assert "t-review-dead" not in reclaimed
|
||||
@@ -0,0 +1,325 @@
|
||||
"""v2.7 父子 Task 关系 + Stage 进度测试"""
|
||||
|
||||
import json
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
pytestmark = pytest.mark.integration
|
||||
|
||||
from src.blackboard.db import init_db, get_connection
|
||||
from src.blackboard.models import Task
|
||||
from src.blackboard.operations import Blackboard
|
||||
from src.blackboard.queries import Queries
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def db_path(tmp_path):
|
||||
p = tmp_path / "test.db"
|
||||
init_db(p)
|
||||
return p
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def bb(db_path):
|
||||
return Blackboard(db_path)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def queries(db_path):
|
||||
return Queries(db_path)
|
||||
|
||||
|
||||
# ======================================================================
|
||||
# 基础父子关系
|
||||
# ======================================================================
|
||||
|
||||
class TestParentChild:
|
||||
def test_create_parent_task(self, bb, queries):
|
||||
"""顶层 Task 无 parent_task"""
|
||||
task = Task(id="parent-1", title="动量策略v1")
|
||||
bb.create_task(task)
|
||||
top = queries.top_level_tasks()
|
||||
assert len(top) == 1
|
||||
assert top[0].id == "parent-1"
|
||||
assert top[0].parent_task is None
|
||||
|
||||
def test_create_child_task(self, bb, queries):
|
||||
"""子 Task 有 parent_task"""
|
||||
parent = Task(id="parent-1", title="动量策略v1",
|
||||
stages_json=json.dumps([
|
||||
{"id": "research", "label": "因子研究", "order": 1},
|
||||
{"id": "coding", "label": "策略编码", "order": 2},
|
||||
]))
|
||||
bb.create_task(parent)
|
||||
|
||||
child = Task(id="child-1", title="因子研究",
|
||||
parent_task="parent-1", stage="research",
|
||||
assignee="zhangfei-dev")
|
||||
bb.create_task(child)
|
||||
|
||||
subtasks = queries.list_subtasks("parent-1")
|
||||
assert len(subtasks) == 1
|
||||
assert subtasks[0].parent_task == "parent-1"
|
||||
assert subtasks[0].stage == "research"
|
||||
|
||||
def test_top_level_excludes_children(self, bb, queries):
|
||||
"""top_level_tasks 不包含子 Task"""
|
||||
bb.create_task(Task(id="p1", title="Parent"))
|
||||
bb.create_task(Task(id="c1", title="Child", parent_task="p1"))
|
||||
|
||||
top = queries.top_level_tasks()
|
||||
assert len(top) == 1
|
||||
assert top[0].id == "p1"
|
||||
|
||||
def test_list_subtasks_empty(self, bb, queries):
|
||||
"""无子 Task 返回空列表"""
|
||||
bb.create_task(Task(id="p1", title="Parent"))
|
||||
assert queries.list_subtasks("p1") == []
|
||||
|
||||
def test_multiple_children(self, bb, queries):
|
||||
"""多个子 Task"""
|
||||
bb.create_task(Task(id="p1", title="Parent"))
|
||||
for i in range(5):
|
||||
bb.create_task(Task(id=f"c{i}", title=f"Child {i}", parent_task="p1"))
|
||||
|
||||
subtasks = queries.list_subtasks("p1")
|
||||
assert len(subtasks) == 5
|
||||
|
||||
def test_child_with_depends_on(self, bb, queries):
|
||||
"""子 Task 间有依赖关系"""
|
||||
bb.create_task(Task(id="p1", title="Parent"))
|
||||
bb.create_task(Task(id="c1", title="因子研究", parent_task="p1"))
|
||||
bb.create_task(Task(id="c2", title="策略编码", parent_task="p1",
|
||||
depends_on=json.dumps(["c1"])))
|
||||
|
||||
subtasks = queries.list_subtasks("p1")
|
||||
assert len(subtasks) == 2
|
||||
c2 = [s for s in subtasks if s.id == "c2"][0]
|
||||
assert json.loads(c2.depends_on) == ["c1"]
|
||||
|
||||
|
||||
# ======================================================================
|
||||
# 父 Task 状态聚合
|
||||
# ======================================================================
|
||||
|
||||
class TestParentStatusAggregation:
|
||||
def test_all_pending(self, bb, queries):
|
||||
"""所有子 Task pending → 父 pending"""
|
||||
bb.create_task(Task(id="p1", title="Parent"))
|
||||
bb.create_task(Task(id="c1", title="Child 1", parent_task="p1"))
|
||||
bb.create_task(Task(id="c2", title="Child 2", parent_task="p1"))
|
||||
|
||||
status = queries.compute_parent_status("p1")
|
||||
assert status == "pending"
|
||||
|
||||
def test_has_working(self, bb, queries):
|
||||
"""有子 Task working → 父 working"""
|
||||
bb.create_task(Task(id="p1", title="Parent"))
|
||||
bb.create_task(Task(id="c1", title="Child 1", parent_task="p1"))
|
||||
bb.create_task(Task(id="c2", title="Child 2", parent_task="p1", status="working"))
|
||||
|
||||
status = queries.compute_parent_status("p1")
|
||||
assert status == "working"
|
||||
|
||||
def test_has_claimed_counts_as_working(self, bb, queries):
|
||||
"""有子 Task claimed → 父 working"""
|
||||
bb.create_task(Task(id="p1", title="Parent"))
|
||||
bb.create_task(Task(id="c1", title="Child 1", parent_task="p1"))
|
||||
bb.create_task(Task(id="c2", title="Child 2", parent_task="p1", status="claimed"))
|
||||
|
||||
status = queries.compute_parent_status("p1")
|
||||
assert status == "working"
|
||||
|
||||
def test_has_review(self, bb, queries):
|
||||
"""有子 Task review → 父 review"""
|
||||
bb.create_task(Task(id="p1", title="Parent"))
|
||||
bb.create_task(Task(id="c1", title="Child 1", parent_task="p1", status="done"))
|
||||
bb.create_task(Task(id="c2", title="Child 2", parent_task="p1", status="review"))
|
||||
|
||||
status = queries.compute_parent_status("p1")
|
||||
assert status == "review"
|
||||
|
||||
def test_all_done(self, bb, queries):
|
||||
"""所有子 Task done → 父 done"""
|
||||
bb.create_task(Task(id="p1", title="Parent"))
|
||||
bb.create_task(Task(id="c1", title="Child 1", parent_task="p1", status="done"))
|
||||
bb.create_task(Task(id="c2", title="Child 2", parent_task="p1", status="done"))
|
||||
|
||||
status = queries.compute_parent_status("p1")
|
||||
assert status == "done"
|
||||
|
||||
def test_has_failed(self, bb, queries):
|
||||
"""有子 Task failed → 父 failed"""
|
||||
bb.create_task(Task(id="p1", title="Parent"))
|
||||
bb.create_task(Task(id="c1", title="Child 1", parent_task="p1", status="done"))
|
||||
bb.create_task(Task(id="c2", title="Child 2", parent_task="p1", status="failed"))
|
||||
|
||||
status = queries.compute_parent_status("p1")
|
||||
assert status == "failed"
|
||||
|
||||
def test_cancelled_excluded_from_aggregation(self, bb, queries):
|
||||
"""cancelled 子 Task 不参与聚合"""
|
||||
bb.create_task(Task(id="p1", title="Parent"))
|
||||
bb.create_task(Task(id="c1", title="Child 1", parent_task="p1", status="done"))
|
||||
bb.create_task(Task(id="c2", title="Child 2", parent_task="p1", status="cancelled"))
|
||||
|
||||
status = queries.compute_parent_status("p1")
|
||||
assert status == "done" # c1 done, c2 excluded
|
||||
|
||||
def test_manual_status_not_overridden(self, bb, queries):
|
||||
"""cancelled 的父 Task 不参与聚合"""
|
||||
bb.create_task(Task(id="p1", title="Parent", status="cancelled"))
|
||||
bb.create_task(Task(id="c1", title="Child 1", parent_task="p1", status="done"))
|
||||
|
||||
status = queries.compute_parent_status("p1")
|
||||
assert status == "cancelled"
|
||||
|
||||
def test_no_children_keeps_current(self, bb, queries):
|
||||
"""无子 Task 保持当前状态"""
|
||||
bb.create_task(Task(id="p1", title="Parent", status="working"))
|
||||
status = queries.compute_parent_status("p1")
|
||||
assert status == "working"
|
||||
|
||||
def test_priority_review_over_working(self, bb, queries):
|
||||
"""review 优先于 working"""
|
||||
bb.create_task(Task(id="p1", title="Parent"))
|
||||
bb.create_task(Task(id="c1", title="Child 1", parent_task="p1", status="working"))
|
||||
bb.create_task(Task(id="c2", title="Child 2", parent_task="p1", status="review"))
|
||||
|
||||
status = queries.compute_parent_status("p1")
|
||||
assert status == "review"
|
||||
|
||||
def test_priority_working_over_blocked(self, bb, queries):
|
||||
"""working 优先于 blocked"""
|
||||
bb.create_task(Task(id="p1", title="Parent"))
|
||||
bb.create_task(Task(id="c1", title="Child 1", parent_task="p1", status="working"))
|
||||
bb.create_task(Task(id="c2", title="Child 2", parent_task="p1", status="blocked"))
|
||||
|
||||
status = queries.compute_parent_status("p1")
|
||||
assert status == "working"
|
||||
|
||||
|
||||
# ======================================================================
|
||||
# Stage 进度
|
||||
# ======================================================================
|
||||
|
||||
class TestStageProgress:
|
||||
def test_parent_task_progress(self, bb, queries):
|
||||
"""Stage 进度查询"""
|
||||
bb.create_task(Task(id="p1", title="动量策略v1",
|
||||
stages_json=json.dumps([
|
||||
{"id": "research", "label": "因子研究", "order": 1},
|
||||
{"id": "coding", "label": "策略编码", "order": 2},
|
||||
{"id": "backtest", "label": "回测验证", "order": 3},
|
||||
])))
|
||||
bb.create_task(Task(id="c1", title="因子研究", parent_task="p1",
|
||||
stage="research", status="done"))
|
||||
bb.create_task(Task(id="c2", title="策略编码", parent_task="p1",
|
||||
stage="coding", status="working"))
|
||||
bb.create_task(Task(id="c3", title="回测验证", parent_task="p1",
|
||||
stage="backtest", status="pending"))
|
||||
|
||||
progress = queries.parent_task_progress("p1")
|
||||
assert progress["total_subtasks"] == 3
|
||||
assert progress["done_subtasks"] == 1
|
||||
assert progress["active_stage"] == "策略编码"
|
||||
assert len(progress["stages"]) == 3
|
||||
|
||||
# Stage 详情
|
||||
research = [s for s in progress["stages"] if s["id"] == "research"][0]
|
||||
assert research["total"] == 1
|
||||
assert research["done"] == 1
|
||||
|
||||
coding = [s for s in progress["stages"] if s["id"] == "coding"][0]
|
||||
assert coding["active"] == 1
|
||||
|
||||
def test_progress_empty_parent(self, bb, queries):
|
||||
"""无子 Task 的父 Task"""
|
||||
bb.create_task(Task(id="p1", title="Empty",
|
||||
stages_json=json.dumps([{"id": "s1", "label": "Step 1", "order": 1}])))
|
||||
progress = queries.parent_task_progress("p1")
|
||||
assert progress["total_subtasks"] == 0
|
||||
assert progress["done_subtasks"] == 0
|
||||
assert progress["active_stage"] is None
|
||||
|
||||
def test_progress_nonexistent(self, bb, queries):
|
||||
"""不存在的 Task 返回空"""
|
||||
progress = queries.parent_task_progress("nonexistent")
|
||||
assert progress == {}
|
||||
|
||||
|
||||
# ======================================================================
|
||||
# stages_json 字段
|
||||
# ======================================================================
|
||||
|
||||
class TestStagesJson:
|
||||
def test_default_stages_json(self, bb, queries):
|
||||
"""默认 stages_json 为空数组"""
|
||||
bb.create_task(Task(id="t1", title="Task"))
|
||||
task = bb.get_task("t1")
|
||||
assert task.stages_json == "[]"
|
||||
|
||||
def test_set_stages_json(self, bb, queries):
|
||||
"""设置 stages_json"""
|
||||
stages = [{"id": "s1", "label": "Step 1", "order": 1}]
|
||||
bb.create_task(Task(id="t1", title="Task", stages_json=json.dumps(stages)))
|
||||
task = bb.get_task("t1")
|
||||
assert json.loads(task.stages_json) == stages
|
||||
|
||||
def test_stage_field_on_child(self, bb, queries):
|
||||
"""子 Task 的 stage 字段"""
|
||||
bb.create_task(Task(id="p1", title="Parent"))
|
||||
bb.create_task(Task(id="c1", title="Child", parent_task="p1", stage="research"))
|
||||
task = bb.get_task("c1")
|
||||
assert task.stage == "research"
|
||||
|
||||
|
||||
# ======================================================================
|
||||
# Ticker 父 Task 聚合刷新
|
||||
# ======================================================================
|
||||
|
||||
class TestTickerParentRefresh:
|
||||
def test_refresh_parent_status(self, bb, queries):
|
||||
"""Ticker._refresh_parent_statuses 聚合写入父 Task"""
|
||||
from src.daemon.ticker import Ticker
|
||||
|
||||
bb.create_task(Task(id="p1", title="Parent"))
|
||||
bb.create_task(Task(id="c1", title="Child", parent_task="p1", status="done"))
|
||||
bb.create_task(Task(id="c2", title="Child", parent_task="p1", status="done"))
|
||||
|
||||
ticker = Ticker(registry=None, dispatcher=None, spawner=None)
|
||||
refreshed = ticker._refresh_parent_statuses(bb.db_path)
|
||||
|
||||
assert "p1" in refreshed
|
||||
parent = bb.get_task("p1")
|
||||
assert parent.status == "done"
|
||||
|
||||
def test_refresh_skips_manual_status(self, bb, queries):
|
||||
"""cancelled 的父 Task 不参与聚合"""
|
||||
from src.daemon.ticker import Ticker
|
||||
|
||||
bb.create_task(Task(id="p1", title="Parent", status="cancelled"))
|
||||
bb.create_task(Task(id="c1", title="Child", parent_task="p1", status="done"))
|
||||
|
||||
ticker = Ticker(registry=None, dispatcher=None, spawner=None)
|
||||
refreshed = ticker._refresh_parent_statuses(bb.db_path)
|
||||
|
||||
assert "p1" not in refreshed
|
||||
parent = bb.get_task("p1")
|
||||
assert parent.status == "cancelled"
|
||||
|
||||
def test_refresh_failed_over_blocked(self, bb, queries):
|
||||
"""failed 优先于 blocked"""
|
||||
from src.daemon.ticker import Ticker
|
||||
|
||||
bb.create_task(Task(id="p1", title="Parent"))
|
||||
bb.create_task(Task(id="c1", title="Child", parent_task="p1", status="blocked"))
|
||||
bb.create_task(Task(id="c2", title="Child", parent_task="p1", status="failed"))
|
||||
|
||||
ticker = Ticker(registry=None, dispatcher=None, spawner=None)
|
||||
ticker._refresh_parent_statuses(bb.db_path)
|
||||
|
||||
parent = bb.get_task("p1")
|
||||
assert parent.status == "failed"
|
||||
Reference in New Issue
Block a user