chore: обновить паузу занятия от main
# Conflicts: # backend/app/scoring/dispatcher.py # backend/app/session/store.py
This commit is contained in:
commit
0d58878cbe
51 changed files with 4545 additions and 87 deletions
|
|
@ -12,6 +12,20 @@ from pathlib import Path
|
|||
from urllib.parse import urlparse
|
||||
|
||||
os.environ.setdefault("DEV_AUTH_BYPASS", "true")
|
||||
# Тесты ждут локальную модель по умолчанию. Личный backend/.env с облачным
|
||||
# провайдером иначе валит проверки офлайн-гарда; переменные окружения
|
||||
# перекрывают .env, а живые проверки test_llm.py выставляют свои поверх.
|
||||
# Рекомендации Vikhr выключены: на WSL2 подключение к закрытому loopback-порту
|
||||
# висит до таймаута, и финиш занятия ждал бы 6 с. Тесты рекомендаций
|
||||
# подменяют coach сами.
|
||||
os.environ.update({
|
||||
"LLM_PROVIDER": "local",
|
||||
"LLM_BASE_URL": "http://127.0.0.1:18080/v1",
|
||||
"LLM_API_KEY": "",
|
||||
"LLM_MODEL_CALLER": "Qwen3-1.7B",
|
||||
"LLM_MODEL_JUDGE": "",
|
||||
"ASSESSMENT_FEEDBACK_ENABLED": "false",
|
||||
})
|
||||
|
||||
import pytest # noqa: E402
|
||||
|
||||
|
|
@ -36,6 +50,19 @@ def postgres_access():
|
|||
pytest.skip(f"PostgreSQL unavailable ({exc})")
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def template_caller():
|
||||
"""Звонящий без модели: сквозные тесты занятия ждут заготовки и офлайн-
|
||||
ответы. С моделью по умолчанию (loopback Qwen) фабрика выбрала бы
|
||||
LlmCaller и ждала неподнятый сервер."""
|
||||
from app.dialog import factory
|
||||
|
||||
settings = get_settings().model_copy(update={"llm_provider": "disabled"})
|
||||
with pytest.MonkeyPatch.context() as patch:
|
||||
patch.setattr(factory, "get_settings", lambda: settings)
|
||||
yield
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True, scope="session")
|
||||
def _dev_auth():
|
||||
"""Флаг обхода читается один раз при создании настроек."""
|
||||
|
|
|
|||
|
|
@ -272,6 +272,40 @@ def test_peer_node_generation_sync_closes_revoked_websocket(monkeypatch):
|
|||
asyncio.run(run())
|
||||
|
||||
|
||||
def test_generation_sync_keeps_revocation_of_login_missing_from_db(monkeypatch):
|
||||
# Демо-логины синтетические, в users их нет. Сброс поколения к 0 после
|
||||
# выхода вернул бы силу cookie, выданной до выхода.
|
||||
login = "demo-instructor"
|
||||
|
||||
class FakeResult:
|
||||
def all(self):
|
||||
return []
|
||||
|
||||
class FakeDb:
|
||||
async def execute(self, _query):
|
||||
return FakeResult()
|
||||
|
||||
class FakeSession:
|
||||
async def __aenter__(self):
|
||||
return FakeDb()
|
||||
|
||||
async def __aexit__(self, *_args):
|
||||
return None
|
||||
|
||||
monkeypatch.setattr(auth, "get_sessionmaker", lambda: lambda: FakeSession())
|
||||
auth.prime_generations({})
|
||||
who = auth._demo_principal(Role.INSTRUCTOR)
|
||||
request = SimpleNamespace(session={})
|
||||
auth._issue_session(request, who)
|
||||
stale = dict(request.session)
|
||||
|
||||
auth.invalidate_login(login)
|
||||
asyncio.run(auth.sync_generations())
|
||||
|
||||
assert auth._session_principal(stale) is None
|
||||
auth._generations.pop(login, None)
|
||||
|
||||
|
||||
def test_revocation_does_not_log_error_if_socket_already_disconnected():
|
||||
class DisconnectedSocket:
|
||||
async def close(self, **_kwargs):
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
|
||||
import asyncio
|
||||
import time
|
||||
from datetime import datetime
|
||||
from datetime import datetime, timedelta
|
||||
from uuid import uuid4
|
||||
|
||||
import pytest
|
||||
|
|
@ -396,6 +396,7 @@ def test_instructor_criteria_change_timer_and_success_threshold(client, monkeypa
|
|||
client,
|
||||
criteria={
|
||||
"decision_time_limit_seconds": 45,
|
||||
"dds_report_reaction_limit_seconds": 20,
|
||||
"allowed_errors": 50,
|
||||
"require_correct_grammar": False,
|
||||
"score_weights": {"dds_ack": 3.5},
|
||||
|
|
@ -454,6 +455,7 @@ def test_instructor_criteria_change_timer_and_success_threshold(client, monkeypa
|
|||
"decision_time_limit_seconds": 45,
|
||||
"card_fill_time_limit_seconds": 180,
|
||||
"dds_card_work_time_limit_seconds": 180,
|
||||
"dds_report_reaction_limit_seconds": 20,
|
||||
"allowed_errors": 50,
|
||||
"require_correct_grammar": False,
|
||||
"score_weights": {"dds_ack": 3.5},
|
||||
|
|
@ -797,6 +799,118 @@ def test_complete_dds_workflow_with_training_calls_and_status_updates(client):
|
|||
]
|
||||
assert all(report.crew == crew for report in state.desk.active.phone_reports)
|
||||
assert not score["findings"]
|
||||
reaction = [m for m in score["metrics"] if m["key"] == "dds_report_reaction"]
|
||||
assert len(reaction) == 1 and reaction[0]["passed"], "каждый доклад отмечен вовремя"
|
||||
assert "с нарушением 0 из 4 докладов" in reaction[0]["fact"]
|
||||
finally:
|
||||
control.__exit__(None, None, None)
|
||||
|
||||
|
||||
STATUS_COMMENT = "Основание: доклад бригады.\nСведения: передано старшему бригады."
|
||||
|
||||
|
||||
def accept_with_crew(station):
|
||||
read_until(station, "card.received")
|
||||
snapshot = read_until(station, "station.state")["snapshot"]
|
||||
service = snapshot["services"][0]
|
||||
crew = next(option for option in snapshot["crew_options"]
|
||||
if option.startswith(service + " — "))
|
||||
station.send_json({"type": "card.status", "service": service, "status": "accepted",
|
||||
"comment": STATUS_COMMENT})
|
||||
read_until(station, "station.state")
|
||||
station.send_json({"type": "crew.select", "crew": crew})
|
||||
read_until(station, "station.state")
|
||||
return service
|
||||
|
||||
|
||||
def send_status(station, service, status):
|
||||
station.send_json({"type": "card.status", "service": service, "status": status,
|
||||
"comment": STATUS_COMMENT})
|
||||
read_until(station, "station.state")
|
||||
|
||||
|
||||
@pytest.mark.parametrize(("reaction", "passed"), [
|
||||
("fast", True), ("slow", False), ("missing", False), ("early", True),
|
||||
])
|
||||
def test_dds_report_reaction_goes_from_criteria_to_report(client, reaction, passed):
|
||||
"""Лимит из `scenario.start` доходит до метрики, отметки D6 и отчёта."""
|
||||
session_id, control = start(client, criteria={
|
||||
"dds_report_reaction_limit_seconds": 20,
|
||||
"score_weights": {"dds_report_reaction": 3.5},
|
||||
})
|
||||
try:
|
||||
state = hub.get(session_id)
|
||||
with client.websocket_connect(f"/ws/station/{session_id}") as station:
|
||||
service = accept_with_crew(station)
|
||||
if reaction == "early":
|
||||
send_status(station, service, "responding")
|
||||
complete_phone_call(station, state, "dispatched")
|
||||
read_until(station, "station.state")
|
||||
if reaction in {"fast", "slow"}:
|
||||
send_status(station, service, "responding")
|
||||
if reaction == "slow":
|
||||
# Доклад на 70 с раньше статуса: время доклада — серверное.
|
||||
state.desk.active.phone_reports[0].at -= timedelta(seconds=70)
|
||||
station.send_json({"type": "station.finish"})
|
||||
read_until(station, "score.ready")
|
||||
|
||||
score = wait_for(lambda: hub.get(session_id).score)
|
||||
metrics = [item for item in score["metrics"] if item["key"] == "dds_report_reaction"]
|
||||
assert len(metrics) == 1
|
||||
metric = metrics[0]
|
||||
assert metric["passed"] is passed
|
||||
assert metric["weight"] == 3.5
|
||||
assert metric["norm"] == "отметить статус по докладу за 20 с"
|
||||
assert "«Начало реагирования» по докладу бригады" in metric["fact"]
|
||||
findings = [item for item in score["findings"]
|
||||
if item["metric_key"] == "dds_report_reaction"]
|
||||
assert [item["code"] for item in findings] == ([] if passed else ["D6"])
|
||||
assert all(item["competency"] == "norms" for item in findings)
|
||||
if reaction == "slow":
|
||||
assert "70 с (+50 с сверх норматива)" in metric["fact"]
|
||||
assert findings[0]["fact"] == metric["fact"]
|
||||
if reaction == "missing":
|
||||
assert metric["fact"].endswith(": не отмечен") and metric["credit"] == 0.0
|
||||
if reaction == "early":
|
||||
assert metric["fact"].endswith("отмечен не позже доклада")
|
||||
|
||||
report = client.get(f"/api/sessions/{session_id}/report").json()
|
||||
assert report["criteria"]["dds_report_reaction_limit_seconds"] == 20
|
||||
assert [item for item in report["metrics"]
|
||||
if item["key"] == "dds_report_reaction"] == metrics
|
||||
if reaction == "slow":
|
||||
csv_text = client.get(f"/api/sessions/{session_id}/report.csv").content.decode("utf-8-sig")
|
||||
assert "Лимит реакции на доклад бригады" in csv_text
|
||||
assert "70 с (+50 с сверх норматива)" in csv_text
|
||||
finally:
|
||||
control.__exit__(None, None, None)
|
||||
|
||||
|
||||
def test_refusal_after_crew_report_does_not_lower_score_without_finding(client):
|
||||
"""Бригада доложила о прибытии, но работы не проводились: отказ с причиной
|
||||
— терминальный исход, неотмеченное «Прибытие» не штрафуется."""
|
||||
session_id, control = start(client)
|
||||
try:
|
||||
state = hub.get(session_id)
|
||||
with client.websocket_connect(f"/ws/station/{session_id}") as station:
|
||||
service = accept_with_crew(station)
|
||||
complete_phone_call(station, state, "dispatched")
|
||||
read_until(station, "station.state")
|
||||
send_status(station, service, "responding")
|
||||
complete_phone_call(station, state, "arrived")
|
||||
read_until(station, "station.state")
|
||||
send_status(station, service, "refused")
|
||||
station.send_json({"type": "station.finish"})
|
||||
read_until(station, "score.ready")
|
||||
|
||||
score = wait_for(lambda: hub.get(session_id).score)
|
||||
assert [report.phase for report in state.desk.active.phone_reports] == [
|
||||
"dispatched", "arrived",
|
||||
]
|
||||
metric = next(item for item in score["metrics"] if item["key"] == "dds_report_reaction")
|
||||
assert metric["passed"], "прибытие при отказе не требуется"
|
||||
assert "о выезде" in metric["fact"] and "докладов" not in metric["fact"]
|
||||
assert not [item for item in score["findings"] if item["code"] == "D6"]
|
||||
finally:
|
||||
control.__exit__(None, None, None)
|
||||
|
||||
|
|
|
|||
|
|
@ -1,13 +1,14 @@
|
|||
from datetime import datetime, timezone
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
from pydantic import ValidationError
|
||||
|
||||
from app.domain.statuses import ServiceStatus, StatusEntry
|
||||
from app.domain.taxonomy import ErrorCode
|
||||
from app.domain.statuses import PhoneReportRecord, ServiceStatus, StatusEntry
|
||||
from app.domain.taxonomy import Competency, ErrorCode, FindingSource
|
||||
from app.scenarios.loader import load_file
|
||||
from app.scoring.dispatcher import dispatcher_metrics, evaluate_dispatcher
|
||||
from app.scoring.timing import time_credit
|
||||
from app.session.dds import DdsLiveCard, build_card
|
||||
from app.session.timers import SessionTimers
|
||||
|
||||
|
|
@ -294,3 +295,158 @@ def test_reasoned_refusal_after_acceptance_is_a_valid_terminal_path():
|
|||
deadline_ms=30_000, elapsed_ms=0,
|
||||
)
|
||||
assert ErrorCode.D6 not in [finding.code for finding in findings]
|
||||
|
||||
|
||||
# ── Реакция на доклад бригады (lct-37) ──
|
||||
|
||||
T0 = datetime(2026, 9, 27, 10, 0, tzinfo=timezone.utc)
|
||||
COMMENT = "Основание: доклад старшего.\nСведения: передано дежурному."
|
||||
|
||||
|
||||
def at_second(seconds: int) -> datetime:
|
||||
return T0 + timedelta(seconds=seconds)
|
||||
|
||||
|
||||
def mark(status: ServiceStatus, seconds: int) -> StatusEntry:
|
||||
return StatusEntry(service=SERVICES[0], status=status, at=at_second(seconds), comment=COMMENT)
|
||||
|
||||
|
||||
def crew_report(phase: str, seconds: int) -> PhoneReportRecord:
|
||||
return PhoneReportRecord(service=SERVICES[0], crew="Бригада 12", phase=phase,
|
||||
text="доклад", at=at_second(seconds))
|
||||
|
||||
|
||||
def reaction(status_log, reports, limit_ms=45_000):
|
||||
card = live_card(status_log=status_log, crew_assignments={SERVICES[0]: "Бригада 12"},
|
||||
dispatched_at=T0)
|
||||
card.phone_reports = reports
|
||||
findings = evaluate_dispatcher(
|
||||
entries=status_log, services=SERVICES, crew_assignments=card.crew_assignments,
|
||||
deadline_ms=30_000, elapsed_ms=5_000, phone_reports=reports,
|
||||
report_reaction_ms=limit_ms,
|
||||
)
|
||||
metric = next((m for m in dispatcher_metrics(card, 30_000, services=SERVICES,
|
||||
report_reaction_ms=limit_ms)
|
||||
if m.key == "dds_report_reaction"), None)
|
||||
return [f for f in findings if f.metric_key == "dds_report_reaction"], metric
|
||||
|
||||
|
||||
def full_run(arrived_at: int) -> list[StatusEntry]:
|
||||
return [mark(ServiceStatus.ACCEPTED, 5), mark(ServiceStatus.RESPONDING, 20),
|
||||
mark(ServiceStatus.ARRIVED, arrived_at), mark(ServiceStatus.WORKING, arrived_at + 10),
|
||||
mark(ServiceStatus.COMPLETED, arrived_at + 20)]
|
||||
|
||||
|
||||
def test_status_soon_after_crew_report_passes():
|
||||
findings, metric = reaction(full_run(arrived_at=110), [crew_report("arrived", 100)])
|
||||
|
||||
assert findings == []
|
||||
assert metric.passed and metric.weight == 1.5
|
||||
assert metric.fact == (
|
||||
"статус «Прибытие» по докладу бригады «Бригада 12» о прибытии: "
|
||||
"10 с (на 35 с быстрее норматива)"
|
||||
)
|
||||
assert metric.credit == time_credit(10_000, 45_000)
|
||||
assert metric.title == f"{SERVICES[0]}: реакция на доклад бригады"
|
||||
|
||||
|
||||
def test_status_later_than_limit_after_crew_report_is_d6_with_fact_and_norm():
|
||||
findings, metric = reaction(full_run(arrived_at=170), [crew_report("arrived", 100)])
|
||||
|
||||
assert [f.code for f in findings] == [ErrorCode.D6]
|
||||
assert "«Прибытие»" in findings[0].fact and "70 с (+25 с сверх норматива)" in findings[0].fact
|
||||
assert "о прибытии" in findings[0].fact, "факт ссылается на доклад"
|
||||
assert findings[0].norm == metric.norm == "отметить статус по докладу за 45 с"
|
||||
assert findings[0].competency is Competency.NORMS, "отметка и метрика в одной компетенции"
|
||||
assert findings[0].source is FindingSource.DISPATCHER
|
||||
assert findings[0].service == SERVICES[0]
|
||||
assert not metric.passed and metric.fact == findings[0].fact
|
||||
assert metric.credit == time_credit(70_000, 45_000)
|
||||
|
||||
|
||||
def test_violation_by_a_fraction_of_a_second_is_not_shown_as_the_limit():
|
||||
status_log = full_run(arrived_at=170)
|
||||
status_log[2] = status_log[2].model_copy(
|
||||
update={"at": at_second(100) + timedelta(milliseconds=45_400)},
|
||||
)
|
||||
|
||||
findings, metric = reaction(status_log, [crew_report("arrived", 100)])
|
||||
|
||||
assert not metric.passed
|
||||
assert "45.4 с (+0.4 с сверх норматива)" in findings[0].fact
|
||||
|
||||
|
||||
def test_missing_status_after_crew_report_is_d6():
|
||||
status_log = [mark(ServiceStatus.ACCEPTED, 5), mark(ServiceStatus.RESPONDING, 20)]
|
||||
|
||||
findings, metric = reaction(status_log, [crew_report("arrived", 100)])
|
||||
|
||||
assert [f.code for f in findings] == [ErrorCode.D6]
|
||||
assert "не отмечен" in findings[0].fact and "Бригада 12" in findings[0].fact
|
||||
assert not metric.passed and metric.credit == 0.0
|
||||
assert metric.fact == "статус «Прибытие» по докладу бригады «Бригада 12» о прибытии: не отмечен"
|
||||
|
||||
|
||||
def test_metric_takes_the_worst_report_and_counts_violations():
|
||||
reports = [crew_report("dispatched", 10), crew_report("arrived", 100),
|
||||
crew_report("working", 175)]
|
||||
|
||||
findings, metric = reaction(full_run(arrived_at=170), reports)
|
||||
|
||||
assert len(findings) == 1, "одна отметка на службу, как у других нормативов"
|
||||
assert "о прибытии: 70 с" in metric.fact
|
||||
assert "с нарушением 1 из 3 докладов" in metric.fact
|
||||
|
||||
|
||||
def test_status_set_before_the_report_and_custom_limit():
|
||||
findings, metric = reaction(full_run(arrived_at=90), [crew_report("arrived", 100)])
|
||||
assert findings == [] and metric.passed, "статус раньше доклада — не нарушение"
|
||||
assert metric.fact.endswith("отмечен не позже доклада") and metric.credit == 1.0
|
||||
|
||||
strict, _ = reaction(full_run(arrived_at=130), [crew_report("arrived", 100)], limit_ms=20_000)
|
||||
assert strict[0].norm == "отметить статус по докладу за 20 с"
|
||||
|
||||
|
||||
def test_no_crew_reports_means_no_reaction_metric():
|
||||
findings, metric = reaction(full_run(arrived_at=110), [])
|
||||
|
||||
assert findings == [] and metric is None
|
||||
|
||||
|
||||
def test_refusal_after_crew_report_is_neither_d6_nor_failed_metric():
|
||||
status_log = [
|
||||
mark(ServiceStatus.ACCEPTED, 5), mark(ServiceStatus.RESPONDING, 20),
|
||||
StatusEntry(service=SERVICES[0], status=ServiceStatus.REFUSED, at=at_second(120),
|
||||
comment="Основание: доклад старшего.\nСведения: бригаде отказано в доступе."),
|
||||
]
|
||||
|
||||
findings, metric = reaction(status_log, [crew_report("arrived", 100)])
|
||||
|
||||
assert findings == [], "работ не было — статус «Прибытие» отмечать не нужно"
|
||||
assert metric is None, "метрика не снижает балл без отметки"
|
||||
|
||||
|
||||
def test_refusal_keeps_late_reaction_on_reported_status():
|
||||
status_log = [
|
||||
mark(ServiceStatus.ACCEPTED, 5), mark(ServiceStatus.RESPONDING, 80),
|
||||
StatusEntry(service=SERVICES[0], status=ServiceStatus.REFUSED, at=at_second(120),
|
||||
comment="Основание: доклад старшего.\nСведения: бригаде отказано в доступе."),
|
||||
]
|
||||
|
||||
findings, metric = reaction(status_log, [crew_report("dispatched", 10),
|
||||
crew_report("arrived", 100)])
|
||||
|
||||
assert [f.code for f in findings] == [ErrorCode.D6]
|
||||
assert not metric.passed and "о выезде: 70 с" in metric.fact
|
||||
assert "докладов" not in metric.fact, "неотмеченное прибытие при отказе не считается"
|
||||
|
||||
|
||||
def test_unfinished_work_after_completion_report_names_the_report():
|
||||
status_log = full_run(arrived_at=110)[:-1]
|
||||
findings = evaluate_dispatcher(
|
||||
entries=status_log, services=SERVICES, crew_assignments={SERVICES[0]: "Бригада 12"},
|
||||
deadline_ms=30_000, elapsed_ms=5_000, phone_reports=[crew_report("completed", 150)],
|
||||
)
|
||||
|
||||
unfinished = next(f for f in findings if f.summary.endswith("не доведён до конца"))
|
||||
assert "бригада доложила о завершении работ" in unfinished.fact
|
||||
|
|
|
|||
644
backend/tests/test_finding_review.py
Normal file
644
backend/tests/test_finding_review.py
Normal file
|
|
@ -0,0 +1,644 @@
|
|||
"""Разбор по отметкам (lct-35): преподаватель подтверждает, снимает и добавляет отметку.
|
||||
|
||||
Балл пересчитывается теми же метриками и весами: снятая отметка не штрафует
|
||||
свою метрику, ручная — штрафует. Автоматическая оценка остаётся рядом.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
from datetime import UTC, datetime
|
||||
from uuid import uuid4
|
||||
|
||||
import pytest
|
||||
from fastapi import HTTPException
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from app.api.auth import Principal
|
||||
from app.api.http import sessions
|
||||
from app.domain.events import Metric
|
||||
from app.domain.roles import Role
|
||||
from app.domain.taxonomy import (
|
||||
ErrorCode,
|
||||
Finding,
|
||||
FindingDecision,
|
||||
FindingReview,
|
||||
FindingSource,
|
||||
)
|
||||
from app.main import app
|
||||
from app.scoring.export import to_csv
|
||||
from app.scoring.review import INSTRUCTOR_METRIC, add_finding, review_finding
|
||||
from app.session import finish as finish_module
|
||||
from app.session.hub import hub
|
||||
from app.session.store import FindingAdded, FindingReviewed, MemorySessionStore
|
||||
|
||||
from tests.test_dds_desk_scores import dds_exercise_early_finish
|
||||
|
||||
AT = datetime(2026, 9, 27, 12, 0, tzinfo=UTC)
|
||||
|
||||
|
||||
def review(decision: FindingDecision) -> FindingReview:
|
||||
return FindingReview(decision=decision, reason="доклад пришёл по рации", author="teacher", at=AT)
|
||||
|
||||
|
||||
def report_with(metrics: list[Metric], findings: list[Finding]) -> dict:
|
||||
return {
|
||||
"score_auto": 50.0,
|
||||
"metrics": [metric.model_dump(mode="json") for metric in metrics],
|
||||
"findings": [finding.model_dump(mode="json") for finding in findings],
|
||||
"full_report": {"criteria": {"allowed_errors": 0}, "score_auto": 50.0,
|
||||
"score_final": 50.0, "failed_metrics": 1, "passed": False},
|
||||
}
|
||||
|
||||
|
||||
def dds_metric(key: str, passed: bool, card: int = 1) -> Metric:
|
||||
return Metric(key=key, title=key, fact="факт", norm="норма", passed=passed,
|
||||
weight=1.0, card=card, service="01")
|
||||
|
||||
|
||||
def dds_finding(code: ErrorCode, card: int = 1) -> Finding:
|
||||
return Finding(code=code, source=FindingSource.DISPATCHER, summary=code.value,
|
||||
fact="факт", norm="норма", card=card, service="01")
|
||||
|
||||
|
||||
def test_dismissed_finding_stops_penalizing_its_metric_but_stays_in_report():
|
||||
report = report_with(
|
||||
[dds_metric("dds_primary", True), dds_metric("dds_progress", False)],
|
||||
[dds_finding(ErrorCode.D6)],
|
||||
)
|
||||
|
||||
updated = review_finding(report, 0, review(FindingDecision.DISMISSED))
|
||||
|
||||
assert updated["score_reviewed"] == 100.0
|
||||
assert updated["score_final"] == 100.0
|
||||
assert updated["score_auto"] == 50.0, "автооценка остаётся рядом"
|
||||
progress = updated["metrics"][1]
|
||||
assert (progress["passed"], progress["passed_auto"]) == (True, False)
|
||||
finding = updated["findings"][0]
|
||||
assert finding["reviews"][-1]["decision"] == "dismissed"
|
||||
assert finding["reviews"][-1]["reason"] == "доклад пришёл по рации"
|
||||
archived = updated["full_report"]
|
||||
assert (archived["score_final"], archived["failed_metrics"], archived["passed"]) == (100.0, 0, True)
|
||||
|
||||
|
||||
def test_confirmed_finding_and_other_card_keep_the_metric_failed():
|
||||
report = report_with(
|
||||
[dds_metric("dds_progress", False, card=1), dds_metric("dds_progress", False, card=2)],
|
||||
[dds_finding(ErrorCode.D6, card=1), dds_finding(ErrorCode.D6, card=2)],
|
||||
)
|
||||
|
||||
confirmed = review_finding(report, 0, review(FindingDecision.CONFIRMED))
|
||||
assert confirmed["score_reviewed"] == 0.0
|
||||
|
||||
dismissed = review_finding(report, 0, review(FindingDecision.DISMISSED))
|
||||
assert [metric["passed"] for metric in dismissed["metrics"]] == [True, False], (
|
||||
"снятие отметки первой карточки не трогает вторую"
|
||||
)
|
||||
|
||||
|
||||
def test_metric_stays_failed_while_one_of_its_findings_is_active():
|
||||
report = report_with(
|
||||
[dds_metric("dds_reply", False)],
|
||||
[dds_finding(ErrorCode.D5), dds_finding(ErrorCode.D5)],
|
||||
)
|
||||
|
||||
updated = review_finding(report, 0, review(FindingDecision.DISMISSED))
|
||||
|
||||
assert updated["metrics"][0]["passed"] is False
|
||||
again = review_finding(updated, 1, review(FindingDecision.DISMISSED))
|
||||
assert again["metrics"][0]["passed"] is True
|
||||
|
||||
|
||||
def test_instructor_finding_penalizes_linked_metric_and_unlinked_code_adds_one():
|
||||
report = report_with([dds_metric("dds_progress", True), dds_metric("dds_primary", True)], [])
|
||||
manual = Finding(code=ErrorCode.D6, source=FindingSource.INSTRUCTOR, summary="Карточка 1: D6",
|
||||
fact="«Прибыла» отмечено до доклада", norm="по факту доклада",
|
||||
card=1, service="01", author="teacher", at=AT)
|
||||
|
||||
updated = add_finding(report, manual)
|
||||
|
||||
assert updated["score_reviewed"] == 50.0
|
||||
assert updated["metrics"][0]["passed"] is False
|
||||
assert updated["metrics"][0]["passed_auto"] is True
|
||||
assert updated["findings"][0]["source"] == "instructor"
|
||||
|
||||
# У D4 в методике нет метрики — ручная отметка всё равно должна штрафовать.
|
||||
no_metric = manual.model_copy(update={"code": ErrorCode.D4})
|
||||
extra = add_finding(report, no_metric)
|
||||
assert [metric["key"] for metric in extra["metrics"]][-1] == INSTRUCTOR_METRIC
|
||||
assert extra["score_reviewed"] == pytest.approx(66.7)
|
||||
|
||||
# Снятая ручная отметка не штрафует, и её метрика исчезает при пересчёте.
|
||||
lifted = review_finding(extra, 0, review(FindingDecision.DISMISSED))
|
||||
assert lifted["score_reviewed"] == 100.0
|
||||
assert INSTRUCTOR_METRIC not in {metric["key"] for metric in lifted["metrics"]}
|
||||
|
||||
|
||||
def test_score_override_stays_final_after_finding_review():
|
||||
report = {**report_with([dds_metric("dds_progress", False)], [dds_finding(ErrorCode.D6)]),
|
||||
"score_final": 70.0, "overridden_by": "teacher"}
|
||||
|
||||
updated = review_finding(report, 0, review(FindingDecision.DISMISSED))
|
||||
|
||||
assert updated["score_reviewed"] == 100.0
|
||||
assert updated["score_final"] == 70.0
|
||||
|
||||
|
||||
def test_unknown_finding_index_is_refused():
|
||||
with pytest.raises(LookupError):
|
||||
review_finding(report_with([], []), 0, review(FindingDecision.DISMISSED))
|
||||
|
||||
|
||||
# ── HTTP: живое занятие ДДС ──
|
||||
|
||||
@pytest.fixture
|
||||
def lesson(monkeypatch):
|
||||
"""Завершённое занятие ДДС преподавателя `dev` в памяти узла."""
|
||||
|
||||
class NoCoaching:
|
||||
def model_dump(self, **_kwargs):
|
||||
return {}
|
||||
|
||||
async def no_coach(_metrics):
|
||||
return NoCoaching()
|
||||
|
||||
async def audit_in_memory(*_args, **_kwargs):
|
||||
return None
|
||||
|
||||
async def no_database():
|
||||
yield None
|
||||
|
||||
# Живое занятие в памяти: чтение отчёта не должно ходить в PostgreSQL.
|
||||
monkeypatch.setattr(finish_module, "coach", no_coach)
|
||||
monkeypatch.setattr(sessions, "audit_required", audit_in_memory)
|
||||
monkeypatch.setitem(app.dependency_overrides, sessions.optional_session, no_database)
|
||||
store = MemorySessionStore()
|
||||
monkeypatch.setattr(hub, "store", store)
|
||||
state = dds_exercise_early_finish()
|
||||
state.owner_login = "dev"
|
||||
with TestClient(app) as browser:
|
||||
browser.post("/api/auth/dev-token")
|
||||
hub.store = store
|
||||
hub.register(state)
|
||||
yield browser, state, store
|
||||
|
||||
|
||||
def as_role(monkeypatch, role: Role, login: str = "dev") -> None:
|
||||
def require(_request, *roles):
|
||||
if roles and role not in roles:
|
||||
raise HTTPException(status_code=403, detail="forbidden")
|
||||
return Principal(login=login, full_name="Проверка", role=role,
|
||||
trainee_id=uuid4() if role is Role.TRAINEE else None)
|
||||
|
||||
monkeypatch.setattr(sessions, "require", require)
|
||||
|
||||
|
||||
def test_owner_dismisses_and_adds_finding_through_http(lesson):
|
||||
browser, state, store = lesson
|
||||
session_id = state.session_id
|
||||
before = browser.get(f"/api/sessions/{session_id}/report").json()
|
||||
target = next(index for index, finding in enumerate(before["findings"])
|
||||
if finding["code"] == "D1")
|
||||
|
||||
dismissed = browser.post(
|
||||
f"/api/sessions/{session_id}/findings/{target}/review",
|
||||
json={"decision": "dismissed", "reason": "карточку выдали с опозданием"},
|
||||
)
|
||||
assert dismissed.status_code == 200, dismissed.text
|
||||
body = dismissed.json()
|
||||
assert body["findings"][target]["reviews"][-1]["decision"] == "dismissed"
|
||||
assert body["findings"][target]["reviews"][-1]["author"] == "dev"
|
||||
assert body["score_auto"] == before["score_auto"]
|
||||
assert body["score_reviewed"] >= before["score_auto"]
|
||||
|
||||
added = browser.post(
|
||||
f"/api/sessions/{session_id}/findings",
|
||||
json={"code": "D5", "fact": "в комментарии нет адресата", "norm": "назвать адресата",
|
||||
"card": 1},
|
||||
)
|
||||
assert added.status_code == 200, added.text
|
||||
manual = added.json()["findings"][-1]
|
||||
assert (manual["source"], manual["card"], manual["author"]) == ("instructor", 1, "dev")
|
||||
assert manual["summary"].startswith("Карточка 1: ")
|
||||
|
||||
records = [record for _, batch in store.commits for record in batch]
|
||||
assert [type(record) for record in records[-2:]] == [FindingReviewed, FindingAdded]
|
||||
assert store.scores[session_id]["findings"][target]["reviews"][-1]["decision"] == "dismissed"
|
||||
|
||||
rows = to_csv(sessions.SessionReport.model_validate(added.json())).decode("utf-8-sig")
|
||||
assert "карточку выдали с опозданием" in rows
|
||||
assert "в комментарии нет адресата" in rows
|
||||
|
||||
|
||||
def test_review_requires_reason_and_existing_card(lesson):
|
||||
browser, state, _store = lesson
|
||||
session_id = state.session_id
|
||||
|
||||
blank = browser.post(f"/api/sessions/{session_id}/findings/0/review",
|
||||
json={"decision": "dismissed", "reason": " "})
|
||||
assert blank.status_code == 422
|
||||
missing = browser.post(f"/api/sessions/{session_id}/findings/999/review",
|
||||
json={"decision": "confirmed", "reason": "проверено"})
|
||||
assert missing.status_code == 404
|
||||
card = browser.post(f"/api/sessions/{session_id}/findings",
|
||||
json={"code": "D1", "fact": "факт", "norm": "норма", "card": 99})
|
||||
assert card.status_code == 422
|
||||
|
||||
|
||||
@pytest.mark.parametrize("role, login, status", [
|
||||
(Role.TRAINEE, "trainee", 403),
|
||||
(Role.INSTRUCTOR, "someone-else", 404),
|
||||
])
|
||||
def test_trainee_and_foreign_instructor_cannot_review(lesson, monkeypatch, role, login, status):
|
||||
browser, state, store = lesson
|
||||
commits = len(store.commits)
|
||||
as_role(monkeypatch, role, login)
|
||||
|
||||
reviewed = browser.post(f"/api/sessions/{state.session_id}/findings/0/review",
|
||||
json={"decision": "dismissed", "reason": "не согласен"})
|
||||
added = browser.post(f"/api/sessions/{state.session_id}/findings",
|
||||
json={"code": "D1", "fact": "факт", "norm": "норма"})
|
||||
|
||||
assert (reviewed.status_code, added.status_code) == (status, status)
|
||||
assert len(store.commits) == commits, "отказ ничего не пишет"
|
||||
|
||||
|
||||
def test_finding_review_is_audited_in_postgres(postgres_access):
|
||||
"""Решение и аудит — одна транзакция: строка Score и AuditLog вместе."""
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.db.base import get_sessionmaker
|
||||
from app.db.models import AuditLog, Score
|
||||
from app.session.pg_store import PostgresSessionStore
|
||||
from app.session.store import LessonRequest
|
||||
|
||||
store = PostgresSessionStore(get_sessionmaker())
|
||||
session_id = uuid4()
|
||||
report = report_with([dds_metric("dds_progress", False)], [dds_finding(ErrorCode.D6)])
|
||||
|
||||
async def scenario():
|
||||
from app.session.state import SessionState
|
||||
from app.domain.events import Exercise, SessionMode
|
||||
|
||||
def build(_identity):
|
||||
return SessionState(session_id=session_id, scenario_id="fire-apartment-l2",
|
||||
scenario_title="Пожар", level="L2", mode=SessionMode.TRAINING,
|
||||
exercise=Exercise.DDS)
|
||||
|
||||
await store.open(LessonRequest(
|
||||
session_id=session_id, scenario_id="fire-apartment-l2", mode="training",
|
||||
trainee_name=None, trainee_id=None, owner_login="teacher", backend_node_id=None,
|
||||
), build)
|
||||
from app.session.store import ScoreCalculated
|
||||
await store.commit_archived(session_id, [ScoreCalculated(50.0, report)])
|
||||
await store.commit_archived(session_id, [FindingReviewed(
|
||||
index=0, role="instructor", review=review(FindingDecision.DISMISSED),
|
||||
)])
|
||||
async with get_sessionmaker()() as db:
|
||||
score = await db.scalar(select(Score).where(Score.session_id == session_id))
|
||||
audit = await db.scalar(select(AuditLog).where(
|
||||
AuditLog.object_id == str(session_id), AuditLog.action == "finding.review",
|
||||
))
|
||||
return score, audit
|
||||
|
||||
score, audit = asyncio.run(scenario())
|
||||
assert score.score_auto == 50.0
|
||||
assert score.score_final == 100.0
|
||||
assert score.report["findings"][0]["reviews"][-1]["decision"] == "dismissed"
|
||||
assert audit is not None and audit.actor == "teacher"
|
||||
assert "dismissed" in audit.detail and "50.0 → 100.0" in audit.detail
|
||||
|
||||
|
||||
# ── История решений, коды, карточки, веса ──
|
||||
|
||||
def test_repeated_decision_keeps_history_and_last_one_applies():
|
||||
report = report_with([dds_metric("dds_progress", False)], [dds_finding(ErrorCode.D6)])
|
||||
|
||||
dismissed = review_finding(report, 0, review(FindingDecision.DISMISSED))
|
||||
confirmed = review_finding(dismissed, 0, FindingReview(
|
||||
decision=FindingDecision.CONFIRMED, reason="перепроверил запись", author="head", at=AT,
|
||||
))
|
||||
|
||||
reviews = confirmed["findings"][0]["reviews"]
|
||||
assert [(item["decision"], item["author"], item["reason"]) for item in reviews] == [
|
||||
("dismissed", "teacher", "доклад пришёл по рации"),
|
||||
("confirmed", "head", "перепроверил запись"),
|
||||
]
|
||||
assert confirmed["score_reviewed"] == 0.0, "действует последнее решение"
|
||||
|
||||
|
||||
def test_report_saved_with_single_review_reads_as_history():
|
||||
legacy = dds_finding(ErrorCode.D6).model_dump(mode="json")
|
||||
legacy["review"] = review(FindingDecision.DISMISSED).model_dump(mode="json")
|
||||
|
||||
finding = Finding.model_validate(legacy)
|
||||
|
||||
assert [item.decision for item in finding.reviews] == [FindingDecision.DISMISSED]
|
||||
assert finding.review.decision is FindingDecision.DISMISSED
|
||||
|
||||
|
||||
def test_call_code_in_handoff_penalizes_call_metric_not_instructor_metric():
|
||||
"""Связка 112 → ДДС: E1 без карточки проваливает метрику 112, а не добавляет свою."""
|
||||
call = Metric(key="checklist_completeness", title="Полнота опроса", fact="все", norm="все",
|
||||
passed=True, weight=1.0)
|
||||
report = report_with([call, dds_metric("dds_progress", True)], [])
|
||||
manual = Finding(code=ErrorCode.E1, source=FindingSource.INSTRUCTOR, summary="E1",
|
||||
fact="не уточнён подъезд", norm="уточнить подъезд", author="teacher", at=AT)
|
||||
|
||||
updated = add_finding(report, manual)
|
||||
|
||||
assert [metric["passed"] for metric in updated["metrics"]] == [False, True]
|
||||
assert INSTRUCTOR_METRIC not in {metric["key"] for metric in updated["metrics"]}
|
||||
assert updated["score_reviewed"] == 50.0
|
||||
|
||||
|
||||
def test_codes_and_card_scores_follow_the_review():
|
||||
card = {"card_id": str(uuid4()), "scenario_id": "s", "score_auto": 50.0, "reply_text": "",
|
||||
"metrics": [dds_metric("dds_primary", True).model_dump(mode="json"),
|
||||
dds_metric("dds_progress", False).model_dump(mode="json")],
|
||||
"findings": [dds_finding(ErrorCode.D6).model_dump(mode="json")]}
|
||||
report = {**report_with([dds_metric("dds_primary", True), dds_metric("dds_progress", False)],
|
||||
[dds_finding(ErrorCode.D6)]),
|
||||
"summary": {"hints": 2, "codes": {"D6": 1}}, "card_results": [card]}
|
||||
report["full_report"]["card_results"] = [card]
|
||||
|
||||
dismissed = review_finding(report, 0, review(FindingDecision.DISMISSED))
|
||||
|
||||
assert dismissed["summary"] == {"hints": 2, "codes": {}}, "снятая отметка не ошибка группы"
|
||||
for cards in (dismissed["card_results"], dismissed["full_report"]["card_results"]):
|
||||
assert cards[0]["score_auto"] == 50.0
|
||||
assert cards[0]["score_reviewed"] == 100.0
|
||||
assert cards[0]["metrics"][1]["passed"] is True
|
||||
assert cards[0]["findings"][0]["reviews"][-1]["decision"] == "dismissed"
|
||||
|
||||
manual = Finding(code=ErrorCode.D4, source=FindingSource.INSTRUCTOR, summary="D4",
|
||||
fact="отказ без причины", norm="причина обязательна", card=1,
|
||||
author="teacher", at=AT)
|
||||
added = add_finding(dismissed, manual)
|
||||
assert added["summary"]["codes"] == {"D4": 1}
|
||||
assert added["card_results"][0]["score_reviewed"] == pytest.approx(66.7)
|
||||
|
||||
|
||||
def test_instructor_metric_weight_comes_from_lesson_weights():
|
||||
report = report_with([dds_metric("dds_primary", True)], [])
|
||||
report["full_report"]["criteria"]["score_weights"] = {INSTRUCTOR_METRIC: 3.0}
|
||||
manual = Finding(code=ErrorCode.D4, source=FindingSource.INSTRUCTOR, summary="D4",
|
||||
fact="отказ без причины", norm="причина обязательна", card=1,
|
||||
author="teacher", at=AT)
|
||||
|
||||
updated = add_finding(report, manual)
|
||||
|
||||
assert updated["metrics"][-1]["weight"] == 3.0
|
||||
assert updated["score_reviewed"] == 25.0
|
||||
|
||||
|
||||
def test_lesson_criteria_accept_instructor_metric_weight():
|
||||
from app.domain.events import LessonCriteria
|
||||
|
||||
assert LessonCriteria(score_weights={INSTRUCTOR_METRIC: 2.0}).score_weights == {
|
||||
INSTRUCTOR_METRIC: 2.0}
|
||||
|
||||
|
||||
def test_repeated_manual_finding_with_same_key_is_added_once():
|
||||
report = report_with([dds_metric("dds_primary", True)], [])
|
||||
manual = Finding(code=ErrorCode.D4, source=FindingSource.INSTRUCTOR, summary="D4",
|
||||
fact="отказ без причины", norm="причина обязательна", card=1,
|
||||
author="teacher", at=AT, client_id=uuid4())
|
||||
|
||||
once = add_finding(report, manual)
|
||||
twice = add_finding(once, manual)
|
||||
|
||||
assert len(twice["findings"]) == 1
|
||||
assert twice["score_reviewed"] == once["score_reviewed"] == 50.0
|
||||
|
||||
|
||||
def test_pdf_shows_score_after_review(monkeypatch):
|
||||
from reportlab import platypus
|
||||
|
||||
from app.scoring.export import to_pdf
|
||||
from tests.test_report_export import sample_report
|
||||
|
||||
texts: list[str] = []
|
||||
|
||||
class Recorded(platypus.Paragraph):
|
||||
def __init__(self, text, *args, **kwargs):
|
||||
texts.append(text)
|
||||
super().__init__(text, *args, **kwargs)
|
||||
|
||||
# Абзацы перехватываются до вёрстки: извлечь текст из PDF нечем без poppler.
|
||||
monkeypatch.setattr(platypus, "Paragraph", Recorded)
|
||||
assert to_pdf(sample_report().model_copy(update={"score_reviewed": 91.5})).startswith(b"%PDF-")
|
||||
|
||||
assert "<b>После разбора отметок:</b> 91.5" in texts
|
||||
|
||||
|
||||
# ── HTTP: связка 112 → ДДС, идемпотентность, занятие из истории ──
|
||||
|
||||
def test_call_code_with_queue_card_is_refused_in_handoff(lesson):
|
||||
browser, state, store = lesson
|
||||
commits = len(store.commits)
|
||||
state.score["full_report"]["exercise"] = "call"
|
||||
|
||||
refused = browser.post(f"/api/sessions/{state.session_id}/findings",
|
||||
json={"code": "E1", "fact": "факт", "norm": "норма", "card": 1})
|
||||
|
||||
assert refused.status_code == 422
|
||||
assert refused.json()["detail"] == "call_code_without_card"
|
||||
assert len(store.commits) == commits
|
||||
|
||||
|
||||
def test_repeated_add_request_does_not_add_second_finding(lesson):
|
||||
browser, state, store = lesson
|
||||
body = {"code": "D5", "fact": "нет адресата", "norm": "назвать адресата", "card": 1,
|
||||
"client_id": str(uuid4())}
|
||||
|
||||
first = browser.post(f"/api/sessions/{state.session_id}/findings", json=body)
|
||||
commits = len(store.commits)
|
||||
second = browser.post(f"/api/sessions/{state.session_id}/findings", json=body)
|
||||
|
||||
assert first.status_code == second.status_code == 200
|
||||
assert len(second.json()["findings"]) == len(first.json()["findings"])
|
||||
assert second.json()["score_reviewed"] == first.json()["score_reviewed"]
|
||||
assert not [record for _, batch in store.commits[commits:] for record in batch], (
|
||||
"повтор ничего не пишет")
|
||||
|
||||
|
||||
def test_archived_lesson_is_reviewed_through_http(monkeypatch):
|
||||
"""Занятия нет в памяти узла: решение идёт через commit_archived, ответ — по строке после записи."""
|
||||
from types import SimpleNamespace
|
||||
|
||||
from app.session.pg_store import PostgresSessionStore
|
||||
from tests.test_report_export import sample_report
|
||||
|
||||
archived = sample_report().model_dump(mode="json")
|
||||
archived["findings"] = [dds_finding(ErrorCode.D6).model_dump(mode="json")]
|
||||
archived["metrics"] = [dds_metric("dds_progress", False).model_dump(mode="json")]
|
||||
archived.update(score_final=0.0, overridden_by=None, override_comment=None, score_auto=0.0)
|
||||
session_id = archived["session_id"]
|
||||
session_row = SimpleNamespace(id=session_id, trainee_id=uuid4(), owner_login="dev")
|
||||
score_row = SimpleNamespace(
|
||||
session_id=session_id, score_auto=0.0, score_final=0.0,
|
||||
report={"score_auto": 0.0, "summary": {"codes": {"D6": 1}},
|
||||
"findings": archived["findings"], "metrics": archived["metrics"],
|
||||
"full_report": archived},
|
||||
)
|
||||
|
||||
class FakeDb:
|
||||
def __init__(self):
|
||||
self.added = []
|
||||
self.commits = 0
|
||||
|
||||
async def get(self, _model, _key):
|
||||
return session_row
|
||||
|
||||
async def scalar(self, _statement):
|
||||
return score_row
|
||||
|
||||
def add(self, row):
|
||||
self.added.append(row)
|
||||
|
||||
async def flush(self):
|
||||
pass
|
||||
|
||||
async def rollback(self):
|
||||
pass
|
||||
|
||||
async def commit(self):
|
||||
self.commits += 1
|
||||
|
||||
async def __aenter__(self):
|
||||
return self
|
||||
|
||||
async def __aexit__(self, *_args):
|
||||
return None
|
||||
|
||||
db = FakeDb()
|
||||
|
||||
async def fake_session():
|
||||
yield db
|
||||
|
||||
monkeypatch.setattr(hub, "store", PostgresSessionStore(lambda: db))
|
||||
monkeypatch.setattr(hub, "get", lambda _session_id: None)
|
||||
monkeypatch.setitem(app.dependency_overrides, sessions.optional_session, fake_session)
|
||||
with TestClient(app) as browser:
|
||||
browser.post("/api/auth/dev-token")
|
||||
hub.store = PostgresSessionStore(lambda: db)
|
||||
response = browser.post(f"/api/sessions/{session_id}/findings/0/review",
|
||||
json={"decision": "dismissed", "reason": "доклад по рации"})
|
||||
|
||||
assert response.status_code == 200, response.text
|
||||
body = response.json()
|
||||
assert body["score_reviewed"] == 100.0 and body["score_final"] == 100.0
|
||||
assert body["findings"][0]["reviews"][-1]["reason"] == "доклад по рации"
|
||||
assert db.commits == 1, "решение и аудит — один commit"
|
||||
assert score_row.score_final == 100.0
|
||||
assert score_row.report["summary"]["codes"] == {}
|
||||
audit = db.added[0]
|
||||
assert audit.action == "finding.review" and audit.actor == "dev"
|
||||
assert "D6 dismissed" in audit.detail and "доклад по рации" in audit.detail
|
||||
|
||||
|
||||
def test_finding_review_is_not_saved_when_audit_fails(postgres_access):
|
||||
"""Fail-closed: строка аудита не записалась — решения и нового балла тоже нет."""
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.db.base import get_sessionmaker
|
||||
from app.db.models import AuditLog, Score
|
||||
from app.domain.events import Exercise, SessionMode
|
||||
from app.session.pg_store import PostgresSessionStore
|
||||
from app.session.state import SessionState
|
||||
from app.session.store import LessonRequest, ScoreCalculated
|
||||
|
||||
store = PostgresSessionStore(get_sessionmaker())
|
||||
session_id = uuid4()
|
||||
report = report_with([dds_metric("dds_progress", False)], [dds_finding(ErrorCode.D6)])
|
||||
# actor в audit_log — String(80): длинный логин роняет INSERT аудита
|
||||
# на стороне PostgreSQL, а Score меняется в той же транзакции.
|
||||
broken = FindingReview(decision=FindingDecision.DISMISSED, reason="доклад по рации",
|
||||
author="x" * 200, at=AT)
|
||||
|
||||
async def scenario():
|
||||
def build(_identity):
|
||||
return SessionState(session_id=session_id, scenario_id="fire-apartment-l2",
|
||||
scenario_title="Пожар", level="L2", mode=SessionMode.TRAINING,
|
||||
exercise=Exercise.DDS)
|
||||
|
||||
await store.open(LessonRequest(
|
||||
session_id=session_id, scenario_id="fire-apartment-l2", mode="training",
|
||||
trainee_name=None, trainee_id=None, owner_login="teacher", backend_node_id=None,
|
||||
), build)
|
||||
await store.commit_archived(session_id, [ScoreCalculated(50.0, report)])
|
||||
with pytest.raises(Exception):
|
||||
await store.commit_archived(session_id, [FindingReviewed(
|
||||
index=0, role="instructor", review=broken,
|
||||
)])
|
||||
async with get_sessionmaker()() as db:
|
||||
score = await db.scalar(select(Score).where(Score.session_id == session_id))
|
||||
audit = await db.scalar(select(AuditLog).where(
|
||||
AuditLog.object_id == str(session_id), AuditLog.action == "finding.review",
|
||||
))
|
||||
return score, audit
|
||||
|
||||
score, audit = asyncio.run(scenario())
|
||||
assert audit is None
|
||||
assert score.score_final == 50.0
|
||||
assert score.report["findings"][0].get("reviews", []) == []
|
||||
assert "score_reviewed" not in score.report
|
||||
|
||||
|
||||
def test_archived_lesson_review_through_http_in_postgres(postgres_access, monkeypatch):
|
||||
"""Тот же маршрут на настоящей PostgreSQL: ответ перечитан после commit под блокировкой."""
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.db.base import get_sessionmaker
|
||||
from app.db.models import AuditLog
|
||||
from app.domain.events import Exercise, SessionMode
|
||||
from app.session.pg_store import PostgresSessionStore
|
||||
from app.session.state import SessionState
|
||||
from app.session.store import LessonRequest, ScoreCalculated
|
||||
from tests.test_report_export import sample_report
|
||||
|
||||
session_id = uuid4()
|
||||
archived = sample_report().model_dump(mode="json")
|
||||
archived.update(session_id=str(session_id), score_final=0.0, score_auto=0.0,
|
||||
overridden_by=None, override_comment=None,
|
||||
findings=[dds_finding(ErrorCode.D6).model_dump(mode="json")],
|
||||
metrics=[dds_metric("dds_progress", False).model_dump(mode="json")])
|
||||
report = {"score_auto": 0.0, "summary": {"codes": {"D6": 1}},
|
||||
"findings": archived["findings"], "metrics": archived["metrics"],
|
||||
"full_report": archived}
|
||||
store = PostgresSessionStore(get_sessionmaker())
|
||||
|
||||
async def prepare():
|
||||
def build(_identity):
|
||||
return SessionState(session_id=session_id, scenario_id="fire-apartment-l2",
|
||||
scenario_title="Пожар", level="L2", mode=SessionMode.TRAINING,
|
||||
exercise=Exercise.DDS)
|
||||
|
||||
await store.open(LessonRequest(
|
||||
session_id=session_id, scenario_id="fire-apartment-l2", mode="training",
|
||||
trainee_name=None, trainee_id=None, owner_login="dev", backend_node_id=None,
|
||||
), build)
|
||||
await store.commit_archived(session_id, [ScoreCalculated(0.0, report)])
|
||||
|
||||
async def audit():
|
||||
async with get_sessionmaker()() as db:
|
||||
return await db.scalar(select(AuditLog).where(
|
||||
AuditLog.object_id == str(session_id), AuditLog.action == "finding.review"))
|
||||
|
||||
monkeypatch.setattr(hub, "get", lambda _session_id: None)
|
||||
# Пул соединений привязан к циклу событий: подготовка и проверка идут
|
||||
# в цикле того же TestClient, что и запрос.
|
||||
with TestClient(app) as browser:
|
||||
browser.post("/api/auth/dev-token")
|
||||
previous, hub.store = hub.store, store
|
||||
try:
|
||||
browser.portal.call(prepare)
|
||||
response = browser.post(f"/api/sessions/{session_id}/findings/0/review",
|
||||
json={"decision": "dismissed", "reason": "доклад по рации"})
|
||||
row = browser.portal.call(audit)
|
||||
finally:
|
||||
hub.store = previous
|
||||
|
||||
assert response.status_code == 200, response.text
|
||||
assert response.json()["score_final"] == 100.0
|
||||
assert response.json()["findings"][0]["reviews"][-1]["reason"] == "доклад по рации"
|
||||
assert row is not None and "доклад по рации" in row.detail
|
||||
|
|
@ -21,7 +21,7 @@ from app.session.hub import hub
|
|||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def client():
|
||||
def client(template_caller):
|
||||
# Доступность базы проверяется подключением к порту: синхронного драйвера
|
||||
# в проекте нет, и попытка проверить им даёт ложный пропуск теста.
|
||||
import socket
|
||||
|
|
|
|||
|
|
@ -68,6 +68,7 @@ def test_csv_contains_sections_and_blocks_formula_injection():
|
|||
assert any(row[0] == "Занятие" and row[3] == "'=1+1" for row in rows)
|
||||
assert any(row[0] == "Ошибки" and row[3] == "'+HYPERLINK(\"x\")" for row in rows)
|
||||
assert any(row[0] == "Самооценка" and row[3] == "'@SUM(1,2)" for row in rows)
|
||||
assert ["Критерии", "", "Лимит реакции на доклад бригады, с", "45", ""] in rows
|
||||
assert _cell(" =cmd") == "' =cmd"
|
||||
assert _cell("\tОбычный текст") == "'\tОбычный текст"
|
||||
|
||||
|
|
@ -89,6 +90,7 @@ def test_pdf_supports_cyrillic_and_spans_pages(tmp_path):
|
|||
assert "Отчёт по учебному занятию" in extracted
|
||||
assert "Адрес должен быть уточнён" in extracted
|
||||
assert "Заявитель сообщает о дыме" in extracted
|
||||
assert "Лимит реакции на доклад бригады" in extracted
|
||||
|
||||
|
||||
def test_certificate_pdf_contains_saved_result(tmp_path):
|
||||
|
|
|
|||
|
|
@ -12,13 +12,14 @@ from pathlib import Path
|
|||
API_ROOT = Path(__file__).parents[1] / "app" / "api"
|
||||
|
||||
# Public by design: credential entry/session bootstrap and the non-sensitive
|
||||
# incident classification dictionary. dev-token has its own fail-closed flag
|
||||
# incident classification dictionary and service catalog. dev-token has its own fail-closed flag
|
||||
# and remains hidden in production.
|
||||
PUBLIC_HTTP_ROUTES = {
|
||||
("auth.py", "post", "/login"),
|
||||
("auth.py", "post", "/dev-token"),
|
||||
("http/ekp.py", "get", "/groups"),
|
||||
("http/ekp.py", "get", "/signs"),
|
||||
("http/ekp.py", "get", "/services"),
|
||||
}
|
||||
|
||||
# Routes that centralize ownership + authentication checks in a shared helper.
|
||||
|
|
@ -83,6 +84,8 @@ HTTP_ROLE_GATE_POLICY = {
|
|||
("http/sessions.py", "get", "/{session_id}/checklist"): ((),),
|
||||
("http/sessions.py", "get", "/{session_id}/recording.wav"): (("INSTRUCTOR", "TRAINEE"),),
|
||||
("http/sessions.py", "patch", "/{session_id}/report"): (("INSTRUCTOR",),),
|
||||
("http/sessions.py", "post", "/{session_id}/findings/{index}/review"): (("INSTRUCTOR",),),
|
||||
("http/sessions.py", "post", "/{session_id}/findings"): (("INSTRUCTOR",),),
|
||||
("http/sessions.py", "get", ""): ((),),
|
||||
("http/trainees.py", "get", "/{trainee_id}/certificate.pdf"): ((),),
|
||||
("http/trainees.py", "get", ""): (("ADMIN", "INSTRUCTOR"),),
|
||||
|
|
|
|||
167
backend/tests/test_service_catalog.py
Normal file
167
backend/tests/test_service_catalog.py
Normal file
|
|
@ -0,0 +1,167 @@
|
|||
"""Каталог служб из «СЛУЖБЫ 112» (lct-38).
|
||||
|
||||
Каталог только расширяет ручной выбор служб в карточке. Список оповещения
|
||||
по-прежнему считается по классификатору, эталоны сценариев от каталога не зависят.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
from pathlib import Path
|
||||
from uuid import uuid4
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
from pydantic import ValidationError
|
||||
|
||||
from app.api.ws import call as call_ws
|
||||
from app.domain import ekp
|
||||
from app.domain.events import ErrorEvent, KioPatchIn, KioPatchOut, PatchSource, SessionMode
|
||||
from app.domain.kio import EDITABLE_KIO_FIELDS, KIO, PatchRejected, apply_patch
|
||||
from app.main import app
|
||||
from app.scenarios.loader import load_file
|
||||
from app.scoring.card import evaluate_card
|
||||
from app.session.hub import hub
|
||||
from app.session.state import SessionState
|
||||
from app.session.store import MemorySessionStore
|
||||
|
||||
|
||||
def test_catalog_keeps_all_209_services_with_attribution_and_source():
|
||||
data = ekp.catalog()
|
||||
|
||||
assert len(data.services) == 209
|
||||
assert len({item.name for item in data.services}) == 209
|
||||
assert sum(item.service_level == "ДДС" for item in data.services) == 157
|
||||
assert "ded466988f505082922aacac9fff32b777d1279a" in data.transcription
|
||||
assert data.license.startswith("MIT")
|
||||
assert all(item.source_reference.startswith("СЛУЖБЫ 112.docx#word/media/image")
|
||||
for item in data.services)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("name", [
|
||||
'Служба 101 (ГУ МЧС России по г.Москве, ГКУ "Пожарно спасательный центр" ОДС)',
|
||||
'Служба 104 (АО "МОСГАЗ" Диспетчерское управление)',
|
||||
"ЦЭМП",
|
||||
'ЦОДД (ГКУ "Центр организации дорожного движения")',
|
||||
'ЭВАЖД (ГБУ "Учреждение по эксплуатации высотных административных и жилых домов")',
|
||||
])
|
||||
def test_rows_checked_against_our_screenshots_are_verbatim(name):
|
||||
assert name in ekp.catalog_names()
|
||||
|
||||
|
||||
def test_classifier_services_and_notify_lists_are_untouched():
|
||||
"""Индексы служб классификатора — основа списков оповещения 96 сценариев."""
|
||||
raw = json.loads(ekp.DATA.read_text(encoding="utf-8"))
|
||||
assert [item.name for item in ekp.services()] == [item["name"] for item in raw["services"]]
|
||||
assert len(ekp.services()) == 61
|
||||
|
||||
|
||||
def test_operator_adds_catalog_services_without_changing_the_notify_list():
|
||||
assert "notify_extra" in EDITABLE_KIO_FIELDS
|
||||
card = KIO(notify=["Служба 101"])
|
||||
|
||||
updated = apply_patch(card, {"notify_extra": ["ФСБ", "ЦЭМП", "ФСБ"], "notify": []})
|
||||
|
||||
assert updated.notify_extra == ["ФСБ", "ЦЭМП"]
|
||||
assert updated.notify == ["Служба 101"], "автоматический список оператор не правит"
|
||||
with pytest.raises(PatchRejected):
|
||||
apply_patch(card, {"notify_extra": ["Служба, которой нет"]})
|
||||
with pytest.raises(ValidationError):
|
||||
apply_patch(card, {"notify_extra": [item.name for item in ekp.catalog().services[:21]]})
|
||||
|
||||
|
||||
def test_service_already_in_notify_is_not_added_twice():
|
||||
"""ЦЭМП, ФСБ, МОЭК, Метро, Мосгортранс есть и в каталоге, и в классификаторе."""
|
||||
card = KIO(notify=["ЦЭМП"])
|
||||
|
||||
updated = apply_patch(card, {"notify_extra": ["ЦЭМП", "ФСБ"]})
|
||||
|
||||
assert updated.notify_extra == ["ФСБ"]
|
||||
|
||||
|
||||
def test_saved_card_with_renamed_service_still_loads():
|
||||
"""Каталог проверяется на входе kio.patch: правка названия в каталоге
|
||||
не должна ломать восстановление снимков и сданных карточек."""
|
||||
card = KIO.model_validate({"notify_extra": ["Прежнее название службы"]})
|
||||
|
||||
assert card.notify_extra == ["Прежнее название службы"]
|
||||
|
||||
|
||||
def test_added_services_do_not_change_the_score_against_the_reference():
|
||||
library = Path(__file__).resolve().parents[2] / "scenarios"
|
||||
scenario = load_file(library / "fire-apartment-l2.yaml", library)
|
||||
card = KIO(address="улица Ленина, 14", description="горит балкон")
|
||||
|
||||
plain = evaluate_card(scenario, card)
|
||||
extended = evaluate_card(scenario, card.model_copy(update={"notify_extra": ["ФСБ"]}))
|
||||
|
||||
assert [(m.key, m.passed) for m in plain.metrics] == [(m.key, m.passed) for m in extended.metrics]
|
||||
assert plain.score == extended.score
|
||||
|
||||
|
||||
def test_catalog_is_served_like_the_classifier():
|
||||
with TestClient(app) as client:
|
||||
response = client.get("/api/ekp/services")
|
||||
|
||||
assert response.status_code == 200
|
||||
body = response.json()
|
||||
assert len(body["services"]) == 209
|
||||
assert {"id", "name", "organization", "service_level", "source_reference"} <= body["services"][0].keys()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def lesson(monkeypatch):
|
||||
sent = []
|
||||
monkeypatch.setattr(hub, "store", MemorySessionStore())
|
||||
monkeypatch.setattr(hub, "to_trainee", lambda _session_id, event: sent.append(event))
|
||||
monkeypatch.setattr(hub, "to_observers", lambda *_args: None)
|
||||
state = SessionState(session_id=uuid4(), scenario_id="test", scenario_title="Тест",
|
||||
level="L1", mode=SessionMode.TRAINING)
|
||||
hub.register(state)
|
||||
|
||||
def patch(fields):
|
||||
sent.clear()
|
||||
|
||||
async def run():
|
||||
async with hub.operation(state.session_id):
|
||||
await call_ws._handle(state.session_id, state, KioPatchIn(fields=fields))
|
||||
|
||||
asyncio.run(run())
|
||||
return list(sent)
|
||||
|
||||
return state, patch
|
||||
|
||||
|
||||
def test_unknown_service_in_kio_patch_is_refused_without_failing_the_lesson(lesson):
|
||||
state, patch = lesson
|
||||
|
||||
sent = patch({"notify_extra": ["Служба, которой нет"]})
|
||||
assert isinstance(sent[0], ErrorEvent) and "не принято" in sent[0].message
|
||||
assert state.kio.notify_extra == []
|
||||
|
||||
sent = patch({"notify_extra": ["ФСБ"]})
|
||||
assert state.kio.notify_extra == ["ФСБ"]
|
||||
|
||||
|
||||
def test_refused_kio_patch_returns_server_values_to_drop_pending(lesson):
|
||||
"""Пакет отклоняется целиком — фронт должен получить серверные значения
|
||||
всех его полей с `auto`, иначе pending висит до следующего kio.state."""
|
||||
state, patch = lesson
|
||||
patch({"address": "улица Ленина, 14", "notify_extra": ["ФСБ"]})
|
||||
|
||||
sent = patch({"address": "Тверская, 1", "fire.floors": 5, "notify_extra": ["ФСБ", "Служба, которой нет"]})
|
||||
|
||||
assert isinstance(sent[0], ErrorEvent)
|
||||
assert isinstance(sent[1], KioPatchOut) and sent[1].source is PatchSource.AUTO
|
||||
assert sent[1].fields == {"address": "улица Ленина, 14", "fire.floors": None, "notify_extra": ["ФСБ"]}
|
||||
assert state.kio.address == "улица Ленина, 14"
|
||||
|
||||
|
||||
def test_cleaned_notify_extra_is_sent_back_as_auto(lesson):
|
||||
state, patch = lesson
|
||||
state.kio = state.kio.model_copy(update={"notify": ["ЦЭМП"]})
|
||||
|
||||
sent = patch({"notify_extra": ["ЦЭМП", "ФСБ", "ФСБ"]})
|
||||
|
||||
auto = [event for event in sent if isinstance(event, KioPatchOut) and event.source is PatchSource.AUTO]
|
||||
assert auto and auto[-1].fields["notify_extra"] == ["ФСБ"]
|
||||
assert state.kio.notify_extra == ["ФСБ"]
|
||||
|
|
@ -12,12 +12,13 @@ from app.domain.events import (
|
|||
SessionMode,
|
||||
)
|
||||
from app.domain.kio import KIO
|
||||
from app.domain.statuses import PhoneCallPending, ServiceStatus
|
||||
from app.domain.statuses import PhoneCallPending, PhoneReportRecord, ServiceStatus
|
||||
from app.domain.taxonomy import ErrorCode, Finding, FindingSource
|
||||
from app.domain.timers import TimerCode
|
||||
from app.scenarios.loader import load_file
|
||||
from app.session.checkpoint import dump_state, load_state
|
||||
from app.session.dds import prepare_handoff_queue, prepare_queue
|
||||
from app.session.finish import score_dds_card
|
||||
from app.session.state import DdsCardRecord, SessionState, now_utc
|
||||
|
||||
LIBRARY = Path(__file__).resolve().parents[2] / "scenarios"
|
||||
|
|
@ -85,6 +86,38 @@ def test_active_dds_session_round_trips_without_losing_work():
|
|||
assert timer.limit_ms == 45_000
|
||||
|
||||
|
||||
def test_report_reaction_keeps_times_and_limit_after_restore():
|
||||
before = dds_state()
|
||||
before.criteria.dds_report_reaction_limit_seconds = 20
|
||||
card = before.desk.active
|
||||
service = before.card_services(card)[0]
|
||||
card.phone_reports.append(PhoneReportRecord(
|
||||
service=service, crew=card.crew_selected, phase="dispatched",
|
||||
text="Бригада выехала", at=now_utc(),
|
||||
))
|
||||
card.set_service_status(service, ServiceStatus.RESPONDING,
|
||||
"Основание: доклад.\nСведения: бригада выехала.")
|
||||
card.phone_reports[0].at -= timedelta(seconds=70)
|
||||
|
||||
restored = load_state(dump_state(before), now_utc())
|
||||
|
||||
assert restored.criteria.dds_report_reaction_limit_seconds == 20
|
||||
assert restored.desk.active.phone_reports == card.phone_reports
|
||||
|
||||
def reaction(state):
|
||||
result = score_dds_card(state, state.desk.active)
|
||||
metric = next(item for item in result.metrics if item.key == "dds_report_reaction")
|
||||
findings = [item for item in result.findings if item.metric_key == "dds_report_reaction"]
|
||||
return metric, findings
|
||||
|
||||
metric, findings = reaction(restored)
|
||||
assert not metric.passed
|
||||
assert metric.norm == "отметить статус по докладу за 20 с"
|
||||
assert "70 с (+50 с сверх норматива)" in metric.fact
|
||||
assert [item.code for item in findings] == [ErrorCode.D6]
|
||||
assert reaction(restored) == reaction(before), "оценка после восстановления та же"
|
||||
|
||||
|
||||
def completed_card() -> DdsCardRecord:
|
||||
return DdsCardRecord(
|
||||
card_id=uuid4(),
|
||||
|
|
|
|||
517
backend/tests/test_session_signals.py
Normal file
517
backend/tests/test_session_signals.py
Normal file
|
|
@ -0,0 +1,517 @@
|
|||
"""Колонка сигналов реестра: очередь, повторные отказы, курсант не на связи."""
|
||||
|
||||
import asyncio
|
||||
import time
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from uuid import uuid4
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
from sqlalchemy.engine.result import IteratorResult, SimpleResultMetaData
|
||||
from starlette.websockets import WebSocketDisconnect
|
||||
|
||||
from app.api.auth import Principal
|
||||
from app.api.http import sessions as sessions_http
|
||||
from app.api.ws import session as session_ws
|
||||
from app.config import get_settings
|
||||
from app.db.models import Session
|
||||
from app.domain.events import Exercise, SessionMode
|
||||
from app.domain.roles import Role
|
||||
from app.main import app
|
||||
from app.session.checkpoint import dump_state, load_state
|
||||
from app.session import hub as hub_module
|
||||
from app.session.hub import SessionHub, hub
|
||||
from app.session.state import SessionState
|
||||
from app.session.store import MemorySessionStore
|
||||
|
||||
POOL = ["fire-apartment-l2", "t01-1-fire-container"]
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def client(monkeypatch):
|
||||
monkeypatch.setenv("DEV_AUTH_BYPASS", "true")
|
||||
get_settings.cache_clear()
|
||||
|
||||
async def audit_override(*_args, **_kwargs):
|
||||
return None
|
||||
|
||||
async def optional_session_override():
|
||||
yield None
|
||||
|
||||
monkeypatch.setattr(sessions_http, "audit_required", audit_override)
|
||||
monkeypatch.setitem(
|
||||
app.dependency_overrides, sessions_http.optional_session, optional_session_override
|
||||
)
|
||||
try:
|
||||
with TestClient(app) as test_client:
|
||||
test_client.post("/api/auth/dev-token")
|
||||
hub.store = MemorySessionStore()
|
||||
before = set(hub._sessions)
|
||||
try:
|
||||
yield test_client
|
||||
finally:
|
||||
for session_id in set(hub._sessions) - before:
|
||||
hub.drop(session_id)
|
||||
finally:
|
||||
get_settings.cache_clear()
|
||||
|
||||
|
||||
def wait_for(predicate, timeout=3):
|
||||
end = time.monotonic() + timeout
|
||||
while time.monotonic() < end:
|
||||
value = predicate()
|
||||
if value:
|
||||
return value
|
||||
time.sleep(0.02)
|
||||
raise AssertionError("состояние не обновилось")
|
||||
|
||||
|
||||
def read_until(socket, wanted):
|
||||
received = []
|
||||
for _ in range(20):
|
||||
event = socket.receive_json()
|
||||
received.append(event["type"])
|
||||
if event["type"] == wanted:
|
||||
return event
|
||||
raise AssertionError(f"событие {wanted} не пришло; получены: {received}")
|
||||
|
||||
|
||||
def start_two_card_dds(client, scenario_ids=POOL):
|
||||
session_id = uuid4()
|
||||
context = client.websocket_connect(f"/ws/control/{session_id}")
|
||||
control = context.__enter__()
|
||||
control.send_json({
|
||||
"type": "scenario.start",
|
||||
"scenario_id": POOL[0],
|
||||
"trainee": "Иванов",
|
||||
"mode": "training",
|
||||
"exercise": "dds",
|
||||
"random_scenario_ids": scenario_ids,
|
||||
})
|
||||
wait_for(lambda: hub.get(session_id))
|
||||
return session_id, control
|
||||
|
||||
|
||||
def start_card_handoff(client):
|
||||
session_id = uuid4()
|
||||
context = client.websocket_connect(f"/ws/control/{session_id}")
|
||||
control = context.__enter__()
|
||||
control.send_json({
|
||||
"type": "scenario.start", "scenario_id": POOL[0], "trainee": "Иванов",
|
||||
"mode": "training", "exercise": "card", "handoff_to_dds": True,
|
||||
"scenario_ids": POOL,
|
||||
})
|
||||
wait_for(lambda: hub.get(session_id))
|
||||
return session_id, control
|
||||
|
||||
|
||||
def start_call(client):
|
||||
session_id = uuid4()
|
||||
context = client.websocket_connect(f"/ws/control/{session_id}")
|
||||
control = context.__enter__()
|
||||
control.send_json({
|
||||
"type": "scenario.start", "scenario_id": POOL[0], "trainee": "Иванов",
|
||||
"mode": "training", "exercise": "call",
|
||||
})
|
||||
wait_for(lambda: hub.get(session_id))
|
||||
return session_id, control
|
||||
|
||||
|
||||
def row_for(client, session_id):
|
||||
rows = client.get("/api/sessions/active").json()
|
||||
return next(item for item in rows if item["session_id"] == str(session_id))
|
||||
|
||||
|
||||
def signal_kinds(row):
|
||||
return {signal["kind"] for signal in row["signals"]}
|
||||
|
||||
|
||||
def assigned_trainee(state, monkeypatch):
|
||||
state.trainee_id = uuid4()
|
||||
who = Principal(login="курсант", full_name="Курсант", role=Role.TRAINEE,
|
||||
trainee_id=state.trainee_id)
|
||||
monkeypatch.setattr(session_ws, "principal_of", lambda _ws: who)
|
||||
|
||||
|
||||
def registry_time(monkeypatch, since, seconds):
|
||||
class Clock(datetime):
|
||||
@classmethod
|
||||
def now(cls, tz=None):
|
||||
return since + timedelta(seconds=seconds)
|
||||
|
||||
monkeypatch.setattr(sessions_http, "datetime", Clock)
|
||||
|
||||
|
||||
def test_signal_backlog_when_queue_reaches_threshold(client, monkeypatch):
|
||||
monkeypatch.setenv("SIGNAL_BACKLOG_THRESHOLD", "2")
|
||||
get_settings.cache_clear()
|
||||
session_id, control = start_two_card_dds(client)
|
||||
try:
|
||||
state = hub.get(session_id)
|
||||
assert len(state.desk.cards) == 2, "оба билета должны прийти сразу без интервала"
|
||||
row = row_for(client, session_id)
|
||||
assert row["dds_open_cards"] == 2
|
||||
assert "backlog" in signal_kinds(row)
|
||||
finally:
|
||||
hub.stop_ticker(session_id)
|
||||
control.__exit__(None, None, None)
|
||||
|
||||
|
||||
def test_declined_card_leaves_backlog(client, monkeypatch):
|
||||
monkeypatch.setenv("SIGNAL_BACKLOG_THRESHOLD", "2")
|
||||
get_settings.cache_clear()
|
||||
session_id, control = start_two_card_dds(client)
|
||||
try:
|
||||
state = hub.get(session_id)
|
||||
first_card = state.desk.ordered()[0]
|
||||
with client.websocket_connect(f"/ws/station/{session_id}?role=dds") as station:
|
||||
read_until(station, "station.state")
|
||||
station.send_json({
|
||||
"type": "card.status", "service": state.card_services(first_card)[0],
|
||||
"status": "declined", "comment": "не наш адрес, передано в УК",
|
||||
})
|
||||
read_until(station, "station.state")
|
||||
row = row_for(client, session_id)
|
||||
assert row["dds_open_cards"] == 2, "отклонённая карточка ждёт «Следующей» на пульте"
|
||||
assert "backlog" not in signal_kinds(row)
|
||||
finally:
|
||||
hub.stop_ticker(session_id)
|
||||
control.__exit__(None, None, None)
|
||||
|
||||
|
||||
def test_signal_refusals_after_two_consecutive_declines(client):
|
||||
session_id, control = start_two_card_dds(client)
|
||||
try:
|
||||
state = hub.get(session_id)
|
||||
first_card, second_card = state.desk.ordered()
|
||||
with client.websocket_connect(f"/ws/station/{session_id}?role=dds") as station:
|
||||
read_until(station, "station.state")
|
||||
first_service = state.card_services(first_card)[0]
|
||||
station.send_json({
|
||||
"type": "card.status", "service": first_service, "status": "declined",
|
||||
"comment": "не наш адрес, передано в УК",
|
||||
})
|
||||
read_until(station, "station.state")
|
||||
|
||||
row = row_for(client, session_id)
|
||||
assert "refusals" not in signal_kinds(row), "одного отказа недостаточно для сигнала"
|
||||
|
||||
station.send_json({"type": "card.open", "card_id": str(second_card.card_id)})
|
||||
read_until(station, "station.state")
|
||||
second_service = state.card_services(second_card)[0]
|
||||
station.send_json({
|
||||
"type": "card.status", "service": second_service, "status": "declined",
|
||||
"comment": "не наша территория, передано в ОМВД",
|
||||
})
|
||||
read_until(station, "station.state")
|
||||
|
||||
row = row_for(client, session_id)
|
||||
assert state.consecutive_refusals == 2
|
||||
assert "refusals" in signal_kinds(row)
|
||||
finally:
|
||||
hub.stop_ticker(session_id)
|
||||
control.__exit__(None, None, None)
|
||||
|
||||
|
||||
def test_acceptance_clears_refusal_streak(client):
|
||||
session_id, control = start_two_card_dds(
|
||||
client, [*POOL, "t20-2-stroke"],
|
||||
)
|
||||
try:
|
||||
state = hub.get(session_id)
|
||||
with client.websocket_connect(f"/ws/station/{session_id}") as station:
|
||||
read_until(station, "station.state")
|
||||
for card in state.desk.ordered()[:2]:
|
||||
station.send_json({"type": "card.open", "card_id": str(card.card_id)})
|
||||
read_until(station, "station.state")
|
||||
station.send_json({
|
||||
"type": "card.status", "service": state.card_services(card)[0],
|
||||
"status": "declined", "comment": "Не наша территория, передано дежурному",
|
||||
})
|
||||
read_until(station, "station.state")
|
||||
assert state.consecutive_refusals == 2
|
||||
third = state.desk.ordered()[2]
|
||||
station.send_json({"type": "card.open", "card_id": str(third.card_id)})
|
||||
read_until(station, "station.state")
|
||||
station.send_json({
|
||||
"type": "card.status", "service": state.card_services(third)[0],
|
||||
"status": "accepted", "comment": "Карточка принята диспетчером",
|
||||
})
|
||||
read_until(station, "station.state")
|
||||
assert state.consecutive_refusals == 0
|
||||
assert "refusals" not in signal_kinds(row_for(client, session_id))
|
||||
finally:
|
||||
hub.stop_ticker(session_id)
|
||||
control.__exit__(None, None, None)
|
||||
|
||||
|
||||
def test_signal_offline_when_station_socket_is_closed_past_the_window(client, monkeypatch):
|
||||
session_id, control = start_two_card_dds(client)
|
||||
try:
|
||||
state = hub.get(session_id)
|
||||
assigned_trainee(state, monkeypatch)
|
||||
with client.websocket_connect(f"/ws/station/{session_id}?role=dds") as station:
|
||||
read_until(station, "station.state")
|
||||
assert hub.station_connected(session_id)
|
||||
assert not hub.station_connected(session_id)
|
||||
disconnected_at = state.socket_last_seen_at
|
||||
assert disconnected_at is not None
|
||||
window = get_settings().signal_offline_window_seconds
|
||||
registry_time(monkeypatch, disconnected_at, window - 1)
|
||||
assert "offline" not in signal_kinds(row_for(client, session_id))
|
||||
registry_time(monkeypatch, disconnected_at, window + 1)
|
||||
row = row_for(client, session_id)
|
||||
assert "offline" in signal_kinds(row)
|
||||
assert state.socket_last_seen_at == disconnected_at, "GET реестра не должен менять занятие"
|
||||
finally:
|
||||
hub.stop_ticker(session_id)
|
||||
control.__exit__(None, None, None)
|
||||
|
||||
|
||||
def test_signal_offline_without_first_connection(client, monkeypatch):
|
||||
session_id, control = start_two_card_dds(client)
|
||||
try:
|
||||
state = hub.get(session_id)
|
||||
started = state.socket_last_seen_at
|
||||
assert started is not None
|
||||
registry_time(monkeypatch, started, get_settings().signal_offline_window_seconds + 1)
|
||||
assert "offline" in signal_kinds(row_for(client, session_id))
|
||||
finally:
|
||||
hub.stop_ticker(session_id)
|
||||
control.__exit__(None, None, None)
|
||||
|
||||
|
||||
def test_call_socket_disconnection_triggers_offline(client, monkeypatch):
|
||||
session_id, control = start_call(client)
|
||||
try:
|
||||
state = hub.get(session_id)
|
||||
assigned_trainee(state, monkeypatch)
|
||||
with client.websocket_connect(f"/ws/call/{session_id}") as call:
|
||||
read_until(call, "call.incoming")
|
||||
assert hub.trainee_connected(session_id)
|
||||
assert not hub.trainee_connected(session_id)
|
||||
disconnected_at = state.socket_last_seen_at
|
||||
registry_time(monkeypatch, disconnected_at,
|
||||
get_settings().signal_offline_window_seconds + 1)
|
||||
assert "offline" in signal_kinds(row_for(client, session_id))
|
||||
finally:
|
||||
hub.stop_ticker(session_id)
|
||||
control.__exit__(None, None, None)
|
||||
|
||||
|
||||
def test_teacher_socket_does_not_hide_offline(client, monkeypatch):
|
||||
session_id, control = start_two_card_dds(client)
|
||||
try:
|
||||
state = hub.get(session_id)
|
||||
with client.websocket_connect(f"/ws/station/{session_id}") as station:
|
||||
read_until(station, "station.state")
|
||||
assert not hub.station_connected(session_id)
|
||||
registry_time(monkeypatch, state.socket_last_seen_at,
|
||||
get_settings().signal_offline_window_seconds + 1)
|
||||
assert "offline" in signal_kinds(row_for(client, session_id))
|
||||
finally:
|
||||
hub.stop_ticker(session_id)
|
||||
control.__exit__(None, None, None)
|
||||
|
||||
|
||||
def test_last_of_two_trainee_sockets_starts_offline_window(client, monkeypatch):
|
||||
session_id, control = start_two_card_dds(client)
|
||||
try:
|
||||
state = hub.get(session_id)
|
||||
assigned_trainee(state, monkeypatch)
|
||||
with client.websocket_connect(f"/ws/station/{session_id}") as first:
|
||||
read_until(first, "station.state")
|
||||
with client.websocket_connect(f"/ws/station/{session_id}") as second:
|
||||
read_until(second, "station.state")
|
||||
assert hub.station_connected(session_id)
|
||||
registry_time(monkeypatch, state.socket_last_seen_at,
|
||||
get_settings().signal_offline_window_seconds + 1)
|
||||
assert "offline" not in signal_kinds(row_for(client, session_id))
|
||||
disconnected_at = state.socket_last_seen_at
|
||||
registry_time(monkeypatch, disconnected_at,
|
||||
get_settings().signal_offline_window_seconds + 1)
|
||||
assert "offline" in signal_kinds(row_for(client, session_id))
|
||||
finally:
|
||||
hub.stop_ticker(session_id)
|
||||
control.__exit__(None, None, None)
|
||||
|
||||
|
||||
def test_offline_window_survives_checkpoint_restore(client, monkeypatch):
|
||||
session_id, control = start_two_card_dds(client)
|
||||
try:
|
||||
state = hub.get(session_id)
|
||||
assigned_trainee(state, monkeypatch)
|
||||
with client.websocket_connect(f"/ws/station/{session_id}") as station:
|
||||
read_until(station, "station.state")
|
||||
disconnected_at = state.socket_last_seen_at
|
||||
restored = load_state(dump_state(state), datetime.now(UTC))
|
||||
hub.drop(session_id)
|
||||
asyncio.run(hub._adopt(restored))
|
||||
assert restored.socket_last_seen_at == disconnected_at
|
||||
registry_time(monkeypatch, disconnected_at,
|
||||
get_settings().signal_offline_window_seconds + 1)
|
||||
assert "offline" in signal_kinds(row_for(client, session_id))
|
||||
finally:
|
||||
hub.stop_ticker(session_id)
|
||||
control.__exit__(None, None, None)
|
||||
|
||||
|
||||
def test_takeover_starts_window_when_socket_was_open(client, monkeypatch):
|
||||
session_id, control = start_two_card_dds(client)
|
||||
try:
|
||||
state = hub.get(session_id)
|
||||
assigned_trainee(state, monkeypatch)
|
||||
with client.websocket_connect(f"/ws/station/{session_id}") as station:
|
||||
read_until(station, "station.state")
|
||||
assert state.socket_connected_at_checkpoint
|
||||
snapshot = dump_state(state)
|
||||
old_time = state.socket_last_seen_at
|
||||
takeover_at = old_time + timedelta(minutes=10)
|
||||
monkeypatch.setattr(hub_module, "now_utc", lambda: takeover_at)
|
||||
restored = load_state(snapshot, datetime.now(UTC))
|
||||
|
||||
class CapturingStore(MemorySessionStore):
|
||||
def __init__(self):
|
||||
self.saved = None
|
||||
|
||||
async def commit(self, state, _records=()):
|
||||
self.saved = dump_state(state)
|
||||
|
||||
store = CapturingStore()
|
||||
local_hub = SessionHub(store)
|
||||
try:
|
||||
assert asyncio.run(local_hub._adopt(restored))
|
||||
assert restored.socket_last_seen_at == takeover_at
|
||||
assert not restored.socket_connected_at_checkpoint
|
||||
assert datetime.fromisoformat(
|
||||
store.saved["socket_last_seen_at"].replace("Z", "+00:00")
|
||||
) == takeover_at
|
||||
assert store.saved["socket_connected_at_checkpoint"] is False
|
||||
finally:
|
||||
local_hub.stop_ticker(session_id)
|
||||
finally:
|
||||
hub.stop_ticker(session_id)
|
||||
control.__exit__(None, None, None)
|
||||
|
||||
|
||||
def test_card_handoff_uses_trainee_station_presence(client, monkeypatch):
|
||||
session_id, control = start_card_handoff(client)
|
||||
try:
|
||||
state = hub.get(session_id)
|
||||
assigned_trainee(state, monkeypatch)
|
||||
with monkeypatch.context() as clock:
|
||||
with client.websocket_connect(f"/ws/call/{session_id}") as call:
|
||||
read_until(call, "card.briefing")
|
||||
call.send_json({"type": "card.submit"})
|
||||
read_until(call, "call.ended")
|
||||
handoff_at = state.socket_last_seen_at
|
||||
clock.setattr(hub_module, "now_utc", lambda: handoff_at + timedelta(minutes=1))
|
||||
assert state.socket_last_seen_at == handoff_at, "старый call-сокет не сбрасывает окно ДДС"
|
||||
assert state.exercise.value == "card" and state.dds_phase
|
||||
assert state.desk.scenarios
|
||||
with client.websocket_connect(f"/ws/station/{session_id}") as station:
|
||||
read_until(station, "station.state")
|
||||
registry_time(monkeypatch, state.socket_last_seen_at,
|
||||
get_settings().signal_offline_window_seconds + 1)
|
||||
row = row_for(client, session_id)
|
||||
assert row["dds_open_cards"] > 0
|
||||
assert "offline" not in signal_kinds(row)
|
||||
finally:
|
||||
hub.stop_ticker(session_id)
|
||||
control.__exit__(None, None, None)
|
||||
|
||||
|
||||
def test_checkpoint_on_another_node_marks_presence_unknown(client, monkeypatch):
|
||||
session_id, control = start_two_card_dds(client)
|
||||
try:
|
||||
state = hub.get(session_id)
|
||||
snapshot = dump_state(state)
|
||||
checkpoint_at = datetime.now(UTC)
|
||||
hub.drop(session_id)
|
||||
|
||||
row = Session(id=session_id, owner_login="dev",
|
||||
live_state=snapshot, checkpoint_at=checkpoint_at)
|
||||
|
||||
class CheckpointDB:
|
||||
async def scalars(self, _query):
|
||||
return IteratorResult(SimpleResultMetaData(["Session"]), iter([(row,)])).scalars()
|
||||
|
||||
monkeypatch.setattr(sessions_http, "require", lambda _request, _role:
|
||||
Principal(login="dev", full_name="Преподаватель",
|
||||
role=Role.INSTRUCTOR))
|
||||
rows = asyncio.run(sessions_http.active(None, db=CheckpointDB()))
|
||||
row = next(item for item in rows if item.session_id == session_id)
|
||||
assert row.presence_known is False
|
||||
assert "offline" not in {item.kind for item in row.signals}
|
||||
finally:
|
||||
hub.stop_ticker(session_id)
|
||||
control.__exit__(None, None, None)
|
||||
|
||||
|
||||
def test_failed_presence_commit_does_not_leave_connected_socket():
|
||||
class BrokenStore(MemorySessionStore):
|
||||
async def commit(self, _state, _records=()):
|
||||
raise OSError("test commit failure")
|
||||
|
||||
local_hub = SessionHub(BrokenStore())
|
||||
state = local_hub.register(SessionState(
|
||||
session_id=uuid4(), scenario_id="test", scenario_title="Тест", level="L1",
|
||||
mode=SessionMode.TRAINING, exercise=Exercise.DDS,
|
||||
))
|
||||
|
||||
async def connect():
|
||||
async with local_hub.trainee_socket(state.session_id, station=True, trainee=True):
|
||||
assert local_hub.is_lease_fenced(state.session_id)
|
||||
|
||||
asyncio.run(connect())
|
||||
assert not local_hub.station_connected(state.session_id)
|
||||
|
||||
|
||||
def test_failed_presence_commit_closes_station_with_fencing_code(client, monkeypatch):
|
||||
class BrokenStore(MemorySessionStore):
|
||||
async def commit(self, _state, _records=()):
|
||||
raise OSError("test commit failure")
|
||||
|
||||
session_id, control = start_two_card_dds(client)
|
||||
try:
|
||||
assigned_trainee(hub.get(session_id), monkeypatch)
|
||||
hub.store = BrokenStore()
|
||||
with client.websocket_connect(f"/ws/station/{session_id}") as station:
|
||||
event = station.receive_json()
|
||||
assert event["type"] == "error" and event["code"] == "internal"
|
||||
with pytest.raises(WebSocketDisconnect) as closed:
|
||||
station.receive_json()
|
||||
assert closed.value.code == 1012
|
||||
assert not hub.station_connected(session_id)
|
||||
finally:
|
||||
control.__exit__(None, None, None)
|
||||
|
||||
|
||||
def test_takeover_retries_failed_presence_checkpoint():
|
||||
class FlakyStore(MemorySessionStore):
|
||||
def __init__(self):
|
||||
self.commits = 0
|
||||
|
||||
async def commit(self, _state, _records=()):
|
||||
self.commits += 1
|
||||
if self.commits == 1:
|
||||
raise OSError("temporary database outage")
|
||||
|
||||
store = FlakyStore()
|
||||
local_hub = SessionHub(store)
|
||||
state = SessionState(
|
||||
session_id=uuid4(), scenario_id="test", scenario_title="Тест", level="L1",
|
||||
mode=SessionMode.TRAINING, exercise=Exercise.DDS,
|
||||
socket_last_seen_at=datetime.now(UTC) - timedelta(minutes=10),
|
||||
socket_connected_at_checkpoint=True,
|
||||
)
|
||||
|
||||
async def recover():
|
||||
assert not await local_hub._adopt(state)
|
||||
assert local_hub.get(state.session_id) is None
|
||||
await local_hub.maintain_lease()
|
||||
assert local_hub.get(state.session_id) is state
|
||||
assert store.commits == 2
|
||||
await local_hub.shutdown()
|
||||
|
||||
asyncio.run(recover())
|
||||
|
|
@ -22,7 +22,7 @@ from app.session.store import MemorySessionStore
|
|||
|
||||
|
||||
@pytest.fixture
|
||||
def client(monkeypatch):
|
||||
def client(monkeypatch, template_caller):
|
||||
async def audit_in_memory(*_args, **_kwargs):
|
||||
return None
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue