feat: реагирование смежных служб в карточке ДДС и вводная «бригада просит смежников»
This commit is contained in:
parent
b7445a60fe
commit
de624d09f0
19 changed files with 1139 additions and 21 deletions
383
backend/tests/test_adjacent_services.py
Normal file
383
backend/tests/test_adjacent_services.py
Normal file
|
|
@ -0,0 +1,383 @@
|
|||
"""Смежные службы в карточке ДДС: таймлайн, вводная «бригада просит смежников»,
|
||||
метрики 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
|
||||
|
|
@ -103,9 +103,16 @@ def full_state() -> SessionState:
|
|||
text="Выехали", at=AT)],
|
||||
phone_lines=[PhoneLineRecord(service="01", crew=crew, speaker="crew",
|
||||
text="Выехали", at=AT)],
|
||||
phone_pending=PhoneCallPending(service="01", crew=crew, phase="arrived"),
|
||||
phone_pending=PhoneCallPending(service="01", crew=crew, phase="arrived",
|
||||
incoming=True, adjacent_service="Мособлгаз"),
|
||||
reply_text="Бригада направлена",
|
||||
reply_log=[(AT, "Бригада направлена")],
|
||||
adjacent_started_at=AT,
|
||||
adjacent_next_index=1,
|
||||
crew_request_at=AT,
|
||||
adjacent_reply_at=AT,
|
||||
adjacent_reply_claimed=True,
|
||||
adjacent_call_at=AT,
|
||||
)
|
||||
desk = DdsDesk(
|
||||
scenarios=[first, second],
|
||||
|
|
|
|||
|
|
@ -459,6 +459,7 @@ def test_instructor_criteria_change_timer_and_success_threshold(client, monkeypa
|
|||
"dds_card_work_time_limit_seconds": 180,
|
||||
"dds_report_reaction_enabled": False,
|
||||
"dds_report_reaction_limit_seconds": 20,
|
||||
"dds_adjacent_limit_seconds": 90,
|
||||
"allowed_errors": 50,
|
||||
"require_correct_grammar": False,
|
||||
"score_weights": {"dds_ack": 3.5},
|
||||
|
|
|
|||
Loading…
Reference in a new issue