refactor: пульт ДДС владеет живыми карточками, сессия не копирует их поля

Связка 112→ДДС больше не подменяет таймеры оператора таймерами первой
карточки ДДС, поэтому время заполнения карточки 112 снова попадает в оценку.
This commit is contained in:
gglamer 2026-09-26 21:38:09 +00:00
commit 5a3761a830
24 changed files with 2093 additions and 762 deletions

File diff suppressed because one or more lines are too long

View file

@ -0,0 +1,824 @@
{
"dds_several_cards": {
"score_auto": 53.3,
"card_status": "processed",
"metrics": [
[
"dds_primary",
"Карточка 1: Служба 101: решение по карточке",
"accepted",
true,
2.0,
null
],
[
"dds_ack",
"Карточка 1: Служба 101: первичная отметка за 30 с",
"0.0 с",
true,
1.0,
null
],
[
"dds_decision",
"Карточка 1: Служба 101: решение по эталону сценария",
"accepted",
true,
2.0,
null
],
[
"dds_crew",
"Карточка 1: Служба 101: назначение бригады",
"Служба 101 — бригада №1",
true,
2.0,
null
],
[
"dds_progress",
"Карточка 1: Служба 101: ведение хода реагирования",
"responding",
false,
2.0,
null
],
[
"dds_completion",
"Карточка 1: Служба 101: закрытие работ",
"не завершено",
false,
1.0,
null
],
[
"dds_reply",
"Карточка 1: Служба 101: основание статусов и сведения о ходе работ",
"Основание: доклад старшего.\nСведения: карточка принята, адресат — дежурный.; Бригада выехала, доклад дежурному",
false,
1.0,
null
],
[
"dds_work_time",
"Карточка 1: Отработка карточки ДДС",
"карточка не завершена; 0 с (на 180 с быстрее норматива)",
false,
1.5,
0.0
],
[
"dds_primary",
"Карточка 2: СМП: решение по карточке",
"accepted",
true,
2.0,
null
],
[
"dds_ack",
"Карточка 2: СМП: первичная отметка за 30 с",
"0.0 с",
true,
1.0,
null
],
[
"dds_decision",
"Карточка 2: СМП: решение по эталону сценария",
"accepted",
true,
2.0,
null
],
[
"dds_crew",
"Карточка 2: СМП: назначение бригады",
"бригада не выбрана",
false,
2.0,
null
],
[
"dds_progress",
"Карточка 2: СМП: ведение хода реагирования",
"статусов хода нет",
false,
2.0,
null
],
[
"dds_completion",
"Карточка 2: СМП: закрытие работ",
"не завершено",
false,
1.0,
null
],
[
"dds_reply",
"Карточка 2: СМП: основание статусов и сведения о ходе работ",
"Основание: доклад старшего.\nСведения: карточка принята, адресат — дежурный.",
true,
1.0,
null
],
[
"dds_work_time",
"Карточка 2: Отработка карточки ДДС",
"карточка не завершена; 0 с (на 180 с быстрее норматива)",
false,
1.5,
0.0
],
[
"dds_primary",
"Карточка 3: Служба 101: решение по карточке",
"accepted",
true,
2.0,
null
],
[
"dds_ack",
"Карточка 3: Служба 101: первичная отметка за 30 с",
"0.0 с",
true,
1.0,
null
],
[
"dds_decision",
"Карточка 3: Служба 101: решение по эталону сценария",
"accepted",
true,
2.0,
null
],
[
"dds_crew",
"Карточка 3: Служба 101: назначение бригады",
"Служба 101 — бригада №1",
true,
2.0,
null
],
[
"dds_progress",
"Карточка 3: Служба 101: ведение хода реагирования",
"responding",
false,
2.0,
null
],
[
"dds_completion",
"Карточка 3: Служба 101: закрытие работ",
"не завершено",
false,
1.0,
null
],
[
"dds_reply",
"Карточка 3: Служба 101: основание статусов и сведения о ходе работ",
"Основание: доклад старшего.\nСведения: карточка принята, адресат — дежурный.; Бригада выехала, доклад дежурному",
false,
1.0,
null
],
[
"dds_work_time",
"Карточка 3: Отработка карточки ДДС",
"карточка не завершена; 0 с (на 180 с быстрее норматива)",
false,
1.5,
0.0
]
],
"findings": [
[
"D5",
"Карточка 1: Служба 101: комментарии к статусам не разделяют основание и сведения",
"неполные комментарии: Начало реагирования"
],
[
"D6",
"Карточка 1: Служба 101: ход реагирования не доведён до конца",
"не отмечены: Прибытие, Проведение работ"
],
[
"E3",
"Карточка 1: ДДС: норматив времени отработки карточки не выполнен",
"карточка не завершена; 0 с (на 180 с быстрее норматива)"
],
[
"D2",
"Карточка 2: СМП: «Принята» без назначения бригады и хода реагирования",
"бригада не выбрана, после приёма статусов не было"
],
[
"D6",
"Карточка 2: СМП: ход реагирования не доведён до конца",
"не отмечены: Начало реагирования, Прибытие, Проведение работ"
],
[
"E3",
"Карточка 2: ДДС: норматив времени отработки карточки не выполнен",
"карточка не завершена; 0 с (на 180 с быстрее норматива)"
],
[
"D5",
"Карточка 3: Служба 101: комментарии к статусам не разделяют основание и сведения",
"неполные комментарии: Начало реагирования"
],
[
"D6",
"Карточка 3: Служба 101: ход реагирования не доведён до конца",
"не отмечены: Прибытие, Проведение работ"
],
[
"E3",
"Карточка 3: ДДС: норматив времени отработки карточки не выполнен",
"карточка не завершена; 0 с (на 180 с быстрее норматива)"
]
],
"cards": [
[
"fire-apartment-l2",
56.0,
"Принято, бригада направлена",
[
"card.ack",
"card.status",
"crew.select",
"card.status",
"zone.decision",
"card.reply"
]
],
[
"t20-2-stroke",
48.0,
"",
[
"card.ack",
"card.status"
]
],
[
"t01-1-fire-container",
56.0,
"",
[
"card.ack",
"card.status",
"crew.select",
"card.status",
"zone.decision"
]
]
]
},
"dds_early_finish": {
"score_auto": 35.3,
"card_status": "processed",
"metrics": [
[
"dds_primary",
"Карточка 1: СМП: решение по карточке",
"accepted",
true,
2.0,
null
],
[
"dds_ack",
"Карточка 1: СМП: первичная отметка за 30 с",
"0.0 с",
true,
1.0,
null
],
[
"dds_decision",
"Карточка 1: СМП: решение по эталону сценария",
"accepted",
true,
2.0,
null
],
[
"dds_crew",
"Карточка 1: СМП: назначение бригады",
"бригада не выбрана",
false,
2.0,
null
],
[
"dds_progress",
"Карточка 1: СМП: ведение хода реагирования",
"статусов хода нет",
false,
2.0,
null
],
[
"dds_completion",
"Карточка 1: СМП: закрытие работ",
"не завершено",
false,
1.0,
null
],
[
"dds_reply",
"Карточка 1: СМП: основание статусов и сведения о ходе работ",
"Основание: доклад старшего.\nСведения: карточка принята, адресат — дежурный.",
true,
1.0,
null
],
[
"dds_work_time",
"Карточка 1: Отработка карточки ДДС",
"карточка не завершена; 0 с (на 180 с быстрее норматива)",
false,
1.5,
0.0
],
[
"dds_primary",
"Карточка 2: Служба 101: решение по карточке",
"решение отсутствует",
false,
2.0,
null
],
[
"dds_ack",
"Карточка 2: Служба 101: первичная отметка за 30 с",
"не отмечено",
false,
1.0,
null
],
[
"dds_work_time",
"Карточка 2: Отработка карточки ДДС",
"карточка не завершена; время обработки не зафиксировано",
false,
1.5,
0.0
]
],
"findings": [
[
"D2",
"Карточка 1: СМП: «Принята» без назначения бригады и хода реагирования",
"бригада не выбрана, после приёма статусов не было"
],
[
"D6",
"Карточка 1: СМП: ход реагирования не доведён до конца",
"не отмечены: Начало реагирования, Прибытие, Проведение работ"
],
[
"E3",
"Карточка 1: ДДС: норматив времени отработки карточки не выполнен",
"карточка не завершена; 0 с (на 180 с быстрее норматива)"
],
[
"D1",
"Карточка 2: Служба 101: статус реагирования не проставлен",
"карточка осталась без отметки"
],
[
"E3",
"Карточка 2: ДДС: норматив времени отработки карточки не выполнен",
"карточка не завершена; время обработки не зафиксировано"
]
],
"cards": [
[
"t20-2-stroke",
48.0,
"",
[
"card.ack",
"card.status"
]
],
[
"fire-apartment-l2",
0.0,
"",
[]
]
]
},
"handoff_112_to_dds": {
"score_auto": 47.8,
"card_status": "processed",
"metrics": [
[
"incident_signs",
"Признаки происшествия",
"не определён",
false,
2.0,
null
],
[
"address",
"Адрес происшествия",
"улица Ленина, 14",
true,
2.0,
null
],
[
"victims_count",
"Число пострадавших",
"не указано",
false,
1.0,
null
],
[
"required_fields",
"Обязательные поля карточки",
"пусто: floor, incident_type, victims_count",
false,
2.0,
null
],
[
"card_fill_time",
"Время заполнения карточки",
"0 с (на 180 с быстрее норматива)",
true,
1.5,
1.0
],
[
"description_grammar",
"Грамматика описания происшествия",
"предложение начинается со строчной буквы; нет завершающего знака препинания",
false,
1.0,
null
],
[
"dds_primary",
"Карточка 1: Служба 101: решение по карточке",
"accepted",
true,
2.0,
null
],
[
"dds_ack",
"Карточка 1: Служба 101: первичная отметка за 30 с",
"0.0 с",
true,
1.0,
null
],
[
"dds_decision",
"Карточка 1: Служба 101: решение по эталону сценария",
"accepted",
true,
2.0,
null
],
[
"dds_crew",
"Карточка 1: Служба 101: назначение бригады",
"Служба 101 — бригада №1",
true,
2.0,
null
],
[
"dds_progress",
"Карточка 1: Служба 101: ведение хода реагирования",
"responding",
false,
2.0,
null
],
[
"dds_completion",
"Карточка 1: Служба 101: закрытие работ",
"не завершено",
false,
1.0,
null
],
[
"dds_reply",
"Карточка 1: Служба 101: основание статусов и сведения о ходе работ",
"Основание: доклад старшего.\nСведения: карточка принята, адресат — дежурный.; Бригада выехала, доклад дежурному",
false,
1.0,
null
],
[
"dds_work_time",
"Карточка 1: Отработка карточки ДДС",
"карточка не завершена; 0 с (на 180 с быстрее норматива)",
false,
1.5,
0.0
],
[
"dds_primary",
"Карточка 2: Служба 101: решение по карточке",
"accepted",
true,
2.0,
null
],
[
"dds_ack",
"Карточка 2: Служба 101: первичная отметка за 30 с",
"0.0 с",
true,
1.0,
null
],
[
"dds_decision",
"Карточка 2: Служба 101: решение по эталону сценария",
"accepted",
true,
2.0,
null
],
[
"dds_crew",
"Карточка 2: Служба 101: назначение бригады",
"бригада не выбрана",
false,
2.0,
null
],
[
"dds_progress",
"Карточка 2: Служба 101: ведение хода реагирования",
"статусов хода нет",
false,
2.0,
null
],
[
"dds_completion",
"Карточка 2: Служба 101: закрытие работ",
"не завершено",
false,
1.0,
null
],
[
"dds_reply",
"Карточка 2: Служба 101: основание статусов и сведения о ходе работ",
"Основание: доклад старшего.\nСведения: карточка принята, адресат — дежурный.",
true,
1.0,
null
],
[
"dds_work_time",
"Карточка 2: Отработка карточки ДДС",
"карточка не завершена; 0 с (на 180 с быстрее норматива)",
false,
1.5,
0.0
]
],
"findings": [
[
"E2",
"Признаки происшествия: не определён; требуется 1050201",
"не определён"
],
[
"E5",
"Число пострадавших: не указано; требуется 2",
"не указано"
],
[
"E5",
"Обязательные поля карточки: пусто: floor, incident_type, victims_count; требуется все обязательные поля заполнены",
"пусто: floor, incident_type, victims_count"
],
[
"E4",
"Грамматическая ошибка в описании происшествия",
"предложение начинается со строчной буквы; нет завершающего знака препинания"
],
[
"D5",
"Карточка 1: Служба 101: комментарии к статусам не разделяют основание и сведения",
"неполные комментарии: Начало реагирования"
],
[
"D6",
"Карточка 1: Служба 101: ход реагирования не доведён до конца",
"не отмечены: Прибытие, Проведение работ"
],
[
"E3",
"Карточка 1: ДДС: норматив времени отработки карточки не выполнен",
"карточка не завершена; 0 с (на 180 с быстрее норматива)"
],
[
"D2",
"Карточка 2: Служба 101: «Принята» без назначения бригады и хода реагирования",
"бригада не выбрана, после приёма статусов не было"
],
[
"D6",
"Карточка 2: Служба 101: ход реагирования не доведён до конца",
"не отмечены: Начало реагирования, Прибытие, Проведение работ"
],
[
"E3",
"Карточка 2: ДДС: норматив времени отработки карточки не выполнен",
"карточка не завершена; 0 с (на 180 с быстрее норматива)"
]
],
"cards": [
[
"fire-apartment-l2",
56.0,
"",
[
"card.ack",
"card.status",
"crew.select",
"card.status",
"zone.decision"
]
],
[
"t01-1-fire-container",
48.0,
"",
[
"card.ack",
"card.status"
]
]
]
},
"call_live_dispatcher": {
"score_auto": 62.8,
"card_status": "processed",
"metrics": [
[
"answer_time",
"Ответ на звонок",
"0 с",
true,
1.0,
null
],
[
"interview_time",
"Опрос / идентификация ситуации",
"0 с",
true,
1.5,
null
],
[
"incident_signs",
"Признаки происшествия",
"жилой дом · балкон · открытое пламя",
true,
2.0,
null
],
[
"address",
"Адрес",
"улица Ленина, 14",
true,
2.0,
null
],
[
"victims_count",
"Число пострадавших",
"не указано",
false,
1.0,
null
],
[
"required_fields",
"Обязательные поля КИО",
"пусто: floor, incident_type, dds, victims_count",
false,
2.0,
null
],
[
"description_grammar",
"Грамматика описания происшествия",
"предложение начинается со строчной буквы; нет завершающего знака препинания",
false,
1.0,
null
],
[
"dds_primary",
"Служба 101: решение по карточке",
"accepted",
true,
2.0,
null
],
[
"dds_ack",
"Служба 101: первичная отметка за 30 с",
"0.0 с",
true,
1.0,
null
],
[
"dds_decision",
"Служба 101: решение по эталону сценария",
"accepted",
true,
2.0,
null
],
[
"dds_crew",
"Служба 101: назначение бригады",
"Служба 101 — бригада №1",
true,
2.0,
null
],
[
"dds_progress",
"Служба 101: ведение хода реагирования",
"responding",
false,
2.0,
null
],
[
"dds_completion",
"Служба 101: закрытие работ",
"не завершено",
false,
1.0,
null
],
[
"dds_reply",
"Служба 101: основание статусов и сведения о ходе работ",
"Основание: доклад старшего.\nСведения: карточка принята, адресат — дежурный.; Бригада выехала, доклад дежурному",
false,
1.0,
null
]
],
"findings": [
[
"E5",
"Пострадавших в карточке не указано, верно — 2",
"не указано"
],
[
"E5",
"Не заполнены обязательные поля: floor, incident_type, dds, victims_count",
"пусто: floor, incident_type, dds, victims_count"
],
[
"E4",
"Грамматическая ошибка в описании происшествия",
"предложение начинается со строчной буквы; нет завершающего знака препинания"
],
[
"D5",
"Служба 101: комментарии к статусам не разделяют основание и сведения",
"неполные комментарии: Начало реагирования"
],
[
"D6",
"Служба 101: ход реагирования не доведён до конца",
"не отмечены: Прибытие, Проведение работ"
]
],
"cards": []
}
}

View file

@ -436,7 +436,7 @@ def test_handoff_report_and_checklist_follow_operator_scenario(client, monkeypat
wait_for(lambda: hub.get(session_id).score)
state = hub.get(session_id)
# Предусловие ошибки: к завершению активна не карточка курсанта.
assert state.scenario_id == "t01-1-fire-container"
assert state.desk.active.scenario.id == "t01-1-fire-container"
operator = store.get("fire-apartment-l2")
checklist = client.get(f"/api/sessions/{session_id}/checklist").json()

View file

@ -31,11 +31,12 @@ from app.domain.events import Exercise, SessionMode
from app.domain.roles import Role
from app.main import LIBRARY, app
from app.scenarios import store
from app.session.dds import prepare_card
from app.session.checkpoint import load_state
from app.session.dds import build_card
from app.session.hub import hub
from app.session.journal import DbJournal, SessionLeaseLost
from app.session.state import SessionState
from app.session.timers import SessionTimers
@pytest.fixture
@ -894,16 +895,10 @@ async def test_kio_submission_moderation_and_dds_card_persist_on_real_postgres(
scenario = store.get(scenario_id)
assert scenario is not None and scenario.student_card is not None
state = SessionState(
session_id=UUID("00000000-0000-4000-8000-000000000702"),
scenario_id=scenario.id, scenario_title=scenario.title,
level=scenario.level.value, mode=SessionMode.TRAINING, exercise=Exercise.DDS,
)
prepare_card(state, scenario)
assert state.dispatched_card is not None
assert state.dispatched_card.address == kio["address"]
assert state.dispatched_card.caller_name == kio["caller_name"]
assert state.dispatched_card.fire.object_kind == "мусорный контейнер"
card = build_card(scenario, 0, SessionTimers().limits)
assert card.dispatched_card.address == kio["address"]
assert card.dispatched_card.caller_name == kio["caller_name"]
assert card.dispatched_card.fire.object_kind == "мусорный контейнер"
# Simulate a peer backend with a cold process-local scenario cache:
# scenario.start must resolve the approved row from shared Postgres.
@ -950,9 +945,9 @@ async def test_kio_submission_moderation_and_dds_card_persist_on_real_postgres(
deadline = time.monotonic() + 3
while time.monotonic() < deadline:
state = hub.get(session_id)
if state and any(
if state and state.desk.active and any(
mark.service == service and mark.status.value == expected
for mark in state.status_log
for mark in state.desk.active.status_log
):
break
time.sleep(0.01)

View file

@ -0,0 +1,127 @@
"""Пульт ДДС без сокетов и хаба: очередь, переключение, завершение, темп."""
from datetime import timedelta
from pathlib import Path
from uuid import uuid4
from app.domain.statuses import ServiceStatus
from app.domain.timers import TimerCode
from app.scenarios.loader import load_file
from app.session.dds import DdsCardRecord, DdsDesk
from app.session.timers import SessionTimers, now_utc
LIBRARY = Path(__file__).resolve().parents[2] / "scenarios"
NAMES = ("fire-apartment-l2.yaml", "tickets/t20-2-stroke.yaml",
"tickets/t01-1-fire-container.yaml")
def scenarios(count: int = 3):
return [load_file(LIBRARY / name, LIBRARY) for name in NAMES[:count]]
def record(card) -> DdsCardRecord:
return DdsCardRecord(card_id=card.card_id, scenario_id=card.scenario.id, reply_text="",
metrics=[], findings=[], actions=[], duration_ms=0)
def started(interval: int = 0, max_waiting: int = 3, now=None) -> DdsDesk:
desk = DdsDesk()
desk.start(scenarios(), limits=SessionTimers().limits,
arrival_interval_seconds=interval, max_waiting=max_waiting, now=now or now_utc())
return desk
def test_first_card_becomes_active_and_rest_wait_when_no_pace():
desk = started()
assert [card.scenario.id for card in desk.ordered()] == [
"fire-apartment-l2", "t20-2-stroke", "t01-1-fire-container"]
assert desk.active is desk.ordered()[0]
assert desk.next_arrival_at is None
def test_paced_queue_delivers_next_card_only_after_interval():
start = now_utc()
desk = started(interval=60, now=start)
first = desk.active
assert desk.deliver_due(start + timedelta(seconds=59)) == 0
assert desk.deliver_due(start + timedelta(seconds=61)) == 1
assert len(desk.cards) == 2
assert desk.active is first
def test_waiting_cap_holds_back_delivery():
desk = started(max_waiting=1)
assert len(desk.cards) == 2
assert desk.next_index == 2
def test_open_switches_by_reference_without_copying_fields():
desk = started()
first, second = desk.ordered()[:2]
first.status_log.append(object())
assert desk.open(second.card_id)
second.reply_text = "ответ по второй"
assert desk.open(first.card_id)
assert desk.active is first
assert len(first.status_log) == 1 and second.status_log == []
assert second.reply_text == "ответ по второй" and first.reply_text == ""
assert desk.card_index == 0
def test_open_unknown_card_keeps_active():
desk = started()
active = desk.active
assert not desk.open(uuid4())
assert desk.active is active
def test_complete_moves_to_following_card_and_keeps_record():
desk = started()
first, second, _third = desk.ordered()
following = desk.complete(first.card_id, record(first))
assert following is second and desk.active is second
assert first.card_id not in desk.cards
assert desk.is_completed(first.card_id)
def test_completing_last_card_leaves_desk_between_cards_with_last_index():
desk = started(interval=60, max_waiting=1)
only = desk.active
assert desk.complete(only.card_id, record(only)) is None
assert desk.active is None and desk.active_id is None
assert desk.card_index == 0
assert desk.deliver_due(now_utc() + timedelta(seconds=61)) == 1
assert desk.active.scenario.id == "t20-2-stroke"
def test_each_card_runs_own_decision_timer_with_configured_limit():
desk = DdsDesk()
limits = {**SessionTimers().limits, TimerCode.DDS_ACK: 45_000}
desk.start(scenarios(2), limits=limits, now=now_utc())
first, second = desk.ordered()
assert first.timers is not second.timers
assert first.timers.limits[TimerCode.DDS_ACK] == 45_000
assert TimerCode.DDS_ACK in second.timers.timers
def test_card_status_mark_lands_in_that_card_only():
desk = started()
first, second = desk.ordered()[:2]
service = first.dispatched_card.notify[0]
first.set_service_status(service, ServiceStatus.ACCEPTED, "Основание: доклад")
assert [mark.status for mark in first.status_log] == [ServiceStatus.ACCEPTED]
assert second.status_log == []

View file

@ -0,0 +1,172 @@
"""Чекпойнт пульта ДДС: формат прежний, чтения ничего не меняют.
`golden/checkpoint_before_desk.json` записан кодом до `DdsDesk`: занятие,
поднятое из такого снимка, продолжается с той же активной карточкой.
"""
import asyncio
import json
from pathlib import Path
from uuid import UUID, 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 checkpoint
from app.session import finish as finish_module
from app.session.checkpoint import dump_state, load_state
from app.session.dds import prepare_handoff_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)
@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, "journal", None)
def restored(name: str, **changes):
payload = {**json.loads(json.dumps(BEFORE_DESK[name])), **changes}
return load_state(payload, 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_before_desk_resumes_same_active_card_and_work():
state = restored("dds")
saved = BEFORE_DESK["dds"]
assert str(state.desk.active_id) == saved["dds_active_card_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.timers.measured_ms(TimerCode.DDS_ACK) is not None
station(state, type="card.next", card_id=saved["dds_active_card_id"])
assert [record.card_id for record in state.desk.completed] == [
UUID(saved["dds_active_card_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"]
card = state.desk.active
assert card.card_id == state.dispatched_card.card_id
assert card.crew_selected == saved["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_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 any(item["key"] == "interview_time" for item in state.score["metrics"])
def test_handoff_checkpoint_before_desk_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_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)
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_module.finish(state.session_id, 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

View file

@ -0,0 +1,194 @@
"""Оценки finish() по пульту ДДС закреплены эталоном (lct-dds-desk).
Эталон `golden/dds_desk_scores.json` снят с кода до переноса живых карточек
в `DdsDesk`: перенос меняет владельца состояния, а не правила оценки.
Одно отличие намеренное: в связке 112→ДДС `card_fill_time` теперь считается
по таймерам оператора — до пульта их подменяли таймеры карточки ДДС.
Пересобрать эталон — `UPDATE_GOLDEN=1 pytest tests/test_dds_desk_scores.py`,
только если правила оценки меняются намеренно.
"""
import asyncio
import json
import os
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.scenarios.loader import load_file
from app.session import finish as finish_module
from app.session.dds import prepare_handoff_queue, prepare_queue
from app.session.hub import hub
from app.session.state import SessionState
LIBRARY = Path(__file__).resolve().parents[2] / "scenarios"
GOLDEN = Path(__file__).parent / "golden" / "dds_desk_scores.json"
COMMENT = "Основание: доклад старшего.\nСведения: карточка принята, адресат — дежурный."
_events = TypeAdapter(StationToServer)
@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, "journal", None)
def scenario(name: str):
return load_file(LIBRARY / name, LIBRARY)
def station(state: SessionState, **payload) -> None:
hub.register(state)
asyncio.run(station_ws._handle(state.session_id, state, _events.validate_python(payload)))
def active_id(state: SessionState):
return state.station_snapshot().card_id
def service_of(state: SessionState) -> str:
return state.station_snapshot().managed_service
def work_card(state: SessionState) -> None:
"""Принять, назначить бригаду и довести до выезда — типичный путь курсанта."""
station(state, type="card.ack", comment=COMMENT)
snapshot = state.station_snapshot()
station(state, type="crew.select", crew=snapshot.crew_options[0])
station(state, type="card.status", service=service_of(state),
status="responding", comment="Бригада выехала, доклад дежурному")
station(state, type="zone.decision", in_zone=True)
def summarize(state: SessionState) -> dict:
score = state.score
return {
"score_auto": score["score_auto"],
"card_status": score["card_status"],
"metrics": [
[m["key"], m["title"], m["fact"], m["passed"], m["weight"], m.get("credit")]
for m in score["metrics"]
],
"findings": [[f["code"], f["summary"], f["fact"]] for f in score["findings"]],
"cards": [[card["scenario_id"], card["score_auto"], card["reply_text"],
[action["type"] for action in card["actions"]]]
for card in score["card_results"]],
}
def dds_exercise_several_cards() -> SessionState:
cards = [scenario("fire-apartment-l2.yaml"),
scenario("tickets/t20-2-stroke.yaml"),
scenario("tickets/t01-1-fire-container.yaml")]
state = SessionState(
session_id=uuid4(), scenario_id=cards[0].id, scenario_title=cards[0].title,
level=cards[0].level.value, mode=SessionMode.TRAINING, exercise=Exercise.DDS,
)
prepare_queue(state, cards)
first = active_id(state)
work_card(state)
station(state, type="card.reply", card_id=str(first), text="Принято, бригада направлена")
station(state, type="card.next", card_id=str(first))
second = active_id(state)
station(state, type="card.ack", comment=COMMENT)
third = next(card.card_id for card in state.station_snapshot().queue_cards
if card.card_id not in {first, second})
station(state, type="card.open", card_id=str(third))
work_card(state)
station(state, type="card.open", card_id=str(second))
station(state, type="station.finish")
return state
def dds_exercise_early_finish() -> SessionState:
cards = [scenario("tickets/t20-2-stroke.yaml"),
scenario("fire-apartment-l2.yaml"),
scenario("tickets/t01-1-fire-container.yaml")]
state = SessionState(
session_id=uuid4(), scenario_id=cards[0].id, scenario_title=cards[0].title,
level=cards[0].level.value, mode=SessionMode.TRAINING, exercise=Exercise.DDS,
)
prepare_queue(state, cards, max_waiting=1)
station(state, type="card.ack", comment=COMMENT)
station(state, type="station.finish")
return state
def handoff_112_to_dds() -> SessionState:
first = scenario("fire-apartment-l2.yaml")
second = scenario("tickets/t01-1-fire-container.yaml")
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,
required_fields=list(first.required_fields),
pending_dds_scenarios=[second],
)
state.on_event("card.start")
state.kio = KIO(
address="улица Ленина, 14", description="горит балкон на третьем этаже",
notify=["Служба 101"],
)
state.on_event("card.submit")
state.dispatch()
prepare_handoff_queue(state, state.pending_dds_scenarios)
first_card = active_id(state)
work_card(state)
station(state, type="card.next", card_id=str(first_card))
station(state, type="card.ack", comment=COMMENT)
station(state, type="station.finish")
return state
def call_with_live_dispatcher() -> SessionState:
first = scenario("fire-apartment-l2.yaml")
state = SessionState(
session_id=uuid4(), scenario_id=first.id, scenario_title=first.title,
level=first.level.value, mode=SessionMode.TRAINING, exercise=Exercise.CALL,
scenario=first, required_fields=list(first.required_fields),
)
state.on_event("call.incoming")
state.on_event("call.answer")
state.patch_kio({"address": "улица Ленина, 14", "description": "горит квартира",
"signs": list(first.signs)})
state.on_event("dds.dispatch")
state.dispatch()
work_card(state)
state.ended_at = state.dispatched_at
asyncio.run(finish_module.finish(state.session_id, state))
return state
CASES = {
"dds_several_cards": dds_exercise_several_cards,
"dds_early_finish": dds_exercise_early_finish,
"handoff_112_to_dds": handoff_112_to_dds,
"call_live_dispatcher": call_with_live_dispatcher,
}
def test_update_golden_when_requested():
if not os.environ.get("UPDATE_GOLDEN"):
pytest.skip("эталон пересобирается только явно")
GOLDEN.parent.mkdir(exist_ok=True)
GOLDEN.write_text(json.dumps({name: summarize(build()) for name, build in CASES.items()},
ensure_ascii=False, indent=1) + "\n")
@pytest.mark.parametrize("name", list(CASES))
def test_finish_scores_match_golden(name):
state = CASES[name]()
assert summarize(state) == json.loads(GOLDEN.read_text())[name]

View file

@ -110,8 +110,8 @@ def complete_phone_call(station, state, expected_phase):
station.send_json(
{
"type": "phone.brief",
"address": state.dispatched_card.address,
"incident": state.scenario_title,
"address": state.desk.active.dispatched_card.address,
"incident": state.desk.active.scenario.title,
"request": "Прошу подтвердить выезд и сообщить о прибытии",
}
)
@ -131,9 +131,9 @@ def test_dds_starts_with_prepared_card_without_call(client):
try:
state = hub.get(session_id)
assert state.exercise.value == "dds"
assert state.dispatched_card is not None
assert state.kio.address == "улица Ленина, 14"
assert state.kio.notify
assert state.desk.active.dispatched_card is not None
assert state.desk.active.kio.address == "улица Ленина, 14"
assert state.desk.active.kio.notify
assert state.timers.measured_ms(TimerCode.ANSWER) is None
with client.websocket_connect(f"/ws/station/{session_id}") as station:
card = read_until(station, "card.received")
@ -162,12 +162,12 @@ def test_repeated_ack_and_crew_selection_do_not_duplicate_dds_log(client):
station.send_json({"type": "card.ack", "comment": "Основание: карточка передана диспетчеру."})
station.send_json({"type": "crew.select", "crew": crew})
read_until(station, "station.state")
assert sum(action == "card.ack" for action, *_ in state.dds_log) == 1
assert sum(action == "card.ack" for action, *_ in state.desk.active.dds_log) == 1
station.send_json({"type": "crew.select", "crew": crew})
station.send_json({"type": "zone.decision", "in_zone": True})
read_until(station, "station.state")
assert sum(action == "crew.select" for action, *_ in state.dds_log) == 1
assert sum(action == "crew.select" for action, *_ in state.desk.active.dds_log) == 1
finally:
control.__exit__(None, None, None)
@ -184,14 +184,14 @@ def test_zone_decision_is_one_shot_and_restored_in_station_snapshot(client):
accepted = read_until(station, "station.state")["snapshot"]
assert accepted["zone_decision"] is True
state = hub.get(session_id)
assert sum(action == "zone.decision" for action, *_ in state.dds_log) == 1
assert sum(action == "zone.decision" for action, *_ in state.desk.active.dds_log) == 1
station.send_json({"type": "zone.decision", "in_zone": True})
assert sum(action == "zone.decision" for action, *_ in state.dds_log) == 1
assert sum(action == "zone.decision" for action, *_ in state.desk.active.dds_log) == 1
station.send_json({"type": "zone.decision", "in_zone": False})
error = read_until(station, "error")
assert "уже записано" in error["message"]
assert sum(action == "zone.decision" for action, *_ in state.dds_log) == 1
assert sum(action == "zone.decision" for action, *_ in state.desk.active.dds_log) == 1
station.close()
with client.websocket_connect(f"/ws/station/{session_id}") as station:
@ -212,11 +212,11 @@ def test_server_randomly_selects_only_from_instructor_filtered_card_pool(
session_id, control = start(client, random_scenario_ids=pool)
try:
state = hub.get(session_id)
assert state.scenario_id == "t01-1-fire-container"
assert state.dispatched_card is not None
assert [item.id for item in state.dds_scenarios] == pool[::-1]
assert state.dds_next_scenario_index == 2
assert state.dds_next_arrival_at is None
assert state.desk.active.scenario.id == "t01-1-fire-container"
assert state.desk.active.dispatched_card is not None
assert [item.id for item in state.desk.scenarios] == pool[::-1]
assert state.desk.next_index == 2
assert state.desk.next_arrival_at is None
finally:
hub.stop_ticker(session_id)
control.__exit__(None, None, None)
@ -276,7 +276,7 @@ def test_instructor_live_registry_shows_owned_dds_session_and_deadline_state(cli
assert row["dds_snapshot"]["queue_cards"][0]["service_status"] == "added"
assert row["dds_snapshot"]["phone_reports"] == []
state = hub.get(session_id)
live_card = state.dds_live_cards[0]
live_card = state.desk.ordered()[0]
live_card.timers.on_event("dds.open")
live_card.timers.timers[TimerCode.DDS_WORK].started_at = time.monotonic() - 181
overdue_response = client.get("/api/sessions/active")
@ -304,10 +304,10 @@ def test_instructor_live_registry_includes_current_crew_report(client, monkeypat
state = hub.get(session_id)
with client.websocket_connect(f"/ws/station/{session_id}?role=dds") as station:
read_until(station, "station.state")
crew = state.crew_options()[0]
crew = state.crew_options(state.desk.active)[0]
station.send_json({"type": "crew.select", "crew": crew})
read_until(station, "station.state")
service = state.crew_service(crew)
service = state.crew_service(state.desk.active, crew)
station.send_json({
"type": "card.status", "service": service, "status": "accepted",
"comment": "Основание: профиль полномочий ДДС.\nСведения: карточка принята.",
@ -329,9 +329,9 @@ def test_ticket_dds_card_uses_source_caller_identity_and_phone(client):
session_id, control = start(client, scenario_id="t01-1-fire-container")
try:
state = hub.get(session_id)
assert state.kio.caller_name == "Сидоров Иван Сергеевич"
assert state.kio.caller_number == "916-126-34-71"
assert state.kio.caller_contact == "916-126-34-71"
assert state.desk.active.kio.caller_name == "Сидоров Иван Сергеевич"
assert state.desk.active.kio.caller_number == "916-126-34-71"
assert state.desk.active.kio.caller_contact == "916-126-34-71"
with client.websocket_connect(f"/ws/station/{session_id}") as station:
card = read_until(station, "card.received")["card"]
assert card["caller_name"] == "Сидоров Иван Сергеевич"
@ -345,9 +345,9 @@ def test_ticket_with_multiple_witnesses_does_not_guess_caller_name(client):
session_id, control = start(client, scenario_id="t20-1-three-fighting")
try:
state = hub.get(session_id)
assert state.kio.caller_name is None
assert state.kio.caller_number == "916-123-98-78"
assert state.kio.caller_contact == "916-123-98-78"
assert state.desk.active.kio.caller_name is None
assert state.desk.active.kio.caller_number == "916-123-98-78"
assert state.desk.active.kio.caller_contact == "916-123-98-78"
finally:
control.__exit__(None, None, None)
@ -432,7 +432,7 @@ def test_instructor_criteria_change_timer_and_success_threshold(client, monkeypa
}
)
read_until(station, "station.state")
assert len(state.reply_log) == 1
assert len(state.desk.active.reply_log) == 1
station.send_json({"type": "station.finish"})
read_until(station, "score.ready")
@ -624,10 +624,10 @@ def test_phone_dial_requires_brief_and_rejects_wrong_card_facts(client):
read_until(station, "station.state")["snapshot"]["phone_pending"]
is None
)
assert not hub.get(session_id).phone_reports
assert not hub.get(session_id).desk.active.phone_reports
complete_phone_call(station, hub.get(session_id), "dispatched")
read_until(station, "station.state")
assert len(hub.get(session_id).phone_reports) == 1
assert len(hub.get(session_id).desk.active.phone_reports) == 1
station.send_json({"type": "phone.dial"})
read_until(station, "phone.line")
read_until(station, "station.state")
@ -635,7 +635,7 @@ def test_phone_dial_requires_brief_and_rejects_wrong_card_facts(client):
{"type": "phone.check", "text": "Здравствуйте, хорошая погода"}
)
assert "обстановку" in read_until(station, "error")["message"]
assert len(hub.get(session_id).phone_reports) == 1
assert len(hub.get(session_id).desk.active.phone_reports) == 1
finally:
control.__exit__(None, None, None)
@ -718,7 +718,7 @@ def test_dds_card_over_three_minutes_has_e3_finding_and_actual_deviation(client)
"comment": "Основание: доклад старшего.\nСведения: карточка принята."}
)
snapshot = read_until(station, "station.state")["snapshot"]
state.timers.timers[TimerCode.DDS_WORK].started_at = time.monotonic() - 61
state.desk.active.timers.timers[TimerCode.DDS_WORK].started_at = time.monotonic() - 61
crew = next(
option
for option in snapshot["crew_options"]
@ -789,13 +789,13 @@ def test_complete_dds_workflow_with_training_calls_and_status_updates(client):
read_until(station, "score.ready")
score = wait_for(lambda: state.score)
assert score["score_auto"] == 100.0
assert [report.phase for report in state.phone_reports] == [
assert [report.phase for report in state.desk.active.phone_reports] == [
"dispatched",
"arrived",
"working",
"completed",
]
assert all(report.crew == crew for report in state.phone_reports)
assert all(report.crew == crew for report in state.desk.active.phone_reports)
assert not score["findings"]
finally:
control.__exit__(None, None, None)
@ -828,7 +828,7 @@ def test_concurrent_dds_cards_keep_independent_state_and_timers(client):
assert first_row["active"] is True and second_row["active"] is False
first_live = next(
item
for item in state.dds_live_cards
for item in state.desk.cards.values()
if str(item.card_id) == first_card_id
)
assert TimerCode.DDS_WORK not in first_live.timers.timers
@ -860,7 +860,7 @@ def test_concurrent_dds_cards_keep_independent_state_and_timers(client):
assert second["card"]["card_id"] == second_card_id
second_live = next(
item
for item in state.dds_live_cards
for item in state.desk.cards.values()
if str(item.card_id) == second_card_id
)
assert TimerCode.DDS_WORK in second_live.timers.timers
@ -907,7 +907,7 @@ def test_concurrent_dds_cards_keep_independent_state_and_timers(client):
read_until(station, "score.ready")
state = hub.get(session_id)
assert state.ended and len(state.dds_completed) == 2
assert state.ended and len(state.desk.completed) == 2
assert len(state.score["card_results"]) == 2
assert state.score["card_results"][0]["scenario_id"] == "fire-apartment-l2"
assert state.score["card_results"][1]["scenario_id"] == "t20-2-stroke"
@ -965,8 +965,8 @@ def test_each_dds_card_uses_its_own_scenario_weights():
from app.domain.events import Exercise, SessionMode
from app.scenarios.loader import load_file
from app.session.dds import prepare_card
from app.session.finish import score_current_dds
from app.session.dds import prepare_queue
from app.session.finish import score_dds_card
from app.session.state import SessionState
root = Path(__file__).resolve().parents[2] / "scenarios"
@ -982,13 +982,11 @@ def test_each_dds_card_uses_its_own_scenario_weights():
level=base.level.value,
mode=SessionMode.TRAINING,
exercise=Exercise.DDS,
dds_scenarios=[first, second],
)
prepare_card(state, first)
first_record = score_current_dds(state)
state.dds_card_index = 1
prepare_card(state, second)
second_record = score_current_dds(state)
prepare_queue(state, [first, second])
first_card, second_card = state.desk.ordered()
first_record = score_dds_card(state, first_card)
second_record = score_dds_card(state, second_card)
assert (
next(item.weight for item in first_record.metrics if item.key == "dds_primary")
== 7.0

View file

@ -31,8 +31,9 @@ def offline_finish(monkeypatch):
def accept_active_card(state: SessionState) -> None:
service = state.notified_services()[0]
state.set_service_status(
card = state.desk.active
service = card.dispatched_card.notify[0]
card.set_service_status(
service, ServiceStatus.ACCEPTED,
"Основание: доклад старшего.\nСведения: карточка принята.",
)
@ -41,8 +42,7 @@ def accept_active_card(state: SessionState) -> None:
def assert_active_card_kept(state: SessionState, active_id, active_status) -> None:
assert active_status is not CardStatus.REGISTERED
assert state.score["card_status"] == active_status.value
assert state.dds_active_card_id == active_id
assert state.dispatched_card.card_id == active_id
assert state.desk.active_id == active_id
assert len(state.score["card_results"]) == 2
@ -54,8 +54,8 @@ def test_dds_finish_reports_status_of_active_card_not_last_scored():
level=first.level.value, mode=SessionMode.TRAINING, exercise=Exercise.DDS,
)
prepare_queue(state, [first, second])
assert len(state.dds_live_cards) == 2
active_id = state.dds_active_card_id
assert len(state.desk.cards) == 2
active_id = state.desk.active_id
accept_active_card(state)
active_status = state.station_snapshot().card
@ -78,8 +78,8 @@ def test_handoff_finish_reports_status_of_active_card_not_last_scored():
)
state.dispatch()
prepare_handoff_queue(state, state.pending_dds_scenarios)
assert len(state.dds_live_cards) == 2
active_id = state.dds_active_card_id
assert len(state.desk.cards) == 2
active_id = state.desk.active_id
accept_active_card(state)
active_status = state.station_snapshot().card

View file

@ -13,11 +13,10 @@ from app.api.http.scenario_submissions import reset_demo_submissions
from app.config import get_settings
from app.db.models import AuditLog, Group, Scenario, ScenarioSubmission, Trainee
from app.domain import ekp
from app.domain.events import Exercise, SessionMode
from app.main import app
from app.scenarios import store
from app.session.dds import prepare_card
from app.session.state import SessionState
from app.session.dds import build_card
from app.session.timers import SessionTimers
@pytest.fixture
@ -182,19 +181,10 @@ def test_submitted_kio_is_kept_intact_and_becomes_the_dds_card_after_approval(cl
assert scenario.student_card.fire.object_kind == "мусорный контейнер"
assert scenario.student_card.signs == source.signs
assert "moderated-kio" in scenario.topics
state = SessionState(
session_id=UUID("00000000-0000-4000-8000-000000000701"),
scenario_id=scenario.id,
scenario_title=scenario.title,
level=scenario.level.value,
mode=SessionMode.TRAINING,
exercise=Exercise.DDS,
)
prepare_card(state, scenario)
assert state.dispatched_card is not None
assert state.dispatched_card.address == scenario.student_card.address
assert state.dispatched_card.caller_name == "Учебный заявитель"
assert state.dispatched_card.fire.object_kind == "мусорный контейнер"
card = build_card(scenario, 0, SessionTimers().limits)
assert card.dispatched_card.address == scenario.student_card.address
assert card.dispatched_card.caller_name == "Учебный заявитель"
assert card.dispatched_card.fire.object_kind == "мусорный контейнер"
assert any(
item["id"] == scenario.id and item["source"] == "trainee"
for item in client.get("/api/scenarios").json()

View file

@ -20,7 +20,7 @@ from app.domain.taxonomy import ErrorCode, Finding, FindingSource
from app.domain.timers import TimerCode
from app.scenarios.loader import load_file
from app.session.checkpoint import dump_state, load_state
from app.session.dds import deliver_due_cards, prepare_handoff_queue, prepare_queue
from app.session.dds import prepare_handoff_queue, prepare_queue
from app.session.hub import LEASE_FENCED_MESSAGE, SessionHub
from app.session.state import DdsCardRecord, SessionState, now_utc
@ -37,7 +37,6 @@ def dds_state() -> SessionState:
mode=SessionMode.TRAINING,
exercise=Exercise.DDS,
scenario=scenario.model_copy(deep=True),
dds_scenarios=[scenario.model_copy(deep=True)],
required_fields=list(scenario.required_fields),
trainee_name="Курсант для восстановления",
attempt=2,
@ -48,17 +47,18 @@ def dds_state() -> SessionState:
),
)
state.timers.limits[TimerCode.DDS_ACK] = 45_000
prepare_queue(state, state.dds_scenarios)
service = state.notified_services()[0]
state.set_service_status(service, ServiceStatus.ACCEPTED, "Принято в работу", author="диспетчер")
state.crew_selected = state.crew_options()[0]
state.crew_assignments[service] = state.crew_selected
state.phone_pending = PhoneCallPending(
service=service, crew=state.crew_selected, phase="dispatched"
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"
)
state.reply_text = "Сообщение принято, бригада направлена."
state.reply_log.append((now_utc(), state.reply_text))
state.dds_log.append(("crew.select", now_utc(), state.crew_selected))
card.reply_text = "Сообщение принято, бригада направлена."
card.reply_log.append((now_utc(), card.reply_text))
card.dds_log.append(("crew.select", now_utc(), card.crew_selected))
return state
@ -74,16 +74,17 @@ def test_active_dds_session_round_trips_without_losing_work():
assert restored.session_id == before.session_id
assert restored.exercise is Exercise.DDS
assert restored.criteria.decision_time_limit_seconds == 45
assert restored.dispatched_card == before.dispatched_card
assert restored.status_log == before.status_log
assert restored.crew_selected == before.crew_selected
assert restored.crew_assignments == before.crew_assignments
assert restored.phone_pending == before.phone_pending
assert restored.reply_text == before.reply_text
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
assert restored.processed_station_commands == before.processed_station_commands
assert restored.dds_scenarios[0].id == before.scenario_id
assert restored.desk.scenarios[0].id == before.scenario_id
# Время простоя backend входит в норматив, а не обнуляет таймер.
timer = next(item for item in restored.timers.snapshot() if item.code is TimerCode.DDS_ACK)
timer = next(item for item in card.timers.snapshot() if item.code is TimerCode.DDS_ACK)
assert timer.elapsed_ms >= 1_900
assert timer.limit_ms == 45_000
@ -112,19 +113,19 @@ def completed_card() -> DdsCardRecord:
def test_text_revealed_facts_and_completed_cards_round_trip_whole():
before = dds_state()
before.text_revealed_facts = {"address": "улица Ленина, 14", "floor": "5 этаж, уточнено"}
before.dds_completed = [completed_card()]
before.desk.completed = [completed_card()]
restored = load_state(dump_state(before), now_utc())
assert restored.text_revealed_facts == before.text_revealed_facts
assert restored.dds_completed == before.dds_completed
assert restored.dds_completed[0].received_at.tzinfo is not None
assert restored.desk.completed == before.desk.completed
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.dds_completed = [completed_card()]
before.desk.completed = [completed_card()]
payload = dump_state(before)
del payload["text_revealed_facts"]
legacy_keys = {"card_id", "scenario_id", "reply_text", "metrics",
@ -137,8 +138,8 @@ def test_legacy_checkpoint_without_new_keys_gets_defaults():
restored = load_state(payload, now_utc())
assert restored.text_revealed_facts == {}
card = restored.dds_completed[0]
assert card.card_id == before.dds_completed[0].card_id
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 == []
@ -164,26 +165,25 @@ def test_concurrent_dds_queue_round_trips_with_each_timer_and_status():
level=first.level.value,
mode=SessionMode.TRAINING,
exercise=Exercise.DDS,
dds_scenarios=[first, second],
)
prepare_queue(state, state.dds_scenarios)
first_id = state.dispatched_card.card_id
first_service = state.managed_services()[0]
state.set_service_status(first_service, ServiceStatus.ACCEPTED, "Принято в работу")
state.on_event("card.ack")
second_id = state.dds_live_cards[1].card_id
assert state.activate_dds_card(second_id)
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)
restored = load_state(
dump_state(state),
datetime.now(UTC) - timedelta(seconds=2),
)
assert restored.dds_active_card_id == second_id
assert restored.dispatched_card.card_id == second_id
assert len(restored.dds_live_cards) == 2
first_restored = next(item for item in restored.dds_live_cards if item.card_id == first_id)
second_restored = next(item for item in restored.dds_live_cards if item.card_id == second_id)
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]
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(
@ -209,27 +209,25 @@ def test_delivering_next_dds_card_does_not_clear_previous_card_state():
level=scenarios[0].level.value,
mode=SessionMode.TRAINING,
exercise=Exercise.DDS,
dds_scenarios=scenarios,
)
prepare_queue(state, scenarios, arrival_interval_seconds=60, max_waiting=1)
first_id = state.dds_live_cards[0].card_id
service = state.managed_services()[0]
state.set_service_status(service, ServiceStatus.ACCEPTED, "Принято в работу")
state.capture_active_dds()
first = state.desk.active
first_id = first.card_id
service = state.card_services(first)[0]
first.set_service_status(service, ServiceStatus.ACCEPTED, "Принято в работу")
assert deliver_due_cards(state, now_utc() + timedelta(seconds=61)) == 1
first = next(card for card in state.dds_live_cards if card.card_id == first_id)
assert first.status_log[-1].status is ServiceStatus.ACCEPTED
second = next(card for card in state.dds_live_cards if card.original_index == 1)
assert state.activate_dds_card(second.card_id)
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)
restored = load_state(dump_state(state), now_utc())
first_restored = next(card for card in restored.dds_live_cards if card.card_id == first_id)
first_restored = restored.desk.cards[first_id]
assert first_restored.status_log[-1].status is ServiceStatus.ACCEPTED
assert restored.dds_active_card_id == second.card_id
assert len(restored.dds_live_cards) == 2
assert restored.dds_next_scenario_index == 2
assert restored.dds_next_arrival_at is not None
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
def test_mixed_handoff_checkpoint_preserves_operator_card_and_generated_queue():
@ -249,10 +247,10 @@ def test_mixed_handoff_checkpoint_preserves_operator_card_and_generated_queue():
assert restored.operator_kio.address == "улица Ленина, 14"
assert restored.operator_scenario.id == first.id
assert restored.dds_scenarios[0].id == first.id
assert restored.dds_scenarios[1].id == second.id
assert len(restored.dds_live_cards) == 2
assert restored.dds_active_card_id == state.dds_active_card_id
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
def test_checkpoint_storage_failure_fences_and_notifies_all_data_channels():

View file

@ -180,7 +180,7 @@ def test_dispatcher_sets_a_status_and_the_card_follows(client):
station.send_json({"type": "card.status", "service": "Служба 101", "status": "accepted"})
error = read_until(station, "error")
assert "комментар" in error["message"]
assert hub.get(session_id).status_log == []
assert hub.get(session_id).desk.active.status_log == []
command_id = str(uuid4())
accepted = {
"type": "card.status", "service": "Служба 101", "status": "accepted",
@ -196,7 +196,7 @@ def test_dispatcher_sets_a_status_and_the_card_follows(client):
station.send_json(accepted)
assert read_until(station, "command.ack")["command_id"] == command_id
runtime = hub.get(session_id)
assert sum(item.service == "Служба 101" for item in runtime.status_log) == 1
assert sum(item.service == "Служба 101" for item in runtime.desk.active.status_log) == 1
assert "responding" in state["snapshot"]["available"]["Служба 101"]
assert "accepted" not in state["snapshot"]["available"]["Служба 101"]
finally:
@ -212,7 +212,7 @@ def test_out_of_order_status_is_rejected_with_a_reason(client):
"comment": "Бригада доложила старшему о прибытии."})
error = read_until(station, "error")
assert "Принята" in error["message"]
assert hub.get(session_id).status_log == []
assert hub.get(session_id).desk.active.status_log == []
finally:
close_all(contexts)