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