fix(ptica): honest isReal flags — placeholder stubs no longer marked real (audit #1871 P1.3) #1875
3 changed files with 133 additions and 16 deletions
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue