feat(generator): день-функция — трафик, визиты и просмотры страниц
Зачем: план состава отдаёт дневную аудиторию, но событий у мира ещё не было. День-функция превращает аудиторию в поток просмотров — на нём стоят лабы про сборку визитов и про витрины, а следующий этап вешает на него торговые события. Что: - `day.py` — день как чистая функция зерна и номера дня: суточная волна в местном времени посетителя, визиты по документированным правилам нарезки, все 47 колонок выгрузки; шов для торговых событий — ряды `page` и `product`; - `reference.py` — справочники-литералы: профили устройств, города Поволжья с настоящими гео-id Яндекса, источники трафика, карта сайта; - `catalog.py` и `data/catalog/products.csv` — каталог на 180 позиций, общий у генератора и будущего словаря ClickHouse; - `weights.py` — выбор по целым весам, один на план и на день; - паспорт куки (устройство и город) переехал в план состава; броски приписаны последними, поэтому измеренные числа канонического мира не сдвинулись; - словарь: «визит» закреплён за сессией, одноимённое понятие плана стало «днём активности»; статьи в `CONTEXT.md`; - решения по ходу — в спеку генератора, раздел 9; наполнение `ParsedParamsKey1` отложено тикетом #47. Проверка: `make lint`, `make typecheck`, `make test` — 353 passed (было 297). Счётчики плана после правки те же: приток 3827,64/день, дневная аудитория 6235–7124, 68 119 посетителей за 14 дней, 170 двухкуковых пар. День 0 — 45 810 событий за 0,6 с, снимок 14 дней — 5,9 с при пороге 30 с на день. Две слепые линии ревью, десять находок, все закрыты и перепроверены. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,79 @@
|
||||
"""Каталог товаров: форма файла, а не его длина.
|
||||
|
||||
Файл дорастает механически, поэтому ни один тест не считает его строки и
|
||||
не знает ни одного артикула наизусть. Сторожится ровно то, на что опираются
|
||||
генератор и словарь ClickHouse: колонки, вид артикула, известные категории,
|
||||
целая цена.
|
||||
"""
|
||||
|
||||
import csv
|
||||
import re
|
||||
|
||||
import numpy as np
|
||||
|
||||
from clickstream_generator import catalog
|
||||
|
||||
SKU = re.compile(r"^[A-Z]{4}-\d{4}$")
|
||||
|
||||
# Вилка цены, копейки: от сотни рублей до полумиллиона. Сторож от нуля,
|
||||
# от минуса и от цены, случайно записанной в рублях.
|
||||
PRICE_RANGE = (10_000, 50_000_000)
|
||||
|
||||
|
||||
def rows() -> list[dict[str, str]]:
|
||||
with catalog.CATALOG_PATH.open(encoding="utf-8", newline="") as source:
|
||||
return list(csv.DictReader(source))
|
||||
|
||||
|
||||
def test_the_file_has_the_columns_the_dictionary_expects():
|
||||
with catalog.CATALOG_PATH.open(encoding="utf-8", newline="") as source:
|
||||
assert next(csv.reader(source)) == list(catalog.COLUMNS)
|
||||
|
||||
|
||||
def test_the_catalog_is_not_empty():
|
||||
assert rows()
|
||||
|
||||
|
||||
def test_every_article_is_unique_and_named_by_its_category():
|
||||
known = {category.name: category.prefix for category in catalog.CATEGORIES}
|
||||
articles = set()
|
||||
for row in rows():
|
||||
sku = row["sku"]
|
||||
assert SKU.match(sku), sku
|
||||
assert sku.split("-")[0] == known[row["category"]], sku
|
||||
articles.add(sku)
|
||||
assert len(articles) == len(rows())
|
||||
|
||||
|
||||
def test_every_row_is_filled_and_priced_in_whole_kopecks():
|
||||
low, high = PRICE_RANGE
|
||||
for row in rows():
|
||||
assert row["name"].strip()
|
||||
assert row["brand"].strip()
|
||||
assert row["price"].isdigit(), row["price"]
|
||||
assert low <= int(row["price"]) <= high, row["sku"]
|
||||
|
||||
|
||||
def test_every_category_of_the_assortment_is_covered():
|
||||
"""Каталог покрывает ассортимент целиком: пустых категорий не бывает."""
|
||||
present = {row["category"] for row in rows()}
|
||||
assert present == {category.name for category in catalog.CATEGORIES}
|
||||
|
||||
|
||||
def test_categories_are_told_apart_by_prefix_and_by_address():
|
||||
prefixes = {category.prefix for category in catalog.CATEGORIES}
|
||||
slugs = {category.slug for category in catalog.CATEGORIES}
|
||||
assert len(prefixes) == len(slugs) == len(catalog.CATEGORIES)
|
||||
|
||||
|
||||
def test_the_catalog_is_grouped_by_category_whatever_the_file_order():
|
||||
goods = catalog.catalog()
|
||||
assert goods.count.sum() == goods.sku.size
|
||||
for number in range(len(catalog.CATEGORIES)):
|
||||
first = goods.first[number]
|
||||
rows_here = goods.grouped[first : first + goods.count[number]]
|
||||
assert np.all(goods.category[rows_here] == number)
|
||||
|
||||
|
||||
def test_the_catalog_is_read_once():
|
||||
assert catalog.catalog() is catalog.catalog()
|
||||
@@ -0,0 +1,357 @@
|
||||
"""День-функция: чистота, форма волны, правила резки визитов, шов для #40.
|
||||
|
||||
Числа мира тесты сторожат вилками спеки, а не точными значениями: менти
|
||||
крутит конфигурацию, и падать тесты должны там, где сдвинулся вывод («средний
|
||||
день ~50 тыс. событий», «ночью провал, вечером пик»), а не при каждой правке.
|
||||
"""
|
||||
|
||||
import inspect
|
||||
import re
|
||||
from dataclasses import replace
|
||||
from typing import Any
|
||||
|
||||
import numpy as np
|
||||
import pytest
|
||||
from numpy.typing import NDArray
|
||||
|
||||
from clickstream_generator import catalog, day, plan, reference, schema, world
|
||||
from clickstream_generator.reference import Page
|
||||
from clickstream_generator.seeds import CANONICAL_SEED
|
||||
|
||||
WEEKDAY = 2
|
||||
WEEKEND = 5
|
||||
|
||||
|
||||
@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)
|
||||
|
||||
|
||||
def readable(column: NDArray[Any]) -> list[Any]:
|
||||
"""Колонка в сравнимом виде: массив внутри ячейки — в список."""
|
||||
if column.dtype == object:
|
||||
return [
|
||||
cell.tolist() if isinstance(cell, np.ndarray) else cell for cell in column
|
||||
]
|
||||
return column.tolist()
|
||||
|
||||
|
||||
def snapshot(events: day.Day) -> dict[str, Any]:
|
||||
"""Слепок дня для сравнений: всё, что день отдал наружу.
|
||||
|
||||
Порядок колонок в слепке живёт отдельным списком: словари сравниваются
|
||||
без оглядки на него, а порядок — часть обещания (он же порядок
|
||||
контракта схемы). Швы `page` и `product` — тоже часть отдаваемого, и
|
||||
сторожить их надо тем же слепком, а не отдельной памятью.
|
||||
"""
|
||||
return {
|
||||
"order": list(events.columns),
|
||||
"values": [readable(column) for column in events.columns.values()],
|
||||
"page": events.page.tolist(),
|
||||
"product": events.product.tolist(),
|
||||
}
|
||||
|
||||
|
||||
def local_seconds(events: day.Day) -> NDArray[np.int64]:
|
||||
"""Секунда события внутри модельных суток — в поясе счётчика."""
|
||||
midnight = np.datetime64(world.ORIGIN, "s") + np.timedelta64(events.day, "D")
|
||||
away = np.timedelta64(world.COUNTER_TIMEZONE_MINUTES, "m")
|
||||
return (events.columns["UTCEventTime"] - midnight + away).astype("int64")
|
||||
|
||||
|
||||
def hourly(events: day.Day) -> NDArray[np.int64]:
|
||||
return np.bincount(local_seconds(events) // 3600, minlength=24)
|
||||
|
||||
|
||||
def test_the_snapshot_notices_everything_the_day_hands_out(weekday: day.Day):
|
||||
"""Сторож сторожей: слепок обязан замечать порядок колонок и швы.
|
||||
|
||||
Слепок слепой к чему-нибудь — это три зелёных теста детерминизма при
|
||||
поехавшем выводе, а не одна пропущенная мелочь.
|
||||
"""
|
||||
original = snapshot(weekday)
|
||||
reordered = day.Day(
|
||||
day=weekday.day,
|
||||
columns=dict(reversed(list(weekday.columns.items()))),
|
||||
page=weekday.page,
|
||||
product=weekday.product,
|
||||
)
|
||||
assert snapshot(reordered) != original
|
||||
|
||||
moved = weekday.product.copy()
|
||||
moved[0] += 1
|
||||
assert snapshot(replace(weekday, product=moved)) != original
|
||||
shifted = weekday.page.copy()
|
||||
shifted[0] += 1
|
||||
assert snapshot(replace(weekday, page=shifted)) != original
|
||||
|
||||
|
||||
def test_a_day_is_a_pure_function_of_the_seed_and_the_day():
|
||||
first = snapshot(day.stream(CANONICAL_SEED, 3))
|
||||
plan.cohort.cache_clear()
|
||||
assert snapshot(day.stream(CANONICAL_SEED, 3)) == first
|
||||
|
||||
|
||||
def test_a_day_generated_alone_is_the_same_day():
|
||||
"""День D не зависит от того, прожиты ли дни до него."""
|
||||
alone = snapshot(day.stream(CANONICAL_SEED, 3))
|
||||
plan.cohort.cache_clear()
|
||||
for earlier in range(3):
|
||||
day.stream(CANONICAL_SEED, earlier)
|
||||
assert snapshot(day.stream(CANONICAL_SEED, 3)) == alone
|
||||
|
||||
|
||||
def test_the_next_day_does_not_move_the_days_before_it():
|
||||
before = [snapshot(day.stream(CANONICAL_SEED, number)) for number in range(2)]
|
||||
day.stream(CANONICAL_SEED, 2)
|
||||
assert [snapshot(day.stream(CANONICAL_SEED, n)) for n in range(2)] == before
|
||||
|
||||
|
||||
def test_another_seed_is_another_day():
|
||||
ours = snapshot(day.stream(CANONICAL_SEED, 1))
|
||||
assert snapshot(day.stream(CANONICAL_SEED + 1, 1)) != ours
|
||||
|
||||
|
||||
def test_events_do_not_start_before_the_origin():
|
||||
with pytest.raises(ValueError):
|
||||
day.stream(CANONICAL_SEED, -1)
|
||||
|
||||
|
||||
def test_every_column_of_the_contract_is_present_and_typed(weekday: day.Day):
|
||||
"""Состав колонок решает контракт схемы; день обязан отдать их все."""
|
||||
assert list(weekday.columns) == [column.name for column in schema.COLUMNS]
|
||||
for column in schema.COLUMNS:
|
||||
values = weekday.columns[column.name]
|
||||
assert values.size == len(weekday), column.name
|
||||
if column.clickhouse_type.startswith("Array("):
|
||||
cells = {cell.dtype for cell in values[:1000]}
|
||||
assert cells == {np.dtype(column.numpy_dtype)}, column.name
|
||||
else:
|
||||
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):
|
||||
"""Пусто — пустой массив и пустая строка, а ключ есть у каждого события.
|
||||
|
||||
Проверяются все колонки будущих торговых событий, а не выбранные: тикет
|
||||
#40 добавит свои, и они должны попасть под тот же сторож.
|
||||
"""
|
||||
waiting = [
|
||||
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]
|
||||
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_no_column_hides_a_hole(weekday: day.Day):
|
||||
"""`None` в колонке — та же пропажа ключа, только позже и незаметнее."""
|
||||
for column in schema.COLUMNS:
|
||||
values = weekday.columns[column.name]
|
||||
if values.dtype != object:
|
||||
continue
|
||||
kind = np.ndarray if column.clickhouse_type.startswith("Array(") else str
|
||||
assert all(isinstance(cell, kind) for cell in values[:1000]), column.name
|
||||
|
||||
|
||||
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].dtype == np.uint64
|
||||
|
||||
|
||||
def test_the_event_id_is_unique_because_it_deduplicates(weekday: day.Day):
|
||||
"""`WatchID` — ключ склейки при переигровке дня (спека, раздел 4)."""
|
||||
watch = weekday.columns["WatchID"]
|
||||
assert len(set(watch.tolist())) == watch.size
|
||||
|
||||
|
||||
def test_a_visit_belongs_to_one_cookie(weekday: day.Day):
|
||||
visit = weekday.columns["VisitID"]
|
||||
cookie = weekday.columns["ClientID"]
|
||||
pairs = set(zip(visit.tolist(), cookie.tolist(), strict=True))
|
||||
assert len(pairs) == len(set(visit.tolist()))
|
||||
|
||||
|
||||
def test_the_visit_cutting_rules_rebuild_the_generator_visits(weekday: day.Day):
|
||||
"""Правило лабы: та же кука, пауза не длиннее таймаута — тот же визит.
|
||||
|
||||
Сборка сессий по правилам из докстринга модуля обязана совпасть с
|
||||
`VisitID` — на этой сверке стоит лаба сессий.
|
||||
"""
|
||||
second = local_seconds(weekday)
|
||||
cookie = weekday.columns["ClientID"]
|
||||
order = np.lexsort((second, cookie))
|
||||
cookie, second = cookie[order], second[order]
|
||||
visit = weekday.columns["VisitID"][order]
|
||||
|
||||
started = np.ones(visit.size, dtype=bool)
|
||||
started[1:] = (cookie[1:] != cookie[:-1]) | (
|
||||
second[1:] - second[:-1] > world.VISIT_TIMEOUT_SECONDS
|
||||
)
|
||||
rebuilt = np.cumsum(started)
|
||||
# Совпадение обоюдное: сколько пар «собранный визит — `VisitID`», столько
|
||||
# же и тех, и других. Одного равенства мало — оно ловит только склейку
|
||||
# двух визитов в один, а разрыв одного визита надвое проходит мимо.
|
||||
matched = set(zip(rebuilt.tolist(), visit.tolist(), strict=True))
|
||||
assert len(matched) == int(rebuilt.max())
|
||||
assert len(matched) == len(set(visit.tolist()))
|
||||
|
||||
|
||||
def test_the_day_boundary_cuts_the_visits(weekday: day.Day):
|
||||
"""Событий за границей модельных суток в дне нет — партиция дня целая."""
|
||||
second = local_seconds(weekday)
|
||||
assert second.min() >= 0
|
||||
assert second.max() < day.DAY_SECONDS
|
||||
date = np.datetime64(world.ORIGIN, "D") + np.timedelta64(weekday.day, "D")
|
||||
assert set(weekday.columns["EventDate"].tolist()) == {date.astype("O")}
|
||||
|
||||
|
||||
def test_the_counter_timezone_moves_the_date_apart_from_utc(weekday: day.Day):
|
||||
"""`toDate(UTCEventTime)` ≠ `EventDate`: сутки считаются в поясе счётчика."""
|
||||
utc_date = weekday.columns["UTCEventTime"].astype("datetime64[D]")
|
||||
assert np.any(utc_date != weekday.columns["EventDate"])
|
||||
|
||||
|
||||
def test_the_scale_of_an_average_day_is_about_fifty_thousand(weekday: day.Day):
|
||||
"""Порядок величины (спека, раздел 5); итог сложится после #40."""
|
||||
assert 30_000 < len(weekday) < 70_000
|
||||
visits = len(set(weekday.columns["VisitID"].tolist()))
|
||||
assert 6_000 < visits < 14_000
|
||||
|
||||
|
||||
def test_the_wave_dips_at_night_and_peaks_in_the_evening(weekday: day.Day):
|
||||
counters = hourly(weekday)
|
||||
average = counters.mean()
|
||||
assert counters[2:6].max() < average / 2
|
||||
assert counters[18:22].max() > 1.5 * average
|
||||
assert 0 <= int(counters.argmin()) <= 6
|
||||
assert 11 <= int(counters.argmax()) <= 22
|
||||
|
||||
|
||||
def test_the_weekend_is_shaped_unlike_a_weekday(weekday: day.Day):
|
||||
"""Форма, а не объём: выходной раскачивается позже буднего."""
|
||||
weekend = hourly(day.stream(CANONICAL_SEED, WEEKEND))
|
||||
workday = hourly(weekday)
|
||||
morning = slice(7, 10)
|
||||
assert weekend[morning].sum() / weekend.sum() < (
|
||||
workday[morning].sum() / workday.sum()
|
||||
)
|
||||
|
||||
|
||||
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())
|
||||
assert 0.01 < ordered / visits < 0.04
|
||||
|
||||
|
||||
def test_the_promised_orders_reach_the_confirmation(weekday: day.Day):
|
||||
"""Гарантия двухкуковых пар: назначенный планом заказ обязан состояться."""
|
||||
audience = plan.audience(CANONICAL_SEED, weekday.day)
|
||||
promised = set(audience.client_id[audience.assigned_order].tolist())
|
||||
assert promised
|
||||
confirmed = weekday.columns["ClientID"][weekday.page == Page.CONFIRMATION]
|
||||
assert promised <= set(confirmed.tolist())
|
||||
|
||||
|
||||
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)
|
||||
assert carts.size > 0
|
||||
assert np.all(by_visit[carts - 1] == Page.PRODUCT)
|
||||
|
||||
|
||||
def test_the_product_of_a_card_is_the_seam_for_trade_events(weekday: day.Day):
|
||||
"""`product` — товар карточки и −1 у прочих страниц; больше ничего."""
|
||||
goods = catalog.catalog()
|
||||
card = weekday.page == Page.PRODUCT
|
||||
assert np.all(weekday.product[~card] == -1)
|
||||
assert np.all(weekday.product[card] >= 0)
|
||||
assert np.all(weekday.product[card] < goods.sku.size)
|
||||
shown = zip(weekday.columns["URL"][card], weekday.product[card], strict=True)
|
||||
for url, number in list(shown)[:200]:
|
||||
# У страницы входа в адресе ещё метки перехода — путь до знака «?».
|
||||
assert url.split("?")[0].endswith(f"/product/{goods.sku[number]}")
|
||||
|
||||
|
||||
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]
|
||||
inside = np.flatnonzero(visit[1:] == visit[:-1]) + 1
|
||||
assert np.all(referer[inside] == url[inside - 1])
|
||||
|
||||
entry = np.flatnonzero(visit[1:] != visit[:-1]) + 1
|
||||
known = {source.referer for source in reference.TRAFFIC_SOURCES}
|
||||
assert set(referer[entry].tolist()) <= known
|
||||
|
||||
|
||||
def test_the_passport_of_a_cookie_does_not_change_between_days():
|
||||
"""Кука — браузер на устройстве: во всех её днях профиль и город одни."""
|
||||
passports: dict[int, tuple[Any, ...]] = {}
|
||||
for number in (0, 1, 2):
|
||||
events = day.stream(CANONICAL_SEED, number)
|
||||
seen = zip(
|
||||
events.columns["ClientID"].tolist(),
|
||||
events.columns["Browser"].tolist(),
|
||||
events.columns["ScreenWidth"].tolist(),
|
||||
events.columns["RegionCity"].tolist(),
|
||||
strict=True,
|
||||
)
|
||||
for cookie, *passport in seen:
|
||||
assert passports.setdefault(cookie, tuple(passport)) == tuple(passport)
|
||||
|
||||
|
||||
def test_geography_is_one_region_of_presence(weekday: day.Day):
|
||||
"""Магазин с одним складом: свой миллионник, свой регион, тонкий хвост."""
|
||||
cities = weekday.columns["RegionCity"]
|
||||
assert (cities == "Samara").mean() > 0.25
|
||||
assert set(weekday.columns["RegionCountry"].tolist()) == {reference.COUNTRY_NAME}
|
||||
assert set(weekday.columns["RegionCountryID"].tolist()) == {
|
||||
reference.COUNTRY_REGION_ID
|
||||
}
|
||||
|
||||
|
||||
def test_addresses_are_not_routable(weekday: day.Day):
|
||||
"""Правдоподобные публичные адреса принадлежат живым организациям."""
|
||||
fixed = ("192.0.2.", "198.51.100.", "203.0.113.", "198.18.")
|
||||
|
||||
def cgnat(address: str) -> bool:
|
||||
first, second, *_ = (int(byte) for byte in address.split("."))
|
||||
return first == reference.MOBILE_IP_FIRST_BYTE and 64 <= second < 128
|
||||
|
||||
for address in set(weekday.columns["IPAddress"].tolist()):
|
||||
assert address.startswith(fixed) or cgnat(address), address
|
||||
|
||||
|
||||
def test_phones_carry_a_model_and_desktops_do_not(weekday: day.Day):
|
||||
phone = weekday.columns["DeviceCategory"] == reference.PHONE_CATEGORY
|
||||
assert phone.mean() > 0.5
|
||||
assert np.all(weekday.columns["MobilePhoneModel"][~phone] == "")
|
||||
assert np.all(weekday.columns["MobilePhoneModel"][phone] != "")
|
||||
|
||||
|
||||
def test_randomness_is_drawn_in_whole_numbers():
|
||||
"""Дисциплина спеки (раздел 2): целые числа, никакой системной математики."""
|
||||
source = inspect.getsource(day)
|
||||
assert set(re.findall(r"\brng\.(\w+)", source)) <= {"integers"}
|
||||
assert not re.search(r"^\s*import (random|math)\b", source, re.MULTILINE)
|
||||
@@ -11,7 +11,7 @@ import re
|
||||
import numpy as np
|
||||
import pytest
|
||||
|
||||
from clickstream_generator import plan, world
|
||||
from clickstream_generator import plan, reference, world
|
||||
from clickstream_generator.seeds import CANONICAL_SEED
|
||||
|
||||
# Горизонт эталонного снимка — две недели (спека, раздел 5).
|
||||
@@ -24,9 +24,9 @@ def fresh_memo():
|
||||
plan.cohort.cache_clear()
|
||||
|
||||
|
||||
def visits_of(cohort: plan.Cohort) -> list[tuple[int, int]]:
|
||||
"""Таблица визитов парами «кука — день», как её видит день-функция."""
|
||||
cookies, days = cohort.visit_cookie.tolist(), cohort.visit_day.tolist()
|
||||
def active_days_of(cohort: plan.Cohort) -> list[tuple[int, int]]:
|
||||
"""Таблица парами «кука — день активности», как её видит день-функция."""
|
||||
cookies, days = cohort.active_cookie.tolist(), cohort.active_day.tolist()
|
||||
return list(zip(cookies, days, strict=True))
|
||||
|
||||
|
||||
@@ -37,10 +37,12 @@ def same_cohort(left: plan.Cohort, right: plan.Cohort) -> bool:
|
||||
and np.array_equal(left.client_id, right.client_id)
|
||||
and np.array_equal(left.buyer, right.buyer)
|
||||
and np.array_equal(left.birth_day, right.birth_day)
|
||||
and np.array_equal(left.visit_cookie, right.visit_cookie)
|
||||
and np.array_equal(left.visit_day, right.visit_day)
|
||||
and np.array_equal(left.active_cookie, right.active_cookie)
|
||||
and np.array_equal(left.active_day, right.active_day)
|
||||
and np.array_equal(left.pair_cookies, right.pair_cookies)
|
||||
and np.array_equal(left.pair_order_days, right.pair_order_days)
|
||||
and np.array_equal(left.device, right.device)
|
||||
and np.array_equal(left.city, right.city)
|
||||
)
|
||||
|
||||
|
||||
@@ -92,25 +94,25 @@ def test_events_do_not_start_before_the_origin():
|
||||
|
||||
|
||||
@pytest.mark.parametrize("day", [-world.RETURN_TAIL_DAYS, -1, 0, 6, 13])
|
||||
def test_visits_stay_inside_the_activity_window(day: int):
|
||||
"""Окно активности — хвост возвратов от первого визита человека."""
|
||||
def test_active_days_stay_inside_the_activity_window(day: int):
|
||||
"""Окно активности — хвост возвратов от первого дня человека."""
|
||||
cohort = plan.cohort(CANONICAL_SEED, day)
|
||||
assert cohort.visit_day.min() == day
|
||||
assert cohort.visit_day.max() <= day + world.RETURN_TAIL_DAYS
|
||||
assert cohort.active_day.min() == day
|
||||
assert cohort.active_day.max() <= day + world.RETURN_TAIL_DAYS
|
||||
|
||||
|
||||
def test_every_cookie_visits_on_the_day_it_was_born():
|
||||
def test_every_cookie_is_active_on_the_day_it_was_born():
|
||||
cohort = plan.cohort(CANONICAL_SEED, 0)
|
||||
cookies = range(cohort.client_id.size)
|
||||
born = zip(cookies, cohort.birth_day.tolist(), strict=True)
|
||||
assert set(born) <= set(visits_of(cohort))
|
||||
assert set(born) <= set(active_days_of(cohort))
|
||||
|
||||
|
||||
def test_a_cookie_visits_a_day_once():
|
||||
def test_a_cookie_gets_one_active_day_at_a_time():
|
||||
cohort = plan.cohort(CANONICAL_SEED, 0)
|
||||
visits = visits_of(cohort)
|
||||
assert visits == sorted(visits)
|
||||
assert len(set(visits)) == len(visits)
|
||||
days = active_days_of(cohort)
|
||||
assert days == sorted(days)
|
||||
assert len(set(days)) == len(days)
|
||||
|
||||
|
||||
def test_client_ids_are_unique_and_survive_json():
|
||||
@@ -144,16 +146,44 @@ def test_pairs_are_the_agreed_share_of_buyers():
|
||||
|
||||
@pytest.mark.parametrize("day", [-world.RETURN_TAIL_DAYS, -20, 0, 5])
|
||||
def test_every_pair_orders_from_both_cookies_on_the_axis(day: int):
|
||||
"""Гарантия двухкуковых: заказ назначен на день визита куки, не раньше D0."""
|
||||
"""Гарантия двухкуковых: заказ назначен на день активности куки, от D0."""
|
||||
cohort = plan.cohort(CANONICAL_SEED, day)
|
||||
visits = set(visits_of(cohort))
|
||||
days_of = set(active_days_of(cohort))
|
||||
for cookies, days in zip(
|
||||
cohort.pair_cookies.tolist(), cohort.pair_order_days.tolist(), strict=True
|
||||
):
|
||||
assert cookies[0] != cookies[1], "заказы пары — с двух разных кук"
|
||||
for cookie, order_day in zip(cookies, days, strict=True):
|
||||
assert order_day >= 0
|
||||
assert (cookie, order_day) in visits
|
||||
assert (cookie, order_day) in days_of
|
||||
|
||||
|
||||
def test_the_passport_of_a_pair_is_one_person_with_two_devices():
|
||||
"""Два города у одного человека — ложь в данных (спека, раздел 9)."""
|
||||
cohort = plan.cohort(CANONICAL_SEED, 0)
|
||||
first, second = cohort.pair_cookies[:, 0], cohort.pair_cookies[:, 1]
|
||||
assert np.all(cohort.city[first] == cohort.city[second])
|
||||
assert np.all(cohort.device[first] != cohort.device[second])
|
||||
|
||||
# Обещано не просто «разные строки справочника», а разный род устройства:
|
||||
# ровно одно из двух — телефон, второе десктоп или планшет. Пара «телефон
|
||||
# и ноутбук» мастер-спеки (раздел 5) — про это, а не про запрет планшета.
|
||||
category = np.array([row.category for row in reference.DEVICE_PROFILES])
|
||||
kinds = category[cohort.device[first]], category[cohort.device[second]]
|
||||
assert np.all(kinds[0] != kinds[1])
|
||||
phone = category == reference.PHONE_CATEGORY
|
||||
assert np.all(phone[cohort.device[first]] != phone[cohort.device[second]])
|
||||
|
||||
|
||||
def test_the_passport_points_into_the_directories():
|
||||
cohort = plan.cohort(CANONICAL_SEED, 0)
|
||||
for passport, table in (
|
||||
(cohort.device, reference.DEVICE_PROFILES),
|
||||
(cohort.city, reference.CITIES),
|
||||
):
|
||||
assert passport.size == cohort.client_id.size
|
||||
assert passport.min() >= 0
|
||||
assert passport.max() < len(table)
|
||||
|
||||
|
||||
def test_the_daily_audience_matches_the_spec_band():
|
||||
@@ -244,8 +274,8 @@ def test_plan_arrays_are_whole_numbers():
|
||||
for array in (
|
||||
cohort.client_id,
|
||||
cohort.birth_day,
|
||||
cohort.visit_cookie,
|
||||
cohort.visit_day,
|
||||
cohort.active_cookie,
|
||||
cohort.active_day,
|
||||
cohort.pair_cookies,
|
||||
cohort.pair_order_days,
|
||||
):
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
"""Сторожа справочников: связки, из-за которых таблицы написаны руками.
|
||||
|
||||
Тесты держат не содержание строк — его менти волен менять, — а то, на чём
|
||||
стоит день-функция: доли складываются в сотню, вход и переходы разложены по
|
||||
страницам блуждания, устройство не противоречит само себе.
|
||||
"""
|
||||
|
||||
from clickstream_generator import reference
|
||||
|
||||
|
||||
def test_shares_of_every_directory_add_up_to_a_hundred():
|
||||
"""Веса — проценты: выбор по ним целочисленный и без остатка."""
|
||||
for table in (
|
||||
reference.DEVICE_PROFILES,
|
||||
reference.CITIES,
|
||||
reference.TRAFFIC_SOURCES,
|
||||
):
|
||||
assert sum(row.weight for row in table) == 100
|
||||
|
||||
|
||||
def test_walking_the_site_never_leaves_the_browsing_pages():
|
||||
for percent in (reference.FROM_PRODUCT_PERCENT, reference.FROM_LISTING_PERCENT):
|
||||
assert len(percent) == len(reference.BROWSE_PAGES)
|
||||
assert sum(percent) == 100
|
||||
|
||||
|
||||
def test_a_visitor_comes_to_a_page_that_exists():
|
||||
for source in reference.TRAFFIC_SOURCES:
|
||||
assert len(source.entry_percent) == len(reference.BROWSE_PAGES)
|
||||
assert sum(source.entry_percent) == 100
|
||||
|
||||
|
||||
def test_the_walk_prefers_the_product_card():
|
||||
"""Иначе магазин выглядел бы каталогом, который никто не открывает."""
|
||||
card = reference.BROWSE_PAGES.index(reference.Page.PRODUCT)
|
||||
assert reference.FROM_LISTING_PERCENT[card] > 50
|
||||
|
||||
|
||||
def test_a_phone_carries_a_model_and_a_desktop_does_not():
|
||||
for profile in reference.DEVICE_PROFILES:
|
||||
phone = profile.category == reference.PHONE_CATEGORY
|
||||
assert bool(profile.phone_model) == phone
|
||||
assert profile.screen_width > 0 and profile.screen_height > 0
|
||||
|
||||
|
||||
def test_mobile_traffic_outweighs_the_desktop():
|
||||
"""Российская розница мобильная; на этом стоят доли устройств."""
|
||||
phones = sum(
|
||||
row.weight
|
||||
for row in reference.DEVICE_PROFILES
|
||||
if row.category == reference.PHONE_CATEGORY
|
||||
)
|
||||
assert phones > 50
|
||||
|
||||
|
||||
def test_cities_are_told_apart_by_id_and_by_address_block():
|
||||
assert len({city.region_id for city in reference.CITIES}) == len(reference.CITIES)
|
||||
assert len({city.ip_prefix for city in reference.CITIES}) == len(reference.CITIES)
|
||||
assert reference.COUNTRY_REGION_ID not in {c.region_id for c in reference.CITIES}
|
||||
|
||||
|
||||
def test_the_region_of_presence_outweighs_the_rest_of_the_country():
|
||||
"""Один регион присутствия, а не «топ городов России» (спека, раздел 9)."""
|
||||
home = reference.CITIES[0]
|
||||
assert home.name == "Samara"
|
||||
assert home.weight > 30
|
||||
nearby = sum(
|
||||
city.weight
|
||||
for city in reference.CITIES
|
||||
if city.timezone_minutes == home.timezone_minutes
|
||||
)
|
||||
assert nearby > 50
|
||||
|
||||
|
||||
def test_timezones_are_whole_hours():
|
||||
"""Волна поворачивается на целые часы: получасовых поясов в мире нет."""
|
||||
for city in reference.CITIES:
|
||||
assert city.timezone_minutes % 60 == 0
|
||||
|
||||
|
||||
def test_paid_sources_carry_their_click_labels():
|
||||
for source in reference.TRAFFIC_SOURCES:
|
||||
paid = source.last_traffic_source == "ad"
|
||||
assert bool(source.utm_source) >= paid
|
||||
assert (source.has_gclid or source.has_yclid) == paid
|
||||
|
||||
|
||||
def test_free_sources_carry_no_utm():
|
||||
"""Метки ставит тот, кто платит: у organic и direct их не бывает."""
|
||||
for source in reference.TRAFFIC_SOURCES:
|
||||
if source.last_traffic_source in ("organic", "direct", "recommend"):
|
||||
assert not source.utm_source
|
||||
@@ -64,3 +64,56 @@ def test_most_returns_land_in_the_first_days():
|
||||
def test_pairs_are_a_small_part_of_the_cohort():
|
||||
"""Вторые куки пар добавляют к притоку меньше процента (спека, раздел 9)."""
|
||||
assert world.BUYER_PERCENT * world.PAIRED_BUYER_PERCENT < 100
|
||||
|
||||
|
||||
def test_both_daily_waves_cover_a_day_and_average_to_one():
|
||||
"""Форма волны не меняет суточный объём: его задаёт недельный профиль."""
|
||||
for profile in (world.WEEKDAY_HOURS_PERCENT, world.WEEKEND_HOURS_PERCENT):
|
||||
assert len(profile) == 24
|
||||
assert sum(profile) == 2400
|
||||
|
||||
|
||||
def test_the_wave_dips_at_night_and_peaks_up_to_twice_the_average():
|
||||
"""Пики до ~2× среднего, ночью провал (спека, раздел 2)."""
|
||||
for profile in (world.WEEKDAY_HOURS_PERCENT, world.WEEKEND_HOURS_PERCENT):
|
||||
assert min(profile[2:6]) < 50
|
||||
assert 150 < max(profile) <= 220
|
||||
|
||||
|
||||
def test_the_weekend_wakes_up_later_than_a_weekday():
|
||||
morning = slice(7, 10)
|
||||
assert sum(world.WEEKEND_HOURS_PERCENT[morning]) < sum(
|
||||
world.WEEKDAY_HOURS_PERCENT[morning]
|
||||
)
|
||||
|
||||
|
||||
def test_an_active_day_holds_a_visit_or_two():
|
||||
"""Дневная аудитория 6–8 тыс. и 8–12 тыс. визитов сходятся через это число."""
|
||||
weights = world.VISITS_PER_ACTIVE_DAY_WEIGHTS
|
||||
counts = tuple(range(1, len(weights) + 1))
|
||||
assert 1.2 <= mean_by_weights(counts, weights) <= 1.6
|
||||
|
||||
|
||||
def test_a_visit_is_a_few_pages_long_and_often_a_single_one():
|
||||
weights = world.VISIT_PAGES_WEIGHTS
|
||||
pages = tuple(range(1, len(weights) + 1))
|
||||
assert 4 <= mean_by_weights(pages, weights) <= 6
|
||||
bounced = weights[0] / sum(weights)
|
||||
assert 0.15 < bounced < 0.35
|
||||
|
||||
|
||||
def test_pauses_stay_inside_the_visit_timeout():
|
||||
"""Иначе визит распался бы там, где генератор этого не обещал."""
|
||||
assert max(world.PAGE_PAUSE_SECONDS) < world.VISIT_TIMEOUT_SECONDS
|
||||
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
|
||||
|
||||
Reference in New Issue
Block a user