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
4 changes: 2 additions & 2 deletions apps/admin/backend/src/app.tally_report_csv_export.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -98,7 +98,7 @@ it('exports expected results for full election', async () => {
'Contest ID',
'Selection',
'Selection ID',
'Votes',
'Total Votes',
]);

const bestAnimalMammalExpectedValues: Record<string, string> = {
Expand Down Expand Up @@ -191,7 +191,7 @@ it('incorporates wia and manual data', async () => {
(row) =>
row['Selection ID'] === selectionId &&
row['Voting Method'] === votingMethod &&
row['Votes'] === votes.toString()
row['Total Votes'] === votes.toString()
);
}

Expand Down
6 changes: 3 additions & 3 deletions apps/admin/backend/src/exports/csv_tally_report.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ test('uses appropriate headers', async () => {
'Contest ID',
'Selection',
'Selection ID',
'Votes',
'Total Votes',
];

const SHARED_ROW_VALUES: Record<string, string> = {
Expand All @@ -55,7 +55,7 @@ test('uses appropriate headers', async () => {
'Contest ID': 'fishing',
Selection: 'YES',
'Selection ID': 'ban-fishing',
Votes: '1',
'Total Votes': '1',
};

const testCases: Array<{
Expand Down Expand Up @@ -211,7 +211,7 @@ test('uses appropriate headers', async () => {

const row = find(
rows,
(r) => r['Votes'] === '1' && r['Selection ID'] === 'ban-fishing'
(r) => r['Total Votes'] === '1' && r['Selection ID'] === 'ban-fishing'
);
const expectedAttributes: Record<string, string> = {
...SHARED_ROW_VALUES,
Expand Down
183 changes: 109 additions & 74 deletions apps/admin/backend/src/exports/csv_tally_report.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,10 +7,12 @@ import {
AnyContest,
Election,
} from '@votingworks/types';
import { assert, assertDefined } from '@votingworks/basics';
import { Optional, assert, assertDefined } from '@votingworks/basics';
import {
combineGroupSpecifierAndFilter,
getTallyReportCandidateRows,
groupMapToGroupList,
mergeTabulationGroupMaps,
} from '@votingworks/utils';
import { Readable } from 'stream';
import { Store } from '../store';
Expand All @@ -22,16 +24,28 @@ import {
generateCsvMetadataHeaders,
getCsvMetadataRowValues,
} from './csv_shared';
import { tabulateManualResults } from '../tabulation/manual_results';

// eslint-disable-next-line vx/gts-no-return-type-only-generics
function assertIsOptional<T>(_value?: unknown): asserts _value is Optional<T> {
// noop
}

function generateHeaders({
election,
metadataStructure,
hasManualResults,
}: {
election: Election;
metadataStructure: CsvMetadataStructure;
hasManualResults: boolean;
}): string[] {
const headers = generateCsvMetadataHeaders({ election, metadataStructure });
headers.push('Contest', 'Contest ID', 'Selection', 'Selection ID', 'Votes');
headers.push('Contest', 'Contest ID', 'Selection', 'Selection ID');
if (hasManualResults) {
headers.push('Manual Votes', 'Scanned Votes');
}
headers.push('Total Votes');
Copy link
Collaborator Author

Choose a reason for hiding this comment

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

If there's no manual results, we could just keep the column name here as "Votes." But I assume for external systems consuming our .csv files, it will be less confusing if the column names do not change.

return headers;
}

Expand All @@ -40,47 +54,56 @@ function buildRow({
contest,
selection,
selectionId,
votes,
scannedVotes,
hasManualResults,
manualVotes,
}: {
metadataValues: string[];
contest: Contest;
selection: string;
selectionId: string;
votes: number;
scannedVotes: number;
hasManualResults: boolean;
manualVotes: number;
}): string {
const values: string[] = [...metadataValues];

// Contest, Selection, and Tally
// -----------------------------

values.push(
contest.title,
contest.id,
selection,
selectionId,
votes.toString()
);
values.push(contest.title, contest.id, selection, selectionId);

if (hasManualResults) {
values.push(manualVotes.toString(), scannedVotes.toString());
}
values.push((manualVotes + scannedVotes).toString());

return stringify([values]);
}

interface ScannedAndManualResults {
scannedResults: Tabulation.ElectionResults;
manualResults?: Tabulation.ManualElectionResults;
}

function* generateDataRows({
electionId,
electionDefinition,
overallExportFilter,
resultGroups,
metadataStructure,
store,
hasManualResults,
}: {
electionId: Id;
electionDefinition: ElectionDefinition;
overallExportFilter: Tabulation.Filter;
resultGroups: Tabulation.ElectionResultsGroupList;
resultGroups: Tabulation.GroupList<ScannedAndManualResults>;
metadataStructure: CsvMetadataStructure;
store: Store;
hasManualResults: boolean;
}): Generator<string> {
const { election } = electionDefinition;
const writeInCandidates = store.getWriteInCandidates({ electionId });
const batchLookup = generateBatchLookup(store, assertDefined(electionId));

for (const resultsGroup of resultGroups) {
Expand All @@ -106,76 +129,59 @@ function* generateDataRows({
includedContests.push(contest);
}
}
const { scannedResults, manualResults } = resultsGroup;

for (const contest of includedContests) {
const contestWriteInCandidates = writeInCandidates.filter(
(c) => c.contestId === contest.id
);
const contestResults = resultsGroup.contestResults[contest.id];
assert(contestResults !== undefined);
const scannedContestResults = scannedResults.contestResults[contest.id];
assert(scannedContestResults !== undefined);
const manualContestResults = manualResults?.contestResults[contest.id];

if (contest.type === 'candidate') {
assert(contestResults.contestType === 'candidate');
assert(scannedContestResults.contestType === 'candidate');
assertIsOptional<Tabulation.CandidateContestResults>(
manualContestResults
);

// official candidate rows
for (const candidate of contest.candidates) {
/* c8 ignore next -- trivial fallthrough zero branch */
const votes = contestResults.tallies[candidate.id]?.tally ?? 0;
for (const {
id,
name,
scannedTally,
manualTally,
} of getTallyReportCandidateRows({
contest,
scannedContestResults,
manualContestResults,
})) {
yield buildRow({
metadataValues,
contest,
selection: candidate.name,
selectionId: candidate.id,
votes,
selection: name,
selectionId: id,
scannedVotes: scannedTally,
hasManualResults,
manualVotes: manualTally,
});
}

// generic write-in row
if (contest.allowWriteIns) {
const votes =
contestResults.tallies[Tabulation.GENERIC_WRITE_IN_ID]?.tally ?? 0;
if (votes) {
yield buildRow({
metadataValues,
contest,
selection: Tabulation.GENERIC_WRITE_IN_NAME,
selectionId: Tabulation.GENERIC_WRITE_IN_ID,
votes,
});
}
}

// adjudicated write-in rows
for (const contestWriteInCandidate of contestWriteInCandidates) {
/* c8 ignore next 2 -- trivial fallthrough zero branch */
const votes =
contestResults.tallies[contestWriteInCandidate.id]?.tally ?? 0;

if (votes) {
yield buildRow({
metadataValues,
contest,
selection: contestWriteInCandidate.name,
selectionId: contestWriteInCandidate.id,
votes,
});
}
}
} else if (contest.type === 'yesno') {
assert(contestResults.contestType === 'yesno');
assert(scannedContestResults.contestType === 'yesno');
assertIsOptional<Tabulation.YesNoContestResults>(manualContestResults);
yield buildRow({
metadataValues,
contest,
selection: contest.yesOption.label,
selectionId: contest.yesOption.id,
votes: contestResults.yesTally,
scannedVotes: scannedContestResults.yesTally,
hasManualResults,
manualVotes: manualContestResults?.yesTally ?? 0,
});
yield buildRow({
metadataValues,
contest,
selection: contest.noOption.label,
selectionId: contest.noOption.id,
votes: contestResults.noTally,
scannedVotes: scannedContestResults.noTally,
hasManualResults,
manualVotes: manualContestResults?.noTally ?? 0,
});
}

Expand All @@ -184,15 +190,19 @@ function* generateDataRows({
contest,
selection: 'Overvotes',
selectionId: 'overvotes',
votes: contestResults.overvotes,
scannedVotes: scannedContestResults.overvotes,
hasManualResults,
manualVotes: manualContestResults?.overvotes ?? 0,
});

yield buildRow({
metadataValues,
contest,
selection: 'Undervotes',
selectionId: 'undervotes',
votes: contestResults.undervotes,
scannedVotes: scannedContestResults.undervotes,
hasManualResults,
manualVotes: manualContestResults?.undervotes ?? 0,
});
}
}
Expand Down Expand Up @@ -225,24 +235,48 @@ export async function generateTallyReportCsv({
groupBy,
});

// calculate scanned and manual results separately
const allScannedResults = await tabulateElectionResults({
electionId,
store,
filter,
groupBy,
includeManualResults: false,
includeWriteInAdjudicationResults: true,
});
const manualTabulationResult = tabulateManualResults({
electionId,
store,
filter,
groupBy,
});
const allManualResults = manualTabulationResult.isOk()
? manualTabulationResult.ok()
: {};
const hasManualResults = Object.keys(allManualResults).length > 0;
const resultGroups: Tabulation.GroupList<ScannedAndManualResults> =
groupMapToGroupList(
mergeTabulationGroupMaps(
allScannedResults,
allManualResults,
(scannedResults, manualResults) => {
assert(scannedResults);
return {
scannedResults,
manualResults,
};
}
)
);

const headerRow = stringify([
generateHeaders({
election,
metadataStructure,
hasManualResults,
}),
]);

const resultGroups = groupMapToGroupList(
await tabulateElectionResults({
electionId,
store,
filter,
groupBy,
includeManualResults: true,
includeWriteInAdjudicationResults: true,
})
);

function* generateAllRows() {
yield headerRow;

Expand All @@ -253,6 +287,7 @@ export async function generateTallyReportCsv({
resultGroups,
metadataStructure,
store,
hasManualResults,
})) {
yield dataRow;
}
Expand Down