fix(exporters): real SiteFinderReport keys in PDF/Excel (#989 #991) #1037

Merged
bot-backend merged 1 commit from fix/989-991-exporter-keys into main 2026-06-04 05:56:58 +00:00
4 changed files with 468 additions and 21 deletions

View file

@ -53,6 +53,11 @@ _ADVISORY_MARKER: str = (
)
# Округление чисел по умолчанию (скоры/индексы ∈ [0,1] и доли — 3 знака читаемо).
_ROUND_DIGITS: int = 3
# Основной продуктовый горизонт (мес) — из него тянем сводный deficit_index сценария
# (зеркало report_assembler._PRIMARY_HORIZON_MONTHS). ScenarioForecast.as_dict() несёт
# список forecasts по горизонтам, скалярного «overall» у сценария НЕТ — берём дефицит
# основного горизонта как сводный показатель сценария.
_PRIMARY_HORIZON_MONTHS: int = 12
# RU-метки уровней отчётной уверенности (§15) для листа «Сводка».
_LEVEL_RU: dict[str, str] = {
@ -135,6 +140,52 @@ def _level_ru(level: Any) -> str:
return _DASH
def _future_supply_pairs(future_supply: Any) -> dict[str, Any]:
"""Плоская карта «метка → значение» по FutureSupplyPressure.as_dict() (§9.3). PURE.
`future_supply` = `FutureSupplyPressure.as_dict()`: верхнеуровневый `index`/контекст +
вложенный `breakdown` (объёмы по слоям, поглощение, месяцы давления). Разворачиваем
breakdown на верхний уровень с RU-метками. Пустой/None {} (graceful «нет данных»).
"""
data = _as_dict(future_supply)
if not data:
return {}
breakdown = _as_dict(data.get("breakdown"))
return {
"Индекс давления": data.get("index"),
"Уверенность": _level_ru(data.get("confidence")),
"Открытый сток (ед.)": breakdown.get("open_units"),
"Скрытый запас (ед.)": breakdown.get("hidden_units"),
"Будущий слой в горизонте (ед.)": breakdown.get("future_units_by_horizon"),
"Поглощение (ед./мес)": breakdown.get("monthly_absorption_units"),
"Месяцев давления": breakdown.get("months_of_pressure"),
}
def _scenario_deficit_index(payload: dict[str, Any]) -> Any:
"""Сводный deficit_index сценария = дефицит основного горизонта (12 мес). PURE.
`payload` = `ScenarioForecast.as_dict()`: у сценария НЕТ скалярного «overall» есть
список `forecasts` по горизонтам, каждый с `deficit_index`. Берём горизонт
`_PRIMARY_HORIZON_MONTHS`, иначе первый с не-None дефицитом. Нет None ( "").
"""
forecasts = _as_list(payload.get("forecasts"))
primary = next(
(
f
for f in forecasts
if isinstance(f, dict) and f.get("horizon_months") == _PRIMARY_HORIZON_MONTHS
),
None,
)
if primary is not None and primary.get("deficit_index") is not None:
return primary.get("deficit_index")
for f in forecasts:
if isinstance(f, dict) and f.get("deficit_index") is not None:
return f.get("deficit_index")
return None
# ──────────────────────────────────────────────────────────────────────────────
# Низкоуровневые помощники записи листа (заголовки/строки/ширины). PURE-побочка
# (мутируют переданный лист). Не ходят в БД/сеть.
@ -236,6 +287,16 @@ def _build_summary_sheet(ws: Worksheet, report: dict[str, Any]) -> None:
row = _write_kv(ws, row, "Обоснование", confidence.get("rationale"))
row += 1
# ── Факторы уверенности (§15 #990 — что тянуло уверенность) ──
row = _write_title(ws, row, "Факторы уверенности")
factors = _as_dict(confidence.get("factors"))
if factors:
for key, value in factors.items():
row = _write_kv(ws, row, str(key), value)
else:
row = _write_no_data(ws, row)
row += 1
# ── Контекст (meta) ──
row = _write_title(ws, row, "Контекст")
row = _write_kv(ws, row, "Кадастровый номер", meta.get("cad_num"))
@ -308,8 +369,8 @@ def _build_future_market_sheet(ws: Worksheet, report: dict[str, Any]) -> None:
table_rows = [
[
f.get("horizon_months"),
f.get("demand"),
f.get("supply"),
f.get("projected_demand_units"),
f.get("projected_supply_units"),
f.get("deficit_index"),
_level_ru(f.get("confidence")),
]
@ -319,6 +380,16 @@ def _build_future_market_sheet(ws: Worksheet, report: dict[str, Any]) -> None:
row = _write_table(ws, row, headers, table_rows)
row += 1
# ── Давление будущего предложения §9.3 (FutureSupplyPressure.as_dict()) ──
row = _write_title(ws, row, "Давление будущего предложения")
future_supply_pairs = _future_supply_pairs(future.get("future_supply"))
if future_supply_pairs:
for label, value in future_supply_pairs.items():
row = _write_kv(ws, row, label, value)
else:
row = _write_no_data(ws, row)
row += 1
# ── Разброс сценариев по дефициту ──
row = _write_title(ws, row, "Разброс сценариев (индекс дефицита)")
if scenarios_summary:
@ -383,13 +454,13 @@ def _build_scenarios_sheet(ws: Worksheet, report: dict[str, Any]) -> None:
row = _write_kv(ws, row, "Резюме", scenarios.get("summary"))
row += 1
# ── Таблица сценариев (имя + overall + advisory; прочие ключи в «детали») ──
# ── Таблица сценариев (имя + сводный дефицит 12 мес + advisory) ──
row = _write_title(ws, row, "Сводка по сценариям")
headers = ["Сценарий", "Overall", "Advisory"]
headers = ["Сценарий", "Индекс дефицита (12 мес)", "Advisory"]
table_rows: list[list[Any]] = []
for name, payload in by_scenario.items():
data = _as_dict(payload)
table_rows.append([name, data.get("overall"), data.get("advisory")])
table_rows.append([name, _scenario_deficit_index(data), data.get("advisory")])
_write_table(ws, row, headers, table_rows)
_set_widths(ws, [_LABEL_COL_WIDTH, _VALUE_COL_WIDTH, _VALUE_COL_WIDTH])

View file

@ -55,6 +55,11 @@ _ADVISORY_MARKER: str = (
)
# Округление чисел по умолчанию (скоры/индексы ∈ [0,1] и доли — 3 знака читаемо).
_ROUND_DIGITS: int = 3
# Основной продуктовый горизонт (мес) — из него тянем сводный deficit_index сценария
# (зеркало report_assembler._PRIMARY_HORIZON_MONTHS). ScenarioForecast.as_dict() несёт
# список forecasts по горизонтам, скалярного «overall» у сценария НЕТ — берём дефицит
# основного горизонта как сводный показатель сценария.
_PRIMARY_HORIZON_MONTHS: int = 12
# Сноска-футер на каждой странице (зеркало advisory-духа форсайт-стека).
_FOOTER_NOTE: str = "GenDesign · Site Finder v2 · советующий отчёт §13 (не оферта)"
@ -261,6 +266,28 @@ def _join_ints(values: list[Any]) -> Any:
return ", ".join(str(v) for v in values) if values else None
def _future_supply_pairs(future_supply: Any) -> dict[str, Any]:
"""Плоская карта «метка → значение» по FutureSupplyPressure.as_dict() (§9.3). PURE.
`future_supply` = `FutureSupplyPressure.as_dict()`: верхнеуровневый `index`/контекст +
вложенный `breakdown` (объёмы по слоям, поглощение, месяцы давления). Разворачиваем
breakdown на верхний уровень с RU-метками. Пустой/None {} (graceful «нет данных»).
"""
data = _as_dict(future_supply)
if not data:
return {}
breakdown = _as_dict(data.get("breakdown"))
return {
"Индекс давления": data.get("index"),
"Уверенность": _level_ru(data.get("confidence")),
"Открытый сток (ед.)": breakdown.get("open_units"),
"Скрытый запас (ед.)": breakdown.get("hidden_units"),
"Будущий слой в горизонте (ед.)": breakdown.get("future_units_by_horizon"),
"Поглощение (ед./мес)": breakdown.get("monthly_absorption_units"),
"Месяцев давления": breakdown.get("months_of_pressure"),
}
# ──────────────────────────────────────────────────────────────────────────────
# Построители секций — по одной на содержательную секцию §13. Каждый graceful:
# пустая секция → «нет данных», НИКОГДА не падает. Возвращают HTML-фрагмент.
@ -276,6 +303,7 @@ def _build_summary(report: dict[str, Any]) -> str:
headline = exec_summary.get("headline")
verdict = exec_summary.get("verdict")
key_numbers = _as_dict(exec_summary.get("key_numbers"))
factors = _as_dict(confidence.get("factors"))
cad = _esc(meta.get("cad_num"))
district = _esc(meta.get("district"))
@ -313,6 +341,9 @@ def _build_summary(report: dict[str, Any]) -> str:
<h3>Уверенность отчёта</h3>
{_kv_table(confidence_pairs)}
<h3>Факторы уверенности</h3>
{_dict_kv_table(factors)}
<h3>Контекст</h3>
{_kv_table(context_pairs)}
</div>
@ -358,8 +389,8 @@ def _build_future_market(report: dict[str, Any]) -> str:
forecast_rows = [
[
f.get("horizon_months"),
f.get("demand"),
f.get("supply"),
f.get("projected_demand_units"),
f.get("projected_supply_units"),
f.get("deficit_index"),
_level_ru(f.get("confidence")),
]
@ -376,6 +407,9 @@ def _build_future_market(report: dict[str, Any]) -> str:
<h3>Прогноз по горизонтам</h3>
{_data_table(forecast_headers, forecast_rows)}
<h3>Давление будущего предложения</h3>
{_dict_kv_table(_future_supply_pairs(future.get("future_supply")))}
<h3>Разброс сценариев (индекс дефицита)</h3>
{_dict_kv_table(scenarios_summary)}
</div>
@ -418,6 +452,30 @@ def _build_product_tz(report: dict[str, Any]) -> str:
"""
def _scenario_deficit_index(payload: dict[str, Any]) -> Any:
"""Сводный deficit_index сценария = дефицит основного горизонта (12 мес). PURE.
`payload` = `ScenarioForecast.as_dict()`: у сценария НЕТ скалярного «overall» есть
список `forecasts` по горизонтам, каждый с `deficit_index`. Берём горизонт
`_PRIMARY_HORIZON_MONTHS`, иначе первый с не-None дефицитом. Нет None ( "").
"""
forecasts = _as_list(payload.get("forecasts"))
primary = next(
(
f
for f in forecasts
if isinstance(f, dict) and f.get("horizon_months") == _PRIMARY_HORIZON_MONTHS
),
None,
)
if primary is not None and primary.get("deficit_index") is not None:
return primary.get("deficit_index")
for f in forecasts:
if isinstance(f, dict) and f.get("deficit_index") is not None:
return f.get("deficit_index")
return None
def _build_scenarios(report: dict[str, Any]) -> str:
"""Блок «Сценарии»: conservative/base/aggressive (таблица). Graceful."""
scenarios = _as_dict(report.get("scenarios"))
@ -426,15 +484,16 @@ def _build_scenarios(report: dict[str, Any]) -> str:
rows: list[list[Any]] = []
for name, payload in by_scenario.items():
data = _as_dict(payload)
rows.append([name, data.get("overall"), data.get("advisory")])
rows.append([name, _scenario_deficit_index(data), data.get("advisory")])
headers = ["Сценарий", "Индекс дефицита (12 мес)", "Advisory"]
return f"""
<div class="section" id="scenarios">
<h2>{html.escape(_TITLE_SCENARIOS)}</h2>
<p class="verdict">{_esc(scenarios.get("summary"))}</p>
<h3>Сводка по сценариям</h3>
{_data_table(["Сценарий", "Overall", "Advisory"], rows)}
{_data_table(headers, rows)}
</div>
"""

View file

@ -30,8 +30,11 @@ from app.services.exporters.excel import (
_SHEET_SCENARIOS,
_SHEET_SCORING,
_SHEET_SUMMARY,
_fmt,
_scenario_deficit_index,
export_report_xlsx,
)
from app.services.forecasting.demand_supply_forecast import DemandSupplyForecast
from app.services.forecasting.report import (
ReportConfidence,
ReportExecSummary,
@ -43,6 +46,72 @@ from app.services.forecasting.report import (
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,
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,
rate_future=18.0,
rate_sensitivity_phrase=None,
future_competitors=[],
advisory=True,
confidence="medium",
)
],
advisory=True,
).as_dict()
# Шесть ожидаемых листов (по одному на содержательную секцию §13).
_EXPECTED_SHEETS: tuple[str, ...] = (
@ -75,10 +144,24 @@ def _full_report() -> SiteFinderReport:
),
future_market=ReportFutureMarket(
forecasts_by_horizon=[
{"horizon_months": 6, "demand": 410, "supply": 380, "deficit_index": 0.2},
{"horizon_months": 12, "demand": 920, "supply": 700, "deficit_index": 0.34},
_forecast(6, demand=410.0, supply=380.0, deficit=0.2),
_forecast(12, demand=920.0, supply=700.0, deficit=0.34),
],
future_supply={"index": 0.42, "horizon_months": 12, "confidence": "low"},
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 мес.",
@ -97,9 +180,9 @@ def _full_report() -> SiteFinderReport:
),
scenarios=ReportScenarios(
by_scenario={
"conservative": {"overall": 0.18, "advisory": True},
"base": {"overall": 0.34, "advisory": True},
"aggressive": {"overall": 0.49, "advisory": True},
"conservative": _scenario("conservative", deficit_12mo=0.18),
"base": _scenario("base", deficit_12mo=0.34),
"aggressive": _scenario("aggressive", deficit_12mo=0.49),
},
summary="Разброс сценариев умеренный.",
),
@ -188,6 +271,98 @@ class TestFullReportExport:
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 без падения ──

View file

@ -31,9 +31,12 @@ from app.services.exporters.report_pdf import (
_TITLE_SCORING,
_TITLE_SUMMARY,
_build_html,
_fmt,
_normalize,
_scenario_deficit_index,
export_report_pdf,
)
from app.services.forecasting.demand_supply_forecast import DemandSupplyForecast
from app.services.forecasting.report import (
ReportConfidence,
ReportExecSummary,
@ -45,6 +48,71 @@ from app.services.forecasting.report import (
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,
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,
rate_future=18.0,
rate_sensitivity_phrase=None,
future_competitors=[],
advisory=True,
confidence="medium",
)
],
advisory=True,
).as_dict()
def _weasyprint_native_libs_available() -> tuple[bool, str]:
@ -102,10 +170,24 @@ def _full_report() -> SiteFinderReport:
),
future_market=ReportFutureMarket(
forecasts_by_horizon=[
{"horizon_months": 6, "demand": 410, "supply": 380, "deficit_index": 0.2},
{"horizon_months": 12, "demand": 920, "supply": 700, "deficit_index": 0.34},
_forecast(6, demand=410.0, supply=380.0, deficit=0.2),
_forecast(12, demand=920.0, supply=700.0, deficit=0.34),
],
future_supply={"index": 0.42, "horizon_months": 12, "confidence": "low"},
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 мес.",
@ -124,9 +206,9 @@ def _full_report() -> SiteFinderReport:
),
scenarios=ReportScenarios(
by_scenario={
"conservative": {"overall": 0.18, "advisory": True},
"base": {"overall": 0.34, "advisory": True},
"aggressive": {"overall": 0.49, "advisory": True},
"conservative": _scenario("conservative", deficit_12mo=0.18),
"base": _scenario("base", deficit_12mo=0.34),
"aggressive": _scenario("aggressive", deficit_12mo=0.49),
},
summary="Разброс сценариев умеренный.",
),
@ -219,6 +301,66 @@ class TestRenderedHtmlContent:
assert "&amp; co" in html_str
# ── Regression #989: реальные ключи контракта попадают в вывод (НЕ "—") ─────────
# Эти тесты ловят key-drift экспортёра: они тянут значения из РЕАЛЬНОГО
# `SiteFinderReport.as_dict()` (а не hand-typed строк), поэтому если экспортёр снова
# начнёт читать несуществующий ключ (как demand/supply / per-scenario overall до фикса),
# значение пропадёт из HTML → тест упадёт. Так контракт и тест не разойдутся молча.
class TestContractKeysRendered:
def test_demand_supply_units_rendered_not_dash(self) -> None:
# Берём числа спроса/предложения из самого as_dict() (форма #952), затем
# проверяем, что они ЕСТЬ в HTML — старый bug читал demand/supply → было "—".
data = _full_report().as_dict()
forecasts = data["future_market"]["forecasts_by_horizon"]
assert forecasts, "фикстура должна содержать прогнозы по горизонтам"
html_str = _build_html(_normalize(_full_report()))
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 str(_fmt(demand)) in html_str, f"спрос {demand} не отрисован (читается ключ?)"
assert str(_fmt(supply)) in html_str, f"предложение {supply} не отрисован"
def test_scenario_deficit_rendered_not_dash(self) -> None:
# Сводный дефицит 12-мес горизонта каждого сценария должен попасть в HTML.
# Старый bug читал per-scenario data.get("overall") (нет такого ключа) → "—".
data = _full_report().as_dict()
by_scenario = data["scenarios"]["by_scenario"]
assert by_scenario, "фикстура должна содержать сценарии"
html_str = _build_html(_normalize(_full_report()))
for payload in by_scenario.values():
di = _scenario_deficit_index(payload)
assert di is not None
assert str(_fmt(di)) in html_str, f"дефицит сценария {di} не отрисован"
def test_overall_score_rendered(self) -> None:
# scoring.overall (ReportScoring) — корректный ключ; убеждаемся, что он в HTML.
data = _full_report().as_dict()
overall = data["scoring"]["overall"]
assert overall is not None
html_str = _build_html(_normalize(_full_report()))
assert str(_fmt(overall)) in html_str
def test_future_supply_section_rendered(self) -> None:
# #991/parity: давление будущего предложения (index + breakdown) должно
# попасть в HTML — раньше PDF не разворачивал breakdown по слоям.
data = _full_report().as_dict()
fs = data["future_market"]["future_supply"]
assert fs is not None
html_str = _build_html(_normalize(_full_report()))
assert "Давление будущего предложения" in html_str
assert str(_fmt(fs["index"])) in html_str
assert str(_fmt(fs["breakdown"]["open_units"])) in html_str
def test_confidence_factors_rendered(self) -> None:
# confidence.factors должны попасть в HTML (ключ→значение).
html_str = _build_html(_normalize(_full_report()))
assert "Факторы уверенности" in html_str
assert "data_coverage" in html_str
# ── Graceful: частичный / пустой / мусорный вход → валидный PDF без падения ─────