kin/tests/test_kin_arch_017_regression.py
2026-03-17 16:14:35 +02:00

365 lines
17 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""
Regression tests for KIN-ARCH-017:
Более строгая проверка отсутствия двойного создания pipeline в _execute_department_head_step.
Решение #354: тест 'WHERE route_type=X AND count=1' НЕ обнаруживает дубликаты
с ДРУГИМ route_type. Если orphaned pipeline создаётся с route_type='dept_feature'
(а не 'dept_sub'), старый тест пропускает дубликат и ложно-зелёный.
Исправление (KIN-ARCH-017): проверять ОБЩИЙ count без фильтра по route_type,
сравнивая количество pipeline ДО и ПОСЛЕ вызова _execute_department_head_step.
Ровно один новый pipeline должен создаваться при любом route_type дубликата.
Convention #304: имена тестов описывают сломанное поведение.
Convention #305: отдельный класс на каждый уровень цепочки.
"""
import json
import pytest
from unittest.mock import patch, MagicMock
from core.db import init_db
from core import models
from agents.runner import _execute_department_head_step
# ---------------------------------------------------------------------------
# Fixtures
# ---------------------------------------------------------------------------
@pytest.fixture
def conn():
c = init_db(":memory:")
models.create_project(c, "proj", "TestProject", "~/projects/test",
tech_stack=["python", "vue3"])
models.create_task(c, "PROJ-001", "proj", "Full-stack feature",
brief={"route_type": "dept_feature"})
yield c
c.close()
def _mock_claude_success(output_data=None):
mock = MagicMock()
mock.stdout = json.dumps(output_data or {"result": "ok"})
mock.stderr = ""
mock.returncode = 0
return mock
def _dept_head_result_with_workers(workers=None):
"""Валидный вывод dept head с sub_pipeline."""
return {
"raw_output": json.dumps({
"status": "done",
"sub_pipeline": workers or [
{"role": "backend_dev", "brief": "implement endpoint"},
],
"artifacts": {"files_changed": ["api.py"]},
"handoff_notes": "Backend API done",
})
}
def _count_all_pipelines(conn) -> int:
"""Возвращает общее количество pipelines в БД без фильтра по route_type."""
row = conn.execute("SELECT COUNT(*) FROM pipelines").fetchone()
return row[0]
# ---------------------------------------------------------------------------
# Уровень 1: Ровно ОДИН pipeline создаётся — проверка ОБЩЕГО count (решение #354)
#
# Слабость старого теста KIN-ARCH-012: WHERE route_type='dept_sub' AND count=1
# не ловит orphaned pipeline с другим route_type (например, 'dept_feature').
# ---------------------------------------------------------------------------
class TestTotalPipelineCountAfterDeptHeadStep:
"""Уровень 1: Общий count всех pipeline до/после — ровно +1 запись в БД."""
@patch("agents.runner.check_claude_auth")
@patch("agents.runner.subprocess.run")
def test_exactly_one_new_pipeline_created_total_without_route_type_filter(
self, mock_run, mock_auth, conn
):
"""Решение #354: проверяем ОБЩИЙ count без фильтра по route_type.
Сломанное поведение (до KIN-ARCH-012/017): явный create_pipeline() в
_execute_department_head_step создавал orphaned pipeline (с любым route_type),
затем run_pipeline() создавал второй. Итого +2 pipeline за один вызов.
Тест 'WHERE route_type=dept_sub AND count=1' пропускал дубликат, если orphaned
pipeline имел другой route_type (например, dept_feature).
Fixed: сравниваем total_count_after - total_count_before == 1, без фильтра.
"""
mock_auth.return_value = None
mock_run.return_value = _mock_claude_success()
parent_pipeline = models.create_pipeline(
conn, "PROJ-001", "proj", "dept_feature", [{"role": "backend_head"}]
)
count_before = _count_all_pipelines(conn)
assert count_before == 1, "До вызова должен быть только parent pipeline"
_execute_department_head_step(
conn, "PROJ-001", "proj",
parent_pipeline_id=parent_pipeline["id"],
step={"role": "backend_head", "brief": "plan backend"},
dept_head_result=_dept_head_result_with_workers(),
)
count_after = _count_all_pipelines(conn)
new_pipelines_count = count_after - count_before
assert new_pipelines_count == 1, (
f"Ожидалось создание ровно 1 нового pipeline, создано: {new_pipelines_count}. "
"Признак двойного создания (KIN-ARCH-012/017): если новых pipeline > 1, "
"значит _execute_department_head_step снова вызывает явный create_pipeline() "
"до run_pipeline(). Этот тест ловит дубликаты с ЛЮБЫМ route_type — "
"в отличие от 'WHERE route_type=dept_sub' из решения #354."
)
@patch("agents.runner.check_claude_auth")
@patch("agents.runner.subprocess.run")
def test_no_orphaned_pipeline_with_different_route_type_created(
self, mock_run, mock_auth, conn
):
"""Нет orphaned pipeline с route_type != 'dept_sub' после вызова.
Решение #354: если старый баг возвращается, orphaned pipeline может
создаваться с dept_feature или custom route_type. Явно проверяем:
все новые pipeline (кроме parent) должны иметь route_type='dept_sub'.
"""
mock_auth.return_value = None
mock_run.return_value = _mock_claude_success()
parent_pipeline = models.create_pipeline(
conn, "PROJ-001", "proj", "dept_feature", [{"role": "backend_head"}]
)
_execute_department_head_step(
conn, "PROJ-001", "proj",
parent_pipeline_id=parent_pipeline["id"],
step={"role": "backend_head", "brief": "plan backend"},
dept_head_result=_dept_head_result_with_workers(),
)
# Все pipeline кроме parent должны иметь route_type='dept_sub'
child_pipelines = conn.execute(
"SELECT id, route_type FROM pipelines WHERE id != ?",
(parent_pipeline["id"],)
).fetchall()
for row in child_pipelines:
row = dict(row)
assert row["route_type"] == "dept_sub", (
f"Найден pipeline id={row['id']} с route_type='{row['route_type']}', "
"ожидался 'dept_sub'. "
"Это orphaned pipeline — признак двойного создания (KIN-ARCH-012/017). "
"Решение #354: старый тест не замечал дубликаты с другим route_type."
)
# ---------------------------------------------------------------------------
# Уровень 2: route_type созданного pipeline = 'dept_sub'
# Convention #305: отдельный класс
# ---------------------------------------------------------------------------
class TestChildPipelineRouteType:
"""Уровень 2: Созданный child pipeline имеет route_type='dept_sub'."""
@patch("agents.runner.check_claude_auth")
@patch("agents.runner.subprocess.run")
def test_child_pipeline_route_type_is_dept_sub(
self, mock_run, mock_auth, conn
):
"""Единственный child pipeline должен иметь route_type='dept_sub'.
runner.py:1267: effective_route_type = 'dept_sub' if parent_pipeline_id else route_type
Проверяем что это условие работает и route_type корректен.
"""
mock_auth.return_value = None
mock_run.return_value = _mock_claude_success()
parent_pipeline = models.create_pipeline(
conn, "PROJ-001", "proj", "dept_feature", [{"role": "backend_head"}]
)
_execute_department_head_step(
conn, "PROJ-001", "proj",
parent_pipeline_id=parent_pipeline["id"],
step={"role": "backend_head", "brief": "plan backend"},
dept_head_result=_dept_head_result_with_workers(),
)
child = conn.execute(
"SELECT * FROM pipelines WHERE id != ?",
(parent_pipeline["id"],)
).fetchone()
assert child is not None, "Child pipeline не найден в БД"
child = dict(child)
assert child["route_type"] == "dept_sub", (
f"route_type={child['route_type']!r}, ожидался 'dept_sub'. "
"runner.py:1267: effective_route_type = 'dept_sub' if parent_pipeline_id else route_type"
)
# ---------------------------------------------------------------------------
# Уровень 3: parent_pipeline_id и department корректны
# Convention #305: отдельный класс
# ---------------------------------------------------------------------------
class TestChildPipelineParentAndDepartment:
"""Уровень 3: Единственный child pipeline имеет корректные parent_pipeline_id и department."""
@patch("agents.runner.check_claude_auth")
@patch("agents.runner.subprocess.run")
def test_child_pipeline_parent_id_and_department_are_not_none(
self, mock_run, mock_auth, conn
):
"""Оба поля parent_pipeline_id и department не None у child pipeline.
runner.py:1223-1224: run_pipeline() принимает parent_pipeline_id и department.
runner.py:1270-1271: models.create_pipeline() вызывается с этими полями.
"""
mock_auth.return_value = None
mock_run.return_value = _mock_claude_success()
parent_pipeline = models.create_pipeline(
conn, "PROJ-001", "proj", "dept_feature", [{"role": "backend_head"}]
)
_execute_department_head_step(
conn, "PROJ-001", "proj",
parent_pipeline_id=parent_pipeline["id"],
step={"role": "backend_head", "brief": "plan backend"},
dept_head_result=_dept_head_result_with_workers(),
)
child = conn.execute(
"SELECT * FROM pipelines WHERE id != ?",
(parent_pipeline["id"],)
).fetchone()
assert child is not None, "Child pipeline не найден"
child = dict(child)
assert child["parent_pipeline_id"] is not None, \
"parent_pipeline_id не должен быть None"
assert child["parent_pipeline_id"] == parent_pipeline["id"], (
f"parent_pipeline_id={child['parent_pipeline_id']!r}, "
f"ожидался {parent_pipeline['id']!r}"
)
assert child["department"] is not None, \
"department не должен быть None"
assert child["department"] == "backend", (
f"department={child['department']!r}, ожидался 'backend' "
"(от role='backend_head' → dept_name = role.replace('_head', ''))"
)
# ---------------------------------------------------------------------------
# Уровень 4: handoff.pipeline_id указывает на child (не orphaned) pipeline
# Convention #305: отдельный класс
# ---------------------------------------------------------------------------
class TestHandoffPipelineIdPointsToChildNotOrphaned:
"""Уровень 4: handoff.pipeline_id = id единственного корректного child pipeline."""
@patch("agents.runner.check_claude_auth")
@patch("agents.runner.subprocess.run")
def test_handoff_pipeline_id_matches_only_child_pipeline_in_db(
self, mock_run, mock_auth, conn
):
"""handoff.pipeline_id должен совпадать с child pipeline в БД.
Усиленная проверка по сравнению с KIN-ARCH-012: явно сверяем что
handoff.pipeline_id не равен parent и не orphaned (не в 'running') записи.
runner.py:1142: pipeline_id = sub_result.get('pipeline_id') or parent_pipeline_id
"""
mock_auth.return_value = None
mock_run.return_value = _mock_claude_success()
parent_pipeline = models.create_pipeline(
conn, "PROJ-001", "proj", "dept_feature", [{"role": "backend_head"}]
)
_execute_department_head_step(
conn, "PROJ-001", "proj",
parent_pipeline_id=parent_pipeline["id"],
step={"role": "backend_head", "brief": "plan backend"},
dept_head_result=_dept_head_result_with_workers(),
next_department="frontend",
)
child = conn.execute(
"SELECT * FROM pipelines WHERE id != ?",
(parent_pipeline["id"],)
).fetchone()
assert child is not None, "Child pipeline не найден"
child = dict(child)
handoffs = models.get_handoffs_for_task(conn, "PROJ-001")
assert len(handoffs) == 1, f"Ожидался 1 handoff, найдено: {len(handoffs)}"
handoff_pipeline_id = handoffs[0]["pipeline_id"]
# handoff должен ссылаться на child, а не parent
assert handoff_pipeline_id != parent_pipeline["id"], (
"handoff.pipeline_id совпадает с parent_pipeline_id — "
"handoff должен описывать sub-pipeline, не родительский"
)
# handoff должен ссылаться на реальный child в БД
assert handoff_pipeline_id == child["id"], (
f"handoff.pipeline_id={handoff_pipeline_id!r} не совпадает "
f"с child pipeline id={child['id']!r}. "
"До фикса KIN-ARCH-012/017 handoff мог ссылаться на orphaned pipeline."
)
# ---------------------------------------------------------------------------
# Уровень 5: Нет running dept_sub pipeline после завершения (DB-cleanup)
# Convention #305: отдельный класс
# ---------------------------------------------------------------------------
class TestNoRunningDeptSubPipelinesAfterCompletion:
"""Уровень 5: Нет pipeline с route_type='dept_sub' AND status='running' после вызова."""
@patch("agents.runner.check_claude_auth")
@patch("agents.runner.subprocess.run")
def test_no_dept_sub_pipeline_remains_in_running_status(
self, mock_run, mock_auth, conn
):
"""После завершения _execute_department_head_step нет 'running' dept_sub pipeline.
db.py:666-668: _migrate() очищает orphaned running dept_sub pipeline.
Тест проверяет финальное состояние — ни один dept_sub не должен висеть
в 'running' после вызова (иначе watchdog будет ложно блокировать задачи).
"""
mock_auth.return_value = None
mock_run.return_value = _mock_claude_success()
parent_pipeline = models.create_pipeline(
conn, "PROJ-001", "proj", "dept_feature", [{"role": "backend_head"}]
)
_execute_department_head_step(
conn, "PROJ-001", "proj",
parent_pipeline_id=parent_pipeline["id"],
step={"role": "backend_head", "brief": "plan backend"},
dept_head_result=_dept_head_result_with_workers(),
)
running_dept_sub = conn.execute(
"SELECT id, status, route_type FROM pipelines "
"WHERE route_type='dept_sub' AND status='running'"
).fetchall()
assert len(running_dept_sub) == 0, (
f"Найдено {len(running_dept_sub)} pipeline с route_type='dept_sub' AND status='running' "
"после завершения вызова. Это признак orphaned pipeline из старого бага "
"двойного создания (KIN-ARCH-012/017). "
"db.py:666-668: _migrate() должна очищать такие записи при инициализации."
)