Files
sanguo_moziplus_v2/tests/test_v27_subtasks.py
T
2026-05-18 12:08:50 +08:00

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"