fix(ptica): honest isReal flags — placeholder stubs no longer marked real (audit #1871 P1.3) #1875

Merged
bot-backend merged 2 commits from audit/1871-p1-3-isreal-stubs into main 2026-06-23 06:28:00 +00:00
3 changed files with 133 additions and 16 deletions

View file

@ -607,6 +607,8 @@
.kvPlaceholder { .kvPlaceholder {
color: var(--text-soft); color: var(--text-soft);
font-weight: 500; font-weight: 500;
/* Stub / not-measured values read fainter than live numbers (audit #1871). */
opacity: 0.7;
} }
.kvCaption { .kvCaption {
display: block; display: block;

View file

@ -0,0 +1,98 @@
/**
* 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";
// 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);
});
});

View file

@ -159,8 +159,8 @@ export function adaptPassport(a: ParcelAnalysis): PticaPassport {
: placeholder("нет данных ЕГРН"), : placeholder("нет данных ЕГРН"),
vri: vri ? real(vri) : placeholder("нет данных ЕГРН"), vri: vri ? real(vri) : placeholder("нет данных ЕГРН"),
status: status ? real(status) : placeholder("нет данных ЕГРН"), status: status ? real(status) : placeholder("нет данных ЕГРН"),
// Matches the existing analysis page: encumbrances are not surfaced yet. // encumbrances never arrive from the backend yet — always a stub.
encumbrances: { value: PLACEHOLDER, isReal: true }, encumbrances: notReal("источник ЕГРН"),
}; };
} }
@ -265,11 +265,12 @@ export interface PticaInvestScore {
* advisory (the forecast itself is advisory). Independent of the overall score, * 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. * 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 kn = report.exec_summary.key_numbers;
const di = kn.deficit_index; const di = kn.deficit_index;
if (di == null) { if (di == null) {
return { value: "наблюдать", isReal: true, caption: "advisory · §22" }; // No forecast number → honest stub, distinct from a real «наблюдать».
return notReal("прогноз недоступен (Сценарии §22)");
} }
const word = const word =
di > 0.05 ? "приобретать" : di < -0.05 ? "пропустить" : "наблюдать"; di > 0.05 ? "приобретать" : di < -0.05 ? "пропустить" : "наблюдать";
@ -677,7 +678,7 @@ export function adaptPassportDrawer(a: ParcelAnalysis): PticaPassportDrawer {
? real(a.egrn.last_egrn_update_date) ? real(a.egrn.last_egrn_update_date)
: notReal("нет данных ЕГРН"), : notReal("нет данных ЕГРН"),
}, },
{ k: "Обременения", field: { value: DASH, isReal: true } }, { k: "Обременения", field: notReal("источник ЕГРН") },
]; ];
const zouitOverlaps = (zouit ?? []).map((z) => ({ const zouitOverlaps = (zouit ?? []).map((z) => ({
@ -1044,7 +1045,7 @@ export function adaptLegalDrawer(a: ParcelAnalysis): PticaLegalDrawer {
? real(formatInt(restr.zouitCount)) ? real(formatInt(restr.zouitCount))
: notReal("источник НСПД"), : notReal("источник НСПД"),
}, },
{ k: "Обременения", field: { value: DASH, isReal: true } }, { k: "Обременения", field: notReal("источник ЕГРН") },
{ {
k: "Блокеры (gate)", k: "Блокеры (gate)",
field: gate field: gate
@ -1937,20 +1938,36 @@ export function adaptBuySignalGauge(
} }
const di = report.exec_summary.key_numbers.deficit_index; 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. // Map signed deficit_index (roughly 0.5..+0.5) onto a 0..100 gauge value.
const gaugeValue = const gaugeValue = Math.max(0, Math.min(100, Math.round(50 + di * 100)));
di == null ? 50 : Math.max(0, Math.min(100, Math.round(50 + di * 100)));
let tone: PticaGauge["tone"] = "neutral"; let tone: PticaGauge["tone"] = "neutral";
let word = "Наблюдать"; let word = "Наблюдать";
if (di != null) { if (di > 0.05) {
if (di > 0.05) { tone = "good";
tone = "good"; word = "Приобретать";
word = "Приобретать"; } else if (di < -0.05) {
} else if (di < -0.05) { tone = "bad";
tone = "bad"; word = "Пропустить";
word = "Пропустить";
}
} }
return { return {