lct-hack/backend/tests/test_station.py
Ivan Gerasimov d4f0d0a0f8 feat: статусы реагирования на АРМ ДДС и ошибки диспетчера D1–D6 (lct-33)
ТЗ называет обучаемого оператором ДДС, а его работа в системе-112 состоит
из одного действия: получить карточку и правильно проставить статус
реагирования с комментарием. Памятка заказчика посвящена этому целиком.
Наш АРМ умел два статуса из девяти.

- domain/statuses.py: девять статусов реагирования с автоматом переходов,
  семь статусов карточки, обязательные комментарии к отказам. Формулировки
  из памятки дословно — диспетчер должен узнать слова своего боевого АРМ.
- Автомат на сервере: фронт рисует только пришедшее в available. Отвергнутая
  отметка не попадает в журнал и возвращается текстом для курсанта.
- scoring/dispatcher.py: D1, D2, D3, D4, D6 детерминированно. D3 (отказ
  от профильного происшествия) проверяется по списку оповещения из ЕКП —
  без классификатора его было бы не на чем посчитать. D5 оставлен судье.
- Отметки D идут в отчёт рядом с E: в живой цепочке 112 → ДДС участвуют обе роли.
- Экран ДДС: таблица служб со статусами, поле комментария у отказов,
  журнал отметок, статус карточки красным в трёх случаях из семи.

Сквозной тест повис на ожидании station.state и вскрыл продуктовый дефект:
снимок уходил только в ответ на действие диспетчера, то есть список оповещения
он видел лишь после того, как что-то нажмёт. Теперь station.state отправляется
сразу за card.received.

Упрощения записаны в карточке: статусы «Проверена» и «Не завершено» не
считаются — первый требует главного специалиста, второй 48 часов.

148 тестов зелёных (23 новых), make typecheck чистый.
2026-09-20 08:23:55 +03:00

214 lines
9.4 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""Цепочка 112 → ДДС: карточка уходит снимком, диспетчер подтверждает или отбивает."""
import time
from uuid import uuid4
import pytest
from fastapi.testclient import TestClient
from app.main import app
from app.session.hub import hub
@pytest.fixture
def client():
with TestClient(app) as test_client:
hub.journal = None
yield test_client
def wait_for(predicate, timeout: float = 3.0):
deadline = time.monotonic() + timeout
while time.monotonic() < deadline:
value = predicate()
if value:
return value
time.sleep(0.02)
raise AssertionError("не дождались")
def read_until(ws, event_type: str, limit: int = 20) -> dict:
for _ in range(limit):
message = ws.receive_json()
if message["type"] == event_type:
return message
raise AssertionError(f"событие {event_type} не пришло")
def lesson(client):
session_id = uuid4()
control = client.websocket_connect(f"/ws/control/{session_id}")
socket = control.__enter__()
socket.send_json({"type": "scenario.start", "scenario_id": "fire-apartment-l2",
"trainee": "Иванов", "mode": "training"})
wait_for(lambda: hub.get(session_id))
return session_id, control, socket
def test_card_goes_to_the_dispatcher_frozen(client):
"""Снимок не меняется после передачи: оператор не дописывает задним числом."""
session_id, control, _ = lesson(client)
try:
with client.websocket_connect(f"/ws/station/{session_id}?role=dds_01") as station:
with client.websocket_connect(f"/ws/call/{session_id}") as trainee:
trainee.send_json({"type": "call.answer"})
trainee.send_json({"type": "kio.patch", "fields": {"address": "улица Ленина, 14", "dds": "01"}})
wait_for(lambda: hub.get(session_id).kio.address)
trainee.send_json({"type": "dds.dispatch", "service": "01"})
received = read_until(station, "card.received")
# Правка после передачи в снимок не попадает.
trainee.send_json({"type": "kio.patch", "fields": {"floor": "5"}})
wait_for(lambda: hub.get(session_id).kio.floor == "5")
assert received["card"]["address"] == "улица Ленина, 14"
assert received["card"]["floor"] is None, "снимок изменился после передачи"
assert received["from_operator"] == "Иванов"
finally:
control.__exit__(None, None, None)
def test_station_joining_late_still_gets_the_card(client):
"""Диспетчер садится за АРМ, когда вызов уже идёт."""
session_id, control, _ = lesson(client)
try:
with client.websocket_connect(f"/ws/call/{session_id}") as trainee:
trainee.send_json({"type": "call.answer"})
trainee.send_json({"type": "dds.dispatch", "service": "01"})
wait_for(lambda: hub.get(session_id).dispatched_card)
with client.websocket_connect(f"/ws/station/{session_id}") as station:
assert read_until(station, "card.received")["card"] is not None
finally:
control.__exit__(None, None, None)
def test_acknowledgement_stops_the_four_second_norm(client):
from app.domain.timers import TimerCode
session_id, control, _ = lesson(client)
try:
state = hub.get(session_id)
with client.websocket_connect(f"/ws/station/{session_id}") as station:
with client.websocket_connect(f"/ws/call/{session_id}") as trainee:
trainee.send_json({"type": "call.answer"})
trainee.send_json({"type": "dds.dispatch", "service": "01"})
read_until(station, "card.received")
station.send_json({"type": "card.ack"})
measured = wait_for(lambda: state.timers.measured_ms(TimerCode.DDS_ACK) is not None)
assert measured
finally:
control.__exit__(None, None, None)
def test_bounced_card_becomes_e6_with_the_reason(client):
"""Неполнота КИО перестаёт быть процентом в отчёте и становится
сорванным выездом с конкретной причиной."""
session_id, control, _ = lesson(client)
try:
state = hub.get(session_id)
with client.websocket_connect(f"/ws/station/{session_id}") as station:
with client.websocket_connect(f"/ws/call/{session_id}") as trainee:
trainee.send_json({"type": "call.answer"})
trainee.send_json({"type": "dds.dispatch", "service": "01"})
read_until(station, "card.received")
station.send_json({"type": "card.bounce", "missing_fields": ["floor", "victims_count"],
"comment": "куда ехать без этажа"})
wait_for(lambda: state.bounced_fields)
trainee.send_json({"type": "call.hangup"})
wait_for(lambda: state.score is not None)
response = client.get(f"/api/sessions/{session_id}/report").json()
chain = [finding for finding in response["findings"] if finding["code"] == "E6"]
assert chain, "возврат карточки должен попасть в разбор"
assert "floor" in chain[0]["summary"] and "выезд сорван" in chain[0]["summary"]
finally:
control.__exit__(None, None, None)
# ── статусы реагирования (lct-33) ──
def dispatched(client):
"""Занятие, доведённое до передачи карточки в ДДС, и подключённая станция."""
session_id, control, _ = lesson(client)
station_ctx = client.websocket_connect(f"/ws/station/{session_id}?role=dds_01")
station = station_ctx.__enter__()
call_ctx = client.websocket_connect(f"/ws/call/{session_id}")
trainee = call_ctx.__enter__()
trainee.send_json({"type": "call.answer"})
trainee.send_json(
{"type": "kio.patch", "fields": {"signs": ["жилой дом", "балкон", "открытое пламя"]}}
)
wait_for(lambda: hub.get(session_id).kio.incident_code)
trainee.send_json({"type": "dds.dispatch", "service": "01"})
read_until(station, "card.received")
return session_id, station, (control, station_ctx, call_ctx)
def close_all(contexts):
for context in contexts:
context.__exit__(None, None, None)
def test_station_sees_the_notify_list_from_the_classifier(client):
"""Диспетчеру есть что отмечать только потому, что список оповещения
посчитан по ЕКП, а не выбран оператором (lct-32)."""
session_id, station, contexts = dispatched(client)
try:
state = read_until(station, "station.state")
assert "Служба 101" in state["snapshot"]["services"]
assert len(state["snapshot"]["services"]) > 1
assert state["snapshot"]["statuses"]["Служба 101"] == "added"
finally:
close_all(contexts)
def test_dispatcher_sets_a_status_and_the_card_follows(client):
session_id, station, contexts = dispatched(client)
try:
read_until(station, "station.state")
station.send_json({"type": "card.status", "service": "Служба 101", "status": "accepted"})
state = read_until(station, "station.state")
assert state["snapshot"]["statuses"]["Служба 101"] == "accepted"
assert "responding" in state["snapshot"]["available"]["Служба 101"]
assert "accepted" not in state["snapshot"]["available"]["Служба 101"]
finally:
close_all(contexts)
def test_out_of_order_status_is_rejected_with_a_reason(client):
"""Автомат живёт на сервере: фронт может ошибиться, состояние — нет."""
session_id, station, contexts = dispatched(client)
try:
read_until(station, "station.state")
station.send_json({"type": "card.status", "service": "Служба 101", "status": "arrived"})
error = read_until(station, "error")
assert "Принята" in error["message"]
assert hub.get(session_id).status_log == []
finally:
close_all(contexts)
def test_refusal_without_a_comment_is_rejected(client):
session_id, station, contexts = dispatched(client)
try:
read_until(station, "station.state")
station.send_json({"type": "card.status", "service": "Служба 101", "status": "declined"})
error = read_until(station, "error")
assert "комментар" in error["message"]
finally:
close_all(contexts)
def test_ack_button_still_works_and_counts_as_accepted(client):
"""Кнопка подтверждения из живой цепочки (lct-20) осталась и означает
«Принята» у главной службы — иначе два экрана учили бы разному."""
session_id, station, contexts = dispatched(client)
try:
read_until(station, "station.state")
station.send_json({"type": "card.ack"})
state = read_until(station, "station.state")
assert state["snapshot"]["statuses"]["Служба 101"] == "accepted"
finally:
close_all(contexts)