Files
nextcraft/apps/ai-service/tests/api/test_assessment.py
T
CIAgent dc00a4f8f5 chore(P05): type & dependency hardening — mypy + recharts 3 (REQ-7-005)
Wave 5-1 mypy adoption (D-058 relaxed-first): mypy 2.3.1, [tool.mypy]
check_untyped_defs + no_implicit_optional + warn_unused_ignores +
warn_redundant_casts + warn_unreachable, no overrides (every dep ships
py.typed), files = ai_service + scripts/sandbox-agent.py + tests (not
excluded). 215 errors → 0: ~140 fixed at source incl. genuine bugs found
by the gate (LLMProvider protocol async-generator shape, SessionStore
protocol sync/async mismatch, structured_reply genericity, get_args
migrations, sandbox-agent Optional guard honesty, proctor test arg-order,
dead ExecResult kwarg, 16 sqlmodel col() sites); 34 live suppressions
scoped-coded + reasoned. Original 27 type:ignore dispositioned: 12 dead
removed, 15 scoped (warn_unused_ignores self-eliminates the rest).
Wired: scripts/typecheck.sh + package.json + turbo task + root
ai:typecheck — pnpm typecheck now 5 tsc + mypy.

Wave 5-2 recharts 2.15.4 → 3.10.1 (D-049/D-059): one breaking class
(Tooltip formatter ValueType typing); 2 one-line inference fixes on the
2 chart files; no rollback needed (well inside the G-106 timebox).
Deprecated direct dep resolved; uuid@9 gone from the tree. Build 248/248
with chart SSR markers verified in exported employer + dashboard pages.

Wave 5-3 deprecated-transitive audit (G-109): 4 lockfile entries remain,
all dev-only, none actionable within NFR budget — glob@7.2.3 + inflight +
rimraf@3 via Storybook docgen chain (flat-cache@3); glob@10.5.0 via
c8→test-exclude (D-052-binding coverage tool — swapping it is out of
budget). Python: no EOL packages; zero-warning regime under
filterwarnings=error is the machine-checked proof.

Counts: 670 py / 44 web / 64 cli, zero warnings, G-104 allowlist still
exactly 2.

---ci---
phase: 5
milestone: v0.7
status: execute
---/ci---
2026-09-27 10:08:07 +00:00

91 lines
2.9 KiB
Python

"""Assessment API tests — stored-grade coaching contract (REQ-3-007)."""
from __future__ import annotations
import json
from collections.abc import Iterator
from datetime import UTC, datetime
import pytest
from fastapi.testclient import TestClient
from ai_service.config import Settings
from ai_service.grading.store import GradeRecord, SQLiteGradeStore
from ai_service.llm.mock import MockProvider
from ai_service.main import create_app
from ai_service.telemetry.ingest import TraceIntegrityMap
from ai_service.telemetry.store import SQLiteTraceStore
COACHING_JSON = json.dumps(
{
"summary": "Good iterative work.",
"strengths": ["Tests after changes."],
"gaps": ["Missing edge cases."],
"next_steps": ["Add an edge-case test."],
}
)
class CoachingMock(MockProvider):
def _reply_for(self, messages, response_format):
if response_format is not None and response_format.get("type") == "json_object":
return COACHING_JSON
return super()._reply_for(messages, response_format)
@pytest.fixture()
def client(tmp_path) -> Iterator[TestClient]:
app = create_app(Settings(provider="mock"))
app.state.provider = CoachingMock()
app.state.trace_store = SQLiteTraceStore(db_path=tmp_path / "t.db")
app.state.grade_store = SQLiteGradeStore(db_path=tmp_path / "g.db")
app.state.trace_integrity = TraceIntegrityMap()
with TestClient(app) as c:
yield c
def _seed_grade(client: TestClient) -> None:
# P5: TestClient.app is typed ASGIApp (Callable); at runtime it is the
# FastAPI instance — this state-injection seam is the suite's pattern.
app = client.app
store: SQLiteGradeStore = app.state.grade_store # type: ignore[attr-defined]
store.save(
GradeRecord(
learner_id="api-learner",
task_id="api-task",
variant_seed=None,
digest={"error_fix_cycles": 1},
scores={"criteria": {"process_quality": 3}, "verdict": "developing"},
verdict="GRADED",
model="gemma4:31b",
created_at=datetime.now(UTC),
)
)
def test_evaluate_renders_stored_grade_as_coaching(client) -> None:
_seed_grade(client)
resp = client.post(
"/v1/assessment/evaluate",
json={"learner_id": "api-learner", "task_id": "api-task"},
)
assert resp.status_code == 200, resp.text
body = resp.json()
assert body["grade_verdict"] == "GRADED"
assert body["coaching"]["summary"]
def test_evaluate_without_grade_404(client) -> None:
resp = client.post(
"/v1/assessment/evaluate",
json={"learner_id": "nobody", "task_id": "nothing"},
)
assert resp.status_code == 404
assert "grade first" in resp.json()["detail"]
def test_missing_fields_422(client) -> None:
resp = client.post("/v1/assessment/evaluate", json={"learner_id": "x"})
assert resp.status_code == 422