kin/tests/test_kin_109_regression.py

263 lines
11 KiB
Python
Raw Permalink Normal View History

2026-03-17 21:13:16 +02:00
"""Regression tests for KIN-109:
Auto-test должен использовать python3.11 вместо python3 (ссылается на 3.14).
Root cause: Makefile test target used bare `pytest tests/` which resolves to the
system pytest (Python 3.14). Tests written for Python 3.11 could fail under 3.14.
Fix: Makefile test target changed to `python3.11 -m pytest tests/`.
2026-03-17 21:17:19 +02:00
PR fix: _detect_test_command() now returns sys.executable -m pytest for
pyproject.toml/setup.py projects (was bare 'pytest').
2026-03-17 21:13:16 +02:00
Coverage:
(1) Makefile test target uses python3.11, not bare pytest
(2) _detect_test_command still returns 'make test' for projects with Makefile
2026-03-17 21:17:19 +02:00
(3) _detect_test_command returns sys.executable -m pytest for pyproject.toml/setup.py
(4) Pipeline auto-test uses project.test_command when explicitly set
(5) Pipeline auto-test uses _detect_test_command when project.test_command is NULL
(6) PATCH /api/projects: test_command absent DB field unchanged (decision #580)
(7) Regression: pipeline auto-test early return (no test framework) does not crash
2026-03-17 21:13:16 +02:00
"""
2026-03-17 21:17:19 +02:00
import sys
2026-03-17 21:13:16 +02:00
import re
from pathlib import Path
2026-03-17 21:17:19 +02:00
from unittest.mock import patch, MagicMock
import pytest
from core.db import init_db
from core import models
2026-03-17 21:13:16 +02:00
# ---------------------------------------------------------------------------
# (1) Makefile — test target uses python3.11 -m pytest
# ---------------------------------------------------------------------------
class TestMakefileTestTarget:
def test_makefile_test_uses_python311(self):
"""Makefile test target must use python3.11 -m pytest, not bare pytest."""
makefile = Path(__file__).parent.parent / "Makefile"
assert makefile.is_file(), "Makefile must exist in project root"
content = makefile.read_text()
# Find test target block
m = re.search(r"^test\s*:.*?\n((?:\t.+\n?)*)", content, re.MULTILINE)
assert m is not None, "Makefile must have a 'test:' target"
test_body = m.group(1)
assert "python3.11 -m pytest" in test_body, (
f"Makefile test target must use 'python3.11 -m pytest', got:\n{test_body!r}"
)
def test_makefile_test_does_not_use_bare_pytest(self):
"""Makefile test target must NOT use bare 'pytest' (would pick up Python 3.14)."""
makefile = Path(__file__).parent.parent / "Makefile"
content = makefile.read_text()
m = re.search(r"^test\s*:.*?\n((?:\t.+\n?)*)", content, re.MULTILINE)
assert m is not None
test_body = m.group(1)
# Bare pytest call (not prefixed by python3.11 -m)
bare_pytest = re.search(r"(?<!python3\.11 -m )(?<!\w)pytest\b", test_body)
assert bare_pytest is None, (
f"Makefile test target must not use bare 'pytest', got:\n{test_body!r}"
)
# ---------------------------------------------------------------------------
# (2) _detect_test_command still returns 'make test' for Makefile projects
# ---------------------------------------------------------------------------
class TestDetectTestCommandUnchanged:
def test_detect_returns_make_test_for_makefile(self, tmp_path):
"""_detect_test_command must return 'make test' when Makefile has test target."""
from agents.runner import _detect_test_command
(tmp_path / "Makefile").write_text("test:\n\tpython3.11 -m pytest\n")
assert _detect_test_command(str(tmp_path)) == "make test"
def test_kin_project_detects_make_test(self):
"""kin project itself must auto-detect 'make test' (has Makefile with test target)."""
from agents.runner import _detect_test_command
project_root = str(Path(__file__).parent.parent)
result = _detect_test_command(project_root)
assert result == "make test", (
f"kin project should detect 'make test', got {result!r}"
)
2026-03-17 21:17:19 +02:00
# ---------------------------------------------------------------------------
# (3) _detect_test_command: pyproject.toml/setup.py → sys.executable -m pytest
# ---------------------------------------------------------------------------
class TestDetectTestCommandSysExecutable:
def test_detect_pyproject_returns_sys_executable_pytest(self, tmp_path):
"""pyproject.toml project must get sys.executable -m pytest, not bare pytest."""
from agents.runner import _detect_test_command
(tmp_path / "pyproject.toml").write_text("[build-system]\n")
result = _detect_test_command(str(tmp_path))
assert result == f"{sys.executable} -m pytest"
def test_detect_setup_py_returns_sys_executable_pytest(self, tmp_path):
"""setup.py project must get sys.executable -m pytest, not bare pytest."""
from agents.runner import _detect_test_command
(tmp_path / "setup.py").write_text("from setuptools import setup\nsetup()\n")
result = _detect_test_command(str(tmp_path))
assert result == f"{sys.executable} -m pytest"
def test_detect_pyproject_not_bare_pytest(self, tmp_path):
"""Regression: _detect_test_command must not return bare 'pytest' for pyproject.toml."""
from agents.runner import _detect_test_command
(tmp_path / "pyproject.toml").write_text("[build-system]\n")
result = _detect_test_command(str(tmp_path))
assert result != "pytest", (
"Must not use bare 'pytest' — would resolve to wrong Python version"
)
# ---------------------------------------------------------------------------
# Shared fixture for pipeline auto-test tests
# ---------------------------------------------------------------------------
@pytest.fixture
def conn_kin109():
c = init_db(":memory:")
models.create_project(c, "proj", "Proj", "/tmp/proj", tech_stack=["python"])
models.create_task(c, "PROJ-001", "proj", "Fix bug", brief={"route_type": "debug"})
yield c
c.close()
def _claude_mock_success():
m = MagicMock()
m.stdout = '{"result": "done", "usage": {"total_tokens": 100}, "cost_usd": 0.001}'
m.stderr = ""
m.returncode = 0
return m
# ---------------------------------------------------------------------------
# (4/5) Pipeline auto-test: project.test_command priority vs auto-detect
# ---------------------------------------------------------------------------
class TestAutoTestCommandPriority:
@patch("agents.runner._run_project_tests")
@patch("agents.runner.subprocess.run")
def test_uses_project_test_command_when_set(self, mock_run, mock_tests, conn_kin109, tmp_path):
"""When project.test_command is set, pipeline auto-test must use that command."""
mock_run.return_value = _claude_mock_success()
mock_tests.return_value = {"success": True, "output": "ok", "returncode": 0}
(tmp_path / "pyproject.toml").write_text("[build-system]\n")
models.update_project(
conn_kin109, "proj",
test_command="my_custom_runner",
auto_test_enabled=True,
path=str(tmp_path),
)
from agents.runner import run_pipeline
run_pipeline(conn_kin109, "PROJ-001", [{"role": "backend_dev", "brief": "fix it"}])
mock_tests.assert_called_once()
assert mock_tests.call_args.args[1] == "my_custom_runner"
@patch("agents.runner._run_project_tests")
@patch("agents.runner.subprocess.run")
def test_uses_detect_when_test_command_null(self, mock_run, mock_tests, conn_kin109, tmp_path):
"""When project.test_command is NULL, pipeline auto-test uses _detect_test_command result."""
mock_run.return_value = _claude_mock_success()
mock_tests.return_value = {"success": True, "output": "ok", "returncode": 0}
(tmp_path / "pyproject.toml").write_text("[build-system]\n")
models.update_project(
conn_kin109, "proj",
test_command=None,
auto_test_enabled=True,
path=str(tmp_path),
)
from agents.runner import run_pipeline
run_pipeline(conn_kin109, "PROJ-001", [{"role": "backend_dev", "brief": "fix it"}])
mock_tests.assert_called_once()
assert mock_tests.call_args.args[1] == f"{sys.executable} -m pytest"
# ---------------------------------------------------------------------------
# (6) PATCH /api/projects: test_command absent → DB field unchanged (decision #580)
# ---------------------------------------------------------------------------
class TestPatchProjectTestCommand:
@pytest.fixture
def client(self, tmp_path):
import web.api as api_module
api_module.DB_PATH = tmp_path / "test.db"
from fastapi.testclient import TestClient
from web.api import app
c = TestClient(app)
c.post("/api/projects", json={"id": "px", "name": "PX", "path": "/px"})
# Pre-set a test_command value
c.patch("/api/projects/px", json={"test_command": "existing_runner"})
return c
def test_patch_without_test_command_does_not_change_db(self, client):
"""PATCH without test_command field must NOT overwrite the existing value."""
r = client.patch("/api/projects/px", json={"auto_test_enabled": False})
assert r.status_code == 200
r2 = client.get("/api/projects/px")
assert r2.status_code == 200
assert r2.json()["test_command"] == "existing_runner"
def test_patch_with_test_command_updates_db(self, client):
"""PATCH with test_command sets the new value in DB."""
r = client.patch("/api/projects/px", json={"test_command": "new_runner"})
assert r.status_code == 200
r2 = client.get("/api/projects/px")
assert r2.json()["test_command"] == "new_runner"
def test_patch_with_empty_test_command_stores_null(self, client):
"""PATCH with test_command='' clears it to NULL (enables auto-detect)."""
r = client.patch("/api/projects/px", json={"test_command": ""})
assert r.status_code == 200
r2 = client.get("/api/projects/px")
assert r2.json()["test_command"] is None
# ---------------------------------------------------------------------------
# (7) Regression: auto-test early return (no framework detected) does not crash
# ---------------------------------------------------------------------------
class TestAutoTestEarlyReturn:
@patch("agents.runner.subprocess.run")
def test_pipeline_skips_auto_test_when_no_framework_detected(self, mock_run, conn_kin109, tmp_path):
"""When no test framework is detected and test_command is NULL, pipeline must
skip auto-test and return success not crash."""
mock_run.return_value = _claude_mock_success()
# tmp_path has no Makefile, pyproject.toml, package.json, setup.py, tsconfig.json
# → _detect_test_command returns None → auto-test skipped
models.update_project(
conn_kin109, "proj",
test_command=None,
auto_test_enabled=True,
path=str(tmp_path),
)
from agents.runner import run_pipeline
result = run_pipeline(conn_kin109, "PROJ-001", [{"role": "backend_dev", "brief": "fix"}])
assert isinstance(result, dict)
assert result["success"] is True
# The skipped auto-test step must be in results
skipped = [r for r in result["results"] if r.get("_skipped") and r.get("_project_test")]
assert len(skipped) == 1