Skip to content

Commit 04151dd

Browse files
committed
filter, may still not work
1 parent 3cd0d1a commit 04151dd

File tree

7 files changed

+389
-42
lines changed

7 files changed

+389
-42
lines changed

apps/client/src/components/ResultsViewer.tsx

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,18 @@ function ResultBar(props: {
3737
);
3838
}
3939

40+
// download text as file
41+
const handleDownload = (filename: string, filetype: string, text: string) => {
42+
const element = document.createElement("a");
43+
const file = new Blob([text], { type: filetype });
44+
element.href = URL.createObjectURL(file);
45+
element.download = filename;
46+
document.body.appendChild(element); // Required for this to work in FireFox
47+
element.click();
48+
49+
document.body.removeChild(element);
50+
};
51+
4052
export function ResultsViewer({ results }: { results: ResultsView }) {
4153
switch (results.type) {
4254
case "SingleVote": {
@@ -72,6 +84,15 @@ export function ResultsViewer({ results }: { results: ResultsView }) {
7284
...results.results.map((v) => v.votes),
7385
);
7486

87+
const handleRoundsDownload = () => {
88+
const roundsText = JSON.stringify(results.rounds, null, 2);
89+
handleDownload(
90+
"preferential-vote-rounds.json",
91+
"application/json",
92+
roundsText,
93+
);
94+
};
95+
7596
return (
7697
<div className="flex flex-col gap-4">
7798
{candidates.map((result) => (
@@ -88,6 +109,9 @@ export function ResultsViewer({ results }: { results: ResultsView }) {
88109
max={maxVote}
89110
grey={true}
90111
/>
112+
<button className="btn" onClick={handleRoundsDownload} type="button">
113+
Download Rounds Data
114+
</button>
91115
</div>
92116
);
93117
}

apps/client/src/routes/room.$roomId/vote.$userId.$votingKey.tsx

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -179,10 +179,12 @@ function PreferentialQuestionVoting({ data }: { data: QuestionVotingData }) {
179179
lastVote?.type === "PreferentialVote"
180180
? lastVote
181181
: {
182-
votes: candidatesReordered.map((candidate, index) => ({
183-
candidateId: candidate.id,
184-
rank: index + 1,
185-
})),
182+
votes: candidatesReordered
183+
.filter(Boolean)
184+
.map((candidate, index) => ({
185+
candidateId: candidate.id,
186+
rank: index + 1,
187+
})),
186188
},
187189
});
188190

apps/server/bunfig.toml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
[test]
2+
root = "./src"

apps/server/src/live/question.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import type { TypeOf } from "zod";
22
import { z } from "zod";
33
import type { QuestionFormat } from "../dbschema/interfaces";
4+
import type { RoundRecord } from "../room/interaction/preferentialVote";
45

56
const singleVoteType = "SingleVote" satisfies QuestionFormat;
67

@@ -66,6 +67,7 @@ export interface SingleVoteResultsView {
6667
export interface PreferentialVoteResultsView {
6768
type: typeof preferentialVoteType;
6869
results: CandidateWithRank[];
70+
rounds: RoundRecord[];
6971
}
7072

7173
export type ResultsView = { abstained: number } & (

apps/server/src/room/interaction/db/questions.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -117,7 +117,7 @@ function mapDbQuestionData(question: DbQuestionData): RoomQuestion {
117117
(candidate) => candidate.id,
118118
);
119119

120-
const { elected } = rankedElection(
120+
const { elected, rounds } = rankedElection(
121121
candidateIds,
122122
votingPreferences,
123123
question.maxElected,
@@ -136,6 +136,7 @@ function mapDbQuestionData(question: DbQuestionData): RoomQuestion {
136136
return {
137137
type: "PreferentialVote",
138138
results,
139+
rounds,
139140
abstained: abstainCount,
140141
};
141142
}
Lines changed: 280 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,280 @@
1+
import { describe, expect, test } from "bun:test";
2+
import { rankedElection } from "./preferentialVote";
3+
4+
describe("rankedElection", () => {
5+
test("IRV single-seat majority election", () => {
6+
const candidates = ["Alice", "Bob", "Charlie"];
7+
const votes = [
8+
["Alice", "Bob", "Charlie"],
9+
["Alice", "Charlie", "Bob"],
10+
["Bob", "Alice", "Charlie"],
11+
["Charlie", "Alice", "Bob"],
12+
["Alice", "Bob", "Charlie"],
13+
];
14+
15+
const { elected, rounds } = rankedElection(candidates, votes, 1);
16+
17+
// ✅ Alice should have majority (3 of 5)
18+
expect(elected[0]?.id).toBe("Alice");
19+
expect(elected[0]?.votes).toBeGreaterThanOrEqual(3);
20+
21+
// Ensure at least 1 round happened
22+
expect(rounds.length).toBeGreaterThan(0);
23+
});
24+
25+
test("STV 2-seat election with surplus redistribution", () => {
26+
const candidates = ["Alice", "Bob", "Charlie", "Diana"];
27+
const votes = [
28+
["Alice", "Bob", "Charlie", "Diana"],
29+
["Alice", "Charlie", "Bob", "Diana"],
30+
["Bob", "Alice", "Charlie", "Diana"],
31+
["Charlie", "Alice", "Bob", "Diana"],
32+
["Diana", "Charlie", "Alice", "Bob"],
33+
["Alice", "Bob", "Charlie", "Diana"],
34+
["Bob", "Diana", "Charlie", "Alice"],
35+
];
36+
37+
const { elected, rounds } = rankedElection(candidates, votes, 2);
38+
39+
// Should return exactly 2 winners
40+
expect(elected.length).toBe(2);
41+
42+
// Verify that each elected candidate is among the initial candidates
43+
elected.forEach((e) => {
44+
expect(candidates).toContain(e.id);
45+
expect(typeof e.votes).toBe("number");
46+
});
47+
48+
// Ensure there are transfer records in at least one round
49+
const anyTransfers = rounds.some((r) => r.transfers.length > 0);
50+
expect(anyTransfers).toBe(true);
51+
});
52+
53+
test("Deterministic tie-breaker produces consistent result", () => {
54+
const candidates = ["A", "B"];
55+
const votes = [
56+
["A", "B"],
57+
["B", "A"],
58+
];
59+
60+
const result1 = rankedElection(candidates, votes, 1, "seed123");
61+
const result2 = rankedElection(candidates, votes, 1, "seed123");
62+
63+
// Same seed → same outcome
64+
expect(result1.elected[0]?.id).toBe(result2.elected[0]?.id);
65+
66+
// Different seed → may change
67+
const result3 = rankedElection(candidates, votes, 1, "different-seed");
68+
expect(result3.elected[0]?.id).toBeOneOf(["A", "B"]);
69+
});
70+
71+
test("Handles all votes exhausted (no preferences left)", () => {
72+
const candidates = ["A", "B"];
73+
const votes = [["A"], ["B"], []];
74+
75+
const { elected, rounds } = rankedElection(candidates, votes, 1);
76+
expect(elected.length).toBe(1);
77+
expect(["A", "B"]).toContain(elected[0]?.id as string);
78+
expect(rounds.length).toBeGreaterThan(0);
79+
});
80+
81+
test("Fills remaining seats deterministically when insufficient candidates reach quota", () => {
82+
const candidates = ["A", "B", "C"];
83+
const votes = [
84+
["A", "B", "C"],
85+
["B", "C", "A"],
86+
["C", "A", "B"],
87+
];
88+
89+
const { elected } = rankedElection(candidates, votes, 2);
90+
91+
// Should elect exactly 2
92+
expect(elected.length).toBe(2);
93+
// No duplicates
94+
const ids = elected.map((e) => e.id);
95+
expect(new Set(ids).size).toBe(2);
96+
});
97+
98+
test("Handles case with no votes cast", () => {
99+
const candidates = ["A", "B", "C"];
100+
const votes: string[][] = [];
101+
102+
const { elected, rounds } = rankedElection(candidates, votes, 2);
103+
104+
// No votes → no one elected
105+
expect(elected.length).toBe(0);
106+
expect(rounds.length).toBe(0);
107+
});
108+
109+
test("Handles case with no candidates", () => {
110+
const candidates: string[] = [];
111+
const votes = [
112+
["A", "B"],
113+
["B", "A"],
114+
];
115+
116+
const { elected, rounds } = rankedElection(candidates, votes, 1);
117+
118+
// No candidates → no one elected
119+
expect(elected.length).toBe(0);
120+
expect(rounds.length).toBe(0);
121+
})
122+
123+
test("Handles all candidates being eliminated without reaching quota", () => {
124+
const candidates = ["A", "B", "C"];
125+
const votes = [
126+
["A"],
127+
["B"],
128+
["C"],
129+
];
130+
131+
const { elected } = rankedElection(candidates, votes, 2);
132+
133+
// Should elect exactly 2
134+
expect(elected.length).toBe(2);
135+
// No duplicates
136+
const ids = elected.map((e) => e.id);
137+
expect(new Set(ids).size).toBe(2);
138+
})
139+
140+
test("Handles tie situations during elimination", () => {
141+
const candidates = ["A", "B", "C"];
142+
const votes = [
143+
["A", "B", "C"],
144+
["B", "A", "C"],
145+
["C", "A", "B"],
146+
["C", "B", "A"],
147+
];
148+
149+
const { elected, rounds } = rankedElection(candidates, votes, 1, "tie-seed");
150+
151+
// Should elect exactly 1
152+
expect(elected.length).toBe(1);
153+
// Elected candidate should be among the initial candidates
154+
expect(candidates).toContain(elected[0]?.id as string);
155+
// Ensure rounds were processed
156+
expect(rounds.length).toBeGreaterThan(0);
157+
});
158+
159+
test("Handles tie situations during surplus transfer", () => {
160+
const candidates = ["A", "B", "C"];
161+
const votes = [
162+
["A", "B", "C"],
163+
["A", "C", "B"],
164+
["B", "A", "C"],
165+
["C", "A", "B"],
166+
["C", "B", "A"],
167+
["C", "B", "A"],
168+
];
169+
170+
const { elected, rounds } = rankedElection(candidates, votes, 2, "surplus-tie");
171+
172+
// Should elect exactly 2
173+
expect(elected.length).toBe(2);
174+
// Elected candidates should be among the initial candidates
175+
elected.forEach((e) => {
176+
expect(candidates).toContain(e.id);
177+
});
178+
// Ensure rounds were processed
179+
expect(rounds.length).toBeGreaterThan(0);
180+
})
181+
182+
test("Handles all votes being exhausted before filling seats", () => {
183+
const candidates = ["A", "B", "C"];
184+
const votes = [
185+
["A"],
186+
["B"],
187+
["C"],
188+
[],
189+
[],
190+
];
191+
192+
const { elected, rounds } = rankedElection(candidates, votes, 2);
193+
194+
// Should elect exactly 2
195+
expect(elected.length).toBe(2);
196+
// Elected candidates should be among the initial candidates
197+
elected.forEach((e) => {
198+
expect(candidates).toContain(e.id);
199+
});
200+
// Ensure rounds were processed
201+
expect(rounds.length).toBeGreaterThan(0);
202+
})
203+
204+
test("should elect a single winner in a simple majority case", () => {
205+
const candidates = ["Alice", "Bob", "Carol"];
206+
const votes = [
207+
["Alice", "Bob", "Carol"],
208+
["Alice", "Carol", "Bob"],
209+
["Bob", "Alice", "Carol"],
210+
["Alice", "Bob", "Carol"],
211+
];
212+
const result = rankedElection(candidates, votes, 1);
213+
214+
expect(result.elected).toHaveLength(1);
215+
expect(result.elected[0]?.id).toBe("Alice");
216+
expect(result.elected[0]?.votes).toBeGreaterThan(0);
217+
expect(result.rounds.length).toBeGreaterThanOrEqual(1);
218+
});
219+
220+
test("should handle elimination and redistribution correctly", () => {
221+
const candidates = ["Alice", "Bob", "Carol"];
222+
const votes = [
223+
["Bob", "Alice", "Carol"],
224+
["Carol", "Alice", "Bob"],
225+
["Alice", "Bob", "Carol"],
226+
];
227+
const result = rankedElection(candidates, votes, 1);
228+
229+
// Expect one winner
230+
expect(result.elected).toHaveLength(1);
231+
const winner = result.elected[0]?.id;
232+
233+
// Because every candidate gets one first-preference vote,
234+
// tie-breaking or redistribution determines the winner
235+
expect(["Alice", "Bob", "Carol"]).toContain(winner as string);
236+
expect(result.rounds.length).toBeGreaterThanOrEqual(1);
237+
});
238+
239+
test("should elect multiple winners when seats > 1", () => {
240+
const candidates = ["Alice", "Bob", "Carol", "Dave"];
241+
const votes = [
242+
["Alice", "Bob", "Carol", "Dave"],
243+
["Bob", "Alice", "Dave", "Carol"],
244+
["Carol", "Dave", "Bob", "Alice"],
245+
["Dave", "Carol", "Bob", "Alice"],
246+
];
247+
248+
const result = rankedElection(candidates, votes, 2);
249+
250+
expect(result.elected).toHaveLength(2);
251+
for (const elected of result.elected) {
252+
expect(candidates).toContain(elected.id);
253+
expect(elected.votes).toBeGreaterThanOrEqual(0);
254+
}
255+
});
256+
257+
test("should produce deterministic results given the same seed", () => {
258+
const candidates = ["Alice", "Bob", "Carol"];
259+
const votes = [
260+
["Bob", "Alice", "Carol"],
261+
["Carol", "Alice", "Bob"],
262+
["Alice", "Bob", "Carol"],
263+
];
264+
265+
const result1 = rankedElection(candidates, votes, 1, "fixed-seed");
266+
const result2 = rankedElection(candidates, votes, 1, "fixed-seed");
267+
268+
expect(result1.elected[0]?.id).toBe(result2.elected[0]?.id);
269+
});
270+
271+
test("should handle empty votes gracefully", () => {
272+
const candidates = ["Alice", "Bob"];
273+
const votes: string[][] = [];
274+
const result = rankedElection(candidates, votes, 1);
275+
276+
expect(result.elected).toHaveLength(0);
277+
expect(result.rounds).toEqual([]);
278+
});
279+
280+
});

0 commit comments

Comments
 (0)