refactor: пульт ДДС владеет живыми карточками, сессия не копирует их поля
Связка 112→ДДС больше не подменяет таймеры оператора таймерами первой карточки ДДС, поэтому время заполнения карточки 112 снова попадает в оценку.
This commit is contained in:
parent
c0fa0a50a1
commit
5a3761a830
24 changed files with 2093 additions and 762 deletions
1
backend/tests/golden/checkpoint_before_desk.json
Normal file
1
backend/tests/golden/checkpoint_before_desk.json
Normal file
File diff suppressed because one or more lines are too long
824
backend/tests/golden/dds_desk_scores.json
Normal file
824
backend/tests/golden/dds_desk_scores.json
Normal 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": []
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
127
backend/tests/test_dds_desk.py
Normal file
127
backend/tests/test_dds_desk.py
Normal 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 == []
|
||||
172
backend/tests/test_dds_desk_checkpoint.py
Normal file
172
backend/tests/test_dds_desk_checkpoint.py
Normal 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
|
||||
194
backend/tests/test_dds_desk_scores.py
Normal file
194
backend/tests/test_dds_desk_scores.py
Normal 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]
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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():
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue