"""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"