"""Unit-тесты §13 Excel-экспортёра (#991, 955-A5) — `export_report_xlsx`. Чистые тесты БЕЗ БД/сети (экспортёр только ПОТРЕБЛЯЕТ уже-собранный отчёт): • полный `SiteFinderReport` → непустые .xlsx-байты, перечитываемые openpyxl; • все шесть ожидаемых листов присутствуют (Сводка / Рынок сейчас / Будущий рынок / Продукт ТЗ / Сценарии / Скоринг); • известные ячейки заполнены (заголовок вердикта, класс продукта, ADVISORY-маркер); • экспортёр принимает КАК dataclass-инстанс, ТАК и его `as_dict()`-словарь; • частичный / пустой отчёт → всё равно валидный .xlsx (graceful, без падения). Детерминированно, без LLM. DATABASE_URL выставляем до импорта app-модулей (зеркало test_report.py) — на случай side-effect'ов импорта пакета. """ from __future__ import annotations import os os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test") import datetime as dt from io import BytesIO from openpyxl import load_workbook from app.services.exporters.excel import ( _SHEET_FUTURE_MARKET, _SHEET_MARKET_NOW, _SHEET_PRODUCT_TZ, _SHEET_SCENARIOS, _SHEET_SCORING, _SHEET_SUMMARY, _fmt, _sanitize_formula, _scenario_deficit_index, export_report_xlsx, ) from app.services.forecasting.demand_supply_forecast import DemandSupplyForecast from app.services.forecasting.report import ( ReportConfidence, ReportExecSummary, ReportFutureMarket, ReportMarketNow, ReportMeta, ReportProductTz, ReportScenarios, ReportScoring, SiteFinderReport, ) from app.services.forecasting.scenarios import ScenarioForecast def _forecast(horizon: int, demand: float, supply: float, deficit: float) -> dict[str, object]: """Реальный `DemandSupplyForecast.as_dict()` — ключи гарантированно из контракта #952. Строим frozen-dataclass БЕЗ БД и сериализуем через его собственный `as_dict()`: так тест не может разойтись с реальной формой (projected_demand_units / projected_supply_ units), как это случилось со старой hand-typed фикстурой (demand/supply — #989/#991). """ return DemandSupplyForecast( segment={"obj_class": "комфорт"}, horizon_months=horizon, base_pace_units_per_mo=8.0, demand_norm_coefficient=1.0, macro_coefficient=1.0, projected_demand_units=demand, open_units=300, hidden_release_units=80.0, future_online_units=20.0, projected_supply_units=supply, balance_units=demand - supply, balance_ratio=(demand / supply if supply else None), deficit_index=deficit, months_of_inventory=(supply / (demand / horizon) if demand else None), rate_future=18.0, rate_sensitivity_phrase="спрос эластичен к ставке", future_competitors=[{"obj_id": 3, "comm_name": "ЖК Гамма"}], advisory=True, confidence="medium", ).as_dict() def _scenario(name: str, deficit_12mo: float) -> dict[str, object]: """Реальный `ScenarioForecast.as_dict()` — ключи из контракта #984 (НЕ hand-typed). У сценария НЕТ скалярного «overall»: есть `forecasts` (по горизонтам) с deficit_index. Экспортёр тянет сводный дефицит 12-мес горизонта — здесь даём именно его. """ return ScenarioForecast( scenario=name, # type: ignore[arg-type] # Literal в проде; в тесте — str-имя rate_path={6: 18.0, 12: 18.0}, forecasts=[ DemandSupplyForecast( segment={"obj_class": "комфорт"}, horizon_months=12, base_pace_units_per_mo=8.0, demand_norm_coefficient=1.0, macro_coefficient=1.0, projected_demand_units=920.0, open_units=300, hidden_release_units=80.0, future_online_units=20.0, projected_supply_units=700.0, balance_units=220.0, balance_ratio=1.314, deficit_index=deficit_12mo, months_of_inventory=9.1, rate_future=18.0, rate_sensitivity_phrase=None, future_competitors=[], advisory=True, confidence="medium", ) ], advisory=True, ).as_dict() # Шесть ожидаемых листов (по одному на содержательную секцию §13). _EXPECTED_SHEETS: tuple[str, ...] = ( _SHEET_SUMMARY, _SHEET_MARKET_NOW, _SHEET_FUTURE_MARKET, _SHEET_PRODUCT_TZ, _SHEET_SCENARIOS, _SHEET_SCORING, ) def _full_report() -> SiteFinderReport: """Полный SiteFinderReport из реалистичных sample-данных (форма #987 `as_dict()`).""" return SiteFinderReport( exec_summary=ReportExecSummary( headline="Строить комфорт-класс, акцент на студии и 1-к.", verdict="Дефицит малых форматов на горизонте 12 мес; рынок поглощает быстро.", key_numbers={"overall_score": 0.71, "deficit_index": 0.34, "horizon_months": 12}, overall_confidence="medium", ), market_now=ReportMarketNow( market_metrics={"unit_velocity": 8.2, "overstock_index": 0.21, "confidence": "medium"}, competitors=[ {"obj_id": 1, "comm_name": "ЖК Альфа", "relevance_weight": 0.8}, {"obj_id": 2, "comm_name": "ЖК Бета", "relevance_weight": 0.5}, ], supply_layers={"open_units": 1200, "hidden_units": 800}, summary="Текущий рынок: умеренная абсорбция, средняя плотность конкурентов.", ), future_market=ReportFutureMarket( forecasts_by_horizon=[ _forecast(6, demand=410.0, supply=380.0, deficit=0.2), _forecast(12, demand=920.0, supply=700.0, deficit=0.34), ], future_supply={ "district": "Верх-Исетский", "horizon_months": 12, "premise_kind": "квартира", "confidence": "low", "index": 0.42, "breakdown": { "open_units": 1200, "hidden_units": 800, "future_units_by_horizon": 450.0, "monthly_absorption_units": 8.2, "months_of_pressure": 9.0, "index": 0.42, }, }, future_competitors=[{"obj_id": 3, "stage": "котлован"}], scenarios_summary={"base": 0.34, "conservative": 0.18, "aggressive": 0.49}, summary="Будущий рынок: дефицит сохраняется на 12 мес.", ), product_tz=ReportProductTz( obj_class="комфорт", mix=[ {"bucket": "1-Студия", "pct": 35}, {"bucket": "2-1-к", "pct": 40}, {"bucket": "3-2-к", "pct": 25}, ], commercial={"available": False, "caveat": "коммерция: нет достаточных данных"}, usp=[{"segment": "1-Студия", "usp_text": "Дефицит студий — стройте их."}], reasons=[{"why": "deficit_index +0.34 на горизонте 12 мес", "advisory": True}], summary="Рекомендован комфорт-класс с акцентом на малые форматы.", ), scenarios=ReportScenarios( by_scenario={ "conservative": _scenario("conservative", deficit_12mo=0.18), "base": _scenario("base", deficit_12mo=0.34), "aggressive": _scenario("aggressive", deficit_12mo=0.49), }, summary="Разброс сценариев умеренный.", ), scoring=ReportScoring( product_scores={ "overall": 0.71, "scores": {"market_fit": {"value": 0.67}, "demand": {"value": 0.51}}, "advisory": True, }, special_indices={ "indices": {"launch_window": {"value": 0.6, "label": "12 мес"}}, "advisory": True, }, overall=0.71, ), confidence=ReportConfidence( level="medium", rationale="Источники advisory-capped; данные средней плотности.", factors={"data_coverage": 0.6, "engine_validated": False}, ), meta=ReportMeta( cad_num="66:41:0000000:1", district="Верх-Исетский", segment={"obj_class": "комфорт", "room_bucket": "1-к 30-45"}, horizons=[6, 12, 18, 24], generated_at=dt.date(2026, 6, 3), ), ) def _reload(payload: bytes): # type: ignore[no-untyped-def] """Перечитать .xlsx-байты обратно в openpyxl Workbook (round-trip-проверка).""" return load_workbook(BytesIO(payload)) # ── Полный отчёт: непустые байты, перечитываются, листы + ячейки на месте ────── class TestFullReportExport: def test_returns_non_empty_bytes(self) -> None: payload = export_report_xlsx(_full_report()) assert isinstance(payload, bytes) assert len(payload) > 0 def test_openpyxl_can_reload(self) -> None: # Главный контракт: байты — валидный .xlsx, перечитываемый openpyxl. wb = _reload(export_report_xlsx(_full_report())) assert wb.sheetnames # не пусто def test_expected_sheet_titles_present(self) -> None: wb = _reload(export_report_xlsx(_full_report())) for title in _EXPECTED_SHEETS: assert title in wb.sheetnames, f"отсутствует лист {title}" def test_known_cells_populated_summary(self) -> None: wb = _reload(export_report_xlsx(_full_report())) ws = wb[_SHEET_SUMMARY] # Заголовок книги + ADVISORY-маркер в первых строках. assert "§13" in str(ws.cell(row=1, column=1).value) assert "ADVISORY" in str(ws.cell(row=2, column=1).value) # Заголовок вердикта (значение метки «Заголовок») присутствует где-то на листе. flat = [str(c.value) for col in ws.iter_cols() for c in col if c.value is not None] assert any("Строить комфорт" in v for v in flat) assert any("66:41:0000000:1" in v for v in flat) def test_known_cells_populated_product_tz(self) -> None: wb = _reload(export_report_xlsx(_full_report())) ws = wb[_SHEET_PRODUCT_TZ] flat = [str(c.value) for col in ws.iter_cols() for c in col if c.value is not None] assert any("комфорт" in v for v in flat) assert any("1-Студия" in v for v in flat) def test_known_cells_populated_scoring(self) -> None: wb = _reload(export_report_xlsx(_full_report())) ws = wb[_SHEET_SCORING] flat = [c.value for col in ws.iter_cols() for c in col if c.value is not None] # overall-скор 0.71 присутствует как число (округление сохраняет значение). assert 0.71 in flat assert any(isinstance(v, str) and "market_fit" in v for v in flat) def test_accepts_as_dict_input(self) -> None: # Экспортёр принимает уже-словарь `as_dict()` (не только dataclass). payload = export_report_xlsx(_full_report().as_dict()) wb = _reload(payload) for title in _EXPECTED_SHEETS: assert title in wb.sheetnames # ── Regression #989/#991: реальные ключи контракта попадают в ячейки (НЕ "—") ─── # Тянем значения из РЕАЛЬНОГО `SiteFinderReport.as_dict()` (а не hand-typed строк): # если экспортёр снова прочитает несуществующий ключ (demand/supply / per-scenario # overall — как до фикса), число пропадёт из ячеек → тест упадёт. Контракт и тест # не разойдутся молча (root-cause старого зелёного теста — устаревшая фикстура). def _cell_values(ws) -> list: # type: ignore[no-untyped-def] """Все непустые значения ячеек листа (как есть, без str-приведения).""" return [c.value for col in ws.iter_cols() for c in col if c.value is not None] class TestContractKeysWritten: def test_demand_supply_units_in_cells_not_dash(self) -> None: data = _full_report().as_dict() forecasts = data["future_market"]["forecasts_by_horizon"] assert forecasts, "фикстура должна содержать прогнозы по горизонтам" ws = _reload(export_report_xlsx(_full_report()))[_SHEET_FUTURE_MARKET] values = _cell_values(ws) for f in forecasts: demand = f["projected_demand_units"] supply = f["projected_supply_units"] assert demand is not None and supply is not None assert _fmt(demand) in values, f"спрос {demand} не в ячейках (ключ читается?)" assert _fmt(supply) in values, f"предложение {supply} не в ячейках" # «—» НЕ должен стоять там, где число существует. assert _dash_count_in_forecast_table(ws) == 0 def test_scenario_deficit_in_cells_not_dash(self) -> None: data = _full_report().as_dict() by_scenario = data["scenarios"]["by_scenario"] assert by_scenario, "фикстура должна содержать сценарии" ws = _reload(export_report_xlsx(_full_report()))[_SHEET_SCENARIOS] values = _cell_values(ws) for payload in by_scenario.values(): di = _scenario_deficit_index(payload) assert di is not None assert _fmt(di) in values, f"дефицит сценария {di} не в ячейках" def test_overall_score_in_cells(self) -> None: data = _full_report().as_dict() overall = data["scoring"]["overall"] assert overall is not None ws = _reload(export_report_xlsx(_full_report()))[_SHEET_SCORING] assert _fmt(overall) in _cell_values(ws) def test_future_supply_section_in_cells(self) -> None: # #991: давление будущего предложения (index + breakdown по слоям) на листе. data = _full_report().as_dict() fs = data["future_market"]["future_supply"] assert fs is not None ws = _reload(export_report_xlsx(_full_report()))[_SHEET_FUTURE_MARKET] flat_str = [str(v) for v in _cell_values(ws)] values = _cell_values(ws) assert any("Давление будущего предложения" in v for v in flat_str) assert _fmt(fs["index"]) in values assert _fmt(fs["breakdown"]["open_units"]) in values def test_confidence_factors_in_cells(self) -> None: # #991: confidence.factors (ключ→значение) на листе «Сводка». ws = _reload(export_report_xlsx(_full_report()))[_SHEET_SUMMARY] flat_str = [str(v) for v in _cell_values(ws)] assert any("Факторы уверенности" in v for v in flat_str) assert any("data_coverage" in v for v in flat_str) def _dash_count_in_forecast_table(ws) -> int: # type: ignore[no-untyped-def] """Сколько ячеек спроса/предложения в таблице прогноза равны «—» (должно быть 0). Находим строку-шапку «Спрос»/«Предложение», считаем «—» в этих колонках ниже неё. """ from app.services.exporters.excel import _DASH header_row = None demand_col = supply_col = None for r in range(1, ws.max_row + 1): for c in range(1, ws.max_column + 1): val = ws.cell(row=r, column=c).value if val == "Спрос": header_row, demand_col = r, c elif val == "Предложение": supply_col = c if header_row is None or demand_col is None or supply_col is None: return 0 count = 0 for r in range(header_row + 1, ws.max_row + 1): for col in (demand_col, supply_col): if ws.cell(row=r, column=col).value == _DASH: count += 1 return count # ── Graceful: частичный / пустой / мусорный вход → валидный .xlsx без падения ── class TestGracefulPartialReport: def test_empty_report_still_valid_xlsx(self) -> None: # Полностью дефолтный (пустой) отчёт — валидный .xlsx, все листы, не падает. payload = export_report_xlsx(SiteFinderReport()) assert len(payload) > 0 wb = _reload(payload) for title in _EXPECTED_SHEETS: assert title in wb.sheetnames def test_empty_report_shows_no_data_marker(self) -> None: wb = _reload(export_report_xlsx(SiteFinderReport())) ws = wb[_SHEET_SCORING] flat = [str(c.value) for col in ws.iter_cols() for c in col if c.value is not None] # Пустая секция рисует заглушку «нет данных» (graceful), а не падает. assert any("нет данных" in v for v in flat) def test_partial_report_some_sections(self) -> None: # Заполнены только meta + exec_summary — остальные пусты, .xlsx валиден. report = SiteFinderReport( exec_summary=ReportExecSummary(headline="Тонкий анализ — только заголовок."), meta=ReportMeta(cad_num="66:41:0000000:2", horizons=[12]), ) wb = _reload(export_report_xlsx(report)) ws = wb[_SHEET_SUMMARY] flat = [str(c.value) for col in ws.iter_cols() for c in col if c.value is not None] assert any("Тонкий анализ" in v for v in flat) assert any("66:41:0000000:2" in v for v in flat) def test_garbage_input_does_not_crash(self) -> None: # Мусор (None / не-отчёт) → пустой, но валидный .xlsx (нормализация в {}). for junk in (None, 123, "not a report"): payload = export_report_xlsx(junk) assert len(payload) > 0 wb = _reload(payload) assert _SHEET_SUMMARY in wb.sheetnames # ── #1244: formula/DDE-injection — внешние строки не должны стать формулой Excel ── # openpyxl сохраняет строку с лидирующим `= + - @ \t \r` как data_type='f' (формула), # Excel/LibreOffice исполнит её при открытии. Внешние/скрейпинг-поля (comm_name из # DOM.РФ, headline/usp_text/advisory) попадают в ячейки через `_fmt` → должны быть # нейтрализованы префиксом `'` (OWASP CSV-injection) и трактоваться как ТЕКСТ. class TestFormulaInjection: def test_sanitize_prefixes_dangerous_strings(self) -> None: # Каждый OWASP-префикс получает ведущий апостроф. for payload in ("=SUM(A1:A2)", "+1+1", "-2+3", "@cmd", "\tTAB", "\rCR"): assert _sanitize_formula(payload) == "'" + payload def test_sanitize_leaves_safe_strings_untouched(self) -> None: for safe in ("ЖК Альфа", "комфорт", "250 000", "0.34", ""): assert _sanitize_formula(safe) == safe def test_fmt_escapes_formula_string(self) -> None: # Строка-«формула» через _fmt → текст с апострофом (не исполняемая формула). assert _fmt("=1+1") == "'=1+1" assert _fmt("@SUM(1)") == "'@SUM(1)" def test_fmt_does_not_touch_numbers_or_safe_text(self) -> None: # Числа/безопасный текст не трогаем: отрицательный float — это значение, не строка. assert _fmt(-0.5) == -0.5 assert _fmt(-3) == -3 assert _fmt("ЖК Бета") == "ЖК Бета" def test_injected_competitor_name_written_as_text_not_formula(self) -> None: # comm_name из скрейпинга DOM.РФ с лидирующим `=` → ячейка text, не формула. report = SiteFinderReport( market_now=ReportMarketNow( competitors=[ {"obj_id": 1, "comm_name": "=cmd|'/c calc'!A1", "relevance_weight": 0.8} ], ), ) ws = _reload(export_report_xlsx(report))[_SHEET_MARKET_NOW] injected = [ c for col in ws.iter_cols() for c in col if isinstance(c.value, str) and "calc" in c.value ] assert injected, "имя конкурента должно присутствовать в книге" for cell in injected: assert cell.data_type == "s", "ячейка должна быть текстом, не формулой ('f')" assert cell.value.startswith("'="), "опасный префикс должен быть экранирован" def test_injected_headline_and_usp_written_as_text(self) -> None: # headline (Сводка) и usp_text (Продукт ТЗ) — тоже внешние → не формула. report = SiteFinderReport( exec_summary=ReportExecSummary(headline="=HYPERLINK('http://evil','x')"), product_tz=ReportProductTz( usp=[{"segment": "1-Студия", "usp_text": "+evil()"}], ), ) payload = export_report_xlsx(report) wb = _reload(payload) formula_cells = [ c for ws in wb.worksheets for col in ws.iter_cols() for c in col if c.data_type == "f" ] assert not formula_cells, f"ни одна ячейка не должна быть формулой: {formula_cells}"