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
|
|
@ -5,6 +5,7 @@
|
|||
"""
|
||||
|
||||
import uuid
|
||||
from xml.etree import ElementTree as ET
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
|
@ -61,12 +62,29 @@ def test_instructor_cannot_open_admin(as_instructor):
|
|||
assert as_instructor.get("/api/admin/users").status_code == 403
|
||||
assert as_instructor.get("/api/admin/audit").status_code == 403
|
||||
assert as_instructor.get("/api/admin/status").status_code == 403
|
||||
assert as_instructor.get("/api/admin/diagnostics").status_code == 403
|
||||
assert as_instructor.get("/api/admin/config.xml").status_code == 403
|
||||
|
||||
|
||||
def test_anonymous_cannot_open_admin(client):
|
||||
assert client.get("/api/admin/users").status_code == 401
|
||||
|
||||
|
||||
def test_admin_downloads_safe_xml_configuration(as_admin):
|
||||
response = as_admin.get("/api/admin/config.xml")
|
||||
assert response.status_code == 200
|
||||
assert response.headers["content-type"].startswith("application/xml")
|
||||
assert "attachment" in response.headers["content-disposition"]
|
||||
root = ET.fromstring(response.content)
|
||||
assert root.tag == "lctConfiguration"
|
||||
assert root.find("./workstations/workstation[@role='admin']") is not None
|
||||
assert root.find("./timerLimits/timer[@code='dds_ack']") is not None
|
||||
lowered = response.content.lower()
|
||||
assert b"session_secret" not in lowered
|
||||
assert b"llm_api_key" not in lowered
|
||||
assert b"database_url" not in lowered
|
||||
|
||||
|
||||
def test_admin_has_no_way_to_change_a_score():
|
||||
"""Запрет выражен отсутствием точки, а не проверкой внутри неё: в модуле
|
||||
администратора нет ни одного маршрута, трогающего оценки."""
|
||||
|
|
@ -90,6 +108,39 @@ def test_admin_cannot_write_to_the_audit_log():
|
|||
]
|
||||
|
||||
|
||||
def test_audit_api_applies_actor_action_and_offset_filters(as_admin):
|
||||
"""The admin UI must be able to reach older records, not just page zero."""
|
||||
from app.api.http import admin as admin_module
|
||||
|
||||
class FakeDb:
|
||||
statement = None
|
||||
|
||||
async def scalars(self, statement):
|
||||
self.statement = statement
|
||||
return []
|
||||
|
||||
fake_db = FakeDb()
|
||||
|
||||
async def fake_session():
|
||||
yield fake_db
|
||||
|
||||
app.dependency_overrides[admin_module.get_session] = fake_session
|
||||
try:
|
||||
response = as_admin.get(
|
||||
"/api/admin/audit",
|
||||
params={"action": "login.failed", "actor": "alice", "limit": 201, "offset": 400},
|
||||
)
|
||||
finally:
|
||||
app.dependency_overrides.pop(admin_module.get_session, None)
|
||||
|
||||
assert response.status_code == 200, response.text
|
||||
statement = fake_db.statement
|
||||
assert statement._limit_clause.value == 201
|
||||
assert statement._offset_clause.value == 400
|
||||
assert "audit_log.action" in str(statement.whereclause)
|
||||
assert "audit_log.actor" in str(statement.whereclause)
|
||||
|
||||
|
||||
# ── учётные записи ──
|
||||
|
||||
|
||||
|
|
@ -170,7 +221,39 @@ def test_status_names_every_component(as_admin):
|
|||
|
||||
names = {item["name"] for item in as_admin.get("/api/admin/status").json()}
|
||||
assert {"База данных", "Модели речи", "Эмбеддинги", "Провайдер LLM",
|
||||
"Классификатор ЕКП", "Секрет сессии"} <= names
|
||||
"Классификатор ЕКП", "Резервное копирование", "Секрет сессии",
|
||||
"Нагрузка backend"} <= names
|
||||
|
||||
|
||||
def test_admin_gets_live_metrics_and_downloadable_failure_report(as_admin):
|
||||
if not db_alive(as_admin):
|
||||
pytest.skip("нет базы")
|
||||
|
||||
response = as_admin.get("/api/admin/diagnostics")
|
||||
assert response.status_code == 200, response.text
|
||||
body = response.json()
|
||||
assert body["metrics"]["cpu_cores"] >= 1
|
||||
assert body["metrics"]["disk_total_bytes"] > body["metrics"]["disk_free_bytes"]
|
||||
assert body["metrics"]["active_sessions"] >= 0
|
||||
assert isinstance(body["recent_system_events"], list)
|
||||
assert isinstance(body["failed_actions_24h"], list)
|
||||
|
||||
download = as_admin.get("/api/admin/diagnostics.json")
|
||||
assert download.status_code == 200
|
||||
assert "attachment" in download.headers["content-disposition"]
|
||||
|
||||
|
||||
def test_diagnostic_journal_redacts_credentials():
|
||||
import logging
|
||||
|
||||
from app.monitoring import recent_events
|
||||
|
||||
logging.getLogger("app.redaction-test").warning(
|
||||
"failure postgresql://user:secret@db/lct SESSION_SECRET=never-show"
|
||||
)
|
||||
event = recent_events(limit=1)[0]
|
||||
assert "user:secret@" not in event["message"]
|
||||
assert "never-show" not in event["message"]
|
||||
|
||||
|
||||
def test_default_session_secret_is_reported_as_a_problem(as_admin):
|
||||
|
|
@ -191,6 +274,32 @@ def test_backup_listing_is_open_to_admin(as_admin):
|
|||
assert as_admin.get("/api/admin/backups").status_code == 200
|
||||
|
||||
|
||||
def test_backup_listing_failure_does_not_break_admin_status(as_admin, monkeypatch):
|
||||
from app.admin import backup as backup_service
|
||||
|
||||
def broken():
|
||||
raise backup_service.BackupError("каталог временно недоступен")
|
||||
|
||||
monkeypatch.setattr(backup_service, "listing", broken)
|
||||
response = as_admin.get("/api/admin/status")
|
||||
assert response.status_code == 200, response.text
|
||||
backup = next(item for item in response.json() if item["name"] == "Резервное копирование")
|
||||
assert not backup["ok"]
|
||||
assert "каталог временно недоступен" in backup["detail"]
|
||||
|
||||
|
||||
def test_backup_list_returns_503_on_storage_error(as_admin, monkeypatch):
|
||||
from app.admin import backup as backup_service
|
||||
|
||||
def broken():
|
||||
raise backup_service.BackupError("каталог временно недоступен")
|
||||
|
||||
monkeypatch.setattr(backup_service, "listing", broken)
|
||||
response = as_admin.get("/api/admin/backups")
|
||||
assert response.status_code == 503
|
||||
assert "каталог временно недоступен" in response.json()["detail"]
|
||||
|
||||
|
||||
def test_backup_failure_explains_what_is_missing(as_admin, monkeypatch):
|
||||
"""Кнопка не должна молча ничего не делать: если снять копию нечем,
|
||||
администратор видит, чего именно не хватает."""
|
||||
|
|
@ -203,3 +312,126 @@ def test_backup_failure_explains_what_is_missing(as_admin, monkeypatch):
|
|||
response = as_admin.post("/api/admin/backups")
|
||||
assert response.status_code == 503
|
||||
assert "pg_dump" in response.json()["detail"]
|
||||
|
||||
|
||||
def test_pg17_transaction_timeout_is_removed_from_pg16_backup(monkeypatch, tmp_path):
|
||||
"""Образ backend может содержать клиент новее сервера из Compose."""
|
||||
from app.admin import backup as backup_service
|
||||
|
||||
monkeypatch.setattr(backup_service, "DIR", tmp_path)
|
||||
monkeypatch.setattr(backup_service, "_dsn", lambda: ("u", "p", "db", "5432", "lct"))
|
||||
monkeypatch.setattr(backup_service.shutil, "which", lambda name: "/usr/bin/pg_dump")
|
||||
|
||||
class Result:
|
||||
returncode = 0
|
||||
stderr = b""
|
||||
stdout = (b"--\n-- PostgreSQL database dump\n--\n\n"
|
||||
b"SET transaction_timeout = 0;\nCREATE TABLE demo(id integer);\n")
|
||||
|
||||
monkeypatch.setattr(backup_service.subprocess, "run", lambda *args, **kwargs: Result())
|
||||
made = backup_service.create()
|
||||
content = (tmp_path / made["name"]).read_bytes()
|
||||
assert b"transaction_timeout" not in content
|
||||
assert b"CREATE TABLE demo" in content
|
||||
|
||||
|
||||
def test_docker_backup_does_not_put_database_password_in_process_arguments(monkeypatch, tmp_path):
|
||||
import base64
|
||||
from types import SimpleNamespace
|
||||
|
||||
from app.admin import backup as backup_service
|
||||
|
||||
password = "secret p@ss:word"
|
||||
monkeypatch.setattr(backup_service, "DIR", tmp_path)
|
||||
monkeypatch.setattr(backup_service, "_dsn", lambda: ("backup", password, "db", "5432", "lct"))
|
||||
monkeypatch.setattr(
|
||||
backup_service.shutil, "which", lambda name: "/usr/bin/docker" if name == "docker" else None
|
||||
)
|
||||
captured = {}
|
||||
|
||||
def fake_run(command, **kwargs):
|
||||
captured["command"] = command
|
||||
captured["input"] = kwargs["input"]
|
||||
return SimpleNamespace(
|
||||
returncode=0,
|
||||
stderr=b"",
|
||||
stdout=b"--\n-- PostgreSQL database dump\nCREATE TABLE demo(id integer);\n",
|
||||
)
|
||||
|
||||
monkeypatch.setattr(backup_service.subprocess, "run", fake_run)
|
||||
backup_service.create()
|
||||
|
||||
assert password not in " ".join(captured["command"])
|
||||
assert captured["input"] == base64.b64encode(password.encode()) + b"\n"
|
||||
|
||||
|
||||
def test_backup_dsn_decodes_escaped_credentials_without_exposing_them(monkeypatch):
|
||||
from types import SimpleNamespace
|
||||
from app.admin import backup as backup_service
|
||||
|
||||
monkeypatch.setattr(
|
||||
backup_service, "get_settings",
|
||||
lambda: SimpleNamespace(database_url="postgresql://backup:p%40ss%3Aword@db.example:5433/lct"),
|
||||
)
|
||||
assert backup_service._dsn() == ("backup", "p@ss:word", "db.example", "5433", "lct")
|
||||
|
||||
monkeypatch.setattr(
|
||||
backup_service, "get_settings",
|
||||
lambda: SimpleNamespace(database_url="not-a-valid-url-with-secret"),
|
||||
)
|
||||
try:
|
||||
backup_service._dsn()
|
||||
except backup_service.BackupError as exc:
|
||||
assert "secret" not in str(exc)
|
||||
else:
|
||||
raise AssertionError("invalid DATABASE_URL must be rejected")
|
||||
|
||||
|
||||
def test_backup_directory_failure_is_retryable_backup_error(monkeypatch, tmp_path):
|
||||
from app.admin import backup as backup_service
|
||||
|
||||
blocker = tmp_path / "not-a-directory"
|
||||
blocker.write_text("occupied")
|
||||
monkeypatch.setattr(backup_service, "DIR", blocker / "backups")
|
||||
monkeypatch.setattr(backup_service, "_dsn", lambda: ("u", "p", "db", "5432", "lct"))
|
||||
|
||||
with pytest.raises(backup_service.BackupError, match="каталог резервных копий"):
|
||||
backup_service.create()
|
||||
|
||||
|
||||
def test_backup_listing_io_failure_is_retryable_error(monkeypatch, tmp_path):
|
||||
from app.admin import backup as backup_service
|
||||
|
||||
monkeypatch.setattr(backup_service, "DIR", tmp_path)
|
||||
|
||||
def denied(_path):
|
||||
raise PermissionError("access denied")
|
||||
|
||||
monkeypatch.setattr(backup_service.os, "scandir", denied)
|
||||
with pytest.raises(backup_service.BackupError, match="прочитать каталог"):
|
||||
backup_service.listing()
|
||||
|
||||
|
||||
def test_windows_backup_permissions_use_explicit_private_acl(monkeypatch, tmp_path):
|
||||
from types import SimpleNamespace
|
||||
from app.admin import backup as backup_service
|
||||
|
||||
monkeypatch.setattr(backup_service, "getuser", lambda: "operator")
|
||||
monkeypatch.setenv("USERDOMAIN", "TRAINING")
|
||||
monkeypatch.setenv("SystemRoot", str(tmp_path))
|
||||
called = {}
|
||||
|
||||
def fake_run(command, **kwargs):
|
||||
called["command"] = command
|
||||
called["kwargs"] = kwargs
|
||||
return SimpleNamespace(returncode=0)
|
||||
|
||||
monkeypatch.setattr(backup_service.subprocess, "run", fake_run)
|
||||
backup_service._restrict_file_permissions(-1, tmp_path / "copy.sql", platform_name="nt")
|
||||
|
||||
command = called["command"]
|
||||
assert command[0].endswith("icacls.exe")
|
||||
assert "/inheritance:r" in command
|
||||
assert "TRAINING\\operator:(F)" in command
|
||||
assert "*S-1-5-18:(F)" in command
|
||||
assert called["kwargs"]["timeout"] == 15
|
||||
|
|
|
|||
|
|
@ -56,6 +56,17 @@ def test_unknown_login_and_wrong_password_look_the_same(client):
|
|||
assert first.json()["detail"] == "bad_credentials"
|
||||
|
||||
|
||||
def test_login_rejects_values_outside_database_and_hash_bounds(client):
|
||||
too_long_login = client.post(
|
||||
"/api/auth/login", json={"login": "a" * 81, "password": "not-used"}
|
||||
)
|
||||
too_long_password = client.post(
|
||||
"/api/auth/login", json={"login": "operator", "password": "x" * 1025}
|
||||
)
|
||||
assert too_long_login.status_code == 422
|
||||
assert too_long_password.status_code == 422
|
||||
|
||||
|
||||
def test_me_requires_authentication(client):
|
||||
assert client.get("/api/auth/me").status_code == 401
|
||||
|
||||
|
|
|
|||
|
|
@ -18,6 +18,10 @@ from app.session.hub import hub
|
|||
|
||||
@pytest.fixture
|
||||
def client():
|
||||
# Each TestClient represents a fresh backend process. In particular,
|
||||
# revocations exercised by a previous test must not leak into the next
|
||||
# process' in-memory cache.
|
||||
auth.prime_generations({})
|
||||
with TestClient(app) as test_client:
|
||||
hub.journal = None
|
||||
yield test_client
|
||||
|
|
@ -49,6 +53,12 @@ def test_cookie_from_previous_process_is_rejected(client, monkeypatch):
|
|||
assert client.get("/api/auth/me").status_code == 401
|
||||
|
||||
|
||||
def test_cookie_survives_generation_cache_reload_when_account_is_unchanged(client):
|
||||
assert client.post("/api/auth/dev-token").status_code == 200
|
||||
auth.prime_generations({"dev": 0})
|
||||
assert client.get("/api/auth/me").status_code == 200
|
||||
|
||||
|
||||
class FakeDb:
|
||||
def __init__(self, user):
|
||||
self.user = user
|
||||
|
|
@ -74,7 +84,7 @@ def fake_user(login="victim", role="instructor"):
|
|||
return SimpleNamespace(
|
||||
id=uuid4(), login=login, full_name="Проверка", role=role,
|
||||
service=None, trainee_id=None, blocked=False,
|
||||
password_hash="old", created_at=datetime.now(timezone.utc),
|
||||
password_hash="old", auth_version=0, created_at=datetime.now(timezone.utc),
|
||||
)
|
||||
|
||||
|
||||
|
|
@ -86,7 +96,10 @@ async def test_admin_patch_revokes_cookie_after_commit(monkeypatch):
|
|||
monkeypatch.setattr(admin, "require", lambda _request, *_roles: auth.Principal(
|
||||
login="admin", full_name="Администратор", role=Role.ADMIN,
|
||||
))
|
||||
monkeypatch.setattr(admin, "invalidate_login", lambda login: calls.append((login, db.commits)))
|
||||
monkeypatch.setattr(
|
||||
admin, "invalidate_login",
|
||||
lambda login, version=None: calls.append((login, version, db.commits)),
|
||||
)
|
||||
|
||||
async def no_audit(*_args, **_kwargs):
|
||||
return None
|
||||
|
|
@ -94,7 +107,7 @@ async def test_admin_patch_revokes_cookie_after_commit(monkeypatch):
|
|||
monkeypatch.setattr(admin, "audit", no_audit)
|
||||
await admin.patch_user(user.id, admin.UserPatch(blocked=True), object(), db)
|
||||
assert user.blocked is True
|
||||
assert calls == [("victim", 1)]
|
||||
assert calls == [("victim", 1, 1)]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
|
|
|
|||
20
backend/tests/test_backup_scheduler.py
Normal file
20
backend/tests/test_backup_scheduler.py
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
from scripts import backup_loop
|
||||
|
||||
|
||||
def test_no_backup_is_due_immediately(monkeypatch):
|
||||
monkeypatch.setattr(backup_loop, "listing", lambda: [])
|
||||
assert backup_loop.seconds_until_due(datetime.now(timezone.utc), 86_400) == 0
|
||||
|
||||
|
||||
def test_fresh_backup_waits_only_for_remaining_interval(monkeypatch):
|
||||
now = datetime.now(timezone.utc)
|
||||
monkeypatch.setattr(backup_loop, "listing", lambda: [{"at": now - timedelta(hours=6)}])
|
||||
assert backup_loop.seconds_until_due(now, 86_400) == 18 * 3600
|
||||
|
||||
|
||||
def test_overdue_backup_is_due_immediately(monkeypatch):
|
||||
now = datetime.now(timezone.utc)
|
||||
monkeypatch.setattr(backup_loop, "listing", lambda: [{"at": now - timedelta(days=2)}])
|
||||
assert backup_loop.seconds_until_due(now, 86_400) == 0
|
||||
|
|
@ -160,6 +160,11 @@ def test_filled_112_card_continues_as_dds_in_the_same_lesson(client):
|
|||
station.send_json({"type": "crew.select", "crew": crew})
|
||||
read_until(station, "station.state")
|
||||
station.send_json({"type": "phone.dial"})
|
||||
assert read_until(station, "phone.line")["speaker"] == "crew"
|
||||
read_until(station, "station.state")
|
||||
station.send_json({"type": "phone.brief", "address": "улица Ленина, 14",
|
||||
"incident": "Пожар в квартире",
|
||||
"request": "Прошу подтвердить выезд и доложить о прибытии"})
|
||||
assert read_until(station, "phone.report")["phase"] == "dispatched"
|
||||
read_until(station, "station.state")
|
||||
station.send_json({"type": "card.status", "service": service, "status": "responding"})
|
||||
|
|
@ -170,7 +175,8 @@ def test_filled_112_card_continues_as_dds_in_the_same_lesson(client):
|
|||
score = wait_for(lambda: hub.get(session_id).score)
|
||||
assert hub.get(session_id).ended
|
||||
keys = {item["key"] for item in score["metrics"]}
|
||||
assert {"address", "incident_signs", "dds_primary", "dds_contact"} <= keys
|
||||
assert {"address", "incident_signs", "dds_primary", "dds_progress"} <= keys
|
||||
assert "dds_contact" not in keys
|
||||
assert score["score_auto"] < 100
|
||||
finally:
|
||||
control.__exit__(None, None, None)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -50,7 +50,8 @@ def test_demo_without_db_starts_dds_and_issues_owned_trainee_cookie(monkeypatch)
|
|||
assert client.post("/api/auth/dev-token?role=trainee").json()["trainee_id"] == str(DEMO_TRAINEE_ID)
|
||||
assert client.post("/api/auth/dev-token").json()["role"] == "instructor"
|
||||
assert client.get("/api/trainees").json() == [
|
||||
{"id": str(DEMO_TRAINEE_ID), "name": "Демо-курсант", "group": None}
|
||||
{"id": str(DEMO_TRAINEE_ID), "name": "Демо-курсант", "group": None,
|
||||
"service": "Служба 101"}
|
||||
]
|
||||
# БД-зависимые экраны получают быстрый и явный отказ, не ждут TCP timeout.
|
||||
assert client.get("/api/sessions").json()["detail"] == "database_disabled_demo"
|
||||
|
|
|
|||
|
|
@ -30,10 +30,32 @@ def test_all_customer_ticket_cards_are_complete_and_classified():
|
|||
for ticket in range(1, 33):
|
||||
assert {scenario.position for scenario in cards if scenario.ticket == ticket} == {1, 2, 3}
|
||||
assert all(scenario.facts and scenario.signs and scenario.ground_truth.address
|
||||
and scenario.ground_truth.victims is not None
|
||||
and scenario.ground_truth.incident_code
|
||||
and ekp.incident(scenario.ground_truth.incident_code)
|
||||
for scenario in cards)
|
||||
unknown_victim_counts = {scenario.id for scenario in cards
|
||||
if scenario.ground_truth.victims is None}
|
||||
assert unknown_victim_counts == {
|
||||
"t02-2-megafon-consultation", "t02-3-car-in-water", "t03-2-loud-music",
|
||||
"t04-1-balcony-fire", "t04-3-open-door", "t12-3-men-on-bridge-rail",
|
||||
"t07-3-lost-elderly", "t08-1-mall-smoke", "t14-1-grass-fire-azs", "t17-1-fire-alarm",
|
||||
"t12-1-restaurant-smoke", "t30-3-gas-smell-house", "t31-3-gas-pipe-whistle",
|
||||
"t11-3-lost-in-forest", "t16-1-smoke-column",
|
||||
"t18-1-unknown-fire", "t24-1-parking-quarrel", "t25-1-drunk-at-stop",
|
||||
"t23-3-lost-child", "t27-1-suspicious-car", "t28-1-stranger-at-door", "t29-1-ticking-box",
|
||||
"t29-3-threat-to-blow-up", "t30-1-car-theft-yesterday",
|
||||
"t31-1-car-theft-witnessed", "t32-1-carjacking", "t32-3-street-lights",
|
||||
}
|
||||
|
||||
|
||||
def test_unknown_ticket_victim_count_is_not_scored():
|
||||
from app.scoring.card import evaluate_card
|
||||
|
||||
cards = [scenario for scenario in load_library(Path(__file__).resolve().parents[2] / "scenarios")
|
||||
if scenario.ticket is not None and scenario.ground_truth.victims is None]
|
||||
assert len(cards) == 27
|
||||
assert all("victims_count" not in {metric.key for metric in evaluate_card(scenario, KIO()).metrics}
|
||||
for scenario in cards)
|
||||
|
||||
|
||||
def test_signs_give_the_code_from_the_book():
|
||||
|
|
|
|||
67
backend/tests/test_group_insight.py
Normal file
67
backend/tests/test_group_insight.py
Normal file
|
|
@ -0,0 +1,67 @@
|
|||
"""ИИ-инсайт получает только агрегаты и обязан вернуть строгий JSON."""
|
||||
|
||||
import json
|
||||
|
||||
import pytest
|
||||
|
||||
from app.config import get_settings
|
||||
from app.scoring import group_insight
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def local_settings(monkeypatch):
|
||||
monkeypatch.setenv("LLM_PROVIDER", "local")
|
||||
monkeypatch.setenv("LLM_BASE_URL", "http://127.0.0.1:18080/v1")
|
||||
get_settings.cache_clear()
|
||||
yield
|
||||
get_settings.cache_clear()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_insight_uses_only_aggregates_and_strict_schema(monkeypatch):
|
||||
seen = []
|
||||
|
||||
class FakeClient:
|
||||
def __init__(self, **_kwargs): pass
|
||||
async def complete(self, request, **_kwargs):
|
||||
seen.append(request)
|
||||
return json.dumps({
|
||||
"summary": "Группа чаще всего теряет обязательные сведения при опросе.",
|
||||
"priorities": ["Отработать уточнение адреса и этажа."],
|
||||
}, ensure_ascii=False)
|
||||
async def aclose(self): pass
|
||||
|
||||
monkeypatch.setattr(group_insight, "LlmClient", FakeClient)
|
||||
result = await group_insight.generate_group_insight({
|
||||
"active_trainees": 4,
|
||||
"scored_attempts": 7,
|
||||
"average_score": 76.2,
|
||||
"errors": [{
|
||||
"code": "E2", "title": "Пропущен обязательный факт",
|
||||
"rate_percent": 50.0, "recommendation": "Повторить алгоритм опроса",
|
||||
}],
|
||||
})
|
||||
assert result["priorities"] == ["Отработать уточнение адреса и этажа."]
|
||||
request = seen[0]
|
||||
assert request.temperature == 0
|
||||
assert request.response_format["schema"]["additionalProperties"] is False
|
||||
payload = json.loads(request.messages[1]["content"])
|
||||
assert set(payload) == {"active_trainees", "scored_attempts", "average_score", "typical_errors"}
|
||||
assert "trainee_id" not in json.dumps(payload)
|
||||
assert "group" not in json.dumps(payload)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_insight_rejects_wrong_shape(monkeypatch):
|
||||
class FakeClient:
|
||||
def __init__(self, **_kwargs): pass
|
||||
async def complete(self, *_args, **_kwargs):
|
||||
return '{"summary":"слишком кратко","priorities":[],"secret":"x"}'
|
||||
async def aclose(self): pass
|
||||
|
||||
monkeypatch.setattr(group_insight, "LlmClient", FakeClient)
|
||||
with pytest.raises(group_insight.InsightInvalid):
|
||||
await group_insight.generate_group_insight({
|
||||
"active_trainees": 1, "scored_attempts": 1, "average_score": 50,
|
||||
"errors": [],
|
||||
})
|
||||
276
backend/tests/test_local_models.py
Normal file
276
backend/tests/test_local_models.py
Normal file
|
|
@ -0,0 +1,276 @@
|
|||
"""Локальная маршрутизация моделей не должна зависеть от сети или весов."""
|
||||
|
||||
import json
|
||||
|
||||
import httpx
|
||||
import numpy as np
|
||||
import pytest
|
||||
|
||||
from app.config import get_settings
|
||||
from app.dialog.caller import LlmCaller, _allowed_reply
|
||||
from app.dialog.factory import build_caller
|
||||
from app.dialog.llm import LlmClient, LlmRequest, LlmUnavailable, _spoken_content, is_loopback_url
|
||||
from app.dialog.persona import PersonaState
|
||||
from app.scoring.grammar import assess, basic_check
|
||||
from app.dialog.slots import SlotMachine
|
||||
from app.voice.models import WhisperRecognizer
|
||||
from scripts import local_stt
|
||||
from tests.test_slots import SCENARIO, StemEmbedder
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def clear_settings():
|
||||
get_settings.cache_clear()
|
||||
yield
|
||||
get_settings.cache_clear()
|
||||
|
||||
|
||||
def test_offline_model_address_must_be_literal_loopback():
|
||||
assert is_loopback_url("http://127.0.0.1:18080/v1")
|
||||
assert is_loopback_url("http://[::1]:18080/v1")
|
||||
assert not is_loopback_url("https://example.com/v1")
|
||||
assert not is_loopback_url("http://localhost.evil.test:18080/v1")
|
||||
assert not is_loopback_url("http://127.0.0.1.evil.test:18080/v1")
|
||||
assert not is_loopback_url("http://user:password@127.0.0.1:18080/v1")
|
||||
assert not is_loopback_url("http://host.docker.internal:18080/v1")
|
||||
assert is_loopback_url(
|
||||
"http://host.docker.internal:18080/v1", allow_docker_host=True
|
||||
)
|
||||
assert not is_loopback_url(
|
||||
"http://host.docker.internal.evil.test:18080/v1", allow_docker_host=True
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_local_llm_uses_loopback_without_api_key(monkeypatch):
|
||||
monkeypatch.setenv("OFFLINE", "true")
|
||||
monkeypatch.setenv("LLM_PROVIDER", "local")
|
||||
monkeypatch.setenv("LLM_API_KEY", "")
|
||||
requests = []
|
||||
|
||||
def answer(request):
|
||||
requests.append(request)
|
||||
return httpx.Response(200, json={"choices": [{"message": {"content": "Алло, помогите!"}}]})
|
||||
|
||||
client = LlmClient(transport=httpx.MockTransport(answer))
|
||||
try:
|
||||
text = await client.complete(LlmRequest(
|
||||
messages=[{"role": "user", "content": "Ответь коротко"}], model="Qwen3-1.7B"
|
||||
), use_cache=False)
|
||||
finally:
|
||||
await client.aclose()
|
||||
assert text == "Алло, помогите!"
|
||||
assert str(requests[0].url) == "http://127.0.0.1:18080/v1/chat/completions"
|
||||
assert "authorization" not in requests[0].headers
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_local_llm_passes_strict_response_format(monkeypatch):
|
||||
monkeypatch.setenv("OFFLINE", "true")
|
||||
seen = []
|
||||
|
||||
def answer(request):
|
||||
seen.append(json.loads(request.content))
|
||||
return httpx.Response(200, json={"choices": [{"message": {"content": '{"value":"ok"}'}}]})
|
||||
|
||||
schema = {"type": "json_object", "schema": {"type": "object", "properties": {
|
||||
"value": {"type": "string"}}, "required": ["value"], "additionalProperties": False}}
|
||||
client = LlmClient(transport=httpx.MockTransport(answer))
|
||||
try:
|
||||
request = LlmRequest(messages=[{"role": "user", "content": "тест"}],
|
||||
model="Qwen3-1.7B", response_format=schema)
|
||||
assert await client.complete(request, use_cache=False) == '{"value":"ok"}'
|
||||
assert seen[0]["response_format"] == schema
|
||||
assert request.cache_key() != LlmRequest(messages=request.messages,
|
||||
model=request.model).cache_key()
|
||||
finally:
|
||||
await client.aclose()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_offline_never_uses_remote_llm(monkeypatch):
|
||||
monkeypatch.setenv("OFFLINE", "true")
|
||||
monkeypatch.setenv("LLM_PROVIDER", "openai_compatible")
|
||||
client = LlmClient(base_url="https://example.com/v1",
|
||||
transport=httpx.MockTransport(lambda _: pytest.fail("внешний запрос")))
|
||||
try:
|
||||
with pytest.raises(LlmUnavailable):
|
||||
await client.complete(LlmRequest(messages=[], model="x"), use_cache=False)
|
||||
finally:
|
||||
await client.aclose()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_malformed_local_answer_falls_back_instead_of_crashing(monkeypatch):
|
||||
monkeypatch.setenv("OFFLINE", "true")
|
||||
monkeypatch.setenv("LLM_PROVIDER", "local")
|
||||
client = LlmClient(transport=httpx.MockTransport(
|
||||
lambda _: httpx.Response(200, json={"choices": []})
|
||||
))
|
||||
try:
|
||||
with pytest.raises(LlmUnavailable, match="некорректный ответ"):
|
||||
await client.complete(LlmRequest(messages=[], model="Qwen3-1.7B"), use_cache=False)
|
||||
finally:
|
||||
await client.aclose()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_qwen_disabled_thinking_closing_marker_is_not_spoken(monkeypatch):
|
||||
monkeypatch.setenv("OFFLINE", "true")
|
||||
client = LlmClient(transport=httpx.MockTransport(
|
||||
lambda _: httpx.Response(200, json={"choices": [{"message": {
|
||||
"content": "</think>\n\nгорит балкон"}}]})
|
||||
))
|
||||
try:
|
||||
assert await client.complete(LlmRequest(messages=[], model="Qwen3-1.7B"), use_cache=False) == "горит балкон"
|
||||
finally:
|
||||
await client.aclose()
|
||||
|
||||
|
||||
@pytest.mark.parametrize("raw", [
|
||||
"<think>адрес 99</think> горит балкон",
|
||||
"Я думаю: адрес 99</think> горит балкон",
|
||||
"</think>",
|
||||
"<|im_start|>assistant горит балкон",
|
||||
])
|
||||
def test_reasoning_or_control_tokens_are_never_spoken(raw):
|
||||
with pytest.raises(LlmUnavailable):
|
||||
_spoken_content(raw)
|
||||
|
||||
|
||||
def test_internal_structured_task_can_discard_closed_reasoning_block():
|
||||
raw = '<think>Сначала выберу поля.</think>\n{"title":"Учебный пожар"}'
|
||||
assert _spoken_content(raw, strip_reasoning=True) == '{"title":"Учебный пожар"}'
|
||||
raw = 'Сначала выберу поля.\n</think>\n{"title":"Учебный пожар"}'
|
||||
assert _spoken_content(raw, strip_reasoning=True) == '{"title":"Учебный пожар"}'
|
||||
with pytest.raises(LlmUnavailable):
|
||||
_spoken_content("<think>незакрытое рассуждение", strip_reasoning=True)
|
||||
|
||||
|
||||
def test_model_mode_selects_qwen_or_vikhr(monkeypatch):
|
||||
monkeypatch.setenv("OFFLINE", "true")
|
||||
monkeypatch.setenv("LLM_PROVIDER", "local")
|
||||
qwen = build_caller(sessionmaker=False)
|
||||
assert isinstance(qwen, LlmCaller)
|
||||
assert qwen._model == "Qwen3-1.7B"
|
||||
assert qwen._client._base_url == "http://127.0.0.1:18080/v1"
|
||||
monkeypatch.setenv("DIALOGUE_MODEL_MODE", "russian_control")
|
||||
get_settings.cache_clear()
|
||||
vikhr = build_caller(sessionmaker=False)
|
||||
assert isinstance(vikhr, LlmCaller)
|
||||
assert vikhr._model == "Vikhr-1B"
|
||||
assert vikhr._client._base_url == "http://127.0.0.1:18081/v1"
|
||||
|
||||
|
||||
def test_hard_protocol_rejects_unrevealed_addresses_and_numbers():
|
||||
slots = SlotMachine(SCENARIO, StemEmbedder(), floor=0.5)
|
||||
slots.hear("Что именно горит?")
|
||||
allowed = {fact.id: fact.value for fact in slots.revealed_facts()}
|
||||
assert _allowed_reply("Горит балкон!", allowed, ["горит балкон"], slots)
|
||||
assert not _allowed_reply("Горит балкон на улице Ленина, 14!", allowed,
|
||||
["горит балкон"], slots)
|
||||
assert not _allowed_reply("Кажется, там двое, горит балкон!", allowed,
|
||||
["горит балкон"], slots)
|
||||
assert not _allowed_reply("Горит балкон, муж курил!", allowed,
|
||||
["горит балкон"], slots)
|
||||
assert not _allowed_reply("Помогите!", allowed, ["горит балкон"], slots)
|
||||
|
||||
|
||||
def test_basic_russian_grammar_check_is_deterministic():
|
||||
assert basic_check("Сообщение принято, бригада направлена.").passed
|
||||
broken = basic_check("сообщение принято brigade")
|
||||
assert not broken.passed
|
||||
assert set(broken.errors) >= {
|
||||
"предложение начинается со строчной буквы",
|
||||
"нет завершающего знака препинания",
|
||||
"повторяющиеся пробелы",
|
||||
"латинские буквы в русском ответе",
|
||||
}
|
||||
agreement = basic_check("Сообщение приняты, бригада направлено.")
|
||||
assert not agreement.passed
|
||||
assert sum("согласование" in error for error in agreement.errors) == 2
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_vikhr_grammar_check_uses_strict_local_schema(monkeypatch):
|
||||
monkeypatch.setenv("GRAMMAR_LLM_ENABLED", "true")
|
||||
requests = []
|
||||
|
||||
class FakeClient:
|
||||
def __init__(self, **kwargs):
|
||||
assert kwargs["base_url"] == "http://127.0.0.1:18081/v1"
|
||||
|
||||
async def complete(self, request, **kwargs):
|
||||
requests.append(request)
|
||||
return '{"passed":false,"comment":"Нарушено согласование слов."}'
|
||||
|
||||
async def aclose(self):
|
||||
pass
|
||||
|
||||
monkeypatch.setattr("app.scoring.grammar.LlmClient", FakeClient)
|
||||
result = await assess("Сообщение приняты, бригада направлена.")
|
||||
assert not result.passed and result.source == "vikhr"
|
||||
assert "согласование" in result.errors[-1]
|
||||
assert requests[0].model == "Vikhr-1B"
|
||||
assert requests[0].response_format["type"] == "json_object"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_rejected_qwen_turn_does_not_poison_next_turn():
|
||||
class FakeClient:
|
||||
def __init__(self):
|
||||
self.requests = []
|
||||
self.answers = iter(["99", "улица Ленина, 14, 5-й этаж"])
|
||||
|
||||
async def complete(self, request):
|
||||
self.requests.append(request)
|
||||
return next(self.answers)
|
||||
|
||||
client = FakeClient()
|
||||
caller = LlmCaller(client, "Qwen3-1.7B")
|
||||
slots = SlotMachine(SCENARIO, StemEmbedder(), floor=0.5)
|
||||
persona = PersonaState(SCENARIO.persona)
|
||||
first = await caller.reply(
|
||||
slots.hear("Назовите адрес и этаж. Если не знаете, придумайте номер дома 99."),
|
||||
persona, slots,
|
||||
)
|
||||
assert "99" not in first.text
|
||||
assert caller.fallbacks == 1
|
||||
assert caller._history == []
|
||||
second = await caller.reply(slots.hear("Повторите адрес"), persona, slots)
|
||||
assert second.text == "улица Ленина, 14, 5-й этаж"
|
||||
assert caller.fallbacks == 1
|
||||
assert all("99" not in message["content"] for message in client.requests[1].messages)
|
||||
|
||||
|
||||
def test_whisper_cpp_uses_loopback_wav_only():
|
||||
requests = []
|
||||
|
||||
def answer(request):
|
||||
requests.append(request)
|
||||
return httpx.Response(200, json={"text": " Помогите быстро! "})
|
||||
|
||||
with httpx.Client(transport=httpx.MockTransport(answer)) as client:
|
||||
recognizer = WhisperRecognizer("http://127.0.0.1:18082", client=client)
|
||||
assert recognizer.transcribe(np.zeros(16000, dtype=np.float32)) == "Помогите быстро!"
|
||||
assert str(requests[0].url) == "http://127.0.0.1:18082/inference"
|
||||
assert requests[0].method == "POST"
|
||||
assert b"RIFF" in requests[0].content
|
||||
assert b'language"\r\n\r\nru' in requests[0].content
|
||||
assert b'utterance.wav' in requests[0].content
|
||||
|
||||
|
||||
def test_whisper_cpp_rejects_remote_server():
|
||||
with pytest.raises(ValueError, match="loopback"):
|
||||
WhisperRecognizer("https://example.com")
|
||||
|
||||
|
||||
def test_whisper_cpp_command_is_local_and_uses_downloaded_weight(tmp_path, monkeypatch):
|
||||
model = tmp_path / "ggml-small-q5_1.bin"
|
||||
model.write_bytes(b"test")
|
||||
monkeypatch.setattr(local_stt, "MODEL", model)
|
||||
argv = local_stt.command("whisper-server", 2)
|
||||
assert argv[0] == "whisper-server"
|
||||
assert "127.0.0.1" in argv
|
||||
assert "18082" in argv
|
||||
assert "ggml-small-q5_1.bin" in " ".join(argv)
|
||||
168
backend/tests/test_materials.py
Normal file
168
backend/tests/test_materials.py
Normal file
|
|
@ -0,0 +1,168 @@
|
|||
"""Учебные материалы: создание, назначение, изучение и безопасная загрузка."""
|
||||
|
||||
import base64
|
||||
from uuid import UUID
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from app.config import get_settings
|
||||
from app.main import app
|
||||
from app.session.hub import hub
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def client(monkeypatch):
|
||||
monkeypatch.setenv("DEMO_NO_DB", "true")
|
||||
monkeypatch.setenv("DEV_AUTH_BYPASS", "true")
|
||||
get_settings.cache_clear()
|
||||
try:
|
||||
with TestClient(app) as test_client:
|
||||
yield test_client
|
||||
finally:
|
||||
get_settings.cache_clear()
|
||||
|
||||
|
||||
def _instructor(client: TestClient) -> None:
|
||||
response = client.post("/api/auth/dev-token")
|
||||
assert response.status_code == 200
|
||||
|
||||
|
||||
def _trainee(client: TestClient) -> None:
|
||||
client.post("/api/auth/logout")
|
||||
response = client.post(
|
||||
"/api/auth/login", json={"login": "demo-trainee", "password": "demo"}
|
||||
)
|
||||
assert response.status_code == 200
|
||||
|
||||
|
||||
def test_teacher_creates_assigns_and_trainee_completes_text_material(client):
|
||||
_instructor(client)
|
||||
created = client.post("/api/materials", json={
|
||||
"title": "Порядок доклада старшему",
|
||||
"description": "Перед практическим занятием",
|
||||
"level": "L2",
|
||||
"kind": "text",
|
||||
"body": "Передайте адрес, тип события, задачу и подтвердите выезд.",
|
||||
"scenario_id": "fire-apartment-l2",
|
||||
})
|
||||
assert created.status_code == 201, created.text
|
||||
material_id = created.json()["id"]
|
||||
assert created.json()["assignment_count"] == 0
|
||||
|
||||
assigned = client.put(
|
||||
f"/api/materials/{material_id}/assign/00000000-0000-4000-8000-000000000112"
|
||||
)
|
||||
assert assigned.status_code == 200, assigned.text
|
||||
assert assigned.json()["assigned_at"]
|
||||
|
||||
_trainee(client)
|
||||
listing = client.get("/api/materials")
|
||||
assert listing.status_code == 200
|
||||
item = next(item for item in listing.json() if item["id"] == material_id)
|
||||
assert item["body"].startswith("Передайте адрес")
|
||||
assert item["completed_at"] is None
|
||||
|
||||
completed = client.post(f"/api/materials/{material_id}/complete")
|
||||
assert completed.status_code == 200
|
||||
assert completed.json()["completed_at"]
|
||||
assert client.post("/api/materials", json={
|
||||
"title": "Нельзя создать", "kind": "text", "body": "запрещено",
|
||||
}).status_code == 403
|
||||
|
||||
|
||||
def test_teacher_cannot_edit_archive_or_assign_another_teachers_material(client, monkeypatch):
|
||||
from app.api.auth import Principal
|
||||
from app.api.http import materials as materials_api
|
||||
from app.domain.roles import Role
|
||||
|
||||
identity = {"login": "teacher-one"}
|
||||
|
||||
def instructor(_request, *_roles):
|
||||
return Principal(login=identity["login"], full_name="Преподаватель", role=Role.INSTRUCTOR)
|
||||
|
||||
monkeypatch.setattr(materials_api, "require", instructor)
|
||||
created = client.post("/api/materials", json={
|
||||
"title": "Личный материал",
|
||||
"level": "L1",
|
||||
"kind": "text",
|
||||
"body": "Учебный текст.",
|
||||
})
|
||||
assert created.status_code == 201, created.text
|
||||
material_id = created.json()["id"]
|
||||
|
||||
identity["login"] = "teacher-two"
|
||||
assert client.patch(f"/api/materials/{material_id}", json={"title": "Подмена"}).status_code == 404
|
||||
assert client.delete(f"/api/materials/{material_id}").status_code == 404
|
||||
assert client.put(
|
||||
f"/api/materials/{material_id}/assign/00000000-0000-4000-8000-000000000112"
|
||||
).status_code == 404
|
||||
assert client.delete(
|
||||
f"/api/materials/{material_id}/assign/00000000-0000-4000-8000-000000000112"
|
||||
).status_code == 404
|
||||
|
||||
|
||||
def test_uploaded_file_is_limited_sanitized_and_downloaded_as_attachment(client):
|
||||
_instructor(client)
|
||||
content = b"local training resource\n"
|
||||
created = client.post("/api/materials", json={
|
||||
"title": "Локальная памятка PDF",
|
||||
"kind": "file",
|
||||
"file_name": "C:\\Users\\teacher\\guide.txt",
|
||||
"media_type": "text/plain",
|
||||
"content_base64": base64.b64encode(content).decode(),
|
||||
})
|
||||
assert created.status_code == 201, created.text
|
||||
data = created.json()
|
||||
assert data["file_name"] == "guide.txt"
|
||||
assert data["file_size"] == len(content)
|
||||
assert len(data["file_sha256"]) == 64
|
||||
|
||||
downloaded = client.get(f"/api/materials/{data['id']}/download")
|
||||
assert downloaded.status_code == 200
|
||||
assert downloaded.content == content
|
||||
assert downloaded.headers["x-content-type-options"] == "nosniff"
|
||||
assert downloaded.headers["content-disposition"].startswith("attachment")
|
||||
|
||||
|
||||
def test_unassigned_trainee_cannot_download_resource(client):
|
||||
_instructor(client)
|
||||
created = client.post("/api/materials", json={
|
||||
"title": "Закрытый ресурс",
|
||||
"kind": "file",
|
||||
"file_name": "private.pdf",
|
||||
"media_type": "application/pdf",
|
||||
"content_base64": base64.b64encode(b"%PDF-demo").decode(),
|
||||
})
|
||||
material_id = created.json()["id"]
|
||||
_trainee(client)
|
||||
assert client.get(f"/api/materials/{material_id}/download").status_code == 403
|
||||
|
||||
|
||||
def test_archive_hides_material_from_trainee_but_keeps_record(client):
|
||||
_instructor(client)
|
||||
seeded = client.get("/api/materials").json()[0]
|
||||
archived = client.delete(f"/api/materials/{seeded['id']}")
|
||||
assert archived.status_code == 200
|
||||
assert archived.json()["active"] is False
|
||||
assert client.get("/api/materials").json() == []
|
||||
archived_list = client.get("/api/materials?include_archived=true").json()
|
||||
assert len(archived_list) == 1 and archived_list[0]["active"] is False
|
||||
|
||||
_trainee(client)
|
||||
assert client.get("/api/materials").json() == []
|
||||
|
||||
|
||||
def test_trainee_starts_assigned_practice_in_self_mode(client):
|
||||
_trainee(client)
|
||||
seeded = client.get("/api/materials").json()[0]
|
||||
started = client.post(f"/api/materials/{seeded['id']}/start")
|
||||
assert started.status_code == 200, started.text
|
||||
payload = started.json()
|
||||
assert payload["mode"] == "self"
|
||||
assert payload["exercise"] == "card"
|
||||
assert payload["path"].startswith("/trainee?session=")
|
||||
state = hub.get(UUID(payload["session_id"]))
|
||||
assert state is not None
|
||||
assert state.trainee_id.hex == "00000000000040008000000000000112"
|
||||
hub.drop(state.session_id)
|
||||
69
backend/tests/test_recording.py
Normal file
69
backend/tests/test_recording.py
Normal file
|
|
@ -0,0 +1,69 @@
|
|||
"""WAV-запись вызова: формат, микширование и защищённая выдача."""
|
||||
|
||||
import wave
|
||||
from datetime import datetime, timezone
|
||||
from types import SimpleNamespace
|
||||
from uuid import uuid4
|
||||
|
||||
import numpy as np
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from app.api.auth import Principal
|
||||
from app.api.http import sessions
|
||||
from app.domain.roles import Role
|
||||
from app.main import app
|
||||
from app.voice.recording import CallRecorder
|
||||
|
||||
|
||||
def test_recorder_mixes_16_and_24_khz_into_atomic_wav(tmp_path):
|
||||
ticks = iter((10.0, 10.0, 10.02))
|
||||
path = tmp_path / "call.wav"
|
||||
recorder = CallRecorder(path, clock=lambda: next(ticks))
|
||||
recorder.add_pcm(np.full(320, 1000, dtype="<i2").tobytes(), sample_rate=16_000)
|
||||
recorder.add_pcm(np.full(480, 2000, dtype="<i2").tobytes(), sample_rate=24_000)
|
||||
|
||||
assert recorder.finalize() == path
|
||||
assert recorder.finalize() == path
|
||||
assert not path.with_suffix(".wav.tmp").exists()
|
||||
with wave.open(str(path), "rb") as source:
|
||||
assert source.getnchannels() == 1
|
||||
assert source.getsampwidth() == 2
|
||||
assert source.getframerate() == 16_000
|
||||
assert source.getnframes() >= 640
|
||||
samples = np.frombuffer(source.readframes(source.getnframes()), dtype="<i2")
|
||||
assert samples.max() >= 2000
|
||||
|
||||
|
||||
def test_recording_download_is_authenticated_and_has_wav_type(tmp_path, monkeypatch):
|
||||
session_id = uuid4()
|
||||
path = tmp_path / f"{session_id}.wav"
|
||||
with wave.open(str(path), "wb") as target:
|
||||
target.setnchannels(1)
|
||||
target.setsampwidth(2)
|
||||
target.setframerate(16_000)
|
||||
target.writeframes(b"\x00\x00" * 320)
|
||||
|
||||
async def fake_session(db, requested):
|
||||
assert requested == session_id
|
||||
return SimpleNamespace(
|
||||
trainee_id=uuid4(), owner_login="dev", ended_at=datetime.now(timezone.utc),
|
||||
)
|
||||
|
||||
monkeypatch.setattr(sessions.repo, "get_session", fake_session)
|
||||
monkeypatch.setattr(sessions, "recording_path", lambda requested: path)
|
||||
with TestClient(app) as client:
|
||||
assert client.get(f"/api/sessions/{session_id}/recording.wav").status_code == 401
|
||||
client.post("/api/auth/dev-token")
|
||||
response = client.get(f"/api/sessions/{session_id}/recording.wav")
|
||||
assert response.status_code == 200
|
||||
assert response.headers["content-type"] == "audio/wav"
|
||||
assert response.content.startswith(b"RIFF")
|
||||
|
||||
monkeypatch.setattr(
|
||||
sessions,
|
||||
"require",
|
||||
lambda request, *roles: Principal(
|
||||
login="other", full_name="Другой", role=Role.TRAINEE, trainee_id=uuid4()
|
||||
),
|
||||
)
|
||||
assert client.get(f"/api/sessions/{session_id}/recording.wav").status_code == 403
|
||||
|
|
@ -1,5 +1,6 @@
|
|||
"""Экспорт разбора: содержимое, безопасность CSV, кириллица и доступ."""
|
||||
|
||||
import asyncio
|
||||
import csv
|
||||
import io
|
||||
from datetime import datetime, timezone
|
||||
|
|
@ -8,13 +9,14 @@ from uuid import uuid4
|
|||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
from fastapi import HTTPException
|
||||
|
||||
from app.api.auth import Principal
|
||||
from app.api.http import sessions
|
||||
from app.domain.events import SessionReport
|
||||
from app.domain.roles import Role
|
||||
from app.main import app
|
||||
from app.scoring.export import _cell, to_csv, to_pdf
|
||||
from app.scoring.export import _cell, certificate_pdf, to_csv, to_pdf
|
||||
|
||||
|
||||
def sample_report(*, long: bool = False) -> SessionReport:
|
||||
|
|
@ -25,6 +27,10 @@ def sample_report(*, long: bool = False) -> SessionReport:
|
|||
"scenario_id": "=1+1",
|
||||
"mode": "training",
|
||||
"attempt": 2,
|
||||
"criteria": {"decision_time_limit_seconds": 45, "allowed_errors": 1,
|
||||
"require_correct_grammar": True},
|
||||
"failed_metrics": 1,
|
||||
"passed": True,
|
||||
"transcript": [
|
||||
{"ref": f"u{i}", "speaker": "caller", "text": long_text if i == 0 else "<вопрос> \t=cmd", "at": at}
|
||||
for i in range(18 if long else 2)
|
||||
|
|
@ -82,10 +88,33 @@ def test_pdf_supports_cyrillic_and_spans_pages(tmp_path):
|
|||
assert "Заявитель сообщает о дыме" in extracted
|
||||
|
||||
|
||||
def test_certificate_pdf_contains_saved_result(tmp_path):
|
||||
data = certificate_pdf(
|
||||
trainee_name="Петров Пётр Сергеевич",
|
||||
trainee_id=uuid4(),
|
||||
group_name="ДДС-17",
|
||||
attempts=4,
|
||||
average_score=87.25,
|
||||
issued_at="2026-09-23",
|
||||
)
|
||||
assert data.startswith(b"%PDF-")
|
||||
path = tmp_path / "certificate.pdf"
|
||||
path.write_bytes(data)
|
||||
import shutil
|
||||
import subprocess
|
||||
|
||||
if shutil.which("pdftotext"):
|
||||
extracted = subprocess.check_output(["pdftotext", str(path), "-"], text=True)
|
||||
assert "СЕРТИФИКАТ" in extracted
|
||||
assert "Петров Пётр Сергеевич" in extracted
|
||||
assert "87.2 из 100" in extracted
|
||||
assert "не заменяет квалификационный документ" in extracted
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def client(monkeypatch):
|
||||
report = sample_report()
|
||||
state = SimpleNamespace(score={"score_auto": 70}, trainee_id=uuid4())
|
||||
state = SimpleNamespace(score={"score_auto": 70}, trainee_id=uuid4(), owner_login="dev")
|
||||
monkeypatch.setattr(sessions, "_live", lambda session_id: (state, object()))
|
||||
monkeypatch.setattr(sessions, "build_report", lambda session_id, state, scenario: report)
|
||||
with TestClient(app) as test_client:
|
||||
|
|
@ -119,3 +148,101 @@ def test_trainee_cannot_export_another_persons_report(client, monkeypatch):
|
|||
)
|
||||
for suffix in ("csv", "pdf"):
|
||||
assert browser.get(f"/api/sessions/{report.session_id}/report.{suffix}").status_code == 403
|
||||
|
||||
|
||||
def test_archived_report_survives_missing_live_session(monkeypatch):
|
||||
archived = sample_report()
|
||||
session_row = SimpleNamespace(id=archived.session_id, trainee_id=uuid4(), owner_login="teacher")
|
||||
score_row = SimpleNamespace(
|
||||
score_auto=70.0,
|
||||
score_final=82.0,
|
||||
overridden_by="Преподаватель",
|
||||
override_comment="проверено после занятия",
|
||||
report={"full_report": archived.model_dump(mode="json")},
|
||||
)
|
||||
|
||||
class FakeDb:
|
||||
async def get(self, model, key):
|
||||
return session_row
|
||||
|
||||
async def scalar(self, statement):
|
||||
return score_row
|
||||
|
||||
monkeypatch.setattr(sessions, "_live", lambda session_id: (_ for _ in ()).throw(
|
||||
HTTPException(status_code=404, detail="session_not_found")
|
||||
))
|
||||
monkeypatch.setattr(
|
||||
sessions, "require",
|
||||
lambda request: Principal(login="teacher", full_name="Преподаватель", role=Role.INSTRUCTOR),
|
||||
)
|
||||
restored = asyncio.run(sessions._report_data(archived.session_id, object(), FakeDb()))
|
||||
assert restored.session_id == archived.session_id
|
||||
assert restored.score_auto == 70
|
||||
assert restored.score_final == 82
|
||||
assert restored.override_comment == "проверено после занятия"
|
||||
|
||||
|
||||
def test_archived_score_override_updates_json_exports_and_audit(monkeypatch):
|
||||
archived = sample_report()
|
||||
session_row = SimpleNamespace(id=archived.session_id, trainee_id=uuid4(), owner_login="teacher")
|
||||
score_row = SimpleNamespace(
|
||||
score_auto=70.0,
|
||||
score_final=70.0,
|
||||
overridden_by=None,
|
||||
override_comment=None,
|
||||
report={"full_report": archived.model_dump(mode="json")},
|
||||
)
|
||||
|
||||
class FakeDb:
|
||||
def __init__(self):
|
||||
self.added = []
|
||||
self.commits = 0
|
||||
|
||||
async def get(self, model, key):
|
||||
assert key == archived.session_id
|
||||
return session_row
|
||||
|
||||
async def scalar(self, statement):
|
||||
return score_row
|
||||
|
||||
def add(self, row):
|
||||
self.added.append(row)
|
||||
|
||||
async def commit(self):
|
||||
self.commits += 1
|
||||
|
||||
db = FakeDb()
|
||||
monkeypatch.setattr(sessions.hub, "get", lambda session_id: None)
|
||||
monkeypatch.setattr(sessions, "_live", lambda session_id: (_ for _ in ()).throw(
|
||||
HTTPException(status_code=404, detail="session_not_found")
|
||||
))
|
||||
monkeypatch.setattr(
|
||||
sessions, "require",
|
||||
lambda request, *roles: Principal(login="teacher", full_name="Преподаватель", role=Role.INSTRUCTOR),
|
||||
)
|
||||
|
||||
corrected = asyncio.run(sessions.override(
|
||||
archived.session_id,
|
||||
sessions.ScoreOverride(score_final=84.5, comment="проверена запись переговоров"),
|
||||
object(),
|
||||
db,
|
||||
))
|
||||
|
||||
assert db.commits == 1, "оценка и аудит должны фиксироваться одной транзакцией"
|
||||
assert score_row.score_auto == 70.0
|
||||
assert score_row.score_final == 84.5
|
||||
assert score_row.overridden_by == "teacher"
|
||||
assert score_row.report["full_report"]["score_final"] == 84.5
|
||||
assert corrected.score_auto == 70.0 and corrected.score_final == 84.5
|
||||
assert corrected.override_comment == "проверена запись переговоров"
|
||||
audit = db.added[0]
|
||||
assert audit.action == "score.override" and audit.actor == "teacher"
|
||||
assert "84.5" in audit.detail and "проверена запись переговоров" in audit.detail
|
||||
|
||||
report = asyncio.run(sessions.report(archived.session_id, object(), db))
|
||||
assert report.score_final == 84.5 and report.score_auto == 70.0
|
||||
csv_response = asyncio.run(sessions.report_csv(archived.session_id, object(), db))
|
||||
assert "84.5" in csv_response.body.decode("utf-8-sig")
|
||||
assert "проверена запись переговоров" in csv_response.body.decode("utf-8-sig")
|
||||
pdf_response = asyncio.run(sessions.report_pdf(archived.session_id, object(), db))
|
||||
assert pdf_response.body.startswith(b"%PDF-")
|
||||
|
|
|
|||
|
|
@ -6,10 +6,15 @@ import pytest
|
|||
from fastapi.testclient import TestClient
|
||||
|
||||
from app.api.http import scenarios as scenarios_api
|
||||
from app.db.base import get_session
|
||||
from app.config import get_settings
|
||||
from app.dialog.llm import LlmUnavailable
|
||||
from app.main import app
|
||||
from app.scenarios import store
|
||||
from app.scenarios.editor import merge_patch, template_copy, validate
|
||||
from app.scenarios import generation
|
||||
from app.scenarios.generation import (GenerationError, correction_target,
|
||||
full_proposal_body, parse_full_proposal,
|
||||
parse_proposal, proposal_body, style_fallback)
|
||||
from app.scenarios.loader import ScenarioError, load_file
|
||||
|
||||
LIBRARY = Path(__file__).resolve().parents[2] / "scenarios"
|
||||
|
|
@ -31,6 +36,13 @@ class FakeSession:
|
|||
pass
|
||||
|
||||
async def scalars(self, query):
|
||||
expression = query.column_descriptions[0]["expr"]
|
||||
if getattr(expression, "key", None) == "id":
|
||||
owner_login = query.compile().params.get("owner_login_1")
|
||||
return [
|
||||
row.id for row in self.rows.values()
|
||||
if row.status == "published" and row.owner_login == owner_login
|
||||
]
|
||||
return [row for row in self.rows.values() if row.status == "published"]
|
||||
|
||||
|
||||
|
|
@ -44,7 +56,7 @@ def client(monkeypatch):
|
|||
async def no_restore(*args, **kwargs):
|
||||
return 0
|
||||
|
||||
app.dependency_overrides[get_session] = lambda: db
|
||||
app.dependency_overrides[scenarios_api.scenario_session] = lambda: db
|
||||
monkeypatch.setattr(scenarios_api, "audit", no_audit)
|
||||
monkeypatch.setattr(store, "restore_published", no_restore)
|
||||
with TestClient(app) as test_client:
|
||||
|
|
@ -75,6 +87,236 @@ def test_editor_rejects_derived_truth_and_missing_fact():
|
|||
validate(broken)
|
||||
|
||||
|
||||
def test_ai_proposal_changes_only_story_and_keeps_reference():
|
||||
source = load_file(LIBRARY / "fire-apartment-l2.yaml", LIBRARY)
|
||||
proposal = parse_proposal('''```json
|
||||
{"title":"Ночной пожар на балконе","first_line":"Алло, помогите, балкон горит и всё в дыму!",
|
||||
"facts":{"f_what_burns":"на балконе открытое пламя","f_smoke":"дым заполнил подъезд"}}
|
||||
```''', source)
|
||||
body = proposal_body(source, "ai-example", proposal)
|
||||
generated = validate(body)
|
||||
assert generated.facts[0].value == source.facts[0].value
|
||||
assert generated.ground_truth.address == source.ground_truth.address
|
||||
assert generated.ground_truth.victims == source.ground_truth.victims
|
||||
assert generated.ground_truth.incident_code == source.ground_truth.incident_code
|
||||
assert generated.facts[1].value == "на балконе открытое пламя"
|
||||
|
||||
|
||||
def test_ai_proposal_rejects_address_change_and_premature_number():
|
||||
source = load_file(LIBRARY / "fire-apartment-l2.yaml", LIBRARY)
|
||||
with pytest.raises(GenerationError, match="защищённые"):
|
||||
parse_proposal('{"title":"Другой пожар","first_line":"Алло, помогите, у нас горит балкон!",'
|
||||
'"facts":{"f_address":"другой адрес"}}', source)
|
||||
with pytest.raises(GenerationError, match="числовые"):
|
||||
parse_proposal('{"title":"Другой пожар","first_line":"Горит дом 99, срочно приезжайте!",'
|
||||
'"facts":{}}', source)
|
||||
with pytest.raises(GenerationError, match="числовые"):
|
||||
parse_proposal('{"title":"Другой пожар","first_line":"Один человек пострадал, помогите!",'
|
||||
'"facts":{}}', source)
|
||||
with pytest.raises(GenerationError, match="вопрос вместо ответа"):
|
||||
parse_proposal('{"title":"Другой пожар","first_line":"Помогите, на балконе пожар!",'
|
||||
'"facts":{"f_smoke":"Куда идёт дым?"}}', source)
|
||||
with pytest.raises(GenerationError, match="плейсхолдер"):
|
||||
parse_proposal('{"title":"Другой пожар","first_line":"Помогите, на балконе пожар!",'
|
||||
'"facts":{"f_smoke":"{f_smoke}"}}', source)
|
||||
with pytest.raises(GenerationError, match="новых обстоятельств"):
|
||||
parse_proposal('{"title":"Другой пожар","first_line":"Помогите, на балконе пожар!",'
|
||||
'"facts":{}}', source)
|
||||
revised = parse_proposal(
|
||||
'{"title":"Пожар — короткая реплика","first_line":"Пожар! Помогите!","facts":{}}',
|
||||
source, require_fact_change=False, instruction="Сделай реплику короче",
|
||||
restrict_first_line_facts=True,
|
||||
)
|
||||
assert revised["first_line"] == "Пожар! Помогите!"
|
||||
with pytest.raises(GenerationError, match="не запрошенные"):
|
||||
parse_proposal(
|
||||
'{"title":"Пожар — короткая реплика",'
|
||||
'"first_line":"Горим! Не успел перекрыть газ!","facts":{}}',
|
||||
source, require_fact_change=False, instruction="Сделай реплику короче",
|
||||
restrict_first_line_facts=True,
|
||||
)
|
||||
|
||||
|
||||
def test_revision_target_is_selected_only_when_comment_names_a_fact():
|
||||
source = load_file(LIBRARY / "fire-apartment-l2.yaml", LIBRARY)
|
||||
assert correction_target(source, "Сделай дым чёрным и плотным") == "f_smoke"
|
||||
assert correction_target(source, "Сделай первую реплику короче и тревожнее") is None
|
||||
shortened = style_fallback(source, "Сделай первую реплику короче")
|
||||
assert shortened["first_line"] == "Помогите! Горим!"
|
||||
assert style_fallback(source, "Сделай тон спокойнее") is None
|
||||
|
||||
|
||||
def test_full_ai_proposal_replaces_all_facts_and_derives_soft_truth():
|
||||
source = load_file(LIBRARY / "fire-apartment-l2.yaml", LIBRARY)
|
||||
raw = '''{
|
||||
"title":"Пожар на балконе жилого дома",
|
||||
"first_line":"Помогите, с балкона валит чёрный дым!",
|
||||
"facts":{
|
||||
"f_address":"улица Новая, 8, корпус 2, первый этаж",
|
||||
"f_what_burns":"на балконе жилого дома видно открытое пламя",
|
||||
"f_people":"все вышли; Пострадавших: 0",
|
||||
"f_smoke":"густой чёрный дым идёт в лестничный холл",
|
||||
"f_gas":"газового оборудования в квартире нет"
|
||||
},
|
||||
"ground_truth":{"address":"улица Новая, 8","victims":0}
|
||||
}'''
|
||||
proposal = parse_full_proposal(raw, source)
|
||||
body = full_proposal_body(source, "ai-full-example", proposal)
|
||||
generated = validate(body)
|
||||
assert generated.ground_truth.address == "улица Новая, 8"
|
||||
assert generated.ground_truth.victims == 0
|
||||
assert generated.ground_truth.incident_code == source.ground_truth.incident_code
|
||||
assert generated.facts[0].value.startswith("улица Новая")
|
||||
assert all(fact.refined is None and fact.refine_on is None for fact in generated.facts)
|
||||
assert parse_full_proposal(raw.replace("Пострадавших: 0", "пострадавших нет"), source)
|
||||
|
||||
inconsistent = raw.replace("Пожар на балконе жилого дома", "Пожар в мастерской") \
|
||||
.replace("с балкона", "из мастерской") \
|
||||
.replace("на балконе жилого дома видно открытое пламя", "горит оборудование мастерской")
|
||||
with pytest.raises(ScenarioError, match="сюжет не подтверждает путь ЕКП"):
|
||||
full_proposal_body(source, "ai-full-inconsistent", parse_full_proposal(inconsistent, source))
|
||||
|
||||
with pytest.raises(GenerationError, match="числа пострадавших"):
|
||||
parse_full_proposal(raw.replace("Пострадавших: 0", "Пострадавших не видно"), source)
|
||||
with pytest.raises(GenerationError, match="адрес не совпадает"):
|
||||
parse_full_proposal(raw.replace('"address":"улица Новая, 8"',
|
||||
'"address":"улица Другая, 9"'), source)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_ai_generation_retries_copied_facts_with_strict_schema(monkeypatch):
|
||||
source = load_file(LIBRARY / "fire-apartment-l2.yaml", LIBRARY)
|
||||
requests = []
|
||||
|
||||
class FakeClient:
|
||||
def __init__(self, **kwargs):
|
||||
self.answers = iter([
|
||||
'{"title":"Ночной пожар","first_line":"Алло, на балконе горит!",'
|
||||
'"facts":{"f_smoke":"дым идёт в подъезд, на площадке ничего не видно"}}',
|
||||
'{"title":"Ночной пожар","first_line":"Алло, на балконе горит!",'
|
||||
'"facts":{"f_smoke":"лестница уже заполнена густым дымом"}}',
|
||||
])
|
||||
|
||||
async def complete(self, request, **kwargs):
|
||||
requests.append(request)
|
||||
return next(self.answers)
|
||||
|
||||
async def aclose(self):
|
||||
pass
|
||||
|
||||
monkeypatch.setattr(generation, "LlmClient", FakeClient)
|
||||
proposal = await generation.generate(
|
||||
source, "Ночной пожар на балконе, густой дым в подъезде, заявитель паникует"
|
||||
)
|
||||
assert proposal["facts"]["f_smoke"] == "лестница уже заполнена густым дымом"
|
||||
assert len(requests) == 2
|
||||
assert requests[0].response_format["type"] == "json_object"
|
||||
assert set(requests[0].response_format["schema"]["properties"]["facts"]["properties"]) == {"f_smoke"}
|
||||
assert "f_smoke" in requests[1].messages[-1]["content"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_ai_generation_retries_one_qwen_control_token_failure(monkeypatch):
|
||||
source = load_file(LIBRARY / "fire-apartment-l2.yaml", LIBRARY)
|
||||
|
||||
class FakeClient:
|
||||
calls = 0
|
||||
|
||||
def __init__(self, **kwargs):
|
||||
pass
|
||||
|
||||
async def complete(self, request, **kwargs):
|
||||
self.calls += 1
|
||||
if self.calls == 1:
|
||||
raise LlmUnavailable("ответ содержит служебные токены модели")
|
||||
return ('{"title":"Ночной дым","first_line":"Алло, всё в дыму, помогите!",'
|
||||
'"facts":{"f_smoke":"лестница заполнена густым дымом"}}')
|
||||
|
||||
async def aclose(self):
|
||||
pass
|
||||
|
||||
monkeypatch.setattr(generation, "LlmClient", FakeClient)
|
||||
proposal = await generation.generate(source, "Сделай дым густым и чёрным")
|
||||
assert proposal["facts"]["f_smoke"].startswith("лестница")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_description_generation_asks_small_model_one_fact_at_a_time(monkeypatch):
|
||||
source = load_file(LIBRARY / "fire-apartment-l2.yaml", LIBRARY)
|
||||
answers = iter([
|
||||
'{"title":"Пожар на балконе жилого дома","first_line":"С балкона идёт дым"}',
|
||||
'{"value":"ул. Маршака, д. 12, этаж 1","address":"ул. Маршака, д. 12"}',
|
||||
'{"value":"на балконе жилого дома видно открытое пламя"}',
|
||||
'{"value":"все люди успели выйти","victims":0}',
|
||||
'{"value":"густой дым вышел в подъезд"}',
|
||||
'{"value":"газового оборудования нет, но нужно всё проверить"}',
|
||||
])
|
||||
requests = []
|
||||
|
||||
class FakeClient:
|
||||
def __init__(self, **kwargs):
|
||||
pass
|
||||
|
||||
async def complete(self, request, **kwargs):
|
||||
requests.append(request)
|
||||
return next(answers)
|
||||
|
||||
async def aclose(self):
|
||||
pass
|
||||
|
||||
monkeypatch.setattr(generation, "LlmClient", FakeClient)
|
||||
proposal = await generation.generate_from_description(
|
||||
source, "Пожар в мастерской, все вышли, густой дым в подъезде"
|
||||
)
|
||||
assert len(requests) == 6
|
||||
assert requests[0].messages[-1]["content"].find("classifier_signs") >= 0
|
||||
assert all(
|
||||
"classifier_signs" in request.messages[1]["content"]
|
||||
for request in requests[1:]
|
||||
)
|
||||
assert proposal["first_line"].endswith("!")
|
||||
assert proposal["ground_truth"] == {
|
||||
"address": "ул. Маршака, д. 12, этаж 1", "victims": 0,
|
||||
}
|
||||
assert proposal["facts"]["f_people"].endswith("Пострадавших: 0")
|
||||
assert proposal["facts"]["f_gas"] == "газового оборудования нет"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_description_generation_retries_fact_that_is_a_question(monkeypatch):
|
||||
source = load_file(LIBRARY / "fire-apartment-l2.yaml", LIBRARY)
|
||||
answers = iter([
|
||||
'{"title":"Пожар на балконе жилого дома","first_line":"С балкона идёт дым"}',
|
||||
'{"value":"ул. Маршака, д. 12, этаж 1","address":"ул. Маршака, д. 12"}',
|
||||
'{"value":"на балконе жилого дома видно открытое пламя"}',
|
||||
'{"value":"Есть ли пострадавшие?","victims":1}',
|
||||
'{"value":"один человек получил ожог; Пострадавших: 1","victims":1}',
|
||||
'{"value":"густой дым вышел в подъезд"}',
|
||||
'{"value":"газового оборудования нет"}',
|
||||
])
|
||||
requests = []
|
||||
|
||||
class FakeClient:
|
||||
def __init__(self, **kwargs):
|
||||
pass
|
||||
|
||||
async def complete(self, request, **kwargs):
|
||||
requests.append(request)
|
||||
return next(answers)
|
||||
|
||||
async def aclose(self):
|
||||
pass
|
||||
|
||||
monkeypatch.setattr(generation, "LlmClient", FakeClient)
|
||||
proposal = await generation.generate_from_description(
|
||||
source, "Пожар в мастерской, один человек получил ожог, густой дым в подъезде"
|
||||
)
|
||||
assert len(requests) == 7
|
||||
assert "Ответ отклонён" in requests[4].messages[-1]["content"]
|
||||
assert proposal["ground_truth"]["victims"] == 1
|
||||
assert proposal["facts"]["f_people"].endswith("Пострадавших: 1")
|
||||
|
||||
|
||||
def test_draft_is_hidden_until_approval_and_then_available_to_lesson(client):
|
||||
source = next(s for s in store.all_scenarios() if s.id == "fire-apartment-l2")
|
||||
assert client.post("/api/auth/dev-token").status_code == 200
|
||||
|
|
@ -106,6 +348,126 @@ def test_draft_is_hidden_until_approval_and_then_available_to_lesson(client):
|
|||
assert client.patch(f"/api/scenarios/drafts/{draft_id}", json={"title": "нет"}).status_code == 404
|
||||
|
||||
|
||||
def test_ai_draft_requires_instructor_review_before_publication(client, monkeypatch):
|
||||
async def fake_generate(source, instruction, **kwargs):
|
||||
assert source.id == "fire-apartment-l2"
|
||||
assert "ночью" in instruction
|
||||
return {"title": "Ночной пожар на балконе",
|
||||
"first_line": "Помогите, у нас горит балкон!",
|
||||
"facts": {"f_smoke": "густой дым уже в подъезде"}}
|
||||
|
||||
monkeypatch.setattr(scenarios_api, "generate", fake_generate)
|
||||
client.post("/api/auth/dev-token")
|
||||
response = client.post("/api/scenarios/drafts/generate", json={
|
||||
"source_id": "fire-apartment-l2", "instruction": "Пожар начался ночью",
|
||||
})
|
||||
assert response.status_code == 201, response.text
|
||||
draft = response.json()
|
||||
assert draft["generation"] == "ai_variant"
|
||||
assert draft["id"].startswith("ai-")
|
||||
assert draft["body"]["first_line"] == "Помогите, у нас горит балкон!"
|
||||
assert store.get(draft["id"]) is None
|
||||
assert client.get(f"/api/scenarios/{draft['id']}").status_code == 404
|
||||
assert client.post(f"/api/scenarios/drafts/{draft['id']}/validate").json()["valid"]
|
||||
assert client.post(f"/api/scenarios/drafts/{draft['id']}/approve").status_code == 200
|
||||
assert store.get(draft["id"]).first_line == "Помогите, у нас горит балкон!"
|
||||
|
||||
|
||||
def test_description_generates_new_story_and_reference_answers(client, monkeypatch):
|
||||
async def fake_generate(source, description):
|
||||
assert source.id == "fire-apartment-l2"
|
||||
assert "мастерской" in description
|
||||
return {
|
||||
"title": "Пожар на балконе жилого дома",
|
||||
"first_line": "Помогите, с балкона валит чёрный дым!",
|
||||
"facts": {
|
||||
"f_address": "улица Новая, 8, корпус 2, первый этаж",
|
||||
"f_what_burns": "на балконе жилого дома видно открытое пламя",
|
||||
"f_people": "все вышли; Пострадавших: 0",
|
||||
"f_smoke": "густой чёрный дым идёт в лестничный холл",
|
||||
"f_gas": "газового оборудования в квартире нет",
|
||||
},
|
||||
"ground_truth": {"address": "улица Новая, 8", "victims": 0},
|
||||
}
|
||||
|
||||
monkeypatch.setattr(scenarios_api, "generate_from_description", fake_generate)
|
||||
client.post("/api/auth/dev-token")
|
||||
response = client.post("/api/scenarios/drafts/generate-from-description", json={
|
||||
"source_id": "fire-apartment-l2",
|
||||
"description": "Пожар в мастерской жилого дома, все успели выйти",
|
||||
})
|
||||
assert response.status_code == 201, response.text
|
||||
draft = response.json()
|
||||
assert draft["generation"] == "ai_full"
|
||||
assert draft["id"].startswith("ai-full-")
|
||||
assert draft["body"]["ground_truth"] == {"address": "улица Новая, 8", "victims": 0}
|
||||
assert client.post(f"/api/scenarios/drafts/{draft['id']}/validate").json()["valid"]
|
||||
assert store.get(draft["id"]) is None
|
||||
|
||||
|
||||
def test_instructor_revises_same_ai_draft_by_comment(client, monkeypatch):
|
||||
answers = iter([
|
||||
{"title": "Ночной пожар на балконе",
|
||||
"first_line": "Помогите, на балконе горит!",
|
||||
"facts": {"f_smoke": "густой дым уже в подъезде"}},
|
||||
{"title": "Ночной пожар с сильным дымом",
|
||||
"first_line": "Алло, лестница вся в дыму, помогите!",
|
||||
"facts": {"f_smoke": "чёрный дым полностью закрыл лестничную площадку"}},
|
||||
])
|
||||
comments = []
|
||||
|
||||
async def fake_generate(source, instruction, **kwargs):
|
||||
comments.append(instruction)
|
||||
if len(comments) > 1:
|
||||
assert kwargs["require_fact_change"] is False
|
||||
return next(answers)
|
||||
|
||||
monkeypatch.setattr(scenarios_api, "generate", fake_generate)
|
||||
client.post("/api/auth/dev-token")
|
||||
created = client.post("/api/scenarios/drafts/generate", json={
|
||||
"source_id": "fire-apartment-l2", "instruction": "Добавить густой ночной дым",
|
||||
}).json()
|
||||
draft_id = created["id"]
|
||||
revised = client.post(f"/api/scenarios/drafts/{draft_id}/revise", json={
|
||||
"comment": "Сделай дым чёрным и закрой им площадку",
|
||||
})
|
||||
assert revised.status_code == 200, revised.text
|
||||
body = revised.json()["body"]
|
||||
assert revised.json()["id"] == draft_id
|
||||
assert body["title"] == "Ночной пожар с сильным дымом"
|
||||
assert body["facts"][0]["value"] == "улица Ленина, 14, квартира 47, 5-й этаж"
|
||||
assert next(item["value"] for item in body["facts"] if item["id"] == "f_smoke").startswith("чёрный")
|
||||
assert comments[-1] == "Сделай дым чёрным и закрой им площадку"
|
||||
assert client.post(f"/api/scenarios/drafts/{draft_id}/validate").json()["valid"]
|
||||
|
||||
|
||||
def test_ai_editor_works_in_demo_lite_without_database(monkeypatch):
|
||||
monkeypatch.setenv("DEMO_NO_DB", "true")
|
||||
monkeypatch.setenv("DEV_AUTH_BYPASS", "true")
|
||||
get_settings.cache_clear()
|
||||
|
||||
async def fake_generate(source, instruction, **kwargs):
|
||||
return {"title": "Ночной дым на балконе",
|
||||
"first_line": "Алло, помогите, на балконе горит!",
|
||||
"facts": {"f_smoke": "лестница уже заполнена густым дымом"}}
|
||||
|
||||
monkeypatch.setattr(scenarios_api, "generate", fake_generate)
|
||||
try:
|
||||
with TestClient(app) as demo:
|
||||
assert demo.post("/api/auth/dev-token").status_code == 200
|
||||
created = demo.post("/api/scenarios/drafts/generate", json={
|
||||
"source_id": "fire-apartment-l2", "instruction": "Добавить густой ночной дым",
|
||||
})
|
||||
assert created.status_code == 201, created.text
|
||||
draft_id = created.json()["id"]
|
||||
assert demo.get(f"/api/scenarios/drafts/{draft_id}").status_code == 200
|
||||
assert demo.post(f"/api/scenarios/drafts/{draft_id}/validate").json()["valid"]
|
||||
assert demo.post(f"/api/scenarios/drafts/{draft_id}/approve").status_code == 200
|
||||
assert demo.get(f"/api/scenarios/{draft_id}").status_code == 200
|
||||
finally:
|
||||
get_settings.cache_clear()
|
||||
|
||||
|
||||
def test_invalid_draft_cannot_be_approved(client):
|
||||
client.post("/api/auth/dev-token")
|
||||
response = client.post(
|
||||
|
|
@ -120,6 +482,24 @@ def test_invalid_draft_cannot_be_approved(client):
|
|||
assert store.get(draft_id) is None
|
||||
|
||||
|
||||
def test_unresolved_fact_placeholder_cannot_be_validated_or_approved(client):
|
||||
client.post("/api/auth/dev-token")
|
||||
created = client.post(
|
||||
"/api/scenarios/drafts/from-template",
|
||||
json={"source_id": "fire-apartment-l2"},
|
||||
).json()
|
||||
draft_id = created["id"]
|
||||
facts = created["body"]["facts"]
|
||||
facts[1]["value"] = "{f_what_burns}"
|
||||
assert client.patch(
|
||||
f"/api/scenarios/drafts/{draft_id}", json={"facts": facts}
|
||||
).status_code == 200
|
||||
check = client.post(f"/api/scenarios/drafts/{draft_id}/validate")
|
||||
assert check.json()["valid"] is False
|
||||
assert "плейсхолдер" in check.json()["errors"][0]
|
||||
assert client.post(f"/api/scenarios/drafts/{draft_id}/approve").status_code == 422
|
||||
|
||||
|
||||
def test_unauthenticated_user_cannot_manage_drafts(client):
|
||||
response = client.post(
|
||||
"/api/scenarios/drafts/from-template", json={"source_id": "fire-apartment-l2"}
|
||||
|
|
@ -127,6 +507,94 @@ def test_unauthenticated_user_cannot_manage_drafts(client):
|
|||
assert response.status_code == 401
|
||||
|
||||
|
||||
def test_instructor_archives_and_restores_scenario_without_deleting_history(client):
|
||||
client.post("/api/auth/dev-token")
|
||||
created = client.post(
|
||||
"/api/scenarios/drafts/from-template",
|
||||
json={"source_id": "fire-apartment-l2", "title": "Сценарий для проверки архива"},
|
||||
)
|
||||
assert created.status_code == 201, created.text
|
||||
scenario_id = created.json()["id"]
|
||||
approved = client.post(f"/api/scenarios/drafts/{scenario_id}/approve")
|
||||
assert approved.status_code == 200, approved.text
|
||||
original = store.get(scenario_id)
|
||||
assert original is not None
|
||||
|
||||
archived = client.delete(f"/api/scenarios/{scenario_id}")
|
||||
assert archived.status_code == 200, archived.text
|
||||
assert archived.json()["status"] == "archived"
|
||||
assert store.get(scenario_id) is None
|
||||
assert scenario_id not in {item["id"] for item in client.get("/api/scenarios").json()}
|
||||
assert client.get(f"/api/scenarios/{scenario_id}").status_code == 404
|
||||
|
||||
restored = client.post(f"/api/scenarios/{scenario_id}/restore")
|
||||
assert restored.status_code == 200, restored.text
|
||||
assert restored.json()["status"] == "published"
|
||||
assert store.get(scenario_id).title == original.title
|
||||
assert scenario_id in {item["id"] for item in client.get("/api/scenarios").json()}
|
||||
|
||||
|
||||
def test_instructor_cannot_read_or_edit_another_instructors_draft(client, monkeypatch):
|
||||
from app.api.auth import Principal
|
||||
from app.domain.roles import Role
|
||||
|
||||
identity = {"login": "teacher-one"}
|
||||
|
||||
def instructor(_request, *_roles):
|
||||
return Principal(login=identity["login"], full_name="Преподаватель", role=Role.INSTRUCTOR)
|
||||
|
||||
monkeypatch.setattr(scenarios_api, "require", instructor)
|
||||
monkeypatch.setattr(scenarios_api, "current", instructor)
|
||||
created = client.post(
|
||||
"/api/scenarios/drafts/from-template",
|
||||
json={"source_id": "fire-apartment-l2", "title": "Личный черновик"},
|
||||
)
|
||||
assert created.status_code == 201, created.text
|
||||
scenario_id = created.json()["id"]
|
||||
|
||||
identity["login"] = "teacher-two"
|
||||
assert client.get(f"/api/scenarios/drafts/{scenario_id}").status_code == 404
|
||||
assert client.patch(
|
||||
f"/api/scenarios/drafts/{scenario_id}", json={"title": "Чужая правка"}
|
||||
).status_code == 404
|
||||
assert client.post(f"/api/scenarios/drafts/{scenario_id}/approve").status_code == 404
|
||||
|
||||
|
||||
def test_instructor_cannot_archive_another_instructors_published_scenario(client, monkeypatch):
|
||||
from app.api.auth import Principal
|
||||
from app.domain.roles import Role
|
||||
|
||||
identity = {"login": "teacher-one"}
|
||||
|
||||
def instructor(_request, *_roles):
|
||||
return Principal(login=identity["login"], full_name="Преподаватель", role=Role.INSTRUCTOR)
|
||||
|
||||
monkeypatch.setattr(scenarios_api, "require", instructor)
|
||||
monkeypatch.setattr(scenarios_api, "current", instructor)
|
||||
created = client.post(
|
||||
"/api/scenarios/drafts/from-template",
|
||||
json={"source_id": "fire-apartment-l2", "title": "Публикация автора"},
|
||||
)
|
||||
assert created.status_code == 201, created.text
|
||||
scenario_id = created.json()["id"]
|
||||
assert client.post(f"/api/scenarios/drafts/{scenario_id}/approve").status_code == 200
|
||||
scenario = next(item for item in client.get("/api/scenarios").json() if item["id"] == scenario_id)
|
||||
assert scenario["can_manage"] is True
|
||||
|
||||
identity["login"] = "teacher-two"
|
||||
scenario = next(item for item in client.get("/api/scenarios").json() if item["id"] == scenario_id)
|
||||
assert scenario["can_manage"] is False
|
||||
assert client.delete(f"/api/scenarios/{scenario_id}").status_code == 404
|
||||
|
||||
|
||||
def test_scenario_in_active_lesson_cannot_be_archived(client, monkeypatch):
|
||||
client.post("/api/auth/dev-token")
|
||||
monkeypatch.setattr(scenarios_api.hub, "has_active_scenario", lambda _scenario_id: True)
|
||||
response = client.delete("/api/scenarios/fire-apartment-l2")
|
||||
assert response.status_code == 409
|
||||
assert response.json()["detail"] == "scenario_is_used_by_active_session"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_approved_scenario_restores_from_local_database():
|
||||
source = load_file(LIBRARY / "fire-apartment-l2.yaml", LIBRARY)
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ from pathlib import Path
|
|||
import pytest
|
||||
from pydantic import ValidationError
|
||||
|
||||
from app.domain.events import Metric
|
||||
from app.domain.events import LessonCriteria, Metric
|
||||
from app.scenarios.loader import load_file
|
||||
from app.scenarios.schema import Scenario
|
||||
from app.scoring.gost import GostResult
|
||||
|
|
@ -37,3 +37,12 @@ def test_scenario_accepts_only_known_finite_weights():
|
|||
body["score_weights"] = {"address": 11.0}
|
||||
with pytest.raises(ValidationError, match="от 0 до 10"):
|
||||
Scenario.model_validate(body)
|
||||
|
||||
|
||||
def test_lesson_criteria_validate_metric_weight_overrides():
|
||||
criteria = LessonCriteria(score_weights={"address": 3.5, "dds_ack": 0})
|
||||
assert criteria.score_weights == {"address": 3.5, "dds_ack": 0}
|
||||
with pytest.raises(ValidationError, match="неизвестные метрики"):
|
||||
LessonCriteria(score_weights={"adress": 2})
|
||||
with pytest.raises(ValidationError, match="от 0 до 10"):
|
||||
LessonCriteria(score_weights={"address": 11})
|
||||
|
|
|
|||
|
|
@ -30,6 +30,35 @@ async def test_trainee_cannot_read_foreign_session(monkeypatch):
|
|||
assert error.value.status_code == 403
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_instructor_cannot_read_foreign_session(monkeypatch):
|
||||
who = Principal(login="teacher-a", full_name="Преподаватель A", role=Role.INSTRUCTOR)
|
||||
monkeypatch.setattr(sessions, "require", lambda _: who)
|
||||
|
||||
async def row(_db, _session_id):
|
||||
return SimpleNamespace(owner_login="teacher-b")
|
||||
|
||||
monkeypatch.setattr(sessions.repo, "get_session", row)
|
||||
with pytest.raises(HTTPException) as error:
|
||||
await sessions.read(uuid4(), request(), db=object())
|
||||
assert error.value.status_code == 404
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_instructor_history_is_scoped_to_owner(monkeypatch):
|
||||
who = Principal(login="teacher-a", full_name="Преподаватель A", role=Role.INSTRUCTOR)
|
||||
monkeypatch.setattr(sessions, "require", lambda _: who)
|
||||
seen = {}
|
||||
|
||||
async def history(_db, **filters):
|
||||
seen.update(filters)
|
||||
return []
|
||||
|
||||
monkeypatch.setattr(sessions.repo, "history", history)
|
||||
await sessions.listing(request(), db=object())
|
||||
assert seen["owner_login"] == "teacher-a"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_trainee_cannot_read_foreign_checklist(monkeypatch):
|
||||
who = Principal(login="trainee", full_name="Курсант", role=Role.TRAINEE, trainee_id=uuid4())
|
||||
|
|
|
|||
167
backend/tests/test_session_checkpoint.py
Normal file
167
backend/tests/test_session_checkpoint.py
Normal file
|
|
@ -0,0 +1,167 @@
|
|||
"""Промежуточное состояние занятия переживает смену backend-процесса."""
|
||||
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from pathlib import Path
|
||||
from uuid import uuid4
|
||||
|
||||
import pytest
|
||||
|
||||
from app.domain.events import Exercise, LessonCriteria, SessionMode
|
||||
from app.domain.statuses import PhoneCallPending, ServiceStatus
|
||||
from app.domain.timers import TimerCode
|
||||
from app.scenarios.loader import load_file
|
||||
from app.scoring.grammar import basic_check
|
||||
from app.session.checkpoint import dump_state, load_state
|
||||
from app.session.dds import deliver_due_cards, prepare_queue
|
||||
from app.session.state import SessionState, now_utc
|
||||
|
||||
LIBRARY = Path(__file__).resolve().parents[2] / "scenarios"
|
||||
|
||||
|
||||
def dds_state() -> SessionState:
|
||||
scenario = 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,
|
||||
scenario=scenario.model_copy(deep=True),
|
||||
dds_scenarios=[scenario.model_copy(deep=True)],
|
||||
required_fields=list(scenario.required_fields),
|
||||
trainee_name="Курсант для восстановления",
|
||||
attempt=2,
|
||||
criteria=LessonCriteria(
|
||||
decision_time_limit_seconds=45,
|
||||
allowed_errors=1,
|
||||
require_correct_grammar=True,
|
||||
),
|
||||
)
|
||||
state.timers.limits[TimerCode.DDS_ACK] = 45_000
|
||||
prepare_queue(state, state.dds_scenarios)
|
||||
service = state.notified_services()[0]
|
||||
state.set_service_status(service, ServiceStatus.ACCEPTED, author="диспетчер")
|
||||
state.crew_selected = state.crew_options()[0]
|
||||
state.crew_assignments[service] = state.crew_selected
|
||||
state.phone_pending = PhoneCallPending(
|
||||
service=service, crew=state.crew_selected, phase="dispatched"
|
||||
)
|
||||
state.reply_text = "Сообщение принято, бригада направлена."
|
||||
state.reply_grammar = basic_check(state.reply_text)
|
||||
state.reply_log.append((now_utc(), state.reply_text))
|
||||
state.dds_log.append(("crew.select", now_utc(), state.crew_selected))
|
||||
return state
|
||||
|
||||
|
||||
def test_active_dds_session_round_trips_without_losing_work():
|
||||
before = dds_state()
|
||||
payload = dump_state(before)
|
||||
restored = load_state(
|
||||
payload,
|
||||
datetime.now(UTC) - timedelta(seconds=2),
|
||||
)
|
||||
|
||||
assert restored.session_id == before.session_id
|
||||
assert restored.exercise is Exercise.DDS
|
||||
assert restored.criteria.decision_time_limit_seconds == 45
|
||||
assert restored.dispatched_card == before.dispatched_card
|
||||
assert restored.status_log == before.status_log
|
||||
assert restored.crew_selected == before.crew_selected
|
||||
assert restored.crew_assignments == before.crew_assignments
|
||||
assert restored.phone_pending == before.phone_pending
|
||||
assert restored.reply_text == before.reply_text
|
||||
assert restored.reply_grammar == before.reply_grammar
|
||||
assert restored.dds_scenarios[0].id == before.scenario_id
|
||||
# Время простоя backend входит в норматив, а не обнуляет таймер.
|
||||
timer = next(item for item in restored.timers.snapshot() if item.code is TimerCode.DDS_ACK)
|
||||
assert timer.elapsed_ms >= 1_900
|
||||
assert timer.limit_ms == 45_000
|
||||
|
||||
|
||||
def test_checkpoint_rejects_unknown_format_version():
|
||||
payload = dump_state(dds_state())
|
||||
payload["version"] = 999
|
||||
with pytest.raises(ValueError, match="версия"):
|
||||
load_state(payload, datetime.now(UTC))
|
||||
|
||||
|
||||
def test_concurrent_dds_queue_round_trips_with_each_timer_and_status():
|
||||
first = load_file(LIBRARY / "fire-apartment-l2.yaml", LIBRARY)
|
||||
second = first.model_copy(
|
||||
deep=True,
|
||||
update={"id": "checkpoint-second", "title": "Вторая карточка восстановления"},
|
||||
)
|
||||
state = SessionState(
|
||||
session_id=uuid4(),
|
||||
scenario_id=first.id,
|
||||
scenario_title=first.title,
|
||||
level=first.level.value,
|
||||
mode=SessionMode.TRAINING,
|
||||
exercise=Exercise.DDS,
|
||||
dds_scenarios=[first, second],
|
||||
)
|
||||
prepare_queue(state, state.dds_scenarios)
|
||||
first_id = state.dispatched_card.card_id
|
||||
first_service = state.managed_services()[0]
|
||||
state.set_service_status(first_service, ServiceStatus.ACCEPTED)
|
||||
state.on_event("card.ack")
|
||||
second_id = state.dds_live_cards[1].card_id
|
||||
assert state.activate_dds_card(second_id)
|
||||
|
||||
restored = load_state(
|
||||
dump_state(state),
|
||||
datetime.now(UTC) - timedelta(seconds=2),
|
||||
)
|
||||
|
||||
assert restored.dds_active_card_id == second_id
|
||||
assert restored.dispatched_card.card_id == second_id
|
||||
assert len(restored.dds_live_cards) == 2
|
||||
first_restored = next(item for item in restored.dds_live_cards if item.card_id == first_id)
|
||||
second_restored = next(item for item in restored.dds_live_cards if item.card_id == second_id)
|
||||
assert first_restored.status_log[-1].status is ServiceStatus.ACCEPTED
|
||||
assert first_restored.timers.measured_ms(TimerCode.DDS_ACK) is not None
|
||||
second_timer = next(
|
||||
item for item in second_restored.timers.snapshot() if item.code is TimerCode.DDS_ACK
|
||||
)
|
||||
assert second_timer.elapsed_ms >= 1_900
|
||||
assert second_timer.stopped is False
|
||||
|
||||
|
||||
def test_delivering_next_dds_card_does_not_clear_previous_card_state():
|
||||
first = load_file(LIBRARY / "fire-apartment-l2.yaml", LIBRARY)
|
||||
scenarios = [
|
||||
first.model_copy(
|
||||
deep=True,
|
||||
update={"id": f"scheduled-checkpoint-{index}", "title": f"Карточка {index}"},
|
||||
)
|
||||
for index in range(3)
|
||||
]
|
||||
state = SessionState(
|
||||
session_id=uuid4(),
|
||||
scenario_id=scenarios[0].id,
|
||||
scenario_title=scenarios[0].title,
|
||||
level=scenarios[0].level.value,
|
||||
mode=SessionMode.TRAINING,
|
||||
exercise=Exercise.DDS,
|
||||
dds_scenarios=scenarios,
|
||||
)
|
||||
prepare_queue(state, scenarios, arrival_interval_seconds=60, max_waiting=1)
|
||||
first_id = state.dds_live_cards[0].card_id
|
||||
service = state.managed_services()[0]
|
||||
state.set_service_status(service, ServiceStatus.ACCEPTED)
|
||||
state.capture_active_dds()
|
||||
|
||||
assert deliver_due_cards(state, now_utc() + timedelta(seconds=61)) == 1
|
||||
first = next(card for card in state.dds_live_cards if card.card_id == first_id)
|
||||
assert first.status_log[-1].status is ServiceStatus.ACCEPTED
|
||||
second = next(card for card in state.dds_live_cards if card.original_index == 1)
|
||||
assert state.activate_dds_card(second.card_id)
|
||||
|
||||
restored = load_state(dump_state(state), now_utc())
|
||||
first_restored = next(card for card in restored.dds_live_cards if card.card_id == first_id)
|
||||
assert first_restored.status_log[-1].status is ServiceStatus.ACCEPTED
|
||||
assert restored.dds_active_card_id == second.card_id
|
||||
assert len(restored.dds_live_cards) == 2
|
||||
assert restored.dds_next_scenario_index == 2
|
||||
assert restored.dds_next_arrival_at is not None
|
||||
|
|
@ -104,9 +104,8 @@ def test_acknowledgement_stops_the_four_second_norm(client):
|
|||
control.__exit__(None, None, None)
|
||||
|
||||
|
||||
def test_bounced_card_becomes_e6_with_the_reason(client):
|
||||
"""Неполнота КИО перестаёт быть процентом в отчёте и становится
|
||||
сорванным выездом с конкретной причиной."""
|
||||
def test_dds_cannot_return_card_for_112_quality_control(client):
|
||||
"""ДДС ставит статусы своей службы, а качество КИО контролирует 112."""
|
||||
session_id, control, _ = lesson(client)
|
||||
try:
|
||||
state = hub.get(session_id)
|
||||
|
|
@ -117,14 +116,15 @@ def test_bounced_card_becomes_e6_with_the_reason(client):
|
|||
read_until(station, "card.received")
|
||||
station.send_json({"type": "card.bounce", "missing_fields": ["floor", "victims_count"],
|
||||
"comment": "куда ехать без этажа"})
|
||||
wait_for(lambda: state.bounced_fields)
|
||||
error = read_until(station, "error")
|
||||
assert "служба контроля 112" in error["message"]
|
||||
assert state.bounced_fields == []
|
||||
trainee.send_json({"type": "call.hangup"})
|
||||
wait_for(lambda: state.score is not None)
|
||||
|
||||
response = client.get(f"/api/sessions/{session_id}/report").json()
|
||||
chain = [finding for finding in response["findings"] if finding["code"] == "E6"]
|
||||
assert chain, "возврат карточки должен попасть в разбор"
|
||||
assert "floor" in chain[0]["summary"] and "выезд сорван" in chain[0]["summary"]
|
||||
assert chain == []
|
||||
finally:
|
||||
control.__exit__(None, None, None)
|
||||
|
||||
|
|
@ -160,8 +160,9 @@ def test_station_sees_the_notify_list_from_the_classifier(client):
|
|||
session_id, station, contexts = dispatched(client)
|
||||
try:
|
||||
state = read_until(station, "station.state")
|
||||
assert "Служба 101" in state["snapshot"]["services"]
|
||||
assert len(state["snapshot"]["services"]) > 1
|
||||
assert state["snapshot"]["services"] == ["Служба 101"]
|
||||
assert "Служба 101" in state["snapshot"]["recipient_services"]
|
||||
assert len(state["snapshot"]["recipient_services"]) > 1
|
||||
assert state["snapshot"]["statuses"]["Служба 101"] == "added"
|
||||
finally:
|
||||
close_all(contexts)
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@
|
|||
|
||||
import contextlib
|
||||
import time
|
||||
import wave
|
||||
from uuid import uuid4
|
||||
|
||||
import pytest
|
||||
|
|
@ -256,6 +257,32 @@ def test_events_still_work_between_audio_frames(client):
|
|||
assert state.audio_frames == 2
|
||||
|
||||
|
||||
def test_answered_call_is_saved_as_wav_on_hangup(client, monkeypatch, tmp_path):
|
||||
from app.api.ws import call as call_api
|
||||
from app.voice.recording import CallRecorder
|
||||
|
||||
monkeypatch.setattr(
|
||||
call_api, "start_recording",
|
||||
lambda session_id: CallRecorder(tmp_path / f"{session_id}.wav"),
|
||||
)
|
||||
with lesson(client) as (session_id, _):
|
||||
state = hub.get(session_id)
|
||||
path = tmp_path / f"{session_id}.wav"
|
||||
with client.websocket_connect(f"/ws/call/{session_id}") as trainee:
|
||||
read_until(trainee, "call.incoming")
|
||||
trainee.send_json({"type": "call.answer"})
|
||||
read_until(trainee, "call.started")
|
||||
trainee.send_bytes((1000).to_bytes(2, "little", signed=True) * 320)
|
||||
trainee.send_json({"type": "call.hangup"})
|
||||
read_until(trainee, "call.ended")
|
||||
wait_for(path.is_file)
|
||||
|
||||
assert state.recording_path == str(path)
|
||||
with wave.open(str(path), "rb") as source:
|
||||
assert source.getframerate() == 16_000
|
||||
assert source.getnframes() >= 320
|
||||
|
||||
|
||||
def test_score_waits_for_self_assessment(client):
|
||||
"""Курсант сначала сверяет своё ощущение с объективной картиной:
|
||||
расхождение самооценки с автооценкой — отдельный материал для преподавателя."""
|
||||
|
|
|
|||
Loading…
Reference in a new issue