lct-hack/backend/tests/test_outcomes.py
Ivan Gerasimov b8dc39fd7d feat: исход вызова — не каждый звонок заканчивается карточкой (lct-36)
Весь продукт стоял на допущении «звонок → карточка → выезд». В билетах
заказчика оно нарушается намеренно: «поругался с продавцом Мегафон» — справка,
а вызов из Волгоградской области передаётся в систему-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 чистый.
2026-09-20 08:33:30 +03:00

136 lines
5.1 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-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