All checks were successful
CI / changes (push) Successful in 6s
CI / changes (pull_request) Successful in 7s
CI / frontend-tests (pull_request) Has been skipped
CI / backend-tests (pull_request) Successful in 6m27s
Deploy / build-frontend (push) Has been skipped
CI / frontend-tests (push) Has been skipped
CI / backend-tests (push) Successful in 6m29s
Deploy / changes (push) Successful in 6s
Deploy / build-backend (push) Successful in 1m43s
Deploy / build-worker (push) Successful in 3m3s
Deploy / deploy (push) Successful in 1m15s
Closes the last #946 gap (СМР). Fetches «Индексы цен производителей на строительную продукцию по РФ» from the OPEN Rosstat xlsx (rosstat.gov.ru/storage/mediabank/Invest_ind_stroitel_MM-YYYY.xlsx), parses the monthly «к предыдущему месяцу» section → macro_indicator (indicator_type='construction_price_index', region='rf', monthly, %). The reCAPTCHA-gated fedstat dataGrid.do path is DELIBERATELY NOT used — the open Rosstat source needs no captcha. EMISS id=31108 documented as a prod-only SDMX alternative (not wired). URL is date-derived with bounded look-back (filename changes monthly; _fetch_construction_latest walks current month back to 6, first 200 wins; year-boundary handled, exhaustion raises, 404 breaks fast — no hang). Wired into rosstat_macro_sync with per-source try/except (can't break demography/income) + SAVEPOINT-per-row. None-not-0 on blank months. 33 tests (incl look-back + year-boundary + graceful), ruff clean. Note: no construction-cost channel in macro_coefficient yet (demand-side only) — series is ingested/available for future cost-side use + display. Refs #946
316 lines
16 KiB
Python
316 lines
16 KiB
Python
"""Тесты 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-<ts>-structure-<ts>.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) == []
|