"""Тесты pure-парсеров Росстат-скрейпера (offline, без живой сети). Покрывают: разбор meta.csv (URL данных + год), парсинг значений (запятая-десятичный, разделители тысяч), извлечение целевых ОКТМО-строк из data-CSV (Свердловская обл. / ЕКБ), фоллбэк года из meta, дедуп по (region, obs_date), graceful на пустом / без-нужных-колонок CSV. Фикстуры — из реального формата open-data Росстата 4.0 (verified live 2026-06-07: датасет 7708234640-population). """ from __future__ import annotations from datetime import date from decimal import Decimal from pathlib import Path import pytest from app.services.scrapers.rosstat_emiss import ( CONSTRUCTION_PRICE_SPEC, OKTMO_EKATERINBURG, OKTMO_SVERDLOVSK_OBLAST, POPULATION_SPEC, DatasetSpec, RegionTarget, _parse_value, _parse_year_cell, parse_construction_index_xlsx, parse_dataset_rows, parse_meta_data_url, parse_meta_modified_year, ) # Фикстура: обрезанный РЕАЛЬНЫЙ xlsx Росстата «Индексы цен производителей на строительную # продукцию по РФ» (лист '1', секции «К предыдущему месяцу» 2022-2025 + «К декабрю # предыдущего года»; снято live 2026-06-08 с Invest_ind_stroitel_*.xlsx). Декабрь-2025 # намеренно ПУСТ (будущий/неопубликованный месяц → проверка None-not-0 пропуска). _FIXTURES = Path(__file__).resolve().parent.parent / "fixtures" CONSTRUCTION_XLSX = (_FIXTURES / "rosstat_construction_stroitel.xlsx").read_bytes() # Реальный shape meta.csv Росстата (open-data 4.0): property,value; строка данных — # property 'data--structure-.csv' → абсолютный URL CSV-данных. FIXTURE_META = ( "property,value\n" "standardversion,https://rosstat.gov.ru/storage/.../versiya_4.0.pdf\n" 'identifier,7708234640-population\n' 'title,"Численность постоянного населения"\n' "created,20180905\n" "modified,20140919\n" "format,CSV\n" "data-20140919T1709-structure-20141013T1710.csv," "https://rosstat.gov.ru/opendata/7708234640-population/" "data-20140919T1709-structure-20141013T1710.csv\n" "structure-20141013T1710.csv," "https://rosstat.gov.ru/opendata/7708234640-population/structure-20141013T1710.csv\n" ) # Реальный shape data-CSV (population): header + муниципальные строки, ОКТМО в 'kode', # значение в 'total', год в 'year'. Запятая — разделитель полей; значения — целые. FIXTURE_POPULATION_DATA = ( "number,year,kode,region,municipalities,total,urban,rural\n" '1,2014,7900000000,Республика Адыгея,Муниципальные образования Республики Адыгеи,' "446406,209929,236477\n" "2,2014,6500000000,Свердловская область,Муниципальные образования Свердловской области," "4320677,3500000,820677\n" "3,2014,657010000011000,Свердловская область,г. Екатеринбург,1412346,1412346,0\n" "4,2014,657013640011000,Свердловская область,Верх-Исетский район,201817,201817,0\n" ) def test_parse_meta_data_url() -> None: """meta.csv → URL последнего data-файла.""" url = parse_meta_data_url(FIXTURE_META) assert url == ( "https://rosstat.gov.ru/opendata/7708234640-population/" "data-20140919T1709-structure-20141013T1710.csv" ) def test_parse_meta_modified_year() -> None: """meta.csv 'modified' (YYYYMMDD) → год.""" assert parse_meta_modified_year(FIXTURE_META) == 2014 def test_parse_meta_missing_data_url() -> None: """meta без строки данных → None (не бросает).""" assert parse_meta_data_url("property,value\nidentifier,foo\n") is None def test_parse_value_comma_decimal() -> None: """Запятая как десятичный разделитель: '1,5' → Decimal('1.5').""" assert _parse_value("1,5") == Decimal("1.5") def test_parse_value_thousands_separator() -> None: """Пробелы-разделители тысяч вырезаются: '4 320 677' → Decimal('4320677').""" assert _parse_value("4 320 677") == Decimal("4320677") # узкий неразрывный пробел (Росстат иногда так) assert _parse_value("1 412 346") == Decimal("1412346") def test_parse_value_empty_and_dash() -> None: """Пустое / прочерк / многоточие → None (строка пропускается).""" assert _parse_value("") is None assert _parse_value("-") is None assert _parse_value("…") is None assert _parse_value("не число") is None def test_parse_dataset_targets_only() -> None: """Из data-CSV извлекаются ТОЛЬКО целевые ОКТМО (субъект + ЕКБ), не районы/др.регионы.""" rows = parse_dataset_rows(FIXTURE_POPULATION_DATA, POPULATION_SPEC, fallback_year=None) by_region = {r.region: r for r in rows} assert set(by_region) == {"sverdl", "ekb"} # Адыгея и Верх-Исетский район — НЕ среди целей assert all(r.region in {"sverdl", "ekb"} for r in rows) def test_parse_dataset_values_and_dates() -> None: """Значения и obs_date (1 января года из колонки 'year') корректны.""" rows = parse_dataset_rows(FIXTURE_POPULATION_DATA, POPULATION_SPEC, fallback_year=None) by_region = {r.region: r for r in rows} assert by_region["sverdl"].value == Decimal("4320677") assert by_region["sverdl"].obs_date == date(2014, 1, 1) assert by_region["ekb"].value == Decimal("1412346") assert by_region["ekb"].obs_date == date(2014, 1, 1) # тип / единица из spec assert by_region["sverdl"].indicator_type == "population_total" assert by_region["sverdl"].unit == "чел" def test_parse_dataset_fallback_year_when_no_year_col() -> None: """Если в spec year_col_header=None — год берётся из fallback_year (meta modified).""" spec_no_year = DatasetSpec( code=POPULATION_SPEC.code, indicator_type="population_total", unit="чел", value_col_header="total", oktmo_col_header="kode", year_col_header=None, # год не из данных targets=(RegionTarget(oktmo=OKTMO_SVERDLOVSK_OBLAST, region="sverdl"),), ) rows = parse_dataset_rows(FIXTURE_POPULATION_DATA, spec_no_year, fallback_year=2014) assert len(rows) == 1 assert rows[0].obs_date == date(2014, 1, 1) def test_parse_dataset_dedup_last_wins() -> None: """Дубль строки по (region, obs_date) схлопывается — последняя выигрывает.""" data = ( "number,year,kode,region,municipalities,total,urban,rural\n" "1,2014,6500000000,Свердловская область,МО,4320677,0,0\n" "2,2014,6500000000,Свердловская область,МО,4400000,0,0\n" # дубль ОКТМО+год ) rows = parse_dataset_rows(data, POPULATION_SPEC, fallback_year=None) assert len(rows) == 1 assert rows[0].value == Decimal("4400000") def test_parse_dataset_skips_unparsable_value() -> None: """Строка с непарсящимся значением пропускается, валидные остаются.""" data = ( "number,year,kode,region,municipalities,total,urban,rural\n" "1,2014,6500000000,Свердловская область,МО,-,0,0\n" # value '-' "2,2014,657010000011000,Свердловская область,г. Екатеринбург,1412346,0,0\n" ) rows = parse_dataset_rows(data, POPULATION_SPEC, fallback_year=None) assert len(rows) == 1 assert rows[0].region == "ekb" def test_parse_dataset_missing_columns_graceful() -> None: """CSV без нужных колонок (kode/total) → [] (+warning), не бросает.""" data = "foo,bar\n1,2\n3,4\n" assert parse_dataset_rows(data, POPULATION_SPEC, fallback_year=2014) == [] def test_parse_dataset_empty_graceful() -> None: """Пустой CSV → [].""" assert parse_dataset_rows("", POPULATION_SPEC, fallback_year=2014) == [] def test_oktmo_constants_used_in_spec() -> None: """Спека population таргетит именно субъект-ОКТМО и ЕКБ-ОКТМО (контракт ТЗ §7.11).""" oktmos = {t.oktmo for t in POPULATION_SPEC.targets} assert OKTMO_SVERDLOVSK_OBLAST in oktmos assert OKTMO_EKATERINBURG in oktmos # ── индекс цен на СМР (открытый xlsx Росстата) ──────────────────────────────────── def test_parse_year_cell_plain_and_footnote() -> None: """Год из ячейки: чистый int, строка-год и сноска-маркер ('2022 1)' → 2022).""" assert _parse_year_cell(2024) == 2024 assert _parse_year_cell("2022 1)") == 2022 assert _parse_year_cell("2025") == 2025 # не-год / пусто / вне диапазона → None assert _parse_year_cell(None) is None assert _parse_year_cell("Январь") is None assert _parse_year_cell(1800) is None def test_construction_spec_contract() -> None: """Спека СМР: тип, region='rf', monthly, % — открытый xlsx, без dataGrid.do/captcha.""" assert CONSTRUCTION_PRICE_SPEC.indicator_type == "construction_price_index" assert CONSTRUCTION_PRICE_SPEC.region == "rf" assert CONSTRUCTION_PRICE_SPEC.frequency == "monthly" assert CONSTRUCTION_PRICE_SPEC.unit == "%" assert "dataGrid" not in CONSTRUCTION_PRICE_SPEC.url_template assert CONSTRUCTION_PRICE_SPEC.url_template.startswith("https://rosstat.gov.ru/") assert "{mm:02d}" in CONSTRUCTION_PRICE_SPEC.url_template # резолв по дате, НЕ хардкод месяца def test_construction_url_resolves_with_lookback(monkeypatch: pytest.MonkeyPatch) -> None: """Имя файла меняется ежемесячно → резолвер идёт назад до первого 200 (не хардкод).""" from app.services.scrapers import rosstat_emiss as mod tried: list[str] = [] def fake_get(client: object, url: str, *, retries: int = 0) -> bytes: tried.append(url) if len(tried) < 3: # первые два месяца → 404 raise mod.RosstatScraperError(f"HTTP 404 {url}") return CONSTRUCTION_XLSX monkeypatch.setattr(mod, "_http_get", fake_get) content = mod._fetch_construction_latest( client=object(), # type: ignore[arg-type] # не используется (get замокан) url_template=mod.ROSSTAT_CONSTRUCTION_XLSX_URL_TEMPLATE, today=date(2026, 6, 15), ) assert content == CONSTRUCTION_XLSX assert tried == [ "https://rosstat.gov.ru/storage/mediabank/Invest_ind_stroitel_06-2026.xlsx", "https://rosstat.gov.ru/storage/mediabank/Invest_ind_stroitel_05-2026.xlsx", "https://rosstat.gov.ru/storage/mediabank/Invest_ind_stroitel_04-2026.xlsx", ] def test_construction_url_lookback_year_boundary_and_exhaustion( monkeypatch: pytest.MonkeyPatch, ) -> None: """Все кандидаты 404 → RosstatScraperError; look-back корректно переходит через год.""" from app.services.scrapers import rosstat_emiss as mod tried: list[str] = [] def fake_get(client: object, url: str, *, retries: int = 0) -> bytes: tried.append(url) raise mod.RosstatScraperError(f"HTTP 404 {url}") monkeypatch.setattr(mod, "_http_get", fake_get) with pytest.raises(mod.RosstatScraperError): mod._fetch_construction_latest( client=object(), # type: ignore[arg-type] url_template=mod.ROSSTAT_CONSTRUCTION_XLSX_URL_TEMPLATE, today=date(2026, 2, 15), max_lookback=3, ) assert tried == [ # переход через год: 02-2026 → 01-2026 → 12-2025 → 11-2025 "https://rosstat.gov.ru/storage/mediabank/Invest_ind_stroitel_02-2026.xlsx", "https://rosstat.gov.ru/storage/mediabank/Invest_ind_stroitel_01-2026.xlsx", "https://rosstat.gov.ru/storage/mediabank/Invest_ind_stroitel_12-2025.xlsx", "https://rosstat.gov.ru/storage/mediabank/Invest_ind_stroitel_11-2025.xlsx", ] def test_parse_construction_monthly_series() -> None: """xlsx → месячный ряд СМР: region='rf', obs_date к 1-му числу, значение в %.""" rows = parse_construction_index_xlsx(CONSTRUCTION_XLSX, CONSTRUCTION_PRICE_SPEC) assert rows, "ожидались строки из фикстуры" assert all(r.region == "rf" for r in rows) assert all(r.indicator_type == "construction_price_index" for r in rows) assert all(r.unit == "%" for r in rows) assert all(r.obs_date.day == 1 for r in rows) by_date = {r.obs_date: r.value for r in rows} # реальные значения «к предыдущему месяцу» из фикстуры (2022-2025) assert by_date[date(2022, 1, 1)] == Decimal("100.6") assert by_date[date(2025, 1, 1)] == Decimal("100.8") # годы покрывают 2022..2025 assert {d.year for d in by_date} == {2022, 2023, 2024, 2025} def test_parse_construction_skips_blank_future_month() -> None: """Пустая ячейка будущего/неопубликованного месяца → пропуск (None-not-0).""" rows = parse_construction_index_xlsx(CONSTRUCTION_XLSX, CONSTRUCTION_PRICE_SPEC) dates = {r.obs_date for r in rows} # Декабрь-2025 в фикстуре намеренно пуст → его НЕ должно быть (а не 0) assert date(2025, 12, 1) not in dates assert date(2025, 11, 1) in dates # соседний заполненный — есть def test_parse_construction_ignores_annual_section() -> None: """Секция «К декабрю предыдущего года» (годовой базис) НЕ попадает в месячный ряд. Годовые декабрьские значения большие (>105), месячные — около 100. Убеждаемся, что ни одно декабрьское наблюдение не унаследовало годовое значение. """ rows = parse_construction_index_xlsx(CONSTRUCTION_XLSX, CONSTRUCTION_PRICE_SPEC) dec_vals = [r.value for r in rows if r.obs_date.month == 12] assert dec_vals, "ожидались декабрьские месячные наблюдения" assert all(v < Decimal("105") for v in dec_vals) def test_parse_construction_empty_and_garbage_graceful() -> None: """Пустые байты / не-xlsx → [] (+warning), не бросает.""" assert parse_construction_index_xlsx(b"", CONSTRUCTION_PRICE_SPEC) == [] assert parse_construction_index_xlsx(b"not a zip", CONSTRUCTION_PRICE_SPEC) == [] def test_parse_construction_missing_sheet_graceful() -> None: """Если запрошенного листа нет в книге → [] (+warning).""" from dataclasses import replace spec_bad = replace(CONSTRUCTION_PRICE_SPEC, sheet="НЕТ_ТАКОГО") assert parse_construction_index_xlsx(CONSTRUCTION_XLSX, spec_bad) == []