228 lines
10 KiB
Python
228 lines
10 KiB
Python
"""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}"
|
||
)
|