"""Тесты pure-парсеров CBR (offline, без живой сети). Покрывают: • parse_key_rate_xml — парсинг (date, value), tz-суффикс, сортировка, пустой / битый / без-KR XML → []. Фикстуры из реального KeyRateXML (verified 2026-06-02). • parse_inflation_xlsx — парсинг колонки «Инфляция, % г/г» Excel-экспорта UniDbQuery (#946): дата MM.YYYY → 1-е число, выбор правильной колонки (НЕ «Цель»), пропуск строк без значения, сортировка, пустой / битый / без-колонки xlsx → []. Фикстуры строятся openpyxl'ом, зеркаля реальный формат CBR (verified 2026-06-08). """ from __future__ import annotations from datetime import date from io import BytesIO from openpyxl import Workbook from app.services.scrapers.cbr_macro import parse_inflation_xlsx, parse_key_rate_xml # Реальный shape KeyRateXML (без diffgram). 3 строки, descending по дате, # tz-суффикс +03:00, точка как десятичный разделитель. FIXTURE_KEYRATE_XML = ( '' '' "" '' "" '' "
2023-09-15T00:00:00+03:00
12.00
" "
2023-08-15T00:00:00+03:00
12.00
" "
2023-08-14T00:00:00+03:00
8.50
" "
" "
" "
" "
" "
" ) # Diffgram-вариант (метод KeyRate) — парсер должен его тоже понимать (находит KR # где угодно), несмотря на schema-блок и namespace на KR. FIXTURE_KEYRATE_DIFFGRAM = ( '' "" '' '' '' "
2023-09-29T00:00:00+03:00
13.00
" '' "
2023-09-15T00:00:00+03:00
12.00
" "
" "
" ) def test_parse_basic_rows() -> None: """3 KR-строки → 3 кортежа (date, float), отсортированы по возрастанию даты.""" rows = parse_key_rate_xml(FIXTURE_KEYRATE_XML) assert rows == [ (date(2023, 8, 14), 8.5), (date(2023, 8, 15), 12.0), (date(2023, 9, 15), 12.0), ] def test_parse_returns_sorted_ascending() -> None: """Вход descending — выход обязан быть ascending по дате.""" rows = parse_key_rate_xml(FIXTURE_KEYRATE_XML) dates = [d for d, _ in rows] assert dates == sorted(dates) def test_parse_strips_timezone_suffix() -> None: """DT с tz-суффиксом '+03:00' парсится в чистую дату (без сдвига).""" rows = parse_key_rate_xml(FIXTURE_KEYRATE_XML) assert (date(2023, 8, 15), 12.0) in rows # значение float, не строка assert all(isinstance(v, float) for _, v in rows) def test_parse_value_types() -> None: """Дробная ставка 8.50 → 8.5 (float).""" rows = dict(parse_key_rate_xml(FIXTURE_KEYRATE_XML)) assert rows[date(2023, 8, 14)] == 8.5 def test_parse_diffgram_variant() -> None: """Diffgram-обёртка (метод KeyRate) тоже парсится — KR находится в любом namespace / вложенности.""" rows = parse_key_rate_xml(FIXTURE_KEYRATE_DIFFGRAM) assert rows == [ (date(2023, 9, 15), 12.0), (date(2023, 9, 29), 13.0), ] def test_parse_empty_string() -> None: assert parse_key_rate_xml("") == [] assert parse_key_rate_xml(" ") == [] def test_parse_empty_response_no_kr() -> None: """Валидный, но пустой KeyRateXMLResponse (период без данных) → [].""" xml = ( '' '' "" ) assert parse_key_rate_xml(xml) == [] def test_parse_malformed_xml() -> None: """Битый XML → [] (не бросает).""" assert parse_key_rate_xml("
2023") == [] def test_parse_skips_kr_with_missing_fields() -> None: """KR без DT или без Rate пропускается; валидные остаются.""" xml = ( "" "
2023-08-15T00:00:00+03:00
12.00
" "
2023-08-16T00:00:00+03:00
" # нет Rate "11.00" # нет DT "
" ) assert parse_key_rate_xml(xml) == [(date(2023, 8, 15), 12.0)] def test_parse_dedups_by_date_last_wins() -> None: """Дубли по дате схлопываются — последний выигрывает.""" xml = ( "" "
2023-08-15T00:00:00+03:00
12.00
" "
2023-08-15T00:00:00+03:00
13.00
" "
" ) assert parse_key_rate_xml(xml) == [(date(2023, 8, 15), 13.0)] # ── parse_inflation_xlsx (#946) ───────────────────────────────────────────────── def _make_inflation_xlsx( rows: list[tuple[object, object, object, object]], *, header: tuple[str, str, str, str] = ( "Дата", "Ключевая ставка, % годовых", "Инфляция, % г/г", "Цель по инфляции", ), ) -> bytes: """Собрать .xlsx, зеркаля реальный CBR-экспорт: header + строки (дата, ставка, инфляция, цель). Возвращает байты (как вернул бы DownloadExcel).""" wb = Workbook() ws = wb.active ws.append(list(header)) for r in rows: ws.append(list(r)) buf = BytesIO() wb.save(buf) return buf.getvalue() def test_parse_inflation_basic() -> None: """Реальный shape: MM.YYYY-даты descending, нативные float → берём колонку «Инфляция» (НЕ «Цель»), дата → 1-е число, сортировка по возрастанию.""" content = _make_inflation_xlsx( [ ("04.2026", 14.5, 5.58, 4.0), ("03.2026", 15.0, 5.86, 4.0), ("02.2026", 15.5, 5.91, 4.0), ] ) rows = parse_inflation_xlsx(content) assert rows == [ (date(2026, 2, 1), 5.91), (date(2026, 3, 1), 5.86), (date(2026, 4, 1), 5.58), ] def test_parse_inflation_picks_inflation_not_target() -> None: """Колонка «Цель по инфляции» (=4.0) НЕ должна попасть вместо «Инфляция».""" content = _make_inflation_xlsx([("01.2026", 16.0, 6.0, 4.0)]) rows = parse_inflation_xlsx(content) assert rows == [(date(2026, 1, 1), 6.0)] # значение именно инфляции, а не цели assert rows[0][1] != 4.0 def test_parse_inflation_skips_rows_without_value() -> None: """Строки с пустой инфляцией пропускаются; валидные остаются.""" content = _make_inflation_xlsx( [ ("03.2013", None, 7.02, None), # ранний месяц: ставка пустая, инфляция есть ("02.2013", None, None, None), # нет инфляции → скип ] ) rows = parse_inflation_xlsx(content) assert rows == [(date(2013, 3, 1), 7.02)] def test_parse_inflation_handles_comma_decimal() -> None: """Строковая ячейка с запятой-разделителем (на случай не-числовой ячейки).""" content = _make_inflation_xlsx([("12.2025", "16,00", "5,59", "4,00")]) rows = parse_inflation_xlsx(content) assert rows == [(date(2025, 12, 1), 5.59)] def test_parse_inflation_dedups_by_month_last_wins() -> None: content = _make_inflation_xlsx( [ ("05.2025", 20.0, 9.9, 4.0), ("05.2025", 20.0, 10.1, 4.0), ] ) rows = parse_inflation_xlsx(content) assert rows == [(date(2025, 5, 1), 10.1)] def test_parse_inflation_empty_bytes() -> None: assert parse_inflation_xlsx(b"") == [] def test_parse_inflation_malformed_bytes() -> None: """Не-xlsx байты → [] (не бросает).""" assert parse_inflation_xlsx(b"not an excel file") == [] def test_parse_inflation_no_inflation_column() -> None: """Excel без колонки «Инфляция» (формат изменился) → [] (graceful).""" content = _make_inflation_xlsx( [("04.2026", 14.5, 5.58, 4.0)], header=("Дата", "Ставка", "Нечто", "Иное"), ) assert parse_inflation_xlsx(content) == [] def test_parse_inflation_skips_unparseable_date() -> None: """Строка с непарсящейся датой (мусор) пропускается, валидные остаются.""" content = _make_inflation_xlsx( [ ("04.2026", 14.5, 5.58, 4.0), ("мусор", 1.0, 9.9, 4.0), ] ) rows = parse_inflation_xlsx(content) assert rows == [(date(2026, 4, 1), 5.58)]