kin/tests/test_kin_arch_023_regression.py
2026-03-18 22:30:25 +02:00

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