gendesign/backend/tests/scrapers/test_cbr_macro.py
bot-backend cdf493f345
All checks were successful
Deploy / changes (push) Successful in 9s
Deploy Trade-In / changes (push) Successful in 13s
Deploy / build-frontend (push) Has been skipped
Deploy / deploy-caddy (push) Has been skipped
Deploy Trade-In / build-frontend (push) Has been skipped
Deploy Trade-In / build-browser (push) Has been skipped
Deploy / build-backend (push) Successful in 2m23s
Deploy Trade-In / test (push) Successful in 3m56s
Deploy / build-worker (push) Successful in 4m16s
Deploy Trade-In / build-backend (push) Successful in 1m19s
Deploy / deploy (push) Successful in 1m49s
Deploy / deploy-status (push) Successful in 1s
Deploy / perimeter-smoke (push) Successful in 12s
Deploy Trade-In / deploy (push) Successful in 2m25s
Deploy Trade-In / deploy-status (push) Successful in 1s
Deploy Trade-In / perimeter-smoke (push) Successful in 11s
chore(format): нормализация под ruff 0.15.20 — 161 файл, только формат (#2864) (#3022)
2026-08-21 12:01:52 +00:00

250 lines
9.9 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""Тесты 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 = (
'<?xml version="1.0" encoding="utf-8"?>'
'<soap:Envelope xmlns:soap="http://www.w3.org/2003/05/soap-envelope">'
"<soap:Body>"
'<KeyRateXMLResponse xmlns="http://web.cbr.ru/">'
"<KeyRateXMLResult>"
'<KeyRate xmlns="">'
"<KR><DT>2023-09-15T00:00:00+03:00</DT><Rate>12.00</Rate></KR>"
"<KR><DT>2023-08-15T00:00:00+03:00</DT><Rate>12.00</Rate></KR>"
"<KR><DT>2023-08-14T00:00:00+03:00</DT><Rate>8.50</Rate></KR>"
"</KeyRate>"
"</KeyRateXMLResult>"
"</KeyRateXMLResponse>"
"</soap:Body>"
"</soap:Envelope>"
)
# Diffgram-вариант (метод KeyRate) — парсер должен его тоже понимать (находит KR
# где угодно), несмотря на schema-блок и namespace на KR.
FIXTURE_KEYRATE_DIFFGRAM = (
'<soap:Envelope xmlns:soap="http://www.w3.org/2003/05/soap-envelope">'
'<soap:Body><KeyRateResponse xmlns="http://web.cbr.ru/"><KeyRateResult>'
'<diffgr:diffgram xmlns:msdata="urn:schemas-microsoft-com:xml-msdata"'
' xmlns:diffgr="urn:schemas-microsoft-com:xml-diffgram-v1">'
'<KeyRate xmlns="">'
'<KR diffgr:id="KR1" msdata:rowOrder="0">'
"<DT>2023-09-29T00:00:00+03:00</DT><Rate>13.00</Rate></KR>"
'<KR diffgr:id="KR2" msdata:rowOrder="1">'
"<DT>2023-09-15T00:00:00+03:00</DT><Rate>12.00</Rate></KR>"
"</KeyRate></diffgr:diffgram></KeyRateResult></KeyRateResponse>"
"</soap:Body></soap:Envelope>"
)
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 = (
'<soap:Envelope xmlns:soap="http://www.w3.org/2003/05/soap-envelope">'
'<soap:Body><KeyRateXMLResponse xmlns="http://web.cbr.ru/" />'
"</soap:Body></soap:Envelope>"
)
assert parse_key_rate_xml(xml) == []
def test_parse_malformed_xml() -> None:
"""Битый XML → [] (не бросает)."""
assert parse_key_rate_xml("<KeyRate><KR><DT>2023") == []
def test_parse_skips_kr_with_missing_fields() -> None:
"""KR без DT или без Rate пропускается; валидные остаются."""
xml = (
"<KeyRate>"
"<KR><DT>2023-08-15T00:00:00+03:00</DT><Rate>12.00</Rate></KR>"
"<KR><DT>2023-08-16T00:00:00+03:00</DT></KR>" # нет Rate
"<KR><Rate>11.00</Rate></KR>" # нет DT
"</KeyRate>"
)
assert parse_key_rate_xml(xml) == [(date(2023, 8, 15), 12.0)]
def test_parse_dedups_by_date_last_wins() -> None:
"""Дубли по дате схлопываются — последний выигрывает."""
xml = (
"<KeyRate>"
"<KR><DT>2023-08-15T00:00:00+03:00</DT><Rate>12.00</Rate></KR>"
"<KR><DT>2023-08-15T00:00:00+03:00</DT><Rate>13.00</Rate></KR>"
"</KeyRate>"
)
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)]