Skip to content

Commit ebc7828

Browse files
authored
#755 Zero Write-Ins (#759)
* Show 0 write-ins for contests with write-in candidates but no actual write-ins * Refactoring and testing * add test for a non-write-in * test one write-in * test zero write-ins
1 parent fc3ce34 commit ebc7828

4 files changed

Lines changed: 150 additions & 33 deletions

File tree

src/electionguard_gui/services/plaintext_ballot_service.py

Lines changed: 46 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -14,36 +14,49 @@ def get_plaintext_ballot_report(
1414
selection_write_ins = _get_candidate_write_ins(manifest)
1515
parties = _get_selection_parties(manifest)
1616
tally_report = {}
17-
for tally_contest in plaintext_ballot.contests.values():
18-
contest_name = contest_names.get(tally_contest.object_id, "n/a")
19-
# non-write-in selections
20-
non_write_in_selections = [
21-
selection
22-
for selection in tally_contest.selections.values()
23-
if not selection_write_ins[selection.object_id]
24-
]
25-
non_write_in_total = sum(
26-
[selection.tally for selection in non_write_in_selections]
27-
)
28-
non_write_in_selections_report = _get_selections_report(
29-
non_write_in_selections, selection_names, parties, non_write_in_total
17+
contests = plaintext_ballot.contests.values()
18+
for tally_contest in contests:
19+
selections = list(tally_contest.selections.values())
20+
contest_details = _get_contest_details(
21+
selections, selection_names, selection_write_ins, parties
3022
)
23+
contest_name = contest_names.get(tally_contest.object_id, "n/a")
24+
tally_report[contest_name] = contest_details
25+
return tally_report
3126

32-
# write-in selections
33-
write_ins_total = sum(
34-
[
35-
selection.tally
36-
for selection in tally_contest.selections.values()
37-
if selection_write_ins[selection.object_id]
38-
]
39-
)
4027

41-
tally_report[contest_name] = {
42-
"selections": non_write_in_selections_report,
43-
"nonWriteInTotal": non_write_in_total,
44-
"writeInTotal": write_ins_total,
45-
}
46-
return tally_report
28+
def _get_contest_details(
29+
selections: list[PlaintextTallySelection],
30+
selection_names: dict[str, str],
31+
selection_write_ins: dict[str, bool],
32+
parties: dict[str, str],
33+
) -> dict[str, Any]:
34+
35+
# non-write-in selections
36+
non_write_in_selections = [
37+
selection
38+
for selection in selections
39+
if not selection_write_ins[selection.object_id]
40+
]
41+
non_write_in_total = sum([selection.tally for selection in non_write_in_selections])
42+
non_write_in_selections_report = _get_selections_report(
43+
non_write_in_selections, selection_names, parties, non_write_in_total
44+
)
45+
46+
# write-in selections
47+
write_ins = [
48+
selection.tally
49+
for selection in selections
50+
if selection_write_ins[selection.object_id]
51+
]
52+
any_write_ins = len(write_ins) > 0
53+
write_ins_total = sum(write_ins) if any_write_ins else None
54+
55+
return {
56+
"selections": non_write_in_selections_report,
57+
"nonWriteInTotal": non_write_in_total,
58+
"writeInTotal": write_ins_total,
59+
}
4760

4861

4962
def _get_selection_parties(manifest: Manifest) -> dict[str, str]:
@@ -65,14 +78,18 @@ def _get_selection_parties(manifest: Manifest) -> dict[str, str]:
6578

6679

6780
def _get_candidate_write_ins(manifest: Manifest) -> dict[str, bool]:
68-
candidates = {
81+
"""
82+
Returns a dictionary where the key is a selection's object_id and the value is a boolean
83+
indicating whether the selection's candidate is marked as a write-in.
84+
"""
85+
write_in_candidates = {
6986
candidate.object_id: candidate.is_write_in is True
7087
for candidate in manifest.candidates
7188
}
7289
contest_write_ins = {}
7390
for contest in manifest.contests:
7491
for selection in contest.ballot_selections:
75-
candidate_is_write_in = candidates[selection.candidate_id]
92+
candidate_is_write_in = write_in_candidates[selection.candidate_id]
7693
contest_write_ins[selection.object_id] = candidate_is_write_in
7794
return contest_write_ins
7895

src/electionguard_gui/web/components/shared/view-plaintext-ballot-component.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@ export default {
2727
<td class="text-end"><strong>{{contestContents.nonWriteInTotal}}</strong></td>
2828
<td class="text-end"><strong>100.00%</strong></td>
2929
</tr>
30-
<tr v-if="contestContents.writeInTotal">
30+
<tr v-if="contestContents.writeInTotal !== null">
3131
<td></td>
3232
<td class="text-end">Write-Ins</td>
3333
<td class="text-end">{{contestContents.writeInTotal}}</td>

tests/unit/electionguard_gui/test_decryption_dto.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -63,7 +63,7 @@ def test_get_status_with_all_guardians_joined_and_completed(self) -> None:
6363
self.assertEqual(status, "decryption complete")
6464

6565
@patch("electionguard_gui.services.authorization_service.AuthorizationService")
66-
def test_admins_can_not_join_key_ceremony(self, auth_service: MagicMock):
66+
def test_admins_can_not_join_key_ceremony(self, auth_service: MagicMock) -> None:
6767
# ARRANGE
6868
decryption_dto = DecryptionDto({"guardians_joined": []})
6969

@@ -80,7 +80,7 @@ def test_admins_can_not_join_key_ceremony(self, auth_service: MagicMock):
8080
@patch("electionguard_gui.services.authorization_service.AuthorizationService")
8181
def test_users_can_join_key_ceremony_if_not_already_joined(
8282
self, auth_service: MagicMock
83-
):
83+
) -> None:
8484
# ARRANGE
8585
decryption_dto = DecryptionDto({"guardians_joined": []})
8686

@@ -95,7 +95,7 @@ def test_users_can_join_key_ceremony_if_not_already_joined(
9595
self.assertTrue(decryption_dto.can_join)
9696

9797
@patch("electionguard_gui.services.authorization_service.AuthorizationService")
98-
def test_users_cant_join_twice(self, auth_service: MagicMock):
98+
def test_users_cant_join_twice(self, auth_service: MagicMock) -> None:
9999
# ARRANGE
100100
decryption_dto = DecryptionDto({"guardians_joined": ["user1"]})
101101

Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
1+
from unittest.mock import MagicMock, patch
2+
from electionguard.tally import PlaintextTallySelection
3+
from electionguard_gui.services.plaintext_ballot_service import _get_contest_details
4+
from tests.base_test_case import BaseTestCase
5+
6+
7+
class TestPlaintextBallotService(BaseTestCase):
8+
"""Test the ElectionDto class"""
9+
10+
def test_zero_sections(self) -> None:
11+
# ARRANGE
12+
selections: list[PlaintextTallySelection] = []
13+
selection_names: dict[str, str] = {}
14+
selection_write_ins: dict[str, bool] = {}
15+
parties: dict[str, str] = {}
16+
17+
# ACT
18+
result = _get_contest_details(
19+
selections, selection_names, selection_write_ins, parties
20+
)
21+
22+
# ASSERT
23+
self.assertEqual(0, result["nonWriteInTotal"])
24+
self.assertEqual(None, result["writeInTotal"])
25+
self.assertEqual(0, len(result["selections"]))
26+
27+
@patch("electionguard.tally.PlaintextTallySelection")
28+
def test_one_non_write_in(self, plaintext_tally_selection: MagicMock) -> None:
29+
# ARRANGE
30+
plaintext_tally_selection.object_id = "AL"
31+
plaintext_tally_selection.tally = 2
32+
selections: list[PlaintextTallySelection] = [plaintext_tally_selection]
33+
selection_names: dict[str, str] = {
34+
"AL": "Abraham Lincoln",
35+
}
36+
selection_write_ins: dict[str, bool] = {
37+
"AL": False,
38+
}
39+
parties: dict[str, str] = {
40+
"AL": "National Union Party",
41+
}
42+
43+
# ACT
44+
result = _get_contest_details(
45+
selections, selection_names, selection_write_ins, parties
46+
)
47+
48+
# ASSERT
49+
self.assertEqual(2, result["nonWriteInTotal"])
50+
self.assertEqual(None, result["writeInTotal"])
51+
self.assertEqual(1, len(result["selections"]))
52+
selection = result["selections"][0]
53+
self.assertEqual("Abraham Lincoln", selection["name"])
54+
self.assertEqual(2, selection["tally"])
55+
self.assertEqual("National Union Party", selection["party"])
56+
self.assertEqual(1, selection["percent"])
57+
58+
@patch("electionguard.tally.PlaintextTallySelection")
59+
def test_one_write_in(self, plaintext_tally_selection: MagicMock) -> None:
60+
# ARRANGE
61+
plaintext_tally_selection.object_id = "ST"
62+
plaintext_tally_selection.tally = 1
63+
selections: list[PlaintextTallySelection] = [plaintext_tally_selection]
64+
selection_names: dict[str, str] = {}
65+
selection_write_ins: dict[str, bool] = {
66+
"ST": True,
67+
}
68+
parties: dict[str, str] = {}
69+
70+
# ACT
71+
result = _get_contest_details(
72+
selections, selection_names, selection_write_ins, parties
73+
)
74+
75+
# ASSERT
76+
self.assertEqual(0, result["nonWriteInTotal"])
77+
self.assertEqual(1, result["writeInTotal"])
78+
self.assertEqual(0, len(result["selections"]))
79+
80+
@patch("electionguard.tally.PlaintextTallySelection")
81+
def test_zero_write_in(self, plaintext_tally_selection: MagicMock) -> None:
82+
# ARRANGE
83+
plaintext_tally_selection.object_id = "ST"
84+
plaintext_tally_selection.tally = 0
85+
selections: list[PlaintextTallySelection] = [plaintext_tally_selection]
86+
selection_names: dict[str, str] = {}
87+
selection_write_ins: dict[str, bool] = {
88+
"ST": True,
89+
}
90+
parties: dict[str, str] = {}
91+
92+
# ACT
93+
result = _get_contest_details(
94+
selections, selection_names, selection_write_ins, parties
95+
)
96+
97+
# ASSERT
98+
self.assertEqual(0, result["nonWriteInTotal"])
99+
self.assertEqual(0, result["writeInTotal"])
100+
self.assertEqual(0, len(result["selections"]))

0 commit comments

Comments
 (0)