Some checks failed
CI / changes (push) Successful in 7s
CI / changes (pull_request) Successful in 7s
CI / backend-tests (push) Has been skipped
CI / frontend-tests (push) Has been skipped
CI / openapi-codegen-check (push) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Successful in 1m38s
CI / backend-tests (pull_request) Failing after 8m41s
_YEAR_PERIODS ('год') → month=1 collided with Q1 ('I квартал') → month=1
for the same region/year: both produced obs_date=YYYY-01-01, so the second
series in the XML silently overwrote the first in the in-memory by_key dict.
Fix: add _emiss_period_granularity() and extend the dedup key from
(region, obs_date) to (region, obs_date, granularity). 'year' and 'quarter'
are now distinct slots, so both rows survive the parser and reach the upsert.
Test: test_yearly_and_q1_both_survive_dedup verifies len==2 with both
values present; test_period_granularity covers all classification branches.
273 lines
14 KiB
Python
273 lines
14 KiB
Python
"""Тесты SDMX-парсера ЕМИСС/fedstat (offline, на фикстурах — без живой сети).
|
||
|
||
Парсер ``parse_emiss_sdmx`` — pure (SDMX-байты → list[EmissRow]); fetch с fedstat
|
||
исполняется только на проде (за WAF с dev), поэтому здесь тестируем РАЗБОР на реальных
|
||
(обрезанных) SDMX-выгрузках, снятых с прода 2026-06-07:
|
||
• emiss_income_57039.xml — «Среднедушевые денежные доходы населения» (id=57039),
|
||
размерность s_OKATO, квартальный (рубль/мес). Свердл = ОКАТО 65000000000.
|
||
• emiss_empty.xml — валидный конверт с пустым <DataSet>.
|
||
|
||
Покрывают: извлечение конкретных строк (Свердл, реальные период+значение),
|
||
region-фильтрацию (др. регионы/федокруга исключены), маппинг квартала→obs_date,
|
||
дедуп, graceful на пустом/битом/без-DataSet SDMX → [].
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
from datetime import date
|
||
from decimal import Decimal
|
||
from pathlib import Path
|
||
|
||
from app.services.scrapers.rosstat_emiss import (
|
||
INCOME_PER_CAPITA_SPEC,
|
||
OKATO_SVERDLOVSK_OBLAST,
|
||
EmissIndicatorSpec,
|
||
RegionTarget,
|
||
_emiss_period_granularity,
|
||
_emiss_period_to_month,
|
||
parse_emiss_sdmx,
|
||
)
|
||
|
||
_FIXTURES = Path(__file__).resolve().parent.parent / "fixtures"
|
||
|
||
|
||
def _load(name: str) -> bytes:
|
||
return (_FIXTURES / name).read_bytes()
|
||
|
||
|
||
# ── period mapping ────────────────────────────────────────────────────────────────
|
||
|
||
|
||
def test_period_quarter_to_start_month() -> None:
|
||
"""Квартал → стартовый месяц квартала (регистр/пробелы игнор)."""
|
||
assert _emiss_period_to_month("I квартал") == 1
|
||
assert _emiss_period_to_month("II квартал") == 4
|
||
assert _emiss_period_to_month("III квартал") == 7
|
||
assert _emiss_period_to_month(" iv КВАРТАЛ ") == 10
|
||
|
||
|
||
def test_period_month_name_to_num() -> None:
|
||
"""Русское название месяца → номер месяца."""
|
||
assert _emiss_period_to_month("январь") == 1
|
||
assert _emiss_period_to_month("Декабрь") == 12
|
||
|
||
|
||
def test_period_year_and_unknown() -> None:
|
||
"""Годовой под-период → январь; незнакомый/пустой → None."""
|
||
assert _emiss_period_to_month("год") == 1
|
||
assert _emiss_period_to_month("значение показателя за год") == 1
|
||
assert _emiss_period_to_month("3 неделя") is None
|
||
assert _emiss_period_to_month("") is None
|
||
|
||
|
||
def test_period_granularity() -> None:
|
||
"""_emiss_period_granularity корректно классифицирует все под-периоды (#1606)."""
|
||
assert _emiss_period_granularity("год") == "year"
|
||
assert _emiss_period_granularity("значение показателя за год") == "year"
|
||
assert _emiss_period_granularity("за год") == "year"
|
||
assert _emiss_period_granularity("I квартал") == "quarter"
|
||
assert _emiss_period_granularity("IV квартал") == "quarter"
|
||
assert _emiss_period_granularity("январь") == "month"
|
||
assert _emiss_period_granularity("декабрь") == "month"
|
||
assert _emiss_period_granularity("3 неделя") == "unknown"
|
||
assert _emiss_period_granularity("") == "unknown"
|
||
|
||
|
||
def test_yearly_and_q1_both_survive_dedup() -> None:
|
||
"""Годовой ряд ('год') и Q1 ('I квартал') за один год — оба остаются (#1606).
|
||
|
||
До фикса: оба давали obs_date=YYYY-01-01, ключ (region, obs_date) был один →
|
||
второй по XML-порядку перезаписывал первого. После фикса: ключ трёхкомпонентный
|
||
(region, obs_date, granularity) → 'year' и 'quarter' — разные слоты.
|
||
"""
|
||
xml = (
|
||
'<GenericData xmlns="http://www.SDMX.org/resources/SDMXML/schemas/v1_0/message"'
|
||
' xmlns:generic="http://www.SDMX.org/resources/SDMXML/schemas/v1_0/generic">'
|
||
"<DataSet>"
|
||
# годовой ряд за 2023 — значение 48000
|
||
"<generic:Series><generic:SeriesKey>"
|
||
'<generic:Value concept="s_OKATO" value="65000000000"/></generic:SeriesKey>'
|
||
'<generic:Attributes><generic:Value concept="PERIOD" value="год"/>'
|
||
"</generic:Attributes>"
|
||
"<generic:Obs><generic:Time>2023</generic:Time>"
|
||
'<generic:ObsValue value="48000"/></generic:Obs></generic:Series>'
|
||
# Q1 того же 2023 — значение 42000; obs_date тоже 2023-01-01
|
||
"<generic:Series><generic:SeriesKey>"
|
||
'<generic:Value concept="s_OKATO" value="65000000000"/></generic:SeriesKey>'
|
||
'<generic:Attributes><generic:Value concept="PERIOD" value="I квартал"/>'
|
||
"</generic:Attributes>"
|
||
"<generic:Obs><generic:Time>2023</generic:Time>"
|
||
'<generic:ObsValue value="42000"/></generic:Obs></generic:Series>'
|
||
"</DataSet></GenericData>"
|
||
)
|
||
rows = parse_emiss_sdmx(xml, INCOME_PER_CAPITA_SPEC)
|
||
# Оба наблюдения должны присутствовать — разные гранулярности
|
||
assert len(rows) == 2, f"ожидались 2 строки, получили {len(rows)}: {rows}"
|
||
values = {r.value for r in rows}
|
||
assert Decimal("48000") in values, "годовое наблюдение потеряно"
|
||
assert Decimal("42000") in values, "Q1-наблюдение потеряно"
|
||
# obs_date у обоих одинаковый (это нормально — коллизия теперь на стороне DB-upsert)
|
||
assert all(r.obs_date == date(2023, 1, 1) for r in rows)
|
||
|
||
|
||
# ── real-fixture extraction (income id=57039) ─────────────────────────────────────
|
||
|
||
|
||
def test_income_extracts_sverdlovsk_only() -> None:
|
||
"""Из реальной SDMX-выгрузки извлекается ТОЛЬКО Свердл (ОКАТО 65) — РФ/ЦФО/Адыгея нет."""
|
||
rows = parse_emiss_sdmx(_load("emiss_income_57039.xml"), INCOME_PER_CAPITA_SPEC)
|
||
assert rows, "ожидались строки по Свердл"
|
||
assert {r.region for r in rows} == {"sverdl"}
|
||
assert all(r.indicator_type == "income_per_capita" for r in rows)
|
||
assert all(r.unit == "руб" for r in rows)
|
||
assert all(r.frequency == "quarterly" for r in rows)
|
||
|
||
|
||
def test_income_concrete_values_and_dates() -> None:
|
||
"""Конкретные реальные значения Свердл + obs_date = 1-е число стартового месяца квартала.
|
||
|
||
Реальные данные fedstat (Свердл, 2024): I=54006, II=61136, III=66849, IV=76701 ₽/мес;
|
||
I кв.2025 = 64361. Квартал → стартовый месяц (I→01, II→04, III→07, IV→10).
|
||
"""
|
||
rows = parse_emiss_sdmx(_load("emiss_income_57039.xml"), INCOME_PER_CAPITA_SPEC)
|
||
by_date = {r.obs_date: r.value for r in rows}
|
||
|
||
assert by_date[date(2024, 1, 1)] == Decimal("54006")
|
||
assert by_date[date(2024, 4, 1)] == Decimal("61136")
|
||
assert by_date[date(2024, 7, 1)] == Decimal("66849")
|
||
assert by_date[date(2024, 10, 1)] == Decimal("76701")
|
||
assert by_date[date(2025, 1, 1)] == Decimal("64361")
|
||
# ровно 5 свердловских наблюдений в фикстуре (4 квартала 2024 + I кв.2025)
|
||
assert len(rows) == 5
|
||
# отсортировано по дате ASC
|
||
assert [r.obs_date for r in rows] == sorted(by_date)
|
||
|
||
|
||
def test_income_other_regions_excluded() -> None:
|
||
"""ОКАТО других субъектов/агрегатов (Адыгея 79, ЦФО 030, РФ 643) НЕ попадают.
|
||
|
||
Значение Адыгеи (40190) и ЦФО (68110) присутствуют в фикстуре, но НЕ должны
|
||
появиться среди извлечённых (фильтр строго по целевому ОКАТО 65000000000).
|
||
"""
|
||
rows = parse_emiss_sdmx(_load("emiss_income_57039.xml"), INCOME_PER_CAPITA_SPEC)
|
||
values = {r.value for r in rows}
|
||
assert Decimal("40190") not in values # Адыгея
|
||
assert Decimal("68110") not in values # ЦФО
|
||
assert Decimal("53221") not in values # РФ
|
||
|
||
|
||
def test_income_region_filter_targets_adygea() -> None:
|
||
"""Подменив целевой ОКАТО на Адыгею — извлекается ИМЕННО Адыгея (доказывает фильтр)."""
|
||
spec = EmissIndicatorSpec(
|
||
indicator_id="57039",
|
||
indicator_type="income_per_capita",
|
||
unit="руб",
|
||
frequency="quarterly",
|
||
targets=(RegionTarget(oktmo="79000000000", region="adygea"),),
|
||
)
|
||
rows = parse_emiss_sdmx(_load("emiss_income_57039.xml"), spec)
|
||
assert len(rows) == 1
|
||
assert rows[0].region == "adygea"
|
||
assert rows[0].value == Decimal("40190")
|
||
assert rows[0].obs_date == date(2024, 1, 1)
|
||
|
||
|
||
# ── graceful degradation ──────────────────────────────────────────────────────────
|
||
|
||
|
||
def test_empty_dataset_returns_empty() -> None:
|
||
"""Валидный SDMX с пустым <DataSet> → [] (не падает)."""
|
||
assert parse_emiss_sdmx(_load("emiss_empty.xml"), INCOME_PER_CAPITA_SPEC) == []
|
||
|
||
|
||
def test_malformed_xml_returns_empty() -> None:
|
||
"""Битый XML → [] (+warning), не бросает."""
|
||
assert parse_emiss_sdmx(b"<GenericData><DataSet><not closed", INCOME_PER_CAPITA_SPEC) == []
|
||
assert parse_emiss_sdmx("не xml вовсе", INCOME_PER_CAPITA_SPEC) == []
|
||
|
||
|
||
def test_no_dataset_element_returns_empty() -> None:
|
||
"""SDMX-конверт без <DataSet> → [] (не падает)."""
|
||
xml = (
|
||
'<GenericData xmlns="http://www.SDMX.org/resources/SDMXML/schemas/v1_0/message">'
|
||
"<Header/></GenericData>"
|
||
)
|
||
assert parse_emiss_sdmx(xml, INCOME_PER_CAPITA_SPEC) == []
|
||
|
||
|
||
def test_unknown_period_series_skipped() -> None:
|
||
"""Серия с незнакомым PERIOD (неделя) пропускается, валидная остаётся."""
|
||
xml = (
|
||
'<GenericData xmlns="http://www.SDMX.org/resources/SDMXML/schemas/v1_0/message"'
|
||
' xmlns:generic="http://www.SDMX.org/resources/SDMXML/schemas/v1_0/generic">'
|
||
"<DataSet>"
|
||
"<generic:Series><generic:SeriesKey>"
|
||
'<generic:Value concept="s_OKATO" value="65000000000"/></generic:SeriesKey>'
|
||
'<generic:Attributes><generic:Value concept="PERIOD" value="3 неделя"/>'
|
||
"</generic:Attributes>"
|
||
"<generic:Obs><generic:Time>2024</generic:Time>"
|
||
'<generic:ObsValue value="999"/></generic:Obs></generic:Series>'
|
||
"<generic:Series><generic:SeriesKey>"
|
||
'<generic:Value concept="s_OKATO" value="65000000000"/></generic:SeriesKey>'
|
||
'<generic:Attributes><generic:Value concept="PERIOD" value="I квартал"/>'
|
||
"</generic:Attributes>"
|
||
"<generic:Obs><generic:Time>2024</generic:Time>"
|
||
'<generic:ObsValue value="50000"/></generic:Obs></generic:Series>'
|
||
"</DataSet></GenericData>"
|
||
)
|
||
rows = parse_emiss_sdmx(xml, INCOME_PER_CAPITA_SPEC)
|
||
assert len(rows) == 1
|
||
assert rows[0].value == Decimal("50000")
|
||
assert rows[0].obs_date == date(2024, 1, 1)
|
||
|
||
|
||
def test_comma_decimal_value_parsed() -> None:
|
||
"""Значение с запятой-десятичным (ИПЦ-вариант '113,2') → Decimal('113.2')."""
|
||
xml = (
|
||
'<GenericData xmlns="http://www.SDMX.org/resources/SDMXML/schemas/v1_0/message"'
|
||
' xmlns:generic="http://www.SDMX.org/resources/SDMXML/schemas/v1_0/generic">'
|
||
"<DataSet><generic:Series><generic:SeriesKey>"
|
||
'<generic:Value concept="s_OKATO" value="65000000000"/></generic:SeriesKey>'
|
||
'<generic:Attributes><generic:Value concept="PERIOD" value="январь"/>'
|
||
"</generic:Attributes>"
|
||
"<generic:Obs><generic:Time>2024</generic:Time>"
|
||
'<generic:ObsValue value="113,2"/></generic:Obs></generic:Series></DataSet>'
|
||
"</GenericData>"
|
||
)
|
||
rows = parse_emiss_sdmx(xml, INCOME_PER_CAPITA_SPEC)
|
||
assert len(rows) == 1
|
||
assert rows[0].value == Decimal("113.2")
|
||
assert rows[0].obs_date == date(2024, 1, 1)
|
||
|
||
|
||
def test_dedup_last_wins() -> None:
|
||
"""Дубль (region, obs_date) схлопывается — последний выигрывает (PK всё равно один)."""
|
||
xml = (
|
||
'<GenericData xmlns="http://www.SDMX.org/resources/SDMXML/schemas/v1_0/message"'
|
||
' xmlns:generic="http://www.SDMX.org/resources/SDMXML/schemas/v1_0/generic">'
|
||
"<DataSet>"
|
||
"<generic:Series><generic:SeriesKey>"
|
||
'<generic:Value concept="s_OKATO" value="65000000000"/></generic:SeriesKey>'
|
||
'<generic:Attributes><generic:Value concept="PERIOD" value="I квартал"/>'
|
||
"</generic:Attributes>"
|
||
"<generic:Obs><generic:Time>2024</generic:Time>"
|
||
'<generic:ObsValue value="100"/></generic:Obs></generic:Series>'
|
||
"<generic:Series><generic:SeriesKey>"
|
||
'<generic:Value concept="s_OKATO" value="65000000000"/></generic:SeriesKey>'
|
||
'<generic:Attributes><generic:Value concept="PERIOD" value="I квартал"/>'
|
||
"</generic:Attributes>"
|
||
"<generic:Obs><generic:Time>2024</generic:Time>"
|
||
'<generic:ObsValue value="200"/></generic:Obs></generic:Series>'
|
||
"</DataSet></GenericData>"
|
||
)
|
||
rows = parse_emiss_sdmx(xml, INCOME_PER_CAPITA_SPEC)
|
||
assert len(rows) == 1
|
||
assert rows[0].value == Decimal("200")
|
||
|
||
|
||
def test_spec_targets_sverdlovsk_okato() -> None:
|
||
"""Активная спека доходов целит ИМЕННО ОКАТО Свердл (контракт §7.11)."""
|
||
assert OKATO_SVERDLOVSK_OBLAST == "65000000000"
|
||
assert any(t.oktmo == OKATO_SVERDLOVSK_OBLAST for t in INCOME_PER_CAPITA_SPEC.targets)
|
||
assert INCOME_PER_CAPITA_SPEC.indicator_id == "57039"
|
||
assert INCOME_PER_CAPITA_SPEC.frequency == "quarterly"
|