Skip to content

Commit c600389

Browse files
authored
feat(core): give feedback on invalid ballot (#72)
When a ballot is rejected, it is useful to know why.
1 parent 34eb71a commit c600389

File tree

7 files changed

+340
-49
lines changed

7 files changed

+340
-49
lines changed

packages/core/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818
"types": "./dist/index.d.ts",
1919
"exports": {
2020
"./generateNewVoteFolder": "./dist/generateNewVoteFolder.js",
21+
"./getReasonForInvalidateBallot": "./dist/getReasonForInvalidateBallot.js",
2122
"./countBallotsFromGit": "./dist/countBallotsFromGit.js",
2223
"./voteUsingGit": "./dist/voteUsingGit.js",
2324
"./parser": "./dist/parser.js",

packages/core/src/chkballot.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
1-
import { checkBallot, loadYmlFile } from "./parser.js";
1+
import { loadYmlFile } from "./parser.js";
2+
import { getReasonForInvalidateBallot } from "./getReasonForInvalidateBallot.js";
23
import type { BallotFileFormat, VoteFileFormat } from "./parser";
34

45
function main(argv: string[]): void {
@@ -8,7 +9,7 @@ function main(argv: string[]): void {
89
const voteFile = loadYmlFile<VoteFileFormat>(votePath);
910

1011
const ballotFile: BallotFileFormat = loadYmlFile<BallotFileFormat>(ballotPath);
11-
if (checkBallot(ballotFile, voteFile)) {
12+
if (getReasonForInvalidateBallot(ballotFile, voteFile) == null) {
1213
console.log("valid");
1314
process.exit(0);
1415
} else {
Lines changed: 238 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,238 @@
1+
import { it } from "node:test";
2+
import * as assert from "node:assert";
3+
4+
import { getReasonForInvalidateBallot } from "./getReasonForInvalidateBallot.js";
5+
import type { BallotFileFormat, VoteFileFormat } from "./parser.js";
6+
7+
const dummyVote: VoteFileFormat = {
8+
checksum: "/sjnc/rMa4Ux6zRgl3a/DvDpWp+hLwiAR6E2Nj34bUWzkojmy96uw7KkCV1FpKqU28rS3trF1AvKFOOwnDdxOg==",
9+
candidates: ["a", "b", "c", "d", "e", "f", "g"],
10+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
11+
} as any;
12+
13+
it("should return null for valid ballots", () => {
14+
assert.strictEqual(
15+
getReasonForInvalidateBallot({
16+
author: "John Smith",
17+
preferences: [
18+
{ title: "a", score: 0 },
19+
{ title: "b", score: 1 },
20+
{ title: "c", score: 1 },
21+
{ title: "d", score: -0 },
22+
{ title: "e", score: -2 },
23+
{ title: "f", score: -1 },
24+
{ title: "g", score: 0 },
25+
],
26+
poolChecksum: "/sjnc/rMa4Ux6zRgl3a/DvDpWp+hLwiAR6E2Nj34bUWzkojmy96uw7KkCV1FpKqU28rS3trF1AvKFOOwnDdxOg==",
27+
}, dummyVote),
28+
null,
29+
);
30+
});
31+
it("should report missing author", () => {
32+
assert.deepStrictEqual(
33+
getReasonForInvalidateBallot({
34+
preferences: [
35+
{ title: "a", score: 0 },
36+
{ title: "b", score: 1 },
37+
{ title: "c", score: 1 },
38+
{ title: "d", score: -0 },
39+
{ title: "e", score: -2 },
40+
{ title: "f", score: -1 },
41+
{ title: "g", score: 0 },
42+
],
43+
poolChecksum: "/sjnc/rMa4Ux6zRgl3a/DvDpWp+hLwiAR6E2Nj34bUWzkojmy96uw7KkCV1FpKqU28rS3trF1AvKFOOwnDdxOg==",
44+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
45+
} satisfies Omit<BallotFileFormat, "author"> as any, dummyVote),
46+
{
47+
missingAuthor: true,
48+
},
49+
);
50+
});
51+
it("should report empty author", () => {
52+
assert.deepStrictEqual(
53+
getReasonForInvalidateBallot({
54+
author: "",
55+
preferences: [
56+
{ title: "a", score: 0 },
57+
{ title: "b", score: 1 },
58+
{ title: "c", score: 1 },
59+
{ title: "d", score: -0 },
60+
{ title: "e", score: -2 },
61+
{ title: "f", score: -1 },
62+
{ title: "g", score: 0 },
63+
],
64+
poolChecksum: "/sjnc/rMa4Ux6zRgl3a/DvDpWp+hLwiAR6E2Nj34bUWzkojmy96uw7KkCV1FpKqU28rS3trF1AvKFOOwnDdxOg==",
65+
}, dummyVote),
66+
{
67+
missingAuthor: true,
68+
},
69+
);
70+
});
71+
it("should not report additional candidates", () => {
72+
assert.strictEqual(
73+
getReasonForInvalidateBallot({
74+
author: "John Smith",
75+
preferences: [
76+
{ title: "a", score: 0 },
77+
{ title: "b", score: 1 },
78+
{ title: "c", score: 1 },
79+
{ title: "d", score: -0 },
80+
{ title: "e", score: -2 },
81+
{ title: "f", score: -1 },
82+
{ title: "g", score: 0 },
83+
{ title: "h", score: 0 },
84+
],
85+
poolChecksum: "/sjnc/rMa4Ux6zRgl3a/DvDpWp+hLwiAR6E2Nj34bUWzkojmy96uw7KkCV1FpKqU28rS3trF1AvKFOOwnDdxOg==",
86+
}, dummyVote),
87+
null,
88+
);
89+
});
90+
it("should report missing candidates", () => {
91+
assert.deepStrictEqual(
92+
getReasonForInvalidateBallot({
93+
author: "John Smith",
94+
preferences: [
95+
{ title: "a", score: 0 },
96+
{ title: "bb", score: 1 },
97+
{ title: "c", score: -0 },
98+
{ title: "e", score: -2 },
99+
{ title: "f", score: -1 },
100+
{ title: "g", score: 0 },
101+
],
102+
poolChecksum: "/sjnc/rMa4Ux6zRgl3a/DvDpWp+hLwiAR6E2Nj34bUWzkojmy96uw7KkCV1FpKqU28rS3trF1AvKFOOwnDdxOg==",
103+
}, dummyVote),
104+
{
105+
missingCandidates: ["b", "d"],
106+
},
107+
);
108+
});
109+
it("should report overflow integer scores", () => {
110+
assert.deepStrictEqual(
111+
getReasonForInvalidateBallot({
112+
author: "John Smith",
113+
preferences: [
114+
{
115+
title: "a",
116+
score: Number.MAX_SAFE_INTEGER + 1,
117+
},
118+
{ title: "b", score: Number.MAX_SAFE_INTEGER },
119+
{ title: "c", score: Number.MIN_SAFE_INTEGER },
120+
{ title: "d", score: Number.MIN_SAFE_INTEGER - 1 },
121+
{ title: "e", score: Number.MIN_VALUE },
122+
{ title: "f", score: Number.MAX_VALUE },
123+
{ title: "g", score: 0 },
124+
],
125+
poolChecksum: "/sjnc/rMa4Ux6zRgl3a/DvDpWp+hLwiAR6E2Nj34bUWzkojmy96uw7KkCV1FpKqU28rS3trF1AvKFOOwnDdxOg==",
126+
}, dummyVote),
127+
{
128+
candidatesWithInvalidScores: [{
129+
score: 9007199254740992,
130+
title: "a",
131+
},
132+
{
133+
score: -9007199254740992,
134+
title: "d",
135+
},
136+
{
137+
score: 5e-324,
138+
title: "e",
139+
},
140+
{
141+
score: 1.7976931348623157e+308,
142+
title: "f",
143+
}],
144+
},
145+
);
146+
});
147+
it("should report non-integer scores", () => {
148+
assert.deepStrictEqual(
149+
getReasonForInvalidateBallot({
150+
author: "John Smith",
151+
preferences: [
152+
{
153+
title: "a",
154+
// @ts-expect-error score is purposefully mistyped
155+
score: "1",
156+
},
157+
{ title: "b", score: 1.1 },
158+
{ title: "c", score: 1.0 },
159+
// @ts-expect-error score is purposefully mistyped
160+
{ title: "d", score: [0] },
161+
// @ts-expect-error score is purposefully mistyped
162+
{ title: "e", score: { value: 1 } },
163+
// @ts-expect-error score is purposefully mistyped
164+
{ title: "f", score: 1n },
165+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
166+
{ title: "g" } as any as { title: string; score: number },
167+
],
168+
poolChecksum: "/sjnc/rMa4Ux6zRgl3a/DvDpWp+hLwiAR6E2Nj34bUWzkojmy96uw7KkCV1FpKqU28rS3trF1AvKFOOwnDdxOg==",
169+
}, dummyVote),
170+
{
171+
candidatesWithInvalidScores: [
172+
{
173+
score: "1",
174+
title: "a",
175+
},
176+
{
177+
score: 1.1,
178+
title: "b",
179+
},
180+
{
181+
score: [0],
182+
title: "d",
183+
},
184+
{
185+
score: { value: 1 },
186+
title: "e",
187+
},
188+
{
189+
score: 1n,
190+
title: "f",
191+
},
192+
{ title: "g" },
193+
],
194+
},
195+
);
196+
});
197+
198+
it("should report multiple errors at once", () => {
199+
assert.deepStrictEqual(
200+
getReasonForInvalidateBallot({
201+
author: "",
202+
preferences: [
203+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
204+
{ title: "a" } as any as { title: string; score: number },
205+
{ title: "d", score: 1.1 },
206+
{ title: "c", score: -1.0 },
207+
{ title: "e", score: Infinity },
208+
{ title: "ff", score: 1 },
209+
{ title: "g", score: 0 },
210+
{ title: "h", score: 1 },
211+
],
212+
poolChecksum: "invalid",
213+
}, {
214+
checksum: "expected",
215+
candidates: ["a", "b", "c", "d", "e", "f", "g"],
216+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
217+
} as any),
218+
{
219+
missingAuthor: true,
220+
missingCandidates: ["b", "f"],
221+
invalidChecksum: {
222+
actual: "invalid",
223+
expected: "expected",
224+
},
225+
candidatesWithInvalidScores: [
226+
{ title: "a" },
227+
{
228+
score: 1.1,
229+
title: "d",
230+
},
231+
{
232+
score: Infinity,
233+
title: "e",
234+
},
235+
],
236+
},
237+
);
238+
});
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
import type { BallotFileFormat, VoteFileFormat } from "./parser";
2+
3+
export function getReasonForInvalidateBallot(
4+
ballotFile: BallotFileFormat,
5+
voteFile: VoteFileFormat,
6+
author?: string,
7+
): null | {
8+
invalidChecksum?: { expected: string; actual: string };
9+
missingAuthor?: true;
10+
missingCandidates?: VoteFileFormat["candidates"];
11+
candidatesWithInvalidScores?: BallotFileFormat["preferences"];
12+
} {
13+
let reason = null;
14+
if (ballotFile.poolChecksum !== voteFile.checksum) {
15+
(reason ??= {}).invalidChecksum = {
16+
expected: voteFile.checksum,
17+
actual: ballotFile.poolChecksum,
18+
};
19+
}
20+
if (!author && !ballotFile.author) {
21+
(reason ??= {}).missingAuthor = true;
22+
}
23+
const missingCandidates = voteFile.candidates.filter(
24+
candidate =>
25+
!ballotFile.preferences.some(
26+
preference => candidate === preference.title,
27+
),
28+
);
29+
if (missingCandidates.length) {
30+
(reason ??= {}).missingCandidates = missingCandidates;
31+
}
32+
const candidatesWithInvalidScores = ballotFile.preferences.filter(
33+
preference =>
34+
!Number.isSafeInteger(preference.score),
35+
);
36+
if (candidatesWithInvalidScores.length) {
37+
(reason ??= {}).candidatesWithInvalidScores = candidatesWithInvalidScores;
38+
}
39+
return reason;
40+
}

packages/core/src/parser.ts

Lines changed: 0 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -118,20 +118,3 @@ export function templateBallot(
118118
};
119119
return subject + header + yaml.dump(template) + footer + "\n";
120120
}
121-
122-
export function checkBallot(
123-
ballotFile: BallotFileFormat,
124-
voteFile: VoteFileFormat,
125-
author?: string,
126-
): boolean {
127-
return (
128-
ballotFile.poolChecksum === voteFile.checksum
129-
&& (author ?? ballotFile.author) != null
130-
&& ballotFile.preferences.every(
131-
preference =>
132-
voteFile.candidates.some(
133-
candidate => candidate === preference.title,
134-
) && Number.isSafeInteger(preference.score),
135-
)
136-
);
137-
}

packages/core/src/vote.ts

Lines changed: 20 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,11 @@
11
import {
22
type BallotFileFormat,
3-
checkBallot,
43
loadYmlFile,
54
loadYmlString,
65
parseYml,
76
type VoteFileFormat,
87
} from "./parser.js";
8+
import { getReasonForInvalidateBallot } from "./getReasonForInvalidateBallot.js";
99
import type { PathOrFileDescriptor } from "fs";
1010
import type { CandidateScores } from "./votingMethods/VotingMethodImplementation.js";
1111
import VoteResult from "./votingMethods/VoteResult.js";
@@ -145,20 +145,26 @@ export default class Vote {
145145
}
146146

147147
public addBallotFile(ballotData: BallotFileFormat, author?: string): Ballot {
148-
if (ballotData && checkBallot(ballotData, this.voteFileData, author)) {
149-
const preferences: Map<VoteCandidate, Rank> = new Map(
150-
ballotData.preferences.map(element => [element.title, element.score]),
151-
);
152-
const ballot: Ballot = {
153-
voter: { id: author ?? ballotData.author },
154-
preferences,
155-
};
156-
this.addBallot(ballot);
157-
return ballot;
158-
} else {
159-
console.warn("Invalid Ballot", author);
148+
if (!ballotData) {
149+
// eslint-disable-next-line prefer-rest-params
150+
console.warn("Missing ballot data", arguments);
151+
return null;
160152
}
161-
return null;
153+
const invalidityReason = getReasonForInvalidateBallot(ballotData, this.voteFileData, author);
154+
if (invalidityReason != null) {
155+
// eslint-disable-next-line prefer-rest-params
156+
console.warn("Invalid Ballot", arguments, invalidityReason);
157+
return null;
158+
}
159+
const preferences: Map<VoteCandidate, Rank> = new Map(
160+
ballotData.preferences.map(element => [element.title, element.score]),
161+
);
162+
const ballot: Ballot = {
163+
voter: { id: author ?? ballotData.author },
164+
preferences,
165+
};
166+
this.addBallot(ballot);
167+
return ballot;
162168
}
163169

164170
public addFakeBallot(author: string): void {

0 commit comments

Comments
 (0)