Repository: microsoft/electionguard-python

Issue:
🐞 Contests with zero write-in do not show zero write-ins
### Is there an existing issue for this?

- [X] I have searched the existing issues

### Current Behavior

ElectionGuard tally does not report the number of Write-ins if write-ins received 0 votes

**Setup:** 
1.	Load an election on ElectionGuard Scan and open polls
2.	Scan some ballots that include regular office contests that have write-in but the write-ins are never marked (0 votes received)
3.	Close polls
4.	Take the EGDrive to the EG Admin machine and upload ballots
5.	Decrypt the ballots and tally
6.	Compare the number of votes for write-ins on the screen to the Scan tally

**Expected Results:** 
1.	Write-ins will be listed as having 0 votes

**Actual Results:** 
1.	The write-in choice is not reported on the EG tally

**Further testing:** 
1.	The write-in choice is listed if the write-in recieves at least 1 vote

**Impact:** 
1.	Medium - Missing data, but at least it is reported if a vote is received by the write-in


**Defect Details**
Subject	Saving Data
Reproducible	Y
Severity	2-Medium
Priority	3-High
Detected on Date	2022-08-17
Defect Type	Product Defect
Modified	2022-08-17 16:30:05
Detected in Release	ElectionGuard
Detected in Cycle	Drop 4
Target Release	ElectionGuard


### Expected Behavior

Write-ins will be listed as having 0 votes

### Steps To Reproduce

_No response_

### Environment

```markdown
- OS:
```


### Anything else?

_No response_

Comments:

Patch:
diff --git a/src/electionguard_gui/services/plaintext_ballot_service.py b/src/electionguard_gui/services/plaintext_ballot_service.py
index 6cdf70c..47c7122 100644
--- a/src/electionguard_gui/services/plaintext_ballot_service.py
+++ b/src/electionguard_gui/services/plaintext_ballot_service.py
@@ -14,36 +14,49 @@ def get_plaintext_ballot_report(
     selection_write_ins = _get_candidate_write_ins(manifest)
     parties = _get_selection_parties(manifest)
     tally_report = {}
-    for tally_contest in plaintext_ballot.contests.values():
-        contest_name = contest_names.get(tally_contest.object_id, "n/a")
-        # non-write-in selections
-        non_write_in_selections = [
-            selection
-            for selection in tally_contest.selections.values()
-            if not selection_write_ins[selection.object_id]
-        ]
-        non_write_in_total = sum(
-            [selection.tally for selection in non_write_in_selections]
-        )
-        non_write_in_selections_report = _get_selections_report(
-            non_write_in_selections, selection_names, parties, non_write_in_total
+    contests = plaintext_ballot.contests.values()
+    for tally_contest in contests:
+        selections = list(tally_contest.selections.values())
+        contest_details = _get_contest_details(
+            selections, selection_names, selection_write_ins, parties
         )
+        contest_name = contest_names.get(tally_contest.object_id, "n/a")
+        tally_report[contest_name] = contest_details
+    return tally_report
 
-        # write-in selections
-        write_ins_total = sum(
-            [
-                selection.tally
-                for selection in tally_contest.selections.values()
-                if selection_write_ins[selection.object_id]
-            ]
-        )
 
-        tally_report[contest_name] = {
-            "selections": non_write_in_selections_report,
-            "nonWriteInTotal": non_write_in_total,
-            "writeInTotal": write_ins_total,
-        }
-    return tally_report
+def _get_contest_details(
+    selections: list[PlaintextTallySelection],
+    selection_names: dict[str, str],
+    selection_write_ins: dict[str, bool],
+    parties: dict[str, str],
+) -> dict[str, Any]:
+
+    # non-write-in selections
+    non_write_in_selections = [
+        selection
+        for selection in selections
+        if not selection_write_ins[selection.object_id]
+    ]
+    non_write_in_total = sum([selection.tally for selection in non_write_in_selections])
+    non_write_in_selections_report = _get_selections_report(
+        non_write_in_selections, selection_names, parties, non_write_in_total
+    )
+
+    # write-in selections
+    write_ins = [
+        selection.tally
+        for selection in selections
+        if selection_write_ins[selection.object_id]
+    ]
+    any_write_ins = len(write_ins) > 0
+    write_ins_total = sum(write_ins) if any_write_ins else None
+
+    return {
+        "selections": non_write_in_selections_report,
+        "nonWriteInTotal": non_write_in_total,
+        "writeInTotal": write_ins_total,
+    }
 
 
 def _get_selection_parties(manifest: Manifest) -> dict[str, str]:
@@ -65,14 +78,18 @@ def _get_selection_parties(manifest: Manifest) -> dict[str, str]:
 
 
 def _get_candidate_write_ins(manifest: Manifest) -> dict[str, bool]:
-    candidates = {
+    """
+    Returns a dictionary where the key is a selection's object_id and the value is a boolean
+    indicating whether the selection's candidate is marked as a write-in.
+    """
+    write_in_candidates = {
         candidate.object_id: candidate.is_write_in is True
         for candidate in manifest.candidates
     }
     contest_write_ins = {}
     for contest in manifest.contests:
         for selection in contest.ballot_selections:
-            candidate_is_write_in = candidates[selection.candidate_id]
+            candidate_is_write_in = write_in_candidates[selection.candidate_id]
             contest_write_ins[selection.object_id] = candidate_is_write_in
     return contest_write_ins
 
diff --git a/src/electionguard_gui/web/components/shared/view-plaintext-ballot-component.js b/src/electionguard_gui/web/components/shared/view-plaintext-ballot-component.js
index 8a33315..2cabe77 100644
--- a/src/electionguard_gui/web/components/shared/view-plaintext-ballot-component.js
+++ b/src/electionguard_gui/web/components/shared/view-plaintext-ballot-component.js
@@ -27,7 +27,7 @@ export default {
             <td class="text-end"><strong>{{contestContents.nonWriteInTotal}}</strong></td>
             <td class="text-end"><strong>100.00%</strong></td>
           </tr>
-          <tr v-if="contestContents.writeInTotal">
+          <tr v-if="contestContents.writeInTotal !== null">
             <td></td>
             <td class="text-end">Write-Ins</td>
             <td class="text-end">{{contestContents.writeInTotal}}</td>


Test patch:
diff --git a/tests/unit/electionguard_gui/test_decryption_dto.py b/tests/unit/electionguard_gui/test_decryption_dto.py
index 190fb11..56a6bbe 100644
--- a/tests/unit/electionguard_gui/test_decryption_dto.py
+++ b/tests/unit/electionguard_gui/test_decryption_dto.py
@@ -63,7 +63,7 @@ class TestDecryptionDto(BaseTestCase):
         self.assertEqual(status, "decryption complete")
 
     @patch("electionguard_gui.services.authorization_service.AuthorizationService")
-    def test_admins_can_not_join_key_ceremony(self, auth_service: MagicMock):
+    def test_admins_can_not_join_key_ceremony(self, auth_service: MagicMock) -> None:
         # ARRANGE
         decryption_dto = DecryptionDto({"guardians_joined": []})
 
@@ -80,7 +80,7 @@ class TestDecryptionDto(BaseTestCase):
     @patch("electionguard_gui.services.authorization_service.AuthorizationService")
     def test_users_can_join_key_ceremony_if_not_already_joined(
         self, auth_service: MagicMock
-    ):
+    ) -> None:
         # ARRANGE
         decryption_dto = DecryptionDto({"guardians_joined": []})
 
@@ -95,7 +95,7 @@ class TestDecryptionDto(BaseTestCase):
         self.assertTrue(decryption_dto.can_join)
 
     @patch("electionguard_gui.services.authorization_service.AuthorizationService")
-    def test_users_cant_join_twice(self, auth_service: MagicMock):
+    def test_users_cant_join_twice(self, auth_service: MagicMock) -> None:
         # ARRANGE
         decryption_dto = DecryptionDto({"guardians_joined": ["user1"]})
 
diff --git a/tests/unit/electionguard_gui/test_plaintext_ballot_service.py b/tests/unit/electionguard_gui/test_plaintext_ballot_service.py
new file mode 100644
index 0000000..82a27f3
--- /dev/null
+++ b/tests/unit/electionguard_gui/test_plaintext_ballot_service.py
@@ -0,0 +1,100 @@
+from unittest.mock import MagicMock, patch
+from electionguard.tally import PlaintextTallySelection
+from electionguard_gui.services.plaintext_ballot_service import _get_contest_details
+from tests.base_test_case import BaseTestCase
+
+
+class TestPlaintextBallotService(BaseTestCase):
+    """Test the ElectionDto class"""
+
+    def test_zero_sections(self) -> None:
+        # ARRANGE
+        selections: list[PlaintextTallySelection] = []
+        selection_names: dict[str, str] = {}
+        selection_write_ins: dict[str, bool] = {}
+        parties: dict[str, str] = {}
+
+        # ACT
+        result = _get_contest_details(
+            selections, selection_names, selection_write_ins, parties
+        )
+
+        # ASSERT
+        self.assertEqual(0, result["nonWriteInTotal"])
+        self.assertEqual(None, result["writeInTotal"])
+        self.assertEqual(0, len(result["selections"]))
+
+    @patch("electionguard.tally.PlaintextTallySelection")
+    def test_one_non_write_in(self, plaintext_tally_selection: MagicMock) -> None:
+        # ARRANGE
+        plaintext_tally_selection.object_id = "AL"
+        plaintext_tally_selection.tally = 2
+        selections: list[PlaintextTallySelection] = [plaintext_tally_selection]
+        selection_names: dict[str, str] = {
+            "AL": "Abraham Lincoln",
+        }
+        selection_write_ins: dict[str, bool] = {
+            "AL": False,
+        }
+        parties: dict[str, str] = {
+            "AL": "National Union Party",
+        }
+
+        # ACT
+        result = _get_contest_details(
+            selections, selection_names, selection_write_ins, parties
+        )
+
+        # ASSERT
+        self.assertEqual(2, result["nonWriteInTotal"])
+        self.assertEqual(None, result["writeInTotal"])
+        self.assertEqual(1, len(result["selections"]))
+        selection = result["selections"][0]
+        self.assertEqual("Abraham Lincoln", selection["name"])
+        self.assertEqual(2, selection["tally"])
+        self.assertEqual("National Union Party", selection["party"])
+        self.assertEqual(1, selection["percent"])
+
+    @patch("electionguard.tally.PlaintextTallySelection")
+    def test_one_write_in(self, plaintext_tally_selection: MagicMock) -> None:
+        # ARRANGE
+        plaintext_tally_selection.object_id = "ST"
+        plaintext_tally_selection.tally = 1
+        selections: list[PlaintextTallySelection] = [plaintext_tally_selection]
+        selection_names: dict[str, str] = {}
+        selection_write_ins: dict[str, bool] = {
+            "ST": True,
+        }
+        parties: dict[str, str] = {}
+
+        # ACT
+        result = _get_contest_details(
+            selections, selection_names, selection_write_ins, parties
+        )
+
+        # ASSERT
+        self.assertEqual(0, result["nonWriteInTotal"])
+        self.assertEqual(1, result["writeInTotal"])
+        self.assertEqual(0, len(result["selections"]))
+
+    @patch("electionguard.tally.PlaintextTallySelection")
+    def test_zero_write_in(self, plaintext_tally_selection: MagicMock) -> None:
+        # ARRANGE
+        plaintext_tally_selection.object_id = "ST"
+        plaintext_tally_selection.tally = 0
+        selections: list[PlaintextTallySelection] = [plaintext_tally_selection]
+        selection_names: dict[str, str] = {}
+        selection_write_ins: dict[str, bool] = {
+            "ST": True,
+        }
+        parties: dict[str, str] = {}
+
+        # ACT
+        result = _get_contest_details(
+            selections, selection_names, selection_write_ins, parties
+        )
+
+        # ASSERT
+        self.assertEqual(0, result["nonWriteInTotal"])
+        self.assertEqual(0, result["writeInTotal"])
+        self.assertEqual(0, len(result["selections"]))