Files
clickstream-ch-kafka-supers…/generator/tests/test_world_dags_contract.py
T
ddadminandClaude Fable 5 271f1afdb3 test(generator): вычищены прозаические тесты из контракта DAG
Тесты на дословные фразы документов и комментариев удалены: прозу
сторожит ревью, а не pytest. Оставлены структурные инварианты (порядок
задач, монтирования, версии, живые перекрёстные ссылки). В шапке файла —
критерий «инвариант должен переживать честную переписку текста».

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-23 18:41:09 +03:00

364 lines
16 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.
"""Контракты DAG world_init и world_next_day без запуска Airflow.
Что здесь контракт. Проверяем только структурные и поведенческие инварианты
стенда: идентификаторы DAG и задач, порядок задач, пути монтирования, синхронность
версий, живые перекрёстные ссылки между документами и реальные идентификаторы
(имена дашбордов, профилей, make-целей). Критерий: инвариант должен переживать
честную переписку текста — краснеть, только когда ломается само поведение или
структура, а не формулировка.
Чего сюда не добавлять: дословные цитаты прозы из документов и комментариев
(«в README есть слово X», «в комментарии написано Y»). Прозу сторожит ревью, а не
pytest — такой тест зелёный до первой честной переписки и краснеет без дефекта.
"""
import ast
from pathlib import Path
from clickstream_generator.launch import PROFILES
DAG_PATH = Path(__file__).parents[2] / "airflow" / "dags" / "world_init_dag.py"
NEXT_DAY_DAG_PATH = Path(__file__).parents[2] / "airflow" / "dags" / "world_next_day_dag.py"
REPO_ROOT = Path(__file__).parents[2]
def _tree():
return ast.parse(DAG_PATH.read_text(encoding="utf-8"))
def _declared_param_types() -> dict[str, set[str]]:
param_types = {}
for node in ast.walk(_tree()):
if not isinstance(node, ast.Dict):
continue
for key, value in zip(node.keys, node.values, strict=True):
if not (
isinstance(key, ast.Constant)
and isinstance(key.value, str)
and isinstance(value, ast.Call)
and isinstance(value.func, ast.Name)
and value.func.id == "Param"
):
continue
type_keyword = next(
keyword.value for keyword in value.keywords if keyword.arg == "type"
)
if isinstance(type_keyword, ast.List):
param_types[key.value] = {
item.value for item in type_keyword.elts if isinstance(item, ast.Constant)
}
else:
param_types[key.value] = {type_keyword.value}
return param_types
def _declared_param_defaults() -> dict[str, object]:
param_defaults = {}
for node in ast.walk(_tree()):
if not isinstance(node, ast.Dict):
continue
for key, value in zip(node.keys, node.values, strict=True):
if not (
isinstance(key, ast.Constant)
and isinstance(key.value, str)
and isinstance(value, ast.Call)
and isinstance(value.func, ast.Name)
and value.func.id == "Param"
and value.args
):
continue
param_defaults[key.value] = ast.literal_eval(value.args[0])
return param_defaults
def test_dag_file_exists_and_uses_dynamic_profiles():
"""DAG берёт варианты профилей из PROFILES, а не из ручного списка."""
text = DAG_PATH.read_text(encoding="utf-8")
assert "dag_id=\"world_init\"" in text
assert "sorted(PROFILES)" in text
for profile in PROFILES:
assert profile not in {"hardcoded-profile"}
def test_trigger_form_has_expected_param_enums():
"""Форма запуска ограничивает операции и профили."""
text = DAG_PATH.read_text(encoding="utf-8")
param_defaults = _declared_param_defaults()
assert 'enum=["backfill", "import", "check"]' in text
assert param_defaults["operation"] == "import"
assert "enum=sorted(PROFILES)" in text
assert param_defaults["profile"] == "daily-wave"
assert '"duration": Param(' in text
assert '"artifact_path": Param(' in text
assert '"expected_t_end": Param(' not in text
def test_trigger_form_marks_only_optional_params_as_nullable():
"""Форма разрешает оставить необязательные поля пустыми."""
param_types = _declared_param_types()
for name in {
"duration",
"seed",
"model_time_speed",
"artifact_path",
}:
assert "null" in param_types[name]
for name in {"operation", "profile"}:
assert "null" not in param_types[name]
def test_dag_branches_and_waits_for_etl_completion():
"""Backfill/import запускают ETL и ждут завершения перед check."""
text = DAG_PATH.read_text(encoding="utf-8")
assert "BranchPythonOperator" in text
assert "TriggerDagRunOperator" in text
assert "trigger_dag_id=ETL_DAG_ID" in text
assert "wait_for_completion=True" in text
assert 'allowed_states=["success"]' in text
assert 'failed_states=["failed"]' in text
def test_world_next_day_is_parameterless_paused_half_hour_dag():
"""DAG следующего дня запускается пустой формой каждые полчаса."""
text = NEXT_DAY_DAG_PATH.read_text(encoding="utf-8")
assert 'dag_id="world_next_day"' in text
assert 'schedule="*/30 * * * *"' in text
assert "catchup=False" in text
assert "is_paused_upon_creation=True" in text
assert "max_active_runs=1" in text
assert "params=" not in text
assert "precheck_next_day" in text
assert "run_next_day_task" in text
assert "TriggerDagRunOperator" in text
assert "wait_for_completion=True" in text
assert "check_manifest_task" in text
assert "check_etl_not_paused >> precheck" in text
def test_world_dags_precheck_etl_dag_not_paused_before_waiting():
"""Пульт проверяет паузу etl_pipeline до долгого ожидания."""
text = DAG_PATH.read_text(encoding="utf-8")
shared_tasks = (
REPO_ROOT / "airflow" / "dags" / "utils" / "startup_history_tasks.py"
).read_text(encoding="utf-8")
assert "assert_target_dag_not_paused" in text
assert 'ETL_DAG_ID = "etl_pipeline"' in text
assert 'op_kwargs={"dag_id": ETL_DAG_ID}' in text
assert "session.query(DagModel)" in shared_tasks
assert "DagModel.dag_id == dag_id" in shared_tasks
assert "target_dag_trigger_error" in shared_tasks
assert "fail_when_dag_is_paused" in shared_tasks
assert 'return "check_etl_not_paused_before_backfill"' in text
assert 'return "check_etl_not_paused_before_import"' in text
assert "check_etl_not_paused_before_backfill >> precheck_backfill_task" in text
assert "check_etl_not_paused_before_import >> precheck_import_task" in text
assert text.index("check_etl_not_paused_before_backfill >> precheck_backfill_task") < text.index(
"precheck_backfill_task >> backfill_task"
)
assert text.index("check_etl_not_paused_before_import >> precheck_import_task") < text.index(
"precheck_import_task >> import_task"
)
def test_make_up_rebuilds_airflow_images_after_repo_update():
"""make up пересобирает Airflow, но не запускает Superset до DM."""
text = (REPO_ROOT / "Makefile").read_text(encoding="utf-8")
up_block = text.split("\nup:\n", maxsplit=1)[1].split(
"\n\n# Остановить",
maxsplit=1,
)[0]
assert "$(COMPOSE) up -d --build" in up_block
assert "airflow-webserver" in up_block
assert "airflow-scheduler" in up_block
assert "superset" not in up_block
assert "superset-init" not in up_block
def test_readme_quick_start_creates_schema_via_ddl_init_before_world_init():
"""Быстрый старт: терминал — только make up, схему создаёт ddl_init до world_init."""
text = (REPO_ROOT / "README.md").read_text(encoding="utf-8")
# Ручного шага создания схемы в быстром старте быть не должно — её создаёт
# DAG ddl_init. Проверяем этот структурный факт и порядок ddl_init→world_init,
# а не наличие слов «uv»/«Docker» в тексте.
assert "make ddl" not in text
quick_start = text.split("## Быстрый старт", maxsplit=1)[1]
assert quick_start.index("ddl_init") < quick_start.index("world_init")
def test_course_canonical_setup_unpauses_dags_before_world_init():
"""Канонический блок называет три DAG, снятие паузы и порядок etl→world_init."""
# Прозаические формулировки не проверяем — их сторожит ревью, не pytest.
# Держим только структурные факты: якорь-заголовок существует, в блоке
# названы три DAG-а, упомянута пауза, а etl_pipeline идёт до world_init.
text = (REPO_ROOT / "docs" / "course" / "README.md").read_text(encoding="utf-8")
setup = text.split("## Подготовка и канонический сброс", maxsplit=1)[1].split(
"## Уроки",
maxsplit=1,
)[0]
assert "пауз" in setup
for dag_id in ("ddl_init", "etl_pipeline", "world_init"):
assert dag_id in setup
assert setup.index("etl_pipeline") < setup.index("world_init")
def test_next_day_lab_uses_provisioned_superset_dashboard_name():
"""Лаба следующего дня ведёт на существующий дашборд Superset."""
text = (
REPO_ROOT / "docs" / "course" / "lessons" / "07_lab_next_day.md"
).read_text(encoding="utf-8")
assert "E-commerce Analytics Dashboard" in text
assert "Clickstream Analytics" not in text
def test_monitoring_lesson_links_to_canonical_reset():
"""Урок мониторинга ведёт обратно к каноническому сбросу курса."""
# Проверяем живую перекрёстную ссылку: битый якорь — это дефект, а не
# переписанная проза. Точные фразы упражнения не пиним.
text = (
REPO_ROOT / "docs" / "course" / "lessons" / "05_monitoring.md"
).read_text(encoding="utf-8")
assert "../README.md#подготовка-и-канонический-сброс" in text
def test_startup_history_runbook_references_daily_wave_reset_and_import():
"""Runbook ссылается на реальные идентификаторы восстановления стенда."""
# Не пиним формулировку предупреждения о дыре модельного времени — её
# сторожит ревью. Держим ссылки на реальные идентификаторы: профиль,
# make-цель сброса и якорь импорта.
text = (REPO_ROOT / "docs" / "runbooks" / "startup-history.md").read_text(
encoding="utf-8"
)
assert "daily-wave" in text
assert "make generator-reset" in text
assert "startup-history-import" in text
def test_no_docker_or_continue_operation_in_dag():
"""Пульт не управляет Docker и не содержит операцию continue."""
text = DAG_PATH.read_text(encoding="utf-8")
assert "docker" not in text.lower()
assert '"continue"' not in text
def test_compose_mounts_generator_code_without_socket_and_gates_live_service():
"""Airflow видит код генератора, но не получает Docker socket."""
text = (REPO_ROOT / "docker-compose.yml").read_text(encoding="utf-8")
assert "PYTHONPATH: /opt/airflow/generator_src" in text
assert "./generator/src:/opt/airflow/generator_src:ro" in text
assert "/var/run/docker.sock" not in text
assert "profiles:\n - live-generator" in text
def test_down_and_clean_include_live_generator_profile():
"""Остановка стенда удаляет профильный live-генератор."""
text = (REPO_ROOT / "Makefile").read_text(encoding="utf-8")
assert "down:\n\t$(COMPOSE) --profile live-generator down" in text
assert "clean:\n\t$(COMPOSE) --profile live-generator down -v --remove-orphans" in text
def test_console_generator_paths_precheck_clean_stand_before_writes():
"""Backfill/import с хоста проверяют чистый стенд до записи."""
run_generator = (REPO_ROOT / "scripts" / "run_generator.sh").read_text(
encoding="utf-8"
)
import_artifact = (
REPO_ROOT / "scripts" / "import_startup_history_artifact.sh"
).read_text(encoding="utf-8")
assert "bash \"${SCRIPT_DIR}/assert_stand_clean.sh\" clean" in run_generator
assert run_generator.index("assert_stand_clean.sh") < run_generator.index(
"run --rm --no-deps"
)
assert "bash \"${SCRIPT_DIR}/assert_stand_clean.sh\" clean" in import_artifact
assert import_artifact.index("assert_stand_clean.sh") < import_artifact.index(
"startup_history_artifact import"
)
def test_reference_artifact_is_import_default_only():
"""Экспорт не может по умолчанию перезаписать эталонный мир."""
makefile = (REPO_ROOT / "Makefile").read_text(encoding="utf-8")
export_script = (
REPO_ROOT / "scripts" / "export_startup_history_artifact.sh"
).read_text(encoding="utf-8")
import_script = (
REPO_ROOT / "scripts" / "import_startup_history_artifact.sh"
).read_text(encoding="utf-8")
assert "STARTUP_HISTORY_EXPORT_ARTIFACT" in makefile
assert "STARTUP_HISTORY_IMPORT_ARTIFACT" in makefile
assert "/tmp/clickstream-startup-history.json" in export_script
assert "data/startup_history/reference-world.json.xz" in import_script
def test_console_continue_checks_state_or_clean_stand_before_live_start():
"""Continue не стартует новый live-мир на грязном стенде без state."""
run_generator = (REPO_ROOT / "scripts" / "run_generator.sh").read_text(
encoding="utf-8"
)
assert "elif [[ \"${VERB}\" == \"continue\" ]]" in run_generator
assert "bash \"${SCRIPT_DIR}/assert_stand_clean.sh\" continue" in run_generator
assert run_generator.index("assert_stand_clean.sh\" continue") < run_generator.index(
"up -d --build generator"
)
def test_console_reset_checks_clean_stand_before_live_start():
"""Reset не пишет новый мир поверх старых данных."""
run_generator = (REPO_ROOT / "scripts" / "run_generator.sh").read_text(
encoding="utf-8"
)
reset_branch = run_generator.split("else\n", maxsplit=1)[1]
assert "bash \"${SCRIPT_DIR}/assert_stand_clean.sh\" clean" in reset_branch
assert reset_branch.index("assert_stand_clean.sh\" clean") < reset_branch.index(
"up -d --build generator"
)
def test_clean_start_paths_include_live_generator_profile():
"""Все чистые сбросы видят профильный live-генератор."""
generated_history = (
REPO_ROOT / "scripts" / "run_generated_history_analytics.sh"
).read_text(encoding="utf-8")
assert "--profile live-generator down -v --remove-orphans" in generated_history
def test_host_clean_precheck_checks_live_before_kafka_and_stg():
"""Host backfill/import сначала проверяет, что live-генератор не запущен."""
text = (REPO_ROOT / "scripts" / "assert_stand_clean.sh").read_text(
encoding="utf-8"
)
assert "--mode live" in text
assert text.index("--mode live") < text.index("clickhouse-client")
assert text.index("clickhouse-client") < text.index("--mode \"${CHECK_MODE}\"")
def test_airflow_and_generator_kafka_dependency_versions_match():
"""Airflow и генератор используют одну версию kafka-python."""
airflow_req = (REPO_ROOT / "airflow" / "requirements.txt").read_text(encoding="utf-8")
generator_req = (REPO_ROOT / "generator" / "requirements.txt").read_text(encoding="utf-8")
assert "kafka-python==2.0.6" in airflow_req
assert "kafka-python==2.0.6" in generator_req
assert "prometheus-client==0.21.1" in airflow_req