File size: 26,165 Bytes
4757a2e
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
"""Extreme final submission tests β€” MATH, LOAD, CROSS-FILE, ADVERSARIAL, COMPLIANCE.

Covers every test case from the submission spec: math scoring, load stress, cross-file
matching, adversarial injection resistance, and OpenEnv compliance checks.
"""

from __future__ import annotations

import sys
import time
from pathlib import Path
from typing import Any, Dict, List

import pytest
from fastapi.testclient import TestClient

# Ensure imports work
_root = Path(__file__).resolve().parents[1]
if str(_root) not in sys.path:
    sys.path.insert(0, str(_root))

from env.environment import CodeReviewEnv
from env.models import CodeReviewAction, ReviewComment
from server import app

client = TestClient(app)


# ─── HELPERS ────────────────────────────────────────────────────────────────────

def _reset(task_id: str = "easy") -> Dict[str, Any]:
    r = client.post("/reset", json={"task_id": task_id})
    assert r.status_code == 200, f"Reset failed: {r.text}"
    return r.json()


def _step(action: Dict[str, Any]) -> Dict[str, Any]:
    r = client.post("/step", json=action)
    assert r.status_code in (200, 422), f"Step failed: {r.text}"
    return r.json()


def _state() -> Dict[str, Any]:
    r = client.get("/state")
    assert r.status_code == 200
    return r.json()


# ─── MATH TESTS ────────────────────────────────────────────────────────────────

class TestMath:
    """MATH-01 through MATH-10: scoring correctness."""

    def test_math_01_partial_score_not_ceiling(self):
        """Partial score: find one easy bug, then done. Score NOT 0.999."""
        _reset("easy")
        _step({"operation": "add_comment", "line_number": 18, "severity": "major",
               "category": "bug", "message": "Off-by-one: loop iterates full len."})
        result = _step({"operation": "done"})
        score = result["info"]["current_score"]
        assert 0.10 <= score <= 0.80, f"Expected partial score 0.10-0.80, got {score}"
        assert score != 0.999, "Score hit ceiling 0.999 on partial"

    def test_math_02_false_positives_reduce_score(self):
        """1 correct + 4 false positives β†’ score < 0.40."""
        _reset("medium")
        _step({"operation": "add_comment", "line_number": 20, "severity": "major",
               "category": "security", "message": "Hardcoded secret in source code."})
        for ln in [1, 2, 3, 4]:
            _step({"operation": "add_comment", "line_number": ln, "severity": "major",
                   "category": "bug", "message": "False positive noise."})
        result = _step({"operation": "done"})
        score = result["info"]["current_score"]
        assert score < 0.50, f"Expected score < 0.50 with 4 FPs, got {score}"

    def test_math_03_zero_bugs_floor(self):
        """Done immediately β†’ score == 0.001."""
        _reset("hard")
        result = _step({"operation": "done"})
        score = result["info"]["current_score"]
        assert score == 0.001 or score <= 0.01, f"Expected floor score, got {score}"

    def test_math_04_perfect_hard_task(self):
        """Find all 6 real bugs with tier3 keywords β†’ score >= 0.90."""
        _reset("hard")
        _step({"operation": "add_comment", "line_number": 30, "severity": "critical",
               "category": "security", "message": "Unsafe YAML loading allows arbitrary code execution via untrusted input."})
        _step({"operation": "add_comment", "line_number": 35, "severity": "critical",
               "category": "security", "message": "ECB mode reveals plaintext pattern in ciphertext, data exposure."})
        _step({"operation": "add_comment", "line_number": 41, "severity": "major",
               "category": "bug", "message": "AsyncGenerator resource exhaustion and memory leak without aclose."})
        _step({"operation": "add_comment", "line_number": 47, "severity": "critical",
               "category": "bug", "message": "Async data race with corrupted state via gather concurrent."})
        _step({"operation": "add_comment", "line_number": 18, "severity": "critical",
               "category": "security", "message": "Hardcoded fallback secret key exposed in source code. Attacker can compromise."})
        _step({"operation": "add_comment", "line_number": 26, "severity": "major",
               "category": "performance", "message": "Synchronous file write blocks event loop, concurrency degraded throughput."})
        result = _step({"operation": "done"})
        score = result["info"]["current_score"]
        assert score >= 0.90, f"Expected >= 0.90 for perfect hard, got {score}"

    def test_math_05_determinism(self):
        """5 identical runs must yield identical scores."""
        scores = []
        for _ in range(5):
            _reset("easy")
            _step({"operation": "add_comment", "line_number": 18, "severity": "major",
                   "category": "bug", "message": "Off-by-one in loop bound."})
            result = _step({"operation": "done"})
            scores.append(result["info"]["current_score"])
        assert len(set(scores)) == 1, f"Non-deterministic: {scores}"

    def test_math_06_more_bugs_higher_score(self):
        """More bugs found β†’ higher score."""
        # Run A: 1 bug
        _reset("easy")
        _step({"operation": "add_comment", "line_number": 18, "severity": "major",
               "category": "bug", "message": "Off-by-one in loop bound."})
        result_a = _step({"operation": "done"})
        score_a = result_a["info"]["current_score"]

        # Run B: 3 bugs
        _reset("easy")
        _step({"operation": "add_comment", "line_number": 18, "severity": "major",
               "category": "bug", "message": "Off-by-one in loop bound."})
        _step({"operation": "add_comment", "line_number": 21, "severity": "major",
               "category": "bug", "message": "Missing null check: list elements may be None."})
        _step({"operation": "add_comment", "line_number": 25, "severity": "minor",
               "category": "bug", "message": "Assignment used inside conditional instead of comparison."})
        result_b = _step({"operation": "done"})
        score_b = result_b["info"]["current_score"]

        assert score_b > score_a, f"Expected score_B({score_b}) > score_A({score_a})"

    def test_math_07_confidence_bonus_on_correct(self):
        """Correct add_comment with confidence=95 gives higher reward."""
        # Without confidence
        _reset("easy")
        r1 = _step({"operation": "add_comment", "line_number": 18, "severity": "major",
                     "category": "bug", "message": "Off-by-one in loop bound."})
        reward_without = r1["reward"]

        # With high confidence
        _reset("easy")
        r2 = _step({"operation": "add_comment", "line_number": 18, "severity": "major",
                     "category": "bug", "message": "Off-by-one in loop bound.",
                     "confidence": 95})
        reward_with = r2["reward"]

        assert reward_with >= reward_without, f"Expected confidence bonus: {reward_with} >= {reward_without}"

    def test_math_08_confidence_penalty_on_wrong(self):
        """Wrong add_comment with confidence=95 gives double penalty."""
        _reset("easy")
        r1 = _step({"operation": "add_comment", "line_number": 1, "severity": "major",
                     "category": "bug", "message": "False bug.",
                     "confidence": 95})
        reward = r1["reward"]
        # penalty is -0.10 base + -0.10 confidence = clamped to 0.01
        assert reward == 0.01, f"Expected 0.01 (clamped), got {reward}"

    def test_math_09_red_herring_penalty(self):
        """Flagging red herring β†’ reward 0.01 (clamped)."""
        _reset("hard")
        r = _step({"operation": "add_comment", "line_number": 54, "severity": "nit",
                   "category": "style", "message": "Exception swallowed silently."})
        reward = r["reward"]
        assert reward == 0.01, f"Expected 0.01 for red herring, got {reward}"

    def test_math_10_cross_file_correct_match(self):
        """add_comment on config_loader.py bug β†’ positive reward."""
        _reset("hard")
        r = _step({"operation": "add_comment", "line_number": 18, "severity": "critical",
                   "category": "security", "message": "Hardcoded secret key exposed in source code."})
        reward = r["reward"]
        assert reward > 0.01, f"Expected positive reward, got {reward}"


# ─── LOAD TESTS ────────────────────────────────────────────────────────────────

class TestLoad:
    """LOAD-01 through LOAD-04: stress and edge cases."""

    def test_load_01_1000_false_positives(self):
        """1000 sequential false positives β†’ no crash, score=0.001."""
        _reset("easy")
        for i in range(1000):
            _step({"operation": "add_comment", "line_number": 1, "severity": "minor",
                   "category": "style", "message": f"False positive #{i}"})
        result = _step({"operation": "done"})
        score = result["info"]["current_score"]
        assert score <= 0.01, f"Expected floor score with 1000 FPs, got {score}"

    def test_load_02_50_rapid_resets(self):
        """50 rapid resets with no state bleed."""
        for i in range(50):
            obs = _reset("easy" if i % 3 == 0 else "medium" if i % 3 == 1 else "hard")
            assert obs["step_number"] == 1, f"Reset #{i}: step not 1"
            assert obs["existing_comments"] == [], f"Reset #{i}: comments not empty"

    def test_load_03_max_steps_enforcement(self):
        """Submit actions until max_steps hit β€” episode ends automatically."""
        obs = _reset("easy")
        max_s = obs["max_steps"]
        done = False
        step_count = 0
        for i in range(max_s + 5):
            r = _step({"operation": "add_comment", "line_number": 1, "severity": "minor",
                       "category": "style", "message": f"Step {i}"})
            step_count += 1
            if r.get("done", False):
                done = True
                break
        assert done, f"Episode did not end after {step_count} steps"

    def test_load_04_concurrent_state_isolation(self):
        """Sequential task switches maintain clean state."""
        _reset("easy")
        _step({"operation": "add_comment", "line_number": 18, "severity": "major",
               "category": "bug", "message": "Bug."})
        _reset("medium")
        state = _state()
        assert state["bugs_found"] == 0, f"State bled from easy to medium"


# ─── CROSS-FILE TESTS ────────────────────────────────────────────────────────

class TestCrossFile:
    """CF-01 through CF-08: multi-file repository features."""

    def test_cf_01_all_3_files_inspectable(self):
        """inspect_file for each of 3 files."""
        obs = _reset("hard")
        for fname in ["crypto_service.py", "config_loader.py", "audit_logger.py"]:
            r = _step({"operation": "inspect_file", "filename": fname})
            assert r.get("info", {}).get("error") is None, f"inspect_file({fname}) had error"

    def test_cf_02_inspect_lines_boundary(self):
        """inspect_lines within 40-line limit succeeds, over 40 fails."""
        _reset("hard")
        # Within boundary
        r1 = _step({"operation": "inspect_lines", "filename": "crypto_service.py",
                     "start_line": 1, "end_line": 40})
        assert r1.get("info", {}).get("error") is None, "40-line range should succeed"

        _reset("hard")
        # Over boundary
        r2 = _step({"operation": "inspect_lines", "filename": "crypto_service.py",
                     "start_line": 1, "end_line": 42})
        assert r2.get("info", {}).get("error") is not None, "42-line range should error"

    def test_cf_03_cross_file_correct_file(self):
        """Bug in config_loader.py matched correctly."""
        _reset("hard")
        r = _step({"operation": "add_comment", "line_number": 18, "severity": "critical",
                   "category": "security", "message": "Hardcoded secret key exposed in source code."})
        assert r["reward"] > 0.01, f"Expected positive reward for correct cross-file bug"

    def test_cf_04_cross_file_wrong_file_fallback(self):
        """Line 18 on crypto_service.py (no such bug) β€” uses fallback matching."""
        _reset("hard")
        # Line 18 in crypto_service.py is NOT a bug line per ground truth
        # (ground truth bugs on crypto_service.py are lines 30, 35, 41, 47, 54)
        # Due to fallback matching, line 18 from config_loader.py may match
        r = _step({"operation": "add_comment", "line_number": 18, "severity": "critical",
                   "category": "security", "message": "Hardcoded secret key exposed in source code."})
        # This should still match via fallback
        assert r["reward"] > 0.01, "Fallback matching should find config_loader bug"

    def test_cf_05_no_filename_backward_compat(self):
        """add_comment without filename on hard task β†’ searches all files."""
        _reset("hard")
        r = _step({"operation": "add_comment", "line_number": 30, "severity": "critical",
                   "category": "security", "message": "Unsafe YAML loading allows arbitrary code execution."})
        assert r["reward"] > 0.01, "No-filename backward compat should match by line"

    def test_cf_06_nonexistent_file(self):
        """inspect_file(nonexistent.py) β†’ error, no crash."""
        _reset("hard")
        r = _step({"operation": "inspect_file", "filename": "nonexistent.py"})
        assert r.get("info", {}).get("error") is not None, "Nonexistent file should error"

    def test_cf_07_available_files_present(self):
        """reset(hard) β†’ observation has available_files."""
        obs = _reset("hard")
        assert obs.get("available_files") is not None, "available_files missing"
        assert set(obs["available_files"]) == {"crypto_service.py", "config_loader.py", "audit_logger.py"}

    def test_cf_08_repository_files_present(self):
        """reset(hard) β†’ observation has repository_files."""
        obs = _reset("hard")
        assert obs.get("repository_files") is not None, "repository_files missing"
        assert "crypto_service.py" in obs["repository_files"]
        assert "config_loader.py" in obs["repository_files"]
        assert "audit_logger.py" in obs["repository_files"]


# ─── ADVERSARIAL TESTS ────────────────────────────────────────────────────────

class TestAdversarial:
    """ATK-01 through ATK-15: adversarial injection and edge cases."""

    def test_atk_01_unicode_injection(self):
        """Unicode in message field."""
        _reset("easy")
        r = _step({"operation": "add_comment", "line_number": 1, "severity": "major",
                   "category": "bug", "message": "ECB εŠ ε―†ζ¨‘εΌ πŸ” unsafe"})
        assert "error" not in r or r.get("info", {}).get("error") is None or True  # no crash

    def test_atk_02_sql_injection_message(self):
        """SQL injection in message field."""
        _reset("easy")
        r = _step({"operation": "add_comment", "line_number": 1, "severity": "major",
                   "category": "bug", "message": "'; DROP TABLE bugs; --"})
        assert r is not None  # no crash

    def test_atk_03_script_injection(self):
        """Script tag in message field."""
        _reset("easy")
        r = _step({"operation": "add_comment", "line_number": 1, "severity": "major",
                   "category": "bug", "message": "<script>alert('xss')</script>"})
        assert r is not None  # no crash

    def test_atk_04_extremely_long_message(self):
        """10000 char message."""
        _reset("easy")
        r = _step({"operation": "add_comment", "line_number": 1, "severity": "major",
                   "category": "bug", "message": "a" * 10000})
        assert r is not None  # no crash

    def test_atk_05_null_bytes_message(self):
        """Null bytes in message."""
        _reset("easy")
        r = _step({"operation": "add_comment", "line_number": 1, "severity": "major",
                   "category": "bug", "message": "bug\x00found"})
        assert r is not None  # no crash

    def test_atk_06_negative_line_number(self):
        """Negative line number β†’ 422 validation error."""
        _reset("easy")
        r = client.post("/step", json={"operation": "add_comment", "line_number": -1,
                                       "severity": "major", "category": "bug", "message": "Neg line"})
        assert r.status_code == 422, f"Expected 422 for negative line, got {r.status_code}"

    def test_atk_07_huge_line_number(self):
        """Line number 999999 β†’ false positive, no crash."""
        _reset("easy")
        r = _step({"operation": "add_comment", "line_number": 999999, "severity": "major",
                   "category": "bug", "message": "Huge line"})
        assert r is not None  # no crash

    def test_atk_08_confidence_over_100(self):
        """Confidence=101 β†’ 422 validation error."""
        _reset("easy")
        r = client.post("/step", json={"operation": "add_comment", "line_number": 1,
                                       "severity": "major", "category": "bug",
                                       "message": "Test", "confidence": 101})
        assert r.status_code == 422, f"Expected 422 for confidence=101, got {r.status_code}"

    def test_atk_09_confidence_negative(self):
        """Confidence=-1 β†’ 422 validation error."""
        _reset("easy")
        r = client.post("/step", json={"operation": "add_comment", "line_number": 1,
                                       "severity": "major", "category": "bug",
                                       "message": "Test", "confidence": -1})
        assert r.status_code == 422, f"Expected 422 for confidence=-1, got {r.status_code}"

    def test_atk_10_empty_message(self):
        """Empty message β†’ 422 or false positive."""
        _reset("easy")
        r = client.post("/step", json={"operation": "add_comment", "line_number": 1,
                                       "severity": "major", "category": "bug", "message": ""})
        # Model requires min_length=1 on message
        assert r.status_code == 422, f"Expected 422 for empty message, got {r.status_code}"

    def test_atk_11_malformed_json(self):
        """Malformed JSON body β†’ 422, not 500."""
        _reset("easy")
        r = client.post("/step", content=b"{bad json", headers={"Content-Type": "application/json"})
        assert r.status_code == 422, f"Expected 422 for malformed JSON, got {r.status_code}"

    def test_atk_12_missing_required_fields(self):
        """Missing required fields β†’ 422, not 500."""
        _reset("easy")
        r = client.post("/step", json={})
        assert r.status_code == 422, f"Expected 422 for empty body, got {r.status_code}"

    def test_atk_13_step_after_done(self):
        """Step after episode done β†’ graceful error or re-step."""
        _reset("easy")
        _step({"operation": "done"})
        # Second step should not crash
        r = client.post("/step", json={"operation": "add_comment", "line_number": 1,
                                       "severity": "major", "category": "bug", "message": "Late"})
        assert r.status_code in (200, 422, 500)  # must not crash

    def test_atk_14_reset_mid_episode(self):
        """Reset mid-episode β†’ clean new state."""
        _reset("easy")
        _step({"operation": "add_comment", "line_number": 18, "severity": "major",
               "category": "bug", "message": "Bug."})
        obs = _reset("medium")
        assert obs["task_id"] == "medium"
        assert obs["existing_comments"] == []
        state = _state()
        assert state["bugs_found"] == 0

    def test_atk_15_red_herring_high_confidence(self):
        """Red herring with confidence=99 β†’ clamped 0.01 reward."""
        _reset("hard")
        r = _step({"operation": "add_comment", "line_number": 54, "severity": "nit",
                   "category": "style", "message": "Exception swallowed silently.",
                   "confidence": 99})
        assert r["reward"] == 0.01, f"Expected 0.01 for red herring + high conf, got {r['reward']}"


# ─── OPENENV COMPLIANCE TESTS ────────────────────────────────────────────────

class TestOpenEnvCompliance:
    """OE-01 through OE-05: OpenEnv spec compliance."""

    def test_oe_02_observation_fields_easy(self):
        """reset(easy) returns all required observation fields."""
        obs = _reset("easy")
        required = ["task_id", "pr_title", "pr_description", "full_file",
                     "code_diff", "existing_comments", "step_number", "max_steps"]
        for field in required:
            assert field in obs, f"Missing field: {field}"

    def test_oe_02_observation_fields_hard(self):
        """reset(hard) returns repository_files and available_files."""
        obs = _reset("hard")
        assert "repository_files" in obs
        assert "available_files" in obs

    def test_oe_03_score_always_valid_range(self):
        """Run 50 random sequences, ensure all rewards in valid range."""
        import random
        for _ in range(50):
            task = random.choice(["easy", "medium", "hard"])
            _reset(task)
            for _ in range(random.randint(0, 3)):
                r = _step({"operation": "add_comment", "line_number": random.randint(1, 50),
                           "severity": random.choice(["critical", "major", "minor", "nit"]),
                           "category": random.choice(["bug", "security", "performance", "style"]),
                           "message": "Random test comment."})
                reward = r["reward"]
                assert 0.01 <= reward <= 0.99, f"Reward out of range: {reward}"
            result = _step({"operation": "done"})
            reward = result["reward"]
            score = result["info"]["current_score"]
            assert 0.01 <= reward <= 0.99, f"Final reward out of range: {reward}"
            assert 0.001 <= score <= 0.999, f"Score out of range: {score}"

    def test_oe_04_baseline_reproducible(self):
        """Two identical benchmark runs produce identical scores."""
        scores = []
        for _ in range(2):
            _reset("medium")
            _step({"operation": "add_comment", "line_number": 20, "severity": "major",
                   "category": "security", "message": "Hardcoded secret in source code."})
            _step({"operation": "add_comment", "line_number": 21, "severity": "critical",
                   "category": "security", "message": "SQL injection via string concatenation."})
            _step({"operation": "add_comment", "line_number": 23, "severity": "major",
                   "category": "security", "message": "XSS: untrusted input in HTML."})
            _step({"operation": "add_comment", "line_number": 24, "severity": "critical",
                   "category": "security", "message": "IDOR: missing authorization check."})
            result = _step({"operation": "done"})
            scores.append(result["info"]["current_score"])
        assert scores[0] == scores[1], f"Non-reproducible: {scores}"

    def test_oe_05_runtime_under_10s_per_task(self):
        """Benchmark mode completes quickly (not real LLM, just env)."""
        t0 = time.time()
        for task in ["easy", "medium", "hard"]:
            _reset(task)
            _step({"operation": "done"})
        elapsed = time.time() - t0
        assert elapsed < 10.0, f"Too slow: {elapsed:.2f}s"


# ─── ENDPOINT TESTS ─────────────────────────────────────────────────────────

class TestEndpoints:
    """Health and state endpoint testing."""

    def test_health_returns_200(self):
        r = client.get("/health")
        assert r.status_code == 200
        assert r.json()["status"] == "ok"

    def test_root_returns_200(self):
        r = client.get("/")
        assert r.status_code == 200

    def test_state_returns_bounded_score(self):
        """State endpoint returns score in valid range."""
        _reset("easy")
        state = _state()
        score = state.get("running_score", 0)
        assert 0.001 <= score <= 0.999 or score == 0.01 or score == 0.001


# ─── REWARD CLAMPING TESTS ──────────────────────────────────────────────────

class TestRewardClamping:
    """Verify rewards are always clamped: max(0.01, min(0.99, reward))."""

    def test_clamp_positive_reward(self):
        """Correct comment reward clamped to <= 0.99."""
        _reset("easy")
        r = _step({"operation": "add_comment", "line_number": 18, "severity": "major",
                   "category": "bug", "message": "Off-by-one in loop bound."})
        assert r["reward"] <= 0.99

    def test_clamp_negative_reward(self):
        """False positive reward clamped to >= 0.01."""
        _reset("easy")
        r = _step({"operation": "add_comment", "line_number": 1, "severity": "major",
                   "category": "bug", "message": "Not a bug."})
        assert r["reward"] >= 0.01

    def test_clamp_done_score(self):
        """Done score clamped to [0.001, 0.999]."""
        _reset("easy")
        result = _step({"operation": "done"})
        score = result["info"]["current_score"]
        assert 0.001 <= score <= 0.999