Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Manual Tally Policy - CSV Tally Reports #4074

Merged
merged 8 commits into from
Oct 23, 2023
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions libs/utils/src/tabulation/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,4 +2,5 @@ export * from './arguments';
export * from './contest_filtering';
export * from './lookups';
export * from './tabulation';
export * from './tally_reports';
export * from './transformations';
99 changes: 99 additions & 0 deletions libs/utils/src/tabulation/tally_reports.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
import { electionTwoPartyPrimaryFixtures } from '@votingworks/fixtures';
import { find } from '@votingworks/basics';
import { CandidateContest, Tabulation } from '@votingworks/types';
import { buildContestResultsFixture } from './tabulation';
import {
getTallyReportCandidateRows,
shorthandTallyReportCandidateRow,
} from './tally_reports';

const { electionDefinition } = electionTwoPartyPrimaryFixtures;
const { election } = electionDefinition;

const contestId = 'zoo-council-mammal';
const contest = find(
election.contests,
(c) => c.id === contestId
) as CandidateContest;

test('getTallyReportRows', () => {
const scannedContestResults = buildContestResultsFixture({
contest,
includeGenericWriteIn: false,
contestResultsSummary: {
type: 'candidate',
ballots: 50,
officialOptionTallies: {
zebra: 40,
},
writeInOptionTallies: {
'write-in-1': {
name: 'Write-In 1',
tally: 5,
},
'write-in-2': {
name: 'Write-In 2',
tally: 5,
},
[Tabulation.PENDING_WRITE_IN_ID]: {
...Tabulation.PENDING_WRITE_IN_CANDIDATE,
tally: 1,
},
},
},
}) as Tabulation.CandidateContestResults;

const manualContestResults = buildContestResultsFixture({
contest,
includeGenericWriteIn: false,
contestResultsSummary: {
type: 'candidate',
ballots: 50,
officialOptionTallies: {
zebra: 20,
lion: 20,
},
writeInOptionTallies: {
'write-in-1': {
name: 'Write-In 1',
tally: 5,
},
'write-in-3': {
name: 'Write-In 3',
tally: 5,
},
},
},
}) as Tabulation.CandidateContestResults;

expect(
getTallyReportCandidateRows({ contest, scannedContestResults }).map(
shorthandTallyReportCandidateRow
)
).toEqual([
['zebra', 'Zebra', 40, 0],
['lion', 'Lion', 0, 0],
['kangaroo', 'Kangaroo', 0, 0],
['elephant', 'Elephant', 0, 0],
['write-in-1', 'Write-In 1', 5, 0],
['write-in-2', 'Write-In 2', 5, 0],
['write-in', 'Unadjudicated Write-In', 1, 0],
]);

expect(
getTallyReportCandidateRows({
contest,
scannedContestResults,
manualContestResults,
}).map(shorthandTallyReportCandidateRow)
).toEqual([
['zebra', 'Zebra', 40, 20],
['lion', 'Lion', 0, 20],
['kangaroo', 'Kangaroo', 0, 0],
['elephant', 'Elephant', 0, 0],
['write-in-1', 'Write-In 1', 5, 5],
['write-in-2', 'Write-In 2', 5, 0],
['write-in-3', 'Write-In 3', 0, 5],
['write-in', 'Unadjudicated Write-In', 1, 0],
]);
});
102 changes: 102 additions & 0 deletions libs/utils/src/tabulation/tally_reports.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
import { Candidate, CandidateContest, Tabulation } from '@votingworks/types';
import { assertDefined } from '@votingworks/basics';
import { combineCandidateContestResults } from './tabulation';

type TallyReportCandidateRow = Candidate & {
scannedTally: number;
manualTally: number;
};

function isNonCandidateWriteInTally(
candidateTally: Tabulation.CandidateTally
): boolean {
return (
candidateTally.id === Tabulation.PENDING_WRITE_IN_ID ||
candidateTally.id === Tabulation.GENERIC_WRITE_IN_ID
);
}

function getAllWriteInRows({
combinedContestResults,
scannedContestResults,
manualContestResults,
}: {
combinedContestResults: Tabulation.CandidateContestResults;
scannedContestResults: Tabulation.CandidateContestResults;
manualContestResults?: Tabulation.CandidateContestResults;
}): TallyReportCandidateRow[] {
const rows: TallyReportCandidateRow[] = [];
const writeInCandidateTallies: Tabulation.CandidateTally[] = [];
const otherWriteInTallies: Tabulation.CandidateTally[] = [];

for (const candidateTally of Object.values(combinedContestResults.tallies)) {
if (candidateTally.isWriteIn) {
if (isNonCandidateWriteInTally(candidateTally)) {
otherWriteInTallies.push(candidateTally);
} else {
writeInCandidateTallies.push(candidateTally);
}
}
}

// list write-in candidates first, then other write-in counts
for (const candidateTally of [
...writeInCandidateTallies,
...otherWriteInTallies,
]) {
rows.push({
...candidateTally,
scannedTally:
scannedContestResults.tallies[candidateTally.id]?.tally ?? 0,
manualTally: manualContestResults?.tallies[candidateTally.id]?.tally ?? 0,
});
}

return rows;
}

export function getTallyReportCandidateRows({
contest,
scannedContestResults,
manualContestResults,
}: {
contest: CandidateContest;
scannedContestResults: Tabulation.CandidateContestResults;
manualContestResults?: Tabulation.CandidateContestResults;
}): TallyReportCandidateRow[] {
Comment on lines +67 to +75
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think that for the use case in this PR, this is a little overbuilt. But. I'm breaking this logic out into a util because I will use the same logic for frontend tally reports, and add different algorithms for grouping / showing write-in candidates. Either "show all", or "show significant." Future features, like the ability to have official write-in candidates, could add more algorithms here. Will build on this in next PR.

const combinedContestResults = manualContestResults
? combineCandidateContestResults({
contest,
allContestResults: [scannedContestResults, manualContestResults],
})
: scannedContestResults;

const rows: TallyReportCandidateRow[] = [];

// official candidates are always listed, in election definition order
for (const candidate of contest.candidates) {
rows.push({
...candidate,
scannedTally: assertDefined(scannedContestResults.tallies[candidate.id])
.tally,
manualTally: manualContestResults?.tallies[candidate.id]?.tally ?? 0,
});
}

rows.push(
...getAllWriteInRows({
combinedContestResults,
scannedContestResults,
manualContestResults,
})
);

return rows;
}

// for testing only
export function shorthandTallyReportCandidateRow(
row: TallyReportCandidateRow
): [id: string, name: string, scannedTally: number, manualTally: number] {
return [row.id, row.name, row.scannedTally, row.manualTally];
}