Files
clickstream-data-platform/generator/tests/test_snapshot.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

338 lines
15 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.
"""Слепок заказов: окно, граница суток, запись на проводе и байты прогона.
Слепок — второй артефакт мира, и сторожится он тем же, чем день событий:
формой записи и побайтовым повтором. Проверяются пять обещаний
(docs/architecture/orders/snapshot.md):
1. **Окно.** Слепок дня D несёт заказы, рождённые в дни D−6…D, и у начала оси
усекается сам.
2. **Граница суток.** Состояние — чтение готовой судьбы на границе D|D+1:
моменты позже границы не учитываются, поэтому заказ «дышит» — `created` в
одном слепке, `paid` в следующем.
3. **Запись на проводе.** Одиннадцать ключей в порядке контракта, деньги
строками с двумя знаками, времена RFC 3339 с миллисекундами.
4. **Сдвиг отправки.** Прогон дня D отправляет слепок дня D−1; прогон дня 0 —
ничего.
5. **Повтор.** Пересъёмка слепка даёт те же байты.
День рождения заказа берётся из его номера, а не из `created_at`: номер
считается в поясе счётчика, а `created_at` — абсолютная метка, и у ночной
покупки их даты расходятся.
"""
import hashlib
import json
import re
import subprocess
import sys
from contextlib import closing
from datetime import UTC, date, datetime, time, timedelta
from decimal import Decimal
import numpy as np
import pytest
from clickstream_generator import catalog, cli, commerce, player, serialize, world
from clickstream_generator import day as day_module
from clickstream_generator.seeds import CANONICAL_SEED
from clickstream_generator.sinks import FileSink
# Первый слепок с полным окном и слепок у начала оси, где окно усечено.
FULL_WINDOW_DAY = world.ORDER_WINDOW_DAYS - 1
SHORT_WINDOW_DAY = 3
# Порядок ключей записи — порядок полей контракта (мастер-спека, раздел 2).
CONTRACT = (
"order_id",
"user_id",
"status",
"created_at",
"updated_at",
"items_total",
"discount",
"delivery",
"total",
"items",
"snapshot_date",
)
ITEM = ("sku", "qty", "price")
STATUSES = {"created", "paid", "cancelled"}
MONEY = re.compile(r"\d+\.\d{2}")
MOMENT = re.compile(r"\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z")
@pytest.fixture(scope="module")
def days() -> list[day_module.Day]:
"""Дни окна: слепок дня 6 собирается переигровкой дней 0…6."""
return [
day_module.stream(CANONICAL_SEED, number)
for number in range(world.ORDER_WINDOW_DAYS)
]
def snapshot_records(days: list[day_module.Day], number: int) -> list[dict]:
"""Слепок дня `number` разобранным обратно из канонических байтов."""
window = [
today.orders for today in days[max(0, number - FULL_WINDOW_DAY) : number + 1]
]
return [json.loads(payload) for payload in serialize.orders(window, number)]
def test_the_record_matches_the_wire_contract(days: list[day_module.Day]):
"""Запись слепка — ровно то, что примет строгий приём хранилища.
Набор и порядок ключей, деньги строками с двумя знаками, времена RFC 3339
в UTC с миллисекундами, `items` обычным массивом — всё это контракт
провода, а не вкус: разбор в ODS сверяет набор ключей и форму значений, и
строка мимо формы уходит в брак целиком (спецификация приёма заказов).
Деньги сверяются с позициями и между собой: строка на проводе обязана
отвечать тому же миру, что и заказ в памяти. Цена позиции — цена каталога,
а `created_at` — время той самой покупки, что уехала в трекер: строка
заказа создаётся синхронно с ней.
"""
goods = catalog.catalog()
price = dict(zip(goods.sku.tolist(), goods.price.tolist(), strict=True))
bought = _purchase_times(days[SHORT_WINDOW_DAY])
records = snapshot_records(days, SHORT_WINDOW_DAY)
assert records
for record in records:
assert list(record) == list(CONTRACT)
assert isinstance(record["user_id"], int)
assert record["status"] in STATUSES
assert MOMENT.fullmatch(record["created_at"])
assert MOMENT.fullmatch(record["updated_at"])
assert record["snapshot_date"] == _date(SHORT_WINDOW_DAY).isoformat()
assert isinstance(record["items"], list)
assert record["items"]
lines = Decimal(0)
for item in record["items"]:
assert list(item) == list(ITEM)
assert MONEY.fullmatch(item["price"])
assert isinstance(item["qty"], int)
assert item["qty"] > 0
assert Decimal(item["price"]) * 100 == price[item["sku"]]
lines += Decimal(item["price"]) * item["qty"]
money = {name: record[name] for name in CONTRACT[5:9]}
for value in money.values():
assert MONEY.fullmatch(value)
assert lines == Decimal(money["items_total"])
assert Decimal(money["total"]) == (
Decimal(money["items_total"])
- Decimal(money["discount"])
+ Decimal(money["delivery"])
)
if _born(record) == _date(SHORT_WINDOW_DAY):
assert record["created_at"][:19] + "Z" == bought[record["order_id"]]
def test_the_moments_carry_real_milliseconds(days: list[day_module.Day]):
"""Миллисекунды — часы базы источника, а не три дописанных нуля.
Три знака дробной части контракт требует всегда, и `.000` формально им
отвечают — но тогда миллисекундная точность была бы обещанием без модели за
ним, а разбор в `DateTime64(3)` показывал бы менти ровную секундную сетку
там, где у источника её нет (исследование формата слепка).
Фаза у обоих моментов одна: часы базы ставят её строке при создании, а
судьба заказа отмеряется от неё целыми секундами.
"""
records = snapshot_records(days, SHORT_WINDOW_DAY)
assert records
fractions = {record["created_at"][20:23] for record in records}
assert fractions - {"000"}
for record in records:
assert record["updated_at"][20:23] == record["created_at"][20:23]
def test_the_rows_go_in_the_order_of_birth(days: list[day_module.Day]):
"""Порядок строк слепка — порядок рождения заказов, он же рост номера.
Хешу слепка в описи нужен именно названный порядок: детерминизм даёт его
даром, но обещание побайтового повтора держится на нём, а не на удаче.
"""
numbers = [record["order_id"] for record in snapshot_records(days, FULL_WINDOW_DAY)]
assert numbers
assert numbers == sorted(numbers)
assert len(set(numbers)) == len(numbers)
def test_the_state_is_read_at_the_boundary_of_the_day(days: list[day_module.Day]):
"""Дыхание окна: оплаченный назавтра заказ в сегодняшнем слепке `created`.
Это и есть состояние на границе суток: учитываются моменты не позже
границы, поэтому оплата следующего дня в сегодняшний слепок не попадает.
`updated_at` — поздний учтённый момент, а без единого — `created_at`:
заказ, с которым на границе ещё ничего не случилось, показывает своё
рождение.
Граница модельных суток — полночь пояса счётчика: по нему считается
модельный день, а `created_at` и `updated_at` уезжают абсолютной меткой.
"""
before = {
record["order_id"]: record
for record in snapshot_records(days, SHORT_WINDOW_DAY)
}
after = {
record["order_id"]: record
for record in snapshot_records(days, SHORT_WINDOW_DAY + 1)
}
breathed = [
number
for number, record in before.items()
if record["status"] == "created" and after[number]["status"] == "paid"
]
assert breathed
for number in breathed:
assert before[number]["updated_at"] == before[number]["created_at"]
assert after[number]["created_at"] == before[number]["created_at"]
assert after[number]["updated_at"] > after[number]["created_at"]
edge = _boundary(SHORT_WINDOW_DAY)
for record in before.values():
assert record["updated_at"] <= edge
def test_the_run_sends_the_snapshot_of_the_day_before(
days: list[day_module.Day], tmp_path
):
"""Прогон дня D отправляет слепок дня D−1, и окно усекается у начала оси.
Сдвиг «ночная выгрузка за вчера» живёт в проигрывателе, поэтому и
спрашивается с него: прогоны дней 4…7 обязаны отправить слепки дней 3…6 —
теми же байтами, какие даёт сериализатор, по строке на заказ.
"""
path = tmp_path / "snapshots.jsonl"
with closing(FileSink(path)) as sink:
player.play_snapshots(
sink,
seed=CANONICAL_SEED,
first_day=SHORT_WINDOW_DAY + 1,
days=FULL_WINDOW_DAY - SHORT_WINDOW_DAY + 1,
)
assert path.read_bytes() == b"".join(
payload + b"\n"
for number in range(SHORT_WINDOW_DAY, FULL_WINDOW_DAY + 1)
for payload in serialize.orders(
[today.orders for today in days[: number + 1]], number
)
)
sent = [json.loads(line) for line in path.read_bytes().splitlines()]
for number in (SHORT_WINDOW_DAY, FULL_WINDOW_DAY):
window = {
_born(record)
for record in sent
if record["snapshot_date"] == _date(number).isoformat()
}
assert window == {
_date(born) for born in range(max(0, number - FULL_WINDOW_DAY), number + 1)
}
def test_the_range_plays_every_day_of_its_windows_once(monkeypatch, tmp_path):
"""Диапазон одним прогоном переигрывает каждый день окна один раз.
Окна соседних слепков перекрываются почти целиком, и без переиспользования
стартовый диапазон стоил бы полусотни проигрышей вместо восьми. День здесь
настоящий: считается не содержимое, а то, сколько раз его спросили.
"""
asked: list[int] = []
honest = day_module.stream
def spy(seed: int, number: int) -> day_module.Day:
asked.append(number)
return honest(seed, number)
monkeypatch.setattr(player.day_module, "stream", spy)
with closing(FileSink(tmp_path / "range.jsonl")) as sink:
player.play_snapshots(sink, seed=CANONICAL_SEED, first_day=1, days=3)
assert sorted(asked) == [0, 1, 2]
def test_the_first_run_sends_nothing(tmp_path):
"""На старте оси дня −1 нет: прогону дня 0 отправлять нечего.
Не ошибка и не пустой слепок, а отсутствие выгрузки: первый слепок — дня 0
— уезжает прогоном дня 1.
"""
path = tmp_path / "day-zero.jsonl"
assert cli.main(["snapshot", "--day", "0", "--file", str(path)]) == 0
assert path.read_bytes() == b""
def test_two_runs_give_the_same_snapshot(tmp_path):
"""Пересъёмка слепка даёт те же байты — тем и лечится пропущенный день.
Прогоны идут разными процессами по тому же доводу, что и у дня событий:
внутри одного интерпретатора общее зерно хеширования спрятало бы
зависимость канона от порядка обхода множества.
"""
first, second = tmp_path / "first.jsonl", tmp_path / "second.jsonl"
_run_apart(first)
_run_apart(second)
assert first.read_bytes()
assert _digest(first) == _digest(second)
def _run_apart(path) -> None:
"""Снять слепок отдельным процессом; окружение он берёт от нас."""
finished = subprocess.run(
[
sys.executable,
"-m",
"clickstream_generator",
"snapshot",
"--day",
"2",
"--file",
str(path),
],
capture_output=True,
text=True,
timeout=300,
check=False,
)
assert finished.returncode == 0, finished.stderr
def _purchase_times(today: day_module.Day) -> dict[str, str]:
"""Когда покупка уехала в трекер — по номеру заказа, абсолютной меткой."""
here = today.columns["EventType"] == commerce.PURCHASE
numbers = [cell[0] for cell in today.columns["purchaseID"][here]]
times = np.datetime_as_string(
today.columns["UTCEventTime"][here], unit="s", timezone="UTC"
)
return dict(zip(numbers, times.tolist(), strict=True))
def _born(record: dict) -> date:
"""День рождения заказа: его номер начинается датой дня покупки."""
return datetime.strptime(record["order_id"].split("-")[0], "%Y%m%d").date()
def _date(number: int) -> date:
return world.ORIGIN + timedelta(days=number)
def _boundary(number: int) -> str:
"""Граница суток `number`|`number+1` — полночь пояса счётчика в UTC."""
midnight = datetime.combine(_date(number + 1), time.min, UTC)
edge = midnight - timedelta(minutes=world.COUNTER_TIMEZONE_MINUTES)
return edge.strftime("%Y-%m-%dT%H:%M:%S.") + f"{edge.microsecond // 1000:03d}Z"
def _digest(path) -> str:
return hashlib.sha256(path.read_bytes()).hexdigest()