"""Заказы бэкенда: проекция покупки, деньги магазина и мост к склейке. Здесь сверяются две проекции одной покупки — клиентское событие и заказ бэкенда — и судьба заказа. Сверяется заказ не с внутренней структурой торговой половины (это было бы сверкой кода с самим собой), а с событием, которое уехало в трекер. Чистота заказов от зерна и дня сторожится там же, где чистота событий, — слепком дня в `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