"""Тесты 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 app.services.scrapers.rosstat_emiss import ( OKTMO_EKATERINBURG, OKTMO_SVERDLOVSK_OBLAST, POPULATION_SPEC, DatasetSpec, RegionTarget, _parse_value, parse_dataset_rows, parse_meta_data_url, parse_meta_modified_year, ) # Реальный 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