dc00a4f8f5
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---
129 lines
3.9 KiB
Python
129 lines
3.9 KiB
Python
"""TelemetryEvent model contract tests (REQ-3-003, D-027).
|
|
|
|
Pure validation tests — no store, no network, no fixtures beyond a
|
|
canonical kwargs builder.
|
|
"""
|
|
|
|
import json
|
|
from datetime import UTC, datetime
|
|
from typing import get_args
|
|
|
|
import pytest
|
|
from pydantic import ValidationError
|
|
|
|
from ai_service.telemetry import EventKind, TelemetryEvent, TraceSpan
|
|
|
|
|
|
def make_event_kwargs(**overrides) -> dict:
|
|
base = {
|
|
"learner_id": "learner-1",
|
|
"task_id": "task-1",
|
|
"seq": 0,
|
|
"kind": "command",
|
|
"payload": {"argv": ["pytest"], "cwd": "/workspace"},
|
|
"ts": datetime(2026, 9, 11, tzinfo=UTC),
|
|
"sandbox_id": "sbx-1",
|
|
}
|
|
base.update(overrides)
|
|
return base
|
|
|
|
|
|
def make_event(**overrides) -> TelemetryEvent:
|
|
return TelemetryEvent(**make_event_kwargs(**overrides))
|
|
|
|
|
|
def test_valid_event_constructs_with_all_kinds():
|
|
for kind in (
|
|
"command",
|
|
"file_diff",
|
|
"run_result",
|
|
"test_result",
|
|
"activity",
|
|
"stdin",
|
|
"stdout",
|
|
):
|
|
event = make_event(kind=kind)
|
|
assert event.kind == kind
|
|
|
|
|
|
def test_zero_seq_accepted():
|
|
assert make_event(seq=0).seq == 0
|
|
|
|
|
|
def test_negative_seq_rejected():
|
|
# @validates hooks raise ValueError (not pydantic ValidationError) at
|
|
# construction — same for kind and empty-id checks below.
|
|
with pytest.raises(ValueError, match="seq must be >= 0"):
|
|
make_event(seq=-1)
|
|
|
|
|
|
def test_bad_kind_rejected():
|
|
with pytest.raises(ValueError, match="unknown event kind"):
|
|
make_event(kind="keystroke") # not in the EventKind literal
|
|
|
|
|
|
def test_empty_learner_id_rejected():
|
|
with pytest.raises(ValueError, match="non-empty identifier"):
|
|
make_event(learner_id="")
|
|
|
|
|
|
def test_empty_task_id_rejected():
|
|
with pytest.raises(ValueError, match="non-empty identifier"):
|
|
make_event(task_id="")
|
|
|
|
|
|
def test_payload_json_roundtrip():
|
|
payload = {
|
|
"command": "pytest -q",
|
|
"exit_code": 1,
|
|
"durations": [0.12, 3.4],
|
|
"nested": {"passed": 7, "failed": 2},
|
|
"unicode": "héllo",
|
|
}
|
|
event = make_event(payload=payload)
|
|
assert event.payload == payload
|
|
# JSON-serializable payloads survive a full dumps/loads roundtrip.
|
|
assert json.loads(json.dumps(event.payload)) == payload
|
|
|
|
|
|
def test_event_json_roundtrip():
|
|
event = make_event(payload={"stdout": "ok", "n": 3})
|
|
restored = TelemetryEvent.model_validate_json(event.model_dump_json())
|
|
# pydantic leaves a JSON-str ts as str; compare field-by-field instead of
|
|
# dataclass equality, which distinguishes 'Z' string vs parsed datetime.
|
|
assert restored.learner_id == event.learner_id
|
|
assert restored.task_id == event.task_id
|
|
assert restored.seq == event.seq
|
|
assert restored.kind == event.kind
|
|
assert restored.payload == event.payload
|
|
assert restored.sandbox_id == event.sandbox_id
|
|
# SQLModel leaves a JSON-serialized ts as its str form; parse to compare.
|
|
restored_ts = datetime.fromisoformat(str(restored.ts).replace("Z", "+00:00"))
|
|
assert restored_ts == event.ts
|
|
|
|
|
|
def test_tracespan_holds_ordered_events():
|
|
events = tuple(make_event(seq=seq, kind="file_diff") for seq in range(3))
|
|
span = TraceSpan(learner_id="learner-1", task_id="task-1", events=events)
|
|
assert [e.seq for e in span.events] == [0, 1, 2]
|
|
assert span.latest_seq == 2
|
|
|
|
|
|
def test_tracespan_empty_has_no_latest_seq():
|
|
span = TraceSpan(learner_id="learner-1", task_id="task-1")
|
|
assert span.events == ()
|
|
assert span.latest_seq == -1
|
|
|
|
|
|
def test_tracespan_rejects_empty_ids():
|
|
with pytest.raises(ValidationError):
|
|
TraceSpan(learner_id="", task_id="task-1")
|
|
with pytest.raises(ValidationError):
|
|
TraceSpan(learner_id="learner-1", task_id="")
|
|
|
|
|
|
def test_event_kind_type_is_exported_literal():
|
|
# The kind discriminator is part of the public module surface.
|
|
# P5: get_args() is the typed accessor (bare __args__ trips mypy).
|
|
assert "command" in get_args(EventKind)
|