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,