383 lines
15 KiB
Python
383 lines
15 KiB
Python
|
|
"""Смежные службы в карточке ДДС: таймлайн, вводная «бригада просит смежников»,
|
|||
|
|
метрики 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
|