Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ def update_progress(mission_id: str) -> None:


try:
import task as _task_mod
from task import time_tournament # type: ignore[import]
except ImportError as exc:
print(f"❌ Cannot import time_tournament from task.py: {exc}")
Expand All @@ -34,6 +35,46 @@ def update_progress(mission_id: str) -> None:
except Exception:
pass # any real error = student modified it, continue to actual checks

# ── behavioral spy: time_tournament must actually call _svc.simulate_tournament ──
#
# A fake that just does `return {"battles": n, "time_seconds": 0.0, ...}`
# without ever touching the service would still satisfy a shape-only check.
# Wrap the real simulate_tournament with a spy that records how it was
# called, but still delegates to the real implementation — so the timing
# and result stay genuine.
if not hasattr(_task_mod, "_svc"):
print("❌ task.py: expected module-level _svc (SimulationService) not found")
raise SystemExit(1)

_spy_calls: list[int] = []
_original_simulate = _task_mod._svc.simulate_tournament


def _spy_simulate(n: int):
_spy_calls.append(n)
return _original_simulate(n)


_task_mod._svc.simulate_tournament = _spy_simulate
try:
_spy_calls.clear()
SPY_N = 7
spy_result = time_tournament(SPY_N)
if not _spy_calls:
print(f"❌ time_tournament({SPY_N}) never called _svc.simulate_tournament()")
print(" You can't measure a blocking call you don't actually make.")
raise SystemExit(1)
if SPY_N not in _spy_calls:
print(f"❌ time_tournament({SPY_N}) called simulate_tournament with {_spy_calls}, "
f"expected it to pass battles={SPY_N} through")
raise SystemExit(1)
if spy_result.get("battles") != SPY_N:
print(f"❌ time_tournament({SPY_N}) returned battles={spy_result.get('battles')!r}, expected {SPY_N}")
raise SystemExit(1)
finally:
_task_mod._svc.simulate_tournament = _original_simulate
print(f"✓ time_tournament() actually calls _svc.simulate_tournament(battles) — not hardcoded")

# run the timing check
result = time_tournament(50)
for key in ("battles", "time_seconds", "battles_per_second"):
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -101,7 +101,8 @@ def update_progress(mission_id: str) -> None:
app.dependency_overrides[get_worker] = lambda: check_worker

client = TestClient(app, raise_server_exceptions=False)
r = client.post("/tournaments", json={"battles": 20})
BATTLES = 20
r = client.post("/tournaments", json={"battles": BATTLES})
if r.status_code != 202:
print(f"❌ POST /tournaments should return 202, got {r.status_code}")
app.dependency_overrides.clear()
Expand All @@ -114,7 +115,29 @@ def update_progress(mission_id: str) -> None:
print(f"❌ Job should be completed immediately with SyncWorker, got {r.json().get('status')}")
app.dependency_overrides.clear()
raise SystemExit(1)
print("✓ API: POST /tournaments → 202, job completes instantly (SyncWorker)")

# The result must reflect the actual tournament that ran — not just any dict
# that happens to make the status look "completed". total_battles is
# deterministic (it's the request's battles count); hero_wins/monster_wins
# are random per-battle, so only their sum (not exact values) is checked.
result = r.json().get("result")
if not isinstance(result, dict):
print(f"❌ Job result should be a dict, got {result!r}")
app.dependency_overrides.clear()
raise SystemExit(1)
if result.get("total_battles") != BATTLES:
print(f"❌ result['total_battles'] should be {BATTLES}, got {result.get('total_battles')!r}")
print(" The result must come from the real simulate_tournament() call, not a fabricated dict.")
app.dependency_overrides.clear()
raise SystemExit(1)
hero_wins = result.get("hero_wins")
monster_wins = result.get("monster_wins")
if not isinstance(hero_wins, int) or not isinstance(monster_wins, int) or hero_wins + monster_wins != BATTLES:
print(f"❌ result hero_wins ({hero_wins!r}) + monster_wins ({monster_wins!r}) should equal {BATTLES}")
app.dependency_overrides.clear()
raise SystemExit(1)
print(f"✓ API: POST /tournaments → 202, job completes instantly (SyncWorker), "
f"result matches the real simulation: {result}")

r = client.get("/jobs/nonexistent")
if r.status_code != 404:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@ with ThreadPoolExecutor(max_workers=workers) as pool:
## Key Insight

- **Threads help I/O-bound work**: while one thread waits for a file read or write, another thread can start its own I/O. This overlaps I/O operations.
- **Threads don't help CPU-bound work**: multiple threads on one CPU core don't run in parallel because the GIL (Global Interpreter Lock) prevents true parallelism. Use `ProcessPoolExecutor` for CPU-bound tasks (Mission 07).
- **Threads don't help CPU-bound work**: for CPU-bound pure-Python work, the GIL (Global Interpreter Lock) prevents multiple threads in the same process from running Python bytecode in parallel. Use `ProcessPoolExecutor` for CPU-bound tasks (Mission 07).

## How to Check

Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,17 @@
"""Check: Mission 08 — Testing Background Work."""
"""Check: Mission 08 — Testing Background Work.

Structural checks confirm SyncWorker is used, no sleep()/threading.Event, and
at least 6 test functions exist. The real proof of competence is behavioral:
the student's test suite must pass against the correct code, then FAIL
against each of six independent, single-behavior mutants — one per required
behavior from the README (new job pending, submit completes, exception
stores error, result stored, two jobs independent, unknown job returns
None). A suite of tests that make real calls but assert nothing meaningful
(`assert True`) kills none of these mutants.
"""
import json
import os
import shutil
import subprocess
import sys
from pathlib import Path
Expand All @@ -22,47 +33,196 @@ def update_progress(mission_id: str) -> None:
PROGRESS_FILE.write_text(json.dumps(progress, indent=2))


def _clear_pycache() -> None:
for d in mission.rglob("__pycache__"):
shutil.rmtree(d, ignore_errors=True)


def _run_pytest() -> subprocess.CompletedProcess:
env = os.environ.copy()
env["PYTHONPATH"] = str(mission)
env["PYTHONDONTWRITEBYTECODE"] = "1"
_clear_pycache()
try:
return subprocess.run(
[sys.executable, "-m", "pytest", "task/tests/test_background.py", "-q", "--tb=line"],
capture_output=True, text=True, cwd=str(mission), env=env,
)
finally:
_clear_pycache()


# ── test file exists ──────────────────────────────────────────────────────────

test_file = mission / "task" / "tests" / "test_background.py"
if not test_file.exists():
print("❌ task/tests/test_background.py not found")
raise SystemExit(1)

src = test_file.read_text()

# SyncWorker used
# ── SyncWorker used ───────────────────────────────────────────────────────────

if "SyncWorker" not in src:
print("❌ SyncWorker not imported/used in test_background.py")
raise SystemExit(1)
print("✓ SyncWorker used in tests")

# no time.sleep
# ── no time.sleep / threading.Event ──────────────────────────────────────────

code_lines = [ln for ln in src.splitlines() if not ln.lstrip().startswith("#")]
if "time.sleep" in "\n".join(code_lines):
code_src = "\n".join(code_lines)
if "time.sleep" in code_src:
print("❌ time.sleep() found — use SyncWorker for deterministic tests")
raise SystemExit(1)
print("✓ No time.sleep() — deterministic tests")

# at least 6 test functions
test_fn_count = sum(1 for ln in src.splitlines()
if ln.lstrip().startswith("def test_") and not ln.lstrip().startswith("#"))
# ── at least 6 test functions ─────────────────────────────────────────────────

test_fn_count = sum(
1 for ln in src.splitlines()
if ln.lstrip().startswith("def test_") and not ln.lstrip().startswith("#")
)
if test_fn_count < 6:
print(f"❌ Found {test_fn_count} test functions — write at least 6")
raise SystemExit(1)
print(f"✓ {test_fn_count} test functions found")

# pytest passes
env = os.environ.copy()
env["PYTHONPATH"] = str(mission)
result = subprocess.run(
[sys.executable, "-m", "pytest", "task/tests/test_background.py", "-q", "--tb=short"],
capture_output=True, text=True, cwd=str(mission), env=env,
)
# ── Gate 1: the suite must pass against the correct code ─────────────────────

result = _run_pytest()
if result.returncode != 0:
print("❌ pytest fails:")
print("❌ pytest fails against the correct code:")
print(result.stdout[-2000:])
print(result.stderr[-500:])
raise SystemExit(1)
print("✓ All background tests pass")
print("✓ All background tests pass against the correct code")

# ── Gate 2: the suite must FAIL against each single-behavior mutant ──────────
#
# One mutant per behavior the README's test table asks for. Each mutates
# task/jobs.py's InMemoryJobRepository or task/workers.py's SyncWorker — the
# two components the mission's own tests exercise directly (not through
# BackgroundWorker, which the mission explicitly says not to use in tests).

JOBS_FILE = mission / "task" / "jobs.py"
WORKERS_FILE = mission / "task" / "workers.py"

for path in (JOBS_FILE, WORKERS_FILE):
if not path.exists():
print(f"❌ {path.relative_to(mission)} not found")
raise SystemExit(1)

originals = {p: p.read_text() for p in (JOBS_FILE, WORKERS_FILE)}

_SYNC_WORKER_BLOCK = (
' """Runs jobs inline (no threading). Use in tests for determinism."""\n'
" def __init__(self, repo: JobRepository) -> None:\n"
" self._repo = repo\n"
"\n"
" def submit(self, job_id: str, fn: Callable[[], Any]) -> None:\n"
" self._repo.set_status(job_id, JobStatus.running)\n"
" try:\n"
" result = fn()\n"
" self._repo.set_result(job_id, result)\n"
" self._repo.set_status(job_id, JobStatus.completed)\n"
" except Exception as exc:\n"
" self._repo.set_error(job_id, str(exc))\n"
" self._repo.set_status(job_id, JobStatus.failed)"
)

MUTANTS: list[tuple[str, Path, str, str]] = [
(
"a new job starts as something other than pending",
JOBS_FILE,
" def create(self, job_id: str) -> Job:\n"
" job = Job(id=job_id, status=JobStatus.pending)\n"
" with self._lock:\n"
" self._jobs[job_id] = job",
" def create(self, job_id: str) -> Job:\n"
" job = Job(id=job_id, status=JobStatus.completed)\n"
" with self._lock:\n"
" self._jobs[job_id] = job",
),
(
"SyncWorker.submit() never marks the job completed (stuck on 'running')",
WORKERS_FILE,
_SYNC_WORKER_BLOCK,
_SYNC_WORKER_BLOCK.replace(
" self._repo.set_result(job_id, result)\n"
" self._repo.set_status(job_id, JobStatus.completed)\n",
" self._repo.set_result(job_id, result)\n",
),
),
(
"a raised exception is swallowed — job looks completed instead of failed",
WORKERS_FILE,
_SYNC_WORKER_BLOCK,
_SYNC_WORKER_BLOCK.replace(
" except Exception as exc:\n"
" self._repo.set_error(job_id, str(exc))\n"
" self._repo.set_status(job_id, JobStatus.failed)",
" except Exception:\n"
" self._repo.set_status(job_id, JobStatus.completed)",
),
),
(
"the job completes but its result is never stored",
WORKERS_FILE,
_SYNC_WORKER_BLOCK,
_SYNC_WORKER_BLOCK.replace(
" self._repo.set_result(job_id, result)\n",
"",
),
),
(
"two independent jobs end up sharing the same result",
JOBS_FILE,
" def set_result(self, job_id: str, result: dict) -> None:\n"
" with self._lock:\n"
" if job := self._jobs.get(job_id):\n"
" job.result = result",
" def set_result(self, job_id: str, result: dict) -> None:\n"
" with self._lock:\n"
" for job in self._jobs.values():\n"
" job.result = result",
),
(
"an unknown job_id returns a fabricated job instead of None",
JOBS_FILE,
" def get(self, job_id: str) -> Job | None:\n"
" with self._lock:\n"
" return self._jobs.get(job_id)",
" def get(self, job_id: str) -> Job | None:\n"
" with self._lock:\n"
" return self._jobs.get(job_id) or Job(id=job_id, status=JobStatus.completed, result={})",
),
]

try:
for label, path, old, new in MUTANTS:
original_src = originals[path]
if old not in original_src:
print(f"❌ internal check error: mutant anchor not found for: {label}")
raise SystemExit(1)
path.write_text(original_src.replace(old, new, 1))
result = _run_pytest()
path.write_text(original_src)
if result.returncode == 0:
print(f"❌ Your tests did not catch this bug: {label}.")
print(" A correct test suite for this mission must fail when this "
"behavior breaks — check the actual status/result/error, not just that calls don't crash.")
raise SystemExit(1)
print(f"✓ tests correctly fail when: {label}")
finally:
for path, original_src in originals.items():
path.write_text(original_src)
_clear_pycache()

for path, original_src in originals.items():
if path.read_text() != original_src:
print(f"❌ internal check error: {path.relative_to(mission)} was not restored correctly — please re-run")
raise SystemExit(1)

update_progress("08_testing_background_work")
print("\n✅ Mission 08 complete! Deterministic tests for background work.")
Original file line number Diff line number Diff line change
Expand Up @@ -36,4 +36,12 @@ Then write at least **6 tests** in `tests/test_api.py` covering:
uv run python check.py
```

All 9 gates must pass.
All gates must pass.

---

Quality habits still apply — run Ruff on the code you changed before considering the final project complete:

```bash
uv run python -m ruff check api/ tests/
```
Original file line number Diff line number Diff line change
Expand Up @@ -233,6 +233,19 @@ def update_progress(project_id: str) -> None:
raise SystemExit(1)
print("✓ ProcessPoolTournamentWorker contains no lambda — pickle-safe")

# ── Gate 12: ruff check on the code you wrote (Level 5/6 habits still apply) ──
repo_root = Path(__file__).parents[3]
_ruff_scope = [str(project / "api"), str(project / "tests")]
_ruff_result = subprocess.run(
[sys.executable, "-m", "ruff", "check", *_ruff_scope, "--config", str(repo_root / "pyproject.toml")],
capture_output=True, text=True,
)
if _ruff_result.returncode != 0:
print("❌ ruff check api/ tests/ fails — fix the lint errors above:")
print(_ruff_result.stdout)
raise SystemExit(1)
print("✓ ruff check api/ tests/ passes")

update_progress("03_concurrent_tournament_runner")
print()
print("✅ Boss fight complete! Concurrent Tournament Runner operational.")
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
*.json
!.gitkeep
Loading