lct-hack/backend/tests/test_dispatcher_scoring.py

282 lines
11 KiB
Python
Raw Normal View History

from datetime import datetime, timezone
from types import SimpleNamespace
import pytest
from pydantic import ValidationError
from app.domain.statuses import ServiceStatus, StatusEntry
from app.domain.taxonomy import ErrorCode
from app.scoring.dispatcher import dispatcher_metrics, evaluate_dispatcher
def test_late_primary_status_has_d1_finding_even_when_status_exists():
findings = evaluate_dispatcher(
entries=[StatusEntry(
service="Служба 101",
status=ServiceStatus.ACCEPTED,
at=datetime.now(timezone.utc),
)],
services=["Служба 101"],
deadline_ms=30_000,
elapsed_ms=31_000,
)
d1 = next(finding for finding in findings if finding.code is ErrorCode.D1)
assert "31 с" in d1.fact
def test_primary_status_within_deadline_does_not_have_d1_finding():
findings = evaluate_dispatcher(
entries=[StatusEntry(
service="Служба 101",
status=ServiceStatus.ACCEPTED,
at=datetime.now(timezone.utc),
)],
services=["Служба 101"],
deadline_ms=30_000,
elapsed_ms=30_000,
)
assert ErrorCode.D1 not in [finding.code for finding in findings]
def test_d5_flags_comment_without_recipient_but_not_a_named_crew():
at = datetime.now(timezone.utc)
base = StatusEntry(
service="Служба 101",
status=ServiceStatus.ACCEPTED,
at=at,
comment="Основание: не обслуживаем.\nСведения: информация передана.",
)
findings = evaluate_dispatcher(
entries=[base],
services=["Служба 101"],
deadline_ms=30_000,
elapsed_ms=1_000,
)
d5 = next(finding for finding in findings if finding.code is ErrorCode.D5)
assert "получателя" in d5.summary
assert "передана" in d5.fact
complete = evaluate_dispatcher(
entries=[base.model_copy(update={"comment": "Основание: принято.\nСведения: карточка передана бригаде 12."})],
services=["Служба 101"],
deadline_ms=30_000,
elapsed_ms=1_000,
)
assert ErrorCode.D5 not in [finding.code for finding in complete]
def test_recipient_in_one_status_comment_does_not_mask_another_status_comment():
at = datetime.now(timezone.utc)
entries = [
StatusEntry(
service="Служба 101", status=ServiceStatus.ACCEPTED,
at=at, comment="Основание: карточка принята.\nСведения: переданы бригаде 12.",
),
StatusEntry(
service="Служба 101", status=ServiceStatus.RESPONDING,
at=at, comment="Основание: доклад.\nСведения: подтверждено начало движения.",
),
]
findings = evaluate_dispatcher(
entries=entries,
services=["Служба 101"],
crew_assignments={"Служба 101": "Бригада 12"},
deadline_ms=30_000,
elapsed_ms=1_000,
)
d5 = next(finding for finding in findings if finding.code is ErrorCode.D5)
assert "Начало реагирования" in d5.fact
assert "Бригаде 12 переданы сведения" not in d5.fact
def test_missing_manual_status_comment_has_a_d5_finding():
findings = evaluate_dispatcher(
entries=[StatusEntry(
service="Служба 101",
status=ServiceStatus.ACCEPTED,
at=datetime.now(timezone.utc),
)],
services=["Служба 101"],
deadline_ms=30_000,
elapsed_ms=1_000,
)
d5 = next(finding for finding in findings if finding.code is ErrorCode.D5)
assert "не заполнены комментарии" in d5.fact
def test_each_manual_status_comment_is_part_of_the_numeric_reply_metric():
at = datetime.now(timezone.utc)
state = SimpleNamespace(
managed_services=lambda: ["Служба 101"],
status_log=[
StatusEntry(
service="Служба 101", status=ServiceStatus.ACCEPTED,
at=at, comment="Основание: карточка принята.\nСведения: принято в работу.",
),
StatusEntry(
service="Служба 101", status=ServiceStatus.RESPONDING,
at=at, comment="",
),
],
crew_assignments={"Служба 101": "Бригада 1"},
dispatched_at=at,
)
reply = next(
metric for metric in dispatcher_metrics(state, 30_000)
if metric.key == "dds_reply"
)
assert not reply.passed
assert "к каждой ручной отметке" in reply.norm
findings = evaluate_dispatcher(
entries=state.status_log,
services=["Служба 101"],
crew_assignments=state.crew_assignments,
deadline_ms=30_000,
elapsed_ms=0,
)
d5 = next(finding for finding in findings if finding.code is ErrorCode.D5)
assert "Начало реагирования" in d5.fact
def test_d5_does_not_treat_address_house_number_as_recipient():
entry = StatusEntry(
service="Служба 101",
status=ServiceStatus.ACCEPTED,
at=datetime.now(timezone.utc),
comment="Основание: доклад.\nСведения: в доме 101 проведён осмотр.",
)
findings = evaluate_dispatcher(
entries=[entry],
services=["Служба 101"],
deadline_ms=30_000,
elapsed_ms=1_000,
)
assert ErrorCode.D5 in [finding.code for finding in findings]
def test_scenario_may_define_a_valid_decline_for_duplicate_or_territory():
at = datetime.now(timezone.utc)
entries = [StatusEntry(
service="Служба 101", status=ServiceStatus.DECLINED, at=at,
comment="Основание: дубль.\nСведения: передано в дежурную часть.",
)]
findings = evaluate_dispatcher(
entries=entries, services=["Служба 101"], deadline_ms=30_000,
elapsed_ms=1_000, expected_decision="decline",
expected_decision_reason="дублирующая карточка",
)
assert ErrorCode.D3 not in [finding.code for finding in findings]
state = SimpleNamespace(
managed_services=lambda: ["Служба 101"], status_log=entries,
crew_assignments={}, dispatched_at=at,
)
metrics = dispatcher_metrics(
state, 30_000, expected_decision="decline",
expected_decision_reason="дублирующая карточка",
)
decision = next(metric for metric in metrics if metric.key == "dds_decision")
assert decision.passed
assert "дублирующая карточка" in decision.norm
def test_nonempty_unstructured_comment_fails_reply_metric_but_structured_passes():
at = datetime.now(timezone.utc)
state = SimpleNamespace(
managed_services=lambda: ["Служба 101"],
status_log=[StatusEntry(
service="Служба 101", status=ServiceStatus.ACCEPTED,
at=at, comment="бригада на связи",
)],
crew_assignments={"Служба 101": "Бригада 12"},
dispatched_at=at,
)
reply = next(m for m in dispatcher_metrics(state, 30_000) if m.key == "dds_reply")
assert not reply.passed
findings = evaluate_dispatcher(
entries=state.status_log, services=["Служба 101"],
crew_assignments=state.crew_assignments,
deadline_ms=30_000, elapsed_ms=0,
)
assert any("не разделяют основание и сведения" in f.summary for f in findings)
state.status_log[0] = state.status_log[0].model_copy(update={
"comment": "Основание: доклад старшего.\nСведения: бригада на связи.",
})
reply = next(m for m in dispatcher_metrics(state, 30_000) if m.key == "dds_reply")
assert reply.passed
def test_accepting_card_with_scenario_expected_decline_is_explained():
findings = evaluate_dispatcher(
entries=[StatusEntry(
service="Служба 101", status=ServiceStatus.ACCEPTED,
at=datetime.now(timezone.utc),
)],
services=["Служба 101"], deadline_ms=30_000, elapsed_ms=1_000,
expected_decision="decline", expected_decision_reason="не наша территория",
)
decision_finding = next(
finding for finding in findings
if finding.code is ErrorCode.D2 and "вопреки эталону" in finding.summary
)
assert decision_finding.norm == "не наша территория"
def test_scenario_decline_expectation_requires_an_explicit_reason():
from app.scenarios.schema import DdsDecision
with pytest.raises(ValidationError, match="reason обязателен"):
DdsDecision(expected="decline")
assert DdsDecision(expected="decline", reason="дубль").expected == "decline"
def test_incomplete_work_path_has_d6_for_failed_progress_metrics():
at = datetime.now(timezone.utc)
entries = [
StatusEntry(service="Служба 101", status=ServiceStatus.ACCEPTED, at=at),
StatusEntry(service="Служба 101", status=ServiceStatus.RESPONDING, at=at),
]
state = SimpleNamespace(
managed_services=lambda: ["Служба 101"], status_log=entries,
crew_assignments={"Служба 101": "Бригада 1"}, dispatched_at=at,
)
metrics = dispatcher_metrics(state, 30_000)
assert not next(item for item in metrics if item.key == "dds_progress").passed
assert not next(item for item in metrics if item.key == "dds_completion").passed
findings = evaluate_dispatcher(
entries=entries, services=["Служба 101"],
crew_assignments={"Служба 101": "Бригада 1"},
deadline_ms=30_000, elapsed_ms=0,
)
d6 = next(finding for finding in findings if finding.code is ErrorCode.D6)
assert "Прибытие" in d6.fact and "Проведение работ" in d6.fact
def test_reasoned_refusal_after_acceptance_is_a_valid_terminal_path():
at = datetime.now(timezone.utc)
entries = [
StatusEntry(service="Служба 101", status=ServiceStatus.ACCEPTED, at=at),
StatusEntry(
service="Служба 101", status=ServiceStatus.REFUSED, at=at,
comment="Бригаде переданы сведения, выезд не выполнялся по причине угрозы.",
),
]
state = SimpleNamespace(
managed_services=lambda: ["Служба 101"], status_log=entries,
crew_assignments={"Служба 101": "Бригада 1"}, dispatched_at=at,
)
metrics = dispatcher_metrics(state, 30_000)
assert next(item for item in metrics if item.key == "dds_progress").passed
assert next(item for item in metrics if item.key == "dds_completion").passed
findings = evaluate_dispatcher(
entries=entries, services=["Служба 101"],
crew_assignments={"Служба 101": "Бригада 1"},
deadline_ms=30_000, elapsed_ms=0,
)
assert ErrorCode.D6 not in [finding.code for finding in findings]