167 lines
7.4 KiB
Python
167 lines
7.4 KiB
Python
"""Каталог служб из «СЛУЖБЫ 112» (lct-38).
|
||
|
||
Каталог только расширяет ручной выбор служб в карточке. Список оповещения
|
||
по-прежнему считается по классификатору, эталоны сценариев от каталога не зависят.
|
||
"""
|
||
|
||
import asyncio
|
||
import json
|
||
from pathlib import Path
|
||
from uuid import uuid4
|
||
|
||
import pytest
|
||
from fastapi.testclient import TestClient
|
||
from pydantic import ValidationError
|
||
|
||
from app.api.ws import call as call_ws
|
||
from app.domain import ekp
|
||
from app.domain.events import ErrorEvent, KioPatchIn, KioPatchOut, PatchSource, SessionMode
|
||
from app.domain.kio import EDITABLE_KIO_FIELDS, KIO, PatchRejected, apply_patch
|
||
from app.main import app
|
||
from app.scenarios.loader import load_file
|
||
from app.scoring.card import evaluate_card
|
||
from app.session.hub import hub
|
||
from app.session.state import SessionState
|
||
from app.session.store import MemorySessionStore
|
||
|
||
|
||
def test_catalog_keeps_all_209_services_with_attribution_and_source():
|
||
data = ekp.catalog()
|
||
|
||
assert len(data.services) == 209
|
||
assert len({item.name for item in data.services}) == 209
|
||
assert sum(item.service_level == "ДДС" for item in data.services) == 157
|
||
assert "ded466988f505082922aacac9fff32b777d1279a" in data.transcription
|
||
assert data.license.startswith("MIT")
|
||
assert all(item.source_reference.startswith("СЛУЖБЫ 112.docx#word/media/image")
|
||
for item in data.services)
|
||
|
||
|
||
@pytest.mark.parametrize("name", [
|
||
'Служба 101 (ГУ МЧС России по г.Москве, ГКУ "Пожарно спасательный центр" ОДС)',
|
||
'Служба 104 (АО "МОСГАЗ" Диспетчерское управление)',
|
||
"ЦЭМП",
|
||
'ЦОДД (ГКУ "Центр организации дорожного движения")',
|
||
'ЭВАЖД (ГБУ "Учреждение по эксплуатации высотных административных и жилых домов")',
|
||
])
|
||
def test_rows_checked_against_our_screenshots_are_verbatim(name):
|
||
assert name in ekp.catalog_names()
|
||
|
||
|
||
def test_classifier_services_and_notify_lists_are_untouched():
|
||
"""Индексы служб классификатора — основа списков оповещения 96 сценариев."""
|
||
raw = json.loads(ekp.DATA.read_text(encoding="utf-8"))
|
||
assert [item.name for item in ekp.services()] == [item["name"] for item in raw["services"]]
|
||
assert len(ekp.services()) == 61
|
||
|
||
|
||
def test_operator_adds_catalog_services_without_changing_the_notify_list():
|
||
assert "notify_extra" in EDITABLE_KIO_FIELDS
|
||
card = KIO(notify=["Служба 101"])
|
||
|
||
updated = apply_patch(card, {"notify_extra": ["ФСБ", "ЦЭМП", "ФСБ"], "notify": []})
|
||
|
||
assert updated.notify_extra == ["ФСБ", "ЦЭМП"]
|
||
assert updated.notify == ["Служба 101"], "автоматический список оператор не правит"
|
||
with pytest.raises(PatchRejected):
|
||
apply_patch(card, {"notify_extra": ["Служба, которой нет"]})
|
||
with pytest.raises(ValidationError):
|
||
apply_patch(card, {"notify_extra": [item.name for item in ekp.catalog().services[:21]]})
|
||
|
||
|
||
def test_service_already_in_notify_is_not_added_twice():
|
||
"""ЦЭМП, ФСБ, МОЭК, Метро, Мосгортранс есть и в каталоге, и в классификаторе."""
|
||
card = KIO(notify=["ЦЭМП"])
|
||
|
||
updated = apply_patch(card, {"notify_extra": ["ЦЭМП", "ФСБ"]})
|
||
|
||
assert updated.notify_extra == ["ФСБ"]
|
||
|
||
|
||
def test_saved_card_with_renamed_service_still_loads():
|
||
"""Каталог проверяется на входе kio.patch: правка названия в каталоге
|
||
не должна ломать восстановление снимков и сданных карточек."""
|
||
card = KIO.model_validate({"notify_extra": ["Прежнее название службы"]})
|
||
|
||
assert card.notify_extra == ["Прежнее название службы"]
|
||
|
||
|
||
def test_added_services_do_not_change_the_score_against_the_reference():
|
||
library = Path(__file__).resolve().parents[2] / "scenarios"
|
||
scenario = load_file(library / "fire-apartment-l2.yaml", library)
|
||
card = KIO(address="улица Ленина, 14", description="горит балкон")
|
||
|
||
plain = evaluate_card(scenario, card)
|
||
extended = evaluate_card(scenario, card.model_copy(update={"notify_extra": ["ФСБ"]}))
|
||
|
||
assert [(m.key, m.passed) for m in plain.metrics] == [(m.key, m.passed) for m in extended.metrics]
|
||
assert plain.score == extended.score
|
||
|
||
|
||
def test_catalog_is_served_like_the_classifier():
|
||
with TestClient(app) as client:
|
||
response = client.get("/api/ekp/services")
|
||
|
||
assert response.status_code == 200
|
||
body = response.json()
|
||
assert len(body["services"]) == 209
|
||
assert {"id", "name", "organization", "service_level", "source_reference"} <= body["services"][0].keys()
|
||
|
||
|
||
@pytest.fixture
|
||
def lesson(monkeypatch):
|
||
sent = []
|
||
monkeypatch.setattr(hub, "store", MemorySessionStore())
|
||
monkeypatch.setattr(hub, "to_trainee", lambda _session_id, event: sent.append(event))
|
||
monkeypatch.setattr(hub, "to_observers", lambda *_args: None)
|
||
state = SessionState(session_id=uuid4(), scenario_id="test", scenario_title="Тест",
|
||
level="L1", mode=SessionMode.TRAINING)
|
||
hub.register(state)
|
||
|
||
def patch(fields):
|
||
sent.clear()
|
||
|
||
async def run():
|
||
async with hub.operation(state.session_id):
|
||
await call_ws._handle(state.session_id, state, KioPatchIn(fields=fields))
|
||
|
||
asyncio.run(run())
|
||
return list(sent)
|
||
|
||
return state, patch
|
||
|
||
|
||
def test_unknown_service_in_kio_patch_is_refused_without_failing_the_lesson(lesson):
|
||
state, patch = lesson
|
||
|
||
sent = patch({"notify_extra": ["Служба, которой нет"]})
|
||
assert isinstance(sent[0], ErrorEvent) and "не принято" in sent[0].message
|
||
assert state.kio.notify_extra == []
|
||
|
||
sent = patch({"notify_extra": ["ФСБ"]})
|
||
assert state.kio.notify_extra == ["ФСБ"]
|
||
|
||
|
||
def test_refused_kio_patch_returns_server_values_to_drop_pending(lesson):
|
||
"""Пакет отклоняется целиком — фронт должен получить серверные значения
|
||
всех его полей с `auto`, иначе pending висит до следующего kio.state."""
|
||
state, patch = lesson
|
||
patch({"address": "улица Ленина, 14", "notify_extra": ["ФСБ"]})
|
||
|
||
sent = patch({"address": "Тверская, 1", "fire.floors": 5, "notify_extra": ["ФСБ", "Служба, которой нет"]})
|
||
|
||
assert isinstance(sent[0], ErrorEvent)
|
||
assert isinstance(sent[1], KioPatchOut) and sent[1].source is PatchSource.AUTO
|
||
assert sent[1].fields == {"address": "улица Ленина, 14", "fire.floors": None, "notify_extra": ["ФСБ"]}
|
||
assert state.kio.address == "улица Ленина, 14"
|
||
|
||
|
||
def test_cleaned_notify_extra_is_sent_back_as_auto(lesson):
|
||
state, patch = lesson
|
||
state.kio = state.kio.model_copy(update={"notify": ["ЦЭМП"]})
|
||
|
||
sent = patch({"notify_extra": ["ЦЭМП", "ФСБ", "ФСБ"]})
|
||
|
||
auto = [event for event in sent if isinstance(event, KioPatchOut) and event.source is PatchSource.AUTO]
|
||
assert auto and auto[-1].fields["notify_extra"] == ["ФСБ"]
|
||
assert state.kio.notify_extra == ["ФСБ"]
|