lct-hack/backend/tests/test_checkpoint_model.py

276 lines
10 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""Снимок занятия — это модель сохраняемой части сессии, а не список ключей.
Фикстура заполняет каждое поле значением не по умолчанию: новое поле, которое
не пережило round-trip или осталось дефолтным в фикстуре, роняет тест.
"""
from datetime import UTC, datetime, timedelta
from pathlib import Path
from uuid import uuid4
import pytest
from pydantic import BaseModel
from app.domain.events import (
CallEndReason,
Exercise,
LessonCriteria,
Metric,
SessionMode,
Speaker,
TranscriptEntry,
)
from app.domain.kio import KIO
from app.domain.statuses import (
PhoneCallPending,
PhoneLineRecord,
PhoneReportRecord,
ServiceStatus,
StatusEntry,
)
from app.domain.taxonomy import ErrorCode, Finding, FindingSource
from app.domain.timers import TimerCode
from app.scenarios.loader import load_file
from app.session import timers as timers_module
from app.session.checkpoint import CHECKPOINT_VERSION, dump_state, load_state
from app.session.dds import DdsCardRecord, DdsDesk, DdsLiveCard
from app.session.state import PersistedSession, SessionState
from app.session.timers import SessionTimers, Timer
LIBRARY = Path(__file__).resolve().parents[2] / "scenarios"
MONO = 1_000_000.0
NOW = datetime(2026, 9, 26, 12, 0, tzinfo=UTC)
AT = NOW - timedelta(minutes=5)
@pytest.fixture(autouse=True)
def frozen_clock(monkeypatch):
"""Часы процесса и стены заморожены: round-trip таймера сравнивается точно."""
monkeypatch.setattr(timers_module.time, "monotonic", lambda: MONO)
monkeypatch.setattr(timers_module, "now_utc", lambda: NOW)
def timers() -> SessionTimers:
limits = {**SessionTimers().limits, TimerCode.DDS_ACK: 45_000}
return SessionTimers(limits=limits, timers={
TimerCode.DDS_ACK: Timer(code=TimerCode.DDS_ACK, started_at=MONO - 12.0, attempt=2),
TimerCode.ANSWER: Timer(code=TimerCode.ANSWER, started_at=MONO - 7.0,
elapsed_ms=7_000, attempt=3, stopped=True),
TimerCode.CARD_FILL: Timer(code=TimerCode.CARD_FILL, elapsed_ms=5_000, paused=True),
})
def completed_card() -> DdsCardRecord:
return DdsCardRecord(
card_id=uuid4(),
scenario_id="fire-apartment-l2",
reply_text="Бригада направлена.",
metrics=[Metric(key="dds_ack", title="Приём", fact="20 с", norm="30 с", passed=True)],
findings=[Finding(code=ErrorCode.D1, source=FindingSource.DISPATCHER,
summary="Поздний статус", fact="статус через 5 мин")],
actions=[{"action": "crew.select", "detail": "АЦ-1"}],
duration_ms=61_000,
title="Пожар в квартире",
address="улица Ленина, 14, кв. 5",
description="дым из окна",
incident_type="пожар",
victims_count=2,
received_at=AT,
managed_service="01",
recipient_services=["01", "03"],
)
def full_state() -> SessionState:
first = load_file(LIBRARY / "fire-apartment-l2.yaml", LIBRARY)
second = load_file(LIBRARY / "tickets" / "t01-1-fire-container.yaml", LIBRARY)
crew = "01 — бригада №1"
card = DdsLiveCard(
original_index=1,
scenario=second,
kio=KIO(address="улица Мира, 1", description="горит контейнер"),
dispatched_card=KIO(address="улица Мира, 1", description="горит"),
dispatched_at=AT,
timers=timers(),
bounced_fields=["address"],
dds_log=[("crew.select", AT, crew), ("crew.dispatched", AT, None)],
status_log=[StatusEntry(service="01", status=ServiceStatus.ACCEPTED, at=AT,
comment="Принято в работу", author="диспетчер")],
crew_selected=crew,
crew_assignments={"01": crew},
phone_reports=[PhoneReportRecord(service="01", crew=crew, phase="dispatched",
text="Выехали", at=AT)],
phone_lines=[PhoneLineRecord(service="01", crew=crew, speaker="crew",
text="Выехали", at=AT)],
phone_pending=PhoneCallPending(service="01", crew=crew, phase="arrived"),
reply_text="Бригада направлена",
reply_log=[(AT, "Бригада направлена")],
)
desk = DdsDesk(
scenarios=[first, second],
cards={card.card_id: card},
active_id=card.card_id,
card_index=1,
completed=[completed_card()],
limits={**SessionTimers().limits, TimerCode.DDS_ACK: 45_000},
arrival_interval_seconds=60,
max_waiting=2,
next_index=2,
next_arrival_at=NOW + timedelta(seconds=60),
)
return SessionState(
session_id=uuid4(),
scenario_id=first.id,
scenario_title=first.title,
level=first.level.value,
mode=SessionMode.EXAM,
owner_login="teacher",
backend_fencing_epoch=7,
exercise=Exercise.CARD,
handoff_to_dds=True,
required_fields=["address"],
trainee_name="Курсант",
trainee_id=uuid4(),
dds_service="01",
attempt=3,
criteria=LessonCriteria(decision_time_limit_seconds=45, allowed_errors=1),
kio=KIO(address="улица Ленина, 14"),
transcript=[TranscriptEntry(ref="u1", speaker=Speaker.CALLER, text="Алло", at=AT)],
timers=timers(),
hints_shown=["q_address"],
hints_log=[("q_address", AT)],
notes=[{"ref": "u1", "text": "уверенно"}],
directives=["panic_rises"],
scenario=first,
audio_frames=10,
bad_frames=1,
self_assessed=True,
self_assessment={"score": 80},
score={"score_auto": 75.0},
started_at=AT,
ended_at=NOW,
end_reason=CallEndReason.COMPLETE,
dispatched_card=KIO(address="улица Ленина, 14", description="горит балкон"),
dispatched_at=AT,
bounced_fields=["floor"],
pending_dds_scenarios=[second],
operator_kio=KIO(address="улица Ленина, 14"),
operator_scenario=first,
desk=desk,
resolved_outcome="справка",
resolve_comment="передано в другой регион",
paused=True,
paused_at=AT,
total_paused_ms=15_000,
pause_intervals=[(AT - timedelta(seconds=20), AT - timedelta(seconds=5))],
processed_station_commands=[str(uuid4())],
text_revealed_facts={"f_address": "улица Ленина, 14"},
consecutive_refusals=2,
socket_last_seen_at=AT,
socket_connected_at_checkpoint=True,
)
def test_persisted_part_round_trips_whole():
before = full_state()
restored = load_state(dump_state(before), NOW)
assert restored.persisted() == before.persisted()
def _session_models(value, found: dict[type, list]) -> None:
"""Все экземпляры моделей сессии в сохраняемой части, по классам."""
if isinstance(value, PersistedSession):
found.setdefault(PersistedSession, []).append(value)
for name in PersistedSession.model_fields:
_session_models(getattr(value, name), found)
elif isinstance(value, BaseModel):
if type(value).__module__.startswith("app.session"):
found.setdefault(type(value), []).append(value)
for name in type(value).model_fields:
_session_models(getattr(value, name), found)
elif isinstance(value, dict):
for item in value.values():
_session_models(item, found)
elif isinstance(value, list | tuple):
for item in value:
_session_models(item, found)
def test_fixture_sets_every_persisted_field_to_non_default():
found: dict[type, list] = {}
_session_models(full_state(), found)
assert {PersistedSession, DdsDesk, DdsLiveCard, DdsCardRecord,
SessionTimers, Timer} <= set(found)
for model, instances in found.items():
for name, info in model.model_fields.items():
if info.is_required():
continue
default = info.get_default(call_default_factory=True)
assert any(getattr(item, name) != default for item in instances), (
f"{model.__name__}.{name} в фикстуре по умолчанию — round-trip его не проверяет"
)
def test_running_timer_counts_downtime_stopped_keeps_saved_value():
payload = dump_state(full_state())
restored = load_state(payload, NOW - timedelta(seconds=5))
items = restored.timers.timers
assert items[TimerCode.DDS_ACK].current_ms(MONO) == 12_000 + 5_000
assert items[TimerCode.ANSWER].current_ms(MONO) == 7_000
card = restored.desk.active
assert card.timers.timers[TimerCode.DDS_ACK].current_ms(MONO) == 17_000
def test_snapshot_does_not_carry_process_clock():
timer = dump_state(full_state())["timers"]["timers"][TimerCode.DDS_ACK.value]
assert "started_at" not in timer
assert timer["elapsed_ms"] == 12_000
def test_checkpoint_version_1_is_rejected():
payload = dump_state(full_state())
assert payload["version"] == CHECKPOINT_VERSION == 2
payload["version"] = 1
with pytest.raises(ValueError, match="версия"):
load_state(payload, NOW)
def test_dump_state_does_not_change_state():
state = full_state()
active = state.desk.active
first = dump_state(state)
assert dump_state(state) == first
assert state.desk.active is active
def test_snapshot_keeps_only_persisted_keys():
payload = dump_state(full_state())
assert set(payload) == {"version", *PersistedSession.model_fields}
assert not {"slots", "persona", "caller", "voice", "recorder",
"recording_path", "lease_fenced"} & set(payload)
def test_processed_station_commands_are_trimmed_on_load():
state = full_state()
state.processed_station_commands = [str(index) for index in range(600)]
restored = load_state(dump_state(state), NOW)
assert restored.processed_station_commands == [str(index) for index in range(88, 600)]
def test_live_field_must_be_excluded_from_snapshot():
with pytest.raises(TypeError, match="сохраняемое"):
class Undecided(PersistedSession):
forgotten: int = 0