Merge remote-tracking branch 'origin/main' into feat/decline-scenarios

# Conflicts:
#	docs/spec/GAP.md
This commit is contained in:
gglamer 2026-09-27 20:56:41 +00:00
commit b7445a60fe
66 changed files with 4250 additions and 333 deletions

View file

@ -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,

View file

@ -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

View file

@ -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(),

View file

@ -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,

View file

@ -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():

View file

@ -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))

View file

@ -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

View file

@ -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

View file

@ -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

View 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

View file

@ -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):

View file

@ -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"),),

View file

@ -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)

View 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)

View file

@ -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)

View file

@ -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 с"
)