test_org_node_lifecycle.py 8.51 KB
"""U-010 tests: c_org_node_trigger's full guard + transition logic, mocked at
the subprocess boundary (src.org_node_trigger.trigger_org_node) per CLAUDE.md's
Testing constraint. Real chained-trigger coverage (all three Org Agents,
actually invoked) is in src/test_org_node_lifecycle_e2e.py (RUN_REAL_AGENT_E2E=1).

Run: python3 -m unittest src.test_org_node_lifecycle -v
"""

import unittest
from unittest.mock import patch

from src import org_node_lifecycle, schema, task_create


class LifecycleTestBase(unittest.TestCase):
    def setUp(self):
        self.conn = schema.connect(":memory:")
        schema.create_schema(self.conn)
        self.conn.execute(
            "INSERT INTO project (project_id, name, target_folder_path, has_git_repo, status) "
            "VALUES ('proj-1', 'P', '/tmp/proj-1', 1, 'active')"
        )
        self.conn.commit()
        self.task_id = task_create.create_task(self.conn, "proj-1", "实现贪吃蛇游戏")["task_id"]

    def tearDown(self):
        self.conn.close()

    def _pass_artifact(self, artifact_type, run_id="run-1"):
        self.conn.execute(
            "INSERT INTO work_graph_run (run_id, task_id, org_node, graph_manifest, status) "
            "VALUES (?, ?, 'planner', '{}', 'done') ON CONFLICT(run_id) DO NOTHING",
            (run_id, self.task_id),
        )
        self.conn.execute(
            "INSERT INTO artifact (artifact_id, artifact_type, task_id, produced_by_run_id, "
            "file_path, git_commit_sha, validation_status) VALUES (?, ?, ?, ?, 'f.md', 'sha', 'passed')",
            (artifact_type + "-artifact", artifact_type, self.task_id, run_id),
        )
        self.conn.commit()


class PlannerTriggerTest(LifecycleTestBase):
    def test_success_transitions_queued_to_planning(self):
        with patch("src.org_node_lifecycle.trigger_org_node", return_value="ok"):
            result = org_node_lifecycle.trigger_org_node_full(
                self.conn, "planner", self.task_id, "/tmp/proj-1", "go"
            )
        self.assertEqual(result["status"], "planning_brd_prd_sdd")
        self.assertEqual(result["assigned_org_node"], "planner")
        row = self.conn.execute("SELECT status FROM task WHERE task_id = ?", (self.task_id,)).fetchone()
        self.assertEqual(row[0], "planning_brd_prd_sdd")

    def test_writes_task_status_changed_event(self):
        with patch("src.org_node_lifecycle.trigger_org_node", return_value="ok"):
            org_node_lifecycle.trigger_org_node_full(self.conn, "planner", self.task_id, "/tmp/proj-1", "go")
        event = self.conn.execute(
            "SELECT event_type FROM execution_event WHERE task_id = ? ORDER BY created_at DESC LIMIT 1",
            (self.task_id,),
        ).fetchone()
        self.assertEqual(event[0], "task_status_changed")

    def test_wrong_status_raises_task_not_ready(self):
        self.conn.execute("UPDATE task SET status = 'coding' WHERE task_id = ?", (self.task_id,))
        self.conn.commit()
        with self.assertRaises(org_node_lifecycle.TaskNotReadyError):
            org_node_lifecycle.trigger_org_node_full(self.conn, "planner", self.task_id, "/tmp/proj-1", "go")

    def test_concurrency_limit_reached_blocks_trigger(self):
        self.conn.execute(
            "INSERT INTO org_concurrency_config (config_id, max_concurrent_goals, updated_by) "
            "VALUES ('default', 1, 'test')"
        )
        other_task = task_create.create_task(self.conn, "proj-1", "other goal")["task_id"]
        self.conn.execute("UPDATE task SET status = 'coding' WHERE task_id = ?", (other_task,))
        self.conn.commit()

        with self.assertRaises(org_node_lifecycle.ConcurrencyLimitReachedError):
            org_node_lifecycle.trigger_org_node_full(self.conn, "planner", self.task_id, "/tmp/proj-1", "go")

    def test_unknown_org_node_raises_value_error(self):
        with self.assertRaises(ValueError):
            org_node_lifecycle.trigger_org_node_full(self.conn, "not-a-node", self.task_id, "/tmp/proj-1", "go")

    def test_usage_limit_phrase_maps_to_usage_limit_reached(self):
        from src.org_node_trigger import OrgNodeTriggerError

        with patch(
            "src.org_node_lifecycle.trigger_org_node",
            side_effect=OrgNodeTriggerError("claude --agent planner: You've hit your session limit"),
        ):
            with self.assertRaises(org_node_lifecycle.UsageLimitReachedError):
                org_node_lifecycle.trigger_org_node_full(self.conn, "planner", self.task_id, "/tmp/proj-1", "go")

    def test_other_trigger_errors_are_not_reclassified(self):
        from src.org_node_trigger import OrgNodeTriggerError

        with patch(
            "src.org_node_lifecycle.trigger_org_node", side_effect=OrgNodeTriggerError("some other failure")
        ):
            with self.assertRaises(OrgNodeTriggerError):
                org_node_lifecycle.trigger_org_node_full(self.conn, "planner", self.task_id, "/tmp/proj-1", "go")


class PlannerReviewerTriggerTest(LifecycleTestBase):
    def setUp(self):
        super().setUp()
        self.conn.execute("UPDATE task SET status = 'planning_brd_prd_sdd' WHERE task_id = ?", (self.task_id,))
        self.conn.commit()

    def test_success_transitions_planning_to_reviewing(self):
        with patch("src.org_node_lifecycle.trigger_org_node", return_value="ok"):
            result = org_node_lifecycle.trigger_org_node_full(
                self.conn, "planner_reviewer", self.task_id, "/tmp/proj-1", "go"
            )
        self.assertEqual(result["status"], "reviewing_planner_output")
        self.assertEqual(result["assigned_org_node"], "planner_reviewer")

    def test_wrong_status_raises_task_not_ready(self):
        self.conn.execute("UPDATE task SET status = 'coding' WHERE task_id = ?", (self.task_id,))
        self.conn.commit()
        with self.assertRaises(org_node_lifecycle.TaskNotReadyError):
            org_node_lifecycle.trigger_org_node_full(
                self.conn, "planner_reviewer", self.task_id, "/tmp/proj-1", "go"
            )

    def test_no_artifact_gate_unlike_coder(self):
        # planner_reviewer itself does real semantic judgment (Phase 1's whole point) --
        # it's not gated behind pre-existing passed artifacts the way coder/validator are.
        with patch("src.org_node_lifecycle.trigger_org_node", return_value="ok"):
            result = org_node_lifecycle.trigger_org_node_full(
                self.conn, "planner_reviewer", self.task_id, "/tmp/proj-1", "go"
            )
        self.assertEqual(result["status"], "reviewing_planner_output")


class CoderTriggerTest(LifecycleTestBase):
    def setUp(self):
        super().setUp()
        self.conn.execute("UPDATE task SET status = 'reviewing_planner_output' WHERE task_id = ?", (self.task_id,))
        self.conn.commit()

    def test_blocked_until_sdd_passes(self):
        with self.assertRaises(org_node_lifecycle.TaskNotReadyError):
            org_node_lifecycle.trigger_org_node_full(self.conn, "coder", self.task_id, "/tmp/proj-1", "go")

    def test_succeeds_once_sdd_passes(self):
        # brd/prd/benchmark are deliberately NOT required anymore (2026-08-08,
        # Org Graph unification Phase 1 -- see _REQUIRED_ARTIFACTS's comment):
        # only sdd is checked, and this test proves that's sufficient.
        self._pass_artifact("sdd")
        with patch("src.org_node_lifecycle.trigger_org_node", return_value="ok"):
            result = org_node_lifecycle.trigger_org_node_full(
                self.conn, "coder", self.task_id, "/tmp/proj-1", "go"
            )
        self.assertEqual(result["status"], "coding")
        self.assertEqual(result["assigned_org_node"], "coder")


class ValidatorTriggerTest(LifecycleTestBase):
    def setUp(self):
        super().setUp()
        self.conn.execute("UPDATE task SET status = 'coding' WHERE task_id = ?", (self.task_id,))
        self.conn.commit()

    def test_blocked_until_code_diff_and_test_report_pass(self):
        with self.assertRaises(org_node_lifecycle.TaskNotReadyError):
            org_node_lifecycle.trigger_org_node_full(self.conn, "validator", self.task_id, "/tmp/proj-1", "go")

    def test_succeeds_once_both_artifacts_pass(self):
        self._pass_artifact("code_diff")
        self._pass_artifact("test_report")
        with patch("src.org_node_lifecycle.trigger_org_node", return_value="ok"):
            result = org_node_lifecycle.trigger_org_node_full(
                self.conn, "validator", self.task_id, "/tmp/proj-1", "go"
            )
        self.assertEqual(result["status"], "validating")
        self.assertEqual(result["assigned_org_node"], "validator")


if __name__ == "__main__":
    unittest.main()