From 2303207629bec5d7a8aafd45449041dc85bccc9c Mon Sep 17 00:00:00 2001 From: Light1YT Date: Tue, 23 Jun 2026 11:20:48 +0500 Subject: [PATCH] =?UTF-8?q?fix(ptica):=20honest=20isReal=20flags=20?= =?UTF-8?q?=E2=80=94=20placeholder=20stubs=20no=20longer=20marked=20real?= =?UTF-8?q?=20(audit=20#1871=20P1.3)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adapter — единственный источник истины real-vs-placeholder; стаб с isReal:true рисуется как живое измерение (обычная типографика «—», неотличимо от данных). Helpers real()/placeholder()/notReal() корректны — баг в их misuse на 5 местах. ptica-adapt.ts: - :163 encumbrances (passport-card): {value:PLACEHOLDER, isReal:true} → notReal("источник ЕГРН") - :272 buySignalFromForecast при deficit_index==null: {value:"наблюдать", isReal:true} → notReal("прогноз недоступен (Сценарии §22)") — отличает «прогноза нет» от «прогноз говорит наблюдать». di-present advisory-ветка (real) не тронута. - :1942 adaptBuySignalGauge при di==null: убран synthetic gaugeValue=50 как real; ранний if(di==null) зеркалит no-report ветку (value:null, isReal:false, available:false). - :681 adaptPassportDrawer registry, :1048 adaptLegalDrawer registry: те же «Обременения» стабы {value:DASH, isReal:true} → notReal (найдены code-review, recon их пропустил — drawer-зеркала card-фикса). ptica.module.css: .kvPlaceholder += opacity:0.7 — усиленный визуальный mute стаба. Корректные места НЕ тронуты (recon-verified): market demand-label (условный real/placeholder), engineering utilities, InvestScore potential. +5 unit-тестов (ptica-adapt.honesty.test.ts): encumbrances stub, buy-signal di=null, buy-signal real di (защита от over-downgrade), gauge di=null, оба drawer-обременения. Refs #1871 --- .../analysis/[cad]/ptica/ptica.module.css | 2 + .../__tests__/ptica-adapt.honesty.test.ts | 95 +++++++++++++++++++ .../site-finder/ptica/ptica-adapt.ts | 49 ++++++---- 3 files changed, 130 insertions(+), 16 deletions(-) create mode 100644 frontend/src/components/site-finder/ptica/__tests__/ptica-adapt.honesty.test.ts diff --git a/frontend/src/app/site-finder/analysis/[cad]/ptica/ptica.module.css b/frontend/src/app/site-finder/analysis/[cad]/ptica/ptica.module.css index 209ff344..c7058a79 100644 --- a/frontend/src/app/site-finder/analysis/[cad]/ptica/ptica.module.css +++ b/frontend/src/app/site-finder/analysis/[cad]/ptica/ptica.module.css @@ -607,6 +607,8 @@ .kvPlaceholder { color: var(--text-soft); font-weight: 500; + /* Stub / not-measured values read fainter than live numbers (audit #1871). */ + opacity: 0.7; } .kvCaption { display: block; diff --git a/frontend/src/components/site-finder/ptica/__tests__/ptica-adapt.honesty.test.ts b/frontend/src/components/site-finder/ptica/__tests__/ptica-adapt.honesty.test.ts new file mode 100644 index 00000000..e24fcf88 --- /dev/null +++ b/frontend/src/components/site-finder/ptica/__tests__/ptica-adapt.honesty.test.ts @@ -0,0 +1,95 @@ +/** + * 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, + adaptLegalDrawer, + adaptPassport, + adaptPassportDrawer, + buySignalFromForecast, +} from "@/components/site-finder/ptica/ptica-adapt"; +import type { ParcelAnalysis } from "@/types/site-finder"; +import type { ForecastReport } from "@/types/forecast"; + +import forecastFixture from "@/app/%5F%5Fpreview/ptica/__fixtures__/forecast.json"; +import analyzeFixture from "@/app/%5F%5Fpreview/ptica/__fixtures__/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); + }); +}); diff --git a/frontend/src/components/site-finder/ptica/ptica-adapt.ts b/frontend/src/components/site-finder/ptica/ptica-adapt.ts index 1e0d9bd0..d6101138 100644 --- a/frontend/src/components/site-finder/ptica/ptica-adapt.ts +++ b/frontend/src/components/site-finder/ptica/ptica-adapt.ts @@ -159,8 +159,8 @@ export function adaptPassport(a: ParcelAnalysis): PticaPassport { : placeholder("нет данных ЕГРН"), vri: vri ? real(vri) : placeholder("нет данных ЕГРН"), status: status ? real(status) : placeholder("нет данных ЕГРН"), - // Matches the existing analysis page: encumbrances are not surfaced yet. - encumbrances: { value: PLACEHOLDER, isReal: true }, + // encumbrances never arrive from the backend yet — always a stub. + encumbrances: notReal("источник ЕГРН"), }; } @@ -265,11 +265,12 @@ export interface PticaInvestScore { * advisory (the forecast itself is advisory). Independent of the overall score, * so it surfaces as soon as the report is ready even if scoring is thin. */ -function buySignalFromForecast(report: ForecastReport): PticaField { +export function buySignalFromForecast(report: ForecastReport): PticaField { const kn = report.exec_summary.key_numbers; const di = kn.deficit_index; if (di == null) { - return { value: "наблюдать", isReal: true, caption: "advisory · §22" }; + // No forecast number → honest stub, distinct from a real «наблюдать». + return notReal("прогноз недоступен (Сценарии §22)"); } const word = di > 0.05 ? "приобретать" : di < -0.05 ? "пропустить" : "наблюдать"; @@ -677,7 +678,7 @@ export function adaptPassportDrawer(a: ParcelAnalysis): PticaPassportDrawer { ? real(a.egrn.last_egrn_update_date) : notReal("нет данных ЕГРН"), }, - { k: "Обременения", field: { value: DASH, isReal: true } }, + { k: "Обременения", field: notReal("источник ЕГРН") }, ]; const zouitOverlaps = (zouit ?? []).map((z) => ({ @@ -1044,7 +1045,7 @@ export function adaptLegalDrawer(a: ParcelAnalysis): PticaLegalDrawer { ? real(formatInt(restr.zouitCount)) : notReal("источник НСПД"), }, - { k: "Обременения", field: { value: DASH, isReal: true } }, + { k: "Обременения", field: notReal("источник ЕГРН") }, { k: "Блокеры (gate)", field: gate @@ -1937,20 +1938,36 @@ export function adaptBuySignalGauge( } const di = report.exec_summary.key_numbers.deficit_index; + + // Report present but no deficit_index → the gauge has no real metric. Mirror + // the "no report" branch: muted track, neutral tone, isReal:false. Never dress + // a synthetic mid-scale 50 up as a live number. + if (di == null) { + return { + gauge: { + value: null, + label: "после прогноза", + tone: "neutral", + isReal: false, + footnote: "Scenarios §22", + }, + word: PLACEHOLDER, + available: false, + caption: "после прогноза (Scenarios)", + }; + } + // Map signed deficit_index (roughly −0.5..+0.5) onto a 0..100 gauge value. - const gaugeValue = - di == null ? 50 : Math.max(0, Math.min(100, Math.round(50 + di * 100))); + const gaugeValue = Math.max(0, Math.min(100, Math.round(50 + di * 100))); let tone: PticaGauge["tone"] = "neutral"; let word = "Наблюдать"; - if (di != null) { - if (di > 0.05) { - tone = "good"; - word = "Приобретать"; - } else if (di < -0.05) { - tone = "bad"; - word = "Пропустить"; - } + if (di > 0.05) { + tone = "good"; + word = "Приобретать"; + } else if (di < -0.05) { + tone = "bad"; + word = "Пропустить"; } return {