Files
clickstream-data-platform/generator/tests/test_player.py
T
ddadmin 8e21381e13 feat(generator): добавлена команда слепков заказов
- Зачем:
  - задача #92 добавляет второй источник и показывает слепок как чистую функцию зерна и дня.
- Что:
  - добавлены окно, состояние на границе суток и канонические байты с настоящими миллисекундами.
  - добавлены команда snapshot, сдвиг D → D−1 и переиспользование проигранных дней.
  - опись дополнена хешами слепков и поведенческими тестами.
- Проверка:
  - в generator выполнены make lint, make typecheck и make test: 427 тестов.
2026-08-18 17:12:49 +03:00

149 lines
6.5 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
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()