refactor: снимок занятия — model_dump сохраняемой части сессии вместо ручного перечисления полей
This commit is contained in:
parent
64313a62fb
commit
72c6a7cfa3
7 changed files with 546 additions and 471 deletions
268
backend/tests/test_checkpoint_model.py
Normal file
268
backend/tests/test_checkpoint_model.py
Normal file
|
|
@ -0,0 +1,268 @@
|
|||
"""Снимок занятия — это модель сохраняемой части сессии, а не список ключей.
|
||||
|
||||
Фикстура заполняет каждое поле значением не по умолчанию: новое поле, которое
|
||||
не пережило 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),
|
||||
})
|
||||
|
||||
|
||||
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="передано в другой регион",
|
||||
processed_station_commands=[str(uuid4())],
|
||||
text_revealed_facts={"f_address": "улица Ленина, 14"},
|
||||
)
|
||||
|
||||
|
||||
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
|
||||
|
|
@ -1,13 +1,13 @@
|
|||
"""Чекпойнт пульта ДДС: формат прежний, чтения ничего не меняют.
|
||||
"""Чекпойнт пульта ДДС: занятие продолжается с той же активной карточкой,
|
||||
чтения ничего не меняют.
|
||||
|
||||
`golden/checkpoint_before_desk.json` записан кодом до `DdsDesk`: занятие,
|
||||
поднятое из такого снимка, продолжается с той же активной карточкой.
|
||||
Состояния собраны в коде и проходят через снимок: пульт ДДС на три карточки,
|
||||
упражнение 112 с живым диспетчером и связка 112→ДДС.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
from pathlib import Path
|
||||
from uuid import UUID, uuid4
|
||||
from uuid import uuid4
|
||||
|
||||
import pytest
|
||||
from pydantic import TypeAdapter
|
||||
|
|
@ -18,19 +18,15 @@ from app.domain.kio import KIO
|
|||
from app.domain.statuses import ServiceStatus
|
||||
from app.domain.timers import TimerCode
|
||||
from app.scenarios.loader import load_file
|
||||
from app.session import checkpoint
|
||||
from app.session import finish as finish_module
|
||||
from app.session import timers as timers_module
|
||||
from app.session.checkpoint import dump_state, load_state
|
||||
from app.session.dds import prepare_handoff_queue
|
||||
from app.session.dds import prepare_handoff_queue, prepare_queue
|
||||
from app.session.hub import hub
|
||||
from app.session.state import SessionState
|
||||
from app.session.timers import now_utc
|
||||
|
||||
LIBRARY = Path(__file__).resolve().parents[2] / "scenarios"
|
||||
|
||||
BEFORE_DESK = json.loads(
|
||||
(Path(__file__).parent / "golden" / "checkpoint_before_desk.json").read_text()
|
||||
)
|
||||
_events = TypeAdapter(StationToServer)
|
||||
|
||||
|
||||
|
|
@ -47,9 +43,73 @@ def offline_finish(monkeypatch):
|
|||
monkeypatch.setattr(hub, "journal", None)
|
||||
|
||||
|
||||
def restored(name: str, **changes):
|
||||
payload = {**json.loads(json.dumps(BEFORE_DESK[name])), **changes}
|
||||
return load_state(payload, now_utc())
|
||||
def fire():
|
||||
return load_file(LIBRARY / "fire-apartment-l2.yaml", LIBRARY)
|
||||
|
||||
|
||||
def accept_with_crew(state: SessionState, card) -> None:
|
||||
service = state.card_services(card)[0]
|
||||
card.set_service_status(service, ServiceStatus.ACCEPTED, "Принято в работу")
|
||||
card.on_event("card.ack")
|
||||
card.crew_selected = state.crew_options(card)[0]
|
||||
card.crew_assignments[service] = card.crew_selected
|
||||
card.dds_log.append(("crew.select", now_utc(), card.crew_selected))
|
||||
|
||||
|
||||
def dds_state() -> SessionState:
|
||||
first = fire()
|
||||
scenarios = [first.model_copy(deep=True, update={"id": f"desk-{index}"})
|
||||
for index in range(3)]
|
||||
state = SessionState(
|
||||
session_id=uuid4(), scenario_id=first.id, scenario_title=first.title,
|
||||
level=first.level.value, mode=SessionMode.TRAINING, exercise=Exercise.DDS,
|
||||
)
|
||||
prepare_queue(state, scenarios)
|
||||
first_card, second = state.desk.ordered()[:2]
|
||||
accept_with_crew(state, first_card)
|
||||
state.desk.open(second.card_id)
|
||||
second.reply_text = "Ответ по второй карточке"
|
||||
return state
|
||||
|
||||
|
||||
def call_state() -> SessionState:
|
||||
scenario = fire()
|
||||
state = SessionState(
|
||||
session_id=uuid4(), scenario_id=scenario.id, scenario_title=scenario.title,
|
||||
level=scenario.level.value, mode=SessionMode.TRAINING, exercise=Exercise.CALL,
|
||||
scenario=scenario.model_copy(deep=True),
|
||||
required_fields=list(scenario.required_fields),
|
||||
)
|
||||
state.on_event("call.incoming")
|
||||
state.on_event("call.answer")
|
||||
state.kio = KIO(address="улица Ленина, 14", notify=["Служба 101"])
|
||||
state.dispatch()
|
||||
state.on_event("dds.dispatch")
|
||||
accept_with_crew(state, state.desk.active)
|
||||
return state
|
||||
|
||||
|
||||
def handoff_state() -> SessionState:
|
||||
first = fire()
|
||||
second = load_file(LIBRARY / "tickets" / "t01-1-fire-container.yaml", LIBRARY)
|
||||
state = SessionState(
|
||||
session_id=uuid4(), scenario_id=first.id, scenario_title=first.title,
|
||||
level=first.level.value, mode=SessionMode.TRAINING,
|
||||
exercise=Exercise.CARD, handoff_to_dds=True, scenario=first,
|
||||
)
|
||||
state.kio = KIO(address="улица Ленина, 14", description="горит балкон",
|
||||
notify=["Служба 101"])
|
||||
state.dispatch()
|
||||
prepare_handoff_queue(state, [second])
|
||||
accept_with_crew(state, state.desk.active)
|
||||
return state
|
||||
|
||||
|
||||
BUILDERS = {"dds": dds_state, "call": call_state, "handoff": handoff_state}
|
||||
|
||||
|
||||
def restored(name: str) -> SessionState:
|
||||
return load_state(dump_state(BUILDERS[name]()), now_utc())
|
||||
|
||||
|
||||
def station(state, **payload) -> None:
|
||||
|
|
@ -57,43 +117,32 @@ def station(state, **payload) -> None:
|
|||
asyncio.run(station_ws._handle(state.session_id, state, _events.validate_python(payload)))
|
||||
|
||||
|
||||
def test_dds_checkpoint_before_desk_resumes_same_active_card_and_work():
|
||||
state = restored("dds")
|
||||
saved = BEFORE_DESK["dds"]
|
||||
def test_dds_checkpoint_resumes_same_active_card_and_work():
|
||||
before = dds_state()
|
||||
state = load_state(dump_state(before), now_utc())
|
||||
|
||||
assert str(state.desk.active_id) == saved["dds_active_card_id"]
|
||||
assert state.desk.active_id == before.desk.active_id
|
||||
assert state.desk.active.original_index == 1
|
||||
assert state.desk.active.reply_text == "Ответ по второй карточке"
|
||||
first = state.desk.ordered()[0]
|
||||
assert first.status_log[-1].status is ServiceStatus.ACCEPTED
|
||||
assert first.crew_selected == saved["dds_live_cards"][0]["crew_selected"]
|
||||
assert first.crew_selected == before.desk.ordered()[0].crew_selected
|
||||
assert first.timers.measured_ms(TimerCode.DDS_ACK) is not None
|
||||
|
||||
station(state, type="card.next", card_id=saved["dds_active_card_id"])
|
||||
station(state, type="card.next", card_id=str(before.desk.active_id))
|
||||
|
||||
assert [record.card_id for record in state.desk.completed] == [
|
||||
UUID(saved["dds_active_card_id"])]
|
||||
assert [record.card_id for record in state.desk.completed] == [before.desk.active_id]
|
||||
assert state.desk.active.original_index == 2
|
||||
assert state.station_snapshot().card_index == 3
|
||||
|
||||
|
||||
def test_legacy_checkpoint_without_active_id_opens_first_card():
|
||||
payload = json.loads(json.dumps(BEFORE_DESK["dds"]))
|
||||
del payload["dds_active_card_id"]
|
||||
|
||||
state = load_state(payload, now_utc())
|
||||
|
||||
assert state.desk.active.original_index == 0
|
||||
assert state.station_snapshot().card_index == 1
|
||||
|
||||
|
||||
def test_call_checkpoint_before_desk_moves_dispatcher_work_to_desk_card():
|
||||
state = restored("call")
|
||||
saved = BEFORE_DESK["call"]
|
||||
def test_call_checkpoint_keeps_dispatcher_work_on_shared_timers():
|
||||
before = call_state()
|
||||
state = load_state(dump_state(before), now_utc())
|
||||
|
||||
card = state.desk.active
|
||||
assert card.card_id == state.dispatched_card.card_id
|
||||
assert card.crew_selected == saved["crew_selected"]
|
||||
assert card.crew_selected == before.desk.active.crew_selected
|
||||
assert [mark.status for mark in card.status_log] == [ServiceStatus.ACCEPTED]
|
||||
assert card.timers is state.timers
|
||||
snapshot = state.station_snapshot()
|
||||
|
|
@ -104,11 +153,11 @@ def test_call_checkpoint_before_desk_moves_dispatcher_work_to_desk_card():
|
|||
asyncio.run(finish_module.finish(state.session_id, state))
|
||||
|
||||
crew = next(item for item in state.score["metrics"] if item["key"] == "dds_crew")
|
||||
assert crew["passed"] and crew["fact"] == saved["crew_selected"]
|
||||
assert crew["passed"] and crew["fact"] == card.crew_selected
|
||||
assert any(item["key"] == "interview_time" for item in state.score["metrics"])
|
||||
|
||||
|
||||
def test_handoff_checkpoint_before_desk_keeps_operator_card_and_queue():
|
||||
def test_handoff_checkpoint_keeps_operator_card_and_queue():
|
||||
state = restored("handoff")
|
||||
|
||||
assert state.operator_kio.address == "улица Ленина, 14"
|
||||
|
|
@ -118,14 +167,9 @@ def test_handoff_checkpoint_before_desk_keeps_operator_card_and_queue():
|
|||
assert state.desk.active.status_log[-1].status is ServiceStatus.ACCEPTED
|
||||
|
||||
|
||||
@pytest.mark.parametrize("name", ["dds", "call", "handoff"])
|
||||
def test_new_checkpoint_keeps_json_keys_of_format_before_desk(name):
|
||||
assert set(dump_state(restored(name))) == set(BEFORE_DESK[name])
|
||||
|
||||
|
||||
@pytest.mark.parametrize("name", ["dds", "call", "handoff"])
|
||||
def test_reads_do_not_change_state(name, monkeypatch):
|
||||
monkeypatch.setattr(checkpoint.time, "monotonic", lambda: 1_000_000.0)
|
||||
monkeypatch.setattr(timers_module.time, "monotonic", lambda: 1_000_000.0)
|
||||
state = restored(name)
|
||||
active = state.desk.active
|
||||
|
||||
|
|
|
|||
|
|
@ -122,29 +122,6 @@ def test_text_revealed_facts_and_completed_cards_round_trip_whole():
|
|||
assert restored.desk.completed[0].received_at.tzinfo is not None
|
||||
|
||||
|
||||
def test_legacy_checkpoint_without_new_keys_gets_defaults():
|
||||
before = dds_state()
|
||||
before.text_revealed_facts = {"address": "улица Ленина, 14"}
|
||||
before.desk.completed = [completed_card()]
|
||||
payload = dump_state(before)
|
||||
del payload["text_revealed_facts"]
|
||||
legacy_keys = {"card_id", "scenario_id", "reply_text", "metrics",
|
||||
"findings", "actions", "duration_ms"}
|
||||
payload["dds_completed"] = [
|
||||
{key: value for key, value in item.items() if key in legacy_keys}
|
||||
for item in payload["dds_completed"]
|
||||
]
|
||||
|
||||
restored = load_state(payload, now_utc())
|
||||
|
||||
assert restored.text_revealed_facts == {}
|
||||
card = restored.desk.completed[0]
|
||||
assert card.card_id == before.desk.completed[0].card_id
|
||||
assert (card.title, card.address, card.description, card.incident_type,
|
||||
card.victims_count, card.received_at, card.managed_service) == (None,) * 7
|
||||
assert card.recipient_services == []
|
||||
|
||||
|
||||
def test_checkpoint_rejects_unknown_format_version():
|
||||
payload = dump_state(dds_state())
|
||||
payload["version"] = 999
|
||||
|
|
|
|||
Loading…
Reference in a new issue