Complete DDS training workflow and delivery package
This commit is contained in:
parent
68dd83c7c2
commit
4c4b91064f
229 changed files with 11969 additions and 1024 deletions
|
|
@ -1,13 +1,14 @@
|
|||
"""Готовая карточка → учебный звонок бригаде → числовая оценка ДДС."""
|
||||
|
||||
import time
|
||||
from datetime import datetime
|
||||
from uuid import uuid4
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from app.main import app
|
||||
from app.domain.timers import TimerCode
|
||||
from app.main import app
|
||||
from app.session.hub import hub
|
||||
|
||||
|
||||
|
|
@ -37,16 +38,39 @@ def read_until(socket, wanted):
|
|||
raise AssertionError(f"событие {wanted} не пришло")
|
||||
|
||||
|
||||
def start(client, exercise="dds"):
|
||||
def start(client, exercise="dds", criteria=None, dds_service=None, scenario_id="fire-apartment-l2"):
|
||||
session_id = uuid4()
|
||||
context = client.websocket_connect(f"/ws/control/{session_id}")
|
||||
control = context.__enter__()
|
||||
control.send_json({"type": "scenario.start", "scenario_id": "fire-apartment-l2",
|
||||
"trainee": "Иванов", "mode": "training", "exercise": exercise})
|
||||
payload = {"type": "scenario.start", "scenario_id": scenario_id,
|
||||
"trainee": "Иванов", "mode": "training", "exercise": exercise}
|
||||
if criteria is not None:
|
||||
payload["criteria"] = criteria
|
||||
if dds_service is not None:
|
||||
payload["dds_service"] = dds_service
|
||||
control.send_json(payload)
|
||||
wait_for(lambda: hub.get(session_id))
|
||||
return session_id, context
|
||||
|
||||
|
||||
def complete_phone_call(station, state, expected_phase):
|
||||
station.send_json({"type": "phone.dial"})
|
||||
greeting = read_until(station, "phone.line")
|
||||
assert greeting["speaker"] == "crew"
|
||||
read_until(station, "station.state")
|
||||
if expected_phase == "dispatched":
|
||||
station.send_json({"type": "phone.brief", "address": state.dispatched_card.address,
|
||||
"incident": state.scenario_title,
|
||||
"request": "Прошу подтвердить выезд и сообщить о прибытии"})
|
||||
else:
|
||||
station.send_json({"type": "phone.check", "text": "Сообщите текущую обстановку по карточке"})
|
||||
assert read_until(station, "phone.line")["speaker"] == "dispatcher"
|
||||
assert read_until(station, "phone.line")["speaker"] == "crew"
|
||||
report = read_until(station, "phone.report")
|
||||
assert report["phase"] == expected_phase
|
||||
return report
|
||||
|
||||
|
||||
def test_dds_starts_with_prepared_card_without_call(client):
|
||||
session_id, control = start(client)
|
||||
try:
|
||||
|
|
@ -62,10 +86,125 @@ def test_dds_starts_with_prepared_card_without_call(client):
|
|||
assert card["card"]["address"] == "улица Ленина, 14"
|
||||
snapshot = read_until(station, "station.state")["snapshot"]
|
||||
assert snapshot["crew_options"]
|
||||
assert len(snapshot["services"]) == 1
|
||||
assert snapshot["managed_service"] == snapshot["services"][0]
|
||||
assert set(snapshot["services"]) < set(snapshot["recipient_services"])
|
||||
finally:
|
||||
control.__exit__(None, None, None)
|
||||
|
||||
def test_dds_call_reports_gate_status_and_affect_numeric_score(client):
|
||||
|
||||
def test_ticket_dds_card_uses_source_caller_identity_and_phone(client):
|
||||
session_id, control = start(client, scenario_id="t01-1-fire-container")
|
||||
try:
|
||||
state = hub.get(session_id)
|
||||
assert state.kio.caller_name == "Сидоров Иван Сергеевич"
|
||||
assert state.kio.caller_number == "916-126-34-71"
|
||||
assert state.kio.caller_contact == "916-126-34-71"
|
||||
with client.websocket_connect(f"/ws/station/{session_id}") as station:
|
||||
card = read_until(station, "card.received")["card"]
|
||||
assert card["caller_name"] == "Сидоров Иван Сергеевич"
|
||||
assert card["caller_number"] == "916-126-34-71"
|
||||
assert card["caller_contact"] == "916-126-34-71"
|
||||
finally:
|
||||
control.__exit__(None, None, None)
|
||||
|
||||
|
||||
def test_ticket_with_multiple_witnesses_does_not_guess_caller_name(client):
|
||||
session_id, control = start(client, scenario_id="t20-1-three-fighting")
|
||||
try:
|
||||
state = hub.get(session_id)
|
||||
assert state.kio.caller_name is None
|
||||
assert state.kio.caller_number == "916-123-98-78"
|
||||
assert state.kio.caller_contact == "916-123-98-78"
|
||||
finally:
|
||||
control.__exit__(None, None, None)
|
||||
|
||||
|
||||
def test_dds_can_change_only_its_own_service_status(client):
|
||||
session_id, control = start(client, dds_service="МВД")
|
||||
try:
|
||||
with client.websocket_connect(f"/ws/station/{session_id}") as station:
|
||||
read_until(station, "card.received")
|
||||
snapshot = read_until(station, "station.state")["snapshot"]
|
||||
assert snapshot["services"] == ["МВД"]
|
||||
assert "Служба 101" in snapshot["recipient_services"]
|
||||
station.send_json({
|
||||
"type": "card.status", "service": "Служба 101", "status": "accepted",
|
||||
})
|
||||
assert "только своей ДДС" in read_until(station, "error")["message"]
|
||||
station.send_json({
|
||||
"type": "card.status", "service": "МВД", "status": "accepted",
|
||||
})
|
||||
assert read_until(station, "station.state")["snapshot"]["statuses"]["МВД"] == "accepted"
|
||||
finally:
|
||||
control.__exit__(None, None, None)
|
||||
|
||||
|
||||
def test_instructor_criteria_change_timer_and_success_threshold(client):
|
||||
session_id, control = start(client, criteria={
|
||||
"decision_time_limit_seconds": 45,
|
||||
"allowed_errors": 50,
|
||||
"require_correct_grammar": False,
|
||||
"score_weights": {"dds_ack": 3.5},
|
||||
})
|
||||
try:
|
||||
state = hub.get(session_id)
|
||||
assert state.criteria.decision_time_limit_seconds == 45
|
||||
assert state.timers.limits[TimerCode.DDS_ACK] == 45_000
|
||||
with client.websocket_connect(f"/ws/station/{session_id}") as station:
|
||||
card = read_until(station, "card.received")
|
||||
snapshot = read_until(station, "station.state")["snapshot"]
|
||||
service = snapshot["services"][0]
|
||||
station.send_json({"type": "card.status", "service": service, "status": "accepted"})
|
||||
read_until(station, "station.state")
|
||||
station.send_json({"type": "card.reply", "card_id": card["card"]["card_id"],
|
||||
"text": "Сообщение приняты, бригада направлено."})
|
||||
read_until(station, "station.state")
|
||||
station.send_json({"type": "station.finish"})
|
||||
read_until(station, "score.ready")
|
||||
|
||||
score = wait_for(lambda: hub.get(session_id).score)
|
||||
ack = next(item for item in score["metrics"] if item["key"] == "dds_ack")
|
||||
assert ack["norm"] == "≤ 45 с"
|
||||
assert ack["weight"] == 3.5
|
||||
assert not any(item["key"] in {"dds_reply", "dds_grammar"} for item in score["metrics"])
|
||||
report = client.get(f"/api/sessions/{session_id}/report").json()
|
||||
assert report["criteria"] == {
|
||||
"decision_time_limit_seconds": 45,
|
||||
"allowed_errors": 50,
|
||||
"require_correct_grammar": False,
|
||||
"score_weights": {"dds_ack": 3.5},
|
||||
}
|
||||
assert report["failed_metrics"] > 0
|
||||
assert report["passed"] is True
|
||||
finally:
|
||||
control.__exit__(None, None, None)
|
||||
|
||||
def test_dds_statuses_do_not_require_phone_reports(client):
|
||||
session_id, control = start(client)
|
||||
try:
|
||||
with client.websocket_connect(f"/ws/station/{session_id}") as station:
|
||||
read_until(station, "card.received")
|
||||
snapshot = read_until(station, "station.state")["snapshot"]
|
||||
service = snapshot["services"][0]
|
||||
station.send_json({"type": "card.status", "service": service, "status": "accepted"})
|
||||
read_until(station, "station.state")
|
||||
station.send_json({"type": "card.status", "service": service, "status": "responding"})
|
||||
assert read_until(station, "station.state")["snapshot"]["statuses"][service] == "responding"
|
||||
station.send_json({"type": "station.finish"})
|
||||
read_until(station, "score.ready")
|
||||
score = wait_for(lambda: hub.get(session_id).score)
|
||||
keys = {metric["key"] for metric in score["metrics"]}
|
||||
assert "dds_primary" in keys and "dds_progress" in keys
|
||||
assert "dds_contact" not in keys and "dds_crew" not in keys
|
||||
assert "answer_time" not in keys and "interview_time" not in keys
|
||||
assert 0 < score["score_auto"] < 100
|
||||
assert all(not finding["code"].startswith("E") for finding in score["findings"])
|
||||
finally:
|
||||
control.__exit__(None, None, None)
|
||||
|
||||
|
||||
def test_phone_dial_requires_brief_and_rejects_wrong_card_facts(client):
|
||||
session_id, control = start(client)
|
||||
try:
|
||||
with client.websocket_connect(f"/ws/station/{session_id}") as station:
|
||||
|
|
@ -75,25 +214,35 @@ def test_dds_call_reports_gate_status_and_affect_numeric_score(client):
|
|||
crew = next(option for option in snapshot["crew_options"] if option.startswith(service + " — "))
|
||||
station.send_json({"type": "card.status", "service": service, "status": "accepted"})
|
||||
read_until(station, "station.state")
|
||||
station.send_json({"type": "card.status", "service": service, "status": "responding"})
|
||||
assert "доклада" in read_until(station, "error")["message"]
|
||||
station.send_json({"type": "crew.select", "crew": crew})
|
||||
selected = read_until(station, "station.state")["snapshot"]
|
||||
assert selected["crew_selected"] == crew
|
||||
station.send_json({"type": "phone.dial"})
|
||||
report = read_until(station, "phone.report")
|
||||
assert report["phase"] == "dispatched" and report["crew"] == crew
|
||||
assert read_until(station, "station.state")["snapshot"]["phone_reports"]
|
||||
station.send_json({"type": "card.status", "service": service, "status": "responding"})
|
||||
read_until(station, "station.state")
|
||||
station.send_json({"type": "station.finish"})
|
||||
read_until(station, "score.ready")
|
||||
score = wait_for(lambda: hub.get(session_id).score)
|
||||
keys = {metric["key"] for metric in score["metrics"]}
|
||||
assert "dds_primary" in keys and "dds_contact" in keys
|
||||
assert "answer_time" not in keys and "interview_time" not in keys
|
||||
assert 0 < score["score_auto"] < 100
|
||||
assert all(not finding["code"].startswith("E") for finding in score["findings"])
|
||||
station.send_json({"type": "phone.dial"})
|
||||
assert read_until(station, "phone.line")["speaker"] == "crew"
|
||||
pending = read_until(station, "station.state")["snapshot"]
|
||||
assert pending["phone_pending"]["phase"] == "dispatched"
|
||||
assert not pending["phone_reports"]
|
||||
station.send_json({"type": "card.status", "service": service, "status": "responding"})
|
||||
assert read_until(station, "station.state")["snapshot"]["statuses"][service] == "responding"
|
||||
station.send_json({"type": "phone.brief", "address": "другая улица, дом 99",
|
||||
"incident": "Пожар в квартире",
|
||||
"request": "Прошу направить бригаду"})
|
||||
assert "адрес" in read_until(station, "error")["message"]
|
||||
station.send_json({"type": "phone.brief", "address": "улица Ленина, 14",
|
||||
"incident": "Ничего не произошло",
|
||||
"request": "Прошу направить бригаду"})
|
||||
assert "характер" in read_until(station, "error")["message"]
|
||||
station.send_json({"type": "phone.hangup"})
|
||||
assert read_until(station, "station.state")["snapshot"]["phone_pending"] is None
|
||||
assert not hub.get(session_id).phone_reports
|
||||
complete_phone_call(station, hub.get(session_id), "dispatched")
|
||||
read_until(station, "station.state")
|
||||
assert len(hub.get(session_id).phone_reports) == 1
|
||||
station.send_json({"type": "phone.dial"})
|
||||
read_until(station, "phone.line")
|
||||
read_until(station, "station.state")
|
||||
station.send_json({"type": "phone.check", "text": "Здравствуйте, хорошая погода"})
|
||||
assert "обстановку" in read_until(station, "error")["message"]
|
||||
assert len(hub.get(session_id).phone_reports) == 1
|
||||
finally:
|
||||
control.__exit__(None, None, None)
|
||||
|
||||
|
|
@ -108,26 +257,16 @@ def test_default_exercise_remains_call(client):
|
|||
assert state.dispatched_card is None
|
||||
|
||||
|
||||
def test_complete_dds_workflow_scores_without_call_penalties(client):
|
||||
def test_complete_dds_workflow_scores_100_without_any_call(client):
|
||||
session_id, control = start(client)
|
||||
try:
|
||||
with client.websocket_connect(f"/ws/station/{session_id}") as station:
|
||||
read_until(station, "card.received")
|
||||
snapshot = read_until(station, "station.state")["snapshot"]
|
||||
for service in snapshot["services"]:
|
||||
crew = next(option for option in snapshot["crew_options"]
|
||||
if option.startswith(service + " — "))
|
||||
station.send_json({"type": "card.status", "service": service, "status": "accepted"})
|
||||
read_until(station, "station.state")
|
||||
station.send_json({"type": "crew.select", "crew": crew})
|
||||
read_until(station, "station.state")
|
||||
for expected_phase, status in (
|
||||
("dispatched", "responding"), ("arrived", "arrived"),
|
||||
("working", "working"), ("completed", "completed"),
|
||||
):
|
||||
station.send_json({"type": "phone.dial"})
|
||||
assert read_until(station, "phone.report")["phase"] == expected_phase
|
||||
read_until(station, "station.state")
|
||||
for status in ("responding", "arrived", "working", "completed"):
|
||||
station.send_json({"type": "card.status", "service": service, "status": status})
|
||||
snapshot = read_until(station, "station.state")["snapshot"]
|
||||
assert snapshot["card"] == "completed"
|
||||
|
|
@ -137,11 +276,13 @@ def test_complete_dds_workflow_scores_without_call_penalties(client):
|
|||
assert score["score_auto"] == 100.0
|
||||
assert not score["findings"]
|
||||
assert all(metric["key"].startswith("dds_") for metric in score["metrics"])
|
||||
assert not any(metric["key"] in {"dds_contact", "dds_crew", "dds_reply", "dds_grammar"}
|
||||
for metric in score["metrics"])
|
||||
finally:
|
||||
control.__exit__(None, None, None)
|
||||
|
||||
|
||||
def test_sequential_dds_cards_keep_separate_state_and_scores(client):
|
||||
def test_concurrent_dds_cards_keep_independent_state_and_timers(client):
|
||||
session_id = uuid4()
|
||||
control_ctx = client.websocket_connect(f"/ws/control/{session_id}")
|
||||
control = control_ctx.__enter__()
|
||||
|
|
@ -157,37 +298,53 @@ def test_sequential_dds_cards_keep_separate_state_and_scores(client):
|
|||
first_card_id = first["card"]["card_id"]
|
||||
assert (first["card_index"], first["card_total"]) == (1, 2)
|
||||
snapshot = read_until(station, "station.state")["snapshot"]
|
||||
service = snapshot["services"][0]
|
||||
station.send_json({"type": "card.status", "service": service, "status": "accepted"})
|
||||
read_until(station, "station.state")
|
||||
station.send_json({"type": "card.reply", "card_id": first_card_id,
|
||||
"text": "Сообщение принято, дежурная бригада направлена на место."})
|
||||
assert read_until(station, "station.state")["snapshot"]["reply_text"].startswith("Сообщение")
|
||||
station.send_json({"type": "card.next", "card_id": first_card_id})
|
||||
assert len(snapshot["queue_cards"]) == 2
|
||||
first_row, second_row = snapshot["queue_cards"]
|
||||
second_card_id = second_row["card_id"]
|
||||
assert first_row["active"] is True and second_row["active"] is False
|
||||
assert abs(
|
||||
datetime.fromisoformat(first_row["received_at"]).timestamp()
|
||||
- datetime.fromisoformat(second_row["received_at"]).timestamp()
|
||||
) < 1
|
||||
first_service = snapshot["services"][0]
|
||||
station.send_json({"type": "card.status", "service": first_service,
|
||||
"status": "accepted"})
|
||||
snapshot = read_until(station, "station.state")["snapshot"]
|
||||
first_elapsed = next(item for item in snapshot["queue_cards"]
|
||||
if item["card_id"] == first_card_id)["elapsed_ms"]
|
||||
|
||||
time.sleep(0.03)
|
||||
# Card switching must publish its own fresh station snapshot; do
|
||||
# not let the one-second ticker mask a missing update.
|
||||
hub.stop_ticker(session_id)
|
||||
station.send_json({"type": "card.open", "card_id": second_card_id})
|
||||
second = read_until(station, "card.received")
|
||||
second_card_id = second["card"]["card_id"]
|
||||
assert second_card_id != first_card_id
|
||||
assert second["card"]["card_id"] == second_card_id
|
||||
assert second["card"]["incident_type"] == "medical"
|
||||
assert (second["card_index"], second["card_total"]) == (2, 2)
|
||||
snapshot = read_until(station, "station.state")["snapshot"]
|
||||
assert snapshot["reply_text"] == ""
|
||||
assert snapshot["statuses"].get(service) == "added" or service not in snapshot["statuses"]
|
||||
assert len(snapshot["completed_cards"]) == 1
|
||||
assert snapshot["completed_cards"][0]["card_id"] == first_card_id
|
||||
station.send_json({"type": "card.reply", "card_id": first_card_id,
|
||||
"text": "Запоздалый ответ к прошлой карточке"})
|
||||
assert "не к текущей" in read_until(station, "error")["message"]
|
||||
assert hub.get(session_id).reply_text == ""
|
||||
station.send_json({"type": "card.next", "card_id": first_card_id})
|
||||
assert "ID" in read_until(station, "error")["message"]
|
||||
assert hub.get(session_id).dds_card_index == 1
|
||||
second_queue_row = next(item for item in snapshot["queue_cards"]
|
||||
if item["card_id"] == second_card_id)
|
||||
assert second_queue_row["active"] is True
|
||||
assert second_queue_row["elapsed_ms"] >= first_elapsed
|
||||
first_queue_row = next(item for item in snapshot["queue_cards"]
|
||||
if item["card_id"] == first_card_id)
|
||||
assert first_queue_row["service_status"] == "accepted"
|
||||
assert first_queue_row["timer_stopped"] is True
|
||||
|
||||
with client.websocket_connect(f"/ws/station/{session_id}") as station:
|
||||
assert read_until(station, "card.received")["card"]["card_id"] == second_card_id
|
||||
assert read_until(station, "station.state")["snapshot"]["card_index"] == 2
|
||||
station.send_json({"type": "card.reply", "card_id": second_card_id,
|
||||
"text": "Сообщение принято, бригада направлена на место происшествия."})
|
||||
second_service = snapshot["services"][0]
|
||||
station.send_json({"type": "card.status", "service": second_service,
|
||||
"status": "accepted"})
|
||||
read_until(station, "station.state")
|
||||
station.send_json({"type": "card.open", "card_id": first_card_id})
|
||||
assert read_until(station, "card.received")["card"]["card_id"] == first_card_id
|
||||
restored = read_until(station, "station.state")["snapshot"]
|
||||
assert restored["statuses"][first_service] == "accepted"
|
||||
station.send_json({"type": "card.next", "card_id": first_card_id})
|
||||
assert read_until(station, "card.received")["card"]["card_id"] == second_card_id
|
||||
after_close = read_until(station, "station.state")["snapshot"]
|
||||
assert len(after_close["queue_cards"]) == 1
|
||||
assert after_close["statuses"][second_service] == "accepted"
|
||||
station.send_json({"type": "card.next", "card_id": second_card_id})
|
||||
read_until(station, "score.ready")
|
||||
|
||||
|
|
@ -196,20 +353,22 @@ def test_sequential_dds_cards_keep_separate_state_and_scores(client):
|
|||
assert len(state.score["card_results"]) == 2
|
||||
assert state.score["card_results"][0]["scenario_id"] == "fire-apartment-l2"
|
||||
assert state.score["card_results"][1]["scenario_id"] == "t20-2-stroke"
|
||||
assert {item["key"] for item in state.score["metrics"]} >= {"dds_reply", "dds_primary"}
|
||||
assert "dds_primary" in {item["key"] for item in state.score["metrics"]}
|
||||
assert not any(item["key"] in {"dds_reply", "dds_grammar"}
|
||||
for item in state.score["metrics"])
|
||||
assert all(item["code"].startswith("D") for item in state.score["findings"])
|
||||
report = client.get(f"/api/sessions/{session_id}/report").json()
|
||||
assert report["scenario_id"] == "fire-apartment-l2"
|
||||
assert len(report["card_results"]) == 2
|
||||
assert report["missed_checklist"] == [] and report["reference_questions"] == []
|
||||
first_actions = report["card_results"][0]["actions"]
|
||||
assert {item["type"] for item in first_actions} >= {"card.status", "card.reply"}
|
||||
assert {item["type"] for item in first_actions} >= {"card.status"}
|
||||
assert report["card_results"][0]["duration_ms"] >= 0
|
||||
finally:
|
||||
control_ctx.__exit__(None, None, None)
|
||||
|
||||
|
||||
def test_instructor_can_end_multi_card_early_without_grading_future_cards(client):
|
||||
def test_instructor_end_grades_all_concurrently_issued_cards(client):
|
||||
session_id = uuid4()
|
||||
control_ctx = client.websocket_connect(f"/ws/control/{session_id}")
|
||||
control = control_ctx.__enter__()
|
||||
|
|
@ -227,8 +386,10 @@ def test_instructor_can_end_multi_card_early_without_grading_future_cards(client
|
|||
read_until(station, "score.ready")
|
||||
state = hub.get(session_id)
|
||||
assert state.ended
|
||||
assert len(state.score["card_results"]) == 1
|
||||
assert state.score["card_results"][0]["scenario_id"] == "fire-apartment-l2"
|
||||
assert len(state.score["card_results"]) == 2
|
||||
assert [item["scenario_id"] for item in state.score["card_results"]] == [
|
||||
"fire-apartment-l2", "t20-2-stroke",
|
||||
]
|
||||
finally:
|
||||
control_ctx.__exit__(None, None, None)
|
||||
|
||||
|
|
@ -246,8 +407,8 @@ def test_each_dds_card_uses_its_own_scenario_weights():
|
|||
base = load_file(root / "fire-apartment-l2.yaml", root)
|
||||
first = base.model_copy(deep=True)
|
||||
second = base.model_copy(deep=True)
|
||||
first.score_weights = {"dds_reply": 7.0}
|
||||
second.score_weights = {"dds_reply": 2.0}
|
||||
first.score_weights = {"dds_primary": 7.0}
|
||||
second.score_weights = {"dds_primary": 2.0}
|
||||
state = SessionState(
|
||||
session_id=uuid4(), scenario_id=base.id, scenario_title=base.title,
|
||||
level=base.level.value, mode=SessionMode.TRAINING, exercise=Exercise.DDS,
|
||||
|
|
@ -258,5 +419,5 @@ def test_each_dds_card_uses_its_own_scenario_weights():
|
|||
state.dds_card_index = 1
|
||||
prepare_card(state, second)
|
||||
second_record = score_current_dds(state)
|
||||
assert next(item.weight for item in first_record.metrics if item.key == "dds_reply") == 7.0
|
||||
assert next(item.weight for item in second_record.metrics if item.key == "dds_reply") == 2.0
|
||||
assert next(item.weight for item in first_record.metrics if item.key == "dds_primary") == 7.0
|
||||
assert next(item.weight for item in second_record.metrics if item.key == "dds_primary") == 2.0
|
||||
|
|
|
|||
Loading…
Reference in a new issue