feat(generator): торговые события — корзина, покупка, сырой ecommerce
- Зачем:
- клиентская сторона мира становится целой: без add_to_cart и purchase
в данных нет ни таксономии событий, ни вложенного JSON, ни денег,
а метка «покупатель» из плана состава ни на что не влияла (#40).
- Что:
- добавлен модуль commerce: корзина шире заказа, номер заказа вида
ГГГГММДД-NNNN, промокод без скидки в сумме, сырой ecommerce через orjson;
- метка покупателя получила два рычага — долгую жизнь куки в плане и
свою воронку в дне; CART_PERCENT опущен с 8 до 6, чтобы конверсия
мира осталась около 2%;
- часть цен каталога получила копейки: productPrice округляется форматом,
purchaseRevenue несёт точную сумму — расхождение живёт внутри события;
- граница суток забирает страницу подтверждения вместе с её покупкой:
потерь на клиентской стороне этот этап не заводит;
- решения и перемеренные числа мира записаны в спеку генератора, §9.
- Проверка:
- make lint && make typecheck && make test — 383 passed (было 353).
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -54,6 +54,18 @@ def test_every_row_is_filled_and_priced_in_whole_kopecks():
|
||||
assert low <= int(row["price"]) <= high, row["sku"]
|
||||
|
||||
|
||||
def test_part_of_the_prices_carry_kopecks():
|
||||
"""Без копеек урок про Float64 беспредметен: округлять было бы нечего.
|
||||
|
||||
Сторожится не доля, а то, на чём стоит урок: цены бывают и кратные
|
||||
рублю, и с копейками — тогда `productPrice` округляется форматом, а
|
||||
`purchaseRevenue` несёт точную сумму.
|
||||
"""
|
||||
kopecks = [int(row["price"]) % 100 for row in rows()]
|
||||
assert any(rest for rest in kopecks)
|
||||
assert any(not rest for rest in kopecks)
|
||||
|
||||
|
||||
def test_every_category_of_the_assortment_is_covered():
|
||||
"""Каталог покрывает ассортимент целиком: пустых категорий не бывает."""
|
||||
present = {row["category"] for row in rows()}
|
||||
|
||||
@@ -0,0 +1,488 @@
|
||||
"""Торговые события: корзина шире заказа, деньги целые, номера читаемые.
|
||||
|
||||
Числа мира тесты сторожат вилками спеки, а не точными значениями: менти
|
||||
крутит конфигурацию, и падать тесты должны там, где сдвинулся вывод («заказ
|
||||
уже корзины», «конверсия визита около 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
|
||||
+45
-19
@@ -1,4 +1,4 @@
|
||||
"""День-функция: чистота, форма волны, правила резки визитов, шов для #40.
|
||||
"""День-функция: чистота, форма волны, правила резки визитов, шов с торговлей.
|
||||
|
||||
Числа мира тесты сторожат вилками спеки, а не точными значениями: менти
|
||||
крутит конфигурацию, и падать тесты должны там, где сдвинулся вывод («средний
|
||||
@@ -14,7 +14,16 @@ import numpy as np
|
||||
import pytest
|
||||
from numpy.typing import NDArray
|
||||
|
||||
from clickstream_generator import catalog, day, plan, reference, schema, world
|
||||
from clickstream_generator import (
|
||||
catalog,
|
||||
commerce,
|
||||
day,
|
||||
ids,
|
||||
plan,
|
||||
reference,
|
||||
schema,
|
||||
world,
|
||||
)
|
||||
from clickstream_generator.reference import Page
|
||||
from clickstream_generator.seeds import CANONICAL_SEED
|
||||
|
||||
@@ -137,25 +146,36 @@ def test_every_column_of_the_contract_is_present_and_typed(weekday: day.Day):
|
||||
assert values.dtype == np.dtype(column.numpy_dtype), column.name
|
||||
|
||||
|
||||
def test_what_is_left_to_the_trade_ticket_is_empty_not_missing(weekday: day.Day):
|
||||
def test_a_pageview_carries_the_trade_columns_empty_not_missing(weekday: day.Day):
|
||||
"""Пусто — пустой массив и пустая строка, а ключ есть у каждого события.
|
||||
|
||||
Проверяются все колонки будущих торговых событий, а не выбранные: тикет
|
||||
#40 добавит свои, и они должны попасть под тот же сторож.
|
||||
Проверяются все торговые колонки, а не выбранные: у просмотра страницы
|
||||
пусты они все до одной, иначе строгий приём хранилища не уживётся с
|
||||
полями, пустыми по смыслу (мастер-спека, раздел 6).
|
||||
"""
|
||||
waiting = [
|
||||
pageview = weekday.columns["EventType"] == "pageview"
|
||||
assert pageview.mean() > 0.9
|
||||
trade = [
|
||||
column
|
||||
for column in schema.COLUMNS
|
||||
if column.group in (schema.ColumnGroup.ECOMMERCE, schema.ColumnGroup.PARAMS)
|
||||
]
|
||||
assert len(waiting) > 10
|
||||
for column in waiting:
|
||||
cells = weekday.columns[column.name][:1000]
|
||||
assert len(trade) > 10
|
||||
for column in trade:
|
||||
cells = weekday.columns[column.name][pageview][:1000]
|
||||
if column.clickhouse_type.startswith("Array("):
|
||||
assert all(cell.size == 0 for cell in cells), column.name
|
||||
else:
|
||||
assert set(cells) == {""}, column.name
|
||||
assert set(weekday.columns["EventType"].tolist()) == {"pageview"}
|
||||
|
||||
|
||||
def test_the_taxonomy_is_three_event_types(weekday: day.Day):
|
||||
"""`EventType` — добавка стенда: таксономия нужна явно (мастер-спека, 1.1)."""
|
||||
assert set(weekday.columns["EventType"].tolist()) == {
|
||||
"pageview",
|
||||
commerce.ADD_TO_CART,
|
||||
commerce.PURCHASE,
|
||||
}
|
||||
|
||||
|
||||
def test_no_column_hides_a_hole(weekday: day.Day):
|
||||
@@ -171,7 +191,7 @@ def test_no_column_hides_a_hole(weekday: day.Day):
|
||||
def test_identifiers_survive_json(weekday: day.Day):
|
||||
"""Числа выше 2^53 в JSON округляются — идентификаторам столько не нужно."""
|
||||
for name in ("WatchID", "VisitID", "ClientID"):
|
||||
assert weekday.columns[name].max() < day.ID_LIMIT
|
||||
assert weekday.columns[name].max() < ids.LIMIT
|
||||
assert weekday.columns[name].dtype == np.uint64
|
||||
|
||||
|
||||
@@ -229,7 +249,7 @@ def test_the_counter_timezone_moves_the_date_apart_from_utc(weekday: day.Day):
|
||||
|
||||
|
||||
def test_the_scale_of_an_average_day_is_about_fifty_thousand(weekday: day.Day):
|
||||
"""Порядок величины (спека, раздел 5); итог сложится после #40."""
|
||||
"""Порядок величины: ~50 тыс. событий в средний день (спека, раздел 5)."""
|
||||
assert 30_000 < len(weekday) < 70_000
|
||||
visits = len(set(weekday.columns["VisitID"].tolist()))
|
||||
assert 6_000 < visits < 14_000
|
||||
@@ -256,7 +276,7 @@ def test_the_weekend_is_shaped_unlike_a_weekday(weekday: day.Day):
|
||||
|
||||
def test_the_funnel_converts_about_two_percent_of_visits(weekday: day.Day):
|
||||
visits = len(set(weekday.columns["VisitID"].tolist()))
|
||||
ordered = int((weekday.page == Page.CONFIRMATION).sum())
|
||||
ordered = int((weekday.columns["EventType"] == commerce.PURCHASE).sum())
|
||||
assert 0.01 < ordered / visits < 0.04
|
||||
|
||||
|
||||
@@ -270,7 +290,7 @@ def test_the_promised_orders_reach_the_confirmation(weekday: day.Day):
|
||||
|
||||
|
||||
def test_the_cart_always_follows_a_product_card(weekday: day.Day):
|
||||
"""Шов для #40: товар в корзине посетитель до того открывал."""
|
||||
"""Шов с торговлей: товар в корзине посетитель до того открывал."""
|
||||
order = np.lexsort((local_seconds(weekday), weekday.columns["VisitID"]))
|
||||
by_visit = weekday.page[order]
|
||||
carts = np.flatnonzero(by_visit == Page.CART)
|
||||
@@ -292,11 +312,17 @@ def test_the_product_of_a_card_is_the_seam_for_trade_events(weekday: day.Day):
|
||||
|
||||
|
||||
def test_the_referer_is_the_page_before(weekday: day.Day):
|
||||
"""Внутри визита реферер — предыдущий адрес; на входе — адрес источника."""
|
||||
order = np.lexsort((local_seconds(weekday), weekday.columns["VisitID"]))
|
||||
url = weekday.columns["URL"][order]
|
||||
referer = weekday.columns["Referer"][order]
|
||||
visit = weekday.columns["VisitID"][order]
|
||||
"""Внутри визита реферер — предыдущий адрес; на входе — адрес источника.
|
||||
|
||||
Цепочка считается по просмотрам страниц: торговое событие не открывает
|
||||
страницу, а живёт на уже открытой, и реферер у него тот же, что у неё.
|
||||
"""
|
||||
pageview = weekday.columns["EventType"] == "pageview"
|
||||
columns = {name: value[pageview] for name, value in weekday.columns.items()}
|
||||
order = np.lexsort((columns["UTCEventTime"], columns["VisitID"]))
|
||||
url = columns["URL"][order]
|
||||
referer = columns["Referer"][order]
|
||||
visit = columns["VisitID"][order]
|
||||
inside = np.flatnonzero(visit[1:] == visit[:-1]) + 1
|
||||
assert np.all(referer[inside] == url[inside - 1])
|
||||
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
"""Идентификаторы событий: неповторяющиеся и переживающие JSON.
|
||||
|
||||
Обещание одно, но держит его дедупликация при переигровке дня: два
|
||||
одинаковых `WatchID` склеили бы разные события (спека, раздел 4).
|
||||
"""
|
||||
|
||||
import numpy as np
|
||||
|
||||
from clickstream_generator import ids
|
||||
from clickstream_generator.seeds import CANONICAL_SEED
|
||||
|
||||
|
||||
def stream(offset: int = 0) -> np.random.Generator:
|
||||
return np.random.Generator(np.random.PCG64(CANONICAL_SEED + offset))
|
||||
|
||||
|
||||
def test_ids_do_not_repeat_and_survive_json():
|
||||
numbers = ids.unique(stream(), 10_000)
|
||||
assert numbers.dtype == np.uint64
|
||||
assert len(set(numbers.tolist())) == numbers.size
|
||||
assert numbers.max() < ids.LIMIT
|
||||
assert numbers.min() > 0
|
||||
|
||||
|
||||
def test_ids_do_not_give_away_the_order_of_the_rows():
|
||||
"""Иначе номер события рассказывал бы, каким по счёту оно родилось."""
|
||||
numbers = ids.unique(stream(), 1_000)
|
||||
assert not np.all(np.diff(numbers.astype(np.int64)) > 0)
|
||||
|
||||
|
||||
def test_ids_of_another_stream_are_other_ids():
|
||||
assert not np.array_equal(ids.unique(stream(), 100), ids.unique(stream(1), 100))
|
||||
|
||||
|
||||
def test_ids_can_be_drawn_beside_the_ones_already_taken():
|
||||
"""Торговые строки берут номера из своего подпотока и не задевают чужие."""
|
||||
taken = ids.unique(stream(), 5_000)
|
||||
numbers = ids.unique_apart_from(stream(1), 500, taken)
|
||||
assert len(set(numbers.tolist())) == numbers.size
|
||||
assert not set(numbers.tolist()) & set(taken.tolist())
|
||||
assert numbers.max() < ids.LIMIT
|
||||
@@ -15,6 +15,13 @@ def mean_by_weights(values: tuple[int, ...], weights: tuple[int, ...]) -> float:
|
||||
return weighted / sum(weights)
|
||||
|
||||
|
||||
def active_days(one_shot_percent: int, return_weights: tuple[int, ...]) -> float:
|
||||
"""Сколько дней в среднем ходит кука: день рождения плюс возвраты."""
|
||||
counts = tuple(range(1, len(return_weights) + 1))
|
||||
returns = mean_by_weights(counts, return_weights)
|
||||
return 1 + (100 - one_shot_percent) / 100 * returns
|
||||
|
||||
|
||||
def test_origin_is_a_monday():
|
||||
"""День недели считается как остаток номера дня — это верно от понедельника."""
|
||||
assert world.ORIGIN.weekday() == 0
|
||||
@@ -36,10 +43,7 @@ def test_returns_average_three_to_four():
|
||||
|
||||
def test_average_cookie_is_active_about_two_days():
|
||||
"""Сходимость с разделом 5: ≈1,9 активного дня на куку — отсюда 6–8 тыс."""
|
||||
counts = tuple(range(1, len(world.RETURN_COUNT_WEIGHTS) + 1))
|
||||
returns = mean_by_weights(counts, world.RETURN_COUNT_WEIGHTS)
|
||||
active_days = 1 + (100 - world.ONE_SHOT_PERCENT) / 100 * returns
|
||||
assert 1.8 <= active_days <= 2.0
|
||||
assert 1.8 <= active_days(world.ONE_SHOT_PERCENT, world.RETURN_COUNT_WEIGHTS) <= 2.0
|
||||
|
||||
|
||||
def test_return_delays_cover_the_whole_activity_window():
|
||||
@@ -108,12 +112,50 @@ def test_pauses_stay_inside_the_visit_timeout():
|
||||
assert max(world.LONG_PAUSE_SECONDS) < world.VISIT_TIMEOUT_SECONDS
|
||||
|
||||
|
||||
def test_the_funnel_converts_about_two_percent_of_visits():
|
||||
"""Конверсия ~2% на сессию (спека, раздел 9) — произведение трёх шагов."""
|
||||
conversion = (
|
||||
world.CART_PERCENT
|
||||
* world.CHECKOUT_OF_CART_PERCENT
|
||||
* world.CONFIRMATION_OF_CHECKOUT_PERCENT
|
||||
/ 100**2
|
||||
)
|
||||
assert 1.5 <= conversion <= 2.5
|
||||
def test_the_trade_event_never_overtakes_the_next_page():
|
||||
"""Торговое событие отстаёт от своей страницы меньше, чем длится пауза.
|
||||
|
||||
На этом стоят два свойства сразу: событие корзины не обгоняет страницу,
|
||||
на которой посетитель нажал кнопку, и не выходит за полночь — за ним в
|
||||
том же визите всегда идёт страница, которая полночь пережила.
|
||||
"""
|
||||
assert max(world.TRADE_DELAY_SECONDS) <= min(world.PAGE_PAUSE_SECONDS)
|
||||
|
||||
|
||||
def conversion(cart: int, checkout_of_cart: int) -> float:
|
||||
"""Конверсия визита в заказ, проценты: произведение трёх шагов воронки."""
|
||||
return cart * checkout_of_cart * world.CONFIRMATION_OF_CHECKOUT_PERCENT / 100**2
|
||||
|
||||
|
||||
def test_the_ordinary_visitor_converts_a_bit_below_the_world():
|
||||
"""Воронка обычного посетителя: ~1,5% визитов в оформленный заказ.
|
||||
|
||||
До ~2% конверсию мира (спека, раздел 9) добирают помеченные покупатели
|
||||
и обещанные планом заказы пар, поэтому здесь сторожится слагаемое, а не
|
||||
итог: сам итог меряет день-функция на собранном дне
|
||||
(`test_the_shop_stays_the_same_plausible_shop`).
|
||||
"""
|
||||
assert 1.2 <= conversion(world.CART_PERCENT, world.CHECKOUT_OF_CART_PERCENT) <= 1.8
|
||||
|
||||
|
||||
def test_the_buyer_mark_shows_on_both_steps_of_the_funnel():
|
||||
"""Один шаг дал бы половину картины: кладут реже и бросают чаще оба."""
|
||||
assert world.BUYER_CART_PERCENT > world.CART_PERCENT
|
||||
assert world.BUYER_CHECKOUT_OF_CART_PERCENT > world.CHECKOUT_OF_CART_PERCENT
|
||||
lift = conversion(
|
||||
world.BUYER_CART_PERCENT, world.BUYER_CHECKOUT_OF_CART_PERCENT
|
||||
) / conversion(world.CART_PERCENT, world.CHECKOUT_OF_CART_PERCENT)
|
||||
# Заметно чаще прочих, но не «покупают только помеченные»: 5% людей дали
|
||||
# бы тогда около 40 заказов в день вместо 240 (спека, раздел 9).
|
||||
assert 2 <= lift <= 6
|
||||
|
||||
|
||||
def test_the_buyer_mark_makes_the_cookie_live_longer():
|
||||
"""Второй рычаг метки: без долгой жизни второй покупке негде случиться."""
|
||||
assert world.BUYER_ONE_SHOT_PERCENT < world.ONE_SHOT_PERCENT
|
||||
usual = active_days(world.ONE_SHOT_PERCENT, world.RETURN_COUNT_WEIGHTS)
|
||||
buyer = active_days(world.BUYER_ONE_SHOT_PERCENT, world.BUYER_RETURN_COUNT_WEIGHTS)
|
||||
assert buyer > 2 * usual
|
||||
# Но не бессмертие: дневная аудитория остаётся в вилке 6–8 тыс., а
|
||||
# помеченных всего 5% людей когорты.
|
||||
assert buyer < world.RETURN_TAIL_DAYS / 10
|
||||
|
||||
Reference in New Issue
Block a user