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
|
|
|
|
"""Загрузчик библиотеки. Сломанный сценарий обязан падать на старте
|
|
|
|
|
|
с сообщением, понятным методисту, а не трассировкой pydantic.
|
|
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
|
|
from pathlib import Path
|
|
|
|
|
|
|
|
|
|
|
|
import pytest
|
|
|
|
|
|
|
2026-09-26 17:12:29 +00:00
|
|
|
|
from app.dialog.persona import BASE_MOOD
|
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
|
|
|
|
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), "ДДС не выведен"
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-09-26 17:12:29 +00:00
|
|
|
|
def test_library_personas_have_base_mood():
|
|
|
|
|
|
# base — свободная строка: неизвестное значение молча становится «спокойным»
|
|
|
|
|
|
# звонящим, и сценарий теряет задуманную подачу.
|
|
|
|
|
|
unknown = {s.id: s.persona.base for s in load_library(LIBRARY) if s.persona.base not in BASE_MOOD}
|
|
|
|
|
|
assert not unknown, f"персона без настроения в BASE_MOOD: {unknown}"
|
|
|
|
|
|
|
|
|
|
|
|
|
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
|
|
|
|
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)
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-09-26 18:12:27 +03:00
|
|
|
|
@pytest.mark.parametrize("replace_text", [
|
|
|
|
|
|
(
|
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
|
|
|
|
' - { id: f_addr, value: "Ленина, 1", reveal_on: { question: q_addr } }',
|
|
|
|
|
|
' - { id: f_addr, value: "Ленина, 1", hidden: true, reveal_on: { question: q_addr } }',
|
2026-09-26 18:12:27 +03:00
|
|
|
|
),
|
|
|
|
|
|
(
|
|
|
|
|
|
' - { id: f_addr, value: "Ленина, 1", reveal_on: { question: q_addr } }',
|
|
|
|
|
|
' - { id: f_addr, value: "Ленина, 1", reveal_on: { question: q_addr, approach: "проявил эмпатию" } }',
|
|
|
|
|
|
),
|
|
|
|
|
|
])
|
|
|
|
|
|
def test_llm_controlled_fact_disclosure_is_rejected(tmp_path, replace_text):
|
|
|
|
|
|
body = VALID.replace(*replace_text)
|
|
|
|
|
|
with pytest.raises(ScenarioError, match="Extra inputs are not permitted"):
|
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
|
|
|
|
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)
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-09-26 18:12:27 +03:00
|
|
|
|
def test_duplicate_fact_ids_are_rejected_before_they_can_change_ground_truth(tmp_path):
|
|
|
|
|
|
fact = ' - { id: f_addr, value: "Ленина, 1", reveal_on: { question: q_addr } }'
|
|
|
|
|
|
body = VALID.replace(fact, fact + '\n - { id: f_addr, value: "Ленина, 2", reveal_on: { question: q_addr } }')
|
|
|
|
|
|
with pytest.raises(ScenarioError, match="id фактов должны быть уникальны"):
|
|
|
|
|
|
load_file(write(tmp_path, body), tmp_path)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_duplicate_local_checklist_ids_are_not_silently_merged(tmp_path):
|
|
|
|
|
|
item = ' - { id: q_addr, question: "Адрес?", fact: f_addr }'
|
|
|
|
|
|
body = VALID.replace("checklist:\n" + item, "checklist:\n" + item + "\n" + item)
|
|
|
|
|
|
with pytest.raises(ScenarioError, match="повторяются id пунктов чек-листа"):
|
|
|
|
|
|
load_file(write(tmp_path, body), tmp_path)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@pytest.mark.parametrize(("fields", "message"), [
|
|
|
|
|
|
('[address, coordinates]', "отсутствуют в форме КИО"),
|
|
|
|
|
|
('[card_id]', "заполняются системой"),
|
|
|
|
|
|
('[registered_at]', "заполняются системой"),
|
|
|
|
|
|
('[address, address]', "повторяются поля"),
|
|
|
|
|
|
])
|
|
|
|
|
|
def test_required_fields_must_be_unique_and_fillable_in_kio_form(tmp_path, fields, message):
|
|
|
|
|
|
body = VALID + f"\nrequired_fields: {fields}\n"
|
|
|
|
|
|
with pytest.raises(ScenarioError, match=message):
|
|
|
|
|
|
load_file(write(tmp_path, body), tmp_path)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_non_card_outcomes_cannot_require_kio_fields(tmp_path):
|
|
|
|
|
|
body = VALID + "\noutcome: consultation\nrequired_fields: [address]\n"
|
|
|
|
|
|
with pytest.raises(ScenarioError, match="required_fields должны быть пустыми"):
|
|
|
|
|
|
load_file(write(tmp_path, body), tmp_path)
|
|
|
|
|
|
|
|
|
|
|
|
|
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
|
|
|
|
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)
|