"""Торговые события: корзина шире заказа, деньги целые, номера читаемые. Числа мира тесты сторожат вилками спеки, а не точными значениями: менти крутит конфигурацию, и падать тесты должны там, где сдвинулся вывод («заказ уже корзины», «конверсия визита около 2%»), а не при каждой правке. Неделя дня-функции стоит секунд, поэтому дни, которые нужны нескольким тестам, считаются один раз на модуль. """ import json from typing import Any import numpy as np import pytest from numpy.typing import NDArray from clickstream_generator import catalog, commerce, day, plan, schema, world from clickstream_generator.reference import Page 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 rows_of(events: day.Day, kind: str) -> dict[str, NDArray[Any]]: """Строки одного типа события — все колонки разом.""" here = events.columns["EventType"] == kind return {name: value[here] for name, value in events.columns.items()} def cells(column: NDArray[Any]) -> list[list[Any]]: return [cell.tolist() for cell in column] def raw_of(rows: dict[str, NDArray[Any]]) -> list[dict[str, Any]]: """Сырой `ecommerce` разобранный — чужим разбором, не своим сборщиком.""" return [json.loads(text) for text in rows["ecommerce"]] def by_visit(events: day.Day) -> NDArray[np.int64]: """Порядок строк «визит, потом время» — так их читает лаба сессий.""" return np.lexsort((events.columns["UTCEventTime"], events.columns["VisitID"])) def test_the_day_carries_both_trade_events(weekday: day.Day): """Таксономия ожила: в дне есть и корзина, и покупка.""" carts = rows_of(weekday, commerce.ADD_TO_CART) orders = rows_of(weekday, commerce.PURCHASE) assert orders["WatchID"].size > 100 # Корзина шире заказа и по событиям: не всякая корзина доходит до кассы. assert carts["WatchID"].size > 2 * orders["WatchID"].size def confirmed_visits(events: day.Day) -> set[int]: """Визиты, чья страница подтверждения дожила до потока.""" pageview = events.columns["EventType"] == "pageview" here = pageview & (events.page == Page.CONFIRMATION) return set(events.columns["VisitID"][here].tolist()) def test_a_confirmation_in_the_stream_always_has_its_purchase(weekday: day.Day): """Клиентская сторона честная: подтверждение без покупки — это потеря. Потери и дубли стенд заводит намеренно и позже (этапы 4 и 6); здесь их быть не должно ни одной. Обратное тоже верно: покупка без подтверждения в потоке — заказ ниоткуда. """ bought = set(rows_of(weekday, commerce.PURCHASE)["VisitID"].tolist()) assert confirmed_visits(weekday) == bought def test_the_day_boundary_takes_the_confirmation_together_with_its_purchase( monkeypatch: pytest.MonkeyPatch, ): """Полночь режет подтверждение и покупку вместе — или не режет ни одну. Иначе визит отдал бы страницу «Заказ оформлен» без события покупки, и на клиентской стороне завелась бы потеря. Хвост торгового события здесь растянут до часа: в каноническом мире он секунды, и такой визит выпадает раз в годы, а правило обязано держаться при любом хвосте. """ monkeypatch.setattr(world, "TRADE_DELAY_SECONDS", (3_000, 3_600)) monkeypatch.setattr(day, "TRADE_TAIL_SECONDS", 3_600) events = day.stream(CANONICAL_SEED, WEEKDAY) bought = set(rows_of(events, commerce.PURCHASE)["VisitID"].tolist()) assert bought assert confirmed_visits(events) == bought def test_a_purchase_carries_exactly_one_order_number(weekday: day.Day): """Одно подтверждение — один заказ (мастер-спека, раздел 4).""" orders = rows_of(weekday, commerce.PURCHASE) for name in ("purchaseID", "purchaseRevenue", "purchaseCurrency", "purchaseCoupon"): assert {cell.size for cell in orders[name]} == {1}, name assert {cell[0] for cell in orders["purchaseCurrency"]} == {world.CURRENCY} def test_the_cart_event_has_no_order_block(weekday: day.Day): """У корзины заказа ещё нет: блок `purchase*` пуст, как у просмотра.""" carts = rows_of(weekday, commerce.ADD_TO_CART) for name in ("purchaseID", "purchaseRevenue", "purchaseCurrency", "purchaseCoupon"): assert {cell.size for cell in carts[name]} == {0}, name def test_the_product_arrays_of_an_event_share_one_length(weekday: day.Day): """Длина общая внутри группы `product*`; с `purchase*` она не совпадает.""" trade = weekday.columns["EventType"] != "pageview" names = [ column.name for column in schema.COLUMNS if column.name.startswith("product") ] assert len(names) == 6 sizes = np.array( [[cell.size for cell in weekday.columns[name][trade]] for name in names] ) assert np.all(sizes == sizes[0]) # Корзина — всегда один товар, заказ — от одного и больше. carts = rows_of(weekday, commerce.ADD_TO_CART) assert {cell.size for cell in carts["productID"]} == {1} orders = rows_of(weekday, commerce.PURCHASE) assert min(cell.size for cell in orders["productID"]) == 1 assert max(cell.size for cell in orders["productID"]) > 1 def test_the_raw_json_agrees_with_the_flat_arrays(weekday: day.Day): """Сырое и разобранное — одно событие, поле в поле. Сверяется всё, что есть по обе стороны: разъедься хоть одно поле, лаба «сырое против разобранного» учила бы неправде. Цена — единственная пара, где расхождение законно: в колонке целые рубли, в сыром точная сумма, поэтому сверяется не равенство, а что колонка — та же цена, округлённая до рубля. """ for kind, action in ( (commerce.ADD_TO_CART, commerce.ADD_ACTION), (commerce.PURCHASE, commerce.PURCHASE_ACTION), ): rows = rows_of(weekday, kind) assert rows["ecommerce"].size > 100 for number, block in enumerate(raw_of(rows)): assert block["currencyCode"] == world.CURRENCY products = block[action]["products"] for field, column in ( ("id", "productID"), ("name", "productName"), ("category", "productCategory"), ("quantity", "productQuantity"), ): assert [item[field] for item in products] == ( rows[column][number].tolist() ), (kind, field) assert rows["productEventType"][number].tolist() == [action] * len(products) prices = zip( (item["price"] for item in products), rows["productPrice"][number].tolist(), strict=True, ) assert all(abs(exact - whole) <= 0.5 for exact, whole in prices) orders = rows_of(weekday, commerce.PURCHASE) for number, block in enumerate(raw_of(orders)): field = block[commerce.PURCHASE_ACTION]["actionField"] assert field["id"] == orders["purchaseID"][number][0] assert field["revenue"] == orders["purchaseRevenue"][number][0] def test_the_raw_json_carries_what_the_arrays_do_not(weekday: day.Day): """Иначе лаба «сырое против разобранного» разбирала бы то же самое.""" orders = rows_of(weekday, commerce.PURCHASE) goods = catalog.catalog() brands = set(goods.brand.tolist()) seen_brands: set[str] = set() for block in raw_of(orders): for item in block[commerce.PURCHASE_ACTION]["products"]: assert item["brand"] in brands assert item["variant"] seen_brands.add(item["brand"]) assert len(seen_brands) > 1 assert not any( column.name in ("productBrand", "productVariant") for column in schema.COLUMNS ) def test_money_is_whole_kopecks_inside_and_float_only_on_the_surface(weekday: day.Day): """Деньги целые; дробное число в событии одно — выручка (спека, раздел 2).""" floats = [ column.name for column in schema.COLUMNS if column.numpy_dtype == "float64" ] assert floats == ["purchaseRevenue"] goods = catalog.catalog() orders = rows_of(weekday, commerce.PURCHASE) sku = {article: number for number, article in enumerate(goods.sku.tolist())} for number, revenue in enumerate(orders["purchaseRevenue"]): assert revenue.dtype == np.float64 kopecks = sum( int(goods.price[sku[article]]) * pieces for article, pieces in zip( orders["productID"][number].tolist(), orders["productQuantity"][number].tolist(), strict=True, ) ) assert revenue[0] == kopecks / 100 assert orders["productPrice"][number].dtype == np.int64 def test_the_rounded_price_does_not_add_up_to_the_revenue(weekday: day.Day): """Часть цен несёт копейки, и выручку по массивам события не пересобрать. Разрыв живёт внутри одного события: `productPrice` округлён форматом, `purchaseRevenue` точна. Это и есть урок про Float64 — не выдуманный. """ orders = rows_of(weekday, commerce.PURCHASE) apart = 0 for number, revenue in enumerate(orders["purchaseRevenue"]): by_arrays = float( (orders["productPrice"][number] * orders["productQuantity"][number]).sum() ) apart += by_arrays != revenue[0] assert 0.1 < apart / orders["WatchID"].size < 0.9 def test_the_order_is_narrower_than_the_cart(weekday: day.Day): """Часть положенных позиций не куплена — иначе сравнивать было бы нечего.""" carts = rows_of(weekday, commerce.ADD_TO_CART) orders = rows_of(weekday, commerce.PURCHASE) put: dict[int, set[str]] = {} for visit, article in zip( carts["VisitID"].tolist(), (cell[0] for cell in carts["productID"]), strict=True ): put.setdefault(visit, set()).add(article) dropped = whole = 0 for visit, bought in zip( orders["VisitID"].tolist(), cells(orders["productID"]), strict=True ): assert bought, "пустых заказов не бывает" assert set(bought) <= put[visit], "куплено то, чего не клали в корзину" assert len(set(bought)) == len(bought), "позиция в заказе одна на товар" dropped += len(put[visit]) - len(bought) whole += len(put[visit]) assert 0.05 < dropped / whole < 0.30 def test_the_cart_holds_only_what_the_visitor_opened(weekday: day.Day): """Товар в корзине, которого никто не открывал, — глупость в воронке.""" order = by_visit(weekday) visit = weekday.columns["VisitID"][order] kind = weekday.columns["EventType"][order] product = weekday.product[order] page = weekday.page[order] shown: dict[int, set[int]] = {} for number, this in enumerate(visit.tolist()): if kind[number] == "pageview" and page[number] == Page.PRODUCT: shown.setdefault(this, set()).add(int(product[number])) put: dict[int, list[int]] = {} for number, this in enumerate(visit.tolist()): if kind[number] == commerce.ADD_TO_CART: put.setdefault(this, []).append(int(product[number])) assert put for this, products in put.items(): assert set(products) <= shown[this] # Карточка, открытая дважды, даёт одну позицию, а не две. assert len(set(products)) == len(products) def test_a_trade_event_sits_on_the_page_that_sent_it(weekday: day.Day): """Корзина — на карточке того самого товара, покупка — на подтверждении. Событие не просто стоит после какой-то страницы: у события корзины предыдущая строка визита — карточка именно этого товара, и товар в массивах события тот же. Иначе в корзину попал бы товар, которого посетитель не открывал, а событие село бы на чужую страницу. """ order = by_visit(weekday) columns = {name: value[order] for name, value in weekday.columns.items()} page, product = weekday.page[order], weekday.product[order] kind = columns["EventType"] goods = catalog.catalog() trade = np.flatnonzero(kind != "pageview") assert trade.size > 100 for row in trade.tolist(): assert columns["VisitID"][row] == columns["VisitID"][row - 1] assert columns["URL"][row] == columns["URL"][row - 1] assert columns["Referer"][row] == columns["Referer"][row - 1] away = (columns["UTCEventTime"][row] - columns["UTCEventTime"][row - 1]).astype( "int64" ) assert 0 < away < world.TRADE_DELAY_SECONDS[1] if kind[row] == commerce.ADD_TO_CART: assert page[row] == page[row - 1] == Page.PRODUCT # Карточка предыдущей строки — карточка этого самого товара. assert product[row] == product[row - 1] >= 0 assert columns["productID"][row].tolist() == [goods.sku[product[row]]] # У страницы входа в адресе ещё метки перехода — путь до «?». address = columns["URL"][row].split("?")[0] assert address.endswith(f"/product/{goods.sku[product[row]]}") else: assert page[row] == page[row - 1] == Page.CONFIRMATION assert kind[row - 1] == "pageview" def test_the_goals_repeat_the_trade_events(weekday: day.Day): """Цели дублируют события — в бою так и бывает (мастер-спека, 1.2).""" goals = { kind: {tuple(cell.tolist()) for cell in rows_of(weekday, kind)["GoalsReached"]} for kind in ("pageview", commerce.ADD_TO_CART, commerce.PURCHASE) } assert goals["pageview"] == {()} assert goals[commerce.ADD_TO_CART] == {(world.GOAL_CART_ID,)} assert goals[commerce.PURCHASE] == {(world.GOAL_PURCHASE_ID,)} def test_the_order_number_reads_as_the_day_and_the_count(weekday: day.Day): """Номер — день и порядковый номер покупки в нём; он же `order_id` бэкенда.""" orders = rows_of(weekday, commerce.PURCHASE) numbers = [cell[0] for cell in orders["purchaseID"]] date = str(weekday.columns["EventDate"][0]).replace("-", "") assert numbers == [f"{date}-{place:04d}" for place in range(1, len(numbers) + 1)] assert len(set(numbers)) == len(numbers) # Нумерация идёт в порядке событий: строки дня уже упорядочены по времени. assert list(orders["UTCEventTime"]) == sorted(orders["UTCEventTime"]) def test_the_coupon_comes_from_the_world_table_and_does_not_touch_the_revenue( weekday: day.Day, ): """Промокод в событии есть, скидки в сумме нет: клиент шлёт сумму позиций.""" orders = rows_of(weekday, commerce.PURCHASE) known = {code for code, _ in world.COUPONS} coupons = [cell[0] for cell in orders["purchaseCoupon"]] assert set(coupons) <= known | {""} with_coupon = [code for code in coupons if code] assert 0.05 < len(with_coupon) / len(coupons) < 0.40 assert len(set(with_coupon)) > 1 # Скидка в клиентскую выручку не входит: сумма сходится с позициями и с # тем, что уехало в сыром JSON. for number, block in enumerate(raw_of(orders)): action = block[commerce.PURCHASE_ACTION]["actionField"] assert action["revenue"] == orders["purchaseRevenue"][number][0] assert ("coupon" in action) == bool(coupons[number]) def test_editing_the_trade_flow_does_not_move_the_traffic( monkeypatch: pytest.MonkeyPatch, ): """Иерархия подпотоков: у торговли своя случайность (спека, раздел 2).""" ours = day.stream(CANONICAL_SEED, WEEKDAY) plan.cohort.cache_clear() monkeypatch.setattr(world, "ABANDONED_POSITION_PERCENT", 40) other = day.stream(CANONICAL_SEED, WEEKDAY) def pageviews(events: day.Day) -> dict[str, list[Any]]: rows = rows_of(events, "pageview") return { name: [ cell.tolist() if isinstance(cell, np.ndarray) else cell for cell in value ] for name, value in rows.items() } assert pageviews(other) == pageviews(ours) assert [ cell.tolist() for cell in rows_of(other, commerce.PURCHASE)["productID"] ] != [cell.tolist() for cell in rows_of(ours, commerce.PURCHASE)["productID"]] def test_the_trade_flow_draws_from_its_own_named_branch( monkeypatch: pytest.MonkeyPatch, ): """Торговля берёт подпоток `COMMERCE` — именно его, а не любой другой. Неподвижности трафика для этого мало: возьми торговля ветвь трафика, трафик всё равно не сдвинулся бы — свои броски он сделал раньше и из своего генератора. Разошлись бы только два подпотока, ставшие одним, а в дереве зерна место каждого компонента и есть контракт (спека, раздел 2). Поэтому проверяется адрес, по которому торговля пришла. """ asked: list[tuple[int, int, Component]] = [] honest = commerce.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(commerce, "day_stream", spy) day.stream(CANONICAL_SEED, WEEKDAY) assert asked == [(CANONICAL_SEED, WEEKDAY, Component.COMMERCE)] def test_the_promised_orders_of_a_pair_become_purchases(week: list[day.Day]): """Гарантия плана: обе куки пары покупают в назначенные им дни. Проверяются пары, у которых оба назначенных дня попали внутрь недели: остальные ждут своего дня за горизонтом. """ bought = [ set(rows_of(events, commerce.PURCHASE)["ClientID"].tolist()) for events in week ] 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 for cookie, number in zip(cookies, days, strict=True): assert int(cohort.client_id[cookie]) in bought[number] assert pairs > 10 # Счётчик пар в плане и пары, реализованные в дне, — про одно и то же. assert pairs == plan.counters(CANONICAL_SEED, WEEK).pairs def paired_cookies() -> set[int]: """Куки двухкуковых пар: их заказы обещаны планом, а не решены днём.""" cookies: set[int] = set() for born in range(-world.RETURN_TAIL_DAYS, WEEK): cohort = plan.cohort(CANONICAL_SEED, born) cookies |= set(cohort.client_id[cohort.pair_cookies.ravel()].tolist()) return cookies def test_flagged_buyers_buy_again_much_more_often(week: list[day.Day]): """Метка покупателя перестала быть словом: у неё виден след в данных. «Постоянный покупатель» читается только так — как кука, которая ходит неделями и покупает не раз: метки в событии нет и не будет. Куки двухкуковых пар из счёта исключены: их заказы назначил план, и вместе с ними лифт мерил бы гарантию, а не два новых рычага. Остаются те помеченные, чьи покупки решил день: повторно покупают 4,6% из них против 1,5% прочих — втрое чаще при пороге «вдвое». """ purchases: dict[int, int] = {} flagged: set[int] = set() for number, events in enumerate(week): audience = plan.audience(CANONICAL_SEED, number) flagged |= set(audience.client_id[audience.buyer].tolist()) for cookie in rows_of(events, commerce.PURCHASE)["ClientID"].tolist(): purchases[cookie] = purchases.get(cookie, 0) + 1 def repeat_share(cookies: set[int]) -> float: again = sum(1 for cookie in cookies if purchases[cookie] > 1) return again / len(cookies) buyers = set(purchases) by_levers = (buyers & flagged) - paired_cookies() others = buyers - flagged assert len(by_levers) > 100 assert len(others) > 100 assert repeat_share(by_levers) > 2 * repeat_share(others) def test_the_shop_stays_the_same_plausible_shop(week: list[day.Day]): """Границы правки — вилки спеки (раздел 5): аудитория, объём, конверсия.""" counters = plan.counters(CANONICAL_SEED, WEEK) assert all(6_000 <= size <= 8_000 for size in counters.audience) for events in week: visits = len(set(events.columns["VisitID"].tolist())) orders = int((events.columns["EventType"] == commerce.PURCHASE).sum()) assert 40_000 < len(events) < 60_000 assert 0.015 < orders / visits < 0.035