gendesign/frontend/src/components/site-finder/ptica/__tests__/ptica-adapt.honesty.test.ts
Light1YT 7e42b34a46
All checks were successful
CI / changes (pull_request) Successful in 6s
CI / frontend-tests (pull_request) Successful in 59s
CI / openapi-codegen-check (pull_request) Successful in 1m55s
CI / backend-tests (pull_request) Successful in 11m44s
feat(financial): LTC 70%/equity 30% норматив МКД РФ в модели финансирования
Заменяет 100%-кредитное покрытие разрыва реалистичной LTC-моделью:
банк покрывает 70 % каждого оттока, собственные средства — 30 %
(без начисления процентов). Итог: total_interest снижается ≈ на 30 %,
net_profit_after_financing_rub растёт соответственно.

- financial.py: добавлен _LOAN_TO_COST_RATIO=0.70, _compute_financing принимает
  ltc_ratio (default 1.0 для BC), call site передаёт константу
- ptica-adapt.ts: caveat «financing_is_simplified» уточнён (без англицизмов)
- test_financial_dcf.py: 2 новых теста (ltc_reduces_debt, uses_ltc_ratio)
- honesty.test.ts: assertion обновлён под новый текст caveat
2026-06-24 21:37:17 +05:00

313 lines
12 KiB
TypeScript
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.

/**
* ptica-adapt honesty tests — audit-issue #1871 P1.3 «ptica-adapt помечает
* заглушки isReal:true».
*
* The adapter is the single source of truth for the real-vs-placeholder
* decision; a stub must NEVER carry `isReal:true`, or the cockpit presents a
* fabricated value as a live measurement. These three cases pin the fixed
* spots:
* 1. adaptPassport → encumbrances is a stub (never from backend yet).
* 2. buySignalFromForecast at deficit_index=null → «прогноза нет», not a
* real «наблюдать».
* 3. adaptBuySignalGauge with a report whose deficit_index=null → the gauge
* is not real (no synthetic mid-scale 50 dressed up as live).
*/
import { describe, expect, it } from "vitest";
import {
adaptBuySignalGauge,
adaptEconomyCard,
adaptFinanceDrawer,
adaptInvestmentClearance,
adaptLegalDrawer,
adaptPassport,
adaptPassportDrawer,
adaptSiteVerdict,
buySignalFromForecast,
} from "@/components/site-finder/ptica/ptica-adapt";
import type {
ParcelAnalysis,
ParcelFinancialEstimate,
} from "@/types/site-finder";
import type { ForecastReport } from "@/types/forecast";
// Tracked mock fixtures (NEXT_PUBLIC_USE_MOCKS source). The dev-only
// __preview/ptica/__fixtures__ dir is git-excluded, so it is absent in CI —
// these committed mocks keep the suite reproducible on the Linux runner.
import forecastFixture from "@/lib/mocks/parcel-forecast.json";
import analyzeFixture from "@/lib/mocks/parcel-analyze.json";
const analysis = analyzeFixture as unknown as ParcelAnalysis;
const baseReport = (forecastFixture as { report: unknown })
.report as ForecastReport;
/** A report identical to the fixture but with no deficit_index (thin §22). */
function reportWithoutDeficitIndex(): ForecastReport {
return {
...baseReport,
exec_summary: {
...baseReport.exec_summary,
key_numbers: {
...baseReport.exec_summary.key_numbers,
deficit_index: null,
},
},
};
}
describe("ptica-adapt honesty (#1871 P1.3)", () => {
it("adaptPassport: encumbrances is a stub, never real", () => {
const passport = adaptPassport(analysis);
expect(passport.encumbrances.isReal).toBe(false);
expect(passport.encumbrances.caption).toBeTruthy();
});
it("buySignalFromForecast: deficit_index=null is not a real signal", () => {
const field = buySignalFromForecast(reportWithoutDeficitIndex());
expect(field.isReal).toBe(false);
expect(field.caption).toBeTruthy();
});
it("buySignalFromForecast: a real deficit_index yields a real signal", () => {
const field = buySignalFromForecast({
...baseReport,
exec_summary: {
...baseReport.exec_summary,
key_numbers: {
...baseReport.exec_summary.key_numbers,
deficit_index: 0.2,
},
},
});
expect(field.isReal).toBe(true);
});
it("adaptBuySignalGauge: report with deficit_index=null → gauge not real", () => {
const card = adaptBuySignalGauge(reportWithoutDeficitIndex());
expect(card.gauge.isReal).toBe(false);
expect(card.gauge.value).toBeNull();
expect(card.available).toBe(false);
});
it("drawer «Обременения» rows are stubs, never real (#1871 P1.3)", () => {
// The passport-card encumbrances fix must also cover its two drawer mirrors
// (adaptPassportDrawer + adaptLegalDrawer), else the drill-down presents the
// em-dash stub as a live measurement.
const findEncumbrance = (
rows: { k: string; field: { isReal: boolean } }[],
) => rows.find((r) => r.k === "Обременения");
const passportRow = findEncumbrance(adaptPassportDrawer(analysis).registry);
expect(passportRow?.field.isReal).toBe(false);
const legalRow = findEncumbrance(adaptLegalDrawer(analysis).registry);
expect(legalRow?.field.isReal).toBe(false);
});
});
// ── #1881 PR-4 — финмодель в Investment Clearance ──────────────────────────────
function financialFixture(): ParcelFinancialEstimate {
return {
revenue_rub: 4_200_000_000,
cost_rub: 3_100_000_000,
net_profit_rub: 1_100_000_000,
roi: 0.355,
margin_pct: 0.262,
irr: 0.214,
irr_is_proxy: false,
npv_rub: 640_000_000,
payback_months: 42,
discount_rate_used: 0.18,
price_per_sqm_used: 145_000,
price_is_calibrated: true,
price_source: "objective",
housing_class_inferred: "comfort",
development_type_inferred: "high_rise",
schedule_is_default: true,
sales_duration_months: 30,
teap_synth: {
residential_area_sqm: 28_000,
total_floor_area_sqm: 34_000,
parking_spaces: 280,
apartments_count: 520,
},
caveat: "Расчёт по максимальной застройке НСПД, а не реальной концепции.",
// #1881 PR-5 — финансирование (кредит).
financing_enabled: true,
annual_rate_used: 0.18,
peak_debt_rub: 1_800_000_000,
total_interest_rub: 320_000_000,
net_profit_after_financing_rub: 780_000_000,
financing_is_simplified: true,
};
}
describe("ptica-adapt finance (#1881 PR-4)", () => {
it("adaptInvestmentClearance: null → all placeholders, never real", () => {
const c = adaptInvestmentClearance(null);
expect(c.bignums.every((b) => b.isReal === false)).toBe(true);
expect(c.bignums.every((b) => b.value === "—")).toBe(true);
expect(c.caption).toContain("финмодели");
});
it("adaptInvestmentClearance: estimate → real bignums + NPV + payback", () => {
const c = adaptInvestmentClearance(financialFixture());
expect(c.bignums.every((b) => b.isReal === true)).toBe(true);
const labels = c.bignums.map((b) => b.label);
expect(labels).toContain("NPV");
expect(labels).toContain("Срок окуп.");
// GDV 4.2 млрд ₽ formatted ru (запятая-разделитель дробной части).
const gdv = c.bignums.find((b) => b.label === "GDV · выручка");
expect(gdv?.value).toBe("4,20");
// ROI 35,5 %.
const roi = c.bignums.find((b) => b.label === "ROI");
expect(roi?.value).toBe("35,5 %");
});
it("adaptInvestmentClearance: payback_months=null → «не окуп.»", () => {
const c = adaptInvestmentClearance({
...financialFixture(),
payback_months: null,
});
const payback = c.bignums.find((b) => b.label === "Срок окуп.");
expect(payback?.value).toBe("не окуп.");
});
it("adaptInvestmentClearance: irr_is_proxy flips the IRR caption", () => {
const proxy = adaptInvestmentClearance({
...financialFixture(),
irr_is_proxy: true,
});
const irr = proxy.bignums.find((b) => b.label === "IRR");
expect(irr?.caption).toContain("proxy");
});
it("adaptFinanceDrawer: null → placeholder rows + §22 summary", () => {
const d = adaptFinanceDrawer(null);
expect(d.rows.every((r) => r.field.isReal === false)).toBe(true);
expect(d.summary).toContain("§22");
});
it("adaptFinanceDrawer: estimate → real rows + caveat summary", () => {
const fin = financialFixture();
const d = adaptFinanceDrawer(fin);
expect(d.rows.every((r) => r.field.isReal === true)).toBe(true);
const keys = d.rows.map((r) => r.k);
expect(keys).toContain("IRR");
expect(keys).toContain("ТЭП · квартир");
});
// #1881 PR-5 — финансирование (кредит)
it("adaptFinanceDrawer: financing_enabled → debt/interest/net-after rows + caption", () => {
const fin = financialFixture(); // financing_enabled: true
const d = adaptFinanceDrawer(fin);
const keys = d.rows.map((r) => r.k);
expect(keys).toContain("Пиковый долг");
expect(keys).toContain("Проценты по кредиту");
expect(keys).toContain("Чистая прибыль после финанс.");
const interest = d.rows.find((r) => r.k === "Проценты по кредиту");
expect(interest?.field.isReal).toBe(true);
expect(interest?.field.caption).toContain("18");
expect(interest?.field.caption).toContain("годовых");
});
it("adaptFinanceDrawer: financing_is_simplified → кредитная/собственные caveat appended to summary", () => {
const d = adaptFinanceDrawer(financialFixture());
expect(d.summary).toContain("кредитная линия");
expect(d.summary).toContain("собственные средства");
expect(d.summary).toContain("эскроу");
});
it("adaptFinanceDrawer: financing_enabled=false → no financing rows", () => {
const d = adaptFinanceDrawer({
...financialFixture(),
financing_enabled: false,
});
const keys = d.rows.map((r) => r.k);
expect(keys).not.toContain("Пиковый долг");
expect(keys).not.toContain("Проценты по кредиту");
expect(keys).not.toContain("Чистая прибыль после финанс.");
});
it("adaptFinanceDrawer: sales_duration_months shown with schedule source", () => {
const d = adaptFinanceDrawer(financialFixture()); // schedule_is_default: true
const row = d.rows.find((r) => r.k === "Горизонт продаж");
expect(row?.field.isReal).toBe(true);
expect(row?.field.value).toContain("30");
expect(row?.field.caption).toContain("дефолт");
const d2 = adaptFinanceDrawer({
...financialFixture(),
schedule_is_default: false,
});
const row2 = d2.rows.find((r) => r.k === "Горизонт продаж");
expect(row2?.field.caption).toContain("поглощения");
});
it("adaptInvestmentClearance: financing_enabled → «После финанс.» + relabeled profit", () => {
const c = adaptInvestmentClearance(financialFixture()); // financing_enabled: true
const labels = c.bignums.map((b) => b.label);
expect(labels).toContain("После финанс.");
expect(labels).toContain("Profit · до финанс.");
const after = c.bignums.find((b) => b.label === "После финанс.");
expect(after?.isReal).toBe(true);
expect(after?.caption).toContain("кредит");
});
it("adaptEconomyCard: no financial → all placeholders + badge предв.", () => {
const card = adaptEconomyCard(null);
expect(card.badge).toBe("предв.");
expect(card.rows.every((r) => r.field.isReal === false)).toBe(true);
});
it("adaptEconomyCard: financial present → real rows + badge ориент.", () => {
const card = adaptEconomyCard(financialFixture());
expect(card.badge).toBe("ориент.");
expect(card.rows.every((r) => r.field.isReal === true)).toBe(true);
const rev = card.rows.find((r) => r.key === "Потенц. выручки");
expect(rev?.field.value).toContain("млрд");
expect(rev?.field.isReal).toBe(true);
});
});
// ── #1881 PR-5 gate / site-verdict ─────────────────────────────────────────────
describe("ptica-adapt gate (#1892 ЗОУИТ area-gate)", () => {
it("adaptSiteVerdict: verdict_label «С ограничениями» → word=«С ограничениями» tone=warn", () => {
const a: ParcelAnalysis = {
...analyzeFixture,
gate_verdict: {
can_build_mkd: true,
verdict_label: "С ограничениями",
blockers: [],
warnings: [
{
code: "ZOUIT_ENGINEERING_PARTIAL",
detail: "Охранная зона ЛЭП перекрывает 12 % участка",
},
],
source: "nspd_dump",
},
} as unknown as ParcelAnalysis;
const v = adaptSiteVerdict(a);
expect(v.word).toBe("С ограничениями");
expect(v.tone).toBe("warn");
});
it("adaptSiteVerdict: can_build_mkd=true without ограничения → Приобретать good", () => {
const a: ParcelAnalysis = {
...analyzeFixture,
gate_verdict: {
can_build_mkd: true,
verdict_label: "Можно",
blockers: [],
warnings: [],
source: "nspd_dump",
},
} as unknown as ParcelAnalysis;
const v = adaptSiteVerdict(a);
expect(v.word).toBe("Приобретать");
expect(v.tone).toBe("good");
});
});