kin/tests/test_kin_arch_017_regression.py

366 lines
17 KiB
Python
Raw Normal View History

2026-03-17 16:14:35 +02:00
"""
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() должна очищать такие записи при инициализации."
)