diff --git a/apps/central-scan/backend/src/interpreter.test.ts b/apps/central-scan/backend/src/interpreter.test.ts index 6c828c5a7e..46055acbfc 100644 --- a/apps/central-scan/backend/src/interpreter.test.ts +++ b/apps/central-scan/backend/src/interpreter.test.ts @@ -962,7 +962,7 @@ test('interprets marks in ballots', async () => { Object { "id": "write-in-0", "isWriteIn": true, - "name": "Write-In", + "name": "Write-In #1", }, ], } @@ -2028,7 +2028,7 @@ test('returns metadata if the QR code is readable but the HMPB ballot is not', a Object { "id": "write-in-2", "isWriteIn": true, - "name": "Write-In", + "name": "Write-In #3", }, ], }, diff --git a/apps/central-scan/backend/src/types.ts b/apps/central-scan/backend/src/types.ts index 5c61648262..8b1f558505 100644 --- a/apps/central-scan/backend/src/types.ts +++ b/apps/central-scan/backend/src/types.ts @@ -1,11 +1,4 @@ -import { - BallotLocale, - BallotMark, - BallotTargetMark, - MarkStatus, - MarkThresholds, - PageInterpretation, -} from '@votingworks/types'; +import { BallotLocale, PageInterpretation } from '@votingworks/types'; import { BallotStyleData } from '@votingworks/utils'; export interface PageInterpretationWithAdjudication< @@ -25,25 +18,3 @@ export interface BallotConfig extends BallotStyleData { locales: BallotLocale; isLiveMode: boolean; } - -export function getMarkStatus( - mark: BallotTargetMark, - markThresholds: MarkThresholds -): MarkStatus { - if (mark.score >= markThresholds.definite) { - return MarkStatus.Marked; - } - - if (mark.score >= markThresholds.marginal) { - return MarkStatus.Marginal; - } - - return MarkStatus.Unmarked; -} - -export function isMarginalMark( - mark: BallotMark, - markThresholds: MarkThresholds -): boolean { - return getMarkStatus(mark, markThresholds) === MarkStatus.Marginal; -} diff --git a/apps/central-scan/backend/src/util/option_mark_status.ts b/apps/central-scan/backend/src/util/option_mark_status.ts index bdab85cd3d..75d928674a 100644 --- a/apps/central-scan/backend/src/util/option_mark_status.ts +++ b/apps/central-scan/backend/src/util/option_mark_status.ts @@ -6,7 +6,7 @@ import { MarkThresholds, } from '@votingworks/types'; import { throwIllegalValue } from '@votingworks/basics'; -import { getMarkStatus } from '../types'; +import { getMarkStatus } from '@votingworks/utils'; /** * state of the mark for a given contest and option @@ -29,13 +29,13 @@ export function optionMarkStatus({ switch (mark.type) { case 'candidate': if (mark.optionId === optionId) { - return getMarkStatus(mark, markThresholds); + return getMarkStatus(mark.score, markThresholds); } break; case 'yesno': if (mark.optionId === optionId) { - return getMarkStatus(mark, markThresholds); + return getMarkStatus(mark.score, markThresholds); } break; diff --git a/apps/scan/backend/src/types.ts b/apps/scan/backend/src/types.ts index 243ed0b301..c157740fe6 100644 --- a/apps/scan/backend/src/types.ts +++ b/apps/scan/backend/src/types.ts @@ -1,8 +1,6 @@ import { AdjudicationReasonInfo, - BallotTargetMark, ElectionDefinition, - MarkStatus, MarkThresholds, PageInterpretation, PollsState, @@ -31,21 +29,6 @@ export interface BallotPageQrcode { position: 'top' | 'bottom'; } -export function getMarkStatus( - mark: BallotTargetMark, - markThresholds: MarkThresholds -): MarkStatus { - if (mark.score >= markThresholds.definite) { - return MarkStatus.Marked; - } - - if (mark.score >= markThresholds.marginal) { - return MarkStatus.Marginal; - } - - return MarkStatus.Unmarked; -} - export type PrecinctScannerState = | 'connecting' | 'disconnected' diff --git a/apps/scan/backend/src/util/option_mark_status.ts b/apps/scan/backend/src/util/option_mark_status.ts index 2881ada91a..1ef3ac7319 100644 --- a/apps/scan/backend/src/util/option_mark_status.ts +++ b/apps/scan/backend/src/util/option_mark_status.ts @@ -6,7 +6,7 @@ import { MarkThresholds, } from '@votingworks/types'; import { throwIllegalValue } from '@votingworks/basics'; -import { getMarkStatus } from '../types'; +import { getMarkStatus } from '@votingworks/utils'; /** * state of the mark for a given contest and option @@ -30,13 +30,13 @@ export function optionMarkStatus({ switch (mark.type) { case 'candidate': if (mark.optionId === optionId) { - return getMarkStatus(mark, markThresholds); + return getMarkStatus(mark.score, markThresholds); } break; case 'yesno': if (mark.optionId === optionId) { - return getMarkStatus(mark, markThresholds); + return getMarkStatus(mark.score, markThresholds); } break; diff --git a/apps/scan/backend/src/vx_interpreter.test.ts b/apps/scan/backend/src/vx_interpreter.test.ts index ac0f469a5c..b997609f14 100644 --- a/apps/scan/backend/src/vx_interpreter.test.ts +++ b/apps/scan/backend/src/vx_interpreter.test.ts @@ -377,7 +377,7 @@ test('interprets marks in ballots', async () => { Object { "id": "write-in-0", "isWriteIn": true, - "name": "Write-In", + "name": "Write-In #1", }, ], } diff --git a/libs/backend/src/scan/cast_vote_records/build_cast_vote_record.ts b/libs/backend/src/scan/cast_vote_records/build_cast_vote_record.ts index 6b2315a3f1..959a509b3e 100644 --- a/libs/backend/src/scan/cast_vote_records/build_cast_vote_record.ts +++ b/libs/backend/src/scan/cast_vote_records/build_cast_vote_record.ts @@ -16,12 +16,14 @@ import { getContests, InterpretedBmdPage, InterpretedHmpbPage, + MarkStatus, safeParseInt, SheetOf, VotesDict, YesNoContest, YesNoVote, } from '@votingworks/types'; +import { getMarkStatus } from '@votingworks/utils'; import { BallotPageLayoutsLookup, @@ -372,7 +374,9 @@ function buildOriginalSnapshot({ (Math.floor(mark.score * 100) / 100).toString(), ], HasIndication: - mark.score >= definiteMarkThreshold + getMarkStatus(mark.score, { + definite: definiteMarkThreshold, + }) === MarkStatus.Marked ? CVR.IndicationStatus.Yes : CVR.IndicationStatus.No, }, diff --git a/libs/ballot-interpreter-nh/src/cli/interpret/index.ts b/libs/ballot-interpreter-nh/src/cli/interpret/index.ts index d114648d73..61e6b4646a 100644 --- a/libs/ballot-interpreter-nh/src/cli/interpret/index.ts +++ b/libs/ballot-interpreter-nh/src/cli/interpret/index.ts @@ -1,4 +1,4 @@ -import { Result, ok, err, find, iter } from '@votingworks/basics'; +import { err, find, iter, ok, Result } from '@votingworks/basics'; import { writeImageData } from '@votingworks/image-utils'; import { MarkThresholds, diff --git a/libs/ballot-interpreter-nh/src/interpret/convert_marks_to_adjudication_info.ts b/libs/ballot-interpreter-nh/src/interpret/convert_marks_to_adjudication_info.ts index 3141567211..16e56085d8 100644 --- a/libs/ballot-interpreter-nh/src/interpret/convert_marks_to_adjudication_info.ts +++ b/libs/ballot-interpreter-nh/src/interpret/convert_marks_to_adjudication_info.ts @@ -7,7 +7,7 @@ import { MarkStatus, MarkThresholds, } from '@votingworks/types'; -import { ballotAdjudicationReasons } from '@votingworks/utils'; +import { ballotAdjudicationReasons, getMarkStatus } from '@votingworks/utils'; import { InterpretedOvalMark } from '../types'; /** @@ -56,12 +56,14 @@ export function convertMarksToAdjudicationInfo({ let fallbackStatus = MarkStatus.Unmarked; for (const mark of marks) { - if (mark.score >= markThresholds.definite) { - return MarkStatus.Marked; + const markStatus = getMarkStatus(mark.score, markThresholds); + + if (markStatus === MarkStatus.Marked) { + return markStatus; } - if (mark.score >= markThresholds.marginal) { - fallbackStatus = MarkStatus.Marginal; + if (markStatus === MarkStatus.Marginal) { + fallbackStatus = markStatus; } } diff --git a/libs/ballot-interpreter-nh/src/interpret/convert_marks_to_votes.ts b/libs/ballot-interpreter-nh/src/interpret/convert_marks_to_votes.ts index 3b5c64fe4e..1ba1a61d49 100644 --- a/libs/ballot-interpreter-nh/src/interpret/convert_marks_to_votes.ts +++ b/libs/ballot-interpreter-nh/src/interpret/convert_marks_to_votes.ts @@ -1,16 +1,58 @@ +import { assert, find, iter, throwIllegalValue } from '@votingworks/basics'; import { - Candidate, - ContestOptionId, + BallotTargetMark, Contests, MarkThresholds, - Vote, VotesDict, } from '@votingworks/types'; -import { assert, find, throwIllegalValue } from '@votingworks/basics'; -import makeDebug from 'debug'; +import { convertMarksToVotesDict } from '@votingworks/utils'; import { InterpretedOvalMark } from '../types'; -const log = makeDebug('ballot-interpreter-nh:interpret'); +function convertNewHampshireMarkToSharedMark( + contests: Contests, + mark: InterpretedOvalMark +): BallotTargetMark { + const contest = find(contests, (c) => c.id === mark.gridPosition.contestId); + if (contest.type === 'candidate') { + return { + type: 'candidate', + contestId: contest.id, + optionId: + mark.gridPosition.type === 'option' + ? mark.gridPosition.optionId + : `write-in-${mark.gridPosition.writeInIndex}`, + score: mark.score, + bounds: mark.bounds, + scoredOffset: mark.scoredOffset, + target: { + inner: mark.bounds, + bounds: mark.bounds, + }, + }; + } + + if (contest.type === 'yesno') { + assert(mark.gridPosition.type === 'option'); + assert( + mark.gridPosition.optionId === 'yes' || + mark.gridPosition.optionId === 'no' + ); + return { + type: 'yesno', + contestId: contest.id, + optionId: mark.gridPosition.optionId, + score: mark.score, + bounds: mark.bounds, + scoredOffset: mark.scoredOffset, + target: { + inner: mark.bounds, + bounds: mark.bounds, + }, + }; + } + + throwIllegalValue(contest, 'type'); +} /** * Convert a series of oval marks into a list of candidate votes. @@ -18,74 +60,11 @@ const log = makeDebug('ballot-interpreter-nh:interpret'); export function convertMarksToVotes( contests: Contests, markThresholds: MarkThresholds, - ovalMarks: readonly InterpretedOvalMark[] + ovalMarks: Iterable ): VotesDict { - const votes: VotesDict = {}; - - for (const mark of ovalMarks) { - const { gridPosition } = mark; - const { contestId } = gridPosition; - const contest = find(contests, (c) => c.id === contestId); - - let vote: Vote; - let optionId: ContestOptionId; - - if (contest.type === 'candidate') { - const candidate: Candidate = - gridPosition.type === 'option' - ? find(contest.candidates, (c) => c.id === gridPosition.optionId) - : { - id: `write-in-${gridPosition.writeInIndex}`, - name: `Write-In #${gridPosition.writeInIndex + 1}`, - isWriteIn: true, - }; - vote = [candidate]; - optionId = candidate.id; - } else if (contest.type === 'yesno') { - assert(gridPosition.type === 'option'); - vote = [gridPosition.optionId] as Vote; - optionId = gridPosition.optionId; - } else { - throwIllegalValue(contest, 'type'); - } - - if (mark.score < markThresholds.marginal) { - log( - `Mark for contest '%s' option '%s' will be ignored, score is too low: %d < %d (marginal threshold)`, - contestId, - optionId, - mark.score, - markThresholds.marginal - ); - continue; - } - - if (mark.score < markThresholds.definite) { - log( - `Mark for contest '%s' option '%s' is marginal, score is too low: %d < %d (definite threshold)`, - contestId, - optionId, - mark.score, - markThresholds.definite - ); - continue; - } - - log( - `Mark for contest '%s' option '%s' will be counted, score is high enough: %d (definite threshold) ≤ %d`, - contestId, - optionId, - markThresholds.definite, - mark.score - ); - - if (!votes[contestId]) { - votes[contestId] = vote; - } else { - const existing = votes[contestId] as Vote; - votes[contestId] = [...existing, ...vote] as Vote; - } - } - - return votes; + return convertMarksToVotesDict( + contests, + markThresholds, + iter(ovalMarks).map((m) => convertNewHampshireMarkToSharedMark(contests, m)) + ); } diff --git a/libs/ballot-interpreter-vx/src/cli/commands/interpret.test.ts b/libs/ballot-interpreter-vx/src/cli/commands/interpret.test.ts index 0687d8a911..6bfa664495 100644 --- a/libs/ballot-interpreter-vx/src/cli/commands/interpret.test.ts +++ b/libs/ballot-interpreter-vx/src/cli/commands/interpret.test.ts @@ -429,7 +429,7 @@ test('run interpret', async () => { ╟───────────────────────────────────────────────────────┼────────────────────────────────────────────────────────────────────────────────────────────────────┼────────────────────────────────────────────────────────────────────────────────────────────────────╢ ║ Judge, Texas Supreme Court, Place 6 │ Jane Bland │ ║ ╟───────────────────────────────────────────────────────┼────────────────────────────────────────────────────────────────────────────────────────────────────┼────────────────────────────────────────────────────────────────────────────────────────────────────╢ - ║ Member, Texas House of Representatives, District 111 │ Write-In │ ║ + ║ Member, Texas House of Representatives, District 111 │ Write-In #1 │ ║ ╟───────────────────────────────────────────────────────┼────────────────────────────────────────────────────────────────────────────────────────────────────┼────────────────────────────────────────────────────────────────────────────────────────────────────╢ ║ Dallas County Tax Assessor-Collector │ John Ames │ ║ ╟───────────────────────────────────────────────────────┼────────────────────────────────────────────────────────────────────────────────────────────────────┼────────────────────────────────────────────────────────────────────────────────────────────────────╢ @@ -441,7 +441,7 @@ test('run interpret', async () => { ╟───────────────────────────────────────────────────────┼────────────────────────────────────────────────────────────────────────────────────────────────────┼────────────────────────────────────────────────────────────────────────────────────────────────────╢ ║ Proposition R: Countywide Recycling Program │ │ no ║ ╟───────────────────────────────────────────────────────┼────────────────────────────────────────────────────────────────────────────────────────────────────┼────────────────────────────────────────────────────────────────────────────────────────────────────╢ - ║ City Council │ │ Randall Rupp, Donald Davis, Write-In ║ + ║ City Council │ │ Randall Rupp, Donald Davis, Write-In #2 ║ ╟───────────────────────────────────────────────────────┼────────────────────────────────────────────────────────────────────────────────────────────────────┼────────────────────────────────────────────────────────────────────────────────────────────────────╢ ║ Mayor │ │ ║ ╚═══════════════════════════════════════════════════════╧════════════════════════════════════════════════════════════════════════════════════════════════════╧════════════════════════════════════════════════════════════════════════════════════════════════════╝ @@ -764,7 +764,7 @@ test('run interpret with JSON output', async () => { Object { "id": "write-in-0", "isWriteIn": true, - "name": "Write-In", + "name": "Write-In #1", }, ], "texas-sc-judge-place-6": Array [ diff --git a/libs/ballot-interpreter-vx/src/get_votes_from_marks.ts b/libs/ballot-interpreter-vx/src/get_votes_from_marks.ts index 57e9f4884e..19cc471cb8 100644 --- a/libs/ballot-interpreter-vx/src/get_votes_from_marks.ts +++ b/libs/ballot-interpreter-vx/src/get_votes_from_marks.ts @@ -1,16 +1,5 @@ -import { - BallotMark, - CandidateContest, - Election, - unsafeParse, - VotesDict, - WriteInCandidateSchema, -} from '@votingworks/types'; -import { find, throwIllegalValue } from '@votingworks/basics'; -import makeDebug from 'debug'; -import { addVote } from './hmpb/votes'; - -const debug = makeDebug('ballot-interpreter-vx:getVotesFromMarks'); +import { BallotMark, Election, VotesDict } from '@votingworks/types'; +import { convertMarksToVotesDict } from '@votingworks/utils'; /** * Gets the votes where the given marks have a high enough score to count, where @@ -22,63 +11,9 @@ export function getVotesFromMarks( marks: readonly BallotMark[], { markScoreVoteThreshold }: { markScoreVoteThreshold: number } ): VotesDict { - const votes: VotesDict = {}; - - for (const mark of marks) { - switch (mark.type) { - case 'candidate': - if (mark.score >= markScoreVoteThreshold) { - debug( - `'%s' contest '%s' mark score (%d) for '%s' meets vote threshold (%d)`, - mark.type, - mark.contestId, - mark.score, - mark.optionId, - markScoreVoteThreshold - ); - const contest = find( - election.contests, - (c): c is CandidateContest => c.id === mark.contestId - ); - const option = contest.candidates.find((c) => c.id === mark.optionId); - if (!option || option.isWriteIn) { - addVote( - election, - votes, - mark.contestId, - unsafeParse( - WriteInCandidateSchema, - option ?? { - id: mark.optionId, - name: 'Write-In', - isWriteIn: true, - } - ) - ); - } else { - addVote(election, votes, mark.contestId, mark.optionId); - } - } - break; - - case 'yesno': - if (mark.score >= markScoreVoteThreshold) { - debug( - `'%s' contest '%s' mark score (%d) for '%s' meets vote threshold (%d)`, - mark.type, - mark.contestId, - mark.score, - mark.optionId, - markScoreVoteThreshold - ); - addVote(election, votes, mark.contestId, mark.optionId); - } - break; - - default: - throwIllegalValue(mark, 'type'); - } - } - - return votes; + return convertMarksToVotesDict( + election.contests, + { definite: markScoreVoteThreshold }, + marks + ); } diff --git a/libs/ballot-interpreter-vx/src/interpreter/interpret_votes.test.ts b/libs/ballot-interpreter-vx/src/interpreter/interpret_votes.test.ts index df2feefc6a..5d866f672b 100644 --- a/libs/ballot-interpreter-vx/src/interpreter/interpret_votes.test.ts +++ b/libs/ballot-interpreter-vx/src/interpreter/interpret_votes.test.ts @@ -37,7 +37,7 @@ test('interpret votes', async () => { Object { "id": "write-in-0", "isWriteIn": true, - "name": "Write-In", + "name": "Write-In #1", }, ], "texas-sc-judge-place-6": Array [ diff --git a/libs/ballot-interpreter-vx/src/interpreter/invalid_marks.test.ts b/libs/ballot-interpreter-vx/src/interpreter/invalid_marks.test.ts index 816f2697a8..f07d5e47e9 100644 --- a/libs/ballot-interpreter-vx/src/interpreter/invalid_marks.test.ts +++ b/libs/ballot-interpreter-vx/src/interpreter/invalid_marks.test.ts @@ -43,7 +43,7 @@ test('invalid marks', async () => { Object { "id": "write-in-1", "isWriteIn": true, - "name": "Write-In", + "name": "Write-In #2", }, ], "dallas-county-commissioners-court-pct-3": Array [ diff --git a/libs/ballot-interpreter-vx/src/interpreter/regression_page_outline.test.ts b/libs/ballot-interpreter-vx/src/interpreter/regression_page_outline.test.ts index 89d8003e91..263acfff4c 100644 --- a/libs/ballot-interpreter-vx/src/interpreter/regression_page_outline.test.ts +++ b/libs/ballot-interpreter-vx/src/interpreter/regression_page_outline.test.ts @@ -42,7 +42,7 @@ test('regression: page outline', async () => { Object { "id": "write-in-1", "isWriteIn": true, - "name": "Write-In", + "name": "Write-In #2", }, ], "dallas-county-commissioners-court-pct-3": Array [ diff --git a/libs/ballot-interpreter-vx/src/interpreter/stretch.test.ts b/libs/ballot-interpreter-vx/src/interpreter/stretch.test.ts index 6533212d6d..787dfa8d1e 100644 --- a/libs/ballot-interpreter-vx/src/interpreter/stretch.test.ts +++ b/libs/ballot-interpreter-vx/src/interpreter/stretch.test.ts @@ -55,7 +55,7 @@ test('stretched precinct scanner ballot', async () => { Object { "id": "write-in-0", "isWriteIn": true, - "name": "Write-In", + "name": "Write-In #1", }, ], } diff --git a/libs/ballot-interpreter-vx/src/interpreter/two_column_template.test.ts b/libs/ballot-interpreter-vx/src/interpreter/two_column_template.test.ts index 1d3ccb35a0..ee06f23586 100644 --- a/libs/ballot-interpreter-vx/src/interpreter/two_column_template.test.ts +++ b/libs/ballot-interpreter-vx/src/interpreter/two_column_template.test.ts @@ -1450,7 +1450,7 @@ test('interpret two-column template', async () => { Object { "id": "write-in-0", "isWriteIn": true, - "name": "Write-In", + "name": "Write-In #1", }, ], } diff --git a/libs/ballot-interpreter-vx/src/interpreter/upside_down_ballot.test.ts b/libs/ballot-interpreter-vx/src/interpreter/upside_down_ballot.test.ts index a381d6f02e..c7c56f833b 100644 --- a/libs/ballot-interpreter-vx/src/interpreter/upside_down_ballot.test.ts +++ b/libs/ballot-interpreter-vx/src/interpreter/upside_down_ballot.test.ts @@ -46,7 +46,7 @@ test('upside-down ballot', async () => { Object { "id": "write-in-0", "isWriteIn": true, - "name": "Write-In", + "name": "Write-In #1", }, ], "texas-sc-judge-place-6": Array [ diff --git a/libs/utils/src/votes.test.ts b/libs/utils/src/votes.test.ts index ea70c02256..71b9b3d308 100644 --- a/libs/utils/src/votes.test.ts +++ b/libs/utils/src/votes.test.ts @@ -1,4 +1,6 @@ import { + electionFamousNames2021Fixtures, + electionGridLayoutNewHampshireAmherstFixtures, electionMultiPartyPrimaryFixtures, electionPrimaryNonpartisanContestsFixtures, electionSample, @@ -8,6 +10,7 @@ import { } from '@votingworks/fixtures'; import { BallotIdSchema, + BallotTargetMark, CandidateContest, CastVoteRecord, Election, @@ -20,10 +23,11 @@ import { TallyCategory, unsafeParse, VotingMethod, + WriteInCandidate, writeInCandidate, YesNoContest, } from '@votingworks/types'; -import { assert, find } from '@votingworks/basics'; +import { assert, find, typedAs } from '@votingworks/basics'; import { ALL_PARTY_FILTER, buildVoteFromCvr, @@ -38,6 +42,7 @@ import { getEmptyTally, getPartyIdForCvr, getSingleYesNoVote, + convertMarksToVotesDict, NONPARTISAN_FILTER, normalizeWriteInId, tallyVotesByContest, @@ -1266,3 +1271,159 @@ test('castVoteRecordHasWriteIns with write-in votes', () => { }) ).toEqual(true); }); + +const ballotTargetMarkBase: Pick< + BallotTargetMark, + 'bounds' | 'scoredOffset' | 'target' +> = { + bounds: { x: 0, y: 0, width: 0, height: 0 }, + scoredOffset: { x: 0, y: 0 }, + target: { + inner: { x: 0, y: 0, width: 0, height: 0 }, + bounds: { x: 0, y: 0, width: 0, height: 0 }, + }, +}; + +test('markInfoToVotesDict candidate', () => { + const { election } = electionFamousNames2021Fixtures; + const sherlockForMayorMark: BallotTargetMark = { + type: 'candidate', + contestId: 'mayor', + optionId: 'sherlock-holmes', + score: 0.5, + ...ballotTargetMarkBase, + }; + const edisonForMayorMark: BallotTargetMark = { + type: 'candidate', + contestId: 'mayor', + optionId: 'thomas-edison', + score: 0.5, + ...ballotTargetMarkBase, + }; + const writeInCandidateForMayorMark: BallotTargetMark = { + type: 'candidate', + contestId: 'mayor', + optionId: 'write-in', + score: 0.5, + ...ballotTargetMarkBase, + }; + const indexedWriteInCandidateForMayorMark: BallotTargetMark = { + ...writeInCandidateForMayorMark, + optionId: 'write-in-0', + }; + const mayorContest = find( + election.contests, + (c): c is CandidateContest => + c.id === sherlockForMayorMark.contestId && c.type === 'candidate' + ); + const sherlockCandidate = find( + mayorContest.candidates, + (c) => c.id === sherlockForMayorMark.optionId + ); + const edisonCandidate = find( + mayorContest.candidates, + (c) => c.id === edisonForMayorMark.optionId + ); + expect( + convertMarksToVotesDict( + election.contests, + { marginal: 0.04, definite: 0.1 }, + [sherlockForMayorMark] + ) + ).toEqual({ [mayorContest.id]: [sherlockCandidate] }); + expect( + convertMarksToVotesDict( + election.contests, + { marginal: 0.5, definite: 0.8 }, + [sherlockForMayorMark] + ) + ).toEqual({}); + expect( + convertMarksToVotesDict( + election.contests, + { marginal: 0.04, definite: 0.1 }, + [sherlockForMayorMark, edisonForMayorMark] + ) + ).toEqual({ + [mayorContest.id]: [sherlockCandidate, edisonCandidate], + }); + expect( + convertMarksToVotesDict( + election.contests, + { marginal: 0.04, definite: 0.1 }, + [writeInCandidateForMayorMark] + ) + ).toEqual({ + [mayorContest.id]: [writeInCandidate], + }); + expect( + convertMarksToVotesDict( + election.contests, + { marginal: 0.04, definite: 0.1 }, + [indexedWriteInCandidateForMayorMark] + ) + ).toEqual({ + [mayorContest.id]: [ + typedAs({ + id: 'write-in-0', + name: 'Write-In #1', + isWriteIn: true, + }), + ], + }); +}); + +test('markInfoToVotesDict yesno', () => { + const { election } = electionGridLayoutNewHampshireAmherstFixtures; + const yesnoContest = find( + election.contests, + (c): c is YesNoContest => c.type === 'yesno' + ); + const yesMark: BallotTargetMark = { + type: 'yesno', + contestId: yesnoContest.id, + optionId: 'yes', + score: 0.5, + bounds: { x: 0, y: 0, width: 0, height: 0 }, + scoredOffset: { x: 0, y: 0 }, + target: { + inner: { x: 0, y: 0, width: 0, height: 0 }, + bounds: { x: 0, y: 0, width: 0, height: 0 }, + }, + }; + const noMark: BallotTargetMark = { + type: 'yesno', + contestId: yesnoContest.id, + optionId: 'no', + score: 0.5, + bounds: { x: 0, y: 0, width: 0, height: 0 }, + scoredOffset: { x: 0, y: 0 }, + target: { + inner: { x: 0, y: 0, width: 0, height: 0 }, + bounds: { x: 0, y: 0, width: 0, height: 0 }, + }, + }; + expect( + convertMarksToVotesDict( + election.contests, + { marginal: 0.04, definite: 0.1 }, + [yesMark] + ) + ).toEqual({ [yesnoContest.id]: ['yes'] }); + expect( + convertMarksToVotesDict( + election.contests, + { marginal: 0.5, definite: 0.8 }, + [yesMark] + ) + ).toEqual({}); + expect( + convertMarksToVotesDict( + election.contests, + { marginal: 0.04, definite: 0.1 }, + [yesMark, noMark] + ) + ).toEqual({ + [yesnoContest.id]: ['yes', 'no'], + }); +}); diff --git a/libs/utils/src/votes.ts b/libs/utils/src/votes.ts index 924016464c..eb4d9fb7c1 100644 --- a/libs/utils/src/votes.ts +++ b/libs/utils/src/votes.ts @@ -1,4 +1,6 @@ +import { assert, find, throwIllegalValue, typedAs } from '@votingworks/basics'; import { + BallotTargetMark, BatchTally, Candidate, CandidateContest, @@ -8,26 +10,32 @@ import { ContestId, ContestOptionId, ContestOptionTally, + Contests, ContestTally, ContestTallyMeta, Dictionary, Election, FullElectionTally, getBallotStyle, + MarkStatus, + MarkThresholds, Optional, PartyId, PrecinctId, + safeParse, + safeParseInt, Tally, TallyCategory, + Vote, VotesDict, VotingMethod, writeInCandidate, + WriteInIdSchema, YesNoContest, YesNoVote, YesNoVoteId, YesOrNo, } from '@votingworks/types'; -import { assert, throwIllegalValue, find, typedAs } from '@votingworks/basics'; const MISSING_BATCH_ID = 'missing-batch-id'; @@ -653,3 +661,104 @@ export function castVoteRecordHasWriteIns(cvr: CastVoteRecord): boolean { } return false; } + +type MarkThresholdsOptionalMarginal = Omit & + Partial>; + +export function getMarkStatus( + markScore: BallotTargetMark['score'], + markThresholds: MarkThresholds +): MarkStatus; +export function getMarkStatus( + markScore: BallotTargetMark['score'], + markThresholds: Omit +): Exclude; +export function getMarkStatus( + markScore: BallotTargetMark['score'], + markThresholds: MarkThresholdsOptionalMarginal +): MarkStatus { + if (markScore >= markThresholds.definite) { + return MarkStatus.Marked; + } + + if ( + typeof markThresholds.marginal === 'number' && + markScore >= markThresholds.marginal + ) { + return MarkStatus.Marginal; + } + + return MarkStatus.Unmarked; +} + +function markToCandidateVotes( + contest: CandidateContest, + markThresholds: Pick, + mark: BallotTargetMark +): CandidateVote { + assert(mark.type === 'candidate'); + if (getMarkStatus(mark.score, markThresholds) !== MarkStatus.Marked) { + return []; + } + + if (safeParse(WriteInIdSchema, mark.optionId).isOk()) { + const indexedWriteInMatch = mark.optionId.match(/^write-in-(\d+)$/); + + if (!indexedWriteInMatch) { + return [writeInCandidate]; + } + + const writeInIndex = safeParseInt(indexedWriteInMatch[1]).assertOk( + '\\d+ ensures this is an integer' + ); + + return [ + { + id: mark.optionId, + name: `Write-In #${writeInIndex + 1}`, + isWriteIn: true, + }, + ]; + } + + const candidate = contest.candidates.find((c) => c.id === mark.optionId); + assert(candidate, `Candidate not found: ${mark.contestId}/${mark.optionId}`); + return [candidate]; +} + +function markToYesNoVotes( + markThresholds: Pick, + mark: BallotTargetMark +): YesNoVote { + assert(mark.type === 'yesno'); + return getMarkStatus(mark.score, markThresholds) === MarkStatus.Marked + ? [mark.optionId] + : []; +} + +/** + * Convert {@link BallotTargetMark}s to {@link VotesDict}. + */ +export function convertMarksToVotesDict( + contests: Contests, + markThresholds: MarkThresholdsOptionalMarginal, + marks: Iterable +): VotesDict { + const votesDict: VotesDict = {}; + for (const mark of marks) { + const contest = contests.find((c) => c.id === mark.contestId); + assert(contest, `Contest not found: ${mark.contestId}`); + const existingVotes = votesDict[mark.contestId] ?? []; + const newVotes = + contest.type === 'candidate' + ? markToCandidateVotes(contest, markThresholds, mark) + : contest.type === 'yesno' + ? markToYesNoVotes(markThresholds, mark) + : /* istanbul ignore next */ throwIllegalValue(contest, 'type'); + + if (newVotes.length > 0) { + votesDict[mark.contestId] = [...existingVotes, ...newVotes] as Vote; + } + } + return votesDict; +}