каркас: домен, генерация типов фронта, стенд и карточки задач
Контракт в коде: КИО по нормативу, 43 события WS по шести союзам направлений,
таймеры ГОСТ, таксономия E1–E6, шесть компетенций радара. make types гоняет
их через JSON Schema в generated.ts, тест ловит забытую регенерацию.
Стенд: docker compose postgres + backend, health отвечает, база досягаема
из контейнера бэкенда. Makefile не врёт — нереализованные цели падают
и называют карточку, которая их закроет.
frontend/.npmrc: под WSL2 npm install зависает на дефолтных 15 сокетах.
2026-09-15 19:35:03 +03:00
|
|
|
|
"""Тесты контракта. Домен — общий шов, ломать его молча нельзя."""
|
|
|
|
|
|
|
2026-09-26 18:12:27 +03:00
|
|
|
|
from datetime import UTC, datetime
|
каркас: домен, генерация типов фронта, стенд и карточки задач
Контракт в коде: КИО по нормативу, 43 события WS по шести союзам направлений,
таймеры ГОСТ, таксономия E1–E6, шесть компетенций радара. make types гоняет
их через JSON Schema в generated.ts, тест ловит забытую регенерацию.
Стенд: docker compose postgres + backend, health отвечает, база досягаема
из контейнера бэкенда. Makefile не врёт — нереализованные цели падают
и называют карточку, которая их закроет.
frontend/.npmrc: под WSL2 npm install зависает на дефолтных 15 сокетах.
2026-09-15 19:35:03 +03:00
|
|
|
|
from uuid import uuid4
|
|
|
|
|
|
|
|
|
|
|
|
import pytest
|
|
|
|
|
|
from pydantic import TypeAdapter, ValidationError
|
|
|
|
|
|
|
lct-01: контракт закрыт — координаты, обязательные поля, коды ошибок
Координаты были представлены дважды: кортежем в КИО и объектом в данных
ЭРА-ГЛОНАСС. Теперь Coords {lat, lon} везде — в кортеже не видно, где
широта, и ошибка всплывает на карте у диспетчера, а не в типах.
required_fields едет в call.incoming и session.snapshot: обязательность
полей задаёт сценарий, без списка АРМ не подсветит незаполненное поле,
и курсант узнаёт о неполноте карточки только из разбора.
Канал error получил коды (ErrorKind, восемь значений) — фронт разбирает
код, а не текст сообщения. Имя не ErrorCode: оно занято таксономией E1–E6,
два одинаковых имени дали бы коллизию в generated.ts.
Добавлены поля, которые спрашивает чек-лист, а записать было некуда:
куда идёт дым, уехал ли нарушитель, код домофона.
Документы догнали код: mkh → gkh, payload transcript.append с якорем ref,
причина director у tts.cancel, attempt и stopped у таймеров.
2026-09-15 19:48:56 +03:00
|
|
|
|
from app.config import Settings
|
каркас: домен, генерация типов фронта, стенд и карточки задач
Контракт в коде: КИО по нормативу, 43 события WS по шести союзам направлений,
таймеры ГОСТ, таксономия E1–E6, шесть компетенций радара. make types гоняет
их через JSON Schema в generated.ts, тест ловит забытую регенерацию.
Стенд: docker compose postgres + backend, health отвечает, база досягаема
из контейнера бэкенда. Makefile не врёт — нереализованные цели падают
и называют карточку, которая их закроет.
frontend/.npmrc: под WSL2 npm install зависает на дефолтных 15 сокетах.
2026-09-15 19:35:03 +03:00
|
|
|
|
from app.domain.events import (
|
lct-01: контракт закрыт — координаты, обязательные поля, коды ошибок
Координаты были представлены дважды: кортежем в КИО и объектом в данных
ЭРА-ГЛОНАСС. Теперь Coords {lat, lon} везде — в кортеже не видно, где
широта, и ошибка всплывает на карте у диспетчера, а не в типах.
required_fields едет в call.incoming и session.snapshot: обязательность
полей задаёт сценарий, без списка АРМ не подсветит незаполненное поле,
и курсант узнаёт о неполноте карточки только из разбора.
Канал error получил коды (ErrorKind, восемь значений) — фронт разбирает
код, а не текст сообщения. Имя не ErrorCode: оно занято таксономией E1–E6,
два одинаковых имени дали бы коллизию в generated.ts.
Добавлены поля, которые спрашивает чек-лист, а записать было некуда:
куда идёт дым, уехал ли нарушитель, код домофона.
Документы догнали код: mkh → gkh, payload transcript.append с якорем ref,
причина director у tts.cancel, attempt и stopped у таймеров.
2026-09-15 19:48:56 +03:00
|
|
|
|
CallIncoming,
|
|
|
|
|
|
ErrorKind,
|
каркас: домен, генерация типов фронта, стенд и карточки задач
Контракт в коде: КИО по нормативу, 43 события WS по шести союзам направлений,
таймеры ГОСТ, таксономия E1–E6, шесть компетенций радара. make types гоняет
их через JSON Schema в generated.ts, тест ловит забытую регенерацию.
Стенд: docker compose postgres + backend, health отвечает, база досягаема
из контейнера бэкенда. Makefile не врёт — нереализованные цели падают
и называют карточку, которая их закроет.
frontend/.npmrc: под WSL2 npm install зависает на дефолтных 15 сокетах.
2026-09-15 19:35:03 +03:00
|
|
|
|
EventCatalog,
|
|
|
|
|
|
KioPatchIn,
|
|
|
|
|
|
ServerToTrainee,
|
|
|
|
|
|
SessionMode,
|
lct-01: контракт закрыт — координаты, обязательные поля, коды ошибок
Координаты были представлены дважды: кортежем в КИО и объектом в данных
ЭРА-ГЛОНАСС. Теперь Coords {lat, lon} везде — в кортеже не видно, где
широта, и ошибка всплывает на карте у диспетчера, а не в типах.
required_fields едет в call.incoming и session.snapshot: обязательность
полей задаёт сценарий, без списка АРМ не подсветит незаполненное поле,
и курсант узнаёт о неполноте карточки только из разбора.
Канал error получил коды (ErrorKind, восемь значений) — фронт разбирает
код, а не текст сообщения. Имя не ErrorCode: оно занято таксономией E1–E6,
два одинаковых имени дали бы коллизию в generated.ts.
Добавлены поля, которые спрашивает чек-лист, а записать было некуда:
куда идёт дым, уехал ли нарушитель, код домофона.
Документы догнали код: mkh → gkh, payload transcript.append с якорем ref,
причина director у tts.cancel, attempt и stopped у таймеров.
2026-09-15 19:48:56 +03:00
|
|
|
|
SessionSnapshot,
|
каркас: домен, генерация типов фронта, стенд и карточки задач
Контракт в коде: КИО по нормативу, 43 события WS по шести союзам направлений,
таймеры ГОСТ, таксономия E1–E6, шесть компетенций радара. make types гоняет
их через JSON Schema в generated.ts, тест ловит забытую регенерацию.
Стенд: docker compose postgres + backend, health отвечает, база досягаема
из контейнера бэкенда. Makefile не врёт — нереализованные цели падают
и называют карточку, которая их закроет.
frontend/.npmrc: под WSL2 npm install зависает на дефолтных 15 сокетах.
2026-09-15 19:35:03 +03:00
|
|
|
|
TraineeToServer,
|
|
|
|
|
|
)
|
lct-01: контракт закрыт — координаты, обязательные поля, коды ошибок
Координаты были представлены дважды: кортежем в КИО и объектом в данных
ЭРА-ГЛОНАСС. Теперь Coords {lat, lon} везде — в кортеже не видно, где
широта, и ошибка всплывает на карте у диспетчера, а не в типах.
required_fields едет в call.incoming и session.snapshot: обязательность
полей задаёт сценарий, без списка АРМ не подсветит незаполненное поле,
и курсант узнаёт о неполноте карточки только из разбора.
Канал error получил коды (ErrorKind, восемь значений) — фронт разбирает
код, а не текст сообщения. Имя не ErrorCode: оно занято таксономией E1–E6,
два одинаковых имени дали бы коллизию в generated.ts.
Добавлены поля, которые спрашивает чек-лист, а записать было некуда:
куда идёт дым, уехал ли нарушитель, код домофона.
Документы догнали код: mkh → gkh, payload transcript.append с якорем ref,
причина director у tts.cancel, attempt и stopped у таймеров.
2026-09-15 19:48:56 +03:00
|
|
|
|
from app.domain.kio import KIO, Coords, apply_patch, missing_fields
|
каркас: домен, генерация типов фронта, стенд и карточки задач
Контракт в коде: КИО по нормативу, 43 события WS по шести союзам направлений,
таймеры ГОСТ, таксономия E1–E6, шесть компетенций радара. make types гоняет
их через JSON Schema в generated.ts, тест ловит забытую регенерацию.
Стенд: docker compose postgres + backend, health отвечает, база досягаема
из контейнера бэкенда. Makefile не врёт — нереализованные цели падают
и называют карточку, которая их закроет.
frontend/.npmrc: под WSL2 npm install зависает на дефолтных 15 сокетах.
2026-09-15 19:35:03 +03:00
|
|
|
|
from app.domain.timers import NORMATIVES, TimerCode, TimerState, state_for
|
|
|
|
|
|
from scripts.export_types import OUT, render
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_interview_normative_is_75_seconds():
|
|
|
|
|
|
"""Центральная метрика продукта. Значение меняется только вместе с ГОСТ."""
|
|
|
|
|
|
assert NORMATIVES[TimerCode.INTERVIEW].limit_ms == 75_000
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-09-26 18:12:27 +03:00
|
|
|
|
def test_card_fill_normative_defaults_to_three_minutes_and_is_configurable():
|
|
|
|
|
|
from app.domain.events import LessonCriteria
|
|
|
|
|
|
|
|
|
|
|
|
assert NORMATIVES[TimerCode.CARD_FILL].limit_ms == 180_000
|
|
|
|
|
|
assert LessonCriteria().card_fill_time_limit_seconds == 180
|
|
|
|
|
|
assert LessonCriteria(card_fill_time_limit_seconds=240).card_fill_time_limit_seconds == 240
|
|
|
|
|
|
with pytest.raises(ValidationError):
|
|
|
|
|
|
LessonCriteria(card_fill_time_limit_seconds=20)
|
|
|
|
|
|
|
|
|
|
|
|
|
каркас: домен, генерация типов фронта, стенд и карточки задач
Контракт в коде: КИО по нормативу, 43 события WS по шести союзам направлений,
таймеры ГОСТ, таксономия E1–E6, шесть компетенций радара. make types гоняет
их через JSON Schema в generated.ts, тест ловит забытую регенерацию.
Стенд: docker compose postgres + backend, health отвечает, база досягаема
из контейнера бэкенда. Makefile не врёт — нереализованные цели падают
и называют карточку, которая их закроет.
frontend/.npmrc: под WSL2 npm install зависает на дефолтных 15 сокетах.
2026-09-15 19:35:03 +03:00
|
|
|
|
@pytest.mark.parametrize(
|
|
|
|
|
|
"elapsed_ms,expected",
|
|
|
|
|
|
[(0, TimerState.OK), (59_000, TimerState.OK), (70_000, TimerState.WARN), (94_000, TimerState.VIOLATED)],
|
|
|
|
|
|
)
|
|
|
|
|
|
def test_timer_state(elapsed_ms, expected):
|
|
|
|
|
|
assert state_for(elapsed_ms, 75_000) is expected
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_patch_applies_nested_and_ignores_service_fields():
|
|
|
|
|
|
card = KIO(caller_number="+7 999 000-00-00")
|
|
|
|
|
|
patched = apply_patch(card, {"floor": "5", "fire.floors": 9, "caller_number": "подмена"})
|
|
|
|
|
|
|
|
|
|
|
|
assert patched.floor == "5"
|
|
|
|
|
|
assert patched.fire is not None and patched.fire.floors == 9
|
|
|
|
|
|
# Номер определяется автоматически и курсантом не редактируется.
|
|
|
|
|
|
assert patched.caller_number == "+7 999 000-00-00"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_missing_fields_reports_required_only():
|
|
|
|
|
|
card = KIO(address="улица Ленина, 14")
|
|
|
|
|
|
assert missing_fields(card, ["address", "floor", "victims_count"]) == ["floor", "victims_count"]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_trainee_events_are_discriminated_by_type():
|
|
|
|
|
|
adapter = TypeAdapter(TraineeToServer)
|
|
|
|
|
|
event = adapter.validate_python({"type": "kio.patch", "fields": {"floor": "5"}})
|
|
|
|
|
|
assert isinstance(event, KioPatchIn)
|
|
|
|
|
|
|
|
|
|
|
|
with pytest.raises(ValidationError):
|
|
|
|
|
|
adapter.validate_python({"type": "kio.state", "kio": {}})
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_server_events_round_trip_through_json():
|
|
|
|
|
|
adapter = TypeAdapter(ServerToTrainee)
|
|
|
|
|
|
payload = {
|
|
|
|
|
|
"type": "caller.utterance",
|
|
|
|
|
|
"utterance_id": str(uuid4()),
|
|
|
|
|
|
"text": "Алло! Помогите! Горим!",
|
2026-09-26 18:12:27 +03:00
|
|
|
|
"at": datetime.now(UTC).isoformat(),
|
каркас: домен, генерация типов фронта, стенд и карточки задач
Контракт в коде: КИО по нормативу, 43 события WS по шести союзам направлений,
таймеры ГОСТ, таксономия E1–E6, шесть компетенций радара. make types гоняет
их через JSON Schema в generated.ts, тест ловит забытую регенерацию.
Стенд: docker compose postgres + backend, health отвечает, база досягаема
из контейнера бэкенда. Makefile не врёт — нереализованные цели падают
и называют карточку, которая их закроет.
frontend/.npmrc: под WSL2 npm install зависает на дефолтных 15 сокетах.
2026-09-15 19:35:03 +03:00
|
|
|
|
"mood": "panic",
|
|
|
|
|
|
}
|
|
|
|
|
|
assert adapter.validate_python(payload).text.startswith("Алло")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_session_modes_are_the_three_from_docs():
|
|
|
|
|
|
assert {mode.value for mode in SessionMode} == {"training", "exam", "self"}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_event_catalog_covers_every_channel():
|
feat: вход, роли и аудит действий (lct-23)
Самое крупное расхождение с ТЗ: входа не было вовсе, экраны открывались
ссылкой с номером занятия, и пускало знание адреса.
- Таблицы users и audit_log, миграция. Пароль argon2, сессия — подписанная
cookie; роль на сокетах читается из той же cookie в момент рукопожатия,
отдельного протокола авторизации в канале нет.
- Разграничение: control — преподавателю, observe — преподавателю и админу,
call и station — обучающемуся и преподавателю. Отказ приходит событием
error с кодом forbidden.
- Обучающийся не видит чужого: история подменяет фильтр на его собственный
идентификатор, разбор и профиль сверяют trainee_id. ТЗ запрещает доступ
к чужим результатам, а не только к чужим экранам.
- Администратору закрыта правка оценок — ТЗ запрещает это прямо.
- make users заводит по записи на роль и печатает случайные пароли один раз:
зашитый в репозиторий admin/admin пережил бы сдачу.
- Экран входа и проверка роли на каждом маршруте фронта.
Наши инструменты не сломались: make lesson и тесты входят через dev-token
за флагом dev_auth_bypass, на стенде точка отвечает 404 — выключенной
функции не должно быть видно вовсе. У тестов появился conftest.py.
Role уехала в домен и в generated.ts через EventCatalog.principal: иначе
фронт переписывал бы список ролей руками.
181 тест зелёный (14 новых), make typecheck чистый.
2026-09-20 09:01:05 +03:00
|
|
|
|
"""`principal` каналом не является: он в каталоге ради того, чтобы список
|
|
|
|
|
|
ролей приезжал на фронт из домена, а не переписывался руками (lct-23)."""
|
каркас: домен, генерация типов фронта, стенд и карточки задач
Контракт в коде: КИО по нормативу, 43 события WS по шести союзам направлений,
таймеры ГОСТ, таксономия E1–E6, шесть компетенций радара. make types гоняет
их через JSON Schema в generated.ts, тест ловит забытую регенерацию.
Стенд: docker compose postgres + backend, health отвечает, база досягаема
из контейнера бэкенда. Makefile не врёт — нереализованные цели падают
и называют карточку, которая их закроет.
frontend/.npmrc: под WSL2 npm install зависает на дефолтных 15 сокетах.
2026-09-15 19:35:03 +03:00
|
|
|
|
assert set(EventCatalog.model_fields) == {
|
feat: вход, роли и аудит действий (lct-23)
Самое крупное расхождение с ТЗ: входа не было вовсе, экраны открывались
ссылкой с номером занятия, и пускало знание адреса.
- Таблицы users и audit_log, миграция. Пароль argon2, сессия — подписанная
cookie; роль на сокетах читается из той же cookie в момент рукопожатия,
отдельного протокола авторизации в канале нет.
- Разграничение: control — преподавателю, observe — преподавателю и админу,
call и station — обучающемуся и преподавателю. Отказ приходит событием
error с кодом forbidden.
- Обучающийся не видит чужого: история подменяет фильтр на его собственный
идентификатор, разбор и профиль сверяют trainee_id. ТЗ запрещает доступ
к чужим результатам, а не только к чужим экранам.
- Администратору закрыта правка оценок — ТЗ запрещает это прямо.
- make users заводит по записи на роль и печатает случайные пароли один раз:
зашитый в репозиторий admin/admin пережил бы сдачу.
- Экран входа и проверка роли на каждом маршруте фронта.
Наши инструменты не сломались: make lesson и тесты входят через dev-token
за флагом dev_auth_bypass, на стенде точка отвечает 404 — выключенной
функции не должно быть видно вовсе. У тестов появился conftest.py.
Role уехала в домен и в generated.ts через EventCatalog.principal: иначе
фронт переписывал бы список ролей руками.
181 тест зелёный (14 новых), make typecheck чистый.
2026-09-20 09:01:05 +03:00
|
|
|
|
"principal",
|
каркас: домен, генерация типов фронта, стенд и карточки задач
Контракт в коде: КИО по нормативу, 43 события WS по шести союзам направлений,
таймеры ГОСТ, таксономия E1–E6, шесть компетенций радара. make types гоняет
их через JSON Schema в generated.ts, тест ловит забытую регенерацию.
Стенд: docker compose postgres + backend, health отвечает, база досягаема
из контейнера бэкенда. Makefile не врёт — нереализованные цели падают
и называют карточку, которая их закроет.
frontend/.npmrc: под WSL2 npm install зависает на дефолтных 15 сокетах.
2026-09-15 19:35:03 +03:00
|
|
|
|
"server_to_trainee",
|
|
|
|
|
|
"trainee_to_server",
|
|
|
|
|
|
"server_to_observer",
|
|
|
|
|
|
"instructor_to_server",
|
|
|
|
|
|
"server_to_station",
|
|
|
|
|
|
"station_to_server",
|
|
|
|
|
|
"session_report",
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
lct-01: контракт закрыт — координаты, обязательные поля, коды ошибок
Координаты были представлены дважды: кортежем в КИО и объектом в данных
ЭРА-ГЛОНАСС. Теперь Coords {lat, lon} везде — в кортеже не видно, где
широта, и ошибка всплывает на карте у диспетчера, а не в типах.
required_fields едет в call.incoming и session.snapshot: обязательность
полей задаёт сценарий, без списка АРМ не подсветит незаполненное поле,
и курсант узнаёт о неполноте карточки только из разбора.
Канал error получил коды (ErrorKind, восемь значений) — фронт разбирает
код, а не текст сообщения. Имя не ErrorCode: оно занято таксономией E1–E6,
два одинаковых имени дали бы коллизию в generated.ts.
Добавлены поля, которые спрашивает чек-лист, а записать было некуда:
куда идёт дым, уехал ли нарушитель, код домофона.
Документы догнали код: mkh → gkh, payload transcript.append с якорем ref,
причина director у tts.cancel, attempt и stopped у таймеров.
2026-09-15 19:48:56 +03:00
|
|
|
|
def test_coords_are_named_everywhere():
|
|
|
|
|
|
"""Одно представление координат на весь контракт: в кортеже не видно,
|
|
|
|
|
|
где широта, и ошибка всплывает на карте, а не в типах."""
|
|
|
|
|
|
card = apply_patch(KIO(), {"coords.lat": 55.751244, "coords.lon": 37.618423})
|
|
|
|
|
|
assert card.coords == Coords(lat=55.751244, lon=37.618423)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_required_fields_reach_the_screen():
|
|
|
|
|
|
"""Без этого АРМ не подсветит незаполненное обязательное поле."""
|
|
|
|
|
|
incoming = CallIncoming(
|
|
|
|
|
|
scenario_id="fire-apartment-l2",
|
|
|
|
|
|
caller_number="+7 999 000-00-00",
|
|
|
|
|
|
level="L2",
|
|
|
|
|
|
mode=SessionMode.TRAINING,
|
|
|
|
|
|
required_fields=["address", "floor"],
|
|
|
|
|
|
)
|
|
|
|
|
|
assert incoming.required_fields == ["address", "floor"]
|
|
|
|
|
|
# Наблюдателю они нужны так же: монитор рисует ту же карточку
|
|
|
|
|
|
assert "required_fields" in SessionSnapshot.model_fields
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_error_channel_has_codes_not_prose():
|
|
|
|
|
|
"""Фронт разбирает код, а не текст: текст — для человека."""
|
|
|
|
|
|
adapter = TypeAdapter(ServerToTrainee)
|
|
|
|
|
|
event = adapter.validate_python(
|
|
|
|
|
|
{"type": "error", "code": "hint_denied_in_exam", "message": "В контрольном режиме подсказок нет"}
|
|
|
|
|
|
)
|
|
|
|
|
|
assert event.code is ErrorKind.HINT_DENIED_IN_EXAM
|
|
|
|
|
|
|
|
|
|
|
|
with pytest.raises(ValidationError):
|
|
|
|
|
|
adapter.validate_python({"type": "error", "code": "что-то пошло не так", "message": ""})
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_normative_comes_from_config_not_code():
|
|
|
|
|
|
"""Правка норматива не должна требовать правки кода."""
|
|
|
|
|
|
default = Settings()
|
|
|
|
|
|
assert default.limit_ms(TimerCode.INTERVIEW) == 75_000
|
|
|
|
|
|
|
|
|
|
|
|
overridden = Settings(timer_limits_ms={TimerCode.INTERVIEW: 90_000})
|
|
|
|
|
|
assert overridden.limit_ms(TimerCode.INTERVIEW) == 90_000
|
|
|
|
|
|
assert overridden.limit_ms(TimerCode.ANSWER) == 8_000
|
|
|
|
|
|
|
|
|
|
|
|
|
каркас: домен, генерация типов фронта, стенд и карточки задач
Контракт в коде: КИО по нормативу, 43 события WS по шести союзам направлений,
таймеры ГОСТ, таксономия E1–E6, шесть компетенций радара. make types гоняет
их через JSON Schema в generated.ts, тест ловит забытую регенерацию.
Стенд: docker compose postgres + backend, health отвечает, база досягаема
из контейнера бэкенда. Makefile не врёт — нереализованные цели падают
и называют карточку, которая их закроет.
frontend/.npmrc: под WSL2 npm install зависает на дефолтных 15 сокетах.
2026-09-15 19:35:03 +03:00
|
|
|
|
def test_generated_types_match_models():
|
|
|
|
|
|
"""Забытый `make types` ловится здесь, а не на фронте в последнюю ночь."""
|
|
|
|
|
|
assert OUT.exists(), "нет frontend/src/shared/types/generated.ts — запусти make types"
|
|
|
|
|
|
assert OUT.read_text(encoding="utf-8") == render(), "generated.ts устарел — запусти make types"
|