lct-hack/backend/tests/test_statuses.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

175 lines
6.3 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.

"""Статусы реагирования на АРМ ДДС.
Пункты приёмки lct-33: автомат переходов, обязательный комментарий,
необратимость завершения, статус карточки и ошибки диспетчера D1–D6.
"""
from datetime import datetime, timedelta, timezone
import pytest
from app.domain.statuses import (
CardStatus,
ServiceStatus,
StatusEntry,
StatusError,
card_status,
check,
current,
)
from app.domain.taxonomy import ErrorCode
from app.scoring.dispatcher import evaluate_dispatcher
SERVICES = ["Служба 101", "МВД"]
NOW = datetime(2026, 9, 19, 12, 0, tzinfo=timezone.utc)
def mark(service: str, status: ServiceStatus, comment: str = "", minute: int = 0) -> StatusEntry:
return StatusEntry(
service=service, status=status, at=NOW + timedelta(minutes=minute), comment=comment
)
# ── автомат переходов ──
def test_only_primary_statuses_are_available_at_first():
check([], "Служба 101", ServiceStatus.ACCEPTED, "")
check([], "Служба 101", ServiceStatus.DECLINED, "не обслуживаем, передано в УК")
with pytest.raises(StatusError):
check([], "Служба 101", ServiceStatus.ARRIVED, "")
def test_accepted_opens_the_rest():
log = [mark("Служба 101", ServiceStatus.ACCEPTED)]
check(log, "Служба 101", ServiceStatus.RESPONDING, "")
check(log, "Служба 101", ServiceStatus.COMPLETED, "")
def test_declined_leads_only_back_to_accepted():
"""Служба может передумать, но не может отказаться дважды по-разному."""
log = [mark("Служба 101", ServiceStatus.DECLINED, "не наш адрес")]
check(log, "Служба 101", ServiceStatus.ACCEPTED, "")
with pytest.raises(StatusError):
check(log, "Служба 101", ServiceStatus.RESPONDING, "")
def test_completed_closes_the_card_for_editing():
log = [
mark("Служба 101", ServiceStatus.ACCEPTED),
mark("Служба 101", ServiceStatus.COMPLETED, minute=5),
]
with pytest.raises(StatusError) as exc:
check(log, "Служба 101", ServiceStatus.WORKING, "")
assert "закрыта" in str(exc.value)
def test_refusal_without_a_comment_is_rejected():
"""Не валидация формы, а предмет обучения: половина нарушений в памятке —
отказ без указания, куда передана информация."""
with pytest.raises(StatusError) as exc:
check([], "МВД", ServiceStatus.DECLINED, " ")
assert "комментар" in str(exc.value)
def test_statuses_are_tracked_per_service():
log = [mark("Служба 101", ServiceStatus.ACCEPTED)]
assert current(log, "Служба 101") is ServiceStatus.ACCEPTED
assert current(log, "МВД") is ServiceStatus.ADDED
# ── статус карточки ──
def test_silence_past_the_deadline_is_not_notified():
assert card_status([], SERVICES, notify_deadline_passed=True) is CardStatus.NOT_NOTIFIED
def test_silence_within_the_deadline_is_still_registered():
assert card_status([], SERVICES, notify_deadline_passed=False) is CardStatus.REGISTERED
def test_any_refusal_marks_the_whole_card():
log = [
mark("Служба 101", ServiceStatus.ACCEPTED),
mark("МВД", ServiceStatus.DECLINED, "не наша территория, передано в ОМВД"),
]
assert card_status(log, SERVICES) is CardStatus.REFUSED
def test_all_completed_closes_the_card():
log = []
for service in SERVICES:
log += [mark(service, ServiceStatus.ACCEPTED), mark(service, ServiceStatus.COMPLETED, minute=9)]
assert card_status(log, SERVICES) is CardStatus.COMPLETED
def test_alarming_statuses_are_the_three_from_the_memo():
from app.domain.statuses import ALARMING
assert ALARMING == {CardStatus.NOT_NOTIFIED, CardStatus.REFUSED, CardStatus.NOT_COMPLETED}
# ── ошибки диспетчера ──
def _codes(entries, services=SERVICES, elapsed=45_000):
findings = evaluate_dispatcher(
entries=entries, services=services, deadline_ms=30_000, elapsed_ms=elapsed
)
return [finding.code for finding in findings]
def test_no_status_is_d1():
assert ErrorCode.D1 in _codes([])
def test_refusal_without_comment_is_d4():
"""Автомат такую отметку не пропустит, но карточка может прийти из внешней
системы уже с пустым комментарием — разбор обязан это показать."""
log = [mark(service, ServiceStatus.DECLINED) for service in SERVICES]
assert ErrorCode.D4 in _codes(log)
def test_declining_a_profile_incident_is_d3():
log = [
mark("Служба 101", ServiceStatus.ACCEPTED),
mark("МВД", ServiceStatus.DECLINED, "не в нашей компетенции"),
]
codes = _codes(log)
assert ErrorCode.D3 in codes, "служба в списке оповещения — происшествие профильное"
def test_accepted_and_forgotten_is_d2():
log = [mark(service, ServiceStatus.ACCEPTED) for service in SERVICES]
assert ErrorCode.D2 in _codes(log)
def test_completed_without_progress_is_d6():
log = []
for service in SERVICES:
log += [mark(service, ServiceStatus.ACCEPTED), mark(service, ServiceStatus.COMPLETED, minute=9)]
assert ErrorCode.D6 in _codes(log)
def test_clean_work_has_no_findings():
log = []
for service in SERVICES:
log += [
mark(service, ServiceStatus.ACCEPTED),
mark(service, ServiceStatus.RESPONDING, minute=1),
mark(service, ServiceStatus.ARRIVED, minute=4),
mark(service, ServiceStatus.WORKING, minute=5),
mark(service, ServiceStatus.COMPLETED, minute=20),
]
assert _codes(log) == []
def test_every_finding_carries_its_reason():
"""Правило проекта: ни одной отметки без обоснования."""
findings = evaluate_dispatcher(
entries=[], services=SERVICES, deadline_ms=30_000, elapsed_ms=45_000
)
assert findings
for finding in findings:
assert finding.fact and finding.ref