Заменяет 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
313 lines
12 KiB
TypeScript
313 lines
12 KiB
TypeScript
/**
|
||
* 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");
|
||
});
|
||
});
|