/** * 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"); }); });