fix(ptica): honest isReal flags — placeholder stubs no longer marked real (audit #1871 P1.3)
Some checks failed
CI / frontend-tests (pull_request) Failing after 53s
CI / changes (pull_request) Successful in 7s
CI / backend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Successful in 1m52s

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
This commit is contained in:
Light1YT 2026-06-23 11:20:48 +05:00
parent dfa1e27dab
commit 2303207629
3 changed files with 130 additions and 16 deletions

View file

@ -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;

View file

@ -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);
});
});

View file

@ -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 {