2026-09-24 01:10:49 +03:00
|
|
|
|
"""Промежуточное состояние занятия переживает смену backend-процесса."""
|
|
|
|
|
|
|
2026-09-26 18:12:27 +03:00
|
|
|
|
import asyncio
|
2026-09-24 01:10:49 +03:00
|
|
|
|
from datetime import UTC, datetime, timedelta
|
|
|
|
|
|
from pathlib import Path
|
|
|
|
|
|
from uuid import uuid4
|
|
|
|
|
|
|
|
|
|
|
|
import pytest
|
2026-09-26 19:36:51 +00:00
|
|
|
|
from app.domain.events import (
|
|
|
|
|
|
CallStarted,
|
|
|
|
|
|
CommandAck,
|
|
|
|
|
|
Exercise,
|
|
|
|
|
|
LessonCriteria,
|
|
|
|
|
|
Metric,
|
|
|
|
|
|
SessionMode,
|
|
|
|
|
|
)
|
2026-09-26 18:12:27 +03:00
|
|
|
|
from app.domain.kio import KIO
|
2026-09-24 01:10:49 +03:00
|
|
|
|
from app.domain.statuses import PhoneCallPending, ServiceStatus
|
2026-09-26 19:36:51 +00:00
|
|
|
|
from app.domain.taxonomy import ErrorCode, Finding, FindingSource
|
2026-09-24 01:10:49 +03:00
|
|
|
|
from app.domain.timers import TimerCode
|
|
|
|
|
|
from app.scenarios.loader import load_file
|
|
|
|
|
|
from app.session.checkpoint import dump_state, load_state
|
2026-09-26 21:38:09 +00:00
|
|
|
|
from app.session.dds import prepare_handoff_queue, prepare_queue
|
2026-09-26 18:12:27 +03:00
|
|
|
|
from app.session.hub import LEASE_FENCED_MESSAGE, SessionHub
|
2026-09-26 19:36:51 +00:00
|
|
|
|
from app.session.state import DdsCardRecord, SessionState, now_utc
|
2026-09-24 01:10:49 +03:00
|
|
|
|
|
|
|
|
|
|
LIBRARY = Path(__file__).resolve().parents[2] / "scenarios"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def dds_state() -> SessionState:
|
|
|
|
|
|
scenario = load_file(LIBRARY / "fire-apartment-l2.yaml", LIBRARY)
|
|
|
|
|
|
state = SessionState(
|
|
|
|
|
|
session_id=uuid4(),
|
|
|
|
|
|
scenario_id=scenario.id,
|
|
|
|
|
|
scenario_title=scenario.title,
|
|
|
|
|
|
level=scenario.level.value,
|
|
|
|
|
|
mode=SessionMode.TRAINING,
|
|
|
|
|
|
exercise=Exercise.DDS,
|
|
|
|
|
|
scenario=scenario.model_copy(deep=True),
|
|
|
|
|
|
required_fields=list(scenario.required_fields),
|
|
|
|
|
|
trainee_name="Курсант для восстановления",
|
|
|
|
|
|
attempt=2,
|
|
|
|
|
|
criteria=LessonCriteria(
|
|
|
|
|
|
decision_time_limit_seconds=45,
|
|
|
|
|
|
allowed_errors=1,
|
|
|
|
|
|
require_correct_grammar=True,
|
|
|
|
|
|
),
|
|
|
|
|
|
)
|
|
|
|
|
|
state.timers.limits[TimerCode.DDS_ACK] = 45_000
|
2026-09-26 21:38:09 +00:00
|
|
|
|
prepare_queue(state, [scenario.model_copy(deep=True)])
|
|
|
|
|
|
card = state.desk.active
|
|
|
|
|
|
service = state.card_services(card)[0]
|
|
|
|
|
|
card.set_service_status(service, ServiceStatus.ACCEPTED, "Принято в работу", author="диспетчер")
|
|
|
|
|
|
card.crew_selected = state.crew_options(card)[0]
|
|
|
|
|
|
card.crew_assignments[service] = card.crew_selected
|
|
|
|
|
|
card.phone_pending = PhoneCallPending(
|
|
|
|
|
|
service=service, crew=card.crew_selected, phase="dispatched"
|
2026-09-24 01:10:49 +03:00
|
|
|
|
)
|
2026-09-26 21:38:09 +00:00
|
|
|
|
card.reply_text = "Сообщение принято, бригада направлена."
|
|
|
|
|
|
card.reply_log.append((now_utc(), card.reply_text))
|
|
|
|
|
|
card.dds_log.append(("crew.select", now_utc(), card.crew_selected))
|
2026-09-24 01:10:49 +03:00
|
|
|
|
return state
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_active_dds_session_round_trips_without_losing_work():
|
|
|
|
|
|
before = dds_state()
|
2026-09-26 18:12:27 +03:00
|
|
|
|
before.processed_station_commands = ["2a831a63-dbb0-4d9f-af5b-21a617520001"]
|
2026-09-24 01:10:49 +03:00
|
|
|
|
payload = dump_state(before)
|
|
|
|
|
|
restored = load_state(
|
|
|
|
|
|
payload,
|
|
|
|
|
|
datetime.now(UTC) - timedelta(seconds=2),
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
assert restored.session_id == before.session_id
|
|
|
|
|
|
assert restored.exercise is Exercise.DDS
|
|
|
|
|
|
assert restored.criteria.decision_time_limit_seconds == 45
|
2026-09-26 21:38:09 +00:00
|
|
|
|
card, card_before = restored.desk.active, before.desk.active
|
|
|
|
|
|
assert card.dispatched_card == card_before.dispatched_card
|
|
|
|
|
|
assert card.status_log == card_before.status_log
|
|
|
|
|
|
assert card.crew_selected == card_before.crew_selected
|
|
|
|
|
|
assert card.crew_assignments == card_before.crew_assignments
|
|
|
|
|
|
assert card.phone_pending == card_before.phone_pending
|
|
|
|
|
|
assert card.reply_text == card_before.reply_text
|
2026-09-26 18:12:27 +03:00
|
|
|
|
assert restored.processed_station_commands == before.processed_station_commands
|
2026-09-26 21:38:09 +00:00
|
|
|
|
assert restored.desk.scenarios[0].id == before.scenario_id
|
2026-09-24 01:10:49 +03:00
|
|
|
|
# Время простоя backend входит в норматив, а не обнуляет таймер.
|
2026-09-26 21:38:09 +00:00
|
|
|
|
timer = next(item for item in card.timers.snapshot() if item.code is TimerCode.DDS_ACK)
|
2026-09-24 01:10:49 +03:00
|
|
|
|
assert timer.elapsed_ms >= 1_900
|
|
|
|
|
|
assert timer.limit_ms == 45_000
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-09-26 19:36:51 +00:00
|
|
|
|
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=datetime(2026, 9, 26, 10, 15, 30, 123000, tzinfo=UTC),
|
|
|
|
|
|
managed_service="01",
|
|
|
|
|
|
recipient_services=["01", "03"],
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_text_revealed_facts_and_completed_cards_round_trip_whole():
|
|
|
|
|
|
before = dds_state()
|
|
|
|
|
|
before.text_revealed_facts = {"address": "улица Ленина, 14", "floor": "5 этаж, уточнено"}
|
2026-09-26 21:38:09 +00:00
|
|
|
|
before.desk.completed = [completed_card()]
|
2026-09-26 19:36:51 +00:00
|
|
|
|
|
|
|
|
|
|
restored = load_state(dump_state(before), now_utc())
|
|
|
|
|
|
|
|
|
|
|
|
assert restored.text_revealed_facts == before.text_revealed_facts
|
2026-09-26 21:38:09 +00:00
|
|
|
|
assert restored.desk.completed == before.desk.completed
|
|
|
|
|
|
assert restored.desk.completed[0].received_at.tzinfo is not None
|
2026-09-26 19:36:51 +00:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_legacy_checkpoint_without_new_keys_gets_defaults():
|
|
|
|
|
|
before = dds_state()
|
|
|
|
|
|
before.text_revealed_facts = {"address": "улица Ленина, 14"}
|
2026-09-26 21:38:09 +00:00
|
|
|
|
before.desk.completed = [completed_card()]
|
2026-09-26 19:36:51 +00:00
|
|
|
|
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 == {}
|
2026-09-26 21:38:09 +00:00
|
|
|
|
card = restored.desk.completed[0]
|
|
|
|
|
|
assert card.card_id == before.desk.completed[0].card_id
|
2026-09-26 19:36:51 +00:00
|
|
|
|
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 == []
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-09-24 01:10:49 +03:00
|
|
|
|
def test_checkpoint_rejects_unknown_format_version():
|
|
|
|
|
|
payload = dump_state(dds_state())
|
|
|
|
|
|
payload["version"] = 999
|
|
|
|
|
|
with pytest.raises(ValueError, match="версия"):
|
|
|
|
|
|
load_state(payload, datetime.now(UTC))
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_concurrent_dds_queue_round_trips_with_each_timer_and_status():
|
|
|
|
|
|
first = load_file(LIBRARY / "fire-apartment-l2.yaml", LIBRARY)
|
|
|
|
|
|
second = first.model_copy(
|
|
|
|
|
|
deep=True,
|
|
|
|
|
|
update={"id": "checkpoint-second", "title": "Вторая карточка восстановления"},
|
|
|
|
|
|
)
|
|
|
|
|
|
state = SessionState(
|
|
|
|
|
|
session_id=uuid4(),
|
|
|
|
|
|
scenario_id=first.id,
|
|
|
|
|
|
scenario_title=first.title,
|
|
|
|
|
|
level=first.level.value,
|
|
|
|
|
|
mode=SessionMode.TRAINING,
|
|
|
|
|
|
exercise=Exercise.DDS,
|
|
|
|
|
|
)
|
2026-09-26 21:38:09 +00:00
|
|
|
|
prepare_queue(state, [first, second])
|
|
|
|
|
|
first_card = state.desk.active
|
|
|
|
|
|
first_id = first_card.card_id
|
|
|
|
|
|
first_service = state.card_services(first_card)[0]
|
|
|
|
|
|
first_card.set_service_status(first_service, ServiceStatus.ACCEPTED, "Принято в работу")
|
|
|
|
|
|
first_card.on_event("card.ack")
|
|
|
|
|
|
second_id = state.desk.ordered()[1].card_id
|
|
|
|
|
|
assert state.desk.open(second_id)
|
2026-09-24 01:10:49 +03:00
|
|
|
|
|
|
|
|
|
|
restored = load_state(
|
|
|
|
|
|
dump_state(state),
|
|
|
|
|
|
datetime.now(UTC) - timedelta(seconds=2),
|
|
|
|
|
|
)
|
|
|
|
|
|
|
2026-09-26 21:38:09 +00:00
|
|
|
|
assert restored.desk.active_id == second_id
|
|
|
|
|
|
assert len(restored.desk.cards) == 2
|
|
|
|
|
|
first_restored = restored.desk.cards[first_id]
|
|
|
|
|
|
second_restored = restored.desk.cards[second_id]
|
2026-09-24 01:10:49 +03:00
|
|
|
|
assert first_restored.status_log[-1].status is ServiceStatus.ACCEPTED
|
|
|
|
|
|
assert first_restored.timers.measured_ms(TimerCode.DDS_ACK) is not None
|
|
|
|
|
|
second_timer = next(
|
|
|
|
|
|
item for item in second_restored.timers.snapshot() if item.code is TimerCode.DDS_ACK
|
|
|
|
|
|
)
|
|
|
|
|
|
assert second_timer.elapsed_ms >= 1_900
|
|
|
|
|
|
assert second_timer.stopped is False
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_delivering_next_dds_card_does_not_clear_previous_card_state():
|
|
|
|
|
|
first = load_file(LIBRARY / "fire-apartment-l2.yaml", LIBRARY)
|
|
|
|
|
|
scenarios = [
|
|
|
|
|
|
first.model_copy(
|
|
|
|
|
|
deep=True,
|
|
|
|
|
|
update={"id": f"scheduled-checkpoint-{index}", "title": f"Карточка {index}"},
|
|
|
|
|
|
)
|
|
|
|
|
|
for index in range(3)
|
|
|
|
|
|
]
|
|
|
|
|
|
state = SessionState(
|
|
|
|
|
|
session_id=uuid4(),
|
|
|
|
|
|
scenario_id=scenarios[0].id,
|
|
|
|
|
|
scenario_title=scenarios[0].title,
|
|
|
|
|
|
level=scenarios[0].level.value,
|
|
|
|
|
|
mode=SessionMode.TRAINING,
|
|
|
|
|
|
exercise=Exercise.DDS,
|
|
|
|
|
|
)
|
|
|
|
|
|
prepare_queue(state, scenarios, arrival_interval_seconds=60, max_waiting=1)
|
2026-09-26 21:38:09 +00:00
|
|
|
|
first = state.desk.active
|
|
|
|
|
|
first_id = first.card_id
|
|
|
|
|
|
service = state.card_services(first)[0]
|
|
|
|
|
|
first.set_service_status(service, ServiceStatus.ACCEPTED, "Принято в работу")
|
2026-09-24 01:10:49 +03:00
|
|
|
|
|
2026-09-26 21:38:09 +00:00
|
|
|
|
assert state.desk.deliver_due(now_utc() + timedelta(seconds=61)) == 1
|
|
|
|
|
|
assert state.desk.cards[first_id].status_log[-1].status is ServiceStatus.ACCEPTED
|
|
|
|
|
|
second = next(card for card in state.desk.ordered() if card.original_index == 1)
|
|
|
|
|
|
assert state.desk.open(second.card_id)
|
2026-09-24 01:10:49 +03:00
|
|
|
|
|
|
|
|
|
|
restored = load_state(dump_state(state), now_utc())
|
2026-09-26 21:38:09 +00:00
|
|
|
|
first_restored = restored.desk.cards[first_id]
|
2026-09-24 01:10:49 +03:00
|
|
|
|
assert first_restored.status_log[-1].status is ServiceStatus.ACCEPTED
|
2026-09-26 21:38:09 +00:00
|
|
|
|
assert restored.desk.active_id == second.card_id
|
|
|
|
|
|
assert len(restored.desk.cards) == 2
|
|
|
|
|
|
assert restored.desk.next_index == 2
|
|
|
|
|
|
assert restored.desk.next_arrival_at is not None
|
2026-09-26 18:12:27 +03:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_mixed_handoff_checkpoint_preserves_operator_card_and_generated_queue():
|
|
|
|
|
|
first = load_file(LIBRARY / "fire-apartment-l2.yaml", LIBRARY)
|
|
|
|
|
|
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,
|
|
|
|
|
|
pending_dds_scenarios=[second],
|
|
|
|
|
|
)
|
|
|
|
|
|
state.kio = KIO(address="улица Ленина, 14", description="горит балкон")
|
|
|
|
|
|
state.dispatch()
|
|
|
|
|
|
prepare_handoff_queue(state, state.pending_dds_scenarios)
|
|
|
|
|
|
|
|
|
|
|
|
restored = load_state(dump_state(state), now_utc())
|
|
|
|
|
|
|
|
|
|
|
|
assert restored.operator_kio.address == "улица Ленина, 14"
|
|
|
|
|
|
assert restored.operator_scenario.id == first.id
|
2026-09-26 21:38:09 +00:00
|
|
|
|
assert restored.desk.scenarios[0].id == first.id
|
|
|
|
|
|
assert restored.desk.scenarios[1].id == second.id
|
|
|
|
|
|
assert len(restored.desk.cards) == 2
|
|
|
|
|
|
assert restored.desk.active_id == state.desk.active_id
|
2026-09-26 18:12:27 +03:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_checkpoint_storage_failure_fences_and_notifies_all_data_channels():
|
|
|
|
|
|
class BrokenJournal:
|
|
|
|
|
|
async def checkpoint(self, _state):
|
|
|
|
|
|
raise OSError("simulated database partition")
|
|
|
|
|
|
|
|
|
|
|
|
local_hub = SessionHub(journal=BrokenJournal())
|
|
|
|
|
|
state = dds_state()
|
|
|
|
|
|
local_hub.register(state)
|
|
|
|
|
|
|
|
|
|
|
|
with local_hub.observer(state.session_id) as observers, \
|
|
|
|
|
|
local_hub.trainee(state.session_id) as trainee, \
|
|
|
|
|
|
local_hub.station(state.session_id) as station:
|
|
|
|
|
|
async def failing_transition():
|
|
|
|
|
|
async with local_hub.durable_transition(state.session_id):
|
|
|
|
|
|
local_hub.to_trainee(
|
|
|
|
|
|
state.session_id, CallStarted(started_at=now_utc())
|
|
|
|
|
|
)
|
|
|
|
|
|
assert trainee.empty(), "success event escaped before durable checkpoint"
|
|
|
|
|
|
|
|
|
|
|
|
with pytest.raises(OSError, match="partition"):
|
|
|
|
|
|
asyncio.run(failing_transition())
|
|
|
|
|
|
|
|
|
|
|
|
assert state.lease_fenced
|
|
|
|
|
|
assert local_hub.get(state.session_id) is None
|
|
|
|
|
|
for queue in (observers, trainee, station):
|
|
|
|
|
|
event = queue.get_nowait()
|
|
|
|
|
|
assert event.message == LEASE_FENCED_MESSAGE
|
|
|
|
|
|
assert queue.empty(), "uncommitted success event leaked during fencing"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_durable_transition_publishes_event_only_after_checkpoint_commit():
|
|
|
|
|
|
class CommitJournal:
|
|
|
|
|
|
committed = False
|
|
|
|
|
|
|
|
|
|
|
|
async def checkpoint(self, _state):
|
|
|
|
|
|
await asyncio.sleep(0)
|
|
|
|
|
|
self.committed = True
|
|
|
|
|
|
|
|
|
|
|
|
journal = CommitJournal()
|
|
|
|
|
|
local_hub = SessionHub(journal=journal)
|
|
|
|
|
|
state = dds_state()
|
|
|
|
|
|
local_hub.register(state)
|
|
|
|
|
|
|
|
|
|
|
|
command_id = uuid4()
|
|
|
|
|
|
with local_hub.trainee(state.session_id) as trainee, \
|
|
|
|
|
|
local_hub.station(state.session_id) as station:
|
|
|
|
|
|
async def transition():
|
|
|
|
|
|
async with local_hub.durable_transition(state.session_id):
|
|
|
|
|
|
local_hub.to_trainee(
|
|
|
|
|
|
state.session_id, CallStarted(started_at=now_utc())
|
|
|
|
|
|
)
|
|
|
|
|
|
local_hub.to_station(
|
|
|
|
|
|
state.session_id, CommandAck(command_id=command_id)
|
|
|
|
|
|
)
|
|
|
|
|
|
assert trainee.empty()
|
|
|
|
|
|
assert station.empty()
|
|
|
|
|
|
assert journal.committed
|
|
|
|
|
|
|
|
|
|
|
|
asyncio.run(transition())
|
|
|
|
|
|
event = trainee.get_nowait()
|
|
|
|
|
|
assert isinstance(event, CallStarted)
|
|
|
|
|
|
ack = station.get_nowait()
|
|
|
|
|
|
assert isinstance(ack, CommandAck)
|
|
|
|
|
|
assert ack.command_id == command_id
|