auto-sync: 2026-06-05 11:03:30

This commit is contained in:
cfdaily
2026-06-05 11:03:30 +08:00
parent e9c9aaddfe
commit 6a649aba07
30 changed files with 602 additions and 1276 deletions
View File
+202
View File
@@ -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"
+74
View File
@@ -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
+268
View File
@@ -0,0 +1,268 @@
"""F17 SSE + Hook 单元测试
按 test-plan-v2.6.md §F17
- T1: SSE 事件推送(P0
- T2: Hook 注册/触发(P0
- T3: 回调 HookP0
- 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 检测 pendingP0
- 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
+325
View File
@@ -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"