From 7539ac5117828dcb40618b286750d5e49f93a512 Mon Sep 17 00:00:00 2001 From: KuanHsun Date: Mon, 31 Aug 2026 00:19:15 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20=E6=8A=95=E7=A5=A8=E7=8E=87=E5=88=86?= =?UTF-8?q?=E6=AF=8D=E6=94=B9=E7=94=A8=E5=90=8D=E5=86=8A=E4=BA=BA=E6=95=B8?= =?UTF-8?q?=E8=80=8C=E9=9D=9E=E5=B7=B2=E6=8A=95=E7=A5=A8=E4=BA=BA=E6=95=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit calculateActivityStatistics 原本用 activity.users.length 當投票率的 分母,但該欄位存的是「已投票的學號清單」,等同於分子,導致投票率 恆為 100%、「合格選民數」顯示成已投票人數。 改用 activity.eligible_voters_count(上傳選舉人名冊 CSV 時寫入的 名冊人數),與後台結果頁「合格選民數」的語意一致。未上傳過名冊的 活動該欄位為 0,既有的除以零保護會讓投票率維持 "0"。 新增 __tests__/statisticsService.test.ts 涵蓋投票率計算、無名冊、 choose_one 計票與活動不存在四種情況。 --- __tests__/statisticsService.test.ts | 125 ++++++++++++++++++++++++++++ lib/statisticsService.ts | 4 +- 2 files changed, 128 insertions(+), 1 deletion(-) create mode 100644 __tests__/statisticsService.test.ts diff --git a/__tests__/statisticsService.test.ts b/__tests__/statisticsService.test.ts new file mode 100644 index 0000000..0c129da --- /dev/null +++ b/__tests__/statisticsService.test.ts @@ -0,0 +1,125 @@ +import { calculateActivityStatistics } from "@/lib/statisticsService"; + +jest.mock("@/lib/models/Activity", () => ({ + Activity: { + findById: jest.fn(), + }, +})); + +jest.mock("@/lib/models/Option", () => ({ + Option: { + find: jest.fn(), + }, +})); + +jest.mock("@/lib/models/Vote", () => ({ + Vote: { + find: jest.fn(), + }, +})); + +const { Activity } = jest.requireMock("@/lib/models/Activity") as { + Activity: { findById: jest.Mock }; +}; +const { Option } = jest.requireMock("@/lib/models/Option") as { + Option: { find: jest.Mock }; +}; +const { Vote } = jest.requireMock("@/lib/models/Vote") as { + Vote: { find: jest.Mock }; +}; + +const ACTIVITY_ID = "507f1f77bcf86cd799439011"; +const OPTION_A = "507f1f77bcf86cd799439021"; +const OPTION_B = "507f1f77bcf86cd799439022"; + +function mockActivity(overrides: Record = {}) { + Activity.findById.mockReturnValue({ + populate: jest.fn().mockResolvedValue({ + _id: ACTIVITY_ID, + name: "測試選舉", + type: "election", + rule: "choose_one", + users: [], + eligible_voters_count: 0, + open_from: new Date("2026-01-01"), + open_to: new Date("2026-12-31"), + ...overrides, + }), + }); +} + +function mockOptions() { + Option.find.mockResolvedValue([ + { _id: OPTION_A, candidate: { name: "王小明" } }, + { _id: OPTION_B, candidate: { name: "陳大文" } }, + ]); +} + +describe("statistics service", () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it("uses the eligible voter roster as the turnout denominator", async () => { + mockActivity({ + eligible_voters_count: 500, + users: ["A", "B", "C", "D", "E"], + }); + mockOptions(); + Vote.find.mockResolvedValue([ + { choose_one: OPTION_A }, + { choose_one: OPTION_A }, + { choose_one: OPTION_B }, + { choose_one: OPTION_B }, + { choose_one: OPTION_B }, + ]); + + const result = await calculateActivityStatistics(ACTIVITY_ID); + + expect(result.success).toBe(true); + // 5 票 / 500 名冊人數 = 1.00%,而不是 5/5 = 100% + expect(result.data?.statistics.totalEligibleVoters).toBe(500); + expect(result.data?.statistics.totalVotes).toBe(5); + expect(result.data?.statistics.turnoutRate).toBe("1.00"); + }); + + it("returns zero turnout when no voter roster has been uploaded", async () => { + mockActivity({ eligible_voters_count: 0, users: [] }); + mockOptions(); + Vote.find.mockResolvedValue([]); + + const result = await calculateActivityStatistics(ACTIVITY_ID); + + expect(result.success).toBe(true); + expect(result.data?.statistics.totalEligibleVoters).toBe(0); + expect(result.data?.statistics.turnoutRate).toBe("0"); + }); + + it("counts choose_one votes per option", async () => { + mockActivity({ eligible_voters_count: 10 }); + mockOptions(); + Vote.find.mockResolvedValue([ + { choose_one: OPTION_A }, + { choose_one: OPTION_B }, + { choose_one: OPTION_B }, + ]); + + const result = await calculateActivityStatistics(ACTIVITY_ID); + + const stats = result.data?.statistics.optionStats ?? []; + expect(stats.find((s) => s.option_id === OPTION_A)?.total).toBe(1); + expect(stats.find((s) => s.option_id === OPTION_B)?.total).toBe(2); + expect(result.data?.statistics.turnoutRate).toBe("30.00"); + }); + + it("returns 404 when the activity does not exist", async () => { + Activity.findById.mockReturnValue({ + populate: jest.fn().mockResolvedValue(null), + }); + + const result = await calculateActivityStatistics(ACTIVITY_ID); + + expect(result.success).toBe(false); + expect(result.statusCode).toBe(404); + }); +}); diff --git a/lib/statisticsService.ts b/lib/statisticsService.ts index 9f188db..278ab0a 100644 --- a/lib/statisticsService.ts +++ b/lib/statisticsService.ts @@ -49,6 +49,7 @@ interface ActivityDocument extends Document { type: string; rule: string; users: string[]; + eligible_voters_count: number; open_from: Date; open_to: Date; } @@ -85,7 +86,8 @@ export async function calculateActivityStatistics( // Calculate basic statistics const totalVotes = votes.length; - const totalEligibleVoters = activity.users.length; + // 分母是名冊人數(上傳選舉人 CSV 時寫入),不是已投票人數 + const totalEligibleVoters = activity.eligible_voters_count ?? 0; const turnoutRate = totalEligibleVoters > 0 ? ((totalVotes / totalEligibleVoters) * 100).toFixed(2)