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 {
|
.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;
|
||||||
|
|
|
||||||
|
|
@ -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("нет данных ЕГРН"),
|
: 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 {
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue