lct-hack/backend/tests/test_scenarios.py
Ivan Gerasimov 6b2c96bab6 lct-03 и lct-04: журнал сессий и библиотека сценариев
БД: 11 таблиц, первая миграция. Группы и связь trainee → group заложены
сразу, даже пустыми — размечать накопленные сессии задним числом значит
делать лишнюю миграцию. Номер попытки живёт в сессии, отдельной таблицы
попыток нет: дельта считается запросом по (trainee_id, scenario_id).

Сценарии: строгая схема — опечатка в имени поля падает на старте, а не
игнорируется молча. ground_truth собирается кодом, попытка задать
incident_type, dds или required_facts в YAML отвергается: иначе генератор
разведёт факты и эталон и курсанта оштрафуют за правильный ответ. Руками
задаются только нормализованные адрес и число пострадавших — из фразы
«улица Ленина, 14, квартира 47, 5-й этаж» кодом «улица Ленина, 14»
не достать.

GET /api/scenarios/{id} больше не отдаёт чек-лист. Это содержимое
подсказок: отдать его целиком значит выдать в контрольном режиме то,
чего там быть не должно, в обход выдачи по одному пункту.

Тесты базы поднимают свой движок на каждый тест: глобальный кэшируется
и привязывается к первому событийному циклу.
2026-09-15 20:10:05 +03:00

102 lines
3.8 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""Загрузчик библиотеки. Сломанный сценарий обязан падать на старте
с сообщением, понятным методисту, а не трассировкой pydantic.
"""
from pathlib import Path
import pytest
from app.scenarios.loader import ScenarioError, load_file, load_library
LIBRARY = Path(__file__).resolve().parents[2] / "scenarios"
VALID = """
id: t-1
title: "Проверочный"
type: fire
level: L1
persona: { base: calm }
first_line: "Алло"
facts:
- { id: f_addr, value: "Ленина, 1", reveal_on: { question: q_addr } }
checklist:
- { id: q_addr, question: "Адрес?", fact: f_addr }
ground_truth:
address: "Ленина, 1"
"""
def write(tmp_path: Path, body: str, name: str = "t-1.yaml") -> Path:
path = tmp_path / name
path.write_text(body, encoding="utf-8")
return path
def test_library_loads():
scenarios = load_library(LIBRARY)
assert scenarios, "библиотека пуста"
assert all(s.ground_truth.dds for s in scenarios), "ДДС не выведен"
def test_extends_inherits_whole_checklist():
"""Общий чек-лист по классификатору наследуется целиком,
локальные пункты дополняют его."""
scenario = load_file(LIBRARY / "fire-apartment-l2.yaml", LIBRARY)
ids = [item.id for item in scenario.checklist]
assert "q_caller" in ids, "пункт из общего чек-листа потерялся"
assert all(item.question for item in scenario.checklist), "пункт без текста вопроса"
def test_ground_truth_is_derived_not_written():
scenario = load_file(LIBRARY / "fire-apartment-l2.yaml", LIBRARY)
assert scenario.ground_truth.incident_type.value == "fire"
assert scenario.ground_truth.dds.value == "01"
assert scenario.ground_truth.required_facts, "обязательные факты не собраны"
def test_derived_fields_in_yaml_are_rejected(tmp_path):
"""Иначе генератор сценариев разведёт факты и эталон, и курсанта
оштрафуют за правильный ответ."""
path = write(tmp_path, VALID + ' dds: "03"\n')
with pytest.raises(ScenarioError, match="выводится кодом"):
load_file(path, tmp_path)
def test_broken_yaml_names_the_file(tmp_path):
path = write(tmp_path, "id: [не закрыт\n")
with pytest.raises(ScenarioError, match="битый YAML"):
load_file(path, tmp_path)
def test_hidden_fact_without_approach_is_rejected(tmp_path):
body = VALID.replace(
' - { id: f_addr, value: "Ленина, 1", reveal_on: { question: q_addr } }',
' - { id: f_addr, value: "Ленина, 1", hidden: true, reveal_on: { question: q_addr } }',
)
with pytest.raises(ScenarioError, match="hidden требует"):
load_file(write(tmp_path, body), tmp_path)
def test_checklist_pointing_at_missing_fact_is_rejected(tmp_path):
body = VALID.replace("fact: f_addr }", "fact: f_нет }")
with pytest.raises(ScenarioError, match="которого нет"):
load_file(write(tmp_path, body), tmp_path)
def test_typo_in_field_name_is_rejected(tmp_path):
"""Схема строгая: опечатка должна падать на старте, а не игнорироваться."""
body = VALID.replace("level: L1", "level: L1\nfirst_lines: 'опечатка'")
with pytest.raises(ScenarioError):
load_file(write(tmp_path, body), tmp_path)
def test_era_block_requires_era_type(tmp_path):
body = VALID + """
era_glonass:
vin: "X"
coords: { lat: 1, lon: 2 }
passengers: 1
impact_force: "сильный"
"""
with pytest.raises(ScenarioError, match="era_glonass"):
load_file(write(tmp_path, body), tmp_path)