"""Тесты 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:0012.00"
"2023-08-15T00:00:00+03:0012.00"
"2023-08-14T00:00:00+03:008.50"
""
""
""
""
""
)
# Diffgram-вариант (метод KeyRate) — парсер должен его тоже понимать (находит KR
# где угодно), несмотря на schema-блок и namespace на KR.
FIXTURE_KEYRATE_DIFFGRAM = (
''
""
''
''
''
"2023-09-29T00:00:00+03:0013.00"
''
"2023-09-15T00:00:00+03:0012.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:0012.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:0012.00"
"2023-08-15T00:00:00+03:0013.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)]