Implement DDS exercise, customer UI and local demo
This commit is contained in:
parent
cec84ffcd0
commit
0526b11f91
46 changed files with 2504 additions and 253 deletions
167
backend/tests/test_auth_hardening.py
Normal file
167
backend/tests/test_auth_hardening.py
Normal file
|
|
@ -0,0 +1,167 @@
|
|||
"""Regressions for stale cookies and privileged admin operations."""
|
||||
|
||||
from datetime import datetime, timezone
|
||||
from types import SimpleNamespace
|
||||
from uuid import uuid4
|
||||
|
||||
import pytest
|
||||
from fastapi import HTTPException
|
||||
from fastapi.testclient import TestClient
|
||||
from starlette.websockets import WebSocketDisconnect
|
||||
|
||||
import app.api.auth as auth
|
||||
from app.api.http import admin
|
||||
from app.domain.roles import Role
|
||||
from app.main import app
|
||||
from app.session.hub import hub
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def client():
|
||||
with TestClient(app) as test_client:
|
||||
hub.journal = None
|
||||
yield test_client
|
||||
|
||||
|
||||
def test_account_change_revokes_http_and_new_websocket_handshakes(client):
|
||||
assert client.post("/api/auth/dev-token").status_code == 200
|
||||
assert client.get("/api/auth/me").status_code == 200
|
||||
auth.invalidate_login("dev")
|
||||
assert client.get("/api/auth/me").status_code == 401
|
||||
with client.websocket_connect(f"/ws/control/{uuid4()}") as socket:
|
||||
assert socket.receive_json()["code"] == "forbidden"
|
||||
assert client.post("/api/auth/dev-token").status_code == 200
|
||||
assert client.get("/api/auth/me").status_code == 200
|
||||
|
||||
|
||||
def test_account_change_closes_an_existing_websocket(client):
|
||||
assert client.post("/api/auth/dev-token").status_code == 200
|
||||
with client.websocket_connect(f"/ws/control/{uuid4()}") as socket:
|
||||
auth.invalidate_login("dev")
|
||||
with pytest.raises(WebSocketDisconnect) as exc:
|
||||
socket.receive_json()
|
||||
assert exc.value.code == 1008
|
||||
|
||||
|
||||
def test_cookie_from_previous_process_is_rejected(client, monkeypatch):
|
||||
assert client.post("/api/auth/dev-token").status_code == 200
|
||||
monkeypatch.setattr(auth, "_INSTANCE", "new-server-instance")
|
||||
assert client.get("/api/auth/me").status_code == 401
|
||||
|
||||
|
||||
class FakeDb:
|
||||
def __init__(self, user):
|
||||
self.user = user
|
||||
self.commits = 0
|
||||
self.added = []
|
||||
|
||||
async def get(self, _model, _id):
|
||||
return self.user
|
||||
|
||||
def add(self, object_):
|
||||
self.added.append(object_)
|
||||
|
||||
async def flush(self):
|
||||
for object_ in self.added:
|
||||
if getattr(object_, "id", None) is None:
|
||||
object_.id = uuid4()
|
||||
|
||||
async def commit(self):
|
||||
self.commits += 1
|
||||
|
||||
|
||||
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),
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_admin_patch_revokes_cookie_after_commit(monkeypatch):
|
||||
user = fake_user()
|
||||
db = FakeDb(user)
|
||||
calls = []
|
||||
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)))
|
||||
|
||||
async def no_audit(*_args, **_kwargs):
|
||||
return None
|
||||
|
||||
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)]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_admin_cannot_demote_self(monkeypatch):
|
||||
user = fake_user(login="admin", role="admin")
|
||||
db = FakeDb(user)
|
||||
monkeypatch.setattr(admin, "require", lambda _request, *_roles: auth.Principal(
|
||||
login="admin", full_name="Администратор", role=Role.ADMIN,
|
||||
))
|
||||
with pytest.raises(HTTPException) as exc:
|
||||
await admin.patch_user(user.id, admin.UserPatch(role=Role.TRAINEE), object(), db)
|
||||
assert exc.value.status_code == 409
|
||||
assert db.commits == 0
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_promotion_to_trainee_creates_profile(monkeypatch):
|
||||
user = fake_user()
|
||||
db = FakeDb(user)
|
||||
monkeypatch.setattr(admin, "require", lambda _request, *_roles: auth.Principal(
|
||||
login="admin", full_name="Администратор", role=Role.ADMIN,
|
||||
))
|
||||
|
||||
async def no_audit(*_args, **_kwargs):
|
||||
return None
|
||||
|
||||
monkeypatch.setattr(admin, "audit", no_audit)
|
||||
await admin.patch_user(user.id, admin.UserPatch(role=Role.TRAINEE), object(), db)
|
||||
assert user.role == "trainee"
|
||||
assert user.trainee_id is not None
|
||||
assert db.commits == 1
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_backup_runs_off_event_loop_and_failure_is_audited(monkeypatch):
|
||||
who = auth.Principal(login="admin", full_name="Администратор", role=Role.ADMIN)
|
||||
monkeypatch.setattr(admin, "require", lambda _request, *_roles: who)
|
||||
calls = []
|
||||
|
||||
async def fake_threadpool(fn):
|
||||
calls.append(fn)
|
||||
return fn()
|
||||
|
||||
async def fake_audit(*args, **kwargs):
|
||||
calls.append((args, kwargs))
|
||||
|
||||
monkeypatch.setattr(admin, "run_in_threadpool", fake_threadpool)
|
||||
monkeypatch.setattr(admin, "audit", fake_audit)
|
||||
monkeypatch.setattr(admin.backup_service, "create", lambda: {
|
||||
"name": "example.sql", "size_bytes": 1, "at": datetime.now(timezone.utc),
|
||||
})
|
||||
assert (await admin.make_backup(object())).name == "example.sql"
|
||||
assert calls[0] is admin.backup_service.create
|
||||
|
||||
def broken():
|
||||
raise admin.backup_service.BackupError("pg_dump failed")
|
||||
|
||||
monkeypatch.setattr(admin.backup_service, "create", broken)
|
||||
with pytest.raises(HTTPException) as exc:
|
||||
await admin.make_backup(object())
|
||||
assert exc.value.status_code == 503
|
||||
assert any(isinstance(item, tuple) and item[0][2] == "backup.failed" for item in calls)
|
||||
|
||||
|
||||
def test_backup_error_redacts_database_credentials(monkeypatch):
|
||||
dsn = "postgresql://user:supersecret@localhost:5432/example"
|
||||
monkeypatch.setattr(admin, "get_settings", lambda: SimpleNamespace(database_url=dsn))
|
||||
detail = admin._safe_backup_error(admin.backup_service.BackupError(f"bad DSN: {dsn}"))
|
||||
assert "supersecret" not in detail
|
||||
assert dsn not in detail
|
||||
|
|
@ -139,3 +139,124 @@ def test_complete_dds_workflow_scores_without_call_penalties(client):
|
|||
assert all(metric["key"].startswith("dds_") for metric in score["metrics"])
|
||||
finally:
|
||||
control.__exit__(None, None, None)
|
||||
|
||||
|
||||
def test_sequential_dds_cards_keep_separate_state_and_scores(client):
|
||||
session_id = uuid4()
|
||||
control_ctx = client.websocket_connect(f"/ws/control/{session_id}")
|
||||
control = control_ctx.__enter__()
|
||||
control.send_json({
|
||||
"type": "scenario.start", "scenario_id": "fire-apartment-l2",
|
||||
"scenario_ids": ["fire-apartment-l2", "t20-2-stroke"],
|
||||
"trainee": "Иванов", "mode": "training", "exercise": "dds",
|
||||
})
|
||||
wait_for(lambda: hub.get(session_id))
|
||||
try:
|
||||
with client.websocket_connect(f"/ws/station/{session_id}") as station:
|
||||
first = read_until(station, "card.received")
|
||||
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})
|
||||
second = read_until(station, "card.received")
|
||||
second_card_id = second["card"]["card_id"]
|
||||
assert second_card_id != first_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
|
||||
|
||||
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": "Сообщение принято, бригада направлена на место происшествия."})
|
||||
read_until(station, "station.state")
|
||||
station.send_json({"type": "card.next", "card_id": second_card_id})
|
||||
read_until(station, "score.ready")
|
||||
|
||||
state = hub.get(session_id)
|
||||
assert state.ended and len(state.dds_completed) == 2
|
||||
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 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 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):
|
||||
session_id = uuid4()
|
||||
control_ctx = client.websocket_connect(f"/ws/control/{session_id}")
|
||||
control = control_ctx.__enter__()
|
||||
control.send_json({
|
||||
"type": "scenario.start", "scenario_id": "fire-apartment-l2",
|
||||
"scenario_ids": ["fire-apartment-l2", "t20-2-stroke"],
|
||||
"trainee": "Иванов", "mode": "training", "exercise": "dds",
|
||||
})
|
||||
wait_for(lambda: hub.get(session_id))
|
||||
try:
|
||||
with client.websocket_connect(f"/ws/station/{session_id}") as station:
|
||||
read_until(station, "card.received")
|
||||
read_until(station, "station.state")
|
||||
control.send_json({"type": "session.stop"})
|
||||
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"
|
||||
finally:
|
||||
control_ctx.__exit__(None, None, None)
|
||||
|
||||
|
||||
def test_each_dds_card_uses_its_own_scenario_weights():
|
||||
from pathlib import Path
|
||||
|
||||
from app.domain.events import Exercise, SessionMode
|
||||
from app.scenarios.loader import load_file
|
||||
from app.session.dds import prepare_card
|
||||
from app.session.finish import score_current_dds
|
||||
from app.session.state import SessionState
|
||||
|
||||
root = Path(__file__).resolve().parents[2] / "scenarios"
|
||||
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}
|
||||
state = SessionState(
|
||||
session_id=uuid4(), scenario_id=base.id, scenario_title=base.title,
|
||||
level=base.level.value, mode=SessionMode.TRAINING, exercise=Exercise.DDS,
|
||||
dds_scenarios=[first, second],
|
||||
)
|
||||
prepare_card(state, first)
|
||||
first_record = score_current_dds(state)
|
||||
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
|
||||
|
|
|
|||
75
backend/tests/test_demo_no_db.py
Normal file
75
backend/tests/test_demo_no_db.py
Normal file
|
|
@ -0,0 +1,75 @@
|
|||
"""Явный локальный демо-режим не зависит от Postgres."""
|
||||
|
||||
import time
|
||||
from uuid import uuid4
|
||||
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from app.api.auth import DEMO_TRAINEE_ID
|
||||
from app.config import get_settings
|
||||
from app.main import app
|
||||
from app.session.hub import hub
|
||||
|
||||
|
||||
def _wait_for(predicate, timeout=3):
|
||||
until = time.monotonic() + timeout
|
||||
while time.monotonic() < until:
|
||||
value = predicate()
|
||||
if value:
|
||||
return value
|
||||
time.sleep(0.02)
|
||||
raise AssertionError("занятие не стартовало")
|
||||
|
||||
|
||||
def _read_until(socket, wanted):
|
||||
for _ in range(20):
|
||||
event = socket.receive_json()
|
||||
if event["type"] == wanted:
|
||||
return event
|
||||
raise AssertionError(f"не пришло событие {wanted}")
|
||||
|
||||
|
||||
def test_demo_without_db_starts_dds_and_issues_owned_trainee_cookie(monkeypatch):
|
||||
monkeypatch.setenv("DEMO_NO_DB", "true")
|
||||
monkeypatch.setenv("DEV_AUTH_BYPASS", "true")
|
||||
monkeypatch.setenv("VOICE_ENABLED", "false")
|
||||
get_settings.cache_clear()
|
||||
try:
|
||||
with TestClient(app) as client:
|
||||
health = client.get("/api/health").json()
|
||||
assert health["status"] == "ok" and health["demo_no_db"] is True
|
||||
assert health["scenarios_loaded"] > 0
|
||||
assert hub.journal is None
|
||||
|
||||
assert client.post("/api/auth/login", json={
|
||||
"login": "demo-instructor", "password": "demo"
|
||||
}).json()["role"] == "instructor"
|
||||
assert client.post("/api/auth/login", json={
|
||||
"login": "unknown", "password": "demo"
|
||||
}).status_code == 401
|
||||
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}
|
||||
]
|
||||
# БД-зависимые экраны получают быстрый и явный отказ, не ждут TCP timeout.
|
||||
assert client.get("/api/sessions").json()["detail"] == "database_disabled_demo"
|
||||
|
||||
session_id = uuid4()
|
||||
with client.websocket_connect(f"/ws/control/{session_id}") as control:
|
||||
control.send_json({
|
||||
"type": "scenario.start", "scenario_id": "fire-apartment-l2",
|
||||
"trainee": "Демо-курсант", "trainee_id": str(DEMO_TRAINEE_ID),
|
||||
"mode": "training", "exercise": "dds",
|
||||
})
|
||||
state = _wait_for(lambda: hub.get(session_id))
|
||||
assert state.trainee_id == DEMO_TRAINEE_ID
|
||||
who = client.post("/api/auth/login", json={
|
||||
"login": "demo-trainee", "password": "demo"
|
||||
}).json()
|
||||
assert who["trainee_id"] == str(DEMO_TRAINEE_ID)
|
||||
with client.websocket_connect(f"/ws/station/{session_id}") as station:
|
||||
assert _read_until(station, "card.received")["card"]["address"]
|
||||
assert _read_until(station, "station.state")["snapshot"]["card_index"] == 1
|
||||
finally:
|
||||
get_settings.cache_clear()
|
||||
75
backend/tests/test_group_analytics.py
Normal file
75
backend/tests/test_group_analytics.py
Normal file
|
|
@ -0,0 +1,75 @@
|
|||
"""Групповая сводка считает людей, а не число их повторных попыток."""
|
||||
|
||||
from uuid import uuid4
|
||||
|
||||
import pytest
|
||||
from fastapi import HTTPException
|
||||
from starlette.requests import Request
|
||||
|
||||
from app.api import auth
|
||||
from app.api.http import groups as group_api
|
||||
from app.api.auth import Principal
|
||||
from app.domain.roles import Role
|
||||
from app.scoring.group import ScoredAttempt, summarize
|
||||
|
||||
|
||||
def test_group_errors_count_distinct_trainees_and_give_actions():
|
||||
first, second = uuid4(), uuid4()
|
||||
result = summarize([
|
||||
ScoredAttempt(first, 40.0, {"E1": 2, "D1": 1}),
|
||||
ScoredAttempt(first, 60.0, {"E1": 1}),
|
||||
ScoredAttempt(second, 80.0, {"E1": 1}),
|
||||
], enrolled=3)
|
||||
|
||||
assert result["active_trainees"] == 2
|
||||
assert result["scored_attempts"] == 3
|
||||
assert result["average_score"] == 60.0
|
||||
assert result["errors"][0]["code"] == "E1"
|
||||
assert result["errors"][0]["affected_trainees"] == 2
|
||||
assert result["errors"][0]["occurrences"] == 4
|
||||
assert result["errors"][0]["rate_percent"] == 100.0
|
||||
assert result["errors"][0]["recommendation"]
|
||||
|
||||
|
||||
def test_group_without_scored_attempts_has_no_fake_recommendations():
|
||||
result = summarize([], enrolled=5)
|
||||
assert result == {
|
||||
"enrolled_trainees": 5,
|
||||
"active_trainees": 0,
|
||||
"scored_attempts": 0,
|
||||
"average_score": None,
|
||||
"errors": [],
|
||||
}
|
||||
|
||||
|
||||
def test_unknown_codes_do_not_break_group_summary():
|
||||
result = summarize([ScoredAttempt(uuid4(), 75.0, {"unknown": 2, "E5": 1})], enrolled=1)
|
||||
assert [item["code"] for item in result["errors"]] == ["E5"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_group_creation_requires_staff_and_returns_new_group(monkeypatch):
|
||||
class FakeDb:
|
||||
def add(self, group):
|
||||
group.id = uuid4()
|
||||
|
||||
async def commit(self):
|
||||
pass
|
||||
|
||||
async def no_audit(*args):
|
||||
pass
|
||||
|
||||
monkeypatch.setattr(group_api, "audit", no_audit)
|
||||
instructor = Principal(login="teacher", full_name="Преподаватель", role=Role.INSTRUCTOR)
|
||||
request = Request({"type": "http", "session": {}})
|
||||
auth._issue_session(request, instructor)
|
||||
created = await group_api.create(group_api.GroupCreate(name=" Группа 1 "), request, FakeDb())
|
||||
assert created.name == "Группа 1"
|
||||
assert created.id
|
||||
|
||||
trainee = Principal(login="trainee", full_name="Курсант", role=Role.TRAINEE)
|
||||
forbidden = Request({"type": "http", "session": {}})
|
||||
auth._issue_session(forbidden, trainee)
|
||||
with pytest.raises(HTTPException) as exc:
|
||||
await group_api.create(group_api.GroupCreate(name="Чужая"), forbidden, FakeDb())
|
||||
assert exc.value.status_code == 403
|
||||
39
backend/tests/test_score_weights.py
Normal file
39
backend/tests/test_score_weights.py
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
"""Веса метрик настраиваются сценарием и не меняют факт проверки."""
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
from pydantic import ValidationError
|
||||
|
||||
from app.domain.events import Metric
|
||||
from app.scenarios.loader import load_file
|
||||
from app.scenarios.schema import Scenario
|
||||
from app.scoring.gost import GostResult
|
||||
from app.scoring.weights import apply_weights
|
||||
|
||||
|
||||
LIBRARY = Path(__file__).resolve().parents[2] / "scenarios"
|
||||
|
||||
|
||||
def test_weight_override_changes_score_but_not_findings():
|
||||
result = GostResult(metrics=[
|
||||
Metric(key="address", title="Адрес", fact="пусто", norm="заполнен", passed=False, weight=2),
|
||||
Metric(key="required_fields", title="Поля", fact="есть", norm="есть", passed=True, weight=2),
|
||||
])
|
||||
assert result.score == 50.0
|
||||
apply_weights(result, {"address": 6.0})
|
||||
assert result.score == 25.0
|
||||
assert result.metrics[0].fact == "пусто"
|
||||
|
||||
|
||||
def test_scenario_accepts_only_known_finite_weights():
|
||||
source = load_file(LIBRARY / "fire-apartment-l2.yaml", LIBRARY)
|
||||
body = source.model_dump(mode="json")
|
||||
body["score_weights"] = {"address": 3.0, "dds_ack": 0.0}
|
||||
assert Scenario.model_validate(body).score_weights["address"] == 3.0
|
||||
body["score_weights"] = {"typo": 3.0}
|
||||
with pytest.raises(ValidationError, match="неизвестные метрики"):
|
||||
Scenario.model_validate(body)
|
||||
body["score_weights"] = {"address": 11.0}
|
||||
with pytest.raises(ValidationError, match="от 0 до 10"):
|
||||
Scenario.model_validate(body)
|
||||
|
|
@ -366,7 +366,34 @@ def test_instructor_correction_keeps_the_automatic_score(client):
|
|||
|
||||
assert corrected["score_final"] == 80.0
|
||||
assert corrected["score_auto"] == auto, "автооценка должна сохраниться рядом"
|
||||
assert corrected["overridden_by"] == "преподаватель"
|
||||
assert corrected["overridden_by"] == "dev"
|
||||
|
||||
|
||||
def test_ws_score_override_rejects_other_session_and_invalid_value(client):
|
||||
with lesson(client) as (session_id, control):
|
||||
state = hub.get(session_id)
|
||||
with client.websocket_connect(f"/ws/call/{session_id}") as trainee:
|
||||
trainee.send_json({"type": "call.answer"})
|
||||
trainee.send_json({"type": "call.hangup"})
|
||||
wait_for(lambda: state.score is not None)
|
||||
auto = state.score["score_auto"]
|
||||
control.send_json({
|
||||
"type": "score.override", "session_id": str(uuid4()),
|
||||
"verdict": "90", "comment": "не тот номер",
|
||||
})
|
||||
control.send_json({
|
||||
"type": "score.override", "session_id": str(session_id),
|
||||
"verdict": "nan", "comment": "не число",
|
||||
})
|
||||
time.sleep(0.1)
|
||||
assert state.score["score_auto"] == auto
|
||||
assert "score_final" not in state.score
|
||||
control.send_json({
|
||||
"type": "score.override", "session_id": str(session_id),
|
||||
"verdict": "85", "comment": "ручная проверка",
|
||||
})
|
||||
wait_for(lambda: state.score.get("score_final") == 85)
|
||||
assert state.score["overridden_by"] == "dev"
|
||||
|
||||
|
||||
def test_soft_directive_changes_how_the_caller_sounds(client):
|
||||
|
|
|
|||
Loading…
Reference in a new issue