diff --git a/frontend/user/modules/opportunity-board.js b/frontend/user/modules/opportunity-board.js index 0fe601d..85cc9bd 100644 --- a/frontend/user/modules/opportunity-board.js +++ b/frontend/user/modules/opportunity-board.js @@ -195,15 +195,19 @@ function emptyMessageFor(modeId) { }[modeId] || "当前筛选下暂无结果,可放宽条件后再试。"; } -function sortItems(items, sortSpec) { +function isMissingSortValue(value) { + return value === null || value === undefined || Number.isNaN(value); +} + +export function sortItems(items, sortSpec) { if (!sortSpec) return items; const dir = sortSpec.direction === "asc" ? 1 : -1; return [...items].sort((a, b) => { const av = a[sortSpec.key]; const bv = b[sortSpec.key]; if (av === bv) return 0; - if (av === null || av === undefined) return 1; - if (bv === null || bv === undefined) return -1; + if (isMissingSortValue(av)) return 1; + if (isMissingSortValue(bv)) return -1; return av > bv ? dir : -dir; }); } diff --git a/tests/frontend/test_opportunity_board_sort.mjs b/tests/frontend/test_opportunity_board_sort.mjs new file mode 100644 index 0000000..2e7cc78 --- /dev/null +++ b/tests/frontend/test_opportunity_board_sort.mjs @@ -0,0 +1,100 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; + +import { sortItems } from "../../frontend/user/modules/opportunity-board.js"; + +test("sortItems: descending sort orders numeric values from high to low", () => { + const items = [ + { id: "a", yield: 3 }, + { id: "b", yield: 7 }, + { id: "c", yield: 5 }, + ]; + const sorted = sortItems(items, { key: "yield", direction: "desc" }); + assert.deepEqual(sorted.map((i) => i.id), ["b", "c", "a"]); +}); + +test("sortItems: explicit 0 is a real value, not a missing marker", () => { + // The board sorts by yield/score; rows with an explicit zero should keep + // their numeric position relative to other numbers — not be lumped in with + // null/undefined as "no data". + const items = [ + { id: "neg", yield: -1 }, + { id: "zero", yield: 0 }, + { id: "pos", yield: 4 }, + { id: "missing", yield: null }, + ]; + const desc = sortItems(items, { key: "yield", direction: "desc" }); + assert.deepEqual( + desc.map((i) => i.id), + ["pos", "zero", "neg", "missing"], + "desc: 0 sits between positive and negative; null goes to the end", + ); + const asc = sortItems(items, { key: "yield", direction: "asc" }); + assert.deepEqual( + asc.map((i) => i.id), + ["neg", "zero", "pos", "missing"], + "asc: 0 sits between negative and positive; null still goes to the end", + ); +}); + +test("sortItems: null and undefined values sort to the end regardless of direction", () => { + const items = [ + { id: "n", yield: null }, + { id: "u", yield: undefined }, + { id: "a", yield: 4 }, + { id: "b", yield: 1 }, + ]; + const desc = sortItems(items, { key: "yield", direction: "desc" }); + assert.deepEqual(desc.slice(0, 2).map((i) => i.id), ["a", "b"]); + assert.ok(["n", "u"].includes(desc[2].id) && ["n", "u"].includes(desc[3].id)); + + const asc = sortItems(items, { key: "yield", direction: "asc" }); + assert.deepEqual(asc.slice(0, 2).map((i) => i.id), ["b", "a"]); + assert.ok(["n", "u"].includes(asc[2].id) && ["n", "u"].includes(asc[3].id)); +}); + +test("sortItems: NaN values sort to the end like null (no silent reordering)", () => { + // NaN sneaks through Number() when upstream data is malformed; the board + // should treat it as 'no data' and push it to the end, not float it to the + // top because of how IEEE-754 NaN comparisons return false. + const items = [ + { id: "nan", yield: Number.NaN }, + { id: "low", yield: 1 }, + { id: "high", yield: 9 }, + ]; + const desc = sortItems(items, { key: "yield", direction: "desc" }); + assert.deepEqual( + desc.map((i) => i.id), + ["high", "low", "nan"], + "desc: NaN must go to the end, not the top", + ); + const asc = sortItems(items, { key: "yield", direction: "asc" }); + assert.deepEqual( + asc.map((i) => i.id), + ["low", "high", "nan"], + "asc: NaN must go to the end, not the top", + ); +}); + +test("sortItems: mixed null/NaN/numbers — all missing markers cluster at the end", () => { + const items = [ + { id: "a", score: 50 }, + { id: "nan", score: Number.NaN }, + { id: "null", score: null }, + { id: "b", score: 10 }, + { id: "undef", score: undefined }, + { id: "zero", score: 0 }, + ]; + const sorted = sortItems(items, { key: "score", direction: "desc" }); + // First three are the numeric values, in desc order; last three are the + // missing markers (any order is fine). + assert.deepEqual(sorted.slice(0, 3).map((i) => i.id), ["a", "b", "zero"]); + const tail = sorted.slice(3).map((i) => i.id).sort(); + assert.deepEqual(tail, ["nan", "null", "undef"]); +}); + +test("sortItems: returns the original list when no sortSpec is supplied", () => { + const items = [{ id: "a" }, { id: "b" }]; + assert.equal(sortItems(items, null), items); + assert.equal(sortItems(items, undefined), items); +});