Весь продукт стоял на допущении «звонок → карточка → выезд». В билетах заказчика оно нарушается намеренно: «поругался с продавцом Мегафон» — справка, а вызов из Волгоградской области передаётся в систему-112 другого субъекта. Курсант, заведший карточку на такой вызов, занял расчёт зря, и прежняя оценка этого не видела — наоборот, награждала за полноту заполнения. - Outcome в домене, поле outcome в сценарии, событие call.resolve у курсанта и две кнопки на АРМ. Отдельное действие, а не «положил трубку»: система должна отличить осознанное решение от брошенного вызова. - Метрика outcome (E2, вес 3 — лишний выезд дороже неточного признака). - Там, где карточка не заводится, метрики карточки не считаются вовсе. Полнота опроса считается: передать вызов не значит не опрашивать. - call.resolve останавливает норматив опроса, как и передача карточки. Попутно (lct-34): библиотека стала вложенной — scenarios/tickets/, поля ticket и position, чек-листы для медицины, полиции и ЖКХ. Перенесён билет 1 целиком: три вызова, три классификатора, третий — из другого региона. Найдено по ходу: модификаторы списка оповещения не были подключены ни к чему. В классификаторе скорая добавляется к массовой драке признаком «пострадавшие», но взять его было неоткуда. Теперь модификаторы берутся из карточки — victims_count, life_threat, evacuation_needed, fire.gasified, — и список оповещения пересобирается при правке любого из этих полей. 161 тест зелёный (13 новых), make typecheck чистый.
136 lines
5.1 KiB
Python
136 lines
5.1 KiB
Python
"""Исход вызова: не каждый звонок заканчивается карточкой.
|
||
|
||
Пункты приёмки lct-36. Материал — билет 1 заказчика: третий вызов в нём
|
||
из Волгоградской области, и правильное действие — передать, а не заводить
|
||
карточку (docs/spec/TICKETS.md).
|
||
"""
|
||
|
||
from pathlib import Path
|
||
|
||
import pytest
|
||
|
||
from app.domain.classifiers import Outcome
|
||
from app.domain.kio import KIO
|
||
from app.scenarios.loader import load_file, load_library
|
||
from app.scoring.gost import evaluate
|
||
from app.session.timers import SessionTimers
|
||
|
||
LIBRARY = Path(__file__).resolve().parents[2] / "scenarios"
|
||
|
||
|
||
@pytest.fixture(scope="module")
|
||
def other_region():
|
||
return load_file(LIBRARY / "tickets" / "t01-3-child-other-region.yaml", LIBRARY)
|
||
|
||
|
||
@pytest.fixture(scope="module")
|
||
def ordinary():
|
||
return load_file(LIBRARY / "fire-apartment-l2.yaml", LIBRARY)
|
||
|
||
|
||
def timeline() -> SessionTimers:
|
||
timers = SessionTimers()
|
||
timers.on_event("call.incoming", now=0.0)
|
||
timers.on_event("call.answer", now=5.0)
|
||
return timers
|
||
|
||
|
||
def score(scenario, *, resolved=None, dispatched=False, kio=None):
|
||
return evaluate(
|
||
scenario=scenario,
|
||
kio=kio or KIO(),
|
||
timers=timeline(),
|
||
revealed_facts=[fact.id for fact in scenario.facts],
|
||
refined_facts=[fact.id for fact in scenario.facts if fact.refine_on],
|
||
resolved_outcome=resolved,
|
||
dispatched=dispatched,
|
||
)
|
||
|
||
|
||
def test_default_outcome_is_a_card(ordinary):
|
||
assert ordinary.outcome is Outcome.CARD
|
||
|
||
|
||
def test_transfer_is_declared_by_the_scenario(other_region):
|
||
assert other_region.outcome is Outcome.TRANSFER_REGION
|
||
|
||
|
||
def test_correct_transfer_passes(other_region):
|
||
result = score(other_region, resolved="transfer_region")
|
||
metric = next(m for m in result.metrics if m.key == "outcome")
|
||
assert metric.passed
|
||
|
||
|
||
def test_filing_a_card_instead_of_transferring_is_an_error(other_region):
|
||
"""Курсант завёл карточку и отправил наряд в другой субъект — это ошибка,
|
||
которую прежняя оценка не видела вовсе."""
|
||
result = score(other_region, dispatched=True)
|
||
metric = next(m for m in result.metrics if m.key == "outcome")
|
||
assert not metric.passed
|
||
assert any(finding.code.value == "E2" for finding in result.findings)
|
||
|
||
|
||
def test_doing_nothing_is_also_an_error(other_region):
|
||
result = score(other_region)
|
||
assert not next(m for m in result.metrics if m.key == "outcome").passed
|
||
|
||
|
||
def test_empty_card_is_not_punished_when_no_card_is_expected(other_region):
|
||
"""Штрафовать за пустые поля там, где карточка не заводится, значит учить
|
||
заполнять лишнее."""
|
||
result = score(other_region, resolved="transfer_region")
|
||
keys = {metric.key for metric in result.metrics}
|
||
assert "required_fields" not in keys
|
||
assert "address" not in keys
|
||
assert "victims_count" not in keys
|
||
|
||
|
||
def test_ordinary_call_has_no_outcome_metric(ordinary):
|
||
"""У обычного вызова исход не обсуждается — метрики просто нет,
|
||
а не «пройдена»."""
|
||
result = score(ordinary, dispatched=True)
|
||
assert "outcome" not in {metric.key for metric in result.metrics}
|
||
|
||
|
||
def test_interview_still_counts_for_a_transfer(other_region):
|
||
"""Передать вызов — не значит не опрашивать: адрес и состояние ребёнка
|
||
другому региону всё равно нужны."""
|
||
result = score(other_region, resolved="transfer_region")
|
||
assert "checklist_completeness" in {metric.key for metric in result.metrics}
|
||
|
||
|
||
# ── билет как единица занятия ──
|
||
|
||
|
||
def test_ticket_one_is_three_calls_of_different_services():
|
||
"""Билет собран так, чтобы за одно занятие курсант прошёл разные службы."""
|
||
ticket = [s for s in load_library(LIBRARY) if s.ticket == 1]
|
||
assert len(ticket) == 3
|
||
assert sorted(s.position for s in ticket) == [1, 2, 3]
|
||
assert len({s.type for s in ticket}) == 3
|
||
|
||
|
||
def test_ticket_and_position_go_together():
|
||
from pydantic import ValidationError
|
||
|
||
from app.scenarios.schema import Scenario
|
||
|
||
with pytest.raises(ValidationError):
|
||
Scenario.model_validate(
|
||
{
|
||
"id": "broken",
|
||
"title": "Билет без номера вызова",
|
||
"type": "fire",
|
||
"level": "L1",
|
||
"ticket": 4,
|
||
"persona": {"base": "calm"},
|
||
"first_line": "Алло",
|
||
}
|
||
)
|
||
|
||
|
||
def test_mass_fight_notifies_both_police_and_medics():
|
||
"""Тот случай, где выбор одной службы был бы неверен по построению."""
|
||
scenario = load_file(LIBRARY / "tickets" / "t01-2-mass-fight.yaml", LIBRARY)
|
||
assert "МВД" in scenario.ground_truth.notify
|
||
assert "СМП" in scenario.ground_truth.notify
|