"""Regression tests for KIN-ARCH-023 — Analyst injection into actual executed pipeline. Two concerns: 1. At revise_count >= 2, analyst MUST be the first step in pending_steps saved to DB (not just in the response body). The subprocess picks steps from pending_steps, so a test that only checks the response body misses the real execution path. 2. The condition simplification at api.py ~1056 is semantically correct: `steps and (not steps or steps[0].get('role') != 'analyst')` is equivalent to: `steps and steps[0].get('role') != 'analyst'` because when `steps` is truthy, `not steps` is always False. """ import pytest from unittest.mock import patch from core.db import init_db from core import models # --------------------------------------------------------------------------- # Fixtures # --------------------------------------------------------------------------- @pytest.fixture def api_client(tmp_path): """FastAPI TestClient with isolated DB (same pattern as test_kin_128_regression.py).""" import web.api as api_module api_module.DB_PATH = tmp_path / "test.db" from web.api import app from fastapi.testclient import TestClient client = TestClient(app) client.post("/api/projects", json={"id": "p1", "name": "P1", "path": "/p1"}) client.post("/api/tasks", json={"project_id": "p1", "title": "Fix bug"}) return client @pytest.fixture def api_client_with_conn(tmp_path): """Returns (TestClient, db_path) so tests can query DB directly.""" import web.api as api_module db_path = tmp_path / "test.db" api_module.DB_PATH = db_path from web.api import app from fastapi.testclient import TestClient client = TestClient(app) client.post("/api/projects", json={"id": "p1", "name": "P1", "path": "/p1"}) client.post("/api/tasks", json={"project_id": "p1", "title": "Fix bug"}) return client, db_path # --------------------------------------------------------------------------- # 1. Analyst actually persisted to pending_steps in DB (subprocess-visible path) # --------------------------------------------------------------------------- class TestAnalystInjectedIntoPendingSteps: """The subprocess reads steps from pending_steps in DB, not from the HTTP response. These tests verify the DB state, not just the response body. """ def test_second_revise_saves_analyst_first_in_pending_steps(self, api_client_with_conn): """revise_count=2: pending_steps[0].role == 'analyst' in DB after revise call.""" client, db_path = api_client_with_conn steps = [{"role": "backend_dev", "model": "sonnet"}] with patch("web.api._launch_pipeline_subprocess"): client.post("/api/tasks/P1-001/revise", json={"comment": "r1", "steps": steps}) client.post("/api/tasks/P1-001/revise", json={"comment": "r2", "steps": steps}) conn = init_db(str(db_path)) task = models.get_task(conn, "P1-001") conn.close() pending = task.get("pending_steps") or [] assert pending, "pending_steps не должен быть пустым после 2-й ревизии" assert pending[0].get("role") == "analyst", ( f"KIN-ARCH-023: analyst должен быть первым в pending_steps (subprocess читает именно их), " f"получили: {pending[0].get('role')}" ) def test_first_revise_does_not_add_analyst_to_pending_steps(self, api_client_with_conn): """revise_count=1: analyst НЕ добавляется в pending_steps.""" client, db_path = api_client_with_conn steps = [{"role": "backend_dev", "model": "sonnet"}] with patch("web.api._launch_pipeline_subprocess"): client.post("/api/tasks/P1-001/revise", json={"comment": "r1", "steps": steps}) conn = init_db(str(db_path)) task = models.get_task(conn, "P1-001") conn.close() pending = task.get("pending_steps") or [] assert not pending or pending[0].get("role") != "analyst", ( "analyst не должен быть первым шагом в pending_steps при revise_count=1" ) def test_third_revise_saves_analyst_first_in_pending_steps(self, api_client_with_conn): """revise_count=3: analyst также является первым в pending_steps.""" client, db_path = api_client_with_conn steps = [{"role": "frontend_dev", "model": "sonnet"}] with patch("web.api._launch_pipeline_subprocess"): for comment in ("r1", "r2", "r3"): client.post("/api/tasks/P1-001/revise", json={"comment": comment, "steps": steps}) conn = init_db(str(db_path)) task = models.get_task(conn, "P1-001") conn.close() pending = task.get("pending_steps") or [] assert pending, "pending_steps не должен быть пустым после 3-й ревизии" assert pending[0].get("role") == "analyst", ( f"revise_count=3: первым в pending_steps должен быть analyst, получили: {pending[0].get('role')}" ) def test_pending_steps_length_increases_by_one_after_analyst_injection(self, api_client_with_conn): """После инжекции analyst длина pending_steps на 1 больше исходного списка шагов.""" client, db_path = api_client_with_conn original_steps = [ {"role": "backend_dev", "model": "sonnet"}, {"role": "reviewer", "model": "sonnet"}, ] with patch("web.api._launch_pipeline_subprocess"): client.post("/api/tasks/P1-001/revise", json={"comment": "r1", "steps": original_steps}) client.post("/api/tasks/P1-001/revise", json={"comment": "r2", "steps": original_steps}) conn = init_db(str(db_path)) task = models.get_task(conn, "P1-001") conn.close() pending = task.get("pending_steps") or [] assert len(pending) == len(original_steps) + 1, ( f"Ожидали {len(original_steps) + 1} шагов (с analyst), получили {len(pending)}" ) def test_analyst_not_duplicated_in_pending_steps_if_already_first(self, api_client_with_conn): """Если analyst уже первый шаг — pending_steps не содержит дублей analyst.""" client, db_path = api_client_with_conn steps = [{"role": "analyst", "model": "sonnet"}, {"role": "backend_dev", "model": "sonnet"}] with patch("web.api._launch_pipeline_subprocess"): client.post("/api/tasks/P1-001/revise", json={"comment": "r1", "steps": steps}) client.post("/api/tasks/P1-001/revise", json={"comment": "r2", "steps": steps}) conn = init_db(str(db_path)) task = models.get_task(conn, "P1-001") conn.close() pending = task.get("pending_steps") or [] analyst_count = sum(1 for s in pending if s.get("role") == "analyst") assert analyst_count == 1, ( f"analyst не должен дублироваться в pending_steps, нашли {analyst_count} вхождений" ) # --------------------------------------------------------------------------- # 2. Condition simplification semantics (api.py ~1056) # --------------------------------------------------------------------------- def _original_condition(steps): """Original (verbose) condition from before the simplification.""" return steps and (not steps or steps[0].get("role") != "analyst") def _simplified_condition(steps): """Simplified condition after KIN-ARCH-023 fix.""" return steps and steps[0].get("role") != "analyst" class TestConditionSimplificationEquivalence: """Verify that the simplified condition produces identical results to the original across all meaningful input variants. This guards against future refactors accidentally diverging the two forms. """ def test_empty_list_both_return_falsy(self): """steps=[] → оба варианта возвращают falsy (инжекция не происходит).""" assert not _original_condition([]) assert not _simplified_condition([]) def test_none_both_return_falsy(self): """steps=None → оба варианта возвращают falsy.""" assert not _original_condition(None) assert not _simplified_condition(None) def test_analyst_first_both_return_falsy(self): """Если analyst уже первый — оба варианта возвращают falsy (инжекция пропускается).""" steps = [{"role": "analyst"}, {"role": "backend_dev"}] assert not _original_condition(steps) assert not _simplified_condition(steps) def test_non_analyst_first_both_return_truthy(self): """Если первый шаг не analyst — оба варианта truthy (инжекция происходит).""" steps = [{"role": "backend_dev"}, {"role": "reviewer"}] assert _original_condition(steps) assert _simplified_condition(steps) def test_single_step_non_analyst_both_truthy(self): """Одиночный шаг не-analyst → оба truthy.""" steps = [{"role": "debugger"}] assert _original_condition(steps) assert _simplified_condition(steps) def test_step_without_role_key_both_truthy(self): """Шаг без ключа 'role' → steps[0].get('role') возвращает None ≠ 'analyst' → truthy.""" steps = [{"model": "sonnet"}] assert _original_condition(steps) assert _simplified_condition(steps) @pytest.mark.parametrize("steps", [ [], None, [{"role": "analyst"}], [{"role": "backend_dev"}], [{"role": "reviewer"}, {"role": "backend_dev"}], [{"role": "analyst"}, {"role": "backend_dev"}], [{"model": "sonnet"}], ]) def test_parametrized_equivalence(self, steps): """Для всех входных данных упрощённое условие идентично исходному.""" original = bool(_original_condition(steps)) simplified = bool(_simplified_condition(steps)) assert original == simplified, ( f"Условия расходятся для steps={steps}: " f"original={original}, simplified={simplified}" )