diff --git a/apps/admin/frontend/src/api.ts b/apps/admin/frontend/src/api.ts index 7ede1ef881..d3b55eb825 100644 --- a/apps/admin/frontend/src/api.ts +++ b/apps/admin/frontend/src/api.ts @@ -425,7 +425,7 @@ export const getCardCounts = { return input ? ['getCardCounts', input] : ['getCardCounts']; }, useQuery( - input: GetCardCountsInput = { groupBy: {} }, + input: GetCardCountsInput = {}, options: { enabled: boolean } = { enabled: true } ) { const apiClient = useApiClient(); diff --git a/apps/admin/frontend/src/app.test.tsx b/apps/admin/frontend/src/app.test.tsx index 604b4f4d6e..dd2efc358d 100644 --- a/apps/admin/frontend/src/app.test.tsx +++ b/apps/admin/frontend/src/app.test.tsx @@ -30,10 +30,12 @@ import { eitherNeitherElectionDefinition } from '../test/render_in_app_context'; import { VxFiles } from './lib/converters'; import { buildApp } from '../test/helpers/build_app'; import { ApiMock, createApiMock } from '../test/helpers/mock_api_client'; -import { expectReportsScreenCardCountQueries } from '../test/helpers/api_expect_helpers'; import { mockCastVoteRecordFileRecord } from '../test/api_mock_data'; -import { getSimpleMockTallyResults } from '../test/helpers/mock_results'; +import { + getMockCardCounts, + getSimpleMockTallyResults, +} from '../test/helpers/mock_results'; jest.mock('@votingworks/ballot-encoder', () => { return { @@ -333,7 +335,7 @@ test('marking results as official', async () => { electionDefinition, }); apiMock.expectGetCastVoteRecordFileMode('official'); - apiMock.expectGetResultsForTallyReports({ filter: {} }, [ + apiMock.expectGetResultsForTallyReports({ filter: {}, groupBy: {} }, [ getSimpleMockTallyResults({ election, scannedBallotCount: 100, @@ -341,15 +343,16 @@ test('marking results as official', async () => { }), ]); apiMock.expectGetScannerBatches([]); - apiMock.expectGetManualResultsMetadata([]); - expectReportsScreenCardCountQueries({ apiMock, isPrimary: true }); + apiMock.expectGetCardCounts({}, [getMockCardCounts(0)]); renderApp(); await apiMock.authenticateAsElectionManager(electionDefinition); userEvent.click(screen.getButton('Reports')); - userEvent.click(screen.getButton('Unofficial Full Election Tally Report')); - await screen.findByText('Unofficial Example Primary Election Tally Report'); + userEvent.click(screen.getButton('Full Election Tally Report')); + await screen.findByText( + 'Unofficial Mammal Party Example Primary Election Tally Report' + ); apiMock.expectMarkResultsOfficial(); apiMock.expectGetCurrentElectionMetadata({ @@ -365,7 +368,9 @@ test('marking results as official', async () => { 'Mark Tally Results as Official' ) ); - await screen.findByText('Official Example Primary Election Tally Report'); + await screen.findByText( + 'Official Mammal Party Example Primary Election Tally Report' + ); }); test('removing election resets cvr and manual data files', async () => { @@ -486,11 +491,7 @@ test('election manager UI has expected nav', async () => { apiMock.expectGetCastVoteRecordFileMode('unlocked'); apiMock.expectGetCastVoteRecordFiles([]); apiMock.expectGetManualResultsMetadata([]); - expectReportsScreenCardCountQueries({ - apiMock, - isPrimary: false, - }); - apiMock.expectGetScannerBatches([]); + apiMock.expectGetCardCounts({}, [getMockCardCounts(100)]); renderApp(); await apiMock.authenticateAsElectionManager(eitherNeitherElectionDefinition); diff --git a/apps/admin/frontend/src/components/ballot_counts_table.test.tsx b/apps/admin/frontend/src/components/ballot_counts_table.test.tsx deleted file mode 100644 index fcc71effea..0000000000 --- a/apps/admin/frontend/src/components/ballot_counts_table.test.tsx +++ /dev/null @@ -1,601 +0,0 @@ -import { - electionWithMsEitherNeither, - multiPartyPrimaryElectionDefinition, -} from '@votingworks/fixtures'; -import { TallyCategory, Tabulation } from '@votingworks/types'; - -import { assert } from '@votingworks/basics'; -import { ScannerBatch } from '@votingworks/admin-backend'; -import { - getByText as domGetByText, - screen, -} from '../../test/react_testing_library'; -import { renderInAppContext } from '../../test/render_in_app_context'; - -import { BallotCountsTable } from './ballot_counts_table'; -import { ApiMock, createApiMock } from '../../test/helpers/mock_api_client'; -import { mockBallotCountsTableGroupBy } from '../../test/helpers/api_expect_helpers'; - -let apiMock: ApiMock; - -beforeEach(() => { - apiMock = createApiMock(); -}); - -afterEach(() => { - apiMock.assertComplete(); -}); - -function mockCardCounts(bmdCount: number): Tabulation.CardCounts { - return { - bmd: bmdCount, - hmpb: [], - }; -} - -describe('Ballot Counts by Precinct', () => { - const cardCountsByPrecinct: Array> = - [ - { - precinctId: '6526', - ...mockCardCounts(38), - }, - { - precinctId: '6529', - ...mockCardCounts(52), - }, - { - precinctId: '6528', - ...mockCardCounts(22), - }, - ]; - - it('renders as expected when there is no tally data', async () => { - apiMock.deprecatedExpectGetCardCounts( - mockBallotCountsTableGroupBy({ groupByPrecinct: true }), - [] - ); - apiMock.expectGetScannerBatches([]); - apiMock.expectGetManualResultsMetadata([]); - const { getByText, getAllByTestId } = renderInAppContext( - , - { apiMock } - ); - await screen.findByText('Precinct'); - for (const precinct of electionWithMsEitherNeither.precincts) { - getByText(precinct.name); - const tableRow = getByText(precinct.name).closest('tr'); - expect(tableRow).toBeDefined(); - expect(domGetByText(tableRow!, 0)).toBeInTheDocument(); - expect( - domGetByText(tableRow!, `Unofficial ${precinct.name} Tally Report`) - ).toBeInTheDocument(); - } - getByText('Total Ballot Count'); - const tableRow = getByText('Total Ballot Count').closest('tr'); - expect(tableRow).toBeDefined(); - expect(domGetByText(tableRow!, 0)).toBeInTheDocument(); - expect( - domGetByText(tableRow!, 'Unofficial Tally Reports for All Precincts') - ).toBeInTheDocument(); - - // There should be 2 more rows then the number of precincts (header row and totals row) - expect(getAllByTestId('table-row').length).toEqual( - electionWithMsEitherNeither.precincts.length + 2 - ); - }); - - it('renders as expected when there is tally data', async () => { - apiMock.deprecatedExpectGetCardCounts( - mockBallotCountsTableGroupBy({ groupByPrecinct: true }), - cardCountsByPrecinct - ); - apiMock.expectGetScannerBatches([]); - apiMock.expectGetManualResultsMetadata([]); - const { getByText, getAllByTestId } = renderInAppContext( - , - { - apiMock, - } - ); - await screen.findByText('Precinct'); - for (const precinct of electionWithMsEitherNeither.precincts) { - // Expect that 0 ballots are counted when the precinct is missing in the dictionary or the tally says there are 0 ballots - const expectedNumberOfBallots = - cardCountsByPrecinct.find((cc) => cc.precinctId === precinct.id)?.bmd ?? - 0; - getByText(precinct.name); - const tableRow = getByText(precinct.name).closest('tr'); - expect(tableRow).toBeDefined(); - expect( - domGetByText(tableRow!, expectedNumberOfBallots) - ).toBeInTheDocument(); - expect( - domGetByText(tableRow!, `Unofficial ${precinct.name} Tally Report`) - ).toBeInTheDocument(); - } - getByText('Total Ballot Count'); - const tableRow = getByText('Total Ballot Count').closest('tr'); - expect(tableRow).toBeDefined(); - expect(domGetByText(tableRow!, 112)).toBeInTheDocument(); - expect( - domGetByText(tableRow!, 'Unofficial Tally Reports for All Precincts') - ).toBeInTheDocument(); - - // There should be 2 more rows then the number of precincts (header row and totals row) - expect(getAllByTestId('table-row').length).toEqual( - electionWithMsEitherNeither.precincts.length + 2 - ); - }); -}); - -describe('Ballot Counts by Scanner', () => { - const cardCountsByScanner: Array> = - [ - { - scannerId: 'scanner-1', - ...mockCardCounts(25), - }, - { - scannerId: 'scanner-2', - ...mockCardCounts(52), - }, - ]; - const scannerIds = ['scanner-1', 'scanner-2']; - - it('renders as expected when there is no tally data', async () => { - apiMock.deprecatedExpectGetCardCounts( - mockBallotCountsTableGroupBy({ groupByScanner: true }), - [] - ); - apiMock.expectGetScannerBatches([]); - apiMock.expectGetManualResultsMetadata([]); - const { getByText, getAllByTestId } = renderInAppContext( - , - { apiMock } - ); - - await screen.findByText('Total Ballot Count'); - const tableRow = getByText('Total Ballot Count').closest('tr'); - expect(tableRow).toBeDefined(); - expect(domGetByText(tableRow!, 0)).toBeInTheDocument(); - - // There should be 2 rows in the table, the header row and the totals row. - expect(getAllByTestId('table-row').length).toEqual(2); - }); - - it('renders as expected when there is tally data', async () => { - apiMock.deprecatedExpectGetCardCounts( - mockBallotCountsTableGroupBy({ groupByScanner: true }), - cardCountsByScanner - ); - apiMock.expectGetScannerBatches([]); - apiMock.expectGetManualResultsMetadata([]); - const { getByText, getAllByTestId } = renderInAppContext( - , - { - apiMock, - } - ); - - await screen.findByText('Scanner ID'); - for (const scannerId of scannerIds) { - const expectedNumberOfBallots = - cardCountsByScanner.find((cc) => cc.scannerId === scannerId)?.bmd ?? 0; - getByText(scannerId); - const tableRow = getByText(scannerId).closest('tr'); - expect(tableRow).toBeDefined(); - expect( - domGetByText(tableRow!, expectedNumberOfBallots) - ).toBeInTheDocument(); - if (expectedNumberOfBallots > 0) { - expect( - domGetByText( - tableRow!, - `Unofficial Scanner ${scannerId} Tally Report` - ) - ).toBeInTheDocument(); - } - } - getByText('Total Ballot Count'); - const tableRow = getByText('Total Ballot Count').closest('tr'); - expect(tableRow).toBeDefined(); - expect(domGetByText(tableRow!, 77)).toBeInTheDocument(); - - expect(getAllByTestId('table-row').length).toEqual(scannerIds.length + 2); - }); - - it('renders as expected when there is tally data and manual data', async () => { - apiMock.deprecatedExpectGetCardCounts( - mockBallotCountsTableGroupBy({ groupByScanner: true }), - cardCountsByScanner - ); - apiMock.expectGetScannerBatches([]); - apiMock.expectGetManualResultsMetadata([ - { - precinctId: 'any', - ballotStyleId: 'any', - votingMethod: 'precinct', - ballotCount: 54, - createdAt: 'any', - }, - ]); - const { getByText, getAllByTestId } = renderInAppContext( - , - { - apiMock, - } - ); - - await screen.findByText('Scanner ID'); - - getByText('Manually Entered Results'); - let tableRow = getByText('Manually Entered Results').closest('tr'); - expect(tableRow).toBeDefined(); - expect(domGetByText(tableRow!, 54)).toBeInTheDocument(); - - getByText('Total Ballot Count'); - tableRow = getByText('Total Ballot Count').closest('tr'); - expect(tableRow).toBeDefined(); - expect(domGetByText(tableRow!, 131)).toBeInTheDocument(); - - expect(getAllByTestId('table-row').length).toEqual(scannerIds.length + 3); - }); -}); - -// Test party ballot counts -describe('Ballots Counts by Party', () => { - const cardCountsByParty: Array> = [ - { - partyId: '0', - ...mockCardCounts(25), - }, - { - partyId: '4', - ...mockCardCounts(52), - }, - ]; - - const expectedParties = [ - { partyName: 'Constitution Party', partyId: '3' }, - { partyName: 'Federalist Party', partyId: '4' }, - { partyName: 'Liberty Party', partyId: '0' }, - ]; - - it('renders as expected when there is no data', async () => { - apiMock.deprecatedExpectGetCardCounts( - mockBallotCountsTableGroupBy({ groupByParty: true }), - [] - ); - apiMock.expectGetScannerBatches([]); - apiMock.expectGetManualResultsMetadata([]); - const { getByText, getAllByTestId } = renderInAppContext( - , - { - electionDefinition: { - ...multiPartyPrimaryElectionDefinition, - electionData: '', - }, - apiMock, - } - ); - - await screen.findByText('Party'); - - for (const { partyName } of expectedParties) { - getByText(partyName); - const tableRow = getByText(partyName).closest('tr'); - expect(tableRow).toBeDefined(); - expect(domGetByText(tableRow!, 0)).toBeInTheDocument(); - expect( - domGetByText(tableRow!, `Unofficial ${partyName} Tally Report`) - ).toBeInTheDocument(); - } - - getByText('Total Ballot Count'); - const tableRow = getByText('Total Ballot Count').closest('tr'); - expect(tableRow).toBeDefined(); - expect(domGetByText(tableRow!, 0)).toBeInTheDocument(); - - expect(getAllByTestId('table-row').length).toEqual( - expectedParties.length + 2 - ); - }); - - it('renders as expected when there is tally data', async () => { - apiMock.deprecatedExpectGetCardCounts( - mockBallotCountsTableGroupBy({ groupByParty: true }), - cardCountsByParty - ); - apiMock.expectGetScannerBatches([]); - apiMock.expectGetManualResultsMetadata([]); - const { getByText, getAllByTestId } = renderInAppContext( - , - { - electionDefinition: { - ...multiPartyPrimaryElectionDefinition, - electionData: '', - }, - apiMock, - } - ); - - await screen.findByText('Party'); - - for (const { partyName, partyId } of expectedParties) { - const expectedNumberOfBallots = - cardCountsByParty.find((cc) => cc.partyId === partyId)?.bmd ?? 0; - getByText(partyName); - const tableRow = getByText(partyName).closest('tr'); - expect(tableRow).toBeDefined(); - expect( - domGetByText(tableRow!, expectedNumberOfBallots) - ).toBeInTheDocument(); - expect( - domGetByText(tableRow!, `Unofficial ${partyName} Tally Report`) - ).toBeInTheDocument(); - } - - getByText('Total Ballot Count'); - const tableRow = getByText('Total Ballot Count').closest('tr'); - expect(tableRow).toBeDefined(); - expect(domGetByText(tableRow!, 77)).toBeInTheDocument(); - - expect(getAllByTestId('table-row').length).toEqual( - expectedParties.length + 2 - ); - }); -}); - -describe('Ballots Counts by VotingMethod', () => { - const cardCountsByVotingMethod: Array< - Tabulation.GroupOf - > = [ - { - votingMethod: 'absentee', - ...mockCardCounts(25), - }, - { - votingMethod: 'precinct', - ...mockCardCounts(42), - }, - ]; - const expectedLabels = [ - { - method: 'absentee', - label: 'Absentee', - }, - { method: 'precinct', label: 'Precinct' }, - ]; - - it('renders as expected when there is no data', async () => { - apiMock.deprecatedExpectGetCardCounts( - mockBallotCountsTableGroupBy({ groupByVotingMethod: true }), - [] - ); - apiMock.expectGetScannerBatches([]); - apiMock.expectGetManualResultsMetadata([]); - const { getByText, getAllByTestId } = renderInAppContext( - , - { apiMock } - ); - - await screen.findByText('Voting Method'); - for (const { label } of expectedLabels) { - getByText(label); - const tableRow = getByText(label).closest('tr'); - expect(tableRow).toBeDefined(); - expect(domGetByText(tableRow!, 0)).toBeInTheDocument(); - expect( - domGetByText(tableRow!, `Unofficial ${label} Ballot Tally Report`) - ).toBeInTheDocument(); - } - - getByText('Total Ballot Count'); - const tableRow = getByText('Total Ballot Count').closest('tr'); - expect(tableRow).toBeDefined(); - expect(domGetByText(tableRow!, 0)).toBeInTheDocument(); - - expect(getAllByTestId('table-row').length).toEqual( - expectedLabels.length + 2 - ); - }); - - it('renders as expected when there is tally data', async () => { - apiMock.deprecatedExpectGetCardCounts( - mockBallotCountsTableGroupBy({ groupByVotingMethod: true }), - cardCountsByVotingMethod - ); - apiMock.expectGetScannerBatches([]); - apiMock.expectGetManualResultsMetadata([]); - - const { getByText, getAllByTestId } = renderInAppContext( - , - { apiMock } - ); - - await screen.findByText('Voting Method'); - for (const { method, label } of expectedLabels) { - const expectedNumberOfBallots = - cardCountsByVotingMethod.find((cc) => cc.votingMethod === method) - ?.bmd ?? 0; - getByText(label); - const tableRow = getByText(label).closest('tr'); - expect(tableRow).toBeDefined(); - expect( - domGetByText(tableRow!, expectedNumberOfBallots) - ).toBeInTheDocument(); - expect( - domGetByText(tableRow!, `Unofficial ${label} Ballot Tally Report`) - ).toBeInTheDocument(); - } - - getByText('Total Ballot Count'); - const tableRow = getByText('Total Ballot Count').closest('tr'); - expect(tableRow).toBeDefined(); - expect(domGetByText(tableRow!, 67)).toBeInTheDocument(); - - expect(getAllByTestId('table-row').length).toEqual( - expectedLabels.length + 2 - ); - }); -}); - -describe('Ballots Counts by Batch', () => { - beforeEach(() => { - apiMock.expectGetCastVoteRecordFileMode('official'); - }); - - const cardCountsByBatch: Array> = [ - { - batchId: '12341', - ...mockCardCounts(25), - }, - { - batchId: '12342', - ...mockCardCounts(15), - }, - { - batchId: '12343', - ...mockCardCounts(32), - }, - ]; - - const scannerBatches: ScannerBatch[] = [ - { - electionId: 'any', - batchId: '12341', - label: 'Batch 1', - scannerId: '001', - }, - { - electionId: 'any', - batchId: '12342', - label: 'Batch 2', - scannerId: '001', - }, - { - electionId: 'any', - batchId: '12343', - label: 'Batch 1', - scannerId: '002', - }, - ]; - - it('renders as expected when there is no data', async () => { - apiMock.deprecatedExpectGetCardCounts( - mockBallotCountsTableGroupBy({ groupByBatch: true }), - [] - ); - apiMock.expectGetScannerBatches(scannerBatches); - apiMock.expectGetManualResultsMetadata([]); - const { getByText, getAllByTestId } = renderInAppContext( - , - { apiMock } - ); - - await screen.findByText('Total Ballot Count'); - const tableRow = getByText('Total Ballot Count').closest('tr'); - expect(tableRow).toBeDefined(); - expect(domGetByText(tableRow!, 0)).toBeInTheDocument(); - - expect(getAllByTestId('table-row').length).toEqual(2); - }); - - const expectedLabels = [ - { - batchId: '12341', - label: 'Batch 1', - scannerLabel: '001', - }, - { - batchId: '12342', - label: 'Batch 2', - scannerLabel: '001', - }, - { - batchId: '12343', - label: 'Batch 1', - scannerLabel: '002', - }, - ]; - - it('renders as expected when there is tally data', async () => { - apiMock.deprecatedExpectGetCardCounts( - mockBallotCountsTableGroupBy({ groupByBatch: true }), - cardCountsByBatch - ); - apiMock.expectGetScannerBatches(scannerBatches); - apiMock.expectGetManualResultsMetadata([]); - const { getByText, getAllByTestId } = renderInAppContext( - , - { apiMock } - ); - - await screen.findByText('Batch Name'); - - for (const { batchId, label, scannerLabel } of expectedLabels) { - const expectedNumberOfBallots = - cardCountsByBatch.find((cc) => cc.batchId === batchId)?.bmd ?? 0; - const tableRow = getAllByTestId(`batch-${batchId}`)[0].closest('tr'); - assert(tableRow); - expect(domGetByText(tableRow, label)).toBeInTheDocument(); - expect( - domGetByText(tableRow, expectedNumberOfBallots) - ).toBeInTheDocument(); - expect(domGetByText(tableRow, scannerLabel)).toBeInTheDocument(); - expect( - domGetByText(tableRow, `Unofficial ${label} Tally Report`) - ).toBeInTheDocument(); - } - - getByText('Total Ballot Count'); - const tableRow = getByText('Total Ballot Count').closest('tr'); - expect(tableRow).toBeDefined(); - expect(domGetByText(tableRow!, 72)).toBeInTheDocument(); - - // There should be 2 extra table rows in addition to the batches, one for the headers, and one for the total row. - expect(getAllByTestId('table-row').length).toEqual( - expectedLabels.length + 2 - ); - }); - - it('renders as expected where there is tally data and manual data', async () => { - apiMock.deprecatedExpectGetCardCounts( - mockBallotCountsTableGroupBy({ groupByBatch: true }), - cardCountsByBatch - ); - apiMock.expectGetScannerBatches(scannerBatches); - apiMock.expectGetManualResultsMetadata([ - { - precinctId: 'any', - ballotStyleId: 'any', - votingMethod: 'precinct', - ballotCount: 54, - createdAt: 'any', - }, - ]); - const { getByText, getAllByTestId } = renderInAppContext( - , - { - apiMock, - } - ); - - await screen.findByText('Batch Name'); - const manualTableRow = getAllByTestId('batch-manual')[0].closest('tr'); - assert(manualTableRow); - domGetByText(manualTableRow, 'Manually Entered Results'); - domGetByText(manualTableRow, 54); - - getByText('Total Ballot Count'); - const tableRow = getByText('Total Ballot Count').closest('tr'); - assert(tableRow); - domGetByText(tableRow, 126); - - // There should be 3 extra table rows in addition to the batches, one for the headers, one for the manual data, and one for the total row. - expect(getAllByTestId('table-row').length).toEqual( - expectedLabels.length + 3 - ); - }); -}); diff --git a/apps/admin/frontend/src/components/ballot_counts_table.tsx b/apps/admin/frontend/src/components/ballot_counts_table.tsx deleted file mode 100644 index 0f885683af..0000000000 --- a/apps/admin/frontend/src/components/ballot_counts_table.tsx +++ /dev/null @@ -1,371 +0,0 @@ -import { useContext } from 'react'; -import { format, getBallotCount } from '@votingworks/utils'; -import { assert, find, throwIllegalValue } from '@votingworks/basics'; -import { LinkButton, Loading, Table, TD } from '@votingworks/ui'; -import { Tabulation, TallyCategory } from '@votingworks/types'; - -import { getPartiesWithPrimaryElections } from '../utils/election'; - -import { AppContext } from '../contexts/app_context'; -import { ExportBatchTallyResultsButton } from './export_batch_tally_results_button'; -import { routerPaths } from '../router_paths'; -import { - getCardCounts, - getManualResultsMetadata, - getScannerBatches, -} from '../api'; - -export interface Props { - breakdownCategory: TallyCategory; -} - -export function BallotCountsTable({ - breakdownCategory, -}: Props): JSX.Element | null { - const { electionDefinition, isOfficialResults } = useContext(AppContext); - assert(electionDefinition); - const { election } = electionDefinition; - - const cardCountsQuery = getCardCounts.useQuery({ - groupBy: { - groupByParty: breakdownCategory === TallyCategory.Party, - groupByVotingMethod: breakdownCategory === TallyCategory.VotingMethod, - groupByBatch: breakdownCategory === TallyCategory.Batch, - groupByPrecinct: breakdownCategory === TallyCategory.Precinct, - groupByScanner: breakdownCategory === TallyCategory.Scanner, - }, - }); - const scannerBatchesQuery = getScannerBatches.useQuery(); - const manualResultsMetadataQuery = getManualResultsMetadata.useQuery(); - - if ( - !cardCountsQuery.isSuccess || - !scannerBatchesQuery.isSuccess || - !manualResultsMetadataQuery.isSuccess - ) { - return ; - } - - const cardCountsByCategory = cardCountsQuery.data; - const statusPrefix = isOfficialResults ? 'Official' : 'Unofficial'; - - // depending on the category, combinedCategoryBallotCount may or may not include manual - // counts. specifically, it is not included for batch and scanner categories. - const combinedCategoryBallotCount = cardCountsByCategory.reduce( - (total, cardCounts) => total + getBallotCount(cardCounts), - 0 - ); - const totalManualBallotCount = manualResultsMetadataQuery.data.reduce( - (total, metadata) => total + metadata.ballotCount, - 0 - ); - - const totalBallotCount = - breakdownCategory === TallyCategory.Batch || - breakdownCategory === TallyCategory.Scanner - ? combinedCategoryBallotCount + totalManualBallotCount - : combinedCategoryBallotCount; - - switch (breakdownCategory) { - case TallyCategory.Precinct: { - return ( - - - - - - - - {[...election.precincts] - .sort((a, b) => - a.name.localeCompare(b.name, undefined, { - ignorePunctuation: true, - }) - ) - .map((precinct) => { - const cardCounts = cardCountsByCategory.find( - (cc) => cc.precinctId === precinct.id - ); - const ballotCount = cardCounts ? getBallotCount(cardCounts) : 0; - - return ( - - - - - - ); - })} - - - - - - -
- Precinct - Ballot CountReport
- {precinct.name} - {format.count(ballotCount)} - - {statusPrefix} {precinct.name} Tally Report - -
- Total Ballot Count - - {format.count(totalBallotCount)} - - - {statusPrefix} Tally Reports for All Precincts - -
- ); - } - case TallyCategory.Scanner: { - return ( - - - - - - - - {cardCountsByCategory - .map((cc) => cc.scannerId) - .filter((cc): cc is string => cc !== undefined) - .map((scannerId) => { - const cardCounts = find( - cardCountsByCategory, - (cc) => cc.scannerId === scannerId - ); - const ballotCount = cardCounts ? getBallotCount(cardCounts) : 0; - return ( - - - - - - ); - })} - {totalManualBallotCount ? ( - - - - - ) : null} - - - - - -
- Scanner ID - Ballot CountReport
- {scannerId} - {format.count(ballotCount)} - - {statusPrefix} Scanner {scannerId} Tally Report - -
- Manually Entered Results - {format.count(totalManualBallotCount)} -
- Total Ballot Count - - {format.count(totalBallotCount)} - -
- ); - } - case TallyCategory.Party: { - const partiesForPrimaries = getPartiesWithPrimaryElections(election); - - return ( - - - - - - - - {[...partiesForPrimaries] - .sort((a, b) => - a.fullName.localeCompare(b.fullName, undefined, { - ignorePunctuation: true, - }) - ) - .map((party) => { - const cardCounts = cardCountsByCategory.find( - (cc) => cc.partyId === party.id - ); - const ballotCount = cardCounts ? getBallotCount(cardCounts) : 0; - return ( - - - - - - ); - })} - - - - - -
- Party - Ballot CountReport
- {party.fullName} - {format.count(ballotCount)} - - {statusPrefix} {party.fullName} Tally Report - -
- Total Ballot Count - - {format.count(totalBallotCount)} - -
- ); - } - case TallyCategory.VotingMethod: { - const votingMethods: Tabulation.VotingMethod[] = ['absentee', 'precinct']; - return ( - - - - - - - - {votingMethods.map((votingMethod) => { - const cardCounts = cardCountsByCategory.find( - (cc) => cc.votingMethod === votingMethod - ); - const ballotCount = cardCounts ? getBallotCount(cardCounts) : 0; - - const label = - votingMethod === 'absentee' ? 'Absentee' : 'Precinct'; - return ( - - - - - - ); - })} - - - - - -
- Voting Method - Ballot CountReport
- {label} - {format.count(ballotCount)} - - {statusPrefix} {label} Ballot Tally Report - -
- Total Ballot Count - - {format.count(totalBallotCount)} - -
- ); - } - case TallyCategory.Batch: { - return ( - - - - - - - - - {cardCountsByCategory.map(({ batchId, ...cardCounts }) => { - assert(batchId !== undefined); - const batch = find( - scannerBatchesQuery.data, - (b) => b.batchId === batchId - ); - const ballotCount = getBallotCount(cardCounts); - return ( - - - - - - - ); - })} - {totalManualBallotCount ? ( - - - - - ) : null} - - - - - - -
- Batch Name - ScannerBallot CountReport
- {batch.label} - {batch.scannerId}{format.count(ballotCount)} - - {statusPrefix} {batch.label} Tally Report - -
- Manually Entered Results - - {format.count(totalManualBallotCount)} -
- Total Ballot Count - - - {format.count(totalBallotCount)} - - -
- ); - } - // istanbul ignore next - default: - throwIllegalValue(breakdownCategory); - } -} diff --git a/apps/admin/frontend/src/components/election_manager.tsx b/apps/admin/frontend/src/components/election_manager.tsx index cc3313297d..de3f902668 100644 --- a/apps/admin/frontend/src/components/election_manager.tsx +++ b/apps/admin/frontend/src/components/election_manager.tsx @@ -12,7 +12,6 @@ import { isFeatureFlagEnabled, isSystemAdministratorAuth, } from '@votingworks/utils'; -import { PartyId } from '@votingworks/types'; import type { ManualResultsVotingMethod } from '@votingworks/admin-backend'; import { AppContext } from '../contexts/app_context'; import { routerPaths } from '../router_paths'; @@ -21,15 +20,6 @@ import { BallotListScreen } from '../screens/ballot_list_screen'; import { PrintTestDeckScreen } from '../screens/print_test_deck_screen'; import { UnconfiguredScreen } from '../screens/unconfigured_screen'; import { TallyScreen } from '../screens/tally_screen'; -import { - BatchTallyReportScreen, - PartyTallyReportScreen, - PrecinctTallyReportScreen, - ScannerTallyReportScreen, - VotingMethodTallyReportScreen, - FullElectionTallyReportScreen, - AllPrecinctsTallyReportScreen, -} from '../screens/tally_report_screen'; import { TallyWriteInReportScreen } from '../screens/write_in_adjudication_report_screen'; import { DefinitionViewerScreen } from '../screens/definition_viewer_screen'; import { ManualDataSummaryScreen } from '../screens/manual_data_summary_screen'; @@ -40,15 +30,20 @@ import { WriteInsSummaryScreen } from '../screens/write_ins_summary_screen'; import { LogicAndAccuracyScreen } from '../screens/logic_and_accuracy_screen'; import { SettingsScreen } from '../screens/settings_screen'; import { LogsScreen } from '../screens/logs_screen'; -import { ReportsScreen } from '../screens/reports_screen'; +import { ReportsScreen } from '../screens/reporting/reports_screen'; import { ElectionManagerSystemScreen } from '../screens/election_manager_system_screen'; import { SmartcardTypeRegExPattern } from '../config/types'; import { SmartcardModal } from './smartcard_modal'; import { checkPin } from '../api'; import { canViewAndPrintBallots } from '../utils/can_view_and_print_ballots'; import { WriteInsAdjudicationScreen } from '../screens/write_ins_adjudication_screen'; -import { TallyReportBuilder } from '../screens/tally_report_builder'; -import { BallotCountReportBuilder } from '../screens/ballot_count_report_builder'; +import { TallyReportBuilder } from '../screens/reporting/tally_report_builder'; +import { BallotCountReportBuilder } from '../screens/reporting/ballot_count_report_builder'; +import { AllPrecinctsTallyReportScreen } from '../screens/reporting/all_precincts_tally_report_screen'; +import { SinglePrecinctTallyReportScreen } from '../screens/reporting/single_precinct_tally_report_screen'; +import { PrecinctBallotCountReport } from '../screens/reporting/precinct_ballot_count_report_screen'; +import { VotingMethodBallotCountReport } from '../screens/reporting/voting_method_ballot_count_report_screen'; +import { FullElectionTallyReportScreen } from '../screens/reporting/full_election_tally_report_screen'; export function ElectionManager(): JSX.Element { const { electionDefinition, configuredAt, auth, hasCardReaderAttached } = @@ -198,11 +193,8 @@ export function ElectionManager(): JSX.Element { - - + + @@ -210,28 +202,11 @@ export function ElectionManager(): JSX.Element { - - - - - - - - + + - - + + diff --git a/apps/admin/frontend/src/components/export_batch_tally_results_button.tsx b/apps/admin/frontend/src/components/export_batch_tally_results_button.tsx index f39f012592..93613b14bf 100644 --- a/apps/admin/frontend/src/components/export_batch_tally_results_button.tsx +++ b/apps/admin/frontend/src/components/export_batch_tally_results_button.tsx @@ -28,7 +28,7 @@ export function ExportBatchTallyResultsButton(): JSX.Element { onPress={() => setIsSaveModalOpen(true)} disabled={!castVoteRecordFileModeQuery.isSuccess} > - Save Batch Results as CSV + Save Batch Results CSV {isSaveModalOpen && ( PromiseOr; // Router Props -export interface PrecinctReportScreenProps { - precinctId: PrecinctId; -} -export interface ScannerReportScreenProps { - scannerId: string; -} -export interface BatchReportScreenProps { - batchId: string; -} -export interface PartyReportScreenProps { - partyId: PartyId; -} -export interface VotingMethodReportScreenProps { - votingMethod: string; -} export interface ManualDataEntryScreenProps { precinctId: PrecinctId; ballotStyleId: BallotStyleId; diff --git a/apps/admin/frontend/src/router_paths.ts b/apps/admin/frontend/src/router_paths.ts index deb938790a..456c8a232c 100644 --- a/apps/admin/frontend/src/router_paths.ts +++ b/apps/admin/frontend/src/router_paths.ts @@ -1,11 +1,6 @@ import { ContestId } from '@votingworks/types'; import { - PartyReportScreenProps, - PrecinctReportScreenProps, - ScannerReportScreenProps, - VotingMethodReportScreenProps, ManualDataEntryScreenProps, - BatchReportScreenProps, SmartcardsScreenProps, WriteInsAdjudicationScreenProps, } from './config/types'; @@ -30,22 +25,13 @@ export const routerPaths = { `/tally/manual-data-entry/${ballotStyleId}/${votingMethod}/${precinctId}`, reports: '/reports', tally: '/tally', - tallyFullReport: '/reports/tally-reports/full', - tallyPrecinctReport: ({ precinctId }: PrecinctReportScreenProps): string => - `/reports/tally-reports/precincts/${precinctId}`, - tallyAllPrecinctsReport: `/reports/tally-reports/all-precincts`, - tallyVotingMethodReport: ({ - votingMethod, - }: VotingMethodReportScreenProps): string => - `/reports/tally-reports/votingmethods/${votingMethod}`, - tallyPartyReport: ({ partyId }: PartyReportScreenProps): string => - `/reports/tally-reports/parties/${partyId}`, - tallyScannerReport: ({ scannerId }: ScannerReportScreenProps): string => - `/reports/tally-reports/scanners/${scannerId}`, - tallyBatchReport: ({ batchId }: BatchReportScreenProps): string => - `/reports/tally-reports/batches/${batchId}`, - tallyReportBuilder: `/reports/tally-reports/builder`, - ballotCountReportBuilder: `/reports/ballot-count-reports/builder`, + tallyFullReport: '/reports/tally/full', + tallySinglePrecinctReport: `/reports/tally/precinct`, + tallyAllPrecinctsReport: `/reports/tally/all-precincts`, + tallyReportBuilder: `/reports/tally/builder`, + ballotCountReportBuilder: `/reports/ballot-count/builder`, + ballotCountReportPrecinct: '/reports/ballot-count/precinct', + ballotCountReportVotingMethod: '/reports/ballot-count/voting-method', tallyWriteInReport: '/reports/tally-reports/writein', logicAndAccuracy: '/logic-and-accuracy', testDecks: '/logic-and-accuracy/test-decks', diff --git a/apps/admin/frontend/src/screens/reporting/all_precincts_tally_report_screen.test.tsx b/apps/admin/frontend/src/screens/reporting/all_precincts_tally_report_screen.test.tsx new file mode 100644 index 0000000000..10ccb6537b --- /dev/null +++ b/apps/admin/frontend/src/screens/reporting/all_precincts_tally_report_screen.test.tsx @@ -0,0 +1,114 @@ +import { electionTwoPartyPrimaryFixtures } from '@votingworks/fixtures'; +import { Logger, fakeLogger } from '@votingworks/logging'; +import { ApiMock, createApiMock } from '../../../test/helpers/mock_api_client'; +import { getSimpleMockTallyResults } from '../../../test/helpers/mock_results'; +import { renderInAppContext } from '../../../test/render_in_app_context'; +import { screen, within } from '../../../test/react_testing_library'; +import { + AllPrecinctsTallyReportScreen, + SCREEN_TITLE, +} from './all_precincts_tally_report_screen'; + +let logger: Logger; +let apiMock: ApiMock; + +beforeEach(() => { + logger = fakeLogger(); + apiMock = createApiMock(); +}); + +afterEach(() => { + delete window.kiosk; + apiMock.assertComplete(); +}); + +test('displays report', async () => { + const { election, electionDefinition } = electionTwoPartyPrimaryFixtures; + apiMock.expectGetCastVoteRecordFileMode('official'); + apiMock.expectGetScannerBatches([]); + apiMock.expectGetResultsForTallyReports( + { + filter: {}, + groupBy: { groupByPrecinct: true }, + }, + [ + { + precinctId: 'precinct-1', + ...getSimpleMockTallyResults({ + election, + scannedBallotCount: 25, + cardCountsByParty: { + '0': 20, + '1': 5, + }, + }), + }, + { + precinctId: 'precinct-2', + ...getSimpleMockTallyResults({ + election, + scannedBallotCount: 40, + cardCountsByParty: { + '0': 30, + '1': 10, + }, + }), + }, + ] + ); + + renderInAppContext(, { + electionDefinition, + logger, + apiMock, + isOfficialResults: false, + }); + + const expectedReports: Array<{ + title: string; + subtitle: string; + count: number; + }> = [ + { + title: 'Unofficial Precinct 1 Tally Report', + subtitle: 'Mammal Party Example Primary Election', + count: 20, + }, + { + title: 'Unofficial Precinct 1 Tally Report', + subtitle: 'Fish Party Example Primary Election', + count: 5, + }, + { + title: 'Unofficial Precinct 1 Tally Report', + subtitle: 'Example Primary Election Nonpartisan Contests', + count: 25, + }, + { + title: 'Unofficial Precinct 2 Tally Report', + subtitle: 'Mammal Party Example Primary Election', + count: 30, + }, + { + title: 'Unofficial Precinct 2 Tally Report', + subtitle: 'Fish Party Example Primary Election', + count: 10, + }, + { + title: 'Unofficial Precinct 2 Tally Report', + subtitle: 'Example Primary Election Nonpartisan Contests', + count: 40, + }, + ]; + + screen.getByText(SCREEN_TITLE); + const reports = await screen.findAllByTestId(/tally-report/); + expect(reports).toHaveLength(expectedReports.length); + for (const [i, report] of reports.entries()) { + within(report).getByText(expectedReports[i].title); + within(report).getByText(expectedReports[i].subtitle); + expect(within(report).getByTestId('total-ballot-count')).toHaveTextContent( + expectedReports[i].count.toString() + ); + } +}); diff --git a/apps/admin/frontend/src/screens/reporting/all_precincts_tally_report_screen.tsx b/apps/admin/frontend/src/screens/reporting/all_precincts_tally_report_screen.tsx new file mode 100644 index 0000000000..8baf74827e --- /dev/null +++ b/apps/admin/frontend/src/screens/reporting/all_precincts_tally_report_screen.tsx @@ -0,0 +1,32 @@ +import { Icons, LinkButton, P } from '@votingworks/ui'; +import { useContext } from 'react'; +import { assert } from '@votingworks/basics'; +import { isElectionManagerAuth } from '@votingworks/utils'; +import { AppContext } from '../../contexts/app_context'; +import { NavigationScreen } from '../../components/navigation_screen'; +import { routerPaths } from '../../router_paths'; +import { TallyReportViewer } from '../../components/reporting/tally_report_viewer'; + +export const SCREEN_TITLE = 'All Precincts Tally Report'; + +export function AllPrecinctsTallyReportScreen(): JSX.Element { + const { electionDefinition, auth } = useContext(AppContext); + assert(electionDefinition); + assert(isElectionManagerAuth(auth)); + + return ( + +

+ + Back + +

+ +
+ ); +} diff --git a/apps/admin/frontend/src/screens/ballot_count_report_builder.test.tsx b/apps/admin/frontend/src/screens/reporting/ballot_count_report_builder.test.tsx similarity index 94% rename from apps/admin/frontend/src/screens/ballot_count_report_builder.test.tsx rename to apps/admin/frontend/src/screens/reporting/ballot_count_report_builder.test.tsx index ddda04470d..4a0c1677d9 100644 --- a/apps/admin/frontend/src/screens/ballot_count_report_builder.test.tsx +++ b/apps/admin/frontend/src/screens/reporting/ballot_count_report_builder.test.tsx @@ -6,11 +6,11 @@ import { import userEvent from '@testing-library/user-event'; import { expectPrint } from '@votingworks/test-utils'; import { Tabulation } from '@votingworks/types'; -import { ApiMock, createApiMock } from '../../test/helpers/mock_api_client'; -import { renderInAppContext } from '../../test/render_in_app_context'; -import { screen, within } from '../../test/react_testing_library'; -import { getMockCardCounts } from '../../test/helpers/mock_results'; -import { canonicalizeFilter, canonicalizeGroupBy } from '../utils/reporting'; +import { ApiMock, createApiMock } from '../../../test/helpers/mock_api_client'; +import { renderInAppContext } from '../../../test/render_in_app_context'; +import { screen, within } from '../../../test/react_testing_library'; +import { getMockCardCounts } from '../../../test/helpers/mock_results'; +import { canonicalizeFilter, canonicalizeGroupBy } from '../../utils/reporting'; import { BallotCountReportBuilder } from './ballot_count_report_builder'; let apiMock: ApiMock; diff --git a/apps/admin/frontend/src/screens/ballot_count_report_builder.tsx b/apps/admin/frontend/src/screens/reporting/ballot_count_report_builder.tsx similarity index 87% rename from apps/admin/frontend/src/screens/ballot_count_report_builder.tsx rename to apps/admin/frontend/src/screens/reporting/ballot_count_report_builder.tsx index 8f304d722f..4b3d11491a 100644 --- a/apps/admin/frontend/src/screens/ballot_count_report_builder.tsx +++ b/apps/admin/frontend/src/screens/reporting/ballot_count_report_builder.tsx @@ -16,20 +16,20 @@ import { } from '@votingworks/utils'; import { Tabulation } from '@votingworks/types'; import styled from 'styled-components'; -import { AppContext } from '../contexts/app_context'; -import { NavigationScreen } from '../components/navigation_screen'; -import { routerPaths } from '../router_paths'; +import { AppContext } from '../../contexts/app_context'; +import { NavigationScreen } from '../../components/navigation_screen'; +import { routerPaths } from '../../router_paths'; import { FilterEditor, FilterType, -} from '../components/reporting/filter_editor'; +} from '../../components/reporting/filter_editor'; import { GroupByEditor, GroupByType, -} from '../components/reporting/group_by_editor'; -import { canonicalizeFilter, canonicalizeGroupBy } from '../utils/reporting'; -import { BallotCountReportViewer } from '../components/reporting/ballot_count_report_viewer'; -import { getManualResultsMetadata } from '../api'; +} from '../../components/reporting/group_by_editor'; +import { canonicalizeFilter, canonicalizeGroupBy } from '../../utils/reporting'; +import { BallotCountReportViewer } from '../../components/reporting/ballot_count_report_viewer'; +import { getManualResultsMetadata } from '../../api'; const SCREEN_TITLE = 'Ballot Count Report Builder'; @@ -57,7 +57,7 @@ const BreakdownSelectContainer = styled.div` export function BallotCountReportBuilder(): JSX.Element { const { electionDefinition, auth } = useContext(AppContext); assert(electionDefinition); - assert(isElectionManagerAuth(auth)); // TODO(auth) check permissions for viewing reports. + assert(isElectionManagerAuth(auth)); const { election } = electionDefinition; const getManualResultsMetadataQuery = getManualResultsMetadata.useQuery(); diff --git a/apps/admin/frontend/src/screens/reporting/full_election_tally_report_screen.test.tsx b/apps/admin/frontend/src/screens/reporting/full_election_tally_report_screen.test.tsx new file mode 100644 index 0000000000..2c16e33afb --- /dev/null +++ b/apps/admin/frontend/src/screens/reporting/full_election_tally_report_screen.test.tsx @@ -0,0 +1,138 @@ +import { electionFamousNames2021Fixtures } from '@votingworks/fixtures'; +import { Logger, fakeLogger } from '@votingworks/logging'; +import userEvent from '@testing-library/user-event'; +import { ApiMock, createApiMock } from '../../../test/helpers/mock_api_client'; +import { getSimpleMockTallyResults } from '../../../test/helpers/mock_results'; +import { FullElectionTallyReportScreen } from './full_election_tally_report_screen'; +import { renderInAppContext } from '../../../test/render_in_app_context'; +import { routerPaths } from '../../router_paths'; +import { screen, waitFor, within } from '../../../test/react_testing_library'; + +let logger: Logger; +let apiMock: ApiMock; + +beforeEach(() => { + logger = fakeLogger(); + apiMock = createApiMock(); +}); + +afterEach(() => { + delete window.kiosk; + apiMock.assertComplete(); +}); + +test('displays report', async () => { + const { election, electionDefinition } = electionFamousNames2021Fixtures; + apiMock.expectGetCastVoteRecordFileMode('official'); + apiMock.expectGetScannerBatches([]); + apiMock.expectGetResultsForTallyReports( + { + filter: {}, + groupBy: {}, + }, + [getSimpleMockTallyResults({ election, scannedBallotCount: 11 })] + ); + + renderInAppContext(, { + electionDefinition, + logger, + apiMock, + route: routerPaths.tallyFullReport, + isOfficialResults: true, + }); + + await screen.findByTestId('tally-report'); + screen.getByText('Official Lincoln Municipal General Election Tally Report'); + expect(screen.getByTestId('total-ballot-count')).toHaveTextContent('11'); +}); + +test('mark results as official', async () => { + const { election, electionDefinition } = electionFamousNames2021Fixtures; + apiMock.expectGetCastVoteRecordFileMode('official'); + apiMock.expectGetScannerBatches([]); + apiMock.expectGetResultsForTallyReports( + { + filter: {}, + groupBy: {}, + }, + [getSimpleMockTallyResults({ election, scannedBallotCount: 10 })] + ); + + renderInAppContext(, { + electionDefinition, + logger, + apiMock, + route: routerPaths.tallyFullReport, + isOfficialResults: false, + }); + + await screen.findByTestId('tally-report'); + + // open and close modal + userEvent.click(screen.getButton('Mark Tally Results as Official')); + let modal = await screen.findByRole('alertdialog'); + userEvent.click(within(modal).getButton('Cancel')); + await waitFor(() => expect(modal).not.toBeInTheDocument()); + + // open and mark official + userEvent.click(screen.getButton('Mark Tally Results as Official')); + modal = await screen.findByRole('alertdialog'); + apiMock.expectMarkResultsOfficial(); + userEvent.click(within(modal).getButton('Mark Tally Results as Official')); + await waitFor(() => expect(modal).not.toBeInTheDocument()); +}); + +test('mark official results button disabled when no cvr files', async () => { + const { election, electionDefinition } = electionFamousNames2021Fixtures; + apiMock.expectGetCastVoteRecordFileMode('unlocked'); // no CVR files + apiMock.expectGetScannerBatches([]); + apiMock.expectGetResultsForTallyReports( + { + filter: {}, + groupBy: {}, + }, + [getSimpleMockTallyResults({ election, scannedBallotCount: 10 })] + ); + + renderInAppContext(, { + electionDefinition, + logger, + apiMock, + route: routerPaths.tallyFullReport, + }); + + await screen.findByTestId('tally-report'); + expect( + screen.getByRole('button', { + name: 'Mark Tally Results as Official', + }) + ).toBeDisabled(); +}); + +test('mark official results button disabled when already official', async () => { + const { election, electionDefinition } = electionFamousNames2021Fixtures; + apiMock.expectGetCastVoteRecordFileMode('official'); + apiMock.expectGetScannerBatches([]); + apiMock.expectGetResultsForTallyReports( + { + filter: {}, + groupBy: {}, + }, + [getSimpleMockTallyResults({ election, scannedBallotCount: 10 })] + ); + + renderInAppContext(, { + electionDefinition, + logger, + apiMock, + route: routerPaths.tallyFullReport, + isOfficialResults: true, + }); + + await screen.findByTestId('tally-report'); + expect( + screen.getByRole('button', { + name: 'Mark Tally Results as Official', + }) + ).toBeDisabled(); +}); diff --git a/apps/admin/frontend/src/screens/reporting/full_election_tally_report_screen.tsx b/apps/admin/frontend/src/screens/reporting/full_election_tally_report_screen.tsx new file mode 100644 index 0000000000..c39209b265 --- /dev/null +++ b/apps/admin/frontend/src/screens/reporting/full_election_tally_report_screen.tsx @@ -0,0 +1,103 @@ +import React, { useContext, useState } from 'react'; +import { isElectionManagerAuth } from '@votingworks/utils'; +import { assert } from '@votingworks/basics'; +import { Button, Icons, LinkButton, Modal, P } from '@votingworks/ui'; + +import styled from 'styled-components'; +import { AppContext } from '../../contexts/app_context'; + +import { NavigationScreen } from '../../components/navigation_screen'; + +import { routerPaths } from '../../router_paths'; + +import { getCastVoteRecordFileMode, markResultsOfficial } from '../../api'; +import { Loading } from '../../components/loading'; +import { TallyReportViewer } from '../../components/reporting/tally_report_viewer'; + +const SCREEN_TITLE = 'Full Election Tally Report'; + +const TopButtonBar = styled.div` + display: flex; + gap: 1rem; +`; + +export function FullElectionTallyReportScreen(): JSX.Element { + const { electionDefinition, isOfficialResults, auth } = + useContext(AppContext); + assert(electionDefinition); + assert(isElectionManagerAuth(auth)); + + const [isMarkOfficialModalOpen, setIsMarkOfficialModalOpen] = useState(false); + + const markResultsOfficialMutation = markResultsOfficial.useMutation(); + const castVoteRecordFileModeQuery = getCastVoteRecordFileMode.useQuery(); + + function closeMarkOfficialModal() { + setIsMarkOfficialModalOpen(false); + } + function openMarkOfficialModal() { + setIsMarkOfficialModalOpen(true); + } + function markOfficial() { + setIsMarkOfficialModalOpen(false); + markResultsOfficialMutation.mutate(); + } + + if (!castVoteRecordFileModeQuery.isSuccess) { + return ( + + + + ); + } + + const canMarkResultsOfficial = + castVoteRecordFileModeQuery.data !== 'unlocked' && !isOfficialResults; + + return ( + + + + + Back + {' '} + + + + + {isMarkOfficialModalOpen && ( + +

+ Have all CVR files been loaded? Once results are marked as + official, no additional CVR files can be loaded. +

+

Have all unofficial tally reports been reviewed?

+
+ } + actions={ + + + + + } + onOverlayClick={closeMarkOfficialModal} + /> + )} + + ); +} diff --git a/apps/admin/frontend/src/screens/reporting/precinct_ballot_count_report_screen.test.tsx b/apps/admin/frontend/src/screens/reporting/precinct_ballot_count_report_screen.test.tsx new file mode 100644 index 0000000000..2067f644d5 --- /dev/null +++ b/apps/admin/frontend/src/screens/reporting/precinct_ballot_count_report_screen.test.tsx @@ -0,0 +1,131 @@ +import { + electionFamousNames2021Fixtures, + electionTwoPartyPrimaryFixtures, +} from '@votingworks/fixtures'; +import { Logger, fakeLogger } from '@votingworks/logging'; +import { ApiMock, createApiMock } from '../../../test/helpers/mock_api_client'; +import { getMockCardCounts } from '../../../test/helpers/mock_results'; +import { renderInAppContext } from '../../../test/render_in_app_context'; +import { screen, within } from '../../../test/react_testing_library'; +import { + PrecinctBallotCountReport, + SCREEN_TITLE, +} from './precinct_ballot_count_report_screen'; + +let logger: Logger; +let apiMock: ApiMock; + +beforeEach(() => { + logger = fakeLogger(); + apiMock = createApiMock(); +}); + +afterEach(() => { + delete window.kiosk; + apiMock.assertComplete(); +}); + +test('displays report (primary)', async () => { + const { electionDefinition } = electionTwoPartyPrimaryFixtures; + apiMock.expectGetCastVoteRecordFileMode('official'); + apiMock.expectGetScannerBatches([]); + apiMock.expectGetCardCounts( + { + filter: {}, + groupBy: { groupByPrecinct: true, groupByParty: true }, + }, + [ + { + precinctId: 'precinct-1', + partyId: '0', + ...getMockCardCounts(5), + }, + { + precinctId: 'precinct-1', + partyId: '1', + ...getMockCardCounts(10), + }, + { + precinctId: 'precinct-2', + partyId: '0', + ...getMockCardCounts(15), + }, + { + precinctId: 'precinct-2', + partyId: '1', + ...getMockCardCounts(20), + }, + ] + ); + + renderInAppContext(, { + electionDefinition, + logger, + apiMock, + isOfficialResults: false, + }); + + screen.getByText(SCREEN_TITLE); + + const report = await screen.findByTestId('ballot-count-report'); + within(report).getByText('Unofficial Full Election Ballot Count Report'); + within(report).getByText('Example Primary Election'); + + expect(within(report).getAllByText('Precinct 1')).toHaveLength(2); + expect(within(report).getAllByText('Precinct 2')).toHaveLength(2); + expect(within(report).getAllByText('Mammal')).toHaveLength(2); + expect(within(report).getAllByText('Fish')).toHaveLength(2); + + expect(within(report).getByTestId('footer-bmd')).toHaveTextContent('50'); + expect(within(report).getByTestId('footer-total')).toHaveTextContent('50'); +}); + +test('displays report (general)', async () => { + const { electionDefinition } = electionFamousNames2021Fixtures; + apiMock.expectGetCastVoteRecordFileMode('official'); + apiMock.expectGetScannerBatches([]); + apiMock.expectGetCardCounts( + { + filter: {}, + groupBy: { groupByPrecinct: true, groupByParty: false }, + }, + [ + { + precinctId: '20', + ...getMockCardCounts(5), + }, + { + precinctId: '21', + ...getMockCardCounts(10), + }, + { + precinctId: '22', + ...getMockCardCounts(15), + }, + { + precinctId: '23', + ...getMockCardCounts(20), + }, + ] + ); + + renderInAppContext(, { + electionDefinition, + logger, + apiMock, + isOfficialResults: false, + }); + + screen.getByText(SCREEN_TITLE); + + const report = await screen.findByTestId('ballot-count-report'); + within(report).getByText('Unofficial Full Election Ballot Count Report'); + within(report).getByText('Lincoln Municipal General Election'); + + for (const precinct of electionDefinition.election.precincts) { + within(report).getByText(precinct.name); + } + + expect(within(report).getByTestId('footer-bmd')).toHaveTextContent('50'); + expect(within(report).getByTestId('footer-total')).toHaveTextContent('50'); +}); diff --git a/apps/admin/frontend/src/screens/reporting/precinct_ballot_count_report_screen.tsx b/apps/admin/frontend/src/screens/reporting/precinct_ballot_count_report_screen.tsx new file mode 100644 index 0000000000..25bede8160 --- /dev/null +++ b/apps/admin/frontend/src/screens/reporting/precinct_ballot_count_report_screen.tsx @@ -0,0 +1,36 @@ +import { Icons, LinkButton, P } from '@votingworks/ui'; +import { useContext } from 'react'; +import { assert } from '@votingworks/basics'; +import { isElectionManagerAuth } from '@votingworks/utils'; +import { AppContext } from '../../contexts/app_context'; +import { NavigationScreen } from '../../components/navigation_screen'; +import { routerPaths } from '../../router_paths'; +import { BallotCountReportViewer } from '../../components/reporting/ballot_count_report_viewer'; + +export const SCREEN_TITLE = 'Precinct Ballot Count Report'; + +export function PrecinctBallotCountReport(): JSX.Element { + const { electionDefinition, auth } = useContext(AppContext); + assert(electionDefinition); + assert(isElectionManagerAuth(auth)); + + return ( + +

+ + Back + +

+ +
+ ); +} diff --git a/apps/admin/frontend/src/screens/reports_screen.test.tsx b/apps/admin/frontend/src/screens/reporting/reports_screen.test.tsx similarity index 68% rename from apps/admin/frontend/src/screens/reports_screen.test.tsx rename to apps/admin/frontend/src/screens/reporting/reports_screen.test.tsx index 1f6d1c43a0..4eba6618c1 100644 --- a/apps/admin/frontend/src/screens/reports_screen.test.tsx +++ b/apps/admin/frontend/src/screens/reporting/reports_screen.test.tsx @@ -1,9 +1,7 @@ import { electionTwoPartyPrimaryDefinition } from '@votingworks/fixtures'; import fetchMock from 'fetch-mock'; import userEvent from '@testing-library/user-event'; -import { waitFor } from '@testing-library/react'; import { - advanceTimersAndPromises, fakeKiosk, fakeUsbDrive, hasTextAcrossElements, @@ -11,15 +9,12 @@ import { import { LogEventId, fakeLogger } from '@votingworks/logging'; import { typedAs } from '@votingworks/basics'; import { ReportsScreen } from './reports_screen'; -import { renderInAppContext } from '../../test/render_in_app_context'; -import { ApiMock, createApiMock } from '../../test/helpers/mock_api_client'; -import { screen } from '../../test/react_testing_library'; -import { VxFiles } from '../lib/converters'; -import { - expectReportsScreenCardCountQueries, - mockBallotCountsTableGroupBy, -} from '../../test/helpers/api_expect_helpers'; -import { mockUsbDriveStatus } from '../../test/helpers/mock_usb_drive'; +import { renderInAppContext } from '../../../test/render_in_app_context'; +import { ApiMock, createApiMock } from '../../../test/helpers/mock_api_client'; +import { screen, waitFor } from '../../../test/react_testing_library'; +import { VxFiles } from '../../lib/converters'; +import { mockUsbDriveStatus } from '../../../test/helpers/mock_usb_drive'; +import { getMockCardCounts } from '../../../test/helpers/mock_results'; let apiMock: ApiMock; @@ -51,17 +46,11 @@ test('exporting SEMS results', async () => { outputFiles: [{ name: 'name' }], }) ); - + apiMock.expectGetCardCounts({}, [getMockCardCounts(100)]); apiMock.expectGetSemsExportableTallies({ talliesByPrecinct: {}, }); apiMock.expectGetCastVoteRecordFileMode('test'); - expectReportsScreenCardCountQueries({ - apiMock, - isPrimary: true, - }); - apiMock.expectGetScannerBatches([]); - apiMock.expectGetManualResultsMetadata([]); renderInAppContext(, { electionDefinition, @@ -110,17 +99,8 @@ test('exporting SEMS results', async () => { }); test('exporting batch results', async () => { - const mockKiosk = fakeKiosk(); - mockKiosk.getUsbDriveInfo.mockResolvedValue([fakeUsbDrive()]); - window.kiosk = mockKiosk; - apiMock.expectGetCastVoteRecordFileMode('test'); - expectReportsScreenCardCountQueries({ - apiMock, - isPrimary: true, - }); - apiMock.expectGetScannerBatches([]); - apiMock.expectGetManualResultsMetadata([]); + apiMock.expectGetCardCounts({}, [getMockCardCounts(100)]); renderInAppContext(, { electionDefinition, @@ -128,15 +108,10 @@ test('exporting batch results', async () => { usbDriveStatus: mockUsbDriveStatus('mounted'), }); - apiMock.deprecatedExpectGetCardCounts( - mockBallotCountsTableGroupBy({ groupByBatch: true }), - [] - ); - userEvent.click(screen.getButton('Show Results by Batch and Scanner')); await waitFor(() => { - expect(screen.getButton('Save Batch Results as CSV')).toBeEnabled(); + expect(screen.getButton('Save Batch Results CSV')).toBeEnabled(); }); - userEvent.click(screen.getButton('Save Batch Results as CSV')); + userEvent.click(screen.getButton('Save Batch Results CSV')); await screen.findByRole('alertdialog'); await screen.findByText('Save Batch Results'); @@ -147,7 +122,6 @@ test('exporting batch results', async () => { apiMock.expectExportBatchResults( 'test-mount-point/votingworks-test-batch-results_sample-county_example-primary-election_2020-11-03_22-22-00.csv' ); - await advanceTimersAndPromises(1); // wait for modal to resolve USB path userEvent.click(screen.getByText('Save')); await screen.findByText(/Batch Results Saved/); }); @@ -155,12 +129,7 @@ test('exporting batch results', async () => { describe('ballot count summary text', () => { test('unlocked mode', async () => { apiMock.expectGetCastVoteRecordFileMode('unlocked'); - expectReportsScreenCardCountQueries({ - apiMock, - isPrimary: true, - }); - apiMock.expectGetScannerBatches([]); - apiMock.expectGetManualResultsMetadata([]); + apiMock.expectGetCardCounts({}, [getMockCardCounts(0)]); renderInAppContext(, { electionDefinition, @@ -176,17 +145,7 @@ describe('ballot count summary text', () => { test('official mode', async () => { apiMock.expectGetCastVoteRecordFileMode('official'); - expectReportsScreenCardCountQueries({ - apiMock, - isPrimary: true, - overallCardCount: { - bmd: 1000, - hmpb: [1000], - manual: 1000, - }, - }); - apiMock.expectGetScannerBatches([]); - apiMock.expectGetManualResultsMetadata([]); + apiMock.expectGetCardCounts({}, [getMockCardCounts(3000)]); renderInAppContext(, { electionDefinition, @@ -202,17 +161,7 @@ describe('ballot count summary text', () => { test('test mode', async () => { apiMock.expectGetCastVoteRecordFileMode('test'); - expectReportsScreenCardCountQueries({ - apiMock, - isPrimary: true, - overallCardCount: { - bmd: 1000, - hmpb: [1000], - manual: 1000, - }, - }); - apiMock.expectGetScannerBatches([]); - apiMock.expectGetManualResultsMetadata([]); + apiMock.expectGetCardCounts({}, [getMockCardCounts(3000)]); renderInAppContext(, { electionDefinition, diff --git a/apps/admin/frontend/src/screens/reports_screen.tsx b/apps/admin/frontend/src/screens/reporting/reports_screen.tsx similarity index 65% rename from apps/admin/frontend/src/screens/reports_screen.tsx rename to apps/admin/frontend/src/screens/reporting/reports_screen.tsx index 3649638969..2af13ef707 100644 --- a/apps/admin/frontend/src/screens/reports_screen.tsx +++ b/apps/admin/frontend/src/screens/reporting/reports_screen.tsx @@ -2,7 +2,6 @@ import React, { useContext, useState, useEffect, useCallback } from 'react'; import pluralize from 'pluralize'; import { - canDistinguishVotingMethods, generateSemsFinalExportDefaultFilename, format, isElectionManagerAuth, @@ -16,27 +15,25 @@ import { P, Font, } from '@votingworks/ui'; -import { TallyCategory } from '@votingworks/types'; import { LogEventId } from '@votingworks/logging'; import { assert } from '@votingworks/basics'; -import { AppContext } from '../contexts/app_context'; -import { MsSemsConverterClient } from '../lib/converters/ms_sems_converter_client'; +import { AppContext } from '../../contexts/app_context'; +import { MsSemsConverterClient } from '../../lib/converters/ms_sems_converter_client'; -import { NavigationScreen } from '../components/navigation_screen'; -import { routerPaths } from '../router_paths'; -import { BallotCountsTable } from '../components/ballot_counts_table'; -import { getPartiesWithPrimaryElections } from '../utils/election'; +import { NavigationScreen } from '../../components/navigation_screen'; +import { routerPaths } from '../../router_paths'; import { SaveFrontendFileModal, FileType, -} from '../components/save_frontend_file_modal'; -import { getTallyConverterClient } from '../lib/converters'; +} from '../../components/save_frontend_file_modal'; +import { getTallyConverterClient } from '../../lib/converters'; import { getCardCounts, getCastVoteRecordFileMode, getSemsExportableTallies, -} from '../api'; +} from '../../api'; +import { ExportBatchTallyResultsButton } from '../../components/export_batch_tally_results_button'; export function ReportsScreen(): JSX.Element { const makeCancelable = useCancelablePromise(); @@ -52,7 +49,6 @@ export function ReportsScreen(): JSX.Element { assert(isElectionManagerAuth(auth)); const userRole = auth.user.role; assert(electionDefinition && typeof configuredAt === 'string'); - const { election } = electionDefinition; const cardCountsQuery = getCardCounts.useQuery(); const castVoteRecordFileModeQuery = getCastVoteRecordFileMode.useQuery(); @@ -63,15 +59,8 @@ export function ReportsScreen(): JSX.Element { const [isExportResultsModalOpen, setIsExportResultsModalOpen] = useState(false); - const [isShowingBatchResults, setIsShowingBatchResults] = useState(false); - const toggleShowingBatchResults = useCallback(() => { - setIsShowingBatchResults(!isShowingBatchResults); - }, [isShowingBatchResults, setIsShowingBatchResults]); - const statusPrefix = isOfficialResults ? 'Official' : 'Unofficial'; - const partiesForPrimaries = getPartiesWithPrimaryElections(election); - const [converterName, setConverterName] = useState(''); useEffect(() => { void (async () => { @@ -122,38 +111,6 @@ export function ReportsScreen(): JSX.Element { userRole, ]); - const tallyResultsInfo = ( - -

Tally Report by Precinct

- - {canDistinguishVotingMethods(election) && ( - -

Tally Report by Voting Method

- -
- )} - {partiesForPrimaries.length > 0 && ( - -

Tally Report by Party

- -
- )} -

Tally Report by Scanner

- -
-
- {isShowingBatchResults ? ( - - ) : ( - - )} -
- ); - const fileMode = castVoteRecordFileModeQuery.data; const totalBallotCount = cardCountsQuery.data ? getBallotCount(cardCountsQuery.data[0]) @@ -181,38 +138,59 @@ export function ReportsScreen(): JSX.Element { return ( - {ballotCountSummaryText} +

{statusPrefix} Tally Reports

- - {statusPrefix} Full Election Tally Report - {' '} - {converterName !== '' && ( - - {' '} - - )} + + Full Election Tally Report +

- - {statusPrefix} Write-In Adjudication Report + + All Precincts Tally Report + {' '} + + Single Precinct Tally Report

Tally Report Builder + +

+

{statusPrefix} Ballot Count Reports

+ {ballotCountSummaryText} +

+ + Precinct Ballot Count Report {' '} + + Voting Method Ballot Count Report + +

+

Ballot Count Report Builder

- {tallyResultsInfo} +

Other Reports

+

+ + {statusPrefix} Write-In Adjudication Report + +

+

+ {' '} + {converterName !== '' && ( + + )} +

{isExportResultsModalOpen && ( { + logger = fakeLogger(); + apiMock = createApiMock(); +}); + +afterEach(() => { + delete window.kiosk; + apiMock.assertComplete(); +}); + +test('select precinct and view report', async () => { + const { election, electionDefinition } = electionTwoPartyPrimaryFixtures; + apiMock.expectGetCastVoteRecordFileMode('official'); + apiMock.expectGetScannerBatches([]); + + renderInAppContext(, { + electionDefinition, + logger, + apiMock, + isOfficialResults: false, + }); + + screen.getByText(SCREEN_TITLE); + + // display precinct 1 report + apiMock.expectGetResultsForTallyReports( + { + filter: { precinctIds: ['precinct-1'] }, + groupBy: {}, + }, + [ + getSimpleMockTallyResults({ + election, + scannedBallotCount: 25, + cardCountsByParty: { + '0': 20, + '1': 5, + }, + }), + ] + ); + userEvent.click(screen.getByLabelText('Select Precinct')); + userEvent.click(screen.getByText('Precinct 1')); + + const expectedPrecinct1Reports: Array<{ + subtitle: string; + count: number; + }> = [ + { + subtitle: 'Mammal Party Example Primary Election', + count: 20, + }, + { + subtitle: 'Fish Party Example Primary Election', + count: 5, + }, + { + subtitle: 'Example Primary Election Nonpartisan Contests', + count: 25, + }, + ]; + + const precinct1Reports = await screen.findAllByTestId(/tally-report/); + expect(precinct1Reports).toHaveLength(expectedPrecinct1Reports.length); + for (const [i, report] of precinct1Reports.entries()) { + within(report).getByText('Unofficial Precinct 1 Tally Report'); + within(report).getByText(expectedPrecinct1Reports[i].subtitle); + expect(within(report).getByTestId('total-ballot-count')).toHaveTextContent( + expectedPrecinct1Reports[i].count.toString() + ); + } + + // display precinct 2 report + apiMock.expectGetResultsForTallyReports( + { + filter: { precinctIds: ['precinct-2'] }, + groupBy: {}, + }, + [ + getSimpleMockTallyResults({ + election, + scannedBallotCount: 40, + cardCountsByParty: { + '0': 30, + '1': 10, + }, + }), + ] + ); + userEvent.click(screen.getByLabelText('Select Precinct')); + userEvent.click(screen.getByText('Precinct 2')); + + const expectedPrecinct2Reports: Array<{ + subtitle: string; + count: number; + }> = [ + { + subtitle: 'Mammal Party Example Primary Election', + count: 30, + }, + { + subtitle: 'Fish Party Example Primary Election', + count: 10, + }, + { + subtitle: 'Example Primary Election Nonpartisan Contests', + count: 40, + }, + ]; + + const precinct2Reports = await screen.findAllByTestId(/tally-report/); + expect(precinct2Reports).toHaveLength(expectedPrecinct2Reports.length); + for (const [i, report] of precinct2Reports.entries()) { + within(report).getByText('Unofficial Precinct 2 Tally Report'); + within(report).getByText(expectedPrecinct2Reports[i].subtitle); + expect(within(report).getByTestId('total-ballot-count')).toHaveTextContent( + expectedPrecinct2Reports[i].count.toString() + ); + } +}); diff --git a/apps/admin/frontend/src/screens/reporting/single_precinct_tally_report_screen.tsx b/apps/admin/frontend/src/screens/reporting/single_precinct_tally_report_screen.tsx new file mode 100644 index 0000000000..c86204d29d --- /dev/null +++ b/apps/admin/frontend/src/screens/reporting/single_precinct_tally_report_screen.tsx @@ -0,0 +1,62 @@ +import { Icons, LinkButton, P, SearchSelect } from '@votingworks/ui'; +import { useContext, useState } from 'react'; +import { assert } from '@votingworks/basics'; +import { isElectionManagerAuth } from '@votingworks/utils'; +import styled from 'styled-components'; +import { AppContext } from '../../contexts/app_context'; +import { NavigationScreen } from '../../components/navigation_screen'; +import { routerPaths } from '../../router_paths'; +import { TallyReportViewer } from '../../components/reporting/tally_report_viewer'; + +export const SCREEN_TITLE = 'Single Precinct Tally Report'; + +const SelectPrecinctContainer = styled.div` + display: grid; + grid-template-columns: min-content 30%; + gap: 1rem; + align-items: center; + + p { + white-space: nowrap; + margin: 0; + } +`; + +export function SinglePrecinctTallyReportScreen(): JSX.Element { + const { electionDefinition, auth } = useContext(AppContext); + assert(electionDefinition); + const { election } = electionDefinition; + assert(isElectionManagerAuth(auth)); + + const [precinctId, setPrecinctId] = useState(); + + return ( + +

+ + Back + +

+ +

Select Precinct:

+ ({ + value: precinct.id, + label: precinct.name, + }))} + onChange={(value) => setPrecinctId(value)} + ariaLabel="Select Precinct" + /> +
+ +
+ ); +} diff --git a/apps/admin/frontend/src/screens/tally_report_builder.test.tsx b/apps/admin/frontend/src/screens/reporting/tally_report_builder.test.tsx similarity index 90% rename from apps/admin/frontend/src/screens/tally_report_builder.test.tsx rename to apps/admin/frontend/src/screens/reporting/tally_report_builder.test.tsx index 5b5d0a79d1..1c9c78b762 100644 --- a/apps/admin/frontend/src/screens/tally_report_builder.test.tsx +++ b/apps/admin/frontend/src/screens/reporting/tally_report_builder.test.tsx @@ -1,12 +1,12 @@ import { electionTwoPartyPrimaryDefinition } from '@votingworks/fixtures'; import userEvent from '@testing-library/user-event'; import { expectPrint } from '@votingworks/test-utils'; -import { ApiMock, createApiMock } from '../../test/helpers/mock_api_client'; -import { renderInAppContext } from '../../test/render_in_app_context'; +import { ApiMock, createApiMock } from '../../../test/helpers/mock_api_client'; +import { renderInAppContext } from '../../../test/render_in_app_context'; import { TallyReportBuilder } from './tally_report_builder'; -import { screen, within } from '../../test/react_testing_library'; -import { getSimpleMockTallyResults } from '../../test/helpers/mock_results'; -import { canonicalizeFilter, canonicalizeGroupBy } from '../utils/reporting'; +import { screen, within } from '../../../test/react_testing_library'; +import { getSimpleMockTallyResults } from '../../../test/helpers/mock_results'; +import { canonicalizeFilter, canonicalizeGroupBy } from '../../utils/reporting'; let apiMock: ApiMock; diff --git a/apps/admin/frontend/src/screens/tally_report_builder.tsx b/apps/admin/frontend/src/screens/reporting/tally_report_builder.tsx similarity index 82% rename from apps/admin/frontend/src/screens/tally_report_builder.tsx rename to apps/admin/frontend/src/screens/reporting/tally_report_builder.tsx index 5833abf410..bf7a03d241 100644 --- a/apps/admin/frontend/src/screens/tally_report_builder.tsx +++ b/apps/admin/frontend/src/screens/reporting/tally_report_builder.tsx @@ -8,13 +8,13 @@ import { } from '@votingworks/utils'; import { Tabulation } from '@votingworks/types'; import styled from 'styled-components'; -import { AppContext } from '../contexts/app_context'; -import { NavigationScreen } from '../components/navigation_screen'; -import { routerPaths } from '../router_paths'; -import { FilterEditor } from '../components/reporting/filter_editor'; -import { GroupByEditor } from '../components/reporting/group_by_editor'; -import { TallyReportViewer } from '../components/reporting/tally_report_viewer'; -import { canonicalizeFilter, canonicalizeGroupBy } from '../utils/reporting'; +import { AppContext } from '../../contexts/app_context'; +import { NavigationScreen } from '../../components/navigation_screen'; +import { routerPaths } from '../../router_paths'; +import { FilterEditor } from '../../components/reporting/filter_editor'; +import { GroupByEditor } from '../../components/reporting/group_by_editor'; +import { TallyReportViewer } from '../../components/reporting/tally_report_viewer'; +import { canonicalizeFilter, canonicalizeGroupBy } from '../../utils/reporting'; const SCREEN_TITLE = 'Tally Report Builder'; @@ -32,7 +32,7 @@ const GroupByEditorContainer = styled.div` export function TallyReportBuilder(): JSX.Element { const { electionDefinition, auth } = useContext(AppContext); assert(electionDefinition); - assert(isElectionManagerAuth(auth)); // TODO(auth) check permissions for viewing tally reports. + assert(isElectionManagerAuth(auth)); const { election } = electionDefinition; const [filter, setFilter] = useState({}); diff --git a/apps/admin/frontend/src/screens/reporting/voting_method_ballot_count_report_screen.test.tsx b/apps/admin/frontend/src/screens/reporting/voting_method_ballot_count_report_screen.test.tsx new file mode 100644 index 0000000000..cfcecf9b5c --- /dev/null +++ b/apps/admin/frontend/src/screens/reporting/voting_method_ballot_count_report_screen.test.tsx @@ -0,0 +1,122 @@ +import { + electionFamousNames2021Fixtures, + electionTwoPartyPrimaryFixtures, +} from '@votingworks/fixtures'; +import { Logger, fakeLogger } from '@votingworks/logging'; +import { ApiMock, createApiMock } from '../../../test/helpers/mock_api_client'; +import { getMockCardCounts } from '../../../test/helpers/mock_results'; +import { renderInAppContext } from '../../../test/render_in_app_context'; +import { screen, within } from '../../../test/react_testing_library'; +import { + SCREEN_TITLE, + VotingMethodBallotCountReport, +} from './voting_method_ballot_count_report_screen'; + +let logger: Logger; +let apiMock: ApiMock; + +beforeEach(() => { + logger = fakeLogger(); + apiMock = createApiMock(); +}); + +afterEach(() => { + delete window.kiosk; + apiMock.assertComplete(); +}); + +test('displays report (primary)', async () => { + const { electionDefinition } = electionTwoPartyPrimaryFixtures; + apiMock.expectGetCastVoteRecordFileMode('official'); + apiMock.expectGetScannerBatches([]); + apiMock.expectGetCardCounts( + { + filter: {}, + groupBy: { groupByVotingMethod: true, groupByParty: true }, + }, + [ + { + votingMethod: 'precinct', + partyId: '0', + ...getMockCardCounts(5), + }, + { + votingMethod: 'precinct', + partyId: '1', + ...getMockCardCounts(10), + }, + { + votingMethod: 'absentee', + partyId: '0', + ...getMockCardCounts(15), + }, + { + votingMethod: 'absentee', + partyId: '1', + ...getMockCardCounts(20), + }, + ] + ); + + renderInAppContext(, { + electionDefinition, + logger, + apiMock, + isOfficialResults: false, + }); + + screen.getByText(SCREEN_TITLE); + + const report = await screen.findByTestId('ballot-count-report'); + within(report).getByText('Unofficial Full Election Ballot Count Report'); + within(report).getByText('Example Primary Election'); + + expect(within(report).getAllByText('Precinct')).toHaveLength(2); + expect(within(report).getAllByText('Absentee')).toHaveLength(2); + expect(within(report).getAllByText('Mammal')).toHaveLength(2); + expect(within(report).getAllByText('Fish')).toHaveLength(2); + + expect(within(report).getByTestId('footer-bmd')).toHaveTextContent('50'); + expect(within(report).getByTestId('footer-total')).toHaveTextContent('50'); +}); + +test('displays report (general)', async () => { + const { electionDefinition } = electionFamousNames2021Fixtures; + apiMock.expectGetCastVoteRecordFileMode('official'); + apiMock.expectGetScannerBatches([]); + apiMock.expectGetCardCounts( + { + filter: {}, + groupBy: { groupByVotingMethod: true, groupByParty: false }, + }, + [ + { + votingMethod: 'precinct', + ...getMockCardCounts(5), + }, + { + votingMethod: 'absentee', + ...getMockCardCounts(10), + }, + ] + ); + + renderInAppContext(, { + electionDefinition, + logger, + apiMock, + isOfficialResults: false, + }); + + screen.getByText(SCREEN_TITLE); + + const report = await screen.findByTestId('ballot-count-report'); + within(report).getByText('Unofficial Full Election Ballot Count Report'); + within(report).getByText('Lincoln Municipal General Election'); + + within(report).getByText('Precinct'); + within(report).getByText('Absentee'); + + expect(within(report).getByTestId('footer-bmd')).toHaveTextContent('15'); + expect(within(report).getByTestId('footer-total')).toHaveTextContent('15'); +}); diff --git a/apps/admin/frontend/src/screens/reporting/voting_method_ballot_count_report_screen.tsx b/apps/admin/frontend/src/screens/reporting/voting_method_ballot_count_report_screen.tsx new file mode 100644 index 0000000000..d93b470e2b --- /dev/null +++ b/apps/admin/frontend/src/screens/reporting/voting_method_ballot_count_report_screen.tsx @@ -0,0 +1,36 @@ +import { Icons, LinkButton, P } from '@votingworks/ui'; +import { useContext } from 'react'; +import { assert } from '@votingworks/basics'; +import { isElectionManagerAuth } from '@votingworks/utils'; +import { AppContext } from '../../contexts/app_context'; +import { NavigationScreen } from '../../components/navigation_screen'; +import { routerPaths } from '../../router_paths'; +import { BallotCountReportViewer } from '../../components/reporting/ballot_count_report_viewer'; + +export const SCREEN_TITLE = 'Voting Method Ballot Count Report'; + +export function VotingMethodBallotCountReport(): JSX.Element { + const { electionDefinition, auth } = useContext(AppContext); + assert(electionDefinition); + assert(isElectionManagerAuth(auth)); + + return ( + +

+ + Back + +

+ +
+ ); +} diff --git a/apps/admin/frontend/src/screens/tally_report_screen.test.tsx b/apps/admin/frontend/src/screens/tally_report_screen.test.tsx deleted file mode 100644 index 8fbe184467..0000000000 --- a/apps/admin/frontend/src/screens/tally_report_screen.test.tsx +++ /dev/null @@ -1,490 +0,0 @@ -import { - electionFamousNames2021Fixtures, - electionTwoPartyPrimaryDefinition, -} from '@votingworks/fixtures'; -import { fakeKiosk, fakePrinterInfo } from '@votingworks/test-utils'; -import { fakeLogger, Logger } from '@votingworks/logging'; -import { screen, within } from '@testing-library/react'; - -import { Route } from 'react-router-dom'; -import { PartyId } from '@votingworks/types'; -import { renderInAppContext } from '../../test/render_in_app_context'; -import { ApiMock, createApiMock } from '../../test/helpers/mock_api_client'; -import { - AllPrecinctsTallyReportScreen, - BatchTallyReportScreen, - FullElectionTallyReportScreen, - PartyTallyReportScreen, - PrecinctTallyReportScreen, - ScannerTallyReportScreen, - VotingMethodTallyReportScreen, -} from './tally_report_screen'; -import { routerPaths } from '../router_paths'; -import { getSimpleMockTallyResults } from '../../test/helpers/mock_results'; - -let mockKiosk: jest.Mocked; -let logger: Logger; -let apiMock: ApiMock; - -beforeEach(() => { - jest.useFakeTimers(); - mockKiosk = fakeKiosk(); - mockKiosk.getPrinterInfo.mockResolvedValue([ - fakePrinterInfo({ connected: true, name: 'VxPrinter' }), - ]); - window.kiosk = mockKiosk; - logger = fakeLogger(); - apiMock = createApiMock(); -}); - -afterEach(() => { - delete window.kiosk; - apiMock.assertComplete(); -}); - -async function checkReportSection({ - testId, - title, - subtitle, - ballotCount, - contestCount, -}: { - testId: string; - title: string; - subtitle?: string; - ballotCount: number; - contestCount?: number; -}): Promise { - const report = await screen.findByTestId(testId); - within(report).getByText(title); - if (subtitle) { - within(report).getByText(subtitle); - } - expect(within(report).getByTestId('total-ballot-count')).toHaveTextContent( - `${ballotCount}` - ); - if (contestCount) { - expect(within(report).getAllByTestId(/results-table/)).toHaveLength( - contestCount - ); - } -} - -function expectReportSections(num: number): void { - expect(screen.getAllByTestId(/tally-report/)).toHaveLength(num); -} - -test('full election tally report screen, general', async () => { - const { election, electionDefinition } = electionFamousNames2021Fixtures; - - apiMock.expectGetCastVoteRecordFileMode('unlocked'); - apiMock.expectGetResultsForTallyReports( - { - filter: {}, - }, - [getSimpleMockTallyResults({ election, scannedBallotCount: 10 })] - ); - - renderInAppContext(, { - electionDefinition, - logger, - apiMock, - route: routerPaths.tallyFullReport, - }); - - await checkReportSection({ - testId: 'tally-report', - title: 'Unofficial Lincoln Municipal General Election Tally Report', - ballotCount: 10, - contestCount: election.contests.length, - }); - expectReportSections(1); -}); - -test('full election tally report screen, primary', async () => { - const { election } = electionTwoPartyPrimaryDefinition; - - apiMock.expectGetCastVoteRecordFileMode('unlocked'); - apiMock.expectGetResultsForTallyReports( - { - filter: {}, - }, - [ - getSimpleMockTallyResults({ - election, - scannedBallotCount: 25, - cardCountsByParty: { - '0': 10, - '1': 15, - }, - }), - ] - ); - - renderInAppContext(, { - electionDefinition: electionTwoPartyPrimaryDefinition, - logger, - apiMock, - route: routerPaths.tallyFullReport, - }); - - await checkReportSection({ - testId: 'tally-report-0', - title: 'Unofficial Mammal Party Example Primary Election Tally Report', - ballotCount: 10, - contestCount: 2, - }); - await checkReportSection({ - testId: 'tally-report-1', - title: 'Unofficial Fish Party Example Primary Election Tally Report', - ballotCount: 15, - contestCount: 2, - }); - await checkReportSection({ - testId: 'tally-report-nonpartisan', - title: - 'Unofficial Example Primary Election Nonpartisan Contests Tally Report', - ballotCount: 25, - contestCount: 3, - }); - expectReportSections(3); -}); - -test('precinct tally report screen', async () => { - const { election, electionDefinition } = electionFamousNames2021Fixtures; - - apiMock.expectGetCastVoteRecordFileMode('unlocked'); - apiMock.expectGetResultsForTallyReports( - { - filter: { precinctIds: ['23'] }, - }, - [getSimpleMockTallyResults({ election, scannedBallotCount: 10 })] - ); - - renderInAppContext( - - - , - { - electionDefinition, - logger, - apiMock, - route: routerPaths.tallyPrecinctReport({ precinctId: '23' }), - } - ); - - await checkReportSection({ - testId: 'tally-report', - title: 'Unofficial Precinct Tally Report for North Lincoln', - subtitle: 'Lincoln Municipal General Election', - ballotCount: 10, - }); - expectReportSections(1); -}); - -test('scanner tally report screen', async () => { - const { election, electionDefinition } = electionFamousNames2021Fixtures; - - apiMock.expectGetCastVoteRecordFileMode('unlocked'); - apiMock.expectGetResultsForTallyReports( - { - filter: { scannerIds: ['scanner-1'] }, - }, - [getSimpleMockTallyResults({ election, scannedBallotCount: 10 })] - ); - - renderInAppContext( - - - , - { - electionDefinition, - logger, - apiMock, - route: routerPaths.tallyScannerReport({ scannerId: 'scanner-1' }), - } - ); - - await checkReportSection({ - testId: 'tally-report', - title: 'Unofficial Scanner Tally Report for Scanner scanner-1', - subtitle: 'Lincoln Municipal General Election', - ballotCount: 10, - }); - expectReportSections(1); -}); - -test('batch tally report screen', async () => { - const { election, electionDefinition } = electionFamousNames2021Fixtures; - - apiMock.expectGetCastVoteRecordFileMode('unlocked'); - apiMock.expectGetResultsForTallyReports( - { - filter: { batchIds: ['batch-1'] }, - }, - [getSimpleMockTallyResults({ election, scannedBallotCount: 10 })] - ); - apiMock.expectGetScannerBatches([ - { - batchId: 'batch-1', - label: 'Batch 1', - scannerId: 'scanner-1', - electionId: 'id', - }, - ]); - - renderInAppContext( - - - , - { - electionDefinition, - logger, - apiMock, - route: routerPaths.tallyBatchReport({ batchId: 'batch-1' }), - } - ); - - await checkReportSection({ - testId: 'tally-report', - title: 'Unofficial Batch Tally Report for Batch 1', - subtitle: 'Lincoln Municipal General Election', - ballotCount: 10, - }); - expectReportSections(1); -}); - -test('voting method tally report screen', async () => { - const { election, electionDefinition } = electionFamousNames2021Fixtures; - - apiMock.expectGetCastVoteRecordFileMode('unlocked'); - apiMock.expectGetResultsForTallyReports( - { - filter: { votingMethods: ['absentee'] }, - }, - [getSimpleMockTallyResults({ election, scannedBallotCount: 10 })] - ); - - renderInAppContext( - - - , - { - electionDefinition, - logger, - apiMock, - route: routerPaths.tallyVotingMethodReport({ votingMethod: 'absentee' }), - } - ); - - await checkReportSection({ - testId: 'tally-report', - title: 'Unofficial Absentee Ballot Tally Report', - subtitle: 'Lincoln Municipal General Election', - ballotCount: 10, - }); - expectReportSections(1); -}); - -test('party tally report screen', async () => { - const { election } = electionTwoPartyPrimaryDefinition; - - apiMock.expectGetCastVoteRecordFileMode('unlocked'); - apiMock.expectGetResultsForTallyReports( - { - filter: { partyIds: ['0'] }, - }, - [ - getSimpleMockTallyResults({ - election, - scannedBallotCount: 10, - cardCountsByParty: { - '0': 10, - }, - contestIds: election.contests - .filter((c) => c.type === 'yesno' || c.partyId === '0') - .map((c) => c.id), - }), - ] - ); - - renderInAppContext( - - - , - { - electionDefinition: electionTwoPartyPrimaryDefinition, - logger, - apiMock, - route: routerPaths.tallyPartyReport({ partyId: '0' as PartyId }), - } - ); - - const mammalReport = await screen.findByTestId('tally-report-0'); - within(mammalReport).getByText('Unofficial Mammal Party Tally Report'); - within(mammalReport).getByText('Mammal Party Example Primary Election'); - expect( - within(mammalReport).getByTestId('total-ballot-count') - ).toHaveTextContent('10'); - - await checkReportSection({ - testId: 'tally-report-0', - title: 'Unofficial Mammal Party Tally Report', - subtitle: 'Mammal Party Example Primary Election', - ballotCount: 10, - }); - await checkReportSection({ - testId: 'tally-report-nonpartisan', - title: 'Unofficial Mammal Party Tally Report', - subtitle: 'Example Primary Election Nonpartisan Contests', - ballotCount: 10, - }); - - expectReportSections(2); -}); - -test('all precincts tally report screen', async () => { - const { election } = electionTwoPartyPrimaryDefinition; - - apiMock.expectGetCastVoteRecordFileMode('unlocked'); - apiMock.expectGetResultsForTallyReports( - { - groupBy: { groupByPrecinct: true }, - }, - [ - { - precinctId: 'precinct-1', - ...getSimpleMockTallyResults({ - election, - scannedBallotCount: 30, - cardCountsByParty: { - '0': 10, - '1': 20, - }, - }), - }, - { - precinctId: 'precinct-2', - ...getSimpleMockTallyResults({ - election, - scannedBallotCount: 25, - cardCountsByParty: { - '0': 20, - '1': 5, - }, - }), - }, - ] - ); - - renderInAppContext(, { - electionDefinition: electionTwoPartyPrimaryDefinition, - logger, - apiMock, - route: routerPaths.tallyAllPrecinctsReport, - }); - - await screen.findByText('Unofficial All Precincts Tally Report'); - - await checkReportSection({ - testId: 'tally-report-precinct-1-0', - title: 'Unofficial Precinct Tally Report for Precinct 1', - subtitle: 'Mammal Party Example Primary Election', - ballotCount: 10, - }); - await checkReportSection({ - testId: 'tally-report-precinct-1-1', - title: 'Unofficial Precinct Tally Report for Precinct 1', - subtitle: 'Fish Party Example Primary Election', - ballotCount: 20, - }); - await checkReportSection({ - testId: 'tally-report-precinct-1-nonpartisan', - title: 'Unofficial Precinct Tally Report for Precinct 1', - subtitle: 'Example Primary Election Nonpartisan Contests', - ballotCount: 30, - }); - await checkReportSection({ - testId: 'tally-report-precinct-2-0', - title: 'Unofficial Precinct Tally Report for Precinct 2', - subtitle: 'Mammal Party Example Primary Election', - ballotCount: 20, - }); - await checkReportSection({ - testId: 'tally-report-precinct-2-1', - title: 'Unofficial Precinct Tally Report for Precinct 2', - subtitle: 'Fish Party Example Primary Election', - ballotCount: 5, - }); - await checkReportSection({ - testId: 'tally-report-precinct-2-nonpartisan', - title: 'Unofficial Precinct Tally Report for Precinct 2', - subtitle: 'Example Primary Election Nonpartisan Contests', - ballotCount: 25, - }); - - expectReportSections(6); -}); - -test('mark official results button disabled when no cvr files', async () => { - const { election, electionDefinition } = electionFamousNames2021Fixtures; - apiMock.expectGetCastVoteRecordFileMode('unlocked'); - apiMock.expectGetResultsForTallyReports( - { - filter: {}, - }, - [getSimpleMockTallyResults({ election, scannedBallotCount: 10 })] - ); - - renderInAppContext(, { - electionDefinition, - logger, - apiMock, - route: routerPaths.tallyFullReport, - }); - - await screen.findByTestId('tally-report'); - expect( - screen.getByRole('button', { - name: 'Mark Tally Results as Official', - }) - ).toBeDisabled(); -}); - -test('mark official results button disabled when already official', async () => { - const { election, electionDefinition } = electionFamousNames2021Fixtures; - apiMock.expectGetCastVoteRecordFileMode('official'); - apiMock.expectGetResultsForTallyReports( - { - filter: {}, - }, - [getSimpleMockTallyResults({ election, scannedBallotCount: 10 })] - ); - - renderInAppContext(, { - electionDefinition, - logger, - apiMock, - route: routerPaths.tallyFullReport, - isOfficialResults: true, - }); - - await screen.findByTestId('tally-report'); - expect( - screen.getByRole('button', { - name: 'Mark Tally Results as Official', - }) - ).toBeDisabled(); -}); - -// marking results as official is tested in higher-level tests to confirm refetching diff --git a/apps/admin/frontend/src/screens/tally_report_screen.tsx b/apps/admin/frontend/src/screens/tally_report_screen.tsx deleted file mode 100644 index 5e3b688991..0000000000 --- a/apps/admin/frontend/src/screens/tally_report_screen.tsx +++ /dev/null @@ -1,401 +0,0 @@ -import React, { useContext, useEffect, useState, useMemo } from 'react'; -import { useLocation, useParams } from 'react-router-dom'; -import { getPrecinctById, isElectionManagerAuth } from '@votingworks/utils'; -import { assert, assertDefined, find } from '@votingworks/basics'; -import { LogEventId } from '@votingworks/logging'; -import { - Button, - Caption, - Font, - H2, - Icons, - LinkButton, - Modal, - P, - printElement, - printElementToPdf, - ReportPreviewLoading, - TallyReportMetadata, - TallyReportPreview, -} from '@votingworks/ui'; -import { Tabulation } from '@votingworks/types'; -import { generateDefaultReportFilename } from '../utils/save_as_pdf'; - -import { AppContext } from '../contexts/app_context'; - -import { NavigationScreen } from '../components/navigation_screen'; - -import { routerPaths } from '../router_paths'; - -import { - SaveFrontendFileModal, - FileType, -} from '../components/save_frontend_file_modal'; -import { PrintButton } from '../components/print_button'; -import { - getCastVoteRecordFileMode, - getResultsForTallyReports, - getScannerBatches, - markResultsOfficial, -} from '../api'; -import { Loading } from '../components/loading'; -import { AdminTallyReportByParty } from '../components/admin_tally_report_by_party'; -import { - BatchReportScreenProps, - PartyReportScreenProps, - PrecinctReportScreenProps, - ScannerReportScreenProps, - VotingMethodReportScreenProps, -} from '../config/types'; - -interface BaseTallyReportScreenProps { - report?: JSX.Element; - generatedAtTime?: Date; - title: string; - fileSuffix: string; -} - -function BaseTallyReportScreen({ - report, - title, - fileSuffix, - generatedAtTime, -}: BaseTallyReportScreenProps): JSX.Element { - const { electionDefinition, isOfficialResults, auth, logger } = - useContext(AppContext); - assert(electionDefinition); - assert(isElectionManagerAuth(auth)); // TODO(auth) check permissions for viewing tally reports. - const userRole = auth.user.role; - const { election } = electionDefinition; - - const [isSaveModalOpen, setIsSaveModalOpen] = useState(false); - const [isMarkOfficialModalOpen, setIsMarkOfficialModalOpen] = useState(false); - - const markResultsOfficialMutation = markResultsOfficial.useMutation(); - const castVoteRecordFileModeQuery = getCastVoteRecordFileMode.useQuery(); - - const location = useLocation(); - - useEffect(() => { - void logger.log(LogEventId.TallyReportPreviewed, userRole, { - message: `User previewed the ${title}.`, - disposition: 'success', - tallyReportTitle: title, - }); - }, [logger, title, userRole]); - - async function printTallyReport() { - assert(report); // printing should only be available if the report loaded - try { - await printElement(report, { sides: 'one-sided' }); - await logger.log(LogEventId.TallyReportPrinted, userRole, { - message: `User printed ${title}`, - disposition: 'success', - tallyReportTitle: title, - }); - } catch (error) { - assert(error instanceof Error); - await logger.log(LogEventId.TallyReportPrinted, userRole, { - message: `Error in attempting to print ${title}: ${error.message}`, - disposition: 'failure', - tallyReportTitle: title, - result: 'User shown error.', - }); - } - } - - function closeMarkOfficialModal() { - setIsMarkOfficialModalOpen(false); - } - function openMarkOfficialModal() { - setIsMarkOfficialModalOpen(true); - } - function markOfficial() { - setIsMarkOfficialModalOpen(false); - markResultsOfficialMutation.mutate(); - } - - if (!castVoteRecordFileModeQuery.isSuccess) { - return ( - - - - ); - } - - const defaultReportFilename = generateDefaultReportFilename( - 'tabulation-report', - election, - fileSuffix - ); - const canMarkResultsOfficial = - castVoteRecordFileModeQuery.data !== 'unlocked' && - !isOfficialResults && - report; - - return ( - - - -

- - Print Report - {' '} - {window.kiosk && ( - - )} -

- {location.pathname === routerPaths.tallyFullReport && ( -

- -

- )} -

- - Back to Reports - -

-

Report Preview

- - Note: Printed reports may be - paginated to more than one piece of paper. - - {report ? ( - - {report} - - ) : ( - - )} -
- {isSaveModalOpen && ( - setIsSaveModalOpen(false)} - generateFileContent={() => printElementToPdf(assertDefined(report))} - defaultFilename={defaultReportFilename} - fileType={FileType.TallyReport} - /> - )} - {isMarkOfficialModalOpen && ( - -

- Have all CVR files been loaded? Once results are marked as - official, no additional CVR files can be loaded. -

-

Have all unofficial tally reports been reviewed?

-
- } - actions={ - - - - - } - onOverlayClick={closeMarkOfficialModal} - /> - )} -
- ); -} - -function SingleTallyReportScreen({ - title, - fileSuffix, - filter, -}: { - title?: string; - fileSuffix: string; - filter: Tabulation.Filter; -}): JSX.Element { - const { electionDefinition, isOfficialResults } = useContext(AppContext); - assert(electionDefinition); - const { election } = electionDefinition; - - const reportResultsQuery = getResultsForTallyReports.useQuery({ - filter, - }); - - const report = useMemo(() => { - if (!reportResultsQuery.data) { - return undefined; - } - - return ( - - ); - }, [ - electionDefinition, - isOfficialResults, - reportResultsQuery.data, - reportResultsQuery.dataUpdatedAt, - title, - ]); - - const statusPrefix = isOfficialResults ? 'Official' : 'Unofficial'; - - return BaseTallyReportScreen({ - title: title - ? `${statusPrefix} ${title}` - : `${statusPrefix} ${election.title} Tally Report`, - fileSuffix, - report, - generatedAtTime: report - ? new Date(reportResultsQuery.dataUpdatedAt) - : undefined, - }); -} - -export function PrecinctTallyReportScreen(): JSX.Element { - const { electionDefinition } = useContext(AppContext); - assert(electionDefinition); - const { election } = electionDefinition; - - const { precinctId } = useParams(); - const precinct = find(election.precincts, (p) => p.id === precinctId); - - return SingleTallyReportScreen({ - filter: { precinctIds: [precinctId] }, - fileSuffix: `precinct-${precinctId}}`, - title: `Precinct Tally Report for ${precinct.name}`, - }); -} - -export function ScannerTallyReportScreen(): JSX.Element { - const { scannerId } = useParams(); - return SingleTallyReportScreen({ - filter: { scannerIds: [scannerId] }, - fileSuffix: `scanner-${scannerId}`, - title: `Scanner Tally Report for Scanner ${scannerId}`, - }); -} - -export function VotingMethodTallyReportScreen(): JSX.Element { - const { votingMethod } = useParams(); - const votingMethodLabel = - votingMethod.slice(0, 1).toUpperCase() + votingMethod.slice(1); - return SingleTallyReportScreen({ - filter: { votingMethods: [votingMethod as Tabulation.VotingMethod] }, - fileSuffix: `${votingMethod}-ballots`, - title: `${votingMethodLabel} Ballot Tally Report`, - }); -} - -export function BatchTallyReportScreen(): JSX.Element { - const { batchId } = useParams(); - - const scannerBatchQuery = getScannerBatches.useQuery(); - const batch = scannerBatchQuery.data - ? find(scannerBatchQuery.data, (b) => b.batchId === batchId) - : undefined; - const title = batch - ? `Batch Tally Report for ${batch.label}` - : `Batch Tally Report`; - - return SingleTallyReportScreen({ - filter: { batchIds: [batchId] }, - fileSuffix: `batch-${batchId}`, - title, - }); -} - -export function PartyTallyReportScreen(): JSX.Element { - const { electionDefinition } = useContext(AppContext); - assert(electionDefinition); - const { election } = electionDefinition; - - const { partyId } = useParams(); - const party = find(election.parties, (p) => p.id === partyId); - - return SingleTallyReportScreen({ - filter: { partyIds: [partyId] }, - fileSuffix: `party-${partyId}`, - title: `${party.fullName} Tally Report`, - }); -} - -export function FullElectionTallyReportScreen(): JSX.Element { - return SingleTallyReportScreen({ - filter: {}, - fileSuffix: `full-election`, - }); -} - -export function AllPrecinctsTallyReportScreen(): JSX.Element { - const { electionDefinition, isOfficialResults } = useContext(AppContext); - assert(electionDefinition); - - const reportResultsQuery = getResultsForTallyReports.useQuery({ - groupBy: { - groupByPrecinct: true, - }, - }); - - const report = useMemo(() => { - if (!reportResultsQuery.data) { - return undefined; - } - - const precinctReports: JSX.Element[] = []; - for (const tallyReportResults of reportResultsQuery.data) { - const { precinctId } = tallyReportResults; - assert(precinctId !== undefined); - const precinct = getPrecinctById(electionDefinition, precinctId); - precinctReports.push( - - ); - } - - return {precinctReports}; - }, [ - electionDefinition, - isOfficialResults, - reportResultsQuery.data, - reportResultsQuery.dataUpdatedAt, - ]); - - const statusPrefix = isOfficialResults ? 'Official' : 'Unofficial'; - - return BaseTallyReportScreen({ - title: `${statusPrefix} All Precincts Tally Report`, - fileSuffix: 'all-precincts', - report, - generatedAtTime: report - ? new Date(reportResultsQuery.dataUpdatedAt) - : undefined, - }); -} diff --git a/apps/admin/frontend/test/helpers/api_expect_helpers.ts b/apps/admin/frontend/test/helpers/api_expect_helpers.ts deleted file mode 100644 index af681ea53c..0000000000 --- a/apps/admin/frontend/test/helpers/api_expect_helpers.ts +++ /dev/null @@ -1,57 +0,0 @@ -import { Tabulation } from '@votingworks/types'; -import { getEmptyCardCounts } from '@votingworks/utils'; -import { ApiMock } from './mock_api_client'; - -/** - * To match backend input exactly, per `MockFunction` requirements, mock out - * false values instead of undefined. - */ -export function mockBallotCountsTableGroupBy( - groupBy: Tabulation.GroupBy -): Tabulation.GroupBy { - return { - groupByBatch: false, - groupByParty: false, - groupByPrecinct: false, - groupByScanner: false, - groupByVotingMethod: false, - ...groupBy, - }; -} - -/** - * The reports screen fires off a number of queries of the same type, but they are - * always ordered the same way. This helper function mocks out the expected queries. - * - * This is a temporary solution until we redesign the reports screen to remove the - * excessive ballot counts (e.g. precinct, scanner, and batch do not make sense). - */ -export function expectReportsScreenCardCountQueries({ - apiMock, - isPrimary, - overallCardCount = getEmptyCardCounts(), -}: { - apiMock: ApiMock; - isPrimary: boolean; - overallCardCount?: Tabulation.CardCounts; -}): void { - apiMock.deprecatedExpectGetCardCounts( - mockBallotCountsTableGroupBy({ groupByPrecinct: true }), - [] - ); - apiMock.deprecatedExpectGetCardCounts( - mockBallotCountsTableGroupBy({ groupByVotingMethod: true }), - [] - ); - if (isPrimary) { - apiMock.deprecatedExpectGetCardCounts( - mockBallotCountsTableGroupBy({ groupByParty: true }), - [] - ); - } - apiMock.deprecatedExpectGetCardCounts( - mockBallotCountsTableGroupBy({ groupByScanner: true }), - [] - ); - apiMock.deprecatedExpectGetCardCounts({}, [overallCardCount]); -}