fix(generator): сторожа имён DDS и порядка строк, торговый словарь — по границе

- Зачем:
  - слепая линия Кодекса (свежий тред, high) нашла три места, где обещание
    контракта не подкреплено: имена для DDS не сверялись ни с чем, порядок
    строк документа держался только на нумерации, а комментарий рекламировал
    торговые события, которые мастер-спека прямо исключила.
- Что:
  - имена для DDS записаны независимо и сверяются целиком: они не выводятся
    правилом из имён Метрики, значит осмысленно неверное имя иначе молча
    уезжает в опубликованное описание (проверено подменой referer).
  - строки документа сверяются парами «номер, колонка»: рендер в другом
    порядке больше не проходит зелёным (проверено перевёрнутым рендером).
  - productEventType: detail и remove убраны из комментария — раздел 10
    мастер-спеки отказался от полного словаря торговых событий Метрики;
    стенд шлёт add и purchase.
- Проверка:
  - make test (250 тестов), make lint;
  - make docs, затем git diff --exit-code docs/ — пусто;
  - обе новые проверки проверены мутациями: каждая краснеет своим тестом.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-01 22:44:53 +03:00
co-authored by Claude Opus 5
parent 1f96245c41
commit aabd339a26
4 changed files with 70 additions and 6 deletions
+1 -1
View File
@@ -90,5 +90,5 @@ DDS». Столбец «Тип numpy» показывает, чем колонк
| 43 | `productCategory` | `Array(String)` | `object` | `product_category` | категории тех же товаров | | 43 | `productCategory` | `Array(String)` | `object` | `product_category` | категории тех же товаров |
| 44 | `productPrice` | `Array(Int64)` | `int64` | `product_price` | цена за штуку целым числом: деньги генератор считает целыми | | 44 | `productPrice` | `Array(Int64)` | `int64` | `product_price` | цена за штуку целым числом: деньги генератор считает целыми |
| 45 | `productQuantity` | `Array(UInt64)` | `uint64` | `product_quantity` | количество штук каждого товара | | 45 | `productQuantity` | `Array(UInt64)` | `uint64` | `product_quantity` | количество штук каждого товара |
| 46 | `productEventType` | `Array(String)` | `object` | `product_event_type` | действие с товаром: detail, add, remove, purchase | | 46 | `productEventType` | `Array(String)` | `object` | `product_event_type` | действие с товаром: стенд шлёт add и purchase, полный словарь Метрики (detail, remove, impressions) не берём |
| 47 | `ecommerce` | `String` | `object` | `ecommerce` | сырой JSON события, как отдаёт Метрика — материал лабы про разбор JSON внутри колонки | | 47 | `ecommerce` | `String` | `object` | `ecommerce` | сырой JSON события, как отдаёт Метрика — материал лабы про разбор JSON внутри колонки |
@@ -429,7 +429,8 @@ COLUMNS: tuple[Column, ...] = (
numpy_dtype="object", numpy_dtype="object",
dds_name="product_event_type", dds_name="product_event_type",
group=ColumnGroup.ECOMMERCE, group=ColumnGroup.ECOMMERCE,
comment="действие с товаром: detail, add, remove, purchase", comment="действие с товаром: стенд шлёт add и purchase, полный"
" словарь Метрики (detail, remove, impressions) не берём",
), ),
Column( Column(
name="ecommerce", name="ecommerce",
+59
View File
@@ -87,6 +87,60 @@ NUMPY_BY_CLICKHOUSE_TYPE = {
"DateTime": "datetime64[s]", "DateTime": "datetime64[s]",
} }
# Имена для DDS — не производная от имён Метрики, а решение тикета #36:
# вывести их правилом нельзя (акронимы, «timezone» одним словом), поэтому
# сверять их не с чем, кроме такой же независимой записи. Без неё осмысленно
# неверное имя молча уезжает в опубликованное описание выгрузки.
EXPECTED_DDS_NAMES = {
"WatchID": "watch_id",
"VisitID": "visit_id",
"ClientID": "client_id",
"CounterID": "counter_id",
"EventDate": "event_date",
"UTCEventTime": "utc_event_time",
"ClientTimeZone": "client_timezone",
"EventType": "event_type",
"Sign": "sign",
"URL": "url",
"Referer": "referer",
"Title": "title",
"UTMSource": "utm_source",
"UTMMedium": "utm_medium",
"UTMCampaign": "utm_campaign",
"UTMContent": "utm_content",
"UTMTerm": "utm_term",
"LastTrafficSource": "last_traffic_source",
"HasGCLID": "has_gclid",
"YCLID": "yclid",
"Browser": "browser",
"BrowserMajorVersion": "browser_major_version",
"BrowserLanguage": "browser_language",
"OperatingSystem": "operating_system",
"OperatingSystemRoot": "operating_system_root",
"DeviceCategory": "device_category",
"MobilePhoneModel": "mobile_phone_model",
"ScreenWidth": "screen_width",
"ScreenHeight": "screen_height",
"IPAddress": "ip_address",
"RegionCountry": "region_country",
"RegionCity": "region_city",
"RegionCountryID": "region_country_id",
"RegionCityID": "region_city_id",
"GoalsReached": "goals_reached",
"ParsedParamsKey1": "parsed_params_key1",
"purchaseID": "purchase_id",
"purchaseRevenue": "purchase_revenue",
"purchaseCurrency": "purchase_currency",
"purchaseCoupon": "purchase_coupon",
"productID": "product_id",
"productName": "product_name",
"productCategory": "product_category",
"productPrice": "product_price",
"productQuantity": "product_quantity",
"productEventType": "product_event_type",
"ecommerce": "ecommerce",
}
METRICA_NAME = re.compile(r"^[A-Za-z][A-Za-z0-9]*$") METRICA_NAME = re.compile(r"^[A-Za-z][A-Za-z0-9]*$")
DDS_NAME = re.compile(r"^[a-z][a-z0-9_]*$") DDS_NAME = re.compile(r"^[a-z][a-z0-9_]*$")
ARRAY_TYPE = re.compile(r"^Array\((.+)\)$") ARRAY_TYPE = re.compile(r"^Array\((.+)\)$")
@@ -124,6 +178,11 @@ def test_dds_names_are_unique():
assert len(set(names)) == len(names) assert len(set(names)) == len(names)
def test_dds_names_are_the_ones_we_chose():
"""Переименование колонки в DDS — решение, а не правка мимоходом."""
assert {column.name: column.dds_name for column in COLUMNS} == EXPECTED_DDS_NAMES
@pytest.mark.parametrize("column", COLUMNS, ids=lambda column: column.name) @pytest.mark.parametrize("column", COLUMNS, ids=lambda column: column.name)
def test_attributes_are_filled(column: Column): def test_attributes_are_filled(column: Column):
assert column.name.strip() assert column.name.strip()
+8 -4
View File
@@ -16,7 +16,9 @@ from clickstream_generator.schema_doc import render
REPO_ROOT = Path(__file__).resolve().parents[2] REPO_ROOT = Path(__file__).resolve().parents[2]
DOC_PATH = REPO_ROOT / "docs" / "formats" / "clickstream-event.md" DOC_PATH = REPO_ROOT / "docs" / "formats" / "clickstream-event.md"
TABLE_ROW = re.compile(r"^\| \d+ \|", re.MULTILINE) # Номер и имя колонки из строки таблицы: по ним сверяется не только состав
# документа, но и его порядок — по нему сторона хранилища выпишет колонки.
TABLE_ROW = re.compile(r"^\| (\d+) \| `([^`]+)` \|", re.MULTILINE)
@pytest.fixture(scope="module") @pytest.fixture(scope="module")
@@ -35,9 +37,11 @@ def test_every_column_has_a_row(rendered: str):
assert len(TABLE_ROW.findall(rendered)) == len(COLUMNS) assert len(TABLE_ROW.findall(rendered)) == len(COLUMNS)
def test_rows_are_numbered_in_contract_order(rendered: str): def test_rows_follow_contract_order(rendered: str):
numbers = [int(row.strip("| ")) for row in TABLE_ROW.findall(rendered)] """Строки идут в порядке контракта, а не просто нумеруются с 1 по 47."""
assert numbers == list(range(1, len(COLUMNS) + 1)) rows = [(int(number), name) for number, name in TABLE_ROW.findall(rendered)]
expected = [(number, column.name) for number, column in enumerate(COLUMNS, 1)]
assert rows == expected
@pytest.mark.parametrize("column", COLUMNS, ids=lambda column: column.name) @pytest.mark.parametrize("column", COLUMNS, ids=lambda column: column.name)