From 153285f4a54c1ecce9346009aec3c2bbe615931b Mon Sep 17 00:00:00 2001 From: "adghayes@gmail.com" Date: Wed, 11 Oct 2023 20:00:51 +0300 Subject: [PATCH] testing --- .../src/app.tally_report_csv_export.test.ts | 184 ++++++++++++++---- .../backend/src/exports/batch_results.ts | 3 +- .../src/exports/csv_tally_report.test.ts | 100 ++++++++++ .../backend/src/tabulation/full_results.ts | 1 + 4 files changed, 251 insertions(+), 37 deletions(-) diff --git a/apps/admin/backend/src/app.tally_report_csv_export.test.ts b/apps/admin/backend/src/app.tally_report_csv_export.test.ts index 62265f061e..f8ded4068a 100644 --- a/apps/admin/backend/src/app.tally_report_csv_export.test.ts +++ b/apps/admin/backend/src/app.tally_report_csv_export.test.ts @@ -160,7 +160,7 @@ it('logs failure if export fails for some reason', async () => { ); }); -it('incorporates wia and manual data', async () => { +it('incorporates wia and manual data (grouping by voting method)', async () => { const { electionDefinition, castVoteRecordExport } = electionGridLayoutNewHampshireAmherstFixtures; const { election } = electionDefinition; @@ -180,105 +180,217 @@ it('incorporates wia and manual data', async () => { const candidateContestId = 'State-Representatives-Hillsborough-District-34-b1012d38'; const officialCandidateId = 'Obadiah-Carrigan-5c95145a'; + const officialCandidateName = 'Obadiah Carrigan'; function rowExists( rows: ReturnType['rows'], - selectionId: string, - votingMethod: string, - votes: number + { + selection, + selectionId, + votingMethod, + totalVotes, + scannedVotes, + manualVotes, + }: { + selection: string; + selectionId: string; + votingMethod: string; + totalVotes: number; + scannedVotes?: number; + manualVotes?: number; + } ): boolean { return rows.some( (row) => + row['Selection'] === selection && row['Selection ID'] === selectionId && row['Voting Method'] === votingMethod && - row['Total Votes'] === votes.toString() + row['Total Votes'] === totalVotes.toString() && + (!scannedVotes || row['Scanned Votes'] === scannedVotes.toString()) && + (!manualVotes || row['Manual Votes'] === manualVotes.toString()) ); } // check initial export, without wia and manual data - const { rows: rowsInitial } = await getParsedExport({ + const { headers: headersInitial, rows: rowsInitial } = await getParsedExport({ apiClient, groupBy, }); + expect(headersInitial).toEqual([ + 'Voting Method', + 'Contest', + 'Contest ID', + 'Selection', + 'Selection ID', + 'Total Votes', + ]); // initial official candidate counts expect( - rowExists(rowsInitial, officialCandidateId, 'Precinct', 30) + rowExists(rowsInitial, { + selection: officialCandidateName, + selectionId: officialCandidateId, + votingMethod: 'Precinct', + totalVotes: 30, + }) ).toBeTruthy(); expect( - rowExists(rowsInitial, officialCandidateId, 'Absentee', 30) + rowExists(rowsInitial, { + selection: officialCandidateName, + selectionId: officialCandidateId, + votingMethod: 'Precinct', + totalVotes: 30, + }) ).toBeTruthy(); // initial generic write-in counts - expect(rowExists(rowsInitial, 'write-in', 'Absentee', 28)).toBeTruthy(); - expect(rowExists(rowsInitial, 'write-in', 'Precinct', 28)).toBeTruthy(); + expect( + rowExists(rowsInitial, { + selection: Tabulation.PENDING_WRITE_IN_NAME, + selectionId: Tabulation.PENDING_WRITE_IN_ID, + votingMethod: 'Precinct', + totalVotes: 28, + }) + ).toBeTruthy(); + expect( + rowExists(rowsInitial, { + selection: Tabulation.PENDING_WRITE_IN_NAME, + selectionId: Tabulation.PENDING_WRITE_IN_ID, + votingMethod: 'Absentee', + totalVotes: 28, + }) + ).toBeTruthy(); + + // adjudicate write-ins for unofficial candidate + const writeInCandidate = await apiClient.addWriteInCandidate({ + contestId: candidateContestId, + name: 'Mr. Pickles', + }); + const writeInIds = await apiClient.getWriteInAdjudicationQueue({ + contestId: candidateContestId, + }); + for (const writeInId of writeInIds) { + await apiClient.adjudicateWriteIn({ + writeInId, + type: 'write-in-candidate', + candidateId: writeInCandidate.id, + }); + } // add manual data + const manualOnlyWriteInCandidate = await apiClient.addWriteInCandidate({ + contestId: candidateContestId, + name: 'Ms. Bean', + }); await apiClient.setManualResults({ precinctId: election.precincts[0]!.id, votingMethod: 'absentee', ballotStyleId: election.ballotStyles[0]!.id, manualResults: buildManualResultsFixture({ election, - ballotCount: 10, + ballotCount: 20, contestResultsSummaries: { [candidateContestId]: { type: 'candidate', - ballots: 10, + ballots: 20, overvotes: 0, undervotes: 0, officialOptionTallies: { [officialCandidateId]: 10, }, + writeInOptionTallies: { + [writeInCandidate.id]: { + name: writeInCandidate.name, + tally: 5, + }, + [manualOnlyWriteInCandidate.id]: { + name: manualOnlyWriteInCandidate.name, + tally: 5, + }, + }, }, }, }), }); - // adjudicate write-ins for unofficial candidate - const writeInCandidate = await apiClient.addWriteInCandidate({ - contestId: candidateContestId, - name: 'Mr. Pickles', - }); - const writeInIds = await apiClient.getWriteInAdjudicationQueue({ - contestId: candidateContestId, - }); - for (const writeInId of writeInIds) { - await apiClient.adjudicateWriteIn({ - writeInId, - type: 'write-in-candidate', - candidateId: writeInCandidate.id, - }); - } - // check final export, with wia and manual data - const { rows: rowsFinal } = await getParsedExport({ + const { headers: headersFinal, rows: rowsFinal } = await getParsedExport({ apiClient, groupBy, }); + expect(headersFinal).toEqual([ + 'Voting Method', + 'Contest', + 'Contest ID', + 'Selection', + 'Selection ID', + 'Manual Votes', + 'Scanned Votes', + 'Total Votes', + ]); // final official candidate counts expect( - rowExists(rowsFinal, officialCandidateId, 'Precinct', 30) + rowExists(rowsFinal, { + selection: officialCandidateName, + selectionId: officialCandidateId, + votingMethod: 'Precinct', + manualVotes: 0, + scannedVotes: 30, + totalVotes: 30, + }) ).toBeTruthy(); expect( - rowExists(rowsFinal, officialCandidateId, 'Absentee', 40) + rowExists(rowsFinal, { + selection: officialCandidateName, + selectionId: officialCandidateId, + votingMethod: 'Absentee', + manualVotes: 10, + scannedVotes: 30, + totalVotes: 40, + }) ).toBeTruthy(); // manual data reflected - // added write-in candidate counts + // adjudicated write-in candidate counts expect( - rowExists(rowsFinal, writeInCandidate.id, 'Absentee', 28) + rowExists(rowsFinal, { + selection: writeInCandidate.name, + selectionId: writeInCandidate.id, + votingMethod: 'Precinct', + manualVotes: 0, + scannedVotes: 28, + totalVotes: 28, + }) ).toBeTruthy(); expect( - rowExists(rowsFinal, writeInCandidate.id, 'Precinct', 28) + rowExists(rowsFinal, { + selection: writeInCandidate.name, + selectionId: writeInCandidate.id, + votingMethod: 'Absentee', + manualVotes: 5, + scannedVotes: 28, + totalVotes: 33, + }) + ).toBeTruthy(); + + // manual-only write-in candidate counts + expect( + rowExists(rowsFinal, { + selection: manualOnlyWriteInCandidate.name, + selectionId: manualOnlyWriteInCandidate.id, + votingMethod: 'Absentee', + manualVotes: 5, + scannedVotes: 0, + totalVotes: 5, + }) ).toBeTruthy(); - // generic write-in counts should be gone + // pending write-in counts should be gone expect( rowsFinal.some( (r) => r['Contest ID'] === candidateContestId && - r['Selection ID'] === 'write-in' + r['Selection ID'] === Tabulation.PENDING_WRITE_IN_ID ) ).toBeFalsy(); }); diff --git a/apps/admin/backend/src/exports/batch_results.ts b/apps/admin/backend/src/exports/batch_results.ts index 8596e7c67b..19a3aa8c83 100644 --- a/apps/admin/backend/src/exports/batch_results.ts +++ b/apps/admin/backend/src/exports/batch_results.ts @@ -65,10 +65,11 @@ function generateResultsRow( } if (contest.allowWriteIns) { contestVoteTotals.push( - /* c8 ignore next - trivial fallback case */ + /* c8 ignore start - trivial fallback case */ contestResults.tallies[ Tabulation.GENERIC_WRITE_IN_ID ]?.tally.toString() ?? '0' + /* c8 ignore end */ ); } } else if (contest.type === 'yesno') { diff --git a/apps/admin/backend/src/exports/csv_tally_report.test.ts b/apps/admin/backend/src/exports/csv_tally_report.test.ts index 42d6b76931..abd7ef9105 100644 --- a/apps/admin/backend/src/exports/csv_tally_report.test.ts +++ b/apps/admin/backend/src/exports/csv_tally_report.test.ts @@ -1,6 +1,7 @@ import { electionTwoPartyPrimaryFixtures } from '@votingworks/fixtures'; import { DEFAULT_SYSTEM_SETTINGS, Tabulation } from '@votingworks/types'; import { find } from '@votingworks/basics'; +import { buildManualResultsFixture } from '@votingworks/utils'; import { MockCastVoteRecordFile, addMockCvrFileToStore, @@ -348,3 +349,102 @@ test('does not include results groups when they are excluded by the filter', asy precinctRows.some((r) => r['Voting Method'] === 'Precinct') ).toBeTruthy(); }); + +test('incorporates manual data', async () => { + const store = Store.memoryStore(); + const { electionDefinition } = electionTwoPartyPrimaryFixtures; + const { election, electionData } = electionDefinition; + const electionId = store.addElection({ + electionData, + systemSettingsData: JSON.stringify(DEFAULT_SYSTEM_SETTINGS), + }); + store.setCurrentElectionId(electionId); + + // add some mock cast vote records with one vote each + const mockCastVoteRecordFile: MockCastVoteRecordFile = [ + { + ballotStyleId: '1M', + batchId: 'batch-1', + scannerId: 'scanner-1', + precinctId: 'precinct-1', + votingMethod: 'precinct', + votes: { + 'zoo-council-mammal': ['lion', 'kangaroo', 'elephant'], + fishing: ['ban-fishing'], + }, + card: { type: 'bmd' }, + multiplier: 1, + }, + ]; + addMockCvrFileToStore({ electionId, mockCastVoteRecordFile, store }); + + store.setManualResults({ + electionId, + precinctId: 'precinct-1', + ballotStyleId: '1M', + votingMethod: 'absentee', + manualResults: buildManualResultsFixture({ + election, + ballotCount: 20, + contestResultsSummaries: { + 'zoo-council-mammal': { + type: 'candidate', + ballots: 20, + overvotes: 3, + undervotes: 2, + officialOptionTallies: { + lion: 10, + kangaroo: 5, + }, + }, + fishing: { + type: 'yesno', + ballots: 20, + undervotes: 6, + overvotes: 4, + yesTally: 1, + noTally: 9, + }, + }, + }), + }); + + const stream = await generateTallyReportCsv({ + store, + }); + const fileContents = await streamToString(stream); + const { rows } = parseCsv(fileContents); + expect( + rows + .filter((r) => r['Contest ID'] === 'zoo-council-mammal') + .map((row) => [ + row['Selection ID'], + row['Manual Votes'], + row['Scanned Votes'], + row['Total Votes'], + ]) + ).toEqual([ + ['zebra', '0', '0', '0'], + ['lion', '10', '1', '11'], + ['kangaroo', '5', '1', '6'], + ['elephant', '0', '1', '1'], + ['overvotes', '3', '0', '3'], + ['undervotes', '2', '0', '2'], + ]); + + expect( + rows + .filter((r) => r['Contest ID'] === 'fishing') + .map((row) => [ + row['Selection ID'], + row['Manual Votes'], + row['Scanned Votes'], + row['Total Votes'], + ]) + ).toEqual([ + ['ban-fishing', '1', '1', '2'], + ['allow-fishing', '9', '0', '9'], + ['overvotes', '4', '0', '4'], + ['undervotes', '6', '0', '6'], + ]); +}); diff --git a/apps/admin/backend/src/tabulation/full_results.ts b/apps/admin/backend/src/tabulation/full_results.ts index d20d8bead8..8e552abdb4 100644 --- a/apps/admin/backend/src/tabulation/full_results.ts +++ b/apps/admin/backend/src/tabulation/full_results.ts @@ -152,6 +152,7 @@ export async function tabulateElectionResults({ }); } ); + /* c8 ignore next 3 - debug only */ } else { debug('filter or group by is not compatible with manual results'); }