From c745e4ea6db2922eac6339a88e4b8b5ed6c7b68d Mon Sep 17 00:00:00 2001 From: leo Date: Fri, 15 May 2026 19:56:25 +0800 Subject: [PATCH] fix(frontend): avoid fake zero detail metrics MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Floor bucket values in the detail drawer rendered fake "0" for missing data and the literal "Infinity" for non-finite pipeline values. Treat null/undefined/NaN/±Infinity as missing in bucketBars so formatBucketValue shows "—"; a real numeric 0 still renders as "0". Drop the `?? 0` mask at the call site so missing values are not coerced before bucketBars sees them. Co-Authored-By: Claude Opus 4.7 --- frontend/user/modules/detail-drawer.js | 6 ++-- frontend/user/modules/drawer-data.js | 19 +++++++++---- tests/frontend/test_drawer_data.mjs | 38 ++++++++++++++++++++++++++ 3 files changed, 54 insertions(+), 9 deletions(-) diff --git a/frontend/user/modules/detail-drawer.js b/frontend/user/modules/detail-drawer.js index 18969a6..8adb900 100644 --- a/frontend/user/modules/detail-drawer.js +++ b/frontend/user/modules/detail-drawer.js @@ -159,9 +159,9 @@ export function initDrawer({ root, store }) { } const kpis = pickKpisFor(mode, detail); const bars = bucketBars({ - low: detail.low ?? 0, - mid: detail.mid ?? 0, - high: detail.high ?? 0, + low: detail.low, + mid: detail.mid, + high: detail.high, }); return [ renderKpiRow(kpis), diff --git a/frontend/user/modules/drawer-data.js b/frontend/user/modules/drawer-data.js index 96800a4..86c89ae 100644 --- a/frontend/user/modules/drawer-data.js +++ b/frontend/user/modules/drawer-data.js @@ -93,19 +93,26 @@ export function qualityLabelFor(status) { }[status] || "待复核"; } -export function bucketBars({ low = 0, mid = 0, high = 0 } = {}) { +export function bucketBars({ low, mid, high } = {}) { const values = [ - { key: "low", label: "低层", value: Number(low) || 0 }, - { key: "mid", label: "中层", value: Number(mid) || 0 }, - { key: "high", label: "高层", value: Number(high) || 0 }, + { key: "low", label: "低层", value: coerceFiniteOrNull(low) }, + { key: "mid", label: "中层", value: coerceFiniteOrNull(mid) }, + { key: "high", label: "高层", value: coerceFiniteOrNull(high) }, ]; - const max = Math.max(...values.map((v) => v.value)); + const finiteValues = values.map((v) => v.value).filter((v) => v !== null); + const max = finiteValues.length ? Math.max(...finiteValues) : 0; return values.map((v) => ({ ...v, - pct: max > 0 ? Math.round((v.value / max) * 100) : 0, + pct: v.value !== null && max > 0 ? Math.round((v.value / max) * 100) : 0, })); } +function coerceFiniteOrNull(raw) { + if (raw === null || raw === undefined) return null; + const n = Number(raw); + return Number.isFinite(n) ? n : null; +} + export function pickKpisFor(modeId, detail) { const kpis = KPI_MAP[modeId] || KPI_MAP.yield; return kpis(detail); diff --git a/tests/frontend/test_drawer_data.mjs b/tests/frontend/test_drawer_data.mjs index 4879b98..05dc259 100644 --- a/tests/frontend/test_drawer_data.mjs +++ b/tests/frontend/test_drawer_data.mjs @@ -132,6 +132,44 @@ test("bucketBars: handles all-zero/empty without dividing by zero", () => { assert.equal(bars[2].pct, 0); }); +test("bucketBars: null / non-finite floor inputs drop to null (real 0 is preserved)", () => { + // Floor bucket values come from the pipeline. Missing buckets land as null, + // and a divide-by-zero leak or corrupt payload can produce ±Infinity / NaN. + // None of these are real measurements: bucketBars must surface them as null + // so formatBucketValue renders "—" — never a fake "0" (which would imply a + // real zero-yield bucket and put a zero-height bar next to real buckets) and + // never the literal string "Infinity" (which leaks through `Number(v) || 0` + // because Infinity is truthy). A real numeric 0 stays 0. + const bars = bucketBars({ + low: null, + mid: Number.POSITIVE_INFINITY, + high: 0, + }); + assert.equal(bars[0].value, null); + assert.equal(bars[1].value, null); + assert.equal(bars[2].value, 0); + // Missing buckets contribute 0 to the bar-height scale and don't anchor max. + assert.equal(bars[0].pct, 0); + assert.equal(bars[1].pct, 0); + assert.equal(bars[2].pct, 0); +}); + +test("bucketBars: NaN / -Infinity drop to null while finite siblings still scale", () => { + // Sibling test: when one bucket is non-finite but others are real, the real + // ones must still scale against each other (max is taken over finite values). + const bars = bucketBars({ + low: Number.NaN, + mid: 2, + high: 4, + }); + assert.equal(bars[0].value, null); + assert.equal(bars[1].value, 2); + assert.equal(bars[2].value, 4); + assert.equal(bars[0].pct, 0); + assert.equal(bars[1].pct, 50); + assert.equal(bars[2].pct, 100); +}); + test("pickKpisFor: yield mode focuses on yield/payback/score/sample", () => { const detail = { yieldAvg: 0.04,