"""Тесты 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 — валидный конверт с пустым . Покрывают: извлечение конкретных строк (Свердл, реальные период+значение), 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 = ( '' "" # годовой ряд за 2023 — значение 48000 "" '' '' "" "2023" '' # Q1 того же 2023 — значение 42000; obs_date тоже 2023-01-01 "" '' '' "" "2023" '' "" ) 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 с пустым → [] (не падает).""" 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" None: """SDMX-конверт без → [] (не падает).""" xml = ( '' "
" ) assert parse_emiss_sdmx(xml, INCOME_PER_CAPITA_SPEC) == [] def test_unknown_period_series_skipped() -> None: """Серия с незнакомым PERIOD (неделя) пропускается, валидная остаётся.""" xml = ( '' "" "" '' '' "" "2024" '' "" '' '' "" "2024" '' "" ) 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 = ( '' "" '' '' "" "2024" '' "" ) 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 = ( '' "" "" '' '' "" "2024" '' "" '' '' "" "2024" '' "" ) 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"