Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions frontend/user/modules/detail-drawer.js
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down
19 changes: 13 additions & 6 deletions frontend/user/modules/drawer-data.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
38 changes: 38 additions & 0 deletions tests/frontend/test_drawer_data.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down