From f4abffb487c74ddbd1f8c7102a424ddab80f9a5e Mon Sep 17 00:00:00 2001 From: Jonah Kagan Date: Mon, 18 Mar 2024 10:34:43 -0700 Subject: [PATCH] Fetch jurisdictions discrepancy counts from separate endpoint (#1900) Previously, discrepancy counts were included in jurisdiction round status. This can be a slow calculation, since we need to load all the sampled ballot interpretations. Since the jurisdictions endpoint blocks page load, it's not great to have a slow calculation as part of that endpoint. Instead, we load the discrepancy counts separately and show a spinner while they are loading. --- .../AuditAdmin/Progress/Progress.test.tsx | 33 +++- .../AuditAdmin/Progress/Progress.tsx | 37 +++-- client/src/components/_mocks.ts | 14 +- client/src/components/useJurisdictions.ts | 23 ++- server/api/jurisdictions.py | 145 ++++++++++-------- server/tests/api/test_jurisdictions.py | 30 ++++ .../snapshots/snap_test_ballot_comparison.py | 28 ---- .../test_ballot_comparison.py | 48 +++--- .../snapshots/snap_test_batch_comparison.py | 14 -- .../batch_comparison/test_batch_comparison.py | 30 ++-- .../test_multi_contest_batch_comparison.py | 33 ++-- 11 files changed, 254 insertions(+), 181 deletions(-) diff --git a/client/src/components/AuditAdmin/Progress/Progress.test.tsx b/client/src/components/AuditAdmin/Progress/Progress.test.tsx index 66eec4ad4..f37993ab0 100644 --- a/client/src/components/AuditAdmin/Progress/Progress.test.tsx +++ b/client/src/components/AuditAdmin/Progress/Progress.test.tsx @@ -239,7 +239,14 @@ describe('Progress screen', () => { }) it('shows round status for ballot comparison', async () => { - const expectedCalls = [aaApiCalls.getMapData] + const expectedCalls = [ + aaApiCalls.getMapData, + aaApiCalls.getDiscrepancyCounts({ + [jurisdictionMocks.allComplete[0].id]: 0, + [jurisdictionMocks.allComplete[1].id]: 2, + [jurisdictionMocks.allComplete[2].id]: 1, + }), + ] await withMockFetch(expectedCalls, async () => { const { container } = render({ auditSettings: auditSettingsMocks.ballotComparisonAll, @@ -249,6 +256,10 @@ describe('Progress screen', () => { expect(container.querySelectorAll('.d3-component').length).toBe(1) + // One spinner for each jurisdiction's discrepancy count + expect(container.querySelectorAll('.bp3-spinner').length).toBe( + 1 + jurisdictionMocks.allComplete.length + ) await waitFor(() => { expect(container.querySelectorAll('.bp3-spinner').length).toBe(0) }) @@ -315,7 +326,14 @@ describe('Progress screen', () => { }) it('shows round status for batch comparison', async () => { - const expectedCalls = [aaApiCalls.getMapData] + const expectedCalls = [ + aaApiCalls.getMapData, + aaApiCalls.getDiscrepancyCounts({ + [jurisdictionMocks.oneComplete[0].id]: 3, + [jurisdictionMocks.oneComplete[1].id]: 2, + [jurisdictionMocks.oneComplete[2].id]: 1, + }), + ] await withMockFetch(expectedCalls, async () => { const { container } = render({ auditSettings: auditSettingsMocks.batchComparisonAll, @@ -346,6 +364,7 @@ describe('Progress screen', () => { expect(row1[0]).toHaveTextContent('Jurisdiction 1') expectStatusTag(row1[1], 'In progress', 'warning') expect(row1[2]).toHaveTextContent('2,117') + // Discrepancies hidden until jurisdiction is complete expect(row1[3]).toHaveTextContent('') expect(row1[4]).toHaveTextContent('4') expect(row1[5]).toHaveTextContent('6') @@ -353,6 +372,7 @@ describe('Progress screen', () => { expect(row2[0]).toHaveTextContent('Jurisdiction 2') expectStatusTag(row2[1], 'Not started', 'none') expect(row2[2]).toHaveTextContent('2,117') + // Discrepancies hidden until jurisdiction is complete expect(row2[3]).toHaveTextContent('') expect(row2[4]).toHaveTextContent('0') expect(row2[5]).toHaveTextContent('0') @@ -595,7 +615,14 @@ describe('Progress screen', () => { }) it('shows a different toggle label for batch audits', async () => { - const expectedCalls = [aaApiCalls.getMapData] + const expectedCalls = [ + aaApiCalls.getMapData, + aaApiCalls.getDiscrepancyCounts({ + [jurisdictionMocks.oneComplete[0].id]: 0, + [jurisdictionMocks.oneComplete[1].id]: 0, + [jurisdictionMocks.oneComplete[2].id]: 0, + }), + ] await withMockFetch(expectedCalls, async () => { const { container } = render({ jurisdictions: jurisdictionMocks.oneComplete, diff --git a/client/src/components/AuditAdmin/Progress/Progress.tsx b/client/src/components/AuditAdmin/Progress/Progress.tsx index 37dc9bdcc..06fc8809b 100644 --- a/client/src/components/AuditAdmin/Progress/Progress.tsx +++ b/client/src/components/AuditAdmin/Progress/Progress.tsx @@ -9,6 +9,7 @@ import { ITagProps, Icon, AnchorButton, + Spinner, } from '@blueprintjs/core' import H2Title from '../../Atoms/H2Title' import { @@ -16,6 +17,7 @@ import { IJurisdiction, getJurisdictionStatus, JurisdictionProgressStatus, + useDiscrepancyCountsByJurisdiction, } from '../../useJurisdictions' import JurisdictionDetail from './JurisdictionDetail' import { @@ -69,6 +71,14 @@ const Progress: React.FC = ({ round, }: IProgressProps) => { const { electionId } = useParams<{ electionId: string }>() + const { auditType } = auditSettings + const showDiscrepancies = + Boolean(round) && + (auditType === 'BALLOT_COMPARISON' || auditType === 'BATCH_COMPARISON') + const discrepancyCountsQuery = useDiscrepancyCountsByJurisdiction( + electionId, + { enabled: showDiscrepancies } + ) // Store sort and filter state in URL search params to allow it to persist // across page refreshes const [sortAndFilterState, setSortAndFilterState] = useSearchParams<{ @@ -81,17 +91,12 @@ const Progress: React.FC = ({ string | null >(null) - const { auditType } = auditSettings - const ballotsOrBatches = auditType === 'BATCH_COMPARISON' ? 'Batches' : 'Ballots' - const showDiscrepancies = - round && - (auditType === 'BALLOT_COMPARISON' || auditType === 'BATCH_COMPARISON') - const someJurisdictionHasDiscrepancies = jurisdictions.some( - jurisdiction => (jurisdiction.currentRoundStatus?.numDiscrepancies || 0) > 0 - ) + const someJurisdictionHasDiscrepancies = Object.values( + discrepancyCountsQuery.data ?? {} + ).some(count => count > 0) const columns: Column[] = [ { @@ -306,8 +311,18 @@ const Progress: React.FC = ({ if (showDiscrepancies) { columns.push({ Header: 'Discrepancies', - accessor: ({ currentRoundStatus: s }) => s && s.numDiscrepancies, + accessor: ({ id, currentRoundStatus: s }) => + s && + s.status === JurisdictionRoundStatus.COMPLETE && + discrepancyCountsQuery.data?.[id], Cell: ({ value }: { value: number | null }) => { + if (discrepancyCountsQuery.isLoading) { + return ( +
+ +
+ ) + } if (!value) return null return ( <> @@ -315,7 +330,9 @@ const Progress: React.FC = ({ ) }, - Footer: totalFooter('Discrepancies'), + Footer: discrepancyCountsQuery.isLoading + ? () => null + : totalFooter('Discrepancies'), }) } columns.push( diff --git a/client/src/components/_mocks.ts b/client/src/components/_mocks.ts index 119166a33..c445c1c2b 100644 --- a/client/src/components/_mocks.ts +++ b/client/src/components/_mocks.ts @@ -21,6 +21,7 @@ import { IBallotManifestInfo, IBatchTalliesFileInfo, IJurisdiction, + DiscrepancyCountsByJurisdiction, } from './useJurisdictions' import { IStandardizedContest } from './useStandardizedContests' import { ISampleSizesResponse } from './AuditAdmin/Setup/Review/useSampleSizes' @@ -686,7 +687,6 @@ export const jurisdictionMocks = mocksOfType()({ numUnique: 10, numSamplesAudited: 5, numSamples: 11, - numDiscrepancies: null, }, }, { @@ -701,7 +701,6 @@ export const jurisdictionMocks = mocksOfType()({ numUnique: 20, numSamplesAudited: 0, numSamples: 22, - numDiscrepancies: null, }, }, { @@ -716,7 +715,6 @@ export const jurisdictionMocks = mocksOfType()({ numUnique: 30, numSamplesAudited: 31, numSamples: 31, - numDiscrepancies: 1, }, }, ], @@ -733,7 +731,6 @@ export const jurisdictionMocks = mocksOfType()({ numUnique: 10, numSamplesAudited: 11, numSamples: 11, - numDiscrepancies: 0, }, }, { @@ -748,7 +745,6 @@ export const jurisdictionMocks = mocksOfType()({ numUnique: 20, numSamplesAudited: 22, numSamples: 22, - numDiscrepancies: 2, }, }, { @@ -763,7 +759,6 @@ export const jurisdictionMocks = mocksOfType()({ numUnique: 30, numSamplesAudited: 31, numSamples: 31, - numDiscrepancies: 1, }, }, ], @@ -833,7 +828,6 @@ export const jurisdictionMocks = mocksOfType()({ numUnique: 10, numSamplesAudited: 0, numSamples: 11, - numDiscrepancies: null, }, }, { @@ -848,7 +842,6 @@ export const jurisdictionMocks = mocksOfType()({ numUnique: 20, numSamplesAudited: 0, numSamples: 22, - numDiscrepancies: null, }, }, { @@ -863,7 +856,6 @@ export const jurisdictionMocks = mocksOfType()({ numUnique: 30, numSamplesAudited: 31, numSamples: 31, - numDiscrepancies: 0, }, }, ], @@ -2015,6 +2007,10 @@ export const aaApiCalls = { url: '/us-states-counties.json', response: mapTopology, }, + getDiscrepancyCounts: (response: DiscrepancyCountsByJurisdiction) => ({ + url: '/api/election/1/discrepancy-counts', + response, + }), reopenAuditBoard: { url: '/api/election/1/jurisdiction/jurisdiction-id-1/round/round-1/audit-board/audit-board-1/sign-off', diff --git a/client/src/components/useJurisdictions.ts b/client/src/components/useJurisdictions.ts index d46f2bd46..544cfc735 100644 --- a/client/src/components/useJurisdictions.ts +++ b/client/src/components/useJurisdictions.ts @@ -50,7 +50,6 @@ export interface IJurisdiction { numUnique: number numUniqueAudited: number numBatchesAudited?: number - numDiscrepancies?: number | null } | null } @@ -144,3 +143,25 @@ export const useJurisdictions = ( return response.jurisdictions }) } + +// { jurisidictionId: discrepancyCount } +export type DiscrepancyCountsByJurisdiction = Record + +const discrepancyCountsQueryKey = (electionId: string): string[] => + jurisdictionsQueryKey(electionId).concat('discrepancy-counts') + +export const useDiscrepancyCountsByJurisdiction = ( + electionId: string, + options: { enabled?: boolean } +): UseQueryResult => { + return useQuery( + discrepancyCountsQueryKey(electionId), + async () => { + const response: DiscrepancyCountsByJurisdiction = await fetchApi( + `/api/election/${electionId}/discrepancy-counts` + ) + return response + }, + options + ) +} diff --git a/server/api/jurisdictions.py b/server/api/jurisdictions.py index 98157ba8a..dc3db4690 100644 --- a/server/api/jurisdictions.py +++ b/server/api/jurisdictions.py @@ -424,35 +424,6 @@ def num_ballots_audited(jurisdiction: Jurisdiction) -> int: for jurisdiction in election.jurisdictions } - # Add numDiscrepancies only for ballot comparison - if election.audit_type == AuditType.BALLOT_COMPARISON: - discrepancy_count_by_jurisdiction: Dict[str, int] = Counter() - sampled_ballot_id_to_jurisdiction_id = dict( - SampledBallot.query.filter(SampledBallot.id.in_(ballots_in_round)) - .join(Batch) - .with_entities(SampledBallot.id, Batch.jurisdiction_id) - ) - for contest in election.contests: - reported_results = cvrs_for_contest(contest) - audited_results = sampled_ballot_interpretations_to_cvrs(contest) - for ballot_id, audited_result in audited_results.items(): - vote_deltas = ballot_vote_deltas( - contest, reported_results.get(ballot_id), audited_result["cvr"], - ) - if vote_deltas and ballot_id in sampled_ballot_id_to_jurisdiction_id: - jurisdiction_id = sampled_ballot_id_to_jurisdiction_id[ballot_id] - discrepancy_count_by_jurisdiction[jurisdiction_id] += 1 - - def num_discrepancies(jurisdiction: Jurisdiction) -> Optional[int]: - if status(jurisdiction) != JurisdictionStatus.COMPLETE: - return None - return discrepancy_count_by_jurisdiction[jurisdiction.id] - - for jurisdiction in election.jurisdictions: - statuses[jurisdiction.id]["numDiscrepancies"] = num_discrepancies( - jurisdiction - ) - # Special case: when we're in a full hand tally, also add a count of batches # submitted. if full_hand_tally: @@ -515,37 +486,6 @@ def batch_round_status(election: Election, round: Round) -> Dict[str, JSONDict]: ).values(BatchResultsFinalized.jurisdiction_id) } - discrepancy_count_by_jurisdiction: Dict[str, int] = Counter() - batch_keys_in_round = set( - SampledBatchDraw.query.filter_by(round_id=round.id) - .join(Batch) - .join(Jurisdiction) - .with_entities(Jurisdiction.name, Batch.name) - .all() - ) - jurisdiction_name_to_id = dict( - Jurisdiction.query.filter_by(election_id=election.id).with_entities( - Jurisdiction.name, Jurisdiction.id - ) - ) - contests = list(election.contests) - - # If a batch has a discrepancy in multiple contests, the discrepancy count will be incremented - # multiple times. In other words, the discrepancy count represents the number of batch-contest - # pairs with discrepancies, not the number of batches with discrepancies. - for contest in contests: - reported_results = batch_tallies(contest) - audited_results = sampled_batch_results(contest, include_non_rla_batches=True) - for batch_key, audited_result in audited_results.items(): - if batch_key in batch_keys_in_round: - vote_deltas = batch_vote_deltas( - reported_results[batch_key][contest.id], audited_result[contest.id] - ) - if vote_deltas: - jurisdiction_name, _ = batch_key - jurisdiction_id = jurisdiction_name_to_id[jurisdiction_name] - discrepancy_count_by_jurisdiction[jurisdiction_id] += 1 - def num_samples(jurisdiction_id: str) -> int: return sample_count_by_jurisdiction.get(jurisdiction_id, 0) @@ -573,11 +513,6 @@ def status(jurisdiction_id: str) -> JurisdictionStatus: else: return JurisdictionStatus.COMPLETE - def num_discrepancies(jurisdiction_id: str) -> Optional[int]: - if status(jurisdiction_id) != JurisdictionStatus.COMPLETE: - return None - return discrepancy_count_by_jurisdiction[jurisdiction_id] - return { jurisdiction.id: { "status": status(jurisdiction.id), @@ -585,7 +520,6 @@ def num_discrepancies(jurisdiction_id: str) -> Optional[int]: "numSamplesAudited": num_samples_audited(jurisdiction.id), "numUnique": num_batches(jurisdiction.id), "numUniqueAudited": num_batches_audited(jurisdiction.id), - "numDiscrepancies": num_discrepancies(jurisdiction.id), } for jurisdiction in election.jurisdictions } @@ -670,3 +604,82 @@ def update_jurisdictions_file(election: Election): db_session.commit() return jsonify(status="ok") + + +@api.route("/election//discrepancy-counts", methods=["GET"]) +@restrict_access([UserType.AUDIT_ADMIN]) +def get_discrepancy_counts_by_jurisdiction(election: Election): + discrepancy_count_by_jurisdiction: Dict[str, int] = Counter() + round = get_current_round(election) + if not round: + raise Conflict("Audit not started") + + if election.audit_type == AuditType.BALLOT_COMPARISON: + ballots_in_round = ( + SampledBallot.query.join(SampledBallotDraw) + .filter_by(round_id=round.id) + .distinct(SampledBallot.id) + .with_entities(SampledBallot.id) + .subquery() + ) + sampled_ballot_id_to_jurisdiction_id = dict( + SampledBallot.query.filter(SampledBallot.id.in_(ballots_in_round)) + .join(Batch) + .with_entities(SampledBallot.id, Batch.jurisdiction_id) + ) + for contest in election.contests: + reported_results = cvrs_for_contest(contest) + audited_results = sampled_ballot_interpretations_to_cvrs(contest) + for ballot_id, audited_result in audited_results.items(): + vote_deltas = ballot_vote_deltas( + contest, reported_results.get(ballot_id), audited_result["cvr"], + ) + if vote_deltas and ballot_id in sampled_ballot_id_to_jurisdiction_id: + jurisdiction_id = sampled_ballot_id_to_jurisdiction_id[ballot_id] + discrepancy_count_by_jurisdiction[jurisdiction_id] += 1 + + elif election.audit_type == AuditType.BATCH_COMPARISON: + batch_keys_in_round = set( + SampledBatchDraw.query.filter_by(round_id=round.id) + .join(Batch) + .join(Jurisdiction) + .with_entities(Jurisdiction.name, Batch.name) + .all() + ) + jurisdiction_name_to_id = dict( + Jurisdiction.query.filter_by(election_id=election.id).with_entities( + Jurisdiction.name, Jurisdiction.id + ) + ) + contests = list(election.contests) + + # If a batch has a discrepancy in multiple contests, the discrepancy count will be incremented + # multiple times. In other words, the discrepancy count represents the number of batch-contest + # pairs with discrepancies, not the number of batches with discrepancies. + for contest in contests: + reported_batch_results = batch_tallies(contest) + audited_batch_results = sampled_batch_results( + contest, include_non_rla_batches=True + ) + for batch_key, audited_batch_result in audited_batch_results.items(): + if batch_key in batch_keys_in_round: + vote_deltas = batch_vote_deltas( + reported_batch_results[batch_key][contest.id], + audited_batch_result[contest.id], + ) + if vote_deltas: + jurisdiction_name, _ = batch_key + jurisdiction_id = jurisdiction_name_to_id[jurisdiction_name] + discrepancy_count_by_jurisdiction[jurisdiction_id] += 1 + + else: + raise Conflict( + "Discrepancy counts are only available for ballot comparison and batch comparison audits" + ) + + return jsonify( + { + jurisdiction.id: discrepancy_count_by_jurisdiction[jurisdiction.id] + for jurisdiction in election.jurisdictions + } + ) diff --git a/server/tests/api/test_jurisdictions.py b/server/tests/api/test_jurisdictions.py index f5eaa4f77..d73d4dee7 100644 --- a/server/tests/api/test_jurisdictions.py +++ b/server/tests/api/test_jurisdictions.py @@ -382,3 +382,33 @@ def test_jurisdictions_round_status_offline( jurisdictions = json.loads(rv.data)["jurisdictions"] snapshot.assert_match(jurisdictions[0]["currentRoundStatus"]) + + +def test_discrepancy_counts_wrong_audit_type( + client: FlaskClient, + election_id: str, + jurisdiction_ids: List[str], # pylint: disable=unused-argument + round_1_id: str, # pylint: disable=unused-argument +): + rv = client.get(f"/api/election/{election_id}/discrepancy-counts") + assert rv.status_code == 409 + assert json.loads(rv.data) == { + "errors": [ + { + "errorType": "Conflict", + "message": "Discrepancy counts are only available for ballot comparison and batch comparison audits", + } + ] + } + + +def test_discrepancy_counts_before_audit_launch( + client: FlaskClient, + election_id: str, + jurisdiction_ids: List[str], # pylint: disable=unused-argument +): + rv = client.get(f"/api/election/{election_id}/discrepancy-counts") + assert rv.status_code == 409 + assert json.loads(rv.data) == { + "errors": [{"errorType": "Conflict", "message": "Audit not started",}] + } diff --git a/server/tests/ballot_comparison/snapshots/snap_test_ballot_comparison.py b/server/tests/ballot_comparison/snapshots/snap_test_ballot_comparison.py index ff5d70e6b..456990204 100644 --- a/server/tests/ballot_comparison/snapshots/snap_test_ballot_comparison.py +++ b/server/tests/ballot_comparison/snapshots/snap_test_ballot_comparison.py @@ -80,7 +80,6 @@ } snapshots["test_ballot_comparison_two_rounds 10"] = { - "numDiscrepancies": 1, "numSamples": 6, "numSamplesAudited": 6, "numUnique": 5, @@ -142,7 +141,6 @@ """ snapshots["test_ballot_comparison_two_rounds 2"] = { - "numDiscrepancies": None, "numSamples": 9, "numSamplesAudited": 0, "numUnique": 8, @@ -151,7 +149,6 @@ } snapshots["test_ballot_comparison_two_rounds 3"] = { - "numDiscrepancies": None, "numSamples": 11, "numSamplesAudited": 0, "numUnique": 9, @@ -160,7 +157,6 @@ } snapshots["test_ballot_comparison_two_rounds 4"] = { - "numDiscrepancies": 7, "numSamples": 9, "numSamplesAudited": 9, "numUnique": 8, @@ -169,7 +165,6 @@ } snapshots["test_ballot_comparison_two_rounds 5"] = { - "numDiscrepancies": None, "numSamples": 11, "numSamplesAudited": 11, "numUnique": 9, @@ -178,7 +173,6 @@ } snapshots["test_ballot_comparison_two_rounds 6"] = { - "numDiscrepancies": 7, "numSamples": 9, "numSamplesAudited": 9, "numUnique": 8, @@ -187,7 +181,6 @@ } snapshots["test_ballot_comparison_two_rounds 7"] = { - "numDiscrepancies": 4, "numSamples": 11, "numSamplesAudited": 11, "numUnique": 9, @@ -242,7 +235,6 @@ """ snapshots["test_ballot_comparison_two_rounds 9"] = { - "numDiscrepancies": 5, "numSamples": 4, "numSamplesAudited": 4, "numUnique": 4, @@ -259,23 +251,3 @@ "total_ballots_cast": 30, "votes_allowed": 2, } - -snapshots["test_set_contest_metadata_on_manifest_and_cvr_upload 1"] = { - "choices": [ - {"name": "Choice 2-1", "num_votes": 24}, - {"name": "Choice 2-2", "num_votes": 10}, - {"name": "Choice 2-3", "num_votes": 14}, - ], - "total_ballots_cast": 30, - "votes_allowed": 2, -} - -snapshots["test_set_contest_metadata_on_manifest_and_cvr_upload 2"] = { - "choices": [ - {"name": "Choice 2-1", "num_votes": 18}, - {"name": "Choice 2-2", "num_votes": 8}, - {"name": "Choice 2-3", "num_votes": 10}, - ], - "total_ballots_cast": 24, - "votes_allowed": 2, -} diff --git a/server/tests/ballot_comparison/test_ballot_comparison.py b/server/tests/ballot_comparison/test_ballot_comparison.py index bdb057570..f740d2fc9 100644 --- a/server/tests/ballot_comparison/test_ballot_comparison.py +++ b/server/tests/ballot_comparison/test_ballot_comparison.py @@ -819,15 +819,24 @@ def test_ballot_comparison_two_rounds( jurisdictions = json.loads(rv.data)["jurisdictions"] assert jurisdictions[0]["currentRoundStatus"]["status"] == "COMPLETE" assert jurisdictions[1]["currentRoundStatus"]["status"] == "IN_PROGRESS" - discrepancy_counts = count_discrepancies(round_1_audit_results_j1) - assert ( - jurisdictions[0]["currentRoundStatus"]["numDiscrepancies"] - == discrepancy_counts[jurisdictions[0]["name"]] - ) - assert jurisdictions[1]["currentRoundStatus"]["numDiscrepancies"] is None snapshot.assert_match(jurisdictions[0]["currentRoundStatus"]) snapshot.assert_match(jurisdictions[1]["currentRoundStatus"]) + # Check discrepancy counts + rv = client.get(f"/api/election/{election_id}/discrepancy-counts") + discrepancy_counts = json.loads(rv.data) + expected_discrepancy_counts = count_discrepancies(round_1_audit_results) + assert ( + discrepancy_counts[jurisdictions[0]["id"]] + == expected_discrepancy_counts[jurisdictions[0]["name"]] + ) + # We rely on the frontend to hide the discrepancy counts for J2 until J2 is + # signed off to avoid duplicating the round status logic in this endpoint + assert ( + discrepancy_counts[jurisdictions[1]["id"]] + == expected_discrepancy_counts[jurisdictions[1]["name"]] + ) + # Check the discrepancy report - only the first jurisdiction should have # audit results so far since the second jurisdiction hasn't signed off yet set_logged_in_user(client, UserType.AUDIT_ADMIN, DEFAULT_AA_EMAIL) @@ -854,15 +863,6 @@ def test_ballot_comparison_two_rounds( jurisdictions = json.loads(rv.data)["jurisdictions"] assert jurisdictions[0]["currentRoundStatus"]["status"] == "COMPLETE" assert jurisdictions[1]["currentRoundStatus"]["status"] == "COMPLETE" - discrepancy_counts = count_discrepancies(round_1_audit_results) - assert ( - jurisdictions[0]["currentRoundStatus"]["numDiscrepancies"] - == discrepancy_counts[jurisdictions[0]["name"]] - ) - assert ( - jurisdictions[1]["currentRoundStatus"]["numDiscrepancies"] - == discrepancy_counts[jurisdictions[1]["name"]] - ) snapshot.assert_match(jurisdictions[0]["currentRoundStatus"]) snapshot.assert_match(jurisdictions[1]["currentRoundStatus"]) @@ -945,13 +945,19 @@ def test_ballot_comparison_two_rounds( jurisdictions = json.loads(rv.data)["jurisdictions"] assert jurisdictions[0]["currentRoundStatus"]["status"] == "COMPLETE" assert jurisdictions[1]["currentRoundStatus"]["status"] == "COMPLETE" + snapshot.assert_match(jurisdictions[0]["currentRoundStatus"]) + snapshot.assert_match(jurisdictions[1]["currentRoundStatus"]) + + # Check discrepancy counts + rv = client.get(f"/api/election/{election_id}/discrepancy-counts") + discrepancy_counts = json.loads(rv.data) round_2_sampled_ballot_keys = [ ballot_key(ballot) for ballot in SampledBallot.query.join(SampledBallotDraw) .filter_by(round_id=round_2_id) .all() ] - discrepancy_counts = count_discrepancies( + expected_discrepancy_counts = count_discrepancies( { **{ ballot: result @@ -962,15 +968,13 @@ def test_ballot_comparison_two_rounds( } ) assert ( - jurisdictions[0]["currentRoundStatus"]["numDiscrepancies"] - == discrepancy_counts[jurisdictions[0]["name"]] + discrepancy_counts[jurisdictions[0]["id"]] + == expected_discrepancy_counts[jurisdictions[0]["name"]] ) assert ( - jurisdictions[1]["currentRoundStatus"]["numDiscrepancies"] - == discrepancy_counts[jurisdictions[1]["name"]] + discrepancy_counts[jurisdictions[1]["id"]] + == expected_discrepancy_counts[jurisdictions[1]["name"]] ) - snapshot.assert_match(jurisdictions[0]["currentRoundStatus"]) - snapshot.assert_match(jurisdictions[1]["currentRoundStatus"]) # End the round rv = client.post(f"/api/election/{election_id}/round/current/finish") diff --git a/server/tests/batch_comparison/snapshots/snap_test_batch_comparison.py b/server/tests/batch_comparison/snapshots/snap_test_batch_comparison.py index cdef7cd1e..995dacc9a 100644 --- a/server/tests/batch_comparison/snapshots/snap_test_batch_comparison.py +++ b/server/tests/batch_comparison/snapshots/snap_test_batch_comparison.py @@ -8,7 +8,6 @@ snapshots = Snapshot() snapshots["test_batch_comparison_batches_sampled_multiple_times 1"] = { - "numDiscrepancies": 0, "numSamples": 5, "numSamplesAudited": 5, "numUnique": 4, @@ -17,7 +16,6 @@ } snapshots["test_batch_comparison_batches_sampled_multiple_times 2"] = { - "numDiscrepancies": 0, "numSamples": 2, "numSamplesAudited": 2, "numUnique": 1, @@ -53,7 +51,6 @@ """ snapshots["test_batch_comparison_round_1 1"] = { - "numDiscrepancies": None, "numSamples": 9, "numSamplesAudited": 0, "numUnique": 6, @@ -62,7 +59,6 @@ } snapshots["test_batch_comparison_round_1 2"] = { - "numDiscrepancies": None, "numSamples": 5, "numSamplesAudited": 0, "numUnique": 2, @@ -71,7 +67,6 @@ } snapshots["test_batch_comparison_round_2 1"] = { - "numDiscrepancies": None, "numSamples": 5, "numSamplesAudited": 2, "numUnique": 4, @@ -80,7 +75,6 @@ } snapshots["test_batch_comparison_round_2 10"] = { - "numDiscrepancies": None, "numSamples": 2, "numSamplesAudited": 0, "numUnique": 1, @@ -136,7 +130,6 @@ """ snapshots["test_batch_comparison_round_2 2"] = { - "numDiscrepancies": None, "numSamples": 5, "numSamplesAudited": 3, "numUnique": 4, @@ -145,7 +138,6 @@ } snapshots["test_batch_comparison_round_2 3"] = { - "numDiscrepancies": None, "numSamples": 5, "numSamplesAudited": 4, "numUnique": 4, @@ -154,7 +146,6 @@ } snapshots["test_batch_comparison_round_2 4"] = { - "numDiscrepancies": None, "numSamples": 5, "numSamplesAudited": 5, "numUnique": 4, @@ -163,7 +154,6 @@ } snapshots["test_batch_comparison_round_2 5"] = { - "numDiscrepancies": 4, "numSamples": 5, "numSamplesAudited": 5, "numUnique": 4, @@ -172,7 +162,6 @@ } snapshots["test_batch_comparison_round_2 6"] = { - "numDiscrepancies": None, "numSamples": 2, "numSamplesAudited": 0, "numUnique": 1, @@ -181,7 +170,6 @@ } snapshots["test_batch_comparison_round_2 7"] = { - "numDiscrepancies": 4, "numSamples": 5, "numSamplesAudited": 5, "numUnique": 4, @@ -190,7 +178,6 @@ } snapshots["test_batch_comparison_round_2 8"] = { - "numDiscrepancies": 1, "numSamples": 2, "numSamplesAudited": 2, "numUnique": 1, @@ -199,7 +186,6 @@ } snapshots["test_batch_comparison_round_2 9"] = { - "numDiscrepancies": None, "numSamples": 3, "numSamplesAudited": 1, "numUnique": 2, diff --git a/server/tests/batch_comparison/test_batch_comparison.py b/server/tests/batch_comparison/test_batch_comparison.py index 99a7089b0..e169e3fd1 100644 --- a/server/tests/batch_comparison/test_batch_comparison.py +++ b/server/tests/batch_comparison/test_batch_comparison.py @@ -287,12 +287,16 @@ def test_batch_comparison_round_2( set_logged_in_user(client, UserType.AUDIT_ADMIN, DEFAULT_AA_EMAIL) rv = client.get(f"/api/election/{election_id}/jurisdiction") jurisdictions = json.loads(rv.data)["jurisdictions"] - assert jurisdictions[0]["currentRoundStatus"]["numDiscrepancies"] == len( - expected_discrepancies_j1 - ) snapshot.assert_match(jurisdictions[0]["currentRoundStatus"]) snapshot.assert_match(jurisdictions[1]["currentRoundStatus"]) + # Check discrepancy counts + rv = client.get(f"/api/election/{election_id}/discrepancy-counts") + discrepancy_counts = json.loads(rv.data) + assert discrepancy_counts[jurisdictions[0]["id"]] == len(expected_discrepancies_j1) + # In J2, single sampled batch hasn't been audited yet. The frontend won't show this to the user. + assert discrepancy_counts[jurisdictions[1]["id"]] == 1 + # Now do the second jurisdiction set_logged_in_user( client, UserType.JURISDICTION_ADMIN, default_ja_email(election_id) @@ -352,12 +356,15 @@ def test_batch_comparison_round_2( set_logged_in_user(client, UserType.AUDIT_ADMIN, DEFAULT_AA_EMAIL) rv = client.get(f"/api/election/{election_id}/jurisdiction") jurisdictions = json.loads(rv.data)["jurisdictions"] - assert jurisdictions[1]["currentRoundStatus"]["numDiscrepancies"] == len( - expected_discrepancies_j2 - ) snapshot.assert_match(jurisdictions[0]["currentRoundStatus"]) snapshot.assert_match(jurisdictions[1]["currentRoundStatus"]) + # Check discrepancy counts + rv = client.get(f"/api/election/{election_id}/discrepancy-counts") + discrepancy_counts = json.loads(rv.data) + assert discrepancy_counts[jurisdictions[0]["id"]] == len(expected_discrepancies_j1) + assert discrepancy_counts[jurisdictions[1]["id"]] == len(expected_discrepancies_j2) + # End the round rv = client.post(f"/api/election/{election_id}/round/current/finish") assert_ok(rv) @@ -384,8 +391,6 @@ def test_batch_comparison_round_2( # Check jurisdiction status after starting the new round rv = client.get(f"/api/election/{election_id}/jurisdiction") jurisdictions = json.loads(rv.data)["jurisdictions"] - assert jurisdictions[0]["currentRoundStatus"]["numDiscrepancies"] is None - assert jurisdictions[1]["currentRoundStatus"]["numDiscrepancies"] is None snapshot.assert_match(jurisdictions[0]["currentRoundStatus"]) snapshot.assert_match(jurisdictions[1]["currentRoundStatus"]) @@ -568,11 +573,16 @@ def test_batch_comparison_batches_sampled_multiple_times( set_logged_in_user(client, UserType.AUDIT_ADMIN, DEFAULT_AA_EMAIL) rv = client.get(f"/api/election/{election_id}/jurisdiction") jurisdictions = json.loads(rv.data)["jurisdictions"] - assert jurisdictions[0]["currentRoundStatus"]["numDiscrepancies"] == 0 - assert jurisdictions[1]["currentRoundStatus"]["numDiscrepancies"] == 0 snapshot.assert_match(jurisdictions[0]["currentRoundStatus"]) snapshot.assert_match(jurisdictions[1]["currentRoundStatus"]) + # Check discrepancy counts + rv = client.get(f"/api/election/{election_id}/discrepancy-counts") + discrepancy_counts = json.loads(rv.data) + print(discrepancy_counts) + assert discrepancy_counts[jurisdictions[0]["id"]] == 0 + assert discrepancy_counts[jurisdictions[1]["id"]] == 0 + # End the round rv = client.post(f"/api/election/{election_id}/round/current/finish") assert_ok(rv) diff --git a/server/tests/batch_comparison/test_multi_contest_batch_comparison.py b/server/tests/batch_comparison/test_multi_contest_batch_comparison.py index 4e5bf3cd9..09b9322ae 100644 --- a/server/tests/batch_comparison/test_multi_contest_batch_comparison.py +++ b/server/tests/batch_comparison/test_multi_contest_batch_comparison.py @@ -677,12 +677,11 @@ def test_multi_contest_batch_comparison_end_to_end( # Check discrepancy counts set_logged_in_user(client, UserType.AUDIT_ADMIN, DEFAULT_AA_EMAIL) - rv = client.get(f"/api/election/{election_id}/jurisdiction") - assert rv.status_code == 200 - jurisdictions = json.loads(rv.data)["jurisdictions"] - assert jurisdictions[0]["currentRoundStatus"]["numDiscrepancies"] == 4 - assert jurisdictions[1]["currentRoundStatus"]["numDiscrepancies"] == 0 - assert jurisdictions[2]["currentRoundStatus"]["numDiscrepancies"] == 1 + rv = client.get(f"/api/election/{election_id}/discrepancy-counts") + discrepancy_counts = json.loads(rv.data) + assert discrepancy_counts[jurisdictions[0]["id"]] == 4 + assert discrepancy_counts[jurisdictions[1]["id"]] == 0 + assert discrepancy_counts[jurisdictions[2]["id"]] == 1 # # Finish audit @@ -866,12 +865,11 @@ def test_multi_contest_batch_comparison_round_2( # Check discrepancy counts set_logged_in_user(client, UserType.AUDIT_ADMIN, DEFAULT_AA_EMAIL) - rv = client.get(f"/api/election/{election_id}/jurisdiction") - assert rv.status_code == 200 - jurisdictions = json.loads(rv.data)["jurisdictions"] - assert jurisdictions[0]["currentRoundStatus"]["numDiscrepancies"] == 1 - assert jurisdictions[1]["currentRoundStatus"]["numDiscrepancies"] == 0 - assert jurisdictions[2]["currentRoundStatus"]["numDiscrepancies"] == 0 + rv = client.get(f"/api/election/{election_id}/discrepancy-counts") + discrepancy_counts = json.loads(rv.data) + assert discrepancy_counts[jurisdiction_ids[0]] == 1 + assert discrepancy_counts[jurisdiction_ids[1]] == 0 + assert discrepancy_counts[jurisdiction_ids[2]] == 0 # # End round 1 @@ -998,12 +996,11 @@ def test_multi_contest_batch_comparison_round_2( # Check discrepancy counts set_logged_in_user(client, UserType.AUDIT_ADMIN, DEFAULT_AA_EMAIL) - rv = client.get(f"/api/election/{election_id}/jurisdiction") - assert rv.status_code == 200 - jurisdictions = json.loads(rv.data)["jurisdictions"] - assert jurisdictions[0]["currentRoundStatus"]["numDiscrepancies"] == 0 - assert jurisdictions[1]["currentRoundStatus"]["numDiscrepancies"] == 0 - assert jurisdictions[2]["currentRoundStatus"]["numDiscrepancies"] == 0 + rv = client.get(f"/api/election/{election_id}/discrepancy-counts") + discrepancy_counts = json.loads(rv.data) + assert discrepancy_counts[jurisdiction_ids[0]] == 0 + assert discrepancy_counts[jurisdiction_ids[1]] == 0 + assert discrepancy_counts[jurisdiction_ids[2]] == 0 # # End round 2 / finish audit