Files
clickstream-data-platform/generator/tests/test_orders.py
T
ddadmin 0d90bd566b feat(generator): добавлена судьба заказов
- Зачем:
  - issue #91 требует решать исход и расхождение заказа в день его рождения.
- Что:
  - добавлены три исхода, моменты оплаты и отмены и дельта одной позиции.
  - обновлены проверки повторяемости и архитектурное описание заказов.
- Проверка:
  - cd generator && make lint && make typecheck && make test (418 passed).
2026-08-18 16:02:12 +03:00

299 lines
14 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.
"""Заказы бэкенда: проекция покупки, деньги магазина и мост к склейке.
Здесь сверяются две проекции одной покупки — клиентское событие и заказ
бэкенда — и судьба заказа. Сверяется заказ не с внутренней структурой
торговой половины (это было бы сверкой кода с самим собой), а с событием,
которое уехало в трекер.
Чистота заказов от зерна и дня сторожится там же, где чистота событий, —
слепком дня в `test_day.py`: заказы день отдаёт наружу наравне с потоком.
"""
from typing import Any
import numpy as np
import pytest
from numpy.typing import NDArray
from clickstream_generator import catalog, commerce, day, orders, plan, schema, world
from clickstream_generator.seeds import CANONICAL_SEED, Component
WEEKDAY = 2
WEEK = 7
@pytest.fixture(autouse=True)
def fresh_memo():
"""Когорты запоминаются; тесты сравнивают вычисления, а не ссылки."""
plan.cohort.cache_clear()
@pytest.fixture(scope="module")
def weekday() -> day.Day:
return day.stream(CANONICAL_SEED, WEEKDAY)
@pytest.fixture(scope="module")
def week() -> list[day.Day]:
"""Неделя мира: назначенные пары покупают в разные дни."""
return [day.stream(CANONICAL_SEED, number) for number in range(WEEK)]
def purchases_of(events: day.Day) -> dict[str, NDArray[Any]]:
"""Строки покупок дня — колонками, в порядке потока."""
here = events.columns["EventType"] == commerce.PURCHASE
return {name: value[here] for name, value in events.columns.items()}
def user_id_by_order(events: day.Day) -> dict[str, int]:
return dict(
zip(events.orders.order_id, events.orders.user_id.tolist(), strict=True)
)
def test_every_order_of_the_day_is_a_purchase_of_the_day(weekday: day.Day):
"""Заказ — вторая проекция покупки, а не второе порождение.
У всякого заказа ровно одно событие `purchase`, и номер у них один:
сойтись двум источникам больше негде — сверка стенда соединяется
именно по нему.
"""
events = purchases_of(weekday)
numbers = [cell[0] for cell in events["purchaseID"]]
assert len(numbers) > 100
assert list(weekday.orders.order_id) == numbers
def dropped_line(client: list[tuple[Any, int]], order: list[tuple[Any, int]]):
"""Единственный индекс, удалением которого корзина клиента даёт заказ."""
for gone in range(len(client)):
if client[:gone] + client[gone + 1 :] == order:
return gone
return None
def test_the_order_repeats_the_purchase_up_to_one_dropped_line(weekday: day.Day):
"""Заказ повторяет покупку или теряет ровно одну позицию с её деньгами.
Дельта — единственное, чем заказ вправе разойтись с клиентом: строка
уходит целиком, вместе со своей полной стоимостью, и терять её
однопозиционному заказу нечего.
"""
goods = catalog.catalog()
price = dict(zip(goods.sku.tolist(), goods.price.tolist(), strict=True))
events = purchases_of(weekday)
money = weekday.orders
deltas = 0
for number, order in enumerate(weekday.orders.order_id):
assert order == events["purchaseID"][number][0]
client = list(
zip(
events["productID"][number].tolist(),
events["productQuantity"][number].tolist(),
strict=True,
)
)
mine = list(
zip(
[goods.sku[item] for item in money.product[number].tolist()],
money.quantity[number].tolist(),
strict=True,
)
)
# Выручка клиента и `items_total` бэкенда — одно число: в событии
# оно дробное, у заказа целое в копейках.
revenue = round(events["purchaseRevenue"][number][0] * commerce.KOPECKS)
if mine == client:
assert money.items_total[number] == revenue
continue
gone = dropped_line(client, mine)
assert gone is not None, order
assert len(client) > 1
sku, quantity = client[gone]
line = price[sku] * quantity
assert money.items_total[number] == revenue - line
deltas += 1
# Сколько именно дельт — калибровка, а не контракт; ноль их быть не может.
assert deltas > 0
def test_the_discount_comes_from_the_coupon_of_the_event(weekday: day.Day):
"""Промокод в событии обязан обернуться скидкой — иначе данные соврут."""
percent = dict(world.COUPONS)
events = purchases_of(weekday)
codes = [cell[0] for cell in events["purchaseCoupon"]]
assert sum(1 for code in codes if code) > 10
for number, code in enumerate(codes):
# Скидка берётся от клиентской выручки, и складская дельта её не
# пересчитывает. В событии выручка дробная, у заказа — копейки.
revenue = round(events["purchaseRevenue"][number][0] * commerce.KOPECKS)
expected = revenue * percent[code] // 100 if code else 0
assert weekday.orders.discount[number] == expected
# Скидка без кода не берётся ниоткуда, а с кодом не съедает заказ:
# мерой заказа здесь та же исходная выручка, что и у самой скидки.
assert weekday.orders.discount[number] < revenue
def test_the_money_of_an_order_adds_up(weekday: day.Day):
"""`total` = `items_total` `discount` + `delivery`, целыми копейками."""
money = weekday.orders
assert np.array_equal(
money.total, money.items_total - money.discount + money.delivery
)
for column in (money.items_total, money.discount, money.delivery, money.total):
assert np.issubdtype(column.dtype, np.integer)
assert np.all(column >= 0)
prices = {price for price, _ in world.DELIVERY_KOPECKS_WEIGHTS}
assert set(money.delivery.tolist()) == prices
# Доставка — деньги, которых нет ни в одном событии: без неё «считаем по
# бэкенду» ничего не значило бы.
assert np.any(money.total != money.items_total - money.discount)
def test_every_order_leaves_the_window_with_one_of_three_fates(weekday: day.Day):
"""Судьба заказа: три исхода, и моменты рассказывают ту же историю.
Инвариант выхода из окна — заказ либо оплачен, либо отменён; `created`
навсегда мир не допускает. Момента, которого у исхода нет, нет и в
данных: его место занимает −1, а не ноль, иначе «оплатили в секунду
рождения» было бы не отличить от «не оплатили вовсе». Оставшиеся
моменты лежат внутри окна 144 часов — таблица весов кончается там же,
где окно у самого невезучего заказа, — а секунда внутри часа
равномерная: без неё разности времён аудита давали бы точные равенства.
Доли здесь не спрашиваются: их калибруют, а не фиксируют тестом.
"""
window = 144 * 3600
fate = weekday.orders
outcomes = fate.outcome.tolist()
assert len(outcomes) == len(fate)
assert set(outcomes) == {
orders.OrderOutcome.PAID,
orders.OrderOutcome.PAID_THEN_CANCELLED,
orders.OrderOutcome.UNPAID_THEN_CANCELLED,
}
for outcome, paid, cancelled in zip(
outcomes, fate.paid_after.tolist(), fate.cancelled_after.tolist(), strict=True
):
for moment in (paid, cancelled):
assert moment == -1 or 0 <= moment < window
if outcome == orders.OrderOutcome.PAID:
assert paid >= 0 and cancelled == -1
elif outcome == orders.OrderOutcome.PAID_THEN_CANCELLED:
# Ранний из двух моментов и есть оплата: порядок дорог выходит
# сортировкой, а не условной точкой отсчёта.
assert 0 <= paid <= cancelled
else:
assert paid == -1 and cancelled >= 0
seconds = {
moment % 3600
for moment in (*fate.paid_after.tolist(), *fate.cancelled_after.tolist())
if moment >= 0
}
assert len(seconds) > 1
def test_the_order_side_draws_from_its_own_named_branch(
monkeypatch: pytest.MonkeyPatch,
):
"""Заказная сторона берёт свой подпоток и ветвится по дню рождения заказа.
Адрес в дереве зерна и есть контракт компонента (спека генератора,
раздел 2): правка заказной механики не двигает ни трафик, ни торговлю,
а слепок читает судьбу заказа из дня, в который заказ родился.
"""
asked: list[tuple[int, int, Component]] = []
honest = orders.day_stream
def spy(seed: int, day_number: int, component: Component):
asked.append((seed, day_number, component))
return honest(seed, day_number, component)
monkeypatch.setattr(orders, "day_stream", spy)
day.stream(CANONICAL_SEED, WEEKDAY)
assert asked == [(CANONICAL_SEED, WEEKDAY, Component.ORDERS)]
def test_the_user_id_is_the_person_behind_the_cookie(weekday: day.Day):
"""Личность заказу даёт план, а не разбор трекера."""
audience = plan.audience(CANONICAL_SEED, WEEKDAY)
person = dict(
zip(audience.client_id.tolist(), audience.person_id.tolist(), strict=True)
)
events = purchases_of(weekday)
for number, cookie in enumerate(events["ClientID"].tolist()):
assert weekday.orders.user_id[number] == person[cookie]
assert np.all(weekday.orders.user_id > 0)
assert weekday.orders.user_id.max() < 2**53
def test_both_cookies_of_a_pair_order_as_one_user(week: list[day.Day]):
"""Мост к склейке: заказы с двух кук пары несут один `user_id`.
Проверяются пары, у которых оба назначенных дня попали внутрь недели:
остальные ждут своего дня за горизонтом.
"""
users: list[dict[int, set[int]]] = []
for events in week:
by_order = user_id_by_order(events)
rows = purchases_of(events)
seen: dict[int, set[int]] = {}
for cookie, number in zip(
rows["ClientID"].tolist(),
(cell[0] for cell in rows["purchaseID"]),
strict=True,
):
seen.setdefault(cookie, set()).add(by_order[number])
users.append(seen)
pairs = 0
for born in range(-world.RETURN_TAIL_DAYS, WEEK):
cohort = plan.cohort(CANONICAL_SEED, born)
for cookies, days in zip(
cohort.pair_cookies.tolist(), cohort.pair_order_days.tolist(), strict=True
):
if max(days) >= WEEK:
continue
pairs += 1
first, second = (
users[day_number][int(cohort.client_id[cookie])]
for cookie, day_number in zip(cookies, days, strict=True)
)
assert len(first | second) == 1
assert pairs > 10
def test_the_user_id_never_reaches_the_metrica_event(weekday: day.Day):
"""Кликстрим анонимен: личность держится формой контракта, а не забывчивостью.
Спрашивается двумя способами, потому что утечь личность может двумя.
Числом — тогда её видно в числовых колонках дня, и они сверяются со
всеми личностями дневной аудитории. Текстом — тогда сравнение чисел
её прозевало бы, поэтому строки покупок читаются целиком, вместе с
сырым `ecommerce`: этот блок собирается руками, и дописать в него
лишнее поле проще всего. Имени для личности в контракте схемы нет.
"""
people = set(plan.audience(CANONICAL_SEED, WEEKDAY).person_id.tolist())
assert people
names = {column.name.lower() for column in schema.COLUMNS}
assert not (names & {"userid", "user_id", "personid", "person_id"})
for name, value in weekday.columns.items():
if np.issubdtype(value.dtype, np.integer):
assert not (set(value.tolist()) & people), name
events = purchases_of(weekday)
text = "\n".join(
" ".join(str(value[row]) for value in events.values())
for row in range(events["WatchID"].size)
)
for person in weekday.orders.user_id.tolist():
assert str(person) not in text
assert set(weekday.orders.user_id.tolist()) <= people