Merge remote-tracking branch 'origin/main' into feat/decline-scenarios
# Conflicts: # docs/spec/GAP.md
This commit is contained in:
commit
b7445a60fe
66 changed files with 4250 additions and 333 deletions
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"dds_several_cards": {
|
||||
"score_auto": 53.3,
|
||||
"score_auto": 65.3,
|
||||
"card_status": "processed",
|
||||
"metrics": [
|
||||
[
|
||||
|
|
@ -13,7 +13,7 @@
|
|||
],
|
||||
[
|
||||
"dds_ack",
|
||||
"Карточка 1: Служба 101: первичная отметка за 30 с",
|
||||
"Карточка 1: Служба 101: открытие карточки за 30 с",
|
||||
"0.0 с",
|
||||
true,
|
||||
1.0,
|
||||
|
|
@ -61,11 +61,11 @@
|
|||
],
|
||||
[
|
||||
"dds_work_time",
|
||||
"Карточка 1: Отработка карточки ДДС",
|
||||
"карточка не завершена; 0 с (на 180 с быстрее норматива)",
|
||||
false,
|
||||
"Карточка 1: Первая запись по карточке ДДС",
|
||||
"0 с (на 180 с быстрее норматива)",
|
||||
true,
|
||||
1.5,
|
||||
0.0
|
||||
1.0
|
||||
],
|
||||
[
|
||||
"dds_primary",
|
||||
|
|
@ -77,7 +77,7 @@
|
|||
],
|
||||
[
|
||||
"dds_ack",
|
||||
"Карточка 2: СМП: первичная отметка за 30 с",
|
||||
"Карточка 2: СМП: открытие карточки за 30 с",
|
||||
"0.0 с",
|
||||
true,
|
||||
1.0,
|
||||
|
|
@ -125,11 +125,11 @@
|
|||
],
|
||||
[
|
||||
"dds_work_time",
|
||||
"Карточка 2: Отработка карточки ДДС",
|
||||
"карточка не завершена; 0 с (на 180 с быстрее норматива)",
|
||||
false,
|
||||
"Карточка 2: Первая запись по карточке ДДС",
|
||||
"0 с (на 180 с быстрее норматива)",
|
||||
true,
|
||||
1.5,
|
||||
0.0
|
||||
1.0
|
||||
],
|
||||
[
|
||||
"dds_primary",
|
||||
|
|
@ -141,7 +141,7 @@
|
|||
],
|
||||
[
|
||||
"dds_ack",
|
||||
"Карточка 3: Служба 101: первичная отметка за 30 с",
|
||||
"Карточка 3: Служба 101: открытие карточки за 30 с",
|
||||
"0.0 с",
|
||||
true,
|
||||
1.0,
|
||||
|
|
@ -189,11 +189,11 @@
|
|||
],
|
||||
[
|
||||
"dds_work_time",
|
||||
"Карточка 3: Отработка карточки ДДС",
|
||||
"карточка не завершена; 0 с (на 180 с быстрее норматива)",
|
||||
false,
|
||||
"Карточка 3: Первая запись по карточке ДДС",
|
||||
"0 с (на 180 с быстрее норматива)",
|
||||
true,
|
||||
1.5,
|
||||
0.0
|
||||
1.0
|
||||
]
|
||||
],
|
||||
"findings": [
|
||||
|
|
@ -207,11 +207,6 @@
|
|||
"Карточка 1: Служба 101: ход реагирования не доведён до конца",
|
||||
"не отмечены: Прибытие, Проведение работ"
|
||||
],
|
||||
[
|
||||
"E3",
|
||||
"Карточка 1: ДДС: норматив времени отработки карточки не выполнен",
|
||||
"карточка не завершена; 0 с (на 180 с быстрее норматива)"
|
||||
],
|
||||
[
|
||||
"D2",
|
||||
"Карточка 2: СМП: «Принята» без назначения бригады и хода реагирования",
|
||||
|
|
@ -222,11 +217,6 @@
|
|||
"Карточка 2: СМП: ход реагирования не доведён до конца",
|
||||
"не отмечены: Начало реагирования, Прибытие, Проведение работ"
|
||||
],
|
||||
[
|
||||
"E3",
|
||||
"Карточка 2: ДДС: норматив времени отработки карточки не выполнен",
|
||||
"карточка не завершена; 0 с (на 180 с быстрее норматива)"
|
||||
],
|
||||
[
|
||||
"D5",
|
||||
"Карточка 3: Служба 101: комментарии к статусам не разделяют основание и сведения",
|
||||
|
|
@ -236,17 +226,12 @@
|
|||
"D6",
|
||||
"Карточка 3: Служба 101: ход реагирования не доведён до конца",
|
||||
"не отмечены: Прибытие, Проведение работ"
|
||||
],
|
||||
[
|
||||
"E3",
|
||||
"Карточка 3: ДДС: норматив времени отработки карточки не выполнен",
|
||||
"карточка не завершена; 0 с (на 180 с быстрее норматива)"
|
||||
]
|
||||
],
|
||||
"cards": [
|
||||
[
|
||||
"fire-apartment-l2",
|
||||
56.0,
|
||||
68.0,
|
||||
"Принято, бригада направлена",
|
||||
[
|
||||
"card.ack",
|
||||
|
|
@ -259,7 +244,7 @@
|
|||
],
|
||||
[
|
||||
"t20-2-stroke",
|
||||
48.0,
|
||||
60.0,
|
||||
"",
|
||||
[
|
||||
"card.ack",
|
||||
|
|
@ -268,7 +253,7 @@
|
|||
],
|
||||
[
|
||||
"t01-1-fire-container",
|
||||
56.0,
|
||||
68.0,
|
||||
"",
|
||||
[
|
||||
"card.ack",
|
||||
|
|
@ -281,7 +266,7 @@
|
|||
]
|
||||
},
|
||||
"dds_early_finish": {
|
||||
"score_auto": 35.3,
|
||||
"score_auto": 44.1,
|
||||
"card_status": "processed",
|
||||
"metrics": [
|
||||
[
|
||||
|
|
@ -294,7 +279,7 @@
|
|||
],
|
||||
[
|
||||
"dds_ack",
|
||||
"Карточка 1: СМП: первичная отметка за 30 с",
|
||||
"Карточка 1: СМП: открытие карточки за 30 с",
|
||||
"0.0 с",
|
||||
true,
|
||||
1.0,
|
||||
|
|
@ -342,11 +327,11 @@
|
|||
],
|
||||
[
|
||||
"dds_work_time",
|
||||
"Карточка 1: Отработка карточки ДДС",
|
||||
"карточка не завершена; 0 с (на 180 с быстрее норматива)",
|
||||
false,
|
||||
"Карточка 1: Первая запись по карточке ДДС",
|
||||
"0 с (на 180 с быстрее норматива)",
|
||||
true,
|
||||
1.5,
|
||||
0.0
|
||||
1.0
|
||||
],
|
||||
[
|
||||
"dds_primary",
|
||||
|
|
@ -358,16 +343,16 @@
|
|||
],
|
||||
[
|
||||
"dds_ack",
|
||||
"Карточка 2: Служба 101: первичная отметка за 30 с",
|
||||
"не отмечено",
|
||||
"Карточка 2: Служба 101: открытие карточки за 30 с",
|
||||
"карточка не открыта",
|
||||
false,
|
||||
1.0,
|
||||
null
|
||||
],
|
||||
[
|
||||
"dds_work_time",
|
||||
"Карточка 2: Отработка карточки ДДС",
|
||||
"карточка не завершена; время обработки не зафиксировано",
|
||||
"Карточка 2: Первая запись по карточке ДДС",
|
||||
"первая запись не добавлена; карточка не открыта",
|
||||
false,
|
||||
1.5,
|
||||
0.0
|
||||
|
|
@ -385,25 +370,25 @@
|
|||
"не отмечены: Начало реагирования, Прибытие, Проведение работ"
|
||||
],
|
||||
[
|
||||
"E3",
|
||||
"Карточка 1: ДДС: норматив времени отработки карточки не выполнен",
|
||||
"карточка не завершена; 0 с (на 180 с быстрее норматива)"
|
||||
"D1",
|
||||
"Карточка 2: Служба 101: карточка не открыта",
|
||||
"карточка не открыта до конца занятия"
|
||||
],
|
||||
[
|
||||
"D1",
|
||||
"Карточка 2: Служба 101: статус реагирования не проставлен",
|
||||
"карточка осталась без отметки"
|
||||
"карточка осталась без первичного статуса"
|
||||
],
|
||||
[
|
||||
"E3",
|
||||
"Карточка 2: ДДС: норматив времени отработки карточки не выполнен",
|
||||
"карточка не завершена; время обработки не зафиксировано"
|
||||
"Карточка 2: ДДС: первая запись по карточке позже норматива",
|
||||
"первая запись не добавлена; карточка не открыта"
|
||||
]
|
||||
],
|
||||
"cards": [
|
||||
[
|
||||
"t20-2-stroke",
|
||||
48.0,
|
||||
60.0,
|
||||
"",
|
||||
[
|
||||
"card.ack",
|
||||
|
|
@ -419,7 +404,7 @@
|
|||
]
|
||||
},
|
||||
"handoff_112_to_dds": {
|
||||
"score_auto": 47.8,
|
||||
"score_auto": 56.5,
|
||||
"card_status": "processed",
|
||||
"metrics": [
|
||||
[
|
||||
|
|
@ -480,7 +465,7 @@
|
|||
],
|
||||
[
|
||||
"dds_ack",
|
||||
"Карточка 1: Служба 101: первичная отметка за 30 с",
|
||||
"Карточка 1: Служба 101: открытие карточки за 30 с",
|
||||
"0.0 с",
|
||||
true,
|
||||
1.0,
|
||||
|
|
@ -528,11 +513,11 @@
|
|||
],
|
||||
[
|
||||
"dds_work_time",
|
||||
"Карточка 1: Отработка карточки ДДС",
|
||||
"карточка не завершена; 0 с (на 180 с быстрее норматива)",
|
||||
false,
|
||||
"Карточка 1: Первая запись по карточке ДДС",
|
||||
"0 с (на 180 с быстрее норматива)",
|
||||
true,
|
||||
1.5,
|
||||
0.0
|
||||
1.0
|
||||
],
|
||||
[
|
||||
"dds_primary",
|
||||
|
|
@ -544,7 +529,7 @@
|
|||
],
|
||||
[
|
||||
"dds_ack",
|
||||
"Карточка 2: Служба 101: первичная отметка за 30 с",
|
||||
"Карточка 2: Служба 101: открытие карточки за 30 с",
|
||||
"0.0 с",
|
||||
true,
|
||||
1.0,
|
||||
|
|
@ -592,11 +577,11 @@
|
|||
],
|
||||
[
|
||||
"dds_work_time",
|
||||
"Карточка 2: Отработка карточки ДДС",
|
||||
"карточка не завершена; 0 с (на 180 с быстрее норматива)",
|
||||
false,
|
||||
"Карточка 2: Первая запись по карточке ДДС",
|
||||
"0 с (на 180 с быстрее норматива)",
|
||||
true,
|
||||
1.5,
|
||||
0.0
|
||||
1.0
|
||||
]
|
||||
],
|
||||
"findings": [
|
||||
|
|
@ -630,11 +615,6 @@
|
|||
"Карточка 1: Служба 101: ход реагирования не доведён до конца",
|
||||
"не отмечены: Прибытие, Проведение работ"
|
||||
],
|
||||
[
|
||||
"E3",
|
||||
"Карточка 1: ДДС: норматив времени отработки карточки не выполнен",
|
||||
"карточка не завершена; 0 с (на 180 с быстрее норматива)"
|
||||
],
|
||||
[
|
||||
"D2",
|
||||
"Карточка 2: Служба 101: «Принята» без назначения бригады и хода реагирования",
|
||||
|
|
@ -644,17 +624,12 @@
|
|||
"D6",
|
||||
"Карточка 2: Служба 101: ход реагирования не доведён до конца",
|
||||
"не отмечены: Начало реагирования, Прибытие, Проведение работ"
|
||||
],
|
||||
[
|
||||
"E3",
|
||||
"Карточка 2: ДДС: норматив времени отработки карточки не выполнен",
|
||||
"карточка не завершена; 0 с (на 180 с быстрее норматива)"
|
||||
]
|
||||
],
|
||||
"cards": [
|
||||
[
|
||||
"fire-apartment-l2",
|
||||
56.0,
|
||||
68.0,
|
||||
"",
|
||||
[
|
||||
"card.ack",
|
||||
|
|
@ -666,7 +641,7 @@
|
|||
],
|
||||
[
|
||||
"t01-1-fire-container",
|
||||
48.0,
|
||||
60.0,
|
||||
"",
|
||||
[
|
||||
"card.ack",
|
||||
|
|
@ -745,7 +720,7 @@
|
|||
],
|
||||
[
|
||||
"dds_ack",
|
||||
"Служба 101: первичная отметка за 30 с",
|
||||
"Служба 101: открытие карточки за 30 с",
|
||||
"0.0 с",
|
||||
true,
|
||||
1.0,
|
||||
|
|
|
|||
|
|
@ -135,6 +135,229 @@ def test_generation_sync_preserves_synthetic_dev_account(monkeypatch):
|
|||
asyncio.run(run())
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_stale_generation_snapshot_does_not_revoke_newly_resolved_login(monkeypatch):
|
||||
snapshot_read = asyncio.Event()
|
||||
release_snapshot = asyncio.Event()
|
||||
|
||||
class FakeResult:
|
||||
def all(self):
|
||||
return []
|
||||
|
||||
class FakeDb:
|
||||
async def execute(self, _query):
|
||||
snapshot_read.set()
|
||||
await release_snapshot.wait()
|
||||
return FakeResult()
|
||||
|
||||
async def scalar(self, _query):
|
||||
return 0
|
||||
|
||||
class FakeSession:
|
||||
async def __aenter__(self):
|
||||
return FakeDb()
|
||||
|
||||
async def __aexit__(self, *_args):
|
||||
return None
|
||||
|
||||
auth.prime_generations({})
|
||||
monkeypatch.setattr(auth, "get_settings", lambda: SimpleNamespace(dev_auth_bypass=False))
|
||||
monkeypatch.setattr(auth, "get_sessionmaker", lambda: lambda: FakeSession())
|
||||
sync = asyncio.create_task(auth.sync_generations())
|
||||
await snapshot_read.wait()
|
||||
assert await auth._resolve_unknown_login("just-created") == 0
|
||||
release_snapshot.set()
|
||||
await sync
|
||||
assert auth._generations["just-created"] == 0
|
||||
assert "just-created" not in auth._vanished
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_stale_generation_snapshot_does_not_restore_revoked_cookie(monkeypatch):
|
||||
snapshot_read = asyncio.Event()
|
||||
release_snapshot = asyncio.Event()
|
||||
|
||||
raised = []
|
||||
|
||||
class FakeResult:
|
||||
def all(self):
|
||||
return [("revoked", 0)]
|
||||
|
||||
class FakeDb:
|
||||
async def execute(self, query):
|
||||
if query.is_update:
|
||||
raised.append(query.compile().params)
|
||||
return None
|
||||
snapshot_read.set()
|
||||
await release_snapshot.wait()
|
||||
return FakeResult()
|
||||
|
||||
async def commit(self):
|
||||
return None
|
||||
|
||||
class FakeSession:
|
||||
async def __aenter__(self):
|
||||
return FakeDb()
|
||||
|
||||
async def __aexit__(self, *_args):
|
||||
return None
|
||||
|
||||
auth.prime_generations({"revoked": 0})
|
||||
monkeypatch.setattr(auth, "get_settings", lambda: SimpleNamespace(dev_auth_bypass=False))
|
||||
monkeypatch.setattr(auth, "get_sessionmaker", lambda: lambda: FakeSession())
|
||||
sync = asyncio.create_task(auth.sync_generations())
|
||||
await snapshot_read.wait()
|
||||
auth.invalidate_login("revoked", 1)
|
||||
release_snapshot.set()
|
||||
await sync
|
||||
assert auth._generations["revoked"] == 1
|
||||
# В БД версия уже 1 (отзыв записан до invalidate); UPDATE «только вверх»
|
||||
# в реальной БД ничего не изменит, в снимке же он выглядит как отставание.
|
||||
assert [params["auth_version"] for params in raised] == [1]
|
||||
|
||||
|
||||
def _versioned_users_db(versions: dict[str, int]):
|
||||
"""Поддельная users: сверка читает версии, UPDATE поднимает их только вверх."""
|
||||
updates = []
|
||||
|
||||
class FakeResult:
|
||||
def all(self):
|
||||
return list(versions.items())
|
||||
|
||||
class FakeDb:
|
||||
async def execute(self, query):
|
||||
if query.is_update:
|
||||
params = query.compile().params
|
||||
login = next(v for k, v in params.items() if k.startswith("login"))
|
||||
target = params["auth_version"]
|
||||
updates.append((login, target))
|
||||
if versions.get(login, target) < target:
|
||||
versions[login] = target
|
||||
return None
|
||||
return FakeResult()
|
||||
|
||||
async def scalar(self, _query):
|
||||
raise AssertionError("login is cached, no one-shot lookup expected")
|
||||
|
||||
async def commit(self):
|
||||
return None
|
||||
|
||||
async def rollback(self):
|
||||
return None
|
||||
|
||||
class FakeSession:
|
||||
async def __aenter__(self):
|
||||
return FakeDb()
|
||||
|
||||
async def __aexit__(self, *_args):
|
||||
return None
|
||||
|
||||
return updates, lambda: lambda: FakeSession()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_failed_logout_revocation_is_written_to_db_so_nodes_converge(monkeypatch):
|
||||
"""Неудачный logout поднял версию только на этом узле (B). Сверка не
|
||||
откатывает отзыв, а записывает его в БД: соседний узел A догоняет, и вход
|
||||
на A после этого выдаёт cookie, которую B принимает."""
|
||||
login = "failed-logout-user"
|
||||
versions = {login: 4}
|
||||
updates, sessionmaker = _versioned_users_db(versions)
|
||||
monkeypatch.setattr(auth, "get_settings", lambda: SimpleNamespace(dev_auth_bypass=False))
|
||||
monkeypatch.setattr(auth, "get_sessionmaker", sessionmaker)
|
||||
auth.prime_generations({login: 4})
|
||||
try:
|
||||
auth.invalidate_login(login) # ветка except в logout: БД недоступна
|
||||
assert auth._generations[login] == 5
|
||||
|
||||
await auth.sync_generations()
|
||||
assert updates == [(login, 5)]
|
||||
assert versions[login] == 5, "revocation must reach the shared DB"
|
||||
assert auth._generations[login] == 5, "sync must not roll back a local revocation"
|
||||
|
||||
# Вход на A берёт версию из БД, и теперь она совпадает с версией B.
|
||||
assert versions[login] == auth._generations[login]
|
||||
|
||||
await auth.sync_generations()
|
||||
assert updates == [(login, 5)], "converged nodes must not write again"
|
||||
finally:
|
||||
auth._generations.pop(login, None)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_recreated_account_is_raised_to_the_node_revocation_version(monkeypatch):
|
||||
"""Учётку удалили (узел отозвал её, поколение +1) и создали заново с 0.
|
||||
Без записи в БД узел остался бы впереди навсегда."""
|
||||
login = "recreated-user"
|
||||
versions = {}
|
||||
updates, sessionmaker = _versioned_users_db(versions)
|
||||
monkeypatch.setattr(auth, "get_settings", lambda: SimpleNamespace(dev_auth_bypass=False))
|
||||
monkeypatch.setattr(auth, "get_sessionmaker", sessionmaker)
|
||||
auth.prime_generations({login: 2})
|
||||
try:
|
||||
await auth.sync_generations()
|
||||
assert auth._generations[login] == 3
|
||||
assert login in auth._vanished
|
||||
|
||||
versions[login] = 0 # оператор создал учётку заново
|
||||
await auth.sync_generations()
|
||||
assert updates == [(login, 3)]
|
||||
assert versions[login] == 3
|
||||
assert login not in auth._vanished
|
||||
finally:
|
||||
auth._generations.pop(login, None)
|
||||
auth._vanished.discard(login)
|
||||
|
||||
|
||||
def test_login_raises_db_version_when_node_revocation_was_not_persisted(client, monkeypatch):
|
||||
"""Вход на узле, где отзыв не дошёл до БД: cookie получает версию узла,
|
||||
а БД поднимается до неё. Сброс к версии БД вернул бы силу старой cookie."""
|
||||
from app.config import get_settings
|
||||
|
||||
user = SimpleNamespace(
|
||||
login="ahead-login", auth_provider="local", password_hash="hash",
|
||||
blocked=False, role="instructor", full_name="Преподаватель",
|
||||
service=None, trainee_id=None, auth_version=1,
|
||||
)
|
||||
updates = []
|
||||
|
||||
class FakeDb:
|
||||
async def scalar(self, _statement):
|
||||
return user
|
||||
|
||||
async def execute(self, query):
|
||||
assert query.is_update
|
||||
updates.append(query.compile().params["auth_version"])
|
||||
|
||||
async def commit(self):
|
||||
return None
|
||||
|
||||
class FakeSession:
|
||||
async def __aenter__(self):
|
||||
return FakeDb()
|
||||
|
||||
async def __aexit__(self, *_args):
|
||||
return None
|
||||
|
||||
async def audit_ok(*_args, **_kwargs):
|
||||
return True
|
||||
|
||||
settings = get_settings().model_copy(update={"demo_no_db": False, "ldap_enabled": False})
|
||||
monkeypatch.setattr(auth, "get_settings", lambda: settings)
|
||||
monkeypatch.setattr(auth, "get_sessionmaker", lambda: lambda: FakeSession())
|
||||
monkeypatch.setattr(auth, "verify_password", lambda *_args: True)
|
||||
monkeypatch.setattr(auth, "audit", audit_ok)
|
||||
auth._generations[user.login] = 2 # неудачный logout на этом узле
|
||||
try:
|
||||
response = client.post("/api/auth/login", json={"login": user.login, "password": "x"})
|
||||
assert response.status_code == 200
|
||||
assert updates == [2]
|
||||
assert auth._generations[user.login] == 2, "local revocation must not be rolled back"
|
||||
assert client.get("/api/auth/me").status_code == 200
|
||||
finally:
|
||||
auth._generations.pop(user.login, None)
|
||||
|
||||
|
||||
def test_cross_origin_browser_websocket_is_rejected_before_handshake(client):
|
||||
assert client.post("/api/auth/dev-token").status_code == 200
|
||||
with pytest.raises(WebSocketDisconnect) as exc:
|
||||
|
|
@ -410,6 +633,401 @@ def test_auth_middleware_fails_closed_when_generation_cache_is_stale_but_allows_
|
|||
asyncio.run(run())
|
||||
|
||||
|
||||
def test_middleware_resolves_and_caches_login_unknown_to_this_node(monkeypatch):
|
||||
"""Cluster handshake (lct-42): a login another node just authenticated is
|
||||
fetched via a single SELECT rather than being treated as revoked, and the
|
||||
result is cached so a second request for it does not query again."""
|
||||
login = "peer-node-fresh-login"
|
||||
calls = []
|
||||
|
||||
class FakeDb:
|
||||
async def scalar(self, _query):
|
||||
calls.append(1)
|
||||
return 5
|
||||
|
||||
class FakeSession:
|
||||
async def __aenter__(self):
|
||||
return FakeDb()
|
||||
|
||||
async def __aexit__(self, *_args):
|
||||
return None
|
||||
|
||||
class InnerApp:
|
||||
def __init__(self):
|
||||
self.called = 0
|
||||
|
||||
async def __call__(self, _scope, _receive, _send):
|
||||
self.called += 1
|
||||
|
||||
async def run():
|
||||
auth.prime_generations({})
|
||||
|
||||
def make_scope():
|
||||
return {
|
||||
"type": "http", "path": "/api/admin/users",
|
||||
"session": {
|
||||
"principal": {"login": login},
|
||||
"auth_instance": auth._INSTANCE,
|
||||
"auth_generation": 5,
|
||||
},
|
||||
}
|
||||
|
||||
async def receive():
|
||||
return {"type": "http.request", "body": b"", "more_body": False}
|
||||
|
||||
async def send(_message):
|
||||
return None
|
||||
|
||||
first = InnerApp()
|
||||
await auth.AuthVersionMiddleware(first)(make_scope(), receive, send)
|
||||
assert first.called == 1, "a fresh, valid epoch must reach the route"
|
||||
assert auth._generations[login] == 5
|
||||
assert calls == [1]
|
||||
|
||||
second = InnerApp()
|
||||
await auth.AuthVersionMiddleware(second)(make_scope(), receive, send)
|
||||
assert second.called == 1
|
||||
assert calls == [1], "cached epoch must not trigger a second SELECT"
|
||||
|
||||
settings = auth.get_settings().model_copy(update={"demo_no_db": False})
|
||||
monkeypatch.setattr(auth, "get_settings", lambda: settings)
|
||||
monkeypatch.setattr(auth, "get_sessionmaker", lambda: lambda: FakeSession())
|
||||
try:
|
||||
asyncio.run(run())
|
||||
finally:
|
||||
auth._generations.pop(login, None)
|
||||
auth._lookup_locks.pop(login, None)
|
||||
|
||||
|
||||
def test_middleware_rejects_login_missing_from_users_via_one_shot_query(monkeypatch):
|
||||
login = "peer-node-deleted-login"
|
||||
|
||||
class FakeDb:
|
||||
async def scalar(self, _query):
|
||||
return None
|
||||
|
||||
class FakeSession:
|
||||
async def __aenter__(self):
|
||||
return FakeDb()
|
||||
|
||||
async def __aexit__(self, *_args):
|
||||
return None
|
||||
|
||||
class InnerApp:
|
||||
def __init__(self):
|
||||
self.called = False
|
||||
|
||||
async def __call__(self, _scope, _receive, _send):
|
||||
self.called = True
|
||||
|
||||
async def run():
|
||||
auth.prime_generations({})
|
||||
cookie_session = {
|
||||
"principal": {"login": login},
|
||||
"auth_instance": auth._INSTANCE,
|
||||
"auth_generation": 3,
|
||||
}
|
||||
scope = {"type": "http", "path": "/api/admin/users", "session": cookie_session}
|
||||
|
||||
async def receive():
|
||||
return {"type": "http.request", "body": b"", "more_body": False}
|
||||
|
||||
async def send(_message):
|
||||
return None
|
||||
|
||||
protected = InnerApp()
|
||||
await auth.AuthVersionMiddleware(protected)(scope, receive, send)
|
||||
assert protected.called, "route still runs but the session was cleared below"
|
||||
assert cookie_session == {}
|
||||
assert login not in auth._generations
|
||||
|
||||
settings = auth.get_settings().model_copy(update={"demo_no_db": False})
|
||||
monkeypatch.setattr(auth, "get_settings", lambda: settings)
|
||||
monkeypatch.setattr(auth, "get_sessionmaker", lambda: lambda: FakeSession())
|
||||
try:
|
||||
asyncio.run(run())
|
||||
finally:
|
||||
auth._lookup_locks.pop(login, None)
|
||||
|
||||
|
||||
def test_unknown_login_lookup_does_not_clobber_a_newer_local_revocation(monkeypatch):
|
||||
"""A stale SELECT reply landing after a concurrent local revoke must not
|
||||
resurrect the revoked cookie's epoch (regression: lock only deduplicates
|
||||
concurrent lookups, it does not order a lookup against a write)."""
|
||||
login = "peer-node-race-login"
|
||||
started = asyncio.Event()
|
||||
resume = asyncio.Event()
|
||||
|
||||
class FakeDb:
|
||||
async def scalar(self, _query):
|
||||
started.set()
|
||||
await resume.wait()
|
||||
return 0 # the epoch as it stood before the concurrent revoke below
|
||||
|
||||
class FakeSession:
|
||||
async def __aenter__(self):
|
||||
return FakeDb()
|
||||
|
||||
async def __aexit__(self, *_args):
|
||||
return None
|
||||
|
||||
async def run():
|
||||
auth.prime_generations({})
|
||||
task = asyncio.ensure_future(auth._resolve_unknown_login(login))
|
||||
await started.wait() # the SELECT is in flight, holding the login's lock
|
||||
auth.invalidate_login(login, 1) # a local logout/edit races the reply
|
||||
resume.set()
|
||||
result = await task
|
||||
assert result == 1, "the newer local revocation must win over the stale SELECT reply"
|
||||
assert auth._generations[login] == 1
|
||||
|
||||
monkeypatch.setattr(auth, "get_sessionmaker", lambda: lambda: FakeSession())
|
||||
try:
|
||||
asyncio.run(run())
|
||||
finally:
|
||||
auth._generations.pop(login, None)
|
||||
auth._lookup_locks.pop(login, None)
|
||||
|
||||
|
||||
def test_unknown_login_lookup_deduplicates_concurrent_requests_into_one_select(monkeypatch):
|
||||
login = "peer-node-concurrent-login"
|
||||
calls = []
|
||||
|
||||
class FakeDb:
|
||||
async def scalar(self, _query):
|
||||
calls.append(1)
|
||||
await asyncio.sleep(0.01) # widen the window for a racing second caller
|
||||
return 9
|
||||
|
||||
class FakeSession:
|
||||
async def __aenter__(self):
|
||||
return FakeDb()
|
||||
|
||||
async def __aexit__(self, *_args):
|
||||
return None
|
||||
|
||||
async def run():
|
||||
auth.prime_generations({})
|
||||
results = await asyncio.gather(
|
||||
auth._resolve_unknown_login(login),
|
||||
auth._resolve_unknown_login(login),
|
||||
auth._resolve_unknown_login(login),
|
||||
)
|
||||
assert results == [9, 9, 9]
|
||||
assert calls == [1], "concurrent lookups for one login must issue a single SELECT"
|
||||
assert login not in auth._lookup_locks, "lock entry must not outlive its waiters"
|
||||
|
||||
monkeypatch.setattr(auth, "get_sessionmaker", lambda: lambda: FakeSession())
|
||||
try:
|
||||
asyncio.run(run())
|
||||
finally:
|
||||
auth._generations.pop(login, None)
|
||||
auth._lookup_locks.pop(login, None)
|
||||
|
||||
|
||||
def test_unknown_login_lookup_fails_closed_when_database_is_unreachable(monkeypatch):
|
||||
login = "peer-node-db-outage-login"
|
||||
|
||||
class BrokenSession:
|
||||
async def __aenter__(self):
|
||||
raise OSError("database unavailable")
|
||||
|
||||
async def __aexit__(self, *_args):
|
||||
return None
|
||||
|
||||
class InnerApp:
|
||||
def __init__(self):
|
||||
self.called = False
|
||||
|
||||
async def __call__(self, _scope, _receive, _send):
|
||||
self.called = True
|
||||
|
||||
async def run():
|
||||
auth.prime_generations({})
|
||||
cookie_session = {
|
||||
"principal": {"login": login},
|
||||
"auth_instance": auth._INSTANCE,
|
||||
"auth_generation": 0,
|
||||
}
|
||||
|
||||
async def receive():
|
||||
return {"type": "http.request", "body": b"", "more_body": False}
|
||||
|
||||
http_messages = []
|
||||
|
||||
async def http_send(message):
|
||||
http_messages.append(message)
|
||||
|
||||
http_app = InnerApp()
|
||||
await auth.AuthVersionMiddleware(http_app)(
|
||||
{"type": "http", "path": "/api/admin/users", "session": dict(cookie_session)},
|
||||
receive, http_send,
|
||||
)
|
||||
assert not http_app.called
|
||||
assert http_messages[0]["status"] == 503
|
||||
|
||||
ws_messages = []
|
||||
|
||||
async def ws_send(message):
|
||||
ws_messages.append(message)
|
||||
|
||||
ws_app = InnerApp()
|
||||
await auth.AuthVersionMiddleware(ws_app)(
|
||||
{"type": "websocket", "path": "/ws/control/x", "session": dict(cookie_session)},
|
||||
receive, ws_send,
|
||||
)
|
||||
assert not ws_app.called
|
||||
assert ws_messages[0] == {"type": "websocket.close", "code": 1013}
|
||||
|
||||
settings = auth.get_settings().model_copy(update={"demo_no_db": False})
|
||||
monkeypatch.setattr(auth, "get_settings", lambda: settings)
|
||||
monkeypatch.setattr(auth, "get_sessionmaker", lambda: lambda: BrokenSession())
|
||||
try:
|
||||
asyncio.run(run())
|
||||
finally:
|
||||
auth._lookup_locks.pop(login, None)
|
||||
|
||||
|
||||
def test_unknown_login_lookup_times_out_instead_of_hanging_on_partition(monkeypatch):
|
||||
"""При partition SELECT может висеть до таймаута TCP. Разовая проверка
|
||||
ограничена тем же порогом, что сверка, и закрывает вход 503 / 1013 —
|
||||
и для запроса, который ждёт lock за зависшим."""
|
||||
login = "peer-node-hanging-db-login"
|
||||
monkeypatch.setattr(auth, "AUTH_GENERATION_MAX_AGE_SECONDS", 0.3)
|
||||
|
||||
class HangingDb:
|
||||
async def scalar(self, _query):
|
||||
await asyncio.Event().wait()
|
||||
|
||||
class FakeSession:
|
||||
async def __aenter__(self):
|
||||
return HangingDb()
|
||||
|
||||
async def __aexit__(self, *_args):
|
||||
return None
|
||||
|
||||
class InnerApp:
|
||||
called = False
|
||||
|
||||
async def __call__(self, _scope, _receive, _send):
|
||||
self.called = True
|
||||
|
||||
async def run():
|
||||
auth.prime_generations({})
|
||||
cookie_session = {
|
||||
"principal": {"login": login},
|
||||
"auth_instance": auth._INSTANCE,
|
||||
"auth_generation": 0,
|
||||
}
|
||||
|
||||
async def receive():
|
||||
return {"type": "http.request", "body": b"", "more_body": False}
|
||||
|
||||
http_messages, ws_messages = [], []
|
||||
|
||||
async def http_send(message):
|
||||
http_messages.append(message)
|
||||
|
||||
async def ws_send(message):
|
||||
ws_messages.append(message)
|
||||
|
||||
http_app, ws_app = InnerApp(), InnerApp()
|
||||
started = asyncio.get_running_loop().time()
|
||||
await asyncio.wait_for(asyncio.gather(
|
||||
auth.AuthVersionMiddleware(http_app)(
|
||||
{"type": "http", "path": "/api/admin/users", "session": dict(cookie_session)},
|
||||
receive, http_send,
|
||||
),
|
||||
auth.AuthVersionMiddleware(ws_app)(
|
||||
{"type": "websocket", "path": "/ws/control/x", "session": dict(cookie_session)},
|
||||
receive, ws_send,
|
||||
),
|
||||
), timeout=2)
|
||||
elapsed = asyncio.get_running_loop().time() - started
|
||||
assert elapsed < 1, "a queued handshake must not wait for a second timeout"
|
||||
assert not http_app.called and not ws_app.called
|
||||
assert http_messages[0]["status"] == 503
|
||||
assert ws_messages[0] == {"type": "websocket.close", "code": 1013}
|
||||
assert login not in auth._lookup_locks, "timed-out lookup must release its lock entry"
|
||||
|
||||
settings = auth.get_settings().model_copy(update={"demo_no_db": False})
|
||||
monkeypatch.setattr(auth, "get_settings", lambda: settings)
|
||||
monkeypatch.setattr(auth, "get_sessionmaker", lambda: lambda: FakeSession())
|
||||
asyncio.run(run())
|
||||
|
||||
|
||||
def test_one_shot_lookup_logs_login_marker_not_login(caplog, monkeypatch):
|
||||
"""Кластерный смоук ищет эту метку в журнале узла B: она доказывает, что
|
||||
вход прошёл через разовую проверку, а не через обычную сверку."""
|
||||
login = "peer-node-logged-login"
|
||||
|
||||
class FakeDb:
|
||||
async def scalar(self, _query):
|
||||
return 0
|
||||
|
||||
class FakeSession:
|
||||
async def __aenter__(self):
|
||||
return FakeDb()
|
||||
|
||||
async def __aexit__(self, *_args):
|
||||
return None
|
||||
|
||||
async def run():
|
||||
auth.prime_generations({})
|
||||
assert await auth._resolve_unknown_login(login) == 0
|
||||
|
||||
monkeypatch.setattr(auth, "get_sessionmaker", lambda: lambda: FakeSession())
|
||||
try:
|
||||
with caplog.at_level("WARNING", logger="app.api.auth"):
|
||||
asyncio.run(run())
|
||||
finally:
|
||||
auth._generations.pop(login, None)
|
||||
assert auth.login_log_marker(login) in caplog.text
|
||||
assert login not in caplog.text
|
||||
|
||||
|
||||
def test_missing_login_is_cached_until_next_sync_and_lock_entries_are_released(monkeypatch):
|
||||
login = "peer-node-replayed-missing-login"
|
||||
lookups = []
|
||||
|
||||
class FakeResult:
|
||||
def all(self):
|
||||
return []
|
||||
|
||||
class FakeDb:
|
||||
async def scalar(self, _query):
|
||||
lookups.append(1)
|
||||
return None
|
||||
|
||||
async def execute(self, _query):
|
||||
return FakeResult()
|
||||
|
||||
class FakeSession:
|
||||
async def __aenter__(self):
|
||||
return FakeDb()
|
||||
|
||||
async def __aexit__(self, *_args):
|
||||
return None
|
||||
|
||||
async def run():
|
||||
auth.prime_generations({})
|
||||
assert await auth._resolve_unknown_login(login) is None
|
||||
assert await auth._resolve_unknown_login(login) is None
|
||||
assert lookups == [1], "a replayed cookie must not query users on every request"
|
||||
assert auth._lookup_locks == {}
|
||||
|
||||
await auth.sync_generations()
|
||||
assert await auth._resolve_unknown_login(login) is None
|
||||
assert lookups == [1, 1], "the negative result lives only until the next sync"
|
||||
|
||||
monkeypatch.setattr(auth, "get_settings", lambda: SimpleNamespace(dev_auth_bypass=False))
|
||||
monkeypatch.setattr(auth, "get_sessionmaker", lambda: lambda: FakeSession())
|
||||
try:
|
||||
asyncio.run(run())
|
||||
finally:
|
||||
auth._missing_logins.discard(login)
|
||||
|
||||
|
||||
def test_auth_middleware_uses_fresh_generation_cache_without_per_request_database_query(monkeypatch):
|
||||
from app.config import get_settings
|
||||
|
||||
|
|
|
|||
|
|
@ -30,6 +30,7 @@ async def test_text_dialogue_provider_error_does_not_log_prompt_or_provider_body
|
|||
monkeypatch.setattr(call.hub, "to_observers", lambda *_args: None)
|
||||
state = SimpleNamespace(
|
||||
ended=False,
|
||||
paused=False,
|
||||
exercise=Exercise.CARD,
|
||||
dispatched_card=None,
|
||||
caller=Caller(),
|
||||
|
|
|
|||
|
|
@ -57,6 +57,7 @@ def timers() -> SessionTimers:
|
|||
TimerCode.DDS_ACK: Timer(code=TimerCode.DDS_ACK, started_at=MONO - 12.0, attempt=2),
|
||||
TimerCode.ANSWER: Timer(code=TimerCode.ANSWER, started_at=MONO - 7.0,
|
||||
elapsed_ms=7_000, attempt=3, stopped=True),
|
||||
TimerCode.CARD_FILL: Timer(code=TimerCode.CARD_FILL, elapsed_ms=5_000, paused=True),
|
||||
})
|
||||
|
||||
|
||||
|
|
@ -159,6 +160,10 @@ def full_state() -> SessionState:
|
|||
desk=desk,
|
||||
resolved_outcome="справка",
|
||||
resolve_comment="передано в другой регион",
|
||||
paused=True,
|
||||
paused_at=AT,
|
||||
total_paused_ms=15_000,
|
||||
pause_intervals=[(AT - timedelta(seconds=20), AT - timedelta(seconds=5))],
|
||||
processed_station_commands=[str(uuid4())],
|
||||
text_revealed_facts={"f_address": "улица Ленина, 14"},
|
||||
consecutive_refusals=2,
|
||||
|
|
|
|||
|
|
@ -175,7 +175,8 @@ def test_bounce_is_refused_in_favour_of_112_control():
|
|||
outcome = state.desk.apply(CardBounce(missing_fields=["floor"]), state)
|
||||
|
||||
assert outcome.error == (
|
||||
"ДДС не проверяет заполнение карточки: замечания передаёт служба контроля 112")
|
||||
"Карточку не возвращают в 112 при принятии и не правят на АРМ ДДС. "
|
||||
"Если ошибка вскрылась по докладу бригады с места — сообщите о ней в 112 по телефону")
|
||||
|
||||
|
||||
def test_refusals_leave_desk_untouched():
|
||||
|
|
|
|||
|
|
@ -56,8 +56,9 @@ def fire():
|
|||
|
||||
def accept_with_crew(state: SessionState, card) -> None:
|
||||
service = state.card_services(card)[0]
|
||||
card.on_event("dds.open") # карточка открыта: 30 с остановлены
|
||||
card.set_service_status(service, ServiceStatus.ACCEPTED, "Принято в работу")
|
||||
card.on_event("card.ack")
|
||||
card.on_event("dds.record")
|
||||
card.crew_selected = state.crew_options(card)[0]
|
||||
card.crew_assignments[service] = card.crew_selected
|
||||
card.dds_log.append(("crew.select", now_utc(), card.crew_selected))
|
||||
|
|
|
|||
|
|
@ -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},
|
||||
|
|
@ -443,17 +444,21 @@ def test_instructor_criteria_change_timer_and_success_threshold(client, monkeypa
|
|||
reply_metric = next(item for item in score["metrics"] if item["key"] == "dds_reply")
|
||||
assert reply_metric["passed"]
|
||||
assert not any(item["key"] == "dds_grammar" for item in score["metrics"])
|
||||
# 3 минуты — до первой записи, а она сделана сразу: незавершённая
|
||||
# работа по времени не штрафуется (ответ заказчика, П.5).
|
||||
assert (
|
||||
next(item for item in score["metrics"] if item["key"] == "dds_work_time")[
|
||||
"passed"
|
||||
]
|
||||
is False
|
||||
is True
|
||||
)
|
||||
report = client.get(f"/api/sessions/{session_id}/report").json()
|
||||
assert report["criteria"] == {
|
||||
"decision_time_limit_seconds": 45,
|
||||
"card_fill_time_limit_seconds": 180,
|
||||
"dds_card_work_time_limit_seconds": 180,
|
||||
"dds_report_reaction_enabled": False,
|
||||
"dds_report_reaction_limit_seconds": 20,
|
||||
"allowed_errors": 50,
|
||||
"require_correct_grammar": False,
|
||||
"score_weights": {"dds_ack": 3.5},
|
||||
|
|
@ -500,9 +505,8 @@ def test_dds_statuses_do_not_require_phone_reports(client):
|
|||
finding["code"] == "D2" and "бригады" in finding["summary"]
|
||||
for finding in score["findings"]
|
||||
)
|
||||
assert any(
|
||||
finding["code"] == "E3" and "времени отработки" in finding["summary"]
|
||||
for finding in score["findings"]
|
||||
assert not any(finding["code"] == "E3" for finding in score["findings"]), (
|
||||
"первая запись сделана сразу: дальше работы по времени не нормируются"
|
||||
)
|
||||
finding_codes = {finding["code"] for finding in score["findings"]}
|
||||
penalty_codes = {
|
||||
|
|
@ -703,22 +707,26 @@ def test_complete_dds_workflow_scores_100_with_assignment_without_call(client):
|
|||
control.__exit__(None, None, None)
|
||||
|
||||
|
||||
def test_dds_card_over_three_minutes_has_e3_finding_and_actual_deviation(client):
|
||||
def test_late_first_record_has_e3_and_long_work_after_it_does_not(client):
|
||||
"""П.5: 3 минуты — на открытие и первую запись; дальше работы идут часы."""
|
||||
session_id, control = start(
|
||||
client, criteria={"dds_card_work_time_limit_seconds": 60}
|
||||
)
|
||||
try:
|
||||
state = hub.get(session_id)
|
||||
with client.websocket_connect(f"/ws/station/{session_id}") as station:
|
||||
read_until(station, "card.received")
|
||||
received = read_until(station, "card.received")
|
||||
snapshot = read_until(station, "station.state")["snapshot"]
|
||||
service = snapshot["services"][0]
|
||||
station.send_json({"type": "card.open", "card_id": received["card"]["card_id"]})
|
||||
read_until(station, "station.state")
|
||||
assert state.desk.active.timers.measured_ms(TimerCode.DDS_ACK) is not None
|
||||
state.desk.active.timers.timers[TimerCode.DDS_WORK].started_at = time.monotonic() - 61
|
||||
station.send_json(
|
||||
{"type": "card.status", "service": service, "status": "accepted",
|
||||
"comment": "Основание: доклад старшего.\nСведения: карточка принята."}
|
||||
)
|
||||
snapshot = read_until(station, "station.state")["snapshot"]
|
||||
state.desk.active.timers.timers[TimerCode.DDS_WORK].started_at = time.monotonic() - 61
|
||||
crew = next(
|
||||
option
|
||||
for option in snapshot["crew_options"]
|
||||
|
|
@ -740,14 +748,14 @@ def test_dds_card_over_three_minutes_has_e3_finding_and_actual_deviation(client)
|
|||
)
|
||||
assert not metric["passed"]
|
||||
assert "61 с" in metric["fact"] and "+1 с" in metric["fact"]
|
||||
finding = next(item for item in score["findings"] if item["code"] == "E3")
|
||||
assert "времени отработки карточки" in finding["summary"]
|
||||
findings = [item for item in score["findings"] if item["code"] == "E3"]
|
||||
assert len(findings) == 1 and "первая запись" in findings[0]["summary"]
|
||||
finally:
|
||||
control.__exit__(None, None, None)
|
||||
|
||||
|
||||
def test_complete_dds_workflow_with_training_calls_and_status_updates(client):
|
||||
session_id, control = start(client)
|
||||
session_id, control = start(client, criteria={"dds_report_reaction_enabled": True})
|
||||
try:
|
||||
state = hub.get(session_id)
|
||||
with client.websocket_connect(f"/ws/station/{session_id}") as station:
|
||||
|
|
@ -797,6 +805,120 @@ 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_enabled": True,
|
||||
"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 report["criteria"]["dds_report_reaction_enabled"] is True
|
||||
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, criteria={"dds_report_reaction_enabled": True})
|
||||
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)
|
||||
|
||||
|
|
@ -916,7 +1038,9 @@ def test_concurrent_dds_cards_keep_independent_state_and_timers(client):
|
|||
item["key"] == "dds_grammar"
|
||||
for item in state.score["metrics"]
|
||||
)
|
||||
assert any(item["code"] == "E3" for item in state.score["findings"])
|
||||
assert not any(item["code"] == "E3" 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
|
||||
|
|
|
|||
|
|
@ -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,177 @@ 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, pause_intervals=None):
|
||||
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,
|
||||
report_reaction_enabled=True,
|
||||
pause_intervals=pause_intervals,
|
||||
)
|
||||
metric = next((m for m in dispatcher_metrics(card, 30_000, services=SERVICES,
|
||||
report_reaction_ms=limit_ms,
|
||||
report_reaction_enabled=True,
|
||||
pause_intervals=pause_intervals)
|
||||
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_pause_between_report_and_status_does_not_count_as_reaction_time():
|
||||
marks = full_run(arrived_at=180)
|
||||
reports = [crew_report("arrived", 100)]
|
||||
pause = [(at_second(110), at_second(170))]
|
||||
|
||||
without_pause, late = reaction(marks, reports)
|
||||
with_pause, active = reaction(marks, reports, pause_intervals=pause)
|
||||
|
||||
assert [finding.code for finding in without_pause] == [ErrorCode.D6]
|
||||
assert not late.passed
|
||||
assert with_pause == []
|
||||
assert active.passed and active.credit == time_credit(20_000, 45_000)
|
||||
assert "20 с" in active.fact
|
||||
|
||||
|
||||
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
|
||||
|
|
|
|||
|
|
@ -4,6 +4,8 @@
|
|||
производен и не выбирается руками, сценарий размечается признаками.
|
||||
"""
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
|
@ -93,6 +95,31 @@ def test_all_customer_ticket_cards_are_complete_and_classified():
|
|||
}
|
||||
|
||||
|
||||
def test_ticket_ekp_codes_and_notify_lists_match_current_baseline():
|
||||
"""Изменение правил оповещения не должно незаметно переписать 96 эталонов.
|
||||
|
||||
При намеренной смене версии ЕКП сначала сверяют каждый изменившийся список
|
||||
с источником и методистом, затем обновляют контрольную сумму.
|
||||
"""
|
||||
cards = [
|
||||
scenario
|
||||
for scenario in load_library(Path(__file__).resolve().parents[2] / "scenarios")
|
||||
if scenario.ticket is not None
|
||||
]
|
||||
assert len(cards) == 96
|
||||
manifest = {
|
||||
scenario.id: {
|
||||
"code": scenario.ground_truth.incident_code,
|
||||
"notify": scenario.ground_truth.notify,
|
||||
}
|
||||
for scenario in cards
|
||||
}
|
||||
encoded = json.dumps(manifest, ensure_ascii=False, sort_keys=True, separators=(",", ":"))
|
||||
assert hashlib.sha256(encoded.encode()).hexdigest() == (
|
||||
"2e3d49cc1e4a47bf8813437207a4105799c5721cc54bddc795730267ebb44df4"
|
||||
)
|
||||
|
||||
|
||||
def test_ticket_two_preserves_moscow_and_does_not_invent_missing_victim_data():
|
||||
root = Path(__file__).resolve().parents[2] / "scenarios"
|
||||
cards = {
|
||||
|
|
@ -382,6 +409,43 @@ def test_gasified_object_brings_mosgaz():
|
|||
assert "МОСГАЗ" in card.notify
|
||||
|
||||
|
||||
def test_explicit_offence_signs_add_police_and_removal_recalculates_notify():
|
||||
from app.domain.kio import apply_patch, modifiers
|
||||
|
||||
signs = ["на улице", "мусор", "открытое пламя"]
|
||||
base = derive_incident(KIO(signs=signs))
|
||||
assert base.incident_code == "1010101"
|
||||
assert "МВД" not in base.notify
|
||||
|
||||
stated = apply_patch(base, {"offence_signs": True})
|
||||
assert "offence" in modifiers(stated)
|
||||
assert set(stated.notify) == set(base.notify) | {"МВД"}
|
||||
|
||||
denied = apply_patch(stated, {"offence_signs": False})
|
||||
assert denied.notify == base.notify
|
||||
unknown = apply_patch(stated, {"offence_signs": None})
|
||||
assert unknown.notify == base.notify
|
||||
|
||||
|
||||
def test_offence_kind_also_adds_police_until_both_sources_are_removed():
|
||||
from app.domain.kio import apply_patch, modifiers
|
||||
|
||||
base = derive_incident(KIO(signs=["на улице", "мусор", "открытое пламя"]))
|
||||
stated = apply_patch(base, {"police.offence_kind": "поджог"})
|
||||
assert "offence" in modifiers(stated)
|
||||
assert set(stated.notify) == set(base.notify) | {"МВД"}
|
||||
|
||||
denied_flag = apply_patch(stated, {"offence_signs": False})
|
||||
assert "МВД" in denied_flag.notify, "явно указанный вид правонарушения остаётся источником"
|
||||
with_both = apply_patch(stated, {"offence_signs": True})
|
||||
assert modifiers(with_both).count("offence") == 1
|
||||
without_kind = apply_patch(with_both, {"police.offence_kind": " "})
|
||||
assert "МВД" in without_kind.notify
|
||||
without_flag = apply_patch(without_kind, {"offence_signs": False})
|
||||
assert without_flag.notify == base.notify
|
||||
assert "offence" not in modifiers(without_flag)
|
||||
|
||||
|
||||
def test_removed_modifier_recalculates_notify_without_stale_service():
|
||||
from app.domain.kio import KIO, FireDetails, apply_patch, derive_incident
|
||||
|
||||
|
|
|
|||
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
|
||||
|
|
@ -68,10 +68,21 @@ 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 ["Критерии", "", "Лимит реакции на доклад бригады, с", "не применяется", ""] in rows
|
||||
assert _cell(" =cmd") == "' =cmd"
|
||||
assert _cell("\tОбычный текст") == "'\tОбычный текст"
|
||||
|
||||
|
||||
def test_csv_shows_enabled_report_reaction_limit():
|
||||
report = sample_report()
|
||||
report.criteria.dds_report_reaction_enabled = True
|
||||
report.criteria.dds_report_reaction_limit_seconds = 20
|
||||
|
||||
rows = list(csv.reader(io.StringIO(to_csv(report).decode("utf-8-sig"))))
|
||||
|
||||
assert ["Критерии", "", "Лимит реакции на доклад бригады, с", "20", ""] in rows
|
||||
|
||||
|
||||
def test_pdf_supports_cyrillic_and_spans_pages(tmp_path):
|
||||
data = to_pdf(sample_report(long=True))
|
||||
assert data.startswith(b"%PDF-")
|
||||
|
|
@ -89,6 +100,8 @@ def test_pdf_supports_cyrillic_and_spans_pages(tmp_path):
|
|||
assert "Отчёт по учебному занятию" in extracted
|
||||
assert "Адрес должен быть уточнён" in extracted
|
||||
assert "Заявитель сообщает о дыме" in extracted
|
||||
assert "Лимит реакции на доклад бригады" in extracted
|
||||
assert "не применяется" in extracted
|
||||
|
||||
|
||||
def test_certificate_pdf_contains_saved_result(tmp_path):
|
||||
|
|
|
|||
|
|
@ -84,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"),),
|
||||
|
|
|
|||
|
|
@ -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,65 @@ 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_enabled = True
|
||||
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.criteria.dds_report_reaction_enabled
|
||||
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 test_report_reaction_default_off_keeps_status_completeness_d6():
|
||||
state = dds_state()
|
||||
card = state.desk.active
|
||||
service = state.card_services(card)[0]
|
||||
card.set_service_status(service, ServiceStatus.RESPONDING,
|
||||
"Основание: доклад.\nСведения: бригада выехала.")
|
||||
without_report = state.model_copy(deep=True)
|
||||
card.phone_reports.append(PhoneReportRecord(
|
||||
service=service, crew=card.crew_selected, phase="dispatched",
|
||||
text="Бригада выехала", at=card.status_log[-1].at - timedelta(seconds=70),
|
||||
))
|
||||
|
||||
result = score_dds_card(state, card)
|
||||
baseline = score_dds_card(without_report, without_report.desk.active)
|
||||
|
||||
assert state.criteria.dds_report_reaction_enabled is False
|
||||
assert result.score_auto == baseline.score_auto
|
||||
assert [(item.code, item.metric_key, item.fact) for item in result.findings] == [
|
||||
(item.code, item.metric_key, item.fact) for item in baseline.findings
|
||||
]
|
||||
assert not any(item.key == "dds_report_reaction" for item in result.metrics)
|
||||
assert not any(item.metric_key == "dds_report_reaction" for item in result.findings)
|
||||
assert any(item.code is ErrorCode.D6 for item in result.findings)
|
||||
|
||||
|
||||
def completed_card() -> DdsCardRecord:
|
||||
return DdsCardRecord(
|
||||
card_id=uuid4(),
|
||||
|
|
@ -143,8 +203,9 @@ def test_concurrent_dds_queue_round_trips_with_each_timer_and_status():
|
|||
first_card = state.desk.active
|
||||
first_id = first_card.card_id
|
||||
first_service = state.card_services(first_card)[0]
|
||||
first_card.on_event("dds.open") # карточка открыта: 30 с остановлены
|
||||
first_card.set_service_status(first_service, ServiceStatus.ACCEPTED, "Принято в работу")
|
||||
first_card.on_event("card.ack")
|
||||
first_card.on_event("dds.record")
|
||||
second_id = state.desk.ordered()[1].card_id
|
||||
assert state.desk.open(second_id)
|
||||
|
||||
|
|
|
|||
686
backend/tests/test_session_pause.py
Normal file
686
backend/tests/test_session_pause.py
Normal file
|
|
@ -0,0 +1,686 @@
|
|||
"""Пауза занятия с пульта преподавателя (lct-39).
|
||||
|
||||
Часы — управляемый монотонный счётчик: тест продвигает их сам, а не спит,
|
||||
проверяя ровно то, что требует карточка — пауза не считается в норматив,
|
||||
переживает чекпойнт и пишется в аудит.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import time
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from pathlib import Path
|
||||
from uuid import uuid4
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from app.api.http import sessions as sessions_http
|
||||
from app.api.ws import control as control_ws
|
||||
from app.api.ws import call as call_ws
|
||||
from app.api.ws import station as station_ws
|
||||
from app.domain.classifiers import DDSCode
|
||||
from app.domain.events import (
|
||||
CallbackDial,
|
||||
CallEnded,
|
||||
CallEndReason,
|
||||
CardAck,
|
||||
DdsDispatch,
|
||||
DirectorInject,
|
||||
ErrorEvent,
|
||||
Exercise,
|
||||
HintRequest,
|
||||
KioPatchIn,
|
||||
LessonCriteria,
|
||||
Principal,
|
||||
SelfAssessmentSubmit,
|
||||
SessionMode,
|
||||
SessionPause,
|
||||
SessionPaused,
|
||||
SessionResume,
|
||||
TtsCancel,
|
||||
TtsEnd,
|
||||
)
|
||||
from app.domain.roles import Role
|
||||
from app.domain.statuses import PhoneReportRecord, ServiceStatus
|
||||
from app.domain.timers import TimerCode
|
||||
from app.main import app
|
||||
from app.scenarios.loader import load_file
|
||||
from app.scoring.dispatcher import dispatcher_metrics
|
||||
from app.session import timers as timers_module
|
||||
from app.session.checkpoint import dump_state, load_state
|
||||
from app.session.finish import end_session, score_dds_card
|
||||
from app.session.hub import hub
|
||||
from app.session.state import SessionState, now_utc
|
||||
from app.session.store import LessonPaused, LessonResumed, MemorySessionStore
|
||||
from app.session.timers import Timer
|
||||
|
||||
LIBRARY = Path(__file__).resolve().parents[2] / "scenarios"
|
||||
TEACHER = Principal(login="teacher", full_name="Преподаватель", role=Role.INSTRUCTOR)
|
||||
|
||||
|
||||
def _dds_state_with_active_card(clock, criteria_ms: int = 30_000):
|
||||
scenario = load_file(LIBRARY / "fire-apartment-l2.yaml", LIBRARY)
|
||||
state = SessionState(
|
||||
session_id=uuid4(), scenario_id=scenario.id, scenario_title=scenario.title,
|
||||
level=scenario.level.value, mode=SessionMode.TRAINING, exercise=Exercise.DDS,
|
||||
scenario=scenario, owner_login="teacher",
|
||||
)
|
||||
state.timers.limits[TimerCode.DDS_ACK] = criteria_ms
|
||||
from app.session.dds import prepare_queue
|
||||
|
||||
prepare_queue(state, [scenario.model_copy(deep=True)])
|
||||
return state, state.desk.active
|
||||
|
||||
|
||||
class FakeClock:
|
||||
"""Управляемая замена `time.monotonic`: тест продвигает время явно."""
|
||||
|
||||
def __init__(self, start: float = 1_000.0) -> None:
|
||||
self.now = start
|
||||
|
||||
def __call__(self) -> float:
|
||||
return self.now
|
||||
|
||||
def advance(self, seconds: float) -> None:
|
||||
self.now += seconds
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def clock(monkeypatch):
|
||||
fake = FakeClock()
|
||||
monkeypatch.setattr(timers_module.time, "monotonic", fake)
|
||||
return fake
|
||||
|
||||
|
||||
def test_timer_pause_freezes_elapsed_until_resume(clock):
|
||||
timer = Timer(code=TimerCode.DDS_ACK)
|
||||
timer.start(clock.now)
|
||||
clock.advance(10)
|
||||
|
||||
timer.pause(clock.now)
|
||||
frozen = timer.current_ms(clock.now)
|
||||
clock.advance(60) # долгая пауза — часы идут, таймер не должен
|
||||
|
||||
assert timer.current_ms(clock.now) == frozen == 10_000
|
||||
|
||||
timer.resume(clock.now)
|
||||
clock.advance(15)
|
||||
|
||||
assert timer.current_ms(clock.now) == 25_000
|
||||
|
||||
|
||||
def test_session_pause_excludes_wait_from_dds_ack_deadline(clock):
|
||||
"""Карточка: 30 с норматива. Пауза 60 с внутри окна не должна дать D1 —
|
||||
отметка приходит на 25-й секунде активного времени, а не 85-й реальной."""
|
||||
scenario = load_file(LIBRARY / "fire-apartment-l2.yaml", LIBRARY)
|
||||
state = SessionState(
|
||||
session_id=uuid4(), scenario_id=scenario.id, scenario_title=scenario.title,
|
||||
level=scenario.level.value, mode=SessionMode.TRAINING, exercise=Exercise.DDS,
|
||||
scenario=scenario, criteria=LessonCriteria(decision_time_limit_seconds=30),
|
||||
)
|
||||
state.timers.limits[TimerCode.DDS_ACK] = 30_000
|
||||
from app.session.dds import prepare_queue
|
||||
prepare_queue(state, [scenario.model_copy(deep=True)])
|
||||
card = state.desk.active
|
||||
|
||||
clock.advance(10)
|
||||
state.pause()
|
||||
clock.advance(60)
|
||||
state.resume()
|
||||
clock.advance(15)
|
||||
card.on_event("card.ack")
|
||||
|
||||
measured = card.timers.measured_ms(TimerCode.DDS_ACK)
|
||||
assert measured == 25_000
|
||||
assert measured <= card.timers.limits[TimerCode.DDS_ACK]
|
||||
|
||||
|
||||
def test_pause_freezes_queue_arrival_and_resume_shifts_it(clock, monkeypatch):
|
||||
scenario = load_file(LIBRARY / "fire-apartment-l2.yaml", LIBRARY)
|
||||
second = scenario.model_copy(deep=True)
|
||||
state = SessionState(
|
||||
session_id=uuid4(), scenario_id=scenario.id, scenario_title=scenario.title,
|
||||
level=scenario.level.value, mode=SessionMode.TRAINING, exercise=Exercise.DDS,
|
||||
scenario=scenario,
|
||||
)
|
||||
from app.session.dds import prepare_queue
|
||||
prepare_queue(state, [scenario, second], arrival_interval_seconds=30, max_waiting=3)
|
||||
|
||||
due_before = state.desk.next_arrival_at
|
||||
assert due_before is not None
|
||||
|
||||
base = now_utc()
|
||||
monkeypatch.setattr("app.session.state.now_utc", lambda: base)
|
||||
state.pause()
|
||||
idle = timedelta(minutes=1)
|
||||
monkeypatch.setattr("app.session.state.now_utc", lambda: base + idle)
|
||||
state.resume()
|
||||
|
||||
# Следующая карточка не должна поступить раньше, чем простояла пауза.
|
||||
assert state.desk.next_arrival_at == due_before + idle
|
||||
assert state.desk.deliver_due(due_before + timedelta(seconds=1)) == 0
|
||||
|
||||
|
||||
def test_checkpoint_round_trip_keeps_pause_without_adding_downtime(clock):
|
||||
scenario = load_file(LIBRARY / "fire-apartment-l2.yaml", LIBRARY)
|
||||
state = SessionState(
|
||||
session_id=uuid4(), scenario_id=scenario.id, scenario_title=scenario.title,
|
||||
level=scenario.level.value, mode=SessionMode.TRAINING, exercise=Exercise.DDS,
|
||||
scenario=scenario,
|
||||
)
|
||||
from app.session.dds import prepare_queue
|
||||
prepare_queue(state, [scenario.model_copy(deep=True)])
|
||||
card = state.desk.active
|
||||
|
||||
clock.advance(5)
|
||||
state.pause()
|
||||
saved_at = now_utc()
|
||||
|
||||
payload = dump_state(state)
|
||||
# Процесс лежал минуту — по часам сервера, не по monotonic этого теста.
|
||||
restored_at = saved_at + timedelta(minutes=1)
|
||||
restored = load_state(payload, restored_at)
|
||||
|
||||
assert restored.paused is True
|
||||
restored_card = restored.desk.active
|
||||
restored_timer = restored_card.timers.timers[TimerCode.DDS_ACK]
|
||||
# Простоя в счёт не пошло: элапсед остался тем же, что был на паузе.
|
||||
assert restored_timer.elapsed_ms == card.timers.timers[TimerCode.DDS_ACK].elapsed_ms
|
||||
assert restored_timer.started_at is None
|
||||
|
||||
# После takeover пауза всё ещё действует; лишь явное возобновление
|
||||
# продолжает норматив с пяти секунд до прерывания.
|
||||
restored.resume()
|
||||
clock.advance(10)
|
||||
assert restored_timer.current_ms(clock.now) == 15_000
|
||||
|
||||
|
||||
def test_pause_and_resume_are_recorded_for_audit(monkeypatch):
|
||||
scenario = load_file(LIBRARY / "fire-apartment-l2.yaml", LIBRARY)
|
||||
state = SessionState(
|
||||
session_id=uuid4(), scenario_id=scenario.id, scenario_title=scenario.title,
|
||||
level=scenario.level.value, mode=SessionMode.TRAINING, exercise=Exercise.DDS,
|
||||
scenario=scenario, owner_login="teacher",
|
||||
)
|
||||
from app.session.dds import prepare_queue
|
||||
prepare_queue(state, [scenario.model_copy(deep=True)])
|
||||
|
||||
store = MemorySessionStore()
|
||||
monkeypatch.setitem(hub._sessions, state.session_id, state)
|
||||
monkeypatch.setattr(hub, "store", store)
|
||||
|
||||
async def run():
|
||||
async with hub.operation(state.session_id):
|
||||
await control_ws._command(state.session_id, SessionPause(), TEACHER)
|
||||
async with hub.operation(state.session_id):
|
||||
await control_ws._command(state.session_id, SessionResume(), TEACHER)
|
||||
|
||||
asyncio.run(run())
|
||||
|
||||
records = [record for _sid, batch in store.commits for record in batch]
|
||||
assert any(isinstance(record, LessonPaused) for record in records)
|
||||
assert any(isinstance(record, LessonResumed) for record in records)
|
||||
assert state.paused is False
|
||||
|
||||
|
||||
def test_dispatcher_metric_dds_ack_excludes_pause_from_elapsed(clock):
|
||||
"""Регрессия: `dispatcher_metrics` считала `primary.at - dispatched_at` —
|
||||
календарную разницу, которую пауза не трогает. Метрика должна брать то
|
||||
же замороженное время, что и таймер на экране."""
|
||||
state, card = _dds_state_with_active_card(clock)
|
||||
services = state.card_services(card)
|
||||
service = services[0]
|
||||
|
||||
clock.advance(10)
|
||||
state.pause()
|
||||
clock.advance(60)
|
||||
state.resume()
|
||||
clock.advance(15)
|
||||
card.set_service_status(service, ServiceStatus.ACCEPTED, "Принято")
|
||||
card.on_event("card.ack")
|
||||
|
||||
metric = next(m for m in dispatcher_metrics(card, 30_000, services=services) if m.key == "dds_ack")
|
||||
assert metric.passed is True
|
||||
assert "25.0" in metric.fact
|
||||
|
||||
|
||||
def test_ending_session_while_paused_finalizes_pause_and_unblocks_report(clock, monkeypatch):
|
||||
"""Регрессия: занятие, завершённое прямо на паузе, оставляло `paused=True`
|
||||
навсегда — самооценка и отчёт отклонялись баннером «Пауза», а простой,
|
||||
не закрытый явным `session.resume`, не попадал в `total_paused_ms`."""
|
||||
from app.session import finish as finish_module
|
||||
|
||||
async def no_coach(_metrics):
|
||||
class NoCoaching:
|
||||
def model_dump(self, **_kwargs):
|
||||
return {}
|
||||
return NoCoaching()
|
||||
|
||||
monkeypatch.setattr(finish_module, "coach", no_coach)
|
||||
|
||||
scenario = load_file(LIBRARY / "fire-apartment-l2.yaml", LIBRARY)
|
||||
state = SessionState(
|
||||
session_id=uuid4(), scenario_id=scenario.id, scenario_title=scenario.title,
|
||||
level=scenario.level.value, mode=SessionMode.TRAINING, exercise=Exercise.CALL,
|
||||
scenario=scenario, owner_login="teacher",
|
||||
)
|
||||
store = MemorySessionStore()
|
||||
monkeypatch.setattr(hub, "store", store)
|
||||
monkeypatch.setitem(hub._sessions, state.session_id, state)
|
||||
|
||||
base = now_utc()
|
||||
monkeypatch.setattr("app.session.state.now_utc", lambda: base)
|
||||
clock.advance(5)
|
||||
state.pause()
|
||||
clock.advance(30) # преподаватель завершает занятие, не возобновив его
|
||||
idle = timedelta(seconds=45)
|
||||
monkeypatch.setattr("app.session.state.now_utc", lambda: base + idle)
|
||||
|
||||
events = []
|
||||
|
||||
async def run():
|
||||
with hub.trainee(state.session_id) as queue:
|
||||
async with hub.operation(state.session_id):
|
||||
await end_session(state.session_id, state, CallEndReason.INSTRUCTOR)
|
||||
async with hub.operation(state.session_id):
|
||||
await call_ws._handle(
|
||||
state.session_id, state,
|
||||
SelfAssessmentSubmit(missed=[], comment="Проверил"),
|
||||
)
|
||||
while not queue.empty():
|
||||
events.append(queue.get_nowait())
|
||||
|
||||
asyncio.run(run())
|
||||
|
||||
assert state.ended
|
||||
assert state.paused is False
|
||||
assert state.total_paused_ms >= 30_000
|
||||
assert state.self_assessed
|
||||
assert state.score is not None
|
||||
assert state.score["full_report"]["total_paused_ms"] >= 30_000
|
||||
assert any(isinstance(event, SessionPaused) and not event.paused for event in events)
|
||||
# Снятие паузы приходит после `call.ended`: иначе клиент дошлёт отложенные
|
||||
# на паузе правки в уже закрытое занятие.
|
||||
kinds = [type(event) for event in events]
|
||||
assert kinds.index(CallEnded) < kinds.index(SessionPaused)
|
||||
assert any(isinstance(record, LessonResumed) for _sid, batch in store.commits for record in batch)
|
||||
|
||||
|
||||
def test_dds_work_overdue_survives_pause():
|
||||
"""Регрессия: `dds_work_overdue_cards` требовал `started_at is not None`,
|
||||
который пауза обнуляет — уже случившееся нарушение исчезало из реестра."""
|
||||
scenario = load_file(LIBRARY / "fire-apartment-l2.yaml", LIBRARY)
|
||||
from app.session.dds import build_card
|
||||
from app.session.timers import SessionTimers
|
||||
|
||||
limits = {**SessionTimers().limits, TimerCode.DDS_WORK: 1_000}
|
||||
card = build_card(scenario, 0, limits)
|
||||
timer = card.timers.timers.setdefault(TimerCode.DDS_WORK, Timer(code=TimerCode.DDS_WORK))
|
||||
timer.start(time.monotonic() - 5.0) # уже превысил норматив в 1 с
|
||||
timer.pause(time.monotonic())
|
||||
|
||||
overdue = (
|
||||
timer.started_at is not None or timer.paused
|
||||
) and not timer.stopped and timer.current_ms(time.monotonic()) > limits[TimerCode.DDS_WORK]
|
||||
assert overdue is True
|
||||
|
||||
|
||||
def test_active_registry_keeps_existing_overdue_during_pause(http_client, clock):
|
||||
app.dependency_overrides[sessions_http.optional_session] = lambda: None
|
||||
state, card = _dds_state_with_active_card(clock)
|
||||
state.owner_login = "dev"
|
||||
timer = card.timers.timers.setdefault(TimerCode.DDS_WORK, Timer(code=TimerCode.DDS_WORK))
|
||||
card.timers.limits[TimerCode.DDS_WORK] = 1_000
|
||||
timer.start(clock.now)
|
||||
clock.advance(5)
|
||||
state.pause()
|
||||
hub._sessions[state.session_id] = state
|
||||
try:
|
||||
response = http_client.get("/api/sessions/active")
|
||||
assert response.status_code == 200
|
||||
row = next(item for item in response.json() if item["session_id"] == str(state.session_id))
|
||||
assert row["dds_work_overdue_cards"] == 1
|
||||
assert row["paused"] is True
|
||||
finally:
|
||||
hub._sessions.pop(state.session_id, None)
|
||||
app.dependency_overrides.pop(sessions_http.optional_session, None)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def http_client(monkeypatch):
|
||||
async def audit_in_memory(*_args, **_kwargs):
|
||||
return None
|
||||
|
||||
monkeypatch.setattr(sessions_http, "audit_required", audit_in_memory)
|
||||
with TestClient(app) as test_client:
|
||||
test_client.post("/api/auth/dev-token")
|
||||
hub.store = MemorySessionStore()
|
||||
yield test_client
|
||||
|
||||
|
||||
def _wait(predicate, timeout: float = 3.0):
|
||||
deadline = time.monotonic() + timeout
|
||||
while time.monotonic() < deadline:
|
||||
value = predicate()
|
||||
if value:
|
||||
return value
|
||||
time.sleep(0.02)
|
||||
raise AssertionError("не дождались")
|
||||
|
||||
|
||||
def test_paused_socket_drops_audio_instead_of_feeding_voice(http_client):
|
||||
"""Регрессия: пауза блокировала только JSON-команды — бинарные аудиокадры
|
||||
/ws/call всё равно доходили до счётчика и голосового контура."""
|
||||
session_id = uuid4()
|
||||
with http_client.websocket_connect(f"/ws/control/{session_id}") as control:
|
||||
control.send_json({
|
||||
"type": "scenario.start", "scenario_id": "fire-apartment-l2",
|
||||
"trainee": "Иванов И.И.", "mode": "training",
|
||||
})
|
||||
_wait(lambda: hub.get(session_id))
|
||||
state = hub.get(session_id)
|
||||
|
||||
with http_client.websocket_connect(f"/ws/call/{session_id}") as trainee:
|
||||
trainee.send_bytes(b"\x00\x00" * 320)
|
||||
_wait(lambda: state.audio_frames == 1)
|
||||
|
||||
control.send_json({"type": "session.pause"})
|
||||
_wait(lambda: state.paused)
|
||||
|
||||
trainee.send_bytes(b"\x00\x00" * 320)
|
||||
trainee.send_bytes(b"\x00\x00" * 320)
|
||||
time.sleep(0.1) # кадры паузы не должны просочиться в счётчик
|
||||
|
||||
assert state.audio_frames == 1
|
||||
|
||||
|
||||
def test_reconnect_to_call_replays_paused_banner(http_client):
|
||||
"""Регрессия: переподключение к /ws/call не сообщало о текущей паузе —
|
||||
баннер появлялся только после следующего `session.pause`."""
|
||||
session_id = uuid4()
|
||||
with http_client.websocket_connect(f"/ws/control/{session_id}") as control:
|
||||
control.send_json({
|
||||
"type": "scenario.start", "scenario_id": "fire-apartment-l2",
|
||||
"trainee": "Иванов И.И.", "mode": "training",
|
||||
})
|
||||
_wait(lambda: hub.get(session_id))
|
||||
control.send_json({"type": "session.pause"})
|
||||
_wait(lambda: hub.get(session_id).paused)
|
||||
|
||||
with http_client.websocket_connect(f"/ws/call/{session_id}") as trainee:
|
||||
messages = [trainee.receive_json() for _ in range(2)]
|
||||
assert any(
|
||||
message["type"] == "session.paused" and message["paused"]
|
||||
for message in messages
|
||||
)
|
||||
|
||||
control.send_json({"type": "session.resume"})
|
||||
_wait(lambda: not hub.get(session_id).paused)
|
||||
with http_client.websocket_connect(f"/ws/call/{session_id}") as trainee:
|
||||
message = trainee.receive_json()
|
||||
assert message == {"type": "session.paused", "paused": False}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_pause_cancels_inflight_voice_and_clears_buffer(monkeypatch):
|
||||
from app.voice import pipeline
|
||||
|
||||
class FakeVad:
|
||||
def __init__(self, *_args, **_kwargs):
|
||||
self.reset_count = 0
|
||||
|
||||
def reset(self):
|
||||
self.reset_count += 1
|
||||
|
||||
def push(self, _frame):
|
||||
return []
|
||||
|
||||
monkeypatch.setattr(pipeline, "StreamingVad", FakeVad)
|
||||
state, _card = _dds_state_with_active_card(lambda: 1_000.0)
|
||||
events = []
|
||||
audio = []
|
||||
started = asyncio.Event()
|
||||
release = asyncio.Event()
|
||||
|
||||
class Models:
|
||||
vad_path = None
|
||||
|
||||
voice = pipeline.VoiceSession(
|
||||
state.session_id, state, Models(), events.append, lambda _event: None, audio.append,
|
||||
)
|
||||
state.voice = voice
|
||||
|
||||
async def slow_synthesis(_text):
|
||||
started.set()
|
||||
await release.wait()
|
||||
return b"\x00\x00" * 2_400
|
||||
|
||||
monkeypatch.setattr(voice, "synthesize", slow_synthesis)
|
||||
try:
|
||||
speaking = voice.speak("Алло, помогите!", state.persona.mood if state.persona else pipeline.Mood.PANIC)
|
||||
await asyncio.wait_for(started.wait(), timeout=1)
|
||||
voice._queue.put_nowait((b"old", time.monotonic()))
|
||||
state.pause()
|
||||
release.set()
|
||||
with pytest.raises(asyncio.CancelledError):
|
||||
await speaking
|
||||
await asyncio.sleep(0)
|
||||
assert any(isinstance(event, TtsCancel) and event.reason == "director" for event in events)
|
||||
assert not any(isinstance(event, TtsEnd) for event in events)
|
||||
assert audio == []
|
||||
assert voice._queue.empty()
|
||||
assert voice._vad.reset_count == 1
|
||||
finally:
|
||||
await voice.close()
|
||||
|
||||
|
||||
def _call_state():
|
||||
scenario = load_file(LIBRARY / "fire-apartment-l2.yaml", LIBRARY)
|
||||
return SessionState(
|
||||
session_id=uuid4(), scenario_id=scenario.id, scenario_title=scenario.title,
|
||||
level=scenario.level.value, mode=SessionMode.TRAINING, exercise=Exercise.CALL,
|
||||
scenario=scenario, owner_login="teacher",
|
||||
)
|
||||
|
||||
|
||||
def _drain(queue) -> list:
|
||||
events = []
|
||||
while not queue.empty():
|
||||
events.append(queue.get_nowait())
|
||||
return events
|
||||
|
||||
|
||||
def test_timer_start_on_pause_keeps_frozen_elapsed(clock):
|
||||
"""Повторное событие запуска на паузе не должно пустить часы с нуля."""
|
||||
timer = Timer(code=TimerCode.CALLBACK)
|
||||
timer.start(clock.now)
|
||||
clock.advance(4)
|
||||
timer.pause(clock.now)
|
||||
|
||||
timer.start(clock.now)
|
||||
clock.advance(120)
|
||||
|
||||
assert timer.paused is True
|
||||
assert timer.current_ms(clock.now) == 4_000
|
||||
|
||||
|
||||
def test_director_inject_rejected_while_paused(clock, monkeypatch):
|
||||
"""Регрессия: обрыв связи на паузе запускал норматив обратного дозвона,
|
||||
и весь простой уходил в него; курсант получал `call.ended` под баннером."""
|
||||
state = _call_state()
|
||||
monkeypatch.setattr(hub, "store", MemorySessionStore())
|
||||
monkeypatch.setitem(hub._sessions, state.session_id, state)
|
||||
state.on_event("call.incoming")
|
||||
state.on_event("call.answer")
|
||||
state.pause()
|
||||
|
||||
async def run():
|
||||
with hub.observer(state.session_id) as observer, hub.trainee(state.session_id) as trainee:
|
||||
async with hub.operation(state.session_id):
|
||||
await control_ws._command(
|
||||
state.session_id, DirectorInject(directive="line_dropped"), TEACHER,
|
||||
)
|
||||
return _drain(observer), _drain(trainee)
|
||||
|
||||
observed, to_trainee = asyncio.run(run())
|
||||
clock.advance(120)
|
||||
|
||||
assert any(isinstance(event, ErrorEvent) for event in observed)
|
||||
assert to_trainee == []
|
||||
assert state.directives == []
|
||||
assert TimerCode.CALLBACK not in state.timers.timers
|
||||
assert state.timers.timers[TimerCode.INTERVIEW].paused is True
|
||||
|
||||
|
||||
@pytest.mark.parametrize("event", [
|
||||
KioPatchIn(fields={"address": "ул. Ленина, 1"}),
|
||||
HintRequest(),
|
||||
DdsDispatch(service=DDSCode.FIRE),
|
||||
CallbackDial(),
|
||||
], ids=lambda event: event.type)
|
||||
def test_call_channel_rejects_trainee_actions_while_paused(clock, monkeypatch, event):
|
||||
"""Истина — сервер: кнопки на фронте выключены, но команда с паузы
|
||||
не должна ни менять карточку, ни останавливать норматив опроса."""
|
||||
state = _call_state()
|
||||
monkeypatch.setattr(hub, "store", MemorySessionStore())
|
||||
monkeypatch.setitem(hub._sessions, state.session_id, state)
|
||||
state.on_event("call.incoming")
|
||||
state.on_event("call.answer")
|
||||
clock.advance(5)
|
||||
state.pause()
|
||||
kio_before = state.kio.model_dump()
|
||||
|
||||
async def run():
|
||||
with hub.trainee(state.session_id) as queue:
|
||||
async with hub.operation(state.session_id):
|
||||
await call_ws._handle(state.session_id, state, event)
|
||||
return _drain(queue)
|
||||
|
||||
events = asyncio.run(run())
|
||||
|
||||
assert [type(item) for item in events] == [ErrorEvent]
|
||||
assert state.kio.model_dump() == kio_before
|
||||
interview = state.timers.timers[TimerCode.INTERVIEW]
|
||||
assert interview.paused and not interview.stopped
|
||||
assert TimerCode.DDS_ACK not in state.timers.timers
|
||||
assert TimerCode.CALLBACK not in state.timers.timers
|
||||
assert state.hints_shown == []
|
||||
|
||||
|
||||
def test_station_channel_rejects_card_ack_while_paused(clock, monkeypatch):
|
||||
state, card = _dds_state_with_active_card(clock)
|
||||
monkeypatch.setattr(hub, "store", MemorySessionStore())
|
||||
monkeypatch.setitem(hub._sessions, state.session_id, state)
|
||||
clock.advance(5)
|
||||
state.pause()
|
||||
card_before = card.model_dump()
|
||||
|
||||
async def run():
|
||||
with hub.station(state.session_id) as queue:
|
||||
async with hub.operation(state.session_id):
|
||||
await station_ws._handle(state.session_id, state, CardAck(comment="Принято"))
|
||||
return _drain(queue)
|
||||
|
||||
events = asyncio.run(run())
|
||||
|
||||
assert [type(item) for item in events] == [ErrorEvent]
|
||||
assert card.model_dump() == card_before
|
||||
ack = card.timers.timers[TimerCode.DDS_ACK]
|
||||
assert ack.paused and not ack.stopped
|
||||
assert ack.current_ms(clock.now) == 5_000
|
||||
|
||||
|
||||
def test_repeated_pause_and_resume_are_idempotent(clock, monkeypatch):
|
||||
state, card = _dds_state_with_active_card(clock)
|
||||
store = MemorySessionStore()
|
||||
monkeypatch.setattr(hub, "store", store)
|
||||
monkeypatch.setitem(hub._sessions, state.session_id, state)
|
||||
base = now_utc()
|
||||
monkeypatch.setattr("app.session.state.now_utc", lambda: base)
|
||||
|
||||
async def command(event):
|
||||
async with hub.operation(state.session_id):
|
||||
await control_ws._command(state.session_id, event, TEACHER)
|
||||
|
||||
async def run():
|
||||
with hub.station(state.session_id) as queue:
|
||||
clock.advance(5)
|
||||
await command(SessionPause())
|
||||
clock.advance(30)
|
||||
await command(SessionPause()) # второе нажатие не сдвигает начало паузы
|
||||
monkeypatch.setattr("app.session.state.now_utc", lambda: base + timedelta(seconds=60))
|
||||
await command(SessionResume())
|
||||
await command(SessionResume())
|
||||
return _drain(queue)
|
||||
|
||||
events = asyncio.run(run())
|
||||
clock.advance(10)
|
||||
|
||||
records = [record for _sid, batch in store.commits for record in batch]
|
||||
assert sum(isinstance(record, LessonPaused) for record in records) == 1
|
||||
assert sum(isinstance(record, LessonResumed) for record in records) == 1
|
||||
assert [event.paused for event in events if isinstance(event, SessionPaused)] == [True, False]
|
||||
assert state.total_paused_ms == 60_000
|
||||
assert state.pause_intervals == [(base, base + timedelta(seconds=60))]
|
||||
assert card.timers.timers[TimerCode.DDS_ACK].current_ms(clock.now) == 15_000
|
||||
|
||||
|
||||
def test_report_reaction_excludes_pause_after_control_and_checkpoint(clock, monkeypatch):
|
||||
base = datetime(2026, 9, 27, 10, tzinfo=UTC)
|
||||
state, card = _dds_state_with_active_card(clock)
|
||||
state.criteria.dds_report_reaction_enabled = True
|
||||
service = state.card_services(card)[0]
|
||||
monkeypatch.setattr("app.session.dds.now_utc", lambda: base)
|
||||
card.set_service_status(service, ServiceStatus.ACCEPTED, "Карточка принята")
|
||||
card.phone_reports.append(PhoneReportRecord(
|
||||
service=service, crew="Бригада 12", phase="dispatched",
|
||||
text="Бригада выехала", at=base,
|
||||
))
|
||||
store = MemorySessionStore()
|
||||
monkeypatch.setattr(hub, "store", store)
|
||||
monkeypatch.setitem(hub._sessions, state.session_id, state)
|
||||
|
||||
async def command(event):
|
||||
async with hub.operation(state.session_id):
|
||||
await control_ws._command(state.session_id, event, TEACHER)
|
||||
|
||||
monkeypatch.setattr("app.session.state.now_utc", lambda: base + timedelta(seconds=10))
|
||||
asyncio.run(command(SessionPause()))
|
||||
restored = load_state(dump_state(state), base + timedelta(seconds=70))
|
||||
assert restored.paused and restored.pause_intervals == []
|
||||
monkeypatch.setitem(hub._sessions, state.session_id, restored)
|
||||
monkeypatch.setattr("app.session.state.now_utc", lambda: base + timedelta(seconds=70))
|
||||
asyncio.run(command(SessionResume()))
|
||||
monkeypatch.setattr("app.session.dds.now_utc", lambda: base + timedelta(seconds=80))
|
||||
restored.desk.active.set_service_status(
|
||||
service, ServiceStatus.RESPONDING, "Основание: доклад. Сведения: бригада выехала.",
|
||||
)
|
||||
|
||||
result = score_dds_card(restored, restored.desk.active)
|
||||
reaction = next(item for item in result.metrics if item.key == "dds_report_reaction")
|
||||
assert restored.pause_intervals == [
|
||||
(base + timedelta(seconds=10), base + timedelta(seconds=70)),
|
||||
]
|
||||
assert reaction.passed and "20 с" in reaction.fact
|
||||
assert not any(item.metric_key == "dds_report_reaction" for item in result.findings)
|
||||
|
||||
|
||||
def test_pause_is_ignored_in_ended_session(clock, monkeypatch):
|
||||
state, _card = _dds_state_with_active_card(clock)
|
||||
store = MemorySessionStore()
|
||||
monkeypatch.setattr(hub, "store", store)
|
||||
monkeypatch.setitem(hub._sessions, state.session_id, state)
|
||||
state.end(CallEndReason.INSTRUCTOR)
|
||||
|
||||
async def run():
|
||||
with hub.station(state.session_id) as queue:
|
||||
async with hub.operation(state.session_id):
|
||||
await control_ws._command(state.session_id, SessionPause(), TEACHER)
|
||||
return _drain(queue)
|
||||
|
||||
events = asyncio.run(run())
|
||||
|
||||
assert state.paused is False
|
||||
assert state.paused_at is None
|
||||
assert events == []
|
||||
assert not any(isinstance(record, LessonPaused) for _sid, batch in store.commits for record in batch)
|
||||
|
|
@ -123,7 +123,7 @@ def test_dds_cannot_return_card_for_112_quality_control(client):
|
|||
station.send_json({"type": "card.bounce", "missing_fields": ["floor", "victims_count"],
|
||||
"comment": "куда ехать без этажа"})
|
||||
error = read_until(station, "error")
|
||||
assert "служба контроля 112" in error["message"]
|
||||
assert "сообщите о ней в 112 по телефону" in error["message"]
|
||||
assert state.bounced_fields == []
|
||||
trainee.send_json({"type": "call.hangup"})
|
||||
wait_for(lambda: state.score is not None)
|
||||
|
|
|
|||
|
|
@ -62,19 +62,21 @@ def test_card_fill_time_metric_texts(elapsed_ms, completed, fact, passed):
|
|||
(605_000, True, "605 с (+425 с сверх норматива)", False),
|
||||
(25_000, True, "25 с (на 155 с быстрее норматива)", True),
|
||||
(180_000, True, "180 с (точно в норматив)", True),
|
||||
(None, False, "карточка не завершена; время обработки не зафиксировано", False),
|
||||
(25_000, False, "карточка не завершена; 25 с (на 155 с быстрее норматива)", False),
|
||||
(None, False, "первая запись не добавлена; карточка не открыта", False),
|
||||
(25_000, False, "первая запись не добавлена; 25 с (на 155 с быстрее норматива)", False),
|
||||
],
|
||||
)
|
||||
def test_dds_work_time_metric_texts(elapsed_ms, completed, fact, passed):
|
||||
metric, finding = time_metric(DDS_WORK_TIME, elapsed_ms, 180_000, completed=completed)
|
||||
|
||||
assert metric.key == "dds_work_time"
|
||||
assert metric.title == "Отработка карточки ДДС"
|
||||
assert metric.title == "Первая запись по карточке ДДС"
|
||||
assert metric.fact == fact
|
||||
assert metric.norm == "завершить за 180 с"
|
||||
assert metric.norm == "открыть карточку и добавить первую запись (статус и текст) за 180 с"
|
||||
assert metric.passed is passed
|
||||
assert (finding is None) is passed
|
||||
if finding is not None:
|
||||
assert finding.summary == "ДДС: норматив времени отработки карточки не выполнен"
|
||||
assert (finding.fact, finding.norm) == (fact, "завершить за 180 с")
|
||||
assert finding.summary == "ДДС: первая запись по карточке позже норматива"
|
||||
assert (finding.fact, finding.norm) == (
|
||||
fact, "открыть карточку и добавить первую запись (статус и текст) за 180 с"
|
||||
)
|
||||
|
|
|
|||
Loading…
Reference in a new issue