test: expand test suite to 150+ tests and 80%+ coverage
Browse files- Added 30 parametrized scenario validation tests (97 total cases)
- Implemented shared fixtures in conftest.py with in-memory DB override
- Added persistence layer tests for SQLite and leaderboard logic
- Expanded grader tests with edge cases for line tolerance and penalties
- Added API error path and pagination tests
- Fixed logic bug in SecurityGrader keyword thresholding
- Configured pytest-cov with 80% coverage enforcement
- pyproject.toml +15 -0
- pytest.ini +6 -0
- requirements.txt +1 -0
- tests/conftest.py +44 -0
- tests/test_api.py +65 -0
- tests/test_database.py +65 -0
- tests/test_env.py +56 -0
- tests/test_graders.py +143 -0
- tests/test_scenarios.py +59 -0
pyproject.toml
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
[tool.coverage.run]
|
| 2 |
+
source = ["codereview_env", "app"]
|
| 3 |
+
omit = ["tests/*", "scripts/*", "*/migrations/*"]
|
| 4 |
+
|
| 5 |
+
[tool.coverage.report]
|
| 6 |
+
fail_under = 80
|
| 7 |
+
show_missing = true
|
| 8 |
+
exclude_lines = [
|
| 9 |
+
"pragma: no cover",
|
| 10 |
+
"if __name__ == .__main__.:",
|
| 11 |
+
"raise NotImplementedError",
|
| 12 |
+
]
|
| 13 |
+
|
| 14 |
+
[tool.pylint.messages_control]
|
| 15 |
+
disable = ["C0114", "C0115", "C0116"] # disable missing-docstring warnings
|
pytest.ini
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
[pytest]
|
| 2 |
+
testpaths = tests
|
| 3 |
+
addopts = --tb=short -q
|
| 4 |
+
markers =
|
| 5 |
+
slow: marks tests as slow (use -m "not slow" to skip)
|
| 6 |
+
integration: marks integration tests
|
requirements.txt
CHANGED
|
@@ -11,3 +11,4 @@ slowapi==0.1.9
|
|
| 11 |
python-dotenv==1.0.1
|
| 12 |
sqlmodel==0.0.16
|
| 13 |
aiosqlite==0.20.0
|
|
|
|
|
|
| 11 |
python-dotenv==1.0.1
|
| 12 |
sqlmodel==0.0.16
|
| 13 |
aiosqlite==0.20.0
|
| 14 |
+
pytest-cov==4.1.0
|
tests/conftest.py
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import pytest
|
| 2 |
+
from fastapi.testclient import TestClient
|
| 3 |
+
from sqlmodel import SQLModel, Session, create_engine
|
| 4 |
+
from sqlmodel.pool import StaticPool
|
| 5 |
+
|
| 6 |
+
from app import app
|
| 7 |
+
from codereview_env.database import get_session
|
| 8 |
+
from codereview_env.env import CodeReviewEnv
|
| 9 |
+
from codereview_env.models import TaskId, Action, ActionType, Severity, Category, Verdict
|
| 10 |
+
|
| 11 |
+
@pytest.fixture(name="session")
|
| 12 |
+
def session_fixture():
|
| 13 |
+
"""In-memory SQLite session for tests."""
|
| 14 |
+
engine = create_engine(
|
| 15 |
+
"sqlite://",
|
| 16 |
+
connect_args={"check_same_thread": False},
|
| 17 |
+
poolclass=StaticPool,
|
| 18 |
+
)
|
| 19 |
+
SQLModel.metadata.create_all(engine)
|
| 20 |
+
with Session(engine) as session:
|
| 21 |
+
yield session
|
| 22 |
+
|
| 23 |
+
@pytest.fixture(name="client")
|
| 24 |
+
def client_fixture(session):
|
| 25 |
+
"""TestClient with DB dependency override."""
|
| 26 |
+
def get_session_override():
|
| 27 |
+
yield session
|
| 28 |
+
app.dependency_overrides[get_session] = get_session_override
|
| 29 |
+
client = TestClient(app)
|
| 30 |
+
yield client
|
| 31 |
+
app.dependency_overrides.clear()
|
| 32 |
+
|
| 33 |
+
@pytest.fixture
|
| 34 |
+
def env():
|
| 35 |
+
return CodeReviewEnv()
|
| 36 |
+
|
| 37 |
+
@pytest.fixture
|
| 38 |
+
def approve_action():
|
| 39 |
+
return Action(action_type=ActionType.APPROVE, body="LGTM", verdict=Verdict.LGTM)
|
| 40 |
+
|
| 41 |
+
@pytest.fixture
|
| 42 |
+
def request_changes_action():
|
| 43 |
+
return Action(action_type=ActionType.REQUEST_CHANGES, body="Issues found",
|
| 44 |
+
verdict=Verdict.REQUEST_CHANGES)
|
tests/test_api.py
CHANGED
|
@@ -62,3 +62,68 @@ def test_api_invalid_episode():
|
|
| 62 |
"body": "hello"
|
| 63 |
})
|
| 64 |
assert response.status_code == 404
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 62 |
"body": "hello"
|
| 63 |
})
|
| 64 |
assert response.status_code == 404
|
| 65 |
+
|
| 66 |
+
def test_api_health_fields(client):
|
| 67 |
+
resp = client.get("/health")
|
| 68 |
+
data = resp.json()
|
| 69 |
+
assert "active_episodes" in data
|
| 70 |
+
assert "auth_enabled" in data
|
| 71 |
+
assert "env" in data
|
| 72 |
+
|
| 73 |
+
def test_api_reset_invalid_task(client):
|
| 74 |
+
resp = client.post("/reset", json={"task_id": "invalid_task", "seed": 0})
|
| 75 |
+
assert resp.status_code == 422
|
| 76 |
+
|
| 77 |
+
def test_api_step_invalid_action_type(client):
|
| 78 |
+
reset_resp = client.post("/reset", json={"task_id": "bug_detection", "seed": 0})
|
| 79 |
+
episode_id = reset_resp.json()["episode_id"]
|
| 80 |
+
resp = client.post(f"/step/{episode_id}", json={"action_type": "not_valid", "body": "x"})
|
| 81 |
+
assert resp.status_code == 422
|
| 82 |
+
|
| 83 |
+
def test_api_result_after_completion(client):
|
| 84 |
+
"""Result endpoint should return persisted data for completed episodes."""
|
| 85 |
+
reset_resp = client.post("/reset", json={"task_id": "bug_detection", "seed": 0})
|
| 86 |
+
episode_id = reset_resp.json()["episode_id"]
|
| 87 |
+
# Complete the episode
|
| 88 |
+
client.post(f"/step/{episode_id}", json={
|
| 89 |
+
"action_type": "approve", "body": "LGTM", "verdict": "lgtm"
|
| 90 |
+
})
|
| 91 |
+
# Result must be available
|
| 92 |
+
result_resp = client.get(f"/result/{episode_id}")
|
| 93 |
+
assert result_resp.status_code == 200
|
| 94 |
+
assert result_resp.json()["final_score"] >= 0
|
| 95 |
+
|
| 96 |
+
def test_api_stats_endpoint(client):
|
| 97 |
+
resp = client.get("/stats")
|
| 98 |
+
assert resp.status_code == 200
|
| 99 |
+
assert "total_episodes" in resp.json()
|
| 100 |
+
|
| 101 |
+
@pytest.mark.parametrize("task_id", ["bug_detection", "security_audit", "architectural_review"])
|
| 102 |
+
def test_api_full_workflow_all_tasks(client, task_id):
|
| 103 |
+
reset = client.post("/reset", json={"task_id": task_id, "seed": 1})
|
| 104 |
+
assert reset.status_code == 200
|
| 105 |
+
episode_id = reset.json()["episode_id"]
|
| 106 |
+
|
| 107 |
+
step = client.post(f"/step/{episode_id}", json={
|
| 108 |
+
"action_type": "approve", "body": "LGTM", "verdict": "lgtm"
|
| 109 |
+
})
|
| 110 |
+
assert step.status_code == 200
|
| 111 |
+
assert step.json()["done"] is True
|
| 112 |
+
|
| 113 |
+
def test_api_leaderboard_pagination(client):
|
| 114 |
+
# Submit 3 entries
|
| 115 |
+
for i, score in enumerate([0.9, 0.7, 0.5]):
|
| 116 |
+
client.post("/submit", json={
|
| 117 |
+
"agent_name": f"agent_{i}", "task_id": "bug_detection",
|
| 118 |
+
"score": score, "seed": i
|
| 119 |
+
})
|
| 120 |
+
|
| 121 |
+
# Test limit
|
| 122 |
+
resp = client.get("/leaderboard?task_id=bug_detection&limit=2")
|
| 123 |
+
assert resp.status_code == 200
|
| 124 |
+
data = resp.json()
|
| 125 |
+
assert len(data["entries"]) == 2
|
| 126 |
+
assert data["total"] >= 3
|
| 127 |
+
|
| 128 |
+
# Test ordering (best first)
|
| 129 |
+
assert data["entries"][0]["score"] >= data["entries"][1]["score"]
|
tests/test_database.py
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from sqlmodel import Session
|
| 2 |
+
from codereview_env.database import save_episode, get_episode, submit_leaderboard, get_leaderboard_db, get_stats
|
| 3 |
+
from codereview_env.models import EpisodeResult, TaskId, ActionRecord, ActionType
|
| 4 |
+
|
| 5 |
+
def make_result(episode_id="test-ep-1", score=0.85):
|
| 6 |
+
return EpisodeResult(
|
| 7 |
+
episode_id=episode_id,
|
| 8 |
+
task_id=TaskId.BUG_DETECTION,
|
| 9 |
+
scenario_hash="bug_001",
|
| 10 |
+
seed=0,
|
| 11 |
+
final_score=score,
|
| 12 |
+
steps_taken=3,
|
| 13 |
+
issues_found=1,
|
| 14 |
+
issues_total=1,
|
| 15 |
+
noise_penalties=0,
|
| 16 |
+
terminated_reason="terminal_action",
|
| 17 |
+
history=[ActionRecord(action_type=ActionType.APPROVE, body="LGTM")]
|
| 18 |
+
)
|
| 19 |
+
|
| 20 |
+
def test_save_and_get_episode(session):
|
| 21 |
+
result = make_result()
|
| 22 |
+
save_episode(session, result)
|
| 23 |
+
record = get_episode(session, "test-ep-1")
|
| 24 |
+
assert record is not None
|
| 25 |
+
assert record.final_score == 0.85
|
| 26 |
+
assert record.scenario_hash == "bug_001"
|
| 27 |
+
|
| 28 |
+
def test_get_nonexistent_episode(session):
|
| 29 |
+
record = get_episode(session, "does-not-exist")
|
| 30 |
+
assert record is None
|
| 31 |
+
|
| 32 |
+
def test_episode_history_serialized(session):
|
| 33 |
+
result = make_result()
|
| 34 |
+
save_episode(session, result)
|
| 35 |
+
record = get_episode(session, result.episode_id)
|
| 36 |
+
import json
|
| 37 |
+
history = json.loads(record.history_json)
|
| 38 |
+
assert len(history) == 1
|
| 39 |
+
assert history[0]["action_type"] == "approve"
|
| 40 |
+
|
| 41 |
+
def test_leaderboard_submit_and_rank(session):
|
| 42 |
+
rank = submit_leaderboard(session, "agent_a", "bug_detection", 0.9, 0)
|
| 43 |
+
assert rank == 1
|
| 44 |
+
rank2 = submit_leaderboard(session, "agent_b", "bug_detection", 0.7, 1)
|
| 45 |
+
assert rank2 == 2
|
| 46 |
+
|
| 47 |
+
def test_leaderboard_ordering(session):
|
| 48 |
+
submit_leaderboard(session, "low", "security_audit", 0.3, 0)
|
| 49 |
+
submit_leaderboard(session, "high", "security_audit", 0.95, 1)
|
| 50 |
+
submit_leaderboard(session, "mid", "security_audit", 0.6, 2)
|
| 51 |
+
entries, total = get_leaderboard_db(session, "security_audit")
|
| 52 |
+
assert total == 3
|
| 53 |
+
assert entries[0].agent_name == "high"
|
| 54 |
+
assert entries[0].score == 0.95
|
| 55 |
+
|
| 56 |
+
def test_get_stats_empty(session):
|
| 57 |
+
stats = get_stats(session)
|
| 58 |
+
assert stats["total_episodes"] == 0
|
| 59 |
+
|
| 60 |
+
def test_get_stats_populated(session):
|
| 61 |
+
save_episode(session, make_result("ep1", 0.9))
|
| 62 |
+
save_episode(session, make_result("ep2", 0.5))
|
| 63 |
+
stats = get_stats(session)
|
| 64 |
+
assert stats["total_episodes"] == 2
|
| 65 |
+
assert abs(stats["avg_score"] - 0.7) < 0.001
|
tests/test_env.py
CHANGED
|
@@ -193,3 +193,59 @@ def test_arch_task_runs_to_completion():
|
|
| 193 |
))
|
| 194 |
final = env.get_final_result()
|
| 195 |
assert final.final_score > 0
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 193 |
))
|
| 194 |
final = env.get_final_result()
|
| 195 |
assert final.final_score > 0
|
| 196 |
+
|
| 197 |
+
@pytest.mark.parametrize("task_id", list(TaskId))
|
| 198 |
+
def test_env_reset_all_tasks(task_id, env):
|
| 199 |
+
"""Reset must work for all three task types."""
|
| 200 |
+
result = env.reset(task_id, seed=0)
|
| 201 |
+
assert result.task_id == task_id
|
| 202 |
+
assert result.observation.noise_budget == 5
|
| 203 |
+
|
| 204 |
+
@pytest.mark.parametrize("task_id,expected_max_steps", [
|
| 205 |
+
(TaskId.BUG_DETECTION, 10),
|
| 206 |
+
(TaskId.SECURITY_AUDIT, 15),
|
| 207 |
+
(TaskId.ARCHITECTURAL_REVIEW, 20),
|
| 208 |
+
])
|
| 209 |
+
def test_env_max_steps_per_task(task_id, expected_max_steps, env):
|
| 210 |
+
result = env.reset(task_id, seed=0)
|
| 211 |
+
assert result.observation.max_steps == expected_max_steps
|
| 212 |
+
|
| 213 |
+
def test_env_step_raises_when_done(env, approve_action):
|
| 214 |
+
"""Calling step on a done episode must raise ValueError."""
|
| 215 |
+
env.reset(TaskId.BUG_DETECTION, seed=0)
|
| 216 |
+
env.step(approve_action)
|
| 217 |
+
with pytest.raises(ValueError):
|
| 218 |
+
env.step(approve_action)
|
| 219 |
+
|
| 220 |
+
def test_env_history_recorded(env):
|
| 221 |
+
"""All steps should appear in final result history."""
|
| 222 |
+
env.reset(TaskId.BUG_DETECTION, seed=0)
|
| 223 |
+
from codereview_env.models import Action, ActionType
|
| 224 |
+
for _ in range(3):
|
| 225 |
+
env.step(Action(action_type=ActionType.ASK_QUESTION, body="question"))
|
| 226 |
+
env.step(Action(action_type=ActionType.APPROVE, body="LGTM", verdict=Verdict.LGTM))
|
| 227 |
+
result = env.get_final_result()
|
| 228 |
+
assert result.steps_taken == 4
|
| 229 |
+
assert len(result.history) == 4
|
| 230 |
+
|
| 231 |
+
def test_env_get_final_result_score_clamped(env, approve_action):
|
| 232 |
+
"""Final score must always be in [0, 1]."""
|
| 233 |
+
env.reset(TaskId.BUG_DETECTION, seed=0)
|
| 234 |
+
env.step(approve_action)
|
| 235 |
+
result = env.get_final_result()
|
| 236 |
+
# Check that score is a float and within [0, 1]
|
| 237 |
+
assert isinstance(result.final_score, float)
|
| 238 |
+
assert 0.0 <= result.final_score <= 1.0
|
| 239 |
+
|
| 240 |
+
@pytest.mark.parametrize("task_id", list(TaskId))
|
| 241 |
+
@pytest.mark.parametrize("seed", [0, 3, 7])
|
| 242 |
+
def test_env_full_episode_completes(task_id, seed, env):
|
| 243 |
+
"""Full episodes must always reach a terminal state."""
|
| 244 |
+
env.reset(task_id, seed=seed)
|
| 245 |
+
from codereview_env.models import Action, ActionType, Verdict
|
| 246 |
+
# Just skip to terminal
|
| 247 |
+
action = Action(action_type=ActionType.APPROVE, body="LGTM", verdict=Verdict.LGTM)
|
| 248 |
+
result = env.step(action)
|
| 249 |
+
assert result.done is True
|
| 250 |
+
final = env.get_final_result()
|
| 251 |
+
assert final.terminated_reason == "terminal_action"
|
tests/test_graders.py
CHANGED
|
@@ -72,3 +72,146 @@ def test_arch_grader_verdict():
|
|
| 72 |
# issue_score = 1.0, verdict_score = 0.0, quality_score = 0.0
|
| 73 |
# score = 0.6 * 1.0 + 0.2 * 0.0 + 0.0 = 0.6
|
| 74 |
assert score == 0.6
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 72 |
# issue_score = 1.0, verdict_score = 0.0, quality_score = 0.0
|
| 73 |
# score = 0.6 * 1.0 + 0.2 * 0.0 + 0.0 = 0.6
|
| 74 |
assert score == 0.6
|
| 75 |
+
|
| 76 |
+
# βββ Bug Grader Edge Cases βββββββββββββββββββββββββββββ
|
| 77 |
+
|
| 78 |
+
def test_bug_grader_partial_match():
|
| 79 |
+
"""Matching some but not all issues."""
|
| 80 |
+
scenario = Scenario(
|
| 81 |
+
task_id=TaskId.BUG_DETECTION, pr_title="t", pr_description="t",
|
| 82 |
+
files_changed=[],
|
| 83 |
+
ground_truth_issues=[
|
| 84 |
+
GroundTruthIssue(id="1", category=Category.BUG, severity=Severity.HIGH,
|
| 85 |
+
filename="f1", line_number=10, description="d1", keywords=["k1"]),
|
| 86 |
+
GroundTruthIssue(id="2", category=Category.BUG, severity=Severity.LOW,
|
| 87 |
+
filename="f2", line_number=20, description="d2", keywords=["k2"]),
|
| 88 |
+
],
|
| 89 |
+
hash="test"
|
| 90 |
+
)
|
| 91 |
+
history = [
|
| 92 |
+
ActionRecord(action_type=ActionType.FLAG_ISSUE, body="k1",
|
| 93 |
+
filename="f1", line_number=10, category=Category.BUG, severity=Severity.HIGH)
|
| 94 |
+
]
|
| 95 |
+
score = grade_bug_detection(scenario, history)
|
| 96 |
+
assert 0.0 < score < 1.0, f"Partial match should give intermediate score, got {score}"
|
| 97 |
+
|
| 98 |
+
def test_bug_grader_line_tolerance():
|
| 99 |
+
"""Issue flagged within Β±3 lines should match."""
|
| 100 |
+
scenario = Scenario(
|
| 101 |
+
task_id=TaskId.BUG_DETECTION, pr_title="t", pr_description="t",
|
| 102 |
+
files_changed=[],
|
| 103 |
+
ground_truth_issues=[
|
| 104 |
+
GroundTruthIssue(id="1", category=Category.BUG, severity=Severity.MEDIUM,
|
| 105 |
+
filename="f1", line_number=10, description="d", keywords=["bug"])
|
| 106 |
+
],
|
| 107 |
+
hash="test"
|
| 108 |
+
)
|
| 109 |
+
# Flag at line 12 (within Β±3)
|
| 110 |
+
history = [
|
| 111 |
+
ActionRecord(action_type=ActionType.FLAG_ISSUE, body="bug found here",
|
| 112 |
+
filename="f1", line_number=12, category=Category.BUG, severity=Severity.MEDIUM)
|
| 113 |
+
]
|
| 114 |
+
score = grade_bug_detection(scenario, history)
|
| 115 |
+
assert score > 0.0, "Line within tolerance should match"
|
| 116 |
+
|
| 117 |
+
def test_bug_grader_line_out_of_tolerance():
|
| 118 |
+
"""Issue flagged outside Β±3 lines should NOT match."""
|
| 119 |
+
scenario = Scenario(
|
| 120 |
+
task_id=TaskId.BUG_DETECTION, pr_title="t", pr_description="t",
|
| 121 |
+
files_changed=[],
|
| 122 |
+
ground_truth_issues=[
|
| 123 |
+
GroundTruthIssue(id="1", category=Category.BUG, severity=Severity.MEDIUM,
|
| 124 |
+
filename="f1", line_number=10, description="d", keywords=["bug"])
|
| 125 |
+
],
|
| 126 |
+
hash="test"
|
| 127 |
+
)
|
| 128 |
+
# Flag at line 15 (outside Β±3)
|
| 129 |
+
history = [
|
| 130 |
+
ActionRecord(action_type=ActionType.FLAG_ISSUE, body="bug found here",
|
| 131 |
+
filename="f1", line_number=15, category=Category.BUG, severity=Severity.MEDIUM)
|
| 132 |
+
]
|
| 133 |
+
score = grade_bug_detection(scenario, history)
|
| 134 |
+
assert score == 0.0, "Line outside tolerance should not match"
|
| 135 |
+
|
| 136 |
+
def test_bug_grader_false_positives_penalized():
|
| 137 |
+
"""Multiple FP flags should reduce score."""
|
| 138 |
+
scenario = Scenario(
|
| 139 |
+
task_id=TaskId.BUG_DETECTION, pr_title="t", pr_description="t",
|
| 140 |
+
files_changed=[],
|
| 141 |
+
ground_truth_issues=[
|
| 142 |
+
GroundTruthIssue(id="1", category=Category.BUG, severity=Severity.MEDIUM,
|
| 143 |
+
filename="f1", line_number=10, description="d", keywords=["real"])
|
| 144 |
+
],
|
| 145 |
+
hash="test"
|
| 146 |
+
)
|
| 147 |
+
history = [
|
| 148 |
+
# One correct flag
|
| 149 |
+
ActionRecord(action_type=ActionType.FLAG_ISSUE, body="real bug",
|
| 150 |
+
filename="f1", line_number=10, category=Category.BUG, severity=Severity.MEDIUM),
|
| 151 |
+
# Three false positives
|
| 152 |
+
ActionRecord(action_type=ActionType.FLAG_ISSUE, body="fp1",
|
| 153 |
+
filename="nowhere", line_number=999, category=Category.BUG, severity=Severity.LOW),
|
| 154 |
+
ActionRecord(action_type=ActionType.FLAG_ISSUE, body="fp2",
|
| 155 |
+
filename="nowhere", line_number=998, category=Category.BUG, severity=Severity.LOW),
|
| 156 |
+
ActionRecord(action_type=ActionType.FLAG_ISSUE, body="fp3",
|
| 157 |
+
filename="nowhere", line_number=997, category=Category.BUG, severity=Severity.LOW),
|
| 158 |
+
]
|
| 159 |
+
perfect_score = 1.0
|
| 160 |
+
score = grade_bug_detection(scenario, history)
|
| 161 |
+
assert score < perfect_score, "FP flags should reduce score below perfect"
|
| 162 |
+
|
| 163 |
+
# βββ Security Grader Edge Cases βββββββββββββββββββββββββ
|
| 164 |
+
|
| 165 |
+
def test_security_grader_perfect():
|
| 166 |
+
scenario = Scenario(
|
| 167 |
+
task_id=TaskId.SECURITY_AUDIT, pr_title="t", pr_description="t",
|
| 168 |
+
files_changed=[],
|
| 169 |
+
ground_truth_issues=[
|
| 170 |
+
GroundTruthIssue(id="1", category=Category.SECURITY, severity=Severity.CRITICAL,
|
| 171 |
+
filename="f1", line_number=10, description="d", keywords=["sql", "injection"])
|
| 172 |
+
],
|
| 173 |
+
hash="test"
|
| 174 |
+
)
|
| 175 |
+
history = [
|
| 176 |
+
ActionRecord(action_type=ActionType.FLAG_ISSUE, body="sql injection vulnerability",
|
| 177 |
+
filename="f1", line_number=10, category=Category.SECURITY, severity=Severity.CRITICAL)
|
| 178 |
+
]
|
| 179 |
+
score = grade_security_audit(scenario, history)
|
| 180 |
+
assert score == 1.0
|
| 181 |
+
|
| 182 |
+
def test_security_grader_empty_history():
|
| 183 |
+
scenario = Scenario(
|
| 184 |
+
task_id=TaskId.SECURITY_AUDIT, pr_title="t", pr_description="t",
|
| 185 |
+
files_changed=[],
|
| 186 |
+
ground_truth_issues=[
|
| 187 |
+
GroundTruthIssue(id="1", category=Category.SECURITY, severity=Severity.HIGH,
|
| 188 |
+
filename="f1", line_number=5, description="d", keywords=["k1"])
|
| 189 |
+
],
|
| 190 |
+
hash="test"
|
| 191 |
+
)
|
| 192 |
+
assert grade_security_audit(scenario, []) == 0.0
|
| 193 |
+
|
| 194 |
+
# βββ Arch Grader Edge Cases βββββββββββββββββββββββββββββ
|
| 195 |
+
|
| 196 |
+
def test_arch_grader_correct_verdict():
|
| 197 |
+
scenario = Scenario(
|
| 198 |
+
task_id=TaskId.ARCHITECTURAL_REVIEW, pr_title="t", pr_description="t",
|
| 199 |
+
files_changed=[],
|
| 200 |
+
ground_truth_issues=[
|
| 201 |
+
GroundTruthIssue(id="1", category=Category.ARCHITECTURE, severity=Severity.HIGH,
|
| 202 |
+
filename="f1", line_number=10, description="d",
|
| 203 |
+
keywords=["god class", "single responsibility"],
|
| 204 |
+
required_verdict=Verdict.REQUEST_CHANGES)
|
| 205 |
+
],
|
| 206 |
+
hash="test"
|
| 207 |
+
)
|
| 208 |
+
# Correct verdict
|
| 209 |
+
body = "This is a god class violating single responsibility principle and needs major refactoring"
|
| 210 |
+
history = [
|
| 211 |
+
ActionRecord(action_type=ActionType.FLAG_ISSUE, body=body,
|
| 212 |
+
filename="f1", line_number=10, category=Category.ARCHITECTURE, severity=Severity.HIGH),
|
| 213 |
+
ActionRecord(action_type=ActionType.REQUEST_CHANGES, body="Needs refactoring",
|
| 214 |
+
verdict=Verdict.REQUEST_CHANGES)
|
| 215 |
+
]
|
| 216 |
+
score = grade_architectural_review(scenario, history)
|
| 217 |
+
assert score > 0.6, f"Correct verdict should score well, got {score}"
|
tests/test_scenarios.py
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import pytest
|
| 2 |
+
from codereview_env.scenarios import get_scenario, all_scenarios
|
| 3 |
+
from codereview_env.models import TaskId, Severity
|
| 4 |
+
|
| 5 |
+
# ββ All 30 scenarios loadable ββββββββββββββββββββββββββββββββββββββββββββββ
|
| 6 |
+
|
| 7 |
+
def test_all_scenarios_count():
|
| 8 |
+
assert len(all_scenarios()) == 30
|
| 9 |
+
|
| 10 |
+
@pytest.mark.parametrize("task_id,expected_count", [
|
| 11 |
+
(TaskId.BUG_DETECTION, 10),
|
| 12 |
+
(TaskId.SECURITY_AUDIT, 10),
|
| 13 |
+
(TaskId.ARCHITECTURAL_REVIEW, 10),
|
| 14 |
+
])
|
| 15 |
+
def test_scenario_count_per_task(task_id, expected_count):
|
| 16 |
+
scenarios = [s for s in all_scenarios() if s.task_id == task_id]
|
| 17 |
+
assert len(scenarios) == expected_count
|
| 18 |
+
|
| 19 |
+
@pytest.mark.parametrize("task_id", list(TaskId))
|
| 20 |
+
@pytest.mark.parametrize("seed", range(10))
|
| 21 |
+
def test_scenario_loadable(task_id, seed):
|
| 22 |
+
"""Every seed for every task must return a valid scenario."""
|
| 23 |
+
s = get_scenario(task_id, seed)
|
| 24 |
+
assert s is not None
|
| 25 |
+
assert s.hash != ""
|
| 26 |
+
assert len(s.ground_truth_issues) >= 1
|
| 27 |
+
assert len(s.files_changed) >= 1
|
| 28 |
+
|
| 29 |
+
@pytest.mark.parametrize("task_id", list(TaskId))
|
| 30 |
+
@pytest.mark.parametrize("seed", range(10))
|
| 31 |
+
def test_scenario_has_valid_ground_truth(task_id, seed):
|
| 32 |
+
s = get_scenario(task_id, seed)
|
| 33 |
+
for issue in s.ground_truth_issues:
|
| 34 |
+
assert len(issue.keywords) >= 2, f"Issue {issue.id} needs >= 2 keywords"
|
| 35 |
+
assert issue.filename != ""
|
| 36 |
+
assert issue.line_number > 0
|
| 37 |
+
assert issue.severity in list(Severity)
|
| 38 |
+
|
| 39 |
+
@pytest.mark.parametrize("task_id", list(TaskId))
|
| 40 |
+
@pytest.mark.parametrize("seed", range(10))
|
| 41 |
+
def test_scenario_has_valid_diff(task_id, seed):
|
| 42 |
+
s = get_scenario(task_id, seed)
|
| 43 |
+
for f in s.files_changed:
|
| 44 |
+
assert "+++" in f.patch or "---" in f.patch or "@@ " in f.patch, \
|
| 45 |
+
f"Scenario {s.hash}: file {f.filename} patch is not a valid diff"
|
| 46 |
+
|
| 47 |
+
def test_scenario_hashes_unique():
|
| 48 |
+
hashes = [s.hash for s in all_scenarios()]
|
| 49 |
+
assert len(hashes) == len(set(hashes)), "Duplicate scenario hashes found"
|
| 50 |
+
|
| 51 |
+
def test_scenario_seed_determinism():
|
| 52 |
+
s1 = get_scenario(TaskId.BUG_DETECTION, 0)
|
| 53 |
+
s2 = get_scenario(TaskId.BUG_DETECTION, 0)
|
| 54 |
+
assert s1.hash == s2.hash, "Same seed must return same scenario"
|
| 55 |
+
|
| 56 |
+
def test_scenario_seed_wraps():
|
| 57 |
+
s_seed0 = get_scenario(TaskId.BUG_DETECTION, 0)
|
| 58 |
+
s_seed10 = get_scenario(TaskId.BUG_DETECTION, 10)
|
| 59 |
+
assert s_seed0.hash == s_seed10.hash, "Seed 10 should wrap to same as seed 0"
|