324 lines
13 KiB
Python
324 lines
13 KiB
Python
"""v2.7 父子 Task 关系 + Stage 进度测试"""
|
|
|
|
import json
|
|
import tempfile
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
|
|
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"
|