lct-hack/backend/tests/test_dds_desk_checkpoint.py
gglamer 47cb85ee02 refactor: запись хода занятия — один commit SessionStore на операцию вместо журнала и checkpoint
Правка балла с пульта и из отчёта идёт одной доменной операцией: раньше WS-путь не обновлял full_report живой сессии.
2026-09-26 22:48:24 +00:00

223 lines
8.5 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.

"""Чекпойнт пульта ДДС: занятие продолжается с той же активной карточкой,
чтения ничего не меняют.
Состояния собраны в коде и проходят через снимок: пульт ДДС на три карточки,
упражнение 112 с живым диспетчером и связка 112→ДДС.
"""
import asyncio
from pathlib import Path
from uuid import uuid4
import pytest
from pydantic import TypeAdapter
from app.api.ws import station as station_ws
from app.domain.events import Exercise, SessionMode, StationToServer
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 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, prepare_queue
from app.session.hub import hub
from app.session.store import MemorySessionStore
from app.session.state import SessionState
from app.session.timers import now_utc
LIBRARY = Path(__file__).resolve().parents[2] / "scenarios"
_events = TypeAdapter(StationToServer)
async def finish_in_operation(state):
"""finish() пишет оценку в commit операции, завершившей занятие."""
async with hub.operation(state.session_id):
await finish_module.finish(state.session_id, state)
@pytest.fixture(autouse=True)
def offline_finish(monkeypatch):
class NoCoaching:
def model_dump(self, **_kwargs):
return {}
async def no_coach(_metrics):
return NoCoaching()
monkeypatch.setattr(finish_module, "coach", no_coach)
monkeypatch.setattr(hub, "store", MemorySessionStore())
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:
hub.register(state)
asyncio.run(station_ws._handle(state.session_id, state, _events.validate_python(payload)))
def test_dds_checkpoint_resumes_same_active_card_and_work():
before = dds_state()
state = load_state(dump_state(before), now_utc())
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 == before.desk.ordered()[0].crew_selected
assert first.timers.measured_ms(TimerCode.DDS_ACK) is not None
station(state, type="card.next", card_id=str(before.desk.active_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_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 == 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()
assert snapshot.statuses == {snapshot.managed_service: ServiceStatus.ACCEPTED}
assert snapshot.queue_cards == []
state.ended_at = now_utc()
asyncio.run(finish_in_operation(state))
crew = next(item for item in state.score["metrics"] if item["key"] == "dds_crew")
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_keeps_operator_card_and_queue():
state = restored("handoff")
assert state.operator_kio.address == "улица Ленина, 14"
assert [item.id for item in state.desk.scenarios] == [
"fire-apartment-l2", "t01-1-fire-container"]
assert state.desk.active.original_index == 0
assert state.desk.active.status_log[-1].status is ServiceStatus.ACCEPTED
@pytest.mark.parametrize("name", ["dds", "call", "handoff"])
def test_reads_do_not_change_state(name, monkeypatch):
monkeypatch.setattr(timers_module.time, "monotonic", lambda: 1_000_000.0)
state = restored(name)
active = state.desk.active
first = dump_state(state)
state.station_snapshot()
state.snapshot()
state.card_received_event()
assert dump_state(state) == first
assert state.desk.active is active
@pytest.mark.parametrize("name", ["dds", "handoff"])
def test_finish_scores_every_card_without_switching_active(name):
state = restored(name)
active = state.desk.active
unscored = {card.card_id for card in state.desk.cards.values()}
state.ended_at = now_utc()
asyncio.run(finish_in_operation(state))
assert state.desk.active is active
assert {record.card_id for record in state.desk.completed} == unscored
def test_handoff_keeps_operator_timers_for_card_fill_metric():
"""До пульта связка подменяла таймеры оператора таймерами карточки ДДС,
и время заполнения карточки 112 в оценку не попадало."""
first = load_file(LIBRARY / "fire-apartment-l2.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.on_event("card.start")
state.kio = KIO(address="улица Ленина, 14", notify=["Служба 101"])
state.on_event("card.submit")
state.dispatch()
prepare_handoff_queue(state, [])
assert state.timers.measured_ms(TimerCode.CARD_FILL) is not None
assert state.desk.active.timers is not state.timers
assert load_state(dump_state(state), now_utc()).timers.measured_ms(
TimerCode.CARD_FILL) is not None