Files
nextcraft/apps/ai-service/tests/telemetry/test_models.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

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)