lct-hack/backend/tests/test_adjacent_services.py

383 lines
15 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.

"""Смежные службы в карточке ДДС: таймлайн, вводная «бригада просит смежников»,
метрики dds_adjacent_check / dds_adjacent_call (issue 68)."""
from datetime import timedelta
from pathlib import Path
from uuid import uuid4
import pytest
from app.domain.events import AdjacentDial, CardAck, Exercise, PhoneReply, SessionMode
from app.domain.statuses import (
ADJACENT_AUTHOR,
ADJACENT_CALL_AUTHOR,
ServiceStatus,
)
from app.scenarios.loader import ScenarioError, load_file
from app.scoring.adjacent import adjacent_metrics
from app.session.dds import classify_reply, deliver_adjacent_due, prepare_queue
from app.session.state import SessionState
from app.session.timers import now_utc
LIBRARY = Path(__file__).resolve().parents[2] / "scenarios"
BASIS = "Основание: доклад старшего. Сведения: карточка принята."
REASSURE = "Мособлгаз отреагировал, аварийная бригада уже едет к вам"
DEFER = "Понял вас, уточню по карточке и свяжусь со смежной службой"
REQUEST = "Прошу обесточить и перекрыть газовый ввод по карточке, работает бригада"
def lesson(name: str) -> SessionState:
scenario = load_file(LIBRARY / f"{name}.yaml", LIBRARY)
state = SessionState(
session_id=uuid4(), scenario_id=scenario.id, scenario_title=scenario.title,
level=scenario.level.value, mode=SessionMode.TRAINING, exercise=Exercise.DDS,
)
prepare_queue(state, [scenario])
return state
def accepted(name: str) -> SessionState:
state = lesson(name)
assert state.desk.apply(CardAck(comment=BASIS), state).changed
return state
def advance(state: SessionState, seconds: int) -> None:
card = state.desk.active
card.adjacent_started_at -= timedelta(seconds=seconds)
if card.crew_request_at is not None:
card.crew_request_at -= timedelta(seconds=seconds)
deliver_adjacent_due(state)
# ── загрузка ──
def load_variant(tmp_path, patch: str, name: str = "broken.yaml"):
source = (LIBRARY / "dds-adjacent-react.yaml").read_text(encoding="utf-8")
(tmp_path / name).write_text(source.replace(*patch.split("=>", 1))
if "=>" in patch else source + patch,
encoding="utf-8")
return load_file(tmp_path / name, LIBRARY)
def test_library_variants_load():
react = load_file(LIBRARY / "dds-adjacent-react.yaml", LIBRARY)
silent = load_file(LIBRARY / "dds-adjacent-silent.yaml", LIBRARY)
assert react.crew_request is not None and silent.crew_request is not None
assert any(entry.service == "Мособлгаз" for entry in react.adjacent_timeline)
assert not any(entry.service == "Мособлгаз" for entry in silent.adjacent_timeline)
assert "Мособлгаз" in react.notify_services()
def test_timeline_service_outside_notify_fails_load(tmp_path):
with pytest.raises(ScenarioError, match="не входит в список оповещения"):
load_variant(tmp_path, "service: Мособлгаз=>service: Служба спасения кошек")
def test_crew_request_service_outside_notify_fails_load(tmp_path):
with pytest.raises(ScenarioError, match="crew_request"):
load_variant(tmp_path, " service: Мособлгаз\n text:=> service: Чужая служба\n text:")
def test_timeline_breaking_status_machine_fails_load(tmp_path):
with pytest.raises(ScenarioError, match="недопустим статус"):
load_variant(tmp_path, "status: accepted\n comment: \"Основание: карточка по пожару в"
"=>status: completed\n comment: \"Основание: карточка по пожару в")
def test_timeline_blank_comment_fails_load(tmp_path):
with pytest.raises(ScenarioError):
load_variant(
tmp_path,
'comment: "Основание: доклад аварийной бригады. Сведения: бригада выехала на перекрытие ввода."'
'=>comment: " "',
)
# ── доставка таймлайна ──
def test_timeline_waits_for_card_acceptance():
state = lesson("dds-adjacent-react")
assert not deliver_adjacent_due(state, now_utc() + timedelta(seconds=600))
assert state.desk.active.status_log == []
def test_timeline_marks_adjacent_services_with_simulation_author():
state = accepted("dds-adjacent-react")
advance(state, 60)
card = state.desk.active
marks = [entry for entry in card.status_log if entry.author == ADJACENT_AUTHOR]
assert [(entry.service, entry.status) for entry in marks] == [
("Мособлгаз", ServiceStatus.ACCEPTED),
("МВД", ServiceStatus.ACCEPTED),
("Мособлгаз", ServiceStatus.RESPONDING),
]
assert all(entry.comment.strip() for entry in marks)
def test_pause_shifts_timeline():
state = accepted("dds-adjacent-react")
now = now_utc()
state.desk.active.adjacent_started_at = now - timedelta(seconds=30)
state.pause_intervals.append((now - timedelta(seconds=25), now - timedelta(seconds=5)))
deliver_adjacent_due(state, now)
assert state.desk.active.adjacent_next_index == 0, "активных 10 с меньше 20 с таймлайна"
def test_snapshot_shows_adjacent_status_and_keeps_transitions_managed():
state = accepted("dds-adjacent-react")
advance(state, 60)
snapshot = state.station_snapshot()
assert snapshot.statuses["Мособлгаз"] is ServiceStatus.RESPONDING
assert "Мособлгаз" not in snapshot.available
assert set(snapshot.available) == set(snapshot.services)
# ── вводная ──
def test_crew_request_arrives_as_incoming_call():
state = accepted("dds-adjacent-silent")
advance(state, 50)
card = state.desk.active
call = card.phone_pending
assert call is not None and call.incoming
assert call.adjacent_service == "Мособлгаз"
assert card.crew_request_at is not None
assert card.phone_lines[-1].speaker == "crew"
assert "перекрыть ввод" in card.phone_lines[-1].text
def test_classify_reply_understands_reassure_defer_and_negation():
assert classify_reply(REASSURE) is True
assert classify_reply(DEFER) is False
assert classify_reply("Мособлгаз пока не реагирует, разберусь сам") is False
assert classify_reply("Так точно, понял") is None
# Утверждение реакции сильнее обещания уточнить, а «будут» само по себе
# не успокаивание.
assert classify_reply("Служба реагирует, бригада выехала, сообщу о прибытии") is True
assert classify_reply("Сведения будут позже, оставайтесь на связи") is None
def test_phone_reply_closes_incoming_call_and_records_claim():
state = accepted("dds-adjacent-silent")
advance(state, 50)
card = state.desk.active
outcome = state.desk.apply(PhoneReply(text=REASSURE), state)
assert outcome.error is None and outcome.changed
assert card.phone_pending is None
assert card.adjacent_reply_claimed is True
assert ("adjacent.reply", "reassure") in [
(action, detail) for action, _at, detail in card.dds_log]
def test_incoming_call_cannot_be_dropped_or_abandoned():
"""Сброс и закрытие карточки закрыли бы вводную без ответа навсегда."""
from app.domain.events import CardNext, PhoneHangup
state = accepted("dds-adjacent-silent")
advance(state, 50)
card = state.desk.active
hangup = state.desk.apply(PhoneHangup(), state)
closing = state.desk.apply(CardNext(card_id=card.card_id), state)
assert hangup.error is not None and "Ответьте бригаде" in hangup.error
assert closing.error == "Сначала ответьте бригаде на входящий вызов"
assert card.phone_pending is not None
def test_crew_request_waits_for_card_to_be_open():
"""Входящий вызов фоновой карточки курсант не увидел бы — вводная ждёт."""
scenario = load_file(LIBRARY / "dds-adjacent-silent.yaml", LIBRARY)
other = load_file(LIBRARY / "fire-apartment-l2.yaml", LIBRARY)
state = SessionState(
session_id=uuid4(), scenario_id=scenario.id, scenario_title=scenario.title,
level=scenario.level.value, mode=SessionMode.TRAINING, exercise=Exercise.DDS,
)
prepare_queue(state, [scenario, other])
first = state.desk.active
assert state.desk.apply(CardAck(comment=BASIS), state).changed
second = next(card for card in state.desk.ordered() if card is not first)
state.desk.open(second.card_id)
first.adjacent_started_at -= timedelta(seconds=60)
deliver_adjacent_due(state)
assert first.crew_request_at is None and first.phone_pending is None
state.desk.open(first.card_id)
deliver_adjacent_due(state)
assert first.crew_request_at is not None and first.phone_pending.incoming
def test_repeated_adjacent_dial_is_refused():
state = accepted("dds-adjacent-silent")
advance(state, 50)
state.desk.apply(PhoneReply(text=DEFER), state)
assert state.desk.apply(AdjacentDial(service="Мособлгаз", text=REQUEST), state).error is None
lines = len(state.desk.active.phone_lines)
outcome = state.desk.apply(AdjacentDial(service="Мособлгаз", text=REQUEST), state)
assert outcome.error == "Разговор с этой службой уже состоялся; сведения — в журнале"
assert len(state.desk.active.phone_lines) == lines
def test_phone_reply_without_incoming_call_is_refused():
state = accepted("dds-adjacent-silent")
outcome = state.desk.apply(PhoneReply(text=REASSURE), state)
assert outcome.error == "Нет входящего вызова бригады"
def test_adjacent_dial_requires_answering_crew_first():
state = accepted("dds-adjacent-silent")
advance(state, 50)
outcome = state.desk.apply(AdjacentDial(service="Мособлгаз", text=REQUEST), state)
assert outcome.error == "Сначала ответьте бригаде"
def test_adjacent_dial_scripts_answer_and_marks_status():
state = accepted("dds-adjacent-silent")
advance(state, 50)
state.desk.apply(PhoneReply(text=DEFER), state)
card = state.desk.active
outcome = state.desk.apply(AdjacentDial(service="Мособлгаз", text=REQUEST), state)
assert outcome.error is None and len(outcome.events) == 2
mark = card.status_log[-1]
assert (mark.service, mark.status, mark.author) == (
"Мособлгаз", ServiceStatus.ACCEPTED, ADJACENT_CALL_AUTHOR)
assert card.adjacent_call_at is not None
def test_adjacent_dial_to_own_or_unknown_service_is_refused():
state = accepted("dds-adjacent-silent")
advance(state, 50)
state.desk.apply(PhoneReply(text=DEFER), state)
for service in ("Служба 101", "Чужая служба"):
outcome = state.desk.apply(AdjacentDial(service=service, text=REQUEST), state)
assert outcome.error == "Звонить можно смежной службе из списка оповещения карточки"
# ── метрики ──
def metrics_by_key(state):
card = state.desk.active
metrics, findings = adjacent_metrics(
card, state.criteria.dds_adjacent_limit_seconds * 1000, state.pause_intervals)
return {metric.key: metric for metric in metrics}, findings
def test_no_request_no_metrics():
state = accepted("dds-adjacent-react")
metrics, findings = metrics_by_key(state)
assert metrics == {} and findings == []
def test_reassure_after_reaction_passes_check():
state = accepted("dds-adjacent-react")
advance(state, 90)
state.desk.apply(PhoneReply(text=REASSURE), state)
metrics, findings = metrics_by_key(state)
assert metrics["dds_adjacent_check"].passed
assert "dds_adjacent_call" not in metrics, "перестраховочная метрика не выставляется"
assert findings == []
def test_reassure_without_reaction_fails_check():
state = accepted("dds-adjacent-silent")
advance(state, 50)
state.desk.apply(PhoneReply(text=REASSURE), state)
metrics, findings = metrics_by_key(state)
assert not metrics["dds_adjacent_check"].passed
assert any("без сверки" in finding.summary for finding in findings)
def test_ignored_request_fails_metric_of_each_outcome():
react = accepted("dds-adjacent-react")
advance(react, 90)
silent = accepted("dds-adjacent-silent")
advance(silent, 50)
react_metrics, _ = metrics_by_key(react)
silent_metrics, _ = metrics_by_key(silent)
assert not react_metrics["dds_adjacent_check"].passed
assert not silent_metrics["dds_adjacent_call"].passed
def test_silent_service_called_in_time_passes():
state = accepted("dds-adjacent-silent")
advance(state, 50)
state.desk.apply(PhoneReply(text=DEFER), state)
state.desk.apply(AdjacentDial(service="Мособлгаз", text=REQUEST), state)
metrics, findings = metrics_by_key(state)
assert metrics["dds_adjacent_call"].passed
assert findings == []
def test_silent_service_never_called_fails_call_metric():
state = accepted("dds-adjacent-silent")
advance(state, 50)
state.desk.apply(PhoneReply(text=DEFER), state)
metrics, findings = metrics_by_key(state)
assert not metrics["dds_adjacent_call"].passed
assert any("без звонка" in finding.summary for finding in findings)
def test_call_after_limit_fails_call_metric():
state = accepted("dds-adjacent-silent")
advance(state, 50)
state.desk.apply(PhoneReply(text=DEFER), state)
state.desk.apply(AdjacentDial(service="Мособлгаз", text=REQUEST), state)
card = state.desk.active
card.crew_request_at -= timedelta(seconds=state.criteria.dds_adjacent_limit_seconds + 30)
card.adjacent_reply_at -= timedelta(seconds=state.criteria.dds_adjacent_limit_seconds + 30)
metrics, _ = metrics_by_key(state)
assert not metrics["dds_adjacent_call"].passed
def test_reassure_after_own_call_counts_as_reaction():
"""Курсант позвонил молчащей службе, статус появился — успокоить бригаду теперь можно."""
state = accepted("dds-adjacent-silent")
advance(state, 50)
state.desk.apply(PhoneReply(text=DEFER), state)
state.desk.apply(AdjacentDial(service="Мособлгаз", text=REQUEST), state)
card = state.desk.active
card.adjacent_reply_claimed = True
card.adjacent_reply_at = now_utc()
metrics, _ = metrics_by_key(state)
assert metrics["dds_adjacent_check"].passed