Files
clickstream-data-platform/generator/tests/test_schema.py
T
ddadminandClaude Opus 5 1f96245c41 fix(generator): правки по двум линиям ревью — сторож состава и честные обещания
- Зачем:
  - линия постановки: тест инвариантов обещал ловить дрейф колонок, но
    переименование Referer или перенос колонки в другую группу проходили
    все проверки; линия стандартов: докстринг говорил о contract-тесте
    как о существующем и не нёс следа сверки API через Context7.
- Что:
  - тест состава по разделу 1.2 мастер-спеки: группа, имя и тип всех 47
    колонок записаны независимо от контракта, поэтому молчаливое
    переименование или перестановка краснеют — проверено правкой
    Referer → Referrer.
  - контракт: contract-тест переведён в будущее время со ссылкой на
    спеку; записана сверка записи типов ClickHouse (Context7 и запрос
    к узлу стенда 26.3.17.56 — параметры входят в имя типа целиком).
  - описание выгрузки самодостаточнее: расшифрованы коды
    DeviceCategory, домен LastTrafficSource честно назван неполным,
    «идентификатор» сведён к «id» ради одного слова на одну вещь.
  - schema_doc: убраны неиспользуемые параметры render и main,
    row → table_row; тест строки сверяет свойство, а не форму.
- Проверка:
  - make test (249 тестов), make lint;
  - make docs, затем git diff --exit-code docs/ — пусто.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-01 22:32:14 +03:00

163 lines
7.3 KiB
Python

"""Инварианты контракта схемы события.
Контракт — чистые данные, поэтому проверять в нём нечего кроме связности:
состав, уникальность имён, заполненность полей, согласие типов и порядок.
Это и есть сторож границы «трекер | хранилище»: молчаливый дрейф колонок
ловится здесь, а не в DDL через неделю.
"""
import re
import numpy as np
import pytest
from clickstream_generator.schema import COLUMNS, Column, ColumnGroup
# Состав решён мастер-спекой (раздел 1.2) и в этом тикете не переоткрывается.
EXPECTED_COLUMN_COUNT = 47
# Тот же состав, переписанный с мастер-спеки отдельно от контракта: группа,
# имя, тип. Дубль намеренный — только независимая запись ловит молчаливое
# переименование колонки, подмену типа или перестановку. Правка контракта без
# правки спеки краснеет здесь, и это единственный способ узнать о ней вовремя.
MASTER_SPEC_COMPOSITION = (
(ColumnGroup.IDENTIFIERS, "WatchID", "UInt64"),
(ColumnGroup.IDENTIFIERS, "VisitID", "UInt64"),
(ColumnGroup.IDENTIFIERS, "ClientID", "UInt64"),
(ColumnGroup.IDENTIFIERS, "CounterID", "UInt32"),
(ColumnGroup.IDENTIFIERS, "EventDate", "Date"),
(ColumnGroup.IDENTIFIERS, "UTCEventTime", "DateTime"),
(ColumnGroup.IDENTIFIERS, "ClientTimeZone", "Int16"),
(ColumnGroup.IDENTIFIERS, "EventType", "LowCardinality(String)"),
(ColumnGroup.IDENTIFIERS, "Sign", "Int8"),
(ColumnGroup.PAGE, "URL", "String"),
(ColumnGroup.PAGE, "Referer", "String"),
(ColumnGroup.PAGE, "Title", "String"),
(ColumnGroup.PAGE, "UTMSource", "String"),
(ColumnGroup.PAGE, "UTMMedium", "String"),
(ColumnGroup.PAGE, "UTMCampaign", "String"),
(ColumnGroup.PAGE, "UTMContent", "String"),
(ColumnGroup.PAGE, "UTMTerm", "String"),
(ColumnGroup.PAGE, "LastTrafficSource", "String"),
(ColumnGroup.PAGE, "HasGCLID", "UInt8"),
(ColumnGroup.PAGE, "YCLID", "UInt64"),
(ColumnGroup.CLIENT, "Browser", "String"),
(ColumnGroup.CLIENT, "BrowserMajorVersion", "UInt16"),
(ColumnGroup.CLIENT, "BrowserLanguage", "String"),
(ColumnGroup.CLIENT, "OperatingSystem", "String"),
(ColumnGroup.CLIENT, "OperatingSystemRoot", "String"),
(ColumnGroup.CLIENT, "DeviceCategory", "UInt8"),
(ColumnGroup.CLIENT, "MobilePhoneModel", "String"),
(ColumnGroup.CLIENT, "ScreenWidth", "UInt16"),
(ColumnGroup.CLIENT, "ScreenHeight", "UInt16"),
(ColumnGroup.CLIENT, "IPAddress", "String"),
(ColumnGroup.CLIENT, "RegionCountry", "String"),
(ColumnGroup.CLIENT, "RegionCity", "String"),
(ColumnGroup.CLIENT, "RegionCountryID", "UInt32"),
(ColumnGroup.CLIENT, "RegionCityID", "UInt32"),
(ColumnGroup.PARAMS, "GoalsReached", "Array(UInt32)"),
(ColumnGroup.PARAMS, "ParsedParamsKey1", "Array(String)"),
(ColumnGroup.ECOMMERCE, "purchaseID", "Array(String)"),
(ColumnGroup.ECOMMERCE, "purchaseRevenue", "Array(Float64)"),
(ColumnGroup.ECOMMERCE, "purchaseCurrency", "Array(String)"),
(ColumnGroup.ECOMMERCE, "purchaseCoupon", "Array(String)"),
(ColumnGroup.ECOMMERCE, "productID", "Array(String)"),
(ColumnGroup.ECOMMERCE, "productName", "Array(String)"),
(ColumnGroup.ECOMMERCE, "productCategory", "Array(String)"),
(ColumnGroup.ECOMMERCE, "productPrice", "Array(Int64)"),
(ColumnGroup.ECOMMERCE, "productQuantity", "Array(UInt64)"),
(ColumnGroup.ECOMMERCE, "productEventType", "Array(String)"),
(ColumnGroup.ECOMMERCE, "ecommerce", "String"),
)
# Соответствие «тип ClickHouse — тип numpy», записанное независимо от
# контракта: если пара в контракте разъедется, сойтись они уже не смогут.
NUMPY_BY_CLICKHOUSE_TYPE = {
"UInt8": "uint8",
"UInt16": "uint16",
"UInt32": "uint32",
"UInt64": "uint64",
"Int8": "int8",
"Int16": "int16",
"Int64": "int64",
"Float64": "float64",
"String": "object",
"LowCardinality(String)": "object",
"Date": "datetime64[D]",
"DateTime": "datetime64[s]",
}
METRICA_NAME = re.compile(r"^[A-Za-z][A-Za-z0-9]*$")
DDS_NAME = re.compile(r"^[a-z][a-z0-9_]*$")
ARRAY_TYPE = re.compile(r"^Array\((.+)\)$")
def element_type(clickhouse_type: str) -> str:
"""Тип элемента: у массива — то, что внутри `Array(...)`, иначе сам тип."""
array = ARRAY_TYPE.match(clickhouse_type)
return array.group(1) if array else clickhouse_type
def test_columns_are_an_immutable_sequence():
assert isinstance(COLUMNS, tuple)
def test_column_count():
assert len(COLUMNS) == EXPECTED_COLUMN_COUNT
def test_composition_matches_master_spec():
"""Состав, имена, типы и порядок — те же, что в разделе 1.2 мастер-спеки."""
composition = tuple(
(column.group, column.name, column.clickhouse_type) for column in COLUMNS
)
assert composition == MASTER_SPEC_COMPOSITION
def test_metrica_names_are_unique():
names = [column.name for column in COLUMNS]
assert len(set(names)) == len(names)
def test_dds_names_are_unique():
names = [column.dds_name for column in COLUMNS]
assert len(set(names)) == len(names)
@pytest.mark.parametrize("column", COLUMNS, ids=lambda column: column.name)
def test_attributes_are_filled(column: Column):
assert column.name.strip()
assert column.clickhouse_type.strip()
assert column.numpy_dtype.strip()
assert column.dds_name.strip()
assert column.comment.strip()
assert isinstance(column.group, ColumnGroup)
@pytest.mark.parametrize("column", COLUMNS, ids=lambda column: column.name)
def test_names_keep_their_styles(column: Column):
assert METRICA_NAME.match(column.name), "имя источника — как в выгрузке Метрики"
assert DDS_NAME.match(column.dds_name), "имя для DDS — snake_case"
@pytest.mark.parametrize("column", COLUMNS, ids=lambda column: column.name)
def test_numpy_dtype_exists(column: Column):
assert np.dtype(column.numpy_dtype).name == column.numpy_dtype
@pytest.mark.parametrize("column", COLUMNS, ids=lambda column: column.name)
def test_numpy_dtype_matches_clickhouse_type(column: Column):
expected = NUMPY_BY_CLICKHOUSE_TYPE.get(element_type(column.clickhouse_type))
assert expected is not None, f"незнакомый тип ClickHouse: {column.clickhouse_type}"
assert column.numpy_dtype == expected
def test_groups_go_in_runs_and_in_order():
"""Группы не чередуются: каждая идёт одним куском, куски — по объявлению."""
seen = []
for column in COLUMNS:
if not seen or seen[-1] is not column.group:
assert column.group not in seen, f"группа {column.group.name} разорвана"
seen.append(column.group)
assert seen == list(ColumnGroup), "порядок групп разошёлся с их объявлением"