refactor: снимок занятия — model_dump сохраняемой части сессии вместо ручного перечисления полей

This commit is contained in:
gglamer 2026-09-26 22:13:35 +00:00
commit 72c6a7cfa3
7 changed files with 546 additions and 471 deletions

View file

@ -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