Files
ddadmin 7c9eeedc40 feat(stand): make up наполняет стенд стартовым миром, опись сторожит его
Зачем
Стенд поднимался пустым, и всякая приёмка следующих этапов начиналась с
ручной заливки данных. Теперь `make up` сам приводит стенд к одному и тому
же состоянию, а в git лежит то, чем это состояние проверяется.

Что
- Опись мира `data/world-inventory.json`: паспорт (зерно, версия
  генератора, хеш каталога) и по строке на каждый из восьми дней — дата,
  число событий, хеш байтов. Собирается `make inventory`, свежесть сторожит
  `test_inventory.py` — тем же способом, что свежесть описания выгрузки.
- Разовая служба `world-init` вышла из-под профиля и играет в топик восемь
  дней при каждом подъёме; зависимый у неё — `airflow-init`, иначе `--wait`
  считает успешно отработавшую службу упавшей.
- `scripts/wait-for-world.sh` — вторая половина `make up`: приём
  асинхронный, поэтому ждать надо доезда до `ods.event`, а не завершения
  заливки. Ограниченный цикл опроса, не пауза наугад.
- Девятая проверка `make check-clickhouse`: подневный счёт событий против
  описи, рамка по датам стартового мира, счёт через `FINAL`. При
  расхождении называет, где искать, — в событиях или в браке.
- Порог «день ≤ 30 с» снят из спеки генератора в обоих местах: замер дал
  1,7 с, порог был выше факта в восемнадцать раз. На его месте — замеры с
  датой. Раздел 9 спеки закрыт: открытых вопросов не осталось.
- Слова: «манифест» стал описью мира, «зерновой мир» — стартовым миром
  (решение владельца). Оба заведены в словарь CONTEXT.md.

Проверка
`make clean && make up` с нуля — 2 м 50 с, доехало ровно 401 185 событий.
`make check-clickhouse` зелёный (8 с), `make smoke` зелёный (9 с),
`make test` — 407 тестов за 71 с, `make lint`, `make typecheck`,
`make config-test` зелёные.

Что проверка умеет краснеть, снято двумя поломками: снос партиции
2026-06-03 дал диагноз «не доехали до ODS», негодная строка в сырье —
«сломан разбор». Строки опыта убраны, день переигран, счёт вернулся.
Тест свежести проверен молчаливой правкой цены в каталоге: покраснел.

Ссылка: #42
2026-08-07 18:37:01 +03:00

170 lines
7.3 KiB
Python

"""Проигрыватель и его интерфейс: побайтовый повтор, построчность, приёмник.
Главное здесь — обещание раздела 2 спеки, доведённое до байтов на диске: два
прогона одного дня дают тот же файл. Тем же тестом сторожится построчность:
одно событие — одна строка. Дешёвая половина контракта транспорта — склейка
двух событий сломала бы разбор целиком, потому что хранилище читает топик
байтами.
"""
import hashlib
import subprocess
import sys
from contextlib import closing
from dataclasses import replace
import pytest
from clickstream_generator import cli, player
from clickstream_generator import day as day_module
from clickstream_generator.seeds import CANONICAL_SEED
from clickstream_generator.sinks import FileSink
DAY = 2
# Переменные, которыми зовущий задаёт прогон. Тест, читающий их из окружения
# машины, зелен у одного и красен у другого — а на этой машине они как раз и
# живут: стенд их экспортирует.
LAUNCH_VARIABLES = (
"GENERATOR_SEED",
"GENERATOR_DAY",
"GENERATOR_DAYS",
"GENERATOR_LIMIT",
"GENERATOR_SPEED",
"GENERATOR_FILE",
"KAFKA_BOOTSTRAP_SERVERS",
"KAFKA_TOPIC",
)
@pytest.fixture(autouse=True)
def bare_environment(monkeypatch):
"""Прогон тестов не зависит от того, что задано в окружении машины."""
for name in LAUNCH_VARIABLES:
monkeypatch.delenv(name, raising=False)
def _play(path, **options):
"""Проиграть в файл и вернуть итог прогона."""
with closing(FileSink(path)) as sink:
return player.play(sink, seed=CANONICAL_SEED, first_day=DAY, **options)
def test_two_runs_give_the_same_file(tmp_path):
"""Два прогона дня — одинаковые байты и строка на событие.
Хеши сравниваются целиком, а не построчно: обещание побайтовое, и
расхождение в одной запятой обязано покраснеть так же, как расхождение в
наборе событий.
Прогоны идут **разными процессами**, а не двумя вызовами в одном. Внутри
одного интерпретатора зерно хеширования общее на оба прогона, поэтому
зависимость канона от порядка обхода множества такой тест не увидел бы
никогда — а это ровно тот класс расхождений, ради которого обещание и
дано. Заодно день играется тем же путём, каким генератор зовут на самом
деле: через интерфейс запуска, а не через функцию.
"""
first, second = tmp_path / "first.jsonl", tmp_path / "second.jsonl"
_run_apart(first)
_run_apart(second)
assert _digest(first) == _digest(second)
events = len(day_module.stream(CANONICAL_SEED, DAY))
assert len(first.read_bytes().splitlines()) == events
def _run_apart(path) -> None:
"""Проиграть день отдельным процессом; окружение он берёт от нас."""
finished = subprocess.run(
[
sys.executable,
"-m",
"clickstream_generator",
"batch",
"--day",
str(DAY),
"--file",
str(path),
],
capture_output=True,
text=True,
timeout=300,
check=False,
)
assert finished.returncode == 0, finished.stderr
def test_days_play_in_a_row(tmp_path, monkeypatch):
"""Дни идут подряд от названного, а пачка считается на весь прогон.
Восемь дней одним запуском — то, чем заливается стартовый мир, поэтому
порядок дней проверяется, а не предполагается. День здесь подменён коротким:
проверяется ход проигрывателя, а не содержимое дня, и платить за полсотни
тысяч событий трижды незачем.
"""
short = _shorten(day_module.stream(CANONICAL_SEED, DAY), rows=2)
asked: list[int] = []
def stream(seed: int, number: int):
asked.append(number)
return short
monkeypatch.setattr(player.day_module, "stream", stream)
path = tmp_path / "three-days.jsonl"
played = _play(path, days=3, limit=5)
assert asked == [DAY, DAY + 1, DAY + 2]
# Двум дням хватило по два события, третьему досталось последнее: потолок
# считается на весь прогон, а не на каждый день заново.
assert played.events == 5
assert len(path.read_bytes().splitlines()) == 5
FILE = ["--file", "/dev/null"]
KAFKA = ["--brokers", "kafka:29092", "--topic", "hits"]
@pytest.mark.parametrize(
"argv",
[
pytest.param(["batch", "--day", "0"], id="приёмник не назван"),
pytest.param(["batch", "--day", "0", *FILE, *KAFKA], id="названы оба"),
pytest.param(
["batch", "--day", "0", "--brokers", "kafka:29092"], id="топик не назван"
),
pytest.param(["batch", *FILE], id="день не назван"),
],
)
def test_run_is_refused_loudly(argv):
"""Всё, чего проигрыватель не знает, — отказ до первого события.
Правило одно: параметр, описывающий окружение стенда или позицию на оси
мира, своего умолчания не имеет. Тихо подставленное умолчание — чужой факт,
выданный за наш, и прогон отчитается о нём успехом.
"""
with pytest.raises(SystemExit) as refusal:
cli.main(argv)
assert refusal.value.code == 2
def test_cli_plays_a_limited_batch_into_a_file(tmp_path):
"""Тот самый вызов, которым проверки #43 берут срез: код возврата — 0."""
path = tmp_path / "cli.jsonl"
assert cli.main(["batch", "--day", "0", "--limit", "5", "--file", str(path)]) == 0
assert len(path.read_bytes().splitlines()) == 5
def _shorten(today: day_module.Day, rows: int) -> day_module.Day:
"""Тот же день, но короткий: первые строки всех его рядов."""
return replace(
today,
columns={name: value[:rows] for name, value in today.columns.items()},
page=today.page[:rows],
product=today.product[:rows],
)
def _digest(path) -> str:
return hashlib.sha256(path.read_bytes()).hexdigest()