From 04a98a5fd92f55a3bf61030108efe1877e4fbd0f Mon Sep 17 00:00:00 2001 From: Brian Donovan <1938+eventualbuddha@users.noreply.github.com> Date: Thu, 25 Jul 2024 15:57:44 -0700 Subject: [PATCH] test: switch back to `babel`-based coverage c8/v8 coverage is buggy post v20.9.0: https://github.com/nodejs/node/issues/51251 --- .node-version | 2 +- apps/admin/backend/jest.config.js | 2 +- apps/admin/backend/src/adjudication.ts | 2 +- apps/admin/backend/src/app.ts | 2 +- .../src/exports/csv_ballot_count_report.ts | 4 +- apps/admin/backend/src/reports/titles.ts | 2 +- apps/admin/backend/src/store.ts | 6 +- .../backend/src/tabulation/card_counts.ts | 2 +- .../backend/src/tabulation/full_results.ts | 2 +- .../admin/backend/src/tabulation/write_ins.ts | 2 +- apps/admin/backend/src/util/auth.ts | 4 +- apps/admin/backend/src/util/cdf_results.ts | 2 +- apps/admin/backend/src/util/write_ins.ts | 6 +- apps/admin/backend/src/util/zip.ts | 2 +- apps/central-scan/backend/jest.config.js | 2 +- apps/scan/backend/jest.config.js | 6 +- .../src/scanners/custom/state_machine.ts | 2 +- .../backend/src/scanners/pdi/state_machine.ts | 6 +- .../election_package_io.test.ts | 6 +- libs/custom-scanner/jest.config.js | 2 +- libs/custom-scanner/src/custom_a4_scanner.ts | 4 +- libs/custom-scanner/src/parameters.ts | 2 +- libs/custom-scanner/src/protocol.ts | 6 +- libs/pdi-scanner/jest.config.js | 2 +- libs/pdi-scanner/src/ts/scanner_client.ts | 2 +- .../precinct_scanner_tally_reports.tsx | 8 +- libs/utils/jest.config.js | 6 +- libs/utils/src/cast_vote_records.test.ts | 137 ++++++++++++++++++ libs/utils/src/cast_vote_records.ts | 2 +- libs/utils/src/compressed_tallies.ts | 4 +- libs/utils/src/environment_variable.ts | 6 +- libs/utils/src/file_reading_test.test.ts | 86 +++++++++++ libs/utils/src/filenames.ts | 2 +- libs/utils/src/hmpb/all_contest_options.ts | 2 +- libs/utils/src/polls.ts | 16 +- .../src/tabulation/contest_filtering.test.ts | 26 +++- .../utils/src/tabulation/contest_filtering.ts | 26 +++- libs/utils/src/tabulation/lookups.test.ts | 40 ++++- libs/utils/src/tabulation/tabulation.test.ts | 9 ++ libs/utils/src/tabulation/tabulation.ts | 2 +- libs/utils/src/votes.test.ts | 16 ++ libs/utils/src/votes.ts | 2 +- 42 files changed, 390 insertions(+), 80 deletions(-) create mode 100644 libs/utils/src/file_reading_test.test.ts diff --git a/.node-version b/.node-version index 48b14e6b2b5..8ce7030825b 100644 --- a/.node-version +++ b/.node-version @@ -1 +1 @@ -20.14.0 +20.16.0 diff --git a/apps/admin/backend/jest.config.js b/apps/admin/backend/jest.config.js index 51823e03bc9..2417ed513c6 100644 --- a/apps/admin/backend/jest.config.js +++ b/apps/admin/backend/jest.config.js @@ -22,7 +22,7 @@ module.exports = { lines: 100, }, }, - coverageProvider: 'v8', + coverageProvider: 'babel', collectCoverageFrom: [ '**/*.{ts,tsx}', '!**/*.d.ts', diff --git a/apps/admin/backend/src/adjudication.ts b/apps/admin/backend/src/adjudication.ts index 3131a63e70b..41643a1d098 100644 --- a/apps/admin/backend/src/adjudication.ts +++ b/apps/admin/backend/src/adjudication.ts @@ -29,7 +29,7 @@ export function adjudicateVote( const scannedIsVote = contestVotes ? contestVotes.includes(voteAdjudication.optionId) - : /* c8 ignore next 1 */ + : /* istanbul ignore next */ false; // if the vote is already the target status, do nothing diff --git a/apps/admin/backend/src/app.ts b/apps/admin/backend/src/app.ts index a8111a53d8d..fa85b15faff 100644 --- a/apps/admin/backend/src/app.ts +++ b/apps/admin/backend/src/app.ts @@ -138,7 +138,7 @@ function getCurrentElectionRecord( workspace: Workspace ): Optional { const electionId = workspace.store.getCurrentElectionId(); - /* c8 ignore next 3 */ + /* istanbul ignore next */ if (!electionId) { return undefined; } diff --git a/apps/admin/backend/src/exports/csv_ballot_count_report.ts b/apps/admin/backend/src/exports/csv_ballot_count_report.ts index e2e0c0b7c79..6ed95ae03c5 100644 --- a/apps/admin/backend/src/exports/csv_ballot_count_report.ts +++ b/apps/admin/backend/src/exports/csv_ballot_count_report.ts @@ -68,7 +68,7 @@ function buildRow({ const values: string[] = [...metadataValues]; const counts: number[] = []; - /* c8 ignore next - trivial fallthrough case */ + /* istanbul ignore next - trivial fallthrough case */ const manual = cardCounts.manual ?? 0; const { bmd } = cardCounts; const total = getBallotCount(cardCounts); @@ -81,7 +81,7 @@ function buildRow({ if (maxSheetsPerBallot) { for (let i = 0; i < maxSheetsPerBallot; i += 1) { - /* c8 ignore next - trivial fallthrough case */ + /* istanbul ignore next - trivial fallthrough case */ const currentSheetCount = cardCounts.hmpb[i] ?? 0; counts.push(currentSheetCount); } diff --git a/apps/admin/backend/src/reports/titles.ts b/apps/admin/backend/src/reports/titles.ts index 9568a526f3e..adef15eae8a 100644 --- a/apps/admin/backend/src/reports/titles.ts +++ b/apps/admin/backend/src/reports/titles.ts @@ -148,7 +148,7 @@ export function generateTitleForReport({ return ok(`Undervoted ${reportType} Report`); case 'hasWriteIn': return ok(`Write-In ${reportType} Report`); - /* c8 ignore next 2 */ + /* istanbul ignore next */ default: throwIllegalValue(adjudicationFlag); } diff --git a/apps/admin/backend/src/store.ts b/apps/admin/backend/src/store.ts index 7fd308775e2..28e55ce4afd 100644 --- a/apps/admin/backend/src/store.ts +++ b/apps/admin/backend/src/store.ts @@ -1353,7 +1353,7 @@ export class Store { for (const adjudication of adjudications) { const currentContestVotes = - votes[adjudication.contestId] ?? /* c8 ignore next 1 */ []; + votes[adjudication.contestId] ?? /* istanbul ignore next */ []; if (adjudication.isVote) { votes[adjudication.contestId] = [ ...currentContestVotes, @@ -1521,7 +1521,7 @@ export class Store { ballotStyleId: groupBy.groupByBallotStyle ? row.ballotStyleId : undefined, - /* c8 ignore next - edge case coverage needed for bad party grouping in general election */ + /* istanbul ignore next - edge case coverage needed for bad party grouping in general election */ partyId: groupBy.groupByParty ? row.partyId ?? undefined : undefined, batchId: groupBy.groupByBatch ? row.batchId : undefined, scannerId: groupBy.groupByScanner ? row.scannerId : undefined, @@ -1880,7 +1880,7 @@ export class Store { ballotStyleId: groupBy.groupByBallotStyle ? row.ballotStyleId : undefined, - /* c8 ignore next - edge case coverage needed for bad party grouping in general election */ + /* istanbul ignore next - edge case coverage needed for bad party grouping in general election */ partyId: groupBy.groupByParty ? row.partyId ?? undefined : undefined, batchId: groupBy.groupByBatch ? row.batchId : undefined, scannerId: groupBy.groupByScanner ? row.scannerId : undefined, diff --git a/apps/admin/backend/src/tabulation/card_counts.ts b/apps/admin/backend/src/tabulation/card_counts.ts index 12705ebae7b..d76b9dc0512 100644 --- a/apps/admin/backend/src/tabulation/card_counts.ts +++ b/apps/admin/backend/src/tabulation/card_counts.ts @@ -33,7 +33,7 @@ function addCardTallyToCardCounts({ } else { // eslint-disable-next-line no-param-reassign cardCounts.hmpb[card.sheetNumber - 1] = - /* c8 ignore next - trivial fallback case */ + /* istanbul ignore next - trivial fallback case */ (cardCounts.hmpb[card.sheetNumber - 1] ?? 0) + tally; } diff --git a/apps/admin/backend/src/tabulation/full_results.ts b/apps/admin/backend/src/tabulation/full_results.ts index bcd48b7b286..69292d5d408 100644 --- a/apps/admin/backend/src/tabulation/full_results.ts +++ b/apps/admin/backend/src/tabulation/full_results.ts @@ -180,7 +180,7 @@ export async function tabulateElectionResults({ }); } ); - /* c8 ignore next 3 - debug only */ + /* istanbul ignore next - debug only */ } else { debug('filter or group by is not compatible with manual results'); } diff --git a/apps/admin/backend/src/tabulation/write_ins.ts b/apps/admin/backend/src/tabulation/write_ins.ts index fa4dfd1ba03..2f2651d9212 100644 --- a/apps/admin/backend/src/tabulation/write_ins.ts +++ b/apps/admin/backend/src/tabulation/write_ins.ts @@ -146,7 +146,7 @@ function addWriteInTallyToElectionWriteInSummary({ isWriteIn: true, }; break; - /* c8 ignore next 2 */ + /* istanbul ignore next */ default: throwIllegalValue(writeInTally); } diff --git a/apps/admin/backend/src/util/auth.ts b/apps/admin/backend/src/util/auth.ts index 00bd652b6d1..04e15e24488 100644 --- a/apps/admin/backend/src/util/auth.ts +++ b/apps/admin/backend/src/util/auth.ts @@ -29,7 +29,7 @@ export function constructAuthMachineState( return record; })(); - /* c8 ignore next 3 - covered by integration testing */ + /* istanbul ignore next - covered by integration testing */ const jurisdiction = isIntegrationTest() ? TEST_JURISDICTION : process.env.VX_MACHINE_JURISDICTION ?? DEV_JURISDICTION; @@ -64,6 +64,6 @@ export async function getUserRole( if (authStatus.status === 'logged_in') { return authStatus.user.role; } - /* c8 ignore next 2 - trivial fallback case */ + /* istanbul ignore next - trivial fallback case */ return 'unknown'; } diff --git a/apps/admin/backend/src/util/cdf_results.ts b/apps/admin/backend/src/util/cdf_results.ts index f1707d616b3..7437d9d8151 100644 --- a/apps/admin/backend/src/util/cdf_results.ts +++ b/apps/admin/backend/src/util/cdf_results.ts @@ -59,7 +59,7 @@ function buildOfficialCandidates( return candidates.map((candidate) => ({ '@type': 'ElectionResults.Candidate', '@id': candidate.id, - /* c8 ignore next 1 -- trivial fallthrough case */ + /* istanbul ignore next -- trivial fallthrough case */ PartyId: candidate.partyIds?.[0], BallotName: asInternationalizedText(candidate.name), })); diff --git a/apps/admin/backend/src/util/write_ins.ts b/apps/admin/backend/src/util/write_ins.ts index cc45f44e510..6ae6a4c903e 100644 --- a/apps/admin/backend/src/util/write_ins.ts +++ b/apps/admin/backend/src/util/write_ins.ts @@ -40,7 +40,7 @@ export async function getWriteInImageView({ const contestLayout = layout.contests.find( (contest) => contest.contestId === contestId ); - /* c8 ignore next 3 - TODO: revisit our layout assumptions based on our new ballots */ + /* istanbul ignore next - TODO: revisit our layout assumptions based on our new ballots */ if (!contestLayout) { throw new Error('unable to find a layout for the specified contest'); } @@ -52,13 +52,13 @@ export async function getWriteInImageView({ const writeInOptionIndex = safeParseNumber( optionId.slice('write-in-'.length) ); - /* c8 ignore next 3 - TODO: revisit our layout assumptions based on our new ballots */ + /* istanbul ignore next - TODO: revisit our layout assumptions based on our new ballots */ if (writeInOptionIndex.isErr() || writeInOptions === undefined) { throw new Error('unable to interpret layout write-in options'); } const writeInLayout = writeInOptions[writeInOptionIndex.ok()]; - /* c8 ignore next 3 - TODO: revisit our layout assumptions based on our new ballots */ + /* istanbul ignore next - TODO: revisit our layout assumptions based on our new ballots */ if (writeInLayout === undefined) { throw new Error('unexpected write-in option index'); } diff --git a/apps/admin/backend/src/util/zip.ts b/apps/admin/backend/src/util/zip.ts index 2f55ef241fb..f99fc276606 100644 --- a/apps/admin/backend/src/util/zip.ts +++ b/apps/admin/backend/src/util/zip.ts @@ -11,7 +11,7 @@ export function addFileToZipStream( ): Promise { return new Promise((resolve, reject) => { zipStream.entry(file.contents, { name: file.path }, (error) => { - /* c8 ignore next 2 - trivial error case */ + /* istanbul ignore next - trivial error case */ if (error) { reject(error); } else { diff --git a/apps/central-scan/backend/jest.config.js b/apps/central-scan/backend/jest.config.js index 6c6e1ad49eb..efe9f55cdf0 100644 --- a/apps/central-scan/backend/jest.config.js +++ b/apps/central-scan/backend/jest.config.js @@ -12,7 +12,7 @@ module.exports = { roots: ['/src'], setupFiles: ['/test/set_env_vars.ts'], setupFilesAfterEnv: ['/test/setup_custom_matchers.ts'], - coverageProvider: 'v8', + coverageProvider: 'babel', collectCoverageFrom: [ '**/*.{ts,tsx}', '!**/node_modules/**', diff --git a/apps/scan/backend/jest.config.js b/apps/scan/backend/jest.config.js index 9d9a63a87ef..530e57ef920 100644 --- a/apps/scan/backend/jest.config.js +++ b/apps/scan/backend/jest.config.js @@ -12,7 +12,7 @@ module.exports = { roots: ['/src'], setupFiles: ['/test/set_env_vars.ts'], setupFilesAfterEnv: ['/test/setup_custom_matchers.ts'], - coverageProvider: 'v8', + coverageProvider: 'babel', collectCoverageFrom: [ '**/*.{ts,tsx}', '!**/node_modules/**', @@ -24,8 +24,8 @@ module.exports = { coverageThreshold: { global: { statements: -180, - branches: -47, - functions: -15, + branches: -60, + functions: -16, lines: -180, }, }, diff --git a/apps/scan/backend/src/scanners/custom/state_machine.ts b/apps/scan/backend/src/scanners/custom/state_machine.ts index 8debfe45b78..62b285ae04a 100644 --- a/apps/scan/backend/src/scanners/custom/state_machine.ts +++ b/apps/scan/backend/src/scanners/custom/state_machine.ts @@ -1334,7 +1334,7 @@ export function createPrecinctScannerStateMachine({ type: interpretation.type, reasons: interpretation.reasons, }; - /* c8 ignore next 2 */ + /* istanbul ignore next */ default: throwIllegalValue(interpretation, 'type'); } diff --git a/apps/scan/backend/src/scanners/pdi/state_machine.ts b/apps/scan/backend/src/scanners/pdi/state_machine.ts index 87ba816b1d4..a202638e568 100644 --- a/apps/scan/backend/src/scanners/pdi/state_machine.ts +++ b/apps/scan/backend/src/scanners/pdi/state_machine.ts @@ -1002,7 +1002,7 @@ function setupLogging( ); }) .onChange(async (context, previousContext) => { - /* c8 ignore next */ + /* istanbul ignore next */ if (!previousContext) return; const changed = Object.entries(context).filter( ([key, value]) => previousContext[key as keyof Context] !== value @@ -1129,7 +1129,7 @@ export function createPrecinctScannerStateMachine({ return 'calibrating_double_feed_detection.done'; case state.matches('shoeshineModeRescanningBallot'): return 'accepted'; - /* c8 ignore next 2 */ + /* istanbul ignore next */ default: throw new Error(`Unexpected state: ${state.value}`); } @@ -1153,7 +1153,7 @@ export function createPrecinctScannerStateMachine({ type: interpretation.type, reasons: interpretation.reasons, }; - /* c8 ignore next 2 */ + /* istanbul ignore next */ default: return throwIllegalValue(interpretation, 'type'); } diff --git a/libs/backend/src/election_package/election_package_io.test.ts b/libs/backend/src/election_package/election_package_io.test.ts index 23ae155c45a..b46b12db42c 100644 --- a/libs/backend/src/election_package/election_package_io.test.ts +++ b/libs/backend/src/election_package/election_package_io.test.ts @@ -334,7 +334,7 @@ test('readElectionPackageFromFile errors when given an invalid election', async expect(await readElectionPackageFromFile(file)).toEqual( err({ type: 'invalid-election', - message: 'Unexpected token o in JSON at position 1', + message: `Unexpected token 'o', "not a valid election" is not valid JSON`, }) ); }); @@ -351,7 +351,7 @@ test('readElectionPackageFromFile errors when given invalid system settings', as expect(await readElectionPackageFromFile(file)).toEqual( err({ type: 'invalid-system-settings', - message: 'Unexpected token o in JSON at position 1', + message: `Unexpected token 'o', "not a valid"... is not valid JSON`, }) ); }); @@ -368,7 +368,7 @@ test('readElectionPackageFromFile errors when given invalid metadata', async () expect(await readElectionPackageFromFile(file)).toEqual( err({ type: 'invalid-metadata', - message: 'Unexpected token a in JSON at position 0', + message: `Unexpected token 'a', "asdf" is not valid JSON`, }) ); }); diff --git a/libs/custom-scanner/jest.config.js b/libs/custom-scanner/jest.config.js index a18d87f1e82..0b7c8e76287 100644 --- a/libs/custom-scanner/jest.config.js +++ b/libs/custom-scanner/jest.config.js @@ -12,5 +12,5 @@ module.exports = { '!src/mocks/**/*.ts', '!src/types/**/*.ts', ], - coverageProvider: 'v8', + coverageProvider: 'babel', }; diff --git a/libs/custom-scanner/src/custom_a4_scanner.ts b/libs/custom-scanner/src/custom_a4_scanner.ts index b8698fe659a..aa097217805 100644 --- a/libs/custom-scanner/src/custom_a4_scanner.ts +++ b/libs/custom-scanner/src/custom_a4_scanner.ts @@ -273,9 +273,9 @@ export class CustomA4Scanner implements CustomScanner { scan( scanParameters: ScanParameters, { - /* c8 ignore next */ + /* istanbul ignore next */ maxTimeoutNoMoveNoScan = 5_000, - /* c8 ignore next */ + /* istanbul ignore next */ maxRetries = 3, }: { maxTimeoutNoMoveNoScan?: number; maxRetries?: number } = {} ): Promise, ErrorCode>> { diff --git a/libs/custom-scanner/src/parameters.ts b/libs/custom-scanner/src/parameters.ts index 670ef8c402e..41e3d1cf9b8 100644 --- a/libs/custom-scanner/src/parameters.ts +++ b/libs/custom-scanner/src/parameters.ts @@ -52,7 +52,7 @@ function convertToMultiSheetDetectionSensorLevelInternal( return MultiSheetDetectionSensorLevelInternal.Level3; case DoubleSheetDetectOpt.Level4: return MultiSheetDetectionSensorLevelInternal.Level4; - /* c8 ignore next 2 */ + /* istanbul ignore next */ default: throwIllegalValue(option); } diff --git a/libs/custom-scanner/src/protocol.ts b/libs/custom-scanner/src/protocol.ts index 7cf7f193cb1..cca307290ae 100644 --- a/libs/custom-scanner/src/protocol.ts +++ b/libs/custom-scanner/src/protocol.ts @@ -667,7 +667,7 @@ class GetImageDataRequestScanSideCoder extends BaseCoder { case GetImageDataRequestScanSideCoder.SideB: return { value: ScanSide.B, bitOffset: decoded.bitOffset }; - /* c8 ignore next 2 */ + /* istanbul ignore next */ default: return err('InvalidValue'); } @@ -819,7 +819,7 @@ export function checkAnswer(data: Buffer): CheckAnswerResult { case ResponseErrorCode.INVALID_JOB_ID: return { type: 'error', errorCode: ErrorCode.JobNotValid }; - /* c8 ignore next 2 */ + /* istanbul ignore next */ default: throwIllegalValue(errorCode); } @@ -856,7 +856,7 @@ function mapCoderError(result: Result): Result { case 'UnsupportedOffset': throw new Error(`BUG: unsupported offset`); - /* c8 ignore next 2 */ + /* istanbul ignore next */ default: throwIllegalValue(coderError); } diff --git a/libs/pdi-scanner/jest.config.js b/libs/pdi-scanner/jest.config.js index 1fb0f05fab3..35734eed434 100644 --- a/libs/pdi-scanner/jest.config.js +++ b/libs/pdi-scanner/jest.config.js @@ -10,5 +10,5 @@ module.exports = { '!src/ts/index.ts', '!src/ts/demo.ts', ], - coverageProvider: 'v8', + coverageProvider: 'babel', }; diff --git a/libs/pdi-scanner/src/ts/scanner_client.ts b/libs/pdi-scanner/src/ts/scanner_client.ts index 8651184ced0..ce4124fd9e1 100644 --- a/libs/pdi-scanner/src/ts/scanner_client.ts +++ b/libs/pdi-scanner/src/ts/scanner_client.ts @@ -290,7 +290,7 @@ export function createPdiScannerClient() { }); pdictl.stderr.on('data', (data) => { - /* c8 ignore next */ + /* istanbul ignore next */ debug('pdictl stderr:', data.toString('utf-8')); }); diff --git a/libs/ui/src/reports/precinct_scanner_tally_reports.tsx b/libs/ui/src/reports/precinct_scanner_tally_reports.tsx index 64c35e1ad0f..a6691783aa0 100644 --- a/libs/ui/src/reports/precinct_scanner_tally_reports.tsx +++ b/libs/ui/src/reports/precinct_scanner_tally_reports.tsx @@ -8,7 +8,7 @@ import { } from '@votingworks/types'; import { combineElectionResults, - getContestsForPrecinct, + getContestsForPrecinctSelection, getEmptyElectionResults, } from '@votingworks/utils'; import { PrecinctScannerTallyReport } from './precinct_scanner_tally_report'; @@ -50,11 +50,9 @@ export function PrecinctScannerTallyReports({ }); const partyIds = getPartyIdsForPrecinctScannerTallyReports(electionDefinition); - const allContests = getContestsForPrecinct( + const allContests = getContestsForPrecinctSelection( electionDefinition, - precinctSelection.kind === 'SinglePrecinct' - ? precinctSelection.precinctId - : undefined + precinctSelection ); return partyIds.map((partyId) => { diff --git a/libs/utils/jest.config.js b/libs/utils/jest.config.js index fb9891fd03b..51ce0227d39 100644 --- a/libs/utils/jest.config.js +++ b/libs/utils/jest.config.js @@ -7,11 +7,11 @@ module.exports = { ...shared, testEnvironment: 'jsdom', setupFilesAfterEnv: ['/src/setupTests.ts'], - coverageProvider: 'v8', + coverageProvider: 'babel', coverageThreshold: { global: { - branches: -30, - lines: -123, + branches: -35, + lines: -124, }, }, collectCoverageFrom: [ diff --git a/libs/utils/src/cast_vote_records.test.ts b/libs/utils/src/cast_vote_records.test.ts index 4dbfa5aaa6c..1eb72559865 100644 --- a/libs/utils/src/cast_vote_records.test.ts +++ b/libs/utils/src/cast_vote_records.test.ts @@ -3,8 +3,14 @@ import path from 'path'; import { dirSync } from 'tmp'; import { BallotType, CVR } from '@votingworks/types'; +import { + electionFamousNames2021Fixtures, + electionWithMsEitherNeither, +} from '@votingworks/fixtures'; +import { err, ok } from '@votingworks/basics'; import { buildCVRSnapshotBallotTypeMetadata, + castVoteRecordHasValidContestReferences, convertCastVoteRecordVotesToTabulationVotes, getCastVoteRecordBallotType, getCurrentSnapshot, @@ -571,3 +577,134 @@ test('getCastVoteRecordBallotType', () => { ) ).toEqual(BallotType.Precinct); }); + +test('castVoteRecordHasValidContestReferences no contests', () => { + expect( + castVoteRecordHasValidContestReferences( + { + ...mockCastVoteRecord, + CVRSnapshot: [ + { + '@id': 'test', + '@type': 'CVR.CVRSnapshot', + Type: CVR.CVRType.Modified, + CVRContest: [], + }, + ], + }, + electionFamousNames2021Fixtures.election.contests + ) + ).toEqual(ok()); +}); + +test('castVoteRecordHasValidContestReferences no selections', () => { + expect( + castVoteRecordHasValidContestReferences( + { + ...mockCastVoteRecord, + CVRSnapshot: [ + { + '@id': 'test', + '@type': 'CVR.CVRSnapshot', + Type: CVR.CVRType.Modified, + CVRContest: [ + { + '@type': 'CVR.CVRContest', + ContestId: 'mayor', + CVRContestSelection: [], + }, + ], + }, + ], + }, + electionFamousNames2021Fixtures.election.contests + ) + ).toEqual(ok()); +}); + +test('castVoteRecordHasValidContestReferences invalid contest reference', () => { + expect( + castVoteRecordHasValidContestReferences( + { + ...mockCastVoteRecord, + CVRSnapshot: [ + { + '@id': 'test', + '@type': 'CVR.CVRSnapshot', + Type: CVR.CVRType.Modified, + CVRContest: [ + { + '@type': 'CVR.CVRContest', + ContestId: 'not-a-contest', + CVRContestSelection: [], + }, + ], + }, + ], + }, + electionFamousNames2021Fixtures.election.contests + ) + ).toEqual(err('contest-not-found')); +}); + +test('castVoteRecordHasValidContestReferences invalid contest option reference', () => { + expect( + castVoteRecordHasValidContestReferences( + { + ...mockCastVoteRecord, + CVRSnapshot: [ + { + '@id': 'test', + '@type': 'CVR.CVRSnapshot', + Type: CVR.CVRType.Modified, + CVRContest: [ + { + '@type': 'CVR.CVRContest', + ContestId: 'mayor', + CVRContestSelection: [ + { + '@type': 'CVR.CVRContestSelection', + ContestSelectionId: 'not-an-option', + SelectionPosition: [], + }, + ], + }, + ], + }, + ], + }, + electionFamousNames2021Fixtures.election.contests + ) + ).toEqual(err('contest-option-not-found')); +}); + +test('castVoteRecordHasValidContestReferences invalid yesno contest option reference', () => { + expect( + castVoteRecordHasValidContestReferences( + { + ...mockCastVoteRecord, + CVRSnapshot: [ + { + '@id': 'test', + '@type': 'CVR.CVRSnapshot', + Type: CVR.CVRType.Modified, + CVRContest: [ + { + '@type': 'CVR.CVRContest', + ContestId: '750000017', + CVRContestSelection: [ + { + '@type': 'CVR.CVRContestSelection', + ContestSelectionId: 'not-an-option', + SelectionPosition: [], + }, + ], + }, + ], + }, + ], + }, + electionWithMsEitherNeither.contests + ) + ).toEqual(err('contest-option-not-found')); +}); diff --git a/libs/utils/src/cast_vote_records.ts b/libs/utils/src/cast_vote_records.ts index a294c1f43bc..cae272e3b48 100644 --- a/libs/utils/src/cast_vote_records.ts +++ b/libs/utils/src/cast_vote_records.ts @@ -222,7 +222,7 @@ function getValidContestOptions(contest: AnyContest): ContestOptionId[] { ]; case 'yesno': return [contest.yesOption.id, contest.noOption.id]; - /* c8 ignore next 2 */ + /* istanbul ignore next */ default: return throwIllegalValue(contest); } diff --git a/libs/utils/src/compressed_tallies.ts b/libs/utils/src/compressed_tallies.ts index a38d89cbc63..ffe5fab25ef 100644 --- a/libs/utils/src/compressed_tallies.ts +++ b/libs/utils/src/compressed_tallies.ts @@ -48,7 +48,7 @@ export function compressTally( ]); } - /* c8 ignore next 2 */ + /* istanbul ignore next */ default: throwIllegalValue(contest, 'type'); } @@ -118,7 +118,7 @@ function getContestTalliesForCompressedContest( tallies: candidateTallies, }; } - /* c8 ignore next 2 */ + /* istanbul ignore next */ default: throwIllegalValue(contest, 'type'); } diff --git a/libs/utils/src/environment_variable.ts b/libs/utils/src/environment_variable.ts index d8b09e6ac55..b261ebde3ba 100644 --- a/libs/utils/src/environment_variable.ts +++ b/libs/utils/src/environment_variable.ts @@ -183,7 +183,7 @@ export function getEnvironmentVariable( return process.env.REACT_APP_VX_ONLY_ENABLE_SCREEN_READER_FOR_HEADPHONES; case BooleanEnvironmentVariableName.MARK_SCAN_DISABLE_BALLOT_REINSERTION: return process.env.REACT_APP_VX_MARK_SCAN_DISABLE_BALLOT_REINSERTION; - /* c8 ignore next 2 */ + /* istanbul ignore next */ default: throwIllegalValue(name); } @@ -326,7 +326,7 @@ export function getBooleanEnvVarConfig( allowInProduction: true, autoEnableInDevelopment: false, }; - /* c8 ignore next 2 */ + /* istanbul ignore next */ default: throwIllegalValue(name); } @@ -342,7 +342,7 @@ export function getStringEnvVarConfig( defaultValue: 'ms-sems', zodSchema: ConverterClientTypeSchema, }; - /* c8 ignore next 2 */ + /* istanbul ignore next */ default: throwIllegalValue(name); } diff --git a/libs/utils/src/file_reading_test.test.ts b/libs/utils/src/file_reading_test.test.ts new file mode 100644 index 00000000000..b29328171ec --- /dev/null +++ b/libs/utils/src/file_reading_test.test.ts @@ -0,0 +1,86 @@ +import { Buffer } from 'buffer'; +import JsZip from 'jszip'; +import { + getEntries, + getFileByName, + maybeGetFileByName, + openZip, + readEntry, + readFile, + readFileAsyncAsString, + readJsonEntry, + readTextEntry, +} from './file_reading'; + +test('readFileAsyncAsString', async () => { + const file = new File(['hello'], 'hello.txt'); + expect(await readFileAsyncAsString(file)).toEqual('hello'); +}); + +test('readFileAsyncAsString empty', async () => { + const file = new File([], 'empty.txt'); + expect(await readFileAsyncAsString(file)).toEqual(''); +}); + +test('readFileAsyncAsString error', async () => { + jest.spyOn(FileReader.prototype, 'readAsText').mockImplementation(() => { + throw new Error('read error'); + }); + + const file = new File(['hello'], 'hello.txt'); + await expect(readFileAsyncAsString(file)).rejects.toThrow('read error'); +}); + +test('readFile', async () => { + const file = new File(['hello'], 'hello.txt'); + const buffer = await readFile(file); + expect(buffer).toEqual(Buffer.from('hello')); +}); + +test('readFile empty', async () => { + const file = new File([], 'empty.txt'); + const buffer = await readFile(file); + expect(buffer).toEqual(Buffer.from([])); +}); + +test('readFile error', async () => { + jest + .spyOn(FileReader.prototype, 'readAsArrayBuffer') + .mockImplementation(() => { + throw new Error('read error'); + }); + + const file = new File(['hello'], 'hello.txt'); + await expect(readFile(file)).rejects.toThrow('read error'); +}); + +test('openZip', async () => { + const zip = new JsZip(); + zip.file('hello.txt', 'hello'); + zip.file('test.json', JSON.stringify({ test: true })); + const data = await zip.generateAsync({ type: 'uint8array' }); + + const newZip = await openZip(data); + const entries = getEntries(newZip); + expect(entries.map((entry) => entry.name)).toEqual([ + 'hello.txt', + 'test.json', + ]); + await expect(readEntry(getFileByName(entries, 'hello.txt'))).resolves.toEqual( + Buffer.from('hello') + ); + await expect( + readTextEntry(getFileByName(entries, 'hello.txt')) + ).resolves.toEqual('hello'); + await expect( + readJsonEntry(getFileByName(entries, 'test.json')) + ).resolves.toEqual({ + test: true, + }); + + expect(maybeGetFileByName(entries, 'missing.txt')).toBeUndefined(); + expect(maybeGetFileByName(entries, 'hello.txt')).toEqual( + getFileByName(entries, 'hello.txt') + ); + expect(() => getFileByName(entries, 'missing.txt')).toThrowError(); +}); diff --git a/libs/utils/src/filenames.ts b/libs/utils/src/filenames.ts index 52b42d27309..57b237116e9 100644 --- a/libs/utils/src/filenames.ts +++ b/libs/utils/src/filenames.ts @@ -95,7 +95,7 @@ export function generateLogFilename( return `${logFileName}${SUBSECTION_SEPARATOR}${timeSuffix}.log`; case LogFileType.Cdf: return `${logFileName}${WORD_SEPARATOR}cdf${SUBSECTION_SEPARATOR}${timeSuffix}.json`; - /* c8 ignore next 2 */ + /* istanbul ignore next */ default: throwIllegalValue(fileType); } diff --git a/libs/utils/src/hmpb/all_contest_options.ts b/libs/utils/src/hmpb/all_contest_options.ts index a1f6196ca7a..773d000000d 100644 --- a/libs/utils/src/hmpb/all_contest_options.ts +++ b/libs/utils/src/hmpb/all_contest_options.ts @@ -69,7 +69,7 @@ export function* allContestOptions( break; } - /* c8 ignore next 2 */ + /* istanbul ignore next */ default: throwIllegalValue(contest, 'type'); } diff --git a/libs/utils/src/polls.ts b/libs/utils/src/polls.ts index 5af619b15f2..80e61f386d6 100644 --- a/libs/utils/src/polls.ts +++ b/libs/utils/src/polls.ts @@ -16,7 +16,7 @@ export function getPollsTransitionDestinationState( return 'polls_paused'; case 'close_polls': return 'polls_closed_final'; - /* c8 ignore next 2 */ + /* istanbul ignore next */ default: throwIllegalValue(transitionType); } @@ -34,7 +34,7 @@ export function getPollsTransitionAction( return 'Resume Voting'; case 'close_polls': return 'Close Polls'; - /* c8 ignore next 2 */ + /* istanbul ignore next */ default: throwIllegalValue(transitionType); } @@ -52,7 +52,7 @@ export function getPollsReportTitle( return 'Voting Paused Report'; case 'close_polls': return 'Polls Closed Report'; - /* c8 ignore next 2 */ + /* istanbul ignore next */ default: throwIllegalValue(transitionType); } @@ -67,7 +67,7 @@ export function getPollsStateName(state: PollsState): string { case 'polls_closed_initial': case 'polls_closed_final': return 'Closed'; - /* c8 ignore next 2 */ + /* istanbul ignore next */ default: throwIllegalValue(state); } @@ -89,7 +89,7 @@ export function getPollTransitionsFromState( return ['open_polls']; case 'polls_closed_final': return []; - /* c8 ignore next 2 */ + /* istanbul ignore next */ default: throwIllegalValue(state); } @@ -110,7 +110,7 @@ export function isValidPollsStateChange( return newState === 'polls_open' || newState === 'polls_closed_final'; case 'polls_closed_final': return false; - /* c8 ignore next 2 */ + /* istanbul ignore next */ default: throwIllegalValue(prevState); } @@ -127,7 +127,7 @@ export function getPollsTransitionActionPastTense( return 'Voting Resumed'; case 'pause_voting': return 'Voting Paused'; - /* c8 ignore next 2 */ + /* istanbul ignore next */ default: throwIllegalValue(transitionType); } @@ -143,7 +143,7 @@ export function isPollsSuspensionTransition( case 'resume_voting': case 'pause_voting': return true; - /* c8 ignore next 2 */ + /* istanbul ignore next */ default: throwIllegalValue(transitionType); } diff --git a/libs/utils/src/tabulation/contest_filtering.test.ts b/libs/utils/src/tabulation/contest_filtering.test.ts index f8714ded974..9a45d7da5f8 100644 --- a/libs/utils/src/tabulation/contest_filtering.test.ts +++ b/libs/utils/src/tabulation/contest_filtering.test.ts @@ -8,7 +8,7 @@ import { doesContestAppearOnPartyBallot, getContestIdsForBallotStyle, getContestIdsForPrecinct, - getContestsForPrecinct, + getContestsForPrecinctSelection, } from './contest_filtering'; describe('doesContestAppearOnPartyBallot', () => { @@ -85,12 +85,13 @@ test('getContestIdsForPrecinct', () => { ]); }); -test('getContestsForPrecinct', () => { +test('getContestsForPrecinctSelection', () => { const { electionDefinition } = electionPrimaryPrecinctSplitsFixtures; expect( - getContestsForPrecinct(electionDefinition, 'precinct-c1-w2').map( - (c) => c.id - ) + getContestsForPrecinctSelection(electionDefinition, { + kind: 'SinglePrecinct', + precinctId: 'precinct-c1-w2', + }).map((c) => c.id) ).toEqual([ 'county-leader-mammal', 'congressional-1-mammal', @@ -98,4 +99,19 @@ test('getContestsForPrecinct', () => { 'county-leader-fish', 'congressional-1-fish', ]); + + expect( + getContestsForPrecinctSelection(electionDefinition, { + kind: 'AllPrecincts', + }).map((c) => c.id) + ).toEqual([ + 'county-leader-mammal', + 'county-leader-fish', + 'congressional-1-mammal', + 'congressional-1-fish', + 'congressional-2-mammal', + 'congressional-2-fish', + 'water-1-fishing', + 'water-2-fishing', + ]); }); diff --git a/libs/utils/src/tabulation/contest_filtering.ts b/libs/utils/src/tabulation/contest_filtering.ts index 38d038eff10..68b54bf066b 100644 --- a/libs/utils/src/tabulation/contest_filtering.ts +++ b/libs/utils/src/tabulation/contest_filtering.ts @@ -6,8 +6,9 @@ import { PrecinctId, AnyContest, Contests, + PrecinctSelection, } from '@votingworks/types'; -import { assert } from '@votingworks/basics'; +import { assert, throwIllegalValue } from '@votingworks/basics'; import { createElectionMetadataLookupFunction, getContestById, @@ -91,15 +92,24 @@ export function mapContestIdsToContests( ); } -export function getContestsForPrecinct( +export function getContestsForPrecinctSelection( electionDefinition: ElectionDefinition, - precinctId?: PrecinctId + precinctSelection: PrecinctSelection ): Contests { const { election } = electionDefinition; - if (!precinctId) { - return election.contests; - } + switch (precinctSelection.kind) { + case 'AllPrecincts': + return election.contests; + + case 'SinglePrecinct': { + const contestIds = getContestIdsForPrecinct( + electionDefinition, + precinctSelection.precinctId + ); + return mapContestIdsToContests(electionDefinition, contestIds); + } - const contestIds = getContestIdsForPrecinct(electionDefinition, precinctId); - return mapContestIdsToContests(electionDefinition, contestIds); + default: + throwIllegalValue(precinctSelection, 'kind'); + } } diff --git a/libs/utils/src/tabulation/lookups.test.ts b/libs/utils/src/tabulation/lookups.test.ts index 4590badde9b..5f3c1cb0c45 100644 --- a/libs/utils/src/tabulation/lookups.test.ts +++ b/libs/utils/src/tabulation/lookups.test.ts @@ -1,4 +1,9 @@ -import { ElectionDefinition, Tabulation } from '@votingworks/types'; +import { + DistrictIdSchema, + ElectionDefinition, + Tabulation, + unsafeParse, +} from '@votingworks/types'; import { electionTwoPartyPrimaryDefinition } from '@votingworks/fixtures'; import { getContestById, @@ -8,6 +13,7 @@ import { getBallotStylesByPartyId, getBallotStylesByPrecinctId, determinePartyId, + getDistrictById, } from './lookups'; test('getPrecinctById', () => { @@ -120,3 +126,35 @@ test('determinePartyId', () => { undefined ); }); + +test('getDistrictById', () => { + const electionDefinition = electionTwoPartyPrimaryDefinition; + expect(getDistrictById(electionDefinition, 'district-1').name).toEqual( + 'District 1' + ); + expect( + () => getDistrictById(electionDefinition, 'district-2').name + ).toThrowError(); + + // confirm that different elections are maintained separately + const modifiedElectionDefinition: ElectionDefinition = { + ...electionDefinition, + ballotHash: 'modified-ballot-hash', + election: { + ...electionDefinition.election, + districts: [ + { + id: unsafeParse(DistrictIdSchema, 'district-1'), + name: 'First District', + }, + ], + }, + }; + + expect( + getDistrictById(modifiedElectionDefinition, 'district-1').name + ).toEqual('First District'); + expect(getDistrictById(electionDefinition, 'district-1').name).toEqual( + 'District 1' + ); +}); diff --git a/libs/utils/src/tabulation/tabulation.test.ts b/libs/utils/src/tabulation/tabulation.test.ts index 765bc50e93e..d8de88e9176 100644 --- a/libs/utils/src/tabulation/tabulation.test.ts +++ b/libs/utils/src/tabulation/tabulation.test.ts @@ -41,6 +41,7 @@ import { getHmpbBallotCount, combineCandidateContestResults, buildContestResultsFixture, + yieldToEventLoop, } from './tabulation'; import { convertCastVoteRecordVotesToTabulationVotes, @@ -1326,3 +1327,11 @@ test('combinedCandidateContestResults - does not alter original tallies', () => expect(JSON.stringify(contestResultsA)).toEqual(aString); expect(JSON.stringify(contestResultsB)).toEqual(bString); }); + +test('yieldToEventLoop', async () => { + const fn = jest.fn(); + setImmediate(fn); + expect(fn).not.toHaveBeenCalled(); + await yieldToEventLoop(); + expect(fn).toHaveBeenCalled(); +}); diff --git a/libs/utils/src/tabulation/tabulation.ts b/libs/utils/src/tabulation/tabulation.ts index 835742622c9..6b6443a8b2d 100644 --- a/libs/utils/src/tabulation/tabulation.ts +++ b/libs/utils/src/tabulation/tabulation.ts @@ -354,7 +354,7 @@ export function getGroupSpecifierFromGroupKey( value ) as Tabulation.VotingMethod; break; - /* c8 ignore next 2 */ + /* istanbul ignore next */ default: throwIllegalValue(key); } diff --git a/libs/utils/src/votes.test.ts b/libs/utils/src/votes.test.ts index c6d3817df51..04ca2bcc835 100644 --- a/libs/utils/src/votes.test.ts +++ b/libs/utils/src/votes.test.ts @@ -7,6 +7,7 @@ import { import { BallotTargetMark, CandidateContest, + MarkStatus, Tabulation, WriteInCandidate, YesNoContest, @@ -16,6 +17,7 @@ import { convertMarksToVotesDict, getContestVoteOptionsForCandidateContest, getContestVoteOptionsForYesNoContest, + getMarkStatus, getSingleYesNoVote, hasWriteIns, normalizeWriteInId, @@ -252,3 +254,17 @@ test('hasWriteIns', () => { }) ).toEqual(true); }); + +test('getMarkStatus', () => { + expect(getMarkStatus(0.5, { marginal: 0.04, definite: 0.1 })).toEqual( + MarkStatus.Marked + ); + + expect(getMarkStatus(0.08, { marginal: 0.04, definite: 0.1 })).toEqual( + MarkStatus.Marginal + ); + + expect(getMarkStatus(0.07, { marginal: 0.08, definite: 0.1 })).toEqual( + MarkStatus.Unmarked + ); +}); diff --git a/libs/utils/src/votes.ts b/libs/utils/src/votes.ts index fc713f33e87..fd206d9e0cc 100644 --- a/libs/utils/src/votes.ts +++ b/libs/utils/src/votes.ts @@ -153,7 +153,7 @@ export function convertMarksToVotesDict( ? markToCandidateVotes(contest, markThresholds, mark) : contest.type === 'yesno' ? markToYesNoVotes(markThresholds, mark) - : /* c8 ignore next */ + : /* istanbul ignore next */ throwIllegalValue(contest, 'type'); votesDict[mark.contestId] = [...existingVotes, ...newVotes] as Vote;