- Зачем:
- контракт вёл себя как хозяин чужого слоя: поле называлось dds_name, в
описании стоял столбец «Имя в DDS», а два теста прибивали имена
гвоздями. Спека же задала вид имени (snake_case), а не список: имена
атрибутов складывает модель данных DDS, и решать это не трекеру.
- Что:
- поле контракта и столбец описания стали нормализованным именем: имя
источника в нашем стиле. В описании и в докстринге сказано прямо, что
слой DDS называет атрибуты по своей модели.
- сняты оба теста на имена — копия имён DDS и конспект состава по
мастер-спеке. Они не проверяли верность имени, только неизменность, а
неизменность и так сторожит пересборка описания: молчаливой правки
контракта не бывает, она всплывает диффом документа.
- остались проверки формы: 47 колонок, уникальность, стили имён,
заполненность, согласие типов numpy и ClickHouse, порядок групп.
- спека генератора (раздел 3) и CONTEXT.md согласованы тем же
коммитом: уточнение внесено как расхождение, найденное при исполнении.
- Проверка:
- make test (248 тестов), make lint, make config-test;
- make docs, затем git diff --exit-code docs/ — пусто.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
102 lines
4.1 KiB
Python
102 lines
4.1 KiB
Python
"""Инварианты контракта схемы события.
|
|
|
|
Контракт — чистые данные, поэтому проверять в нём нечего кроме связности:
|
|
состав на месте, имена уникальны и в своих стилях, атрибуты заполнены, типы
|
|
согласованы, группы идут подряд. Имена колонок тесты не сторожат намеренно:
|
|
любая правка контракта проходит через пересборку описания выгрузки, а её
|
|
дифф виден в ревью лучше, чем правка внутри питона.
|
|
"""
|
|
|
|
import re
|
|
|
|
import numpy as np
|
|
import pytest
|
|
|
|
from clickstream_generator.schema import COLUMNS, Column, ColumnGroup
|
|
|
|
# Состав решён мастер-спекой (раздел 1.2) и в этом тикете не переоткрывается.
|
|
EXPECTED_COLUMN_COUNT = 47
|
|
|
|
# Соответствие «тип 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]*$")
|
|
NORMALIZED_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_metrica_names_are_unique():
|
|
names = [column.name for column in COLUMNS]
|
|
assert len(set(names)) == len(names)
|
|
|
|
|
|
def test_normalized_names_are_unique():
|
|
names = [column.normalized_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.normalized_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 NORMALIZED_NAME.match(column.normalized_name), "наше имя — 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), "порядок групп разошёлся с их объявлением"
|