diff --git a/apps/central-scan/backend/src/app.deprecated_api.test.ts b/apps/central-scan/backend/src/app.assets_api.test.ts similarity index 56% rename from apps/central-scan/backend/src/app.deprecated_api.test.ts rename to apps/central-scan/backend/src/app.assets_api.test.ts index 3b85f234d31..5b9ad1d2a3a 100644 --- a/apps/central-scan/backend/src/app.deprecated_api.test.ts +++ b/apps/central-scan/backend/src/app.assets_api.test.ts @@ -1,33 +1,29 @@ +import { + buildMockDippedSmartCardAuth, + DippedSmartCardAuthApi, +} from '@votingworks/auth'; import { electionGridLayoutNewHampshireTestBallotFixtures } from '@votingworks/fixtures'; +import { Logger, mockBaseLogger } from '@votingworks/logging'; import { - AdjudicationReason, BallotMetadata, BallotType, DEFAULT_SYSTEM_SETTINGS, - InterpretedHmpbPage, PageInterpretationWithFiles, SheetOf, TEST_JURISDICTION, } from '@votingworks/types'; -import { Scan } from '@votingworks/api'; +import { createMockUsbDrive, MockUsbDrive } from '@votingworks/usb-drive'; import { Application } from 'express'; import * as fs from 'node:fs/promises'; +import { Server } from 'node:http'; import request from 'supertest'; import { dirSync } from 'tmp'; import { v4 as uuid } from 'uuid'; -import { typedAs } from '@votingworks/basics'; -import { - buildMockDippedSmartCardAuth, - DippedSmartCardAuthApi, -} from '@votingworks/auth'; -import { Server } from 'node:http'; -import { Logger, mockBaseLogger } from '@votingworks/logging'; -import { MockUsbDrive, createMockUsbDrive } from '@votingworks/usb-drive'; +import { buildMockLogger } from '../test/helpers/setup_app'; import { makeMock, makeMockScanner } from '../test/util/mocks'; +import { buildCentralScannerApp } from './app'; import { Importer } from './importer'; import { createWorkspace, Workspace } from './util/workspace'; -import { buildCentralScannerApp } from './app'; -import { buildMockLogger } from '../test/helpers/setup_app'; jest.mock('./importer'); @@ -152,7 +148,7 @@ const sheet: SheetOf = (() => { ]; })(); -test('GET /scan/hmpb/ballot/:ballotId/:side/image', async () => { +test('GET /central-scan/hmpb/ballot/:ballotId/:side/image', async () => { const batchId = workspace.store.addBatch(); const sheetId = workspace.store.addSheet(uuid(), batchId, sheet); workspace.store.finishBatch({ batchId }); @@ -166,7 +162,7 @@ test('GET /scan/hmpb/ballot/:ballotId/:side/image', async () => { .expect(200, await fs.readFile(backImagePath)); }); -test('GET /scan/hmpb/ballot/:sheetId/image 404', async () => { +test('GET /central-scan/hmpb/ballot/:sheetId/image 404', async () => { await request(app) .get(`/central-scanner/scan/hmpb/ballot/111/front/image`) .expect(404); @@ -175,128 +171,3 @@ test('GET /scan/hmpb/ballot/:sheetId/image 404', async () => { test('GET /', async () => { await request(app).get('/').expect(404); }); - -test('get next sheet', async () => { - jest.spyOn(workspace.store, 'getNextAdjudicationSheet').mockReturnValueOnce({ - id: 'mock-review-sheet', - front: { - image: { url: '/url/front' }, - interpretation: { type: 'BlankPage' }, - }, - back: { - image: { url: '/url/back' }, - interpretation: { type: 'BlankPage' }, - }, - }); - - await request(app) - .get(`/central-scanner/scan/hmpb/review/next-sheet`) - .expect( - 200, - typedAs({ - interpreted: { - id: 'mock-review-sheet', - front: { - image: { url: '/url/front' }, - interpretation: { type: 'BlankPage' }, - }, - back: { - image: { url: '/url/back' }, - interpretation: { type: 'BlankPage' }, - }, - }, - layouts: {}, - definitions: {}, - }) - ); -}); - -test('get next sheet layouts', async () => { - const metadata: BallotMetadata = { - ballotHash: - electionGridLayoutNewHampshireTestBallotFixtures.electionDefinition - .ballotHash, - ballotType: BallotType.Precinct, - ballotStyleId: 'card-number-3', - precinctId: 'town-id-00701-precinct-id-default', - isTestMode: false, - }; - const frontInterpretation: InterpretedHmpbPage = { - type: 'InterpretedHmpbPage', - metadata: { - ...metadata, - pageNumber: 1, - }, - markInfo: { - ballotSize: { width: 1, height: 1 }, - marks: [], - }, - adjudicationInfo: { - requiresAdjudication: true, - enabledReasons: [AdjudicationReason.Overvote], - enabledReasonInfos: [ - { - type: AdjudicationReason.Overvote, - contestId: 'contest-id', - expected: 1, - optionIds: ['option-id', 'option-id-2'], - optionIndexes: [0, 1], - }, - ], - ignoredReasonInfos: [], - }, - votes: {}, - layout: { - pageSize: { width: 1, height: 1 }, - metadata: { - ...metadata, - pageNumber: 1, - }, - contests: [], - }, - }; - const backInterpretation: InterpretedHmpbPage = { - ...frontInterpretation, - metadata: { - ...frontInterpretation.metadata, - pageNumber: 2, - }, - }; - jest.spyOn(workspace.store, 'getNextAdjudicationSheet').mockReturnValueOnce({ - id: 'mock-review-sheet', - front: { - image: { url: '/url/front' }, - interpretation: frontInterpretation, - }, - back: { - image: { url: '/url/back' }, - interpretation: backInterpretation, - }, - }); - - const response = await request(app) - .get(`/central-scanner/scan/hmpb/review/next-sheet`) - .expect(200); - - expect(response.body).toEqual({ - interpreted: { - id: 'mock-review-sheet', - front: { - image: { url: '/url/front' }, - interpretation: frontInterpretation, - }, - back: { - image: { url: '/url/back' }, - interpretation: backInterpretation, - }, - }, - layouts: { - front: frontInterpretation.layout, - back: backInterpretation.layout, - }, - definitions: { - front: { contestIds: expect.any(Array) }, - back: { contestIds: expect.any(Array) }, - }, - }); -}); diff --git a/apps/central-scan/backend/src/app.scanning.test.ts b/apps/central-scan/backend/src/app.scanning.test.ts index 30f747ae407..c25f6acae61 100644 --- a/apps/central-scan/backend/src/app.scanning.test.ts +++ b/apps/central-scan/backend/src/app.scanning.test.ts @@ -1,12 +1,19 @@ -import { electionFamousNames2021Fixtures } from '@votingworks/fixtures'; import { + electionFamousNames2021Fixtures, + electionGridLayoutNewHampshireTestBallotFixtures, +} from '@votingworks/fixtures'; +import { + AdjudicationReason, + BallotMetadata, + BallotType, BatchInfo, DEFAULT_SYSTEM_SETTINGS, + InterpretedHmpbPage, TEST_JURISDICTION, } from '@votingworks/types'; import { mockElectionManagerAuth } from '../test/helpers/auth'; -import { withApp } from '../test/helpers/setup_app'; import { generateBmdBallotFixture } from '../test/helpers/ballots'; +import { withApp } from '../test/helpers/setup_app'; import { ScannedSheetInfo } from './fujitsu_scanner'; const jurisdiction = TEST_JURISDICTION; @@ -53,6 +60,134 @@ test('scanBatch with multiple sheets', async () => { }); }); +test('get next sheet', async () => { + await withApp(async ({ workspace, apiClient }) => { + jest + .spyOn(workspace.store, 'getNextAdjudicationSheet') + .mockReturnValueOnce({ + id: 'mock-review-sheet', + front: { + image: { url: '/url/front' }, + interpretation: { type: 'BlankPage' }, + }, + back: { + image: { url: '/url/back' }, + interpretation: { type: 'BlankPage' }, + }, + }); + + expect(await apiClient.nextSheet()).toEqual< + Awaited> + >({ + interpreted: { + id: 'mock-review-sheet', + front: { + image: { url: '/url/front' }, + interpretation: { type: 'BlankPage' }, + }, + back: { + image: { url: '/url/back' }, + interpretation: { type: 'BlankPage' }, + }, + }, + layouts: {}, + definitions: {}, + }); + }); +}); + +test('get next sheet layouts', async () => { + const metadata: BallotMetadata = { + ballotHash: + electionGridLayoutNewHampshireTestBallotFixtures.electionDefinition + .ballotHash, + ballotType: BallotType.Precinct, + ballotStyleId: 'card-number-3', + precinctId: 'town-id-00701-precinct-id-default', + isTestMode: false, + }; + const frontInterpretation: InterpretedHmpbPage = { + type: 'InterpretedHmpbPage', + metadata: { + ...metadata, + pageNumber: 1, + }, + markInfo: { + ballotSize: { width: 1, height: 1 }, + marks: [], + }, + adjudicationInfo: { + requiresAdjudication: true, + enabledReasons: [AdjudicationReason.Overvote], + enabledReasonInfos: [ + { + type: AdjudicationReason.Overvote, + contestId: 'contest-id', + expected: 1, + optionIds: ['option-id', 'option-id-2'], + optionIndexes: [0, 1], + }, + ], + ignoredReasonInfos: [], + }, + votes: {}, + layout: { + pageSize: { width: 1, height: 1 }, + metadata: { + ...metadata, + pageNumber: 1, + }, + contests: [], + }, + }; + const backInterpretation: InterpretedHmpbPage = { + ...frontInterpretation, + metadata: { + ...frontInterpretation.metadata, + pageNumber: 2, + }, + }; + await withApp(async ({ apiClient, workspace }) => { + jest + .spyOn(workspace.store, 'getNextAdjudicationSheet') + .mockReturnValueOnce({ + id: 'mock-review-sheet', + front: { + image: { url: '/url/front' }, + interpretation: frontInterpretation, + }, + back: { + image: { url: '/url/back' }, + interpretation: backInterpretation, + }, + }); + + expect(await apiClient.nextSheet()).toEqual< + Awaited> + >({ + interpreted: { + id: 'mock-review-sheet', + front: { + image: { url: '/url/front' }, + interpretation: frontInterpretation, + }, + back: { + image: { url: '/url/back' }, + interpretation: backInterpretation, + }, + }, + layouts: { + front: frontInterpretation.layout, + back: backInterpretation.layout, + }, + definitions: { + front: { contestIds: expect.any(Array) }, + back: { contestIds: expect.any(Array) }, + }, + }); + }); +}); + test('continueScanning after invalid ballot', async () => { const { electionDefinition } = electionFamousNames2021Fixtures; const ballot = await generateBmdBallotFixture(); diff --git a/apps/central-scan/backend/src/app.ts b/apps/central-scan/backend/src/app.ts index 2441f4e48ba..4b38b7d5c27 100644 --- a/apps/central-scan/backend/src/app.ts +++ b/apps/central-scan/backend/src/app.ts @@ -1,35 +1,38 @@ -import { Scan } from '@votingworks/api'; import { DippedSmartCardAuthApi, generateSignedHashValidationQrCodeValue, } from '@votingworks/auth'; -import { Result, assert, ok } from '@votingworks/basics'; import { createSystemCallApi, DiskSpaceSummary, - readSignedElectionPackageFromUsb, - exportCastVoteRecordsToUsbDrive, ElectionRecord, + exportCastVoteRecordsToUsbDrive, + readSignedElectionPackageFromUsb, } from '@votingworks/backend'; +import { assert, ok, Optional, Result } from '@votingworks/basics'; +import { useDevDockRouter } from '@votingworks/dev-dock-backend'; +import * as grout from '@votingworks/grout'; +import { LogEventId, Logger } from '@votingworks/logging'; import { - ElectionPackageConfigurationError, BallotPageLayout, + BallotSheetInfo, + Contest, DEFAULT_SYSTEM_SETTINGS, + DiagnosticRecord, ElectionDefinition, - SystemSettings, + ElectionPackageConfigurationError, ExportCastVoteRecordsToUsbDriveError, - DiagnosticRecord, + SystemSettings, } from '@votingworks/types'; +import { UsbDrive, UsbDriveStatus } from '@votingworks/usb-drive'; import { isElectionManagerAuth } from '@votingworks/utils'; import express, { Application } from 'express'; -import * as grout from '@votingworks/grout'; -import { LogEventId, Logger } from '@votingworks/logging'; -import { useDevDockRouter } from '@votingworks/dev-dock-backend'; -import { UsbDrive, UsbDriveStatus } from '@votingworks/usb-drive'; +import { performScanDiagnostic, ScanDiagnosticOutcome } from './diagnostic'; +import { BatchScanner } from './fujitsu_scanner'; import { Importer } from './importer'; -import { Workspace } from './util/workspace'; -import { MachineConfig, ScanStatus } from './types'; import { getMachineConfig } from './machine_config'; +import { saveReadinessReport } from './readiness_report'; +import { MachineConfig, ScanStatus } from './types'; import { constructAuthMachineState } from './util/auth'; import { logBatchStartFailure, @@ -37,11 +40,7 @@ import { logScanBatchContinueFailure, logScanBatchContinueSuccess, } from './util/logging'; -import { saveReadinessReport } from './readiness_report'; -import { performScanDiagnostic, ScanDiagnosticOutcome } from './diagnostic'; -import { BatchScanner } from './fujitsu_scanner'; - -type NoParams = never; +import { Workspace } from './util/workspace'; export interface AppOptions { auth: DippedSmartCardAuthApi; @@ -219,6 +218,64 @@ function buildApi({ } }, + nextSheet(): Optional<{ + interpreted: BallotSheetInfo; + layouts: { + front?: BallotPageLayout; + back?: BallotPageLayout; + }; + definitions: { + front?: { + contestIds: ReadonlyArray; + }; + back?: { + contestIds: ReadonlyArray; + }; + }; + }> { + const sheet = store.getNextAdjudicationSheet(); + + if (sheet) { + let frontLayout: BallotPageLayout | undefined; + let backLayout: BallotPageLayout | undefined; + let frontDefinition: Optional<{ + contestIds: ReadonlyArray; + }>; + let backDefinition: Optional<{ + contestIds: ReadonlyArray; + }>; + + if (sheet.front.interpretation.type === 'InterpretedHmpbPage') { + const front = sheet.front.interpretation; + frontLayout = front.layout; + const contestIds = Object.keys(front.votes); + frontDefinition = { contestIds }; + } + + if (sheet.back.interpretation.type === 'InterpretedHmpbPage') { + const back = sheet.back.interpretation; + const contestIds = Object.keys(back.votes); + + backLayout = back.layout; + backDefinition = { contestIds }; + } + + return { + interpreted: sheet, + layouts: { + front: frontLayout, + back: backLayout, + }, + definitions: { + front: frontDefinition, + back: backDefinition, + }, + }; + } + + return undefined; + }, + async unconfigure( input: { ignoreBackupRequirement?: boolean; @@ -358,14 +415,12 @@ export function buildCentralScannerApp({ app.use('/api', grout.buildRouter(api, express)); useDevDockRouter(app, express, 'central-scan'); - const deprecatedApiRouter = express.Router(); - deprecatedApiRouter.use(express.raw()); - deprecatedApiRouter.use( - express.json({ limit: '5mb', type: 'application/json' }) - ); - deprecatedApiRouter.use(express.urlencoded({ extended: false })); + const assetsApi = express.Router(); + assetsApi.use(express.raw()); + assetsApi.use(express.json({ limit: '5mb', type: 'application/json' })); + assetsApi.use(express.urlencoded({ extended: false })); - deprecatedApiRouter.get( + assetsApi.get( '/central-scanner/scan/hmpb/ballot/:sheetId/:side/image', (request, response) => { const { sheetId, side } = request.params; @@ -387,54 +442,7 @@ export function buildCentralScannerApp({ } ); - deprecatedApiRouter.get( - '/central-scanner/scan/hmpb/review/next-sheet', - (_request, response) => { - const sheet = store.getNextAdjudicationSheet(); - - if (sheet) { - let frontLayout: BallotPageLayout | undefined; - let backLayout: BallotPageLayout | undefined; - let frontDefinition: - | Scan.GetNextReviewSheetResponse['definitions']['front'] - | undefined; - let backDefinition: - | Scan.GetNextReviewSheetResponse['definitions']['back'] - | undefined; - - if (sheet.front.interpretation.type === 'InterpretedHmpbPage') { - const front = sheet.front.interpretation; - frontLayout = front.layout; - const contestIds = Object.keys(front.votes); - frontDefinition = { contestIds }; - } - - if (sheet.back.interpretation.type === 'InterpretedHmpbPage') { - const back = sheet.back.interpretation; - const contestIds = Object.keys(back.votes); - - backLayout = back.layout; - backDefinition = { contestIds }; - } - - response.json({ - interpreted: sheet, - layouts: { - front: frontLayout, - back: backLayout, - }, - definitions: { - front: frontDefinition, - back: backDefinition, - }, - }); - } else { - response.status(404).end(); - } - } - ); - - app.use(deprecatedApiRouter); + app.use(assetsApi); return app; } diff --git a/apps/central-scan/frontend/src/api.ts b/apps/central-scan/frontend/src/api.ts index 90263c363e0..15397d574f7 100644 --- a/apps/central-scan/frontend/src/api.ts +++ b/apps/central-scan/frontend/src/api.ts @@ -1,12 +1,3 @@ -import React from 'react'; -import { deepEqual } from '@votingworks/basics'; -import type { Api } from '@votingworks/central-scan-backend'; -import { - AUTH_STATUS_POLLING_INTERVAL_MS, - QUERY_CLIENT_DEFAULT_OPTIONS, - USB_DRIVE_STATUS_POLLING_INTERVAL_MS, - createSystemCallApi, -} from '@votingworks/ui'; import { QueryClient, QueryKey, @@ -14,7 +5,16 @@ import { useQuery, useQueryClient, } from '@tanstack/react-query'; +import { deepEqual } from '@votingworks/basics'; +import type { Api } from '@votingworks/central-scan-backend'; import * as grout from '@votingworks/grout'; +import { + AUTH_STATUS_POLLING_INTERVAL_MS, + QUERY_CLIENT_DEFAULT_OPTIONS, + USB_DRIVE_STATUS_POLLING_INTERVAL_MS, + createSystemCallApi, +} from '@votingworks/ui'; +import React from 'react'; export type ApiClient = grout.Client; @@ -163,6 +163,17 @@ export const getMostRecentScannerDiagnostic = { }, } as const; +export const getNextSheetToReview = { + queryKey(): QueryKey { + return ['getNextSheetToAdjudicate']; + }, + + useQuery() { + const apiClient = useApiClient(); + return useQuery(this.queryKey(), () => apiClient.nextSheet()); + }, +} as const; + // Mutations export const setTestMode = { diff --git a/apps/central-scan/frontend/src/api/hmpb.test.ts b/apps/central-scan/frontend/src/api/hmpb.test.ts deleted file mode 100644 index 754829b1716..00000000000 --- a/apps/central-scan/frontend/src/api/hmpb.test.ts +++ /dev/null @@ -1,34 +0,0 @@ -import fetchMock from 'fetch-mock'; -import { Scan } from '@votingworks/api'; -import { fetchNextBallotSheetToReview } from './hmpb'; - -test('can fetch the next ballot sheet needing review', async () => { - const response: Scan.GetNextReviewSheetResponse = { - interpreted: { - id: 'test-sheet', - front: { - image: { url: '/' }, - interpretation: { type: 'UnreadablePage' }, - }, - back: { - image: { url: '/' }, - interpretation: { type: 'UnreadablePage' }, - }, - }, - layouts: {}, - definitions: {}, - }; - fetchMock.getOnce('/central-scanner/scan/hmpb/review/next-sheet', { - status: 200, - body: response, - }); - await expect(fetchNextBallotSheetToReview()).resolves.toBeDefined(); -}); - -test('returns undefined if there are no ballot sheets to review', async () => { - fetchMock.getOnce('/central-scanner/scan/hmpb/review/next-sheet', { - status: 404, - body: '', - }); - await expect(fetchNextBallotSheetToReview()).resolves.toBeUndefined(); -}); diff --git a/apps/central-scan/frontend/src/api/hmpb.ts b/apps/central-scan/frontend/src/api/hmpb.ts deleted file mode 100644 index 19a71a47f2e..00000000000 --- a/apps/central-scan/frontend/src/api/hmpb.ts +++ /dev/null @@ -1,21 +0,0 @@ -import { Scan } from '@votingworks/api'; -import { unsafeParse } from '@votingworks/types'; - -export async function fetchNextBallotSheetToReview(): Promise< - Scan.GetNextReviewSheetResponse | undefined -> { - const response = await fetch('/central-scanner/scan/hmpb/review/next-sheet'); - - if (response.status === 404) { - return undefined; - } - - if (!response.ok) { - throw new Error('fetch response is not ok'); - } - - return unsafeParse( - Scan.GetNextReviewSheetResponseSchema, - await response.json() - ); -} diff --git a/apps/central-scan/frontend/src/screens/ballot_eject_screen.test.tsx b/apps/central-scan/frontend/src/screens/ballot_eject_screen.test.tsx index 9e552721b62..82a7b5efb8b 100644 --- a/apps/central-scan/frontend/src/screens/ballot_eject_screen.test.tsx +++ b/apps/central-scan/frontend/src/screens/ballot_eject_screen.test.tsx @@ -1,4 +1,7 @@ -import { mockBaseLogger, LogEventId } from '@votingworks/logging'; +import userEvent from '@testing-library/user-event'; +import { electionGeneralDefinition } from '@votingworks/fixtures'; +import { LogEventId, mockBaseLogger } from '@votingworks/logging'; +import { hasTextAcrossElements } from '@votingworks/test-utils'; import { AdjudicationReason, BallotMetadata, @@ -6,16 +9,10 @@ import { DEFAULT_SYSTEM_SETTINGS, formatBallotHash, } from '@votingworks/types'; -import { Scan } from '@votingworks/api'; -import { typedAs } from '@votingworks/basics'; -import fetchMock from 'fetch-mock'; -import userEvent from '@testing-library/user-event'; -import { hasTextAcrossElements } from '@votingworks/test-utils'; -import { electionGeneralDefinition } from '@votingworks/fixtures'; +import { ApiMock, createApiMock } from '../../test/api'; import { screen } from '../../test/react_testing_library'; import { renderInAppContext } from '../../test/render_in_app_context'; import { BallotEjectScreen } from './ballot_eject_screen'; -import { createApiMock, ApiMock } from '../../test/api'; let apiMock: ApiMock; @@ -29,24 +26,21 @@ afterEach(() => { }); test('says the sheet is unreadable if it is', async () => { - fetchMock.getOnce( - '/central-scanner/scan/hmpb/review/next-sheet', - typedAs({ - interpreted: { - id: 'mock-sheet-id', - front: { - image: { url: '/front/url' }, - interpretation: { type: 'BlankPage' }, - }, - back: { - image: { url: '/back/url' }, - interpretation: { type: 'BlankPage' }, - }, + apiMock.apiClient.nextSheet.expectCallWith().resolves({ + interpreted: { + id: 'mock-sheet-id', + front: { + image: { url: '/front/url' }, + interpretation: { type: 'BlankPage' }, }, - layouts: {}, - definitions: {}, - }) - ); + back: { + image: { url: '/back/url' }, + interpretation: { type: 'BlankPage' }, + }, + }, + layouts: {}, + definitions: {}, + }); const logger = mockBaseLogger(); @@ -81,82 +75,79 @@ test('says the ballot sheet is overvoted if it is', async () => { ballotHash: 'abcde', isTestMode: false, }; - fetchMock.getOnce( - '/central-scanner/scan/hmpb/review/next-sheet', - typedAs({ - interpreted: { - id: 'mock-sheet-id', - front: { - image: { url: '/front/url' }, - interpretation: { - type: 'InterpretedHmpbPage', - markInfo: { - ballotSize: { width: 1, height: 1 }, - marks: [], - }, + apiMock.apiClient.nextSheet.expectCallWith().resolves({ + interpreted: { + id: 'mock-sheet-id', + front: { + image: { url: '/front/url' }, + interpretation: { + type: 'InterpretedHmpbPage', + markInfo: { + ballotSize: { width: 1, height: 1 }, + marks: [], + }, + metadata: { + ...metadata, + pageNumber: 1, + }, + adjudicationInfo: { + requiresAdjudication: true, + enabledReasonInfos: [ + { + type: AdjudicationReason.Overvote, + contestId: '1', + optionIds: ['1', '2'], + optionIndexes: [0, 1], + expected: 1, + }, + ], + ignoredReasonInfos: [], + enabledReasons: [AdjudicationReason.Overvote], + }, + votes: {}, + layout: { + pageSize: { width: 1, height: 1 }, metadata: { ...metadata, pageNumber: 1, }, - adjudicationInfo: { - requiresAdjudication: true, - enabledReasonInfos: [ - { - type: AdjudicationReason.Overvote, - contestId: '1', - optionIds: ['1', '2'], - optionIndexes: [0, 1], - expected: 1, - }, - ], - ignoredReasonInfos: [], - enabledReasons: [AdjudicationReason.Overvote], - }, - votes: {}, - layout: { - pageSize: { width: 1, height: 1 }, - metadata: { - ...metadata, - pageNumber: 1, - }, - contests: [], - }, + contests: [], }, }, - back: { - image: { url: '/back/url' }, - interpretation: { - type: 'InterpretedHmpbPage', - markInfo: { - ballotSize: { width: 1, height: 1 }, - marks: [], - }, + }, + back: { + image: { url: '/back/url' }, + interpretation: { + type: 'InterpretedHmpbPage', + markInfo: { + ballotSize: { width: 1, height: 1 }, + marks: [], + }, + metadata: { + ...metadata, + pageNumber: 2, + }, + adjudicationInfo: { + requiresAdjudication: false, + enabledReasonInfos: [], + ignoredReasonInfos: [], + enabledReasons: [AdjudicationReason.Overvote], + }, + votes: {}, + layout: { + pageSize: { width: 1, height: 1 }, metadata: { ...metadata, pageNumber: 2, }, - adjudicationInfo: { - requiresAdjudication: false, - enabledReasonInfos: [], - ignoredReasonInfos: [], - enabledReasons: [AdjudicationReason.Overvote], - }, - votes: {}, - layout: { - pageSize: { width: 1, height: 1 }, - metadata: { - ...metadata, - pageNumber: 2, - }, - contests: [], - }, + contests: [], }, }, }, - layouts: {}, - definitions: {}, - }) - ); + }, + layouts: {}, + definitions: {}, + }); const logger = mockBaseLogger(); @@ -193,82 +184,79 @@ test('says the ballot sheet is undervoted if it is', async () => { ballotHash: 'abcde', isTestMode: false, }; - fetchMock.getOnce( - '/central-scanner/scan/hmpb/review/next-sheet', - typedAs({ - interpreted: { - id: 'mock-sheet-id', - front: { - image: { url: '/front/url' }, - interpretation: { - type: 'InterpretedHmpbPage', - markInfo: { - ballotSize: { width: 1, height: 1 }, - marks: [], - }, + apiMock.apiClient.nextSheet.expectCallWith().resolves({ + interpreted: { + id: 'mock-sheet-id', + front: { + image: { url: '/front/url' }, + interpretation: { + type: 'InterpretedHmpbPage', + markInfo: { + ballotSize: { width: 1, height: 1 }, + marks: [], + }, + metadata: { + ...metadata, + pageNumber: 1, + }, + adjudicationInfo: { + requiresAdjudication: true, + enabledReasonInfos: [ + { + type: AdjudicationReason.Undervote, + contestId: '1', + optionIds: [], + optionIndexes: [], + expected: 1, + }, + ], + ignoredReasonInfos: [], + enabledReasons: [AdjudicationReason.Undervote], + }, + votes: {}, + layout: { + pageSize: { width: 1, height: 1 }, metadata: { ...metadata, pageNumber: 1, }, - adjudicationInfo: { - requiresAdjudication: true, - enabledReasonInfos: [ - { - type: AdjudicationReason.Undervote, - contestId: '1', - optionIds: [], - optionIndexes: [], - expected: 1, - }, - ], - ignoredReasonInfos: [], - enabledReasons: [AdjudicationReason.Undervote], - }, - votes: {}, - layout: { - pageSize: { width: 1, height: 1 }, - metadata: { - ...metadata, - pageNumber: 1, - }, - contests: [], - }, + contests: [], }, }, - back: { - image: { url: '/back/url' }, - interpretation: { - type: 'InterpretedHmpbPage', - markInfo: { - ballotSize: { width: 1, height: 1 }, - marks: [], - }, + }, + back: { + image: { url: '/back/url' }, + interpretation: { + type: 'InterpretedHmpbPage', + markInfo: { + ballotSize: { width: 1, height: 1 }, + marks: [], + }, + metadata: { + ...metadata, + pageNumber: 2, + }, + adjudicationInfo: { + requiresAdjudication: false, + enabledReasonInfos: [], + ignoredReasonInfos: [], + enabledReasons: [AdjudicationReason.Overvote], + }, + votes: {}, + layout: { + pageSize: { width: 1, height: 1 }, metadata: { ...metadata, pageNumber: 2, }, - adjudicationInfo: { - requiresAdjudication: false, - enabledReasonInfos: [], - ignoredReasonInfos: [], - enabledReasons: [AdjudicationReason.Overvote], - }, - votes: {}, - layout: { - pageSize: { width: 1, height: 1 }, - metadata: { - ...metadata, - pageNumber: 2, - }, - contests: [], - }, + contests: [], }, }, }, - layouts: {}, - definitions: {}, - }) - ); + }, + layouts: {}, + definitions: {}, + }); const logger = mockBaseLogger(); @@ -306,89 +294,86 @@ test('says the ballot sheet is blank if it is', async () => { ballotHash: 'abcde', isTestMode: false, }; - fetchMock.getOnce( - '/central-scanner/scan/hmpb/review/next-sheet', - typedAs({ - interpreted: { - id: 'mock-sheet-id', - front: { - image: { url: '/front/url' }, - interpretation: { - type: 'InterpretedHmpbPage', - markInfo: { - ballotSize: { width: 1, height: 1 }, - marks: [], - }, + apiMock.apiClient.nextSheet.expectCallWith().resolves({ + interpreted: { + id: 'mock-sheet-id', + front: { + image: { url: '/front/url' }, + interpretation: { + type: 'InterpretedHmpbPage', + markInfo: { + ballotSize: { width: 1, height: 1 }, + marks: [], + }, + metadata: { + ...metadata, + pageNumber: 1, + }, + adjudicationInfo: { + requiresAdjudication: true, + enabledReasonInfos: [ + { + type: AdjudicationReason.Undervote, + contestId: '1', + expected: 1, + optionIds: [], + optionIndexes: [], + }, + { type: AdjudicationReason.BlankBallot }, + ], + ignoredReasonInfos: [], + enabledReasons: [ + AdjudicationReason.BlankBallot, + AdjudicationReason.Undervote, + ], + }, + votes: {}, + layout: { + pageSize: { width: 1, height: 1 }, metadata: { ...metadata, pageNumber: 1, }, - adjudicationInfo: { - requiresAdjudication: true, - enabledReasonInfos: [ - { - type: AdjudicationReason.Undervote, - contestId: '1', - expected: 1, - optionIds: [], - optionIndexes: [], - }, - { type: AdjudicationReason.BlankBallot }, - ], - ignoredReasonInfos: [], - enabledReasons: [ - AdjudicationReason.BlankBallot, - AdjudicationReason.Undervote, - ], - }, - votes: {}, - layout: { - pageSize: { width: 1, height: 1 }, - metadata: { - ...metadata, - pageNumber: 1, - }, - contests: [], - }, + contests: [], }, }, - back: { - image: { url: '/back/url' }, - interpretation: { - type: 'InterpretedHmpbPage', - markInfo: { - ballotSize: { width: 1, height: 1 }, - marks: [], - }, + }, + back: { + image: { url: '/back/url' }, + interpretation: { + type: 'InterpretedHmpbPage', + markInfo: { + ballotSize: { width: 1, height: 1 }, + marks: [], + }, + metadata: { + ...metadata, + pageNumber: 2, + }, + adjudicationInfo: { + requiresAdjudication: true, + enabledReasonInfos: [{ type: AdjudicationReason.BlankBallot }], + ignoredReasonInfos: [], + enabledReasons: [ + AdjudicationReason.BlankBallot, + AdjudicationReason.Undervote, + ], + }, + votes: {}, + layout: { + pageSize: { width: 1, height: 1 }, metadata: { ...metadata, pageNumber: 2, }, - adjudicationInfo: { - requiresAdjudication: true, - enabledReasonInfos: [{ type: AdjudicationReason.BlankBallot }], - ignoredReasonInfos: [], - enabledReasons: [ - AdjudicationReason.BlankBallot, - AdjudicationReason.Undervote, - ], - }, - votes: {}, - layout: { - pageSize: { width: 1, height: 1 }, - metadata: { - ...metadata, - pageNumber: 2, - }, - contests: [], - }, + contests: [], }, }, }, - layouts: {}, - definitions: {}, - }) - ); + }, + layouts: {}, + definitions: {}, + }); const logger = mockBaseLogger(); @@ -418,44 +403,41 @@ test('says the ballot sheet is blank if it is', async () => { }); test('calls out official ballot sheets in test mode', async () => { - fetchMock.getOnce( - '/central-scanner/scan/hmpb/review/next-sheet', - typedAs({ - interpreted: { - id: 'mock-sheet-id', - front: { - image: { url: '/front/url' }, - interpretation: { - type: 'InvalidTestModePage', - metadata: { - ballotStyleId: '1', - precinctId: '1', - ballotType: BallotType.Precinct, - ballotHash: 'abcde', - isTestMode: false, - pageNumber: 1, - }, + apiMock.apiClient.nextSheet.expectCallWith().resolves({ + interpreted: { + id: 'mock-sheet-id', + front: { + image: { url: '/front/url' }, + interpretation: { + type: 'InvalidTestModePage', + metadata: { + ballotStyleId: '1', + precinctId: '1', + ballotType: BallotType.Precinct, + ballotHash: 'abcde', + isTestMode: false, + pageNumber: 1, }, }, - back: { - image: { url: '/back/url' }, - interpretation: { - type: 'InvalidTestModePage', - metadata: { - ballotStyleId: '1', - precinctId: '1', - ballotType: BallotType.Precinct, - ballotHash: 'abcde', - isTestMode: false, - pageNumber: 2, - }, + }, + back: { + image: { url: '/back/url' }, + interpretation: { + type: 'InvalidTestModePage', + metadata: { + ballotStyleId: '1', + precinctId: '1', + ballotType: BallotType.Precinct, + ballotHash: 'abcde', + isTestMode: false, + pageNumber: 2, }, }, }, - layouts: {}, - definitions: {}, - }) - ); + }, + layouts: {}, + definitions: {}, + }); const logger = mockBaseLogger(); @@ -482,44 +464,41 @@ test('calls out official ballot sheets in test mode', async () => { }); test('calls out test ballot sheets in live mode', async () => { - fetchMock.getOnce( - '/central-scanner/scan/hmpb/review/next-sheet', - typedAs({ - interpreted: { - id: 'mock-sheet-id', - front: { - image: { url: '/front/url' }, - interpretation: { - type: 'InvalidTestModePage', - metadata: { - ballotStyleId: '1', - precinctId: '1', - ballotType: BallotType.Precinct, - ballotHash: 'abcde', - isTestMode: false, - pageNumber: 1, - }, + apiMock.apiClient.nextSheet.expectCallWith().resolves({ + interpreted: { + id: 'mock-sheet-id', + front: { + image: { url: '/front/url' }, + interpretation: { + type: 'InvalidTestModePage', + metadata: { + ballotStyleId: '1', + precinctId: '1', + ballotType: BallotType.Precinct, + ballotHash: 'abcde', + isTestMode: false, + pageNumber: 1, }, }, - back: { - image: { url: '/back/url' }, - interpretation: { - type: 'InvalidTestModePage', - metadata: { - ballotStyleId: '1', - precinctId: '1', - ballotType: BallotType.Precinct, - ballotHash: 'abcde', - isTestMode: false, - pageNumber: 2, - }, + }, + back: { + image: { url: '/back/url' }, + interpretation: { + type: 'InvalidTestModePage', + metadata: { + ballotStyleId: '1', + precinctId: '1', + ballotType: BallotType.Precinct, + ballotHash: 'abcde', + isTestMode: false, + pageNumber: 2, }, }, }, - layouts: {}, - definitions: {}, - }) - ); + }, + layouts: {}, + definitions: {}, + }); const logger = mockBaseLogger(); @@ -549,28 +528,25 @@ test('calls out test ballot sheets in live mode', async () => { }); test('shows invalid election screen when appropriate', async () => { - fetchMock.getOnce( - '/central-scanner/scan/hmpb/review/next-sheet', - typedAs({ - interpreted: { - id: 'mock-sheet-id', - front: { - image: { url: '/front/url' }, - interpretation: { - type: 'InvalidBallotHashPage', - actualBallotHash: 'this-is-a-hash-hooray', - expectedBallotHash: 'something', - }, - }, - back: { - image: { url: '/back/url' }, - interpretation: { type: 'BlankPage' }, + apiMock.apiClient.nextSheet.expectCallWith().resolves({ + interpreted: { + id: 'mock-sheet-id', + front: { + image: { url: '/front/url' }, + interpretation: { + type: 'InvalidBallotHashPage', + actualBallotHash: 'this-is-a-hash-hooray', + expectedBallotHash: 'something', }, }, - layouts: {}, - definitions: {}, - }) - ); + back: { + image: { url: '/back/url' }, + interpretation: { type: 'BlankPage' }, + }, + }, + layouts: {}, + definitions: {}, + }); const logger = mockBaseLogger(); @@ -615,82 +591,79 @@ test('does not allow tabulating the overvote if precinctScanDisallowCastingOverv ballotHash: 'abcde', isTestMode: false, }; - fetchMock.getOnce( - '/central-scanner/scan/hmpb/review/next-sheet', - typedAs({ - interpreted: { - id: 'mock-sheet-id', - front: { - image: { url: '/front/url' }, - interpretation: { - type: 'InterpretedHmpbPage', - markInfo: { - ballotSize: { width: 1, height: 1 }, - marks: [], - }, + apiMock.apiClient.nextSheet.expectCallWith().resolves({ + interpreted: { + id: 'mock-sheet-id', + front: { + image: { url: '/front/url' }, + interpretation: { + type: 'InterpretedHmpbPage', + markInfo: { + ballotSize: { width: 1, height: 1 }, + marks: [], + }, + metadata: { + ...metadata, + pageNumber: 1, + }, + adjudicationInfo: { + requiresAdjudication: true, + enabledReasonInfos: [ + { + type: AdjudicationReason.Overvote, + contestId: '1', + optionIds: ['1', '2'], + optionIndexes: [0, 1], + expected: 1, + }, + ], + ignoredReasonInfos: [], + enabledReasons: [AdjudicationReason.Overvote], + }, + votes: {}, + layout: { + pageSize: { width: 1, height: 1 }, metadata: { ...metadata, pageNumber: 1, }, - adjudicationInfo: { - requiresAdjudication: true, - enabledReasonInfos: [ - { - type: AdjudicationReason.Overvote, - contestId: '1', - optionIds: ['1', '2'], - optionIndexes: [0, 1], - expected: 1, - }, - ], - ignoredReasonInfos: [], - enabledReasons: [AdjudicationReason.Overvote], - }, - votes: {}, - layout: { - pageSize: { width: 1, height: 1 }, - metadata: { - ...metadata, - pageNumber: 1, - }, - contests: [], - }, + contests: [], }, }, - back: { - image: { url: '/back/url' }, - interpretation: { - type: 'InterpretedHmpbPage', - markInfo: { - ballotSize: { width: 1, height: 1 }, - marks: [], - }, + }, + back: { + image: { url: '/back/url' }, + interpretation: { + type: 'InterpretedHmpbPage', + markInfo: { + ballotSize: { width: 1, height: 1 }, + marks: [], + }, + metadata: { + ...metadata, + pageNumber: 2, + }, + adjudicationInfo: { + requiresAdjudication: false, + enabledReasonInfos: [], + ignoredReasonInfos: [], + enabledReasons: [AdjudicationReason.Overvote], + }, + votes: {}, + layout: { + pageSize: { width: 1, height: 1 }, metadata: { ...metadata, pageNumber: 2, }, - adjudicationInfo: { - requiresAdjudication: false, - enabledReasonInfos: [], - ignoredReasonInfos: [], - enabledReasons: [AdjudicationReason.Overvote], - }, - votes: {}, - layout: { - pageSize: { width: 1, height: 1 }, - metadata: { - ...metadata, - pageNumber: 2, - }, - contests: [], - }, + contests: [], }, }, }, - layouts: {}, - definitions: {}, - }) - ); + }, + layouts: {}, + definitions: {}, + }); const logger = mockBaseLogger(); diff --git a/apps/central-scan/frontend/src/screens/ballot_eject_screen.tsx b/apps/central-scan/frontend/src/screens/ballot_eject_screen.tsx index d17dbbfda4d..3c03c59d627 100644 --- a/apps/central-scan/frontend/src/screens/ballot_eject_screen.tsx +++ b/apps/central-scan/frontend/src/screens/ballot_eject_screen.tsx @@ -1,3 +1,4 @@ +import { assert } from '@votingworks/basics'; import { LogEventId } from '@votingworks/logging'; import { AdjudicationReason, @@ -6,8 +7,6 @@ import { Side, formatBallotHash, } from '@votingworks/types'; -import { Scan } from '@votingworks/api'; -import { assert } from '@votingworks/basics'; import { Button, H1, @@ -20,13 +19,16 @@ import { Screen, } from '@votingworks/ui'; import { isElectionManagerAuth } from '@votingworks/utils'; -import React, { useCallback, useContext, useEffect, useState } from 'react'; +import React, { useCallback, useContext, useState } from 'react'; import styled from 'styled-components'; -import { fetchNextBallotSheetToReview } from '../api/hmpb'; +import { + continueScanning, + getNextSheetToReview, + getSystemSettings, +} from '../api'; import { BallotSheetImage } from '../components/ballot_sheet_image'; import { AppContext } from '../contexts/app_context'; import { Header } from '../navigation_screen'; -import { continueScanning, getSystemSettings } from '../api'; const AdjudicationHeader = styled(Header)` position: static; @@ -78,8 +80,6 @@ const SHEET_ADJUDICATION_ERRORS: ReadonlyArray = [ export function BallotEjectScreen({ isTestMode }: Props): JSX.Element | null { const { auth, logger, electionDefinition } = useContext(AppContext); - const [reviewInfo, setReviewInfo] = - useState(); const [ballotState, setBallotState] = useState(); function ResetBallotState() { setBallotState(undefined); @@ -90,6 +90,8 @@ export function BallotEjectScreen({ isTestMode }: Props): JSX.Element | null { const systemSettingsQuery = getSystemSettings.useQuery(); const continueScanningMutation = continueScanning.useMutation(); + const getNextSheetToReviewQuery = getNextSheetToReview.useQuery(); + const reviewInfo = getNextSheetToReviewQuery.data; function removeBallotAndContinueScanning() { continueScanningMutation.mutate({ forceAccept: false }); @@ -99,12 +101,6 @@ export function BallotEjectScreen({ isTestMode }: Props): JSX.Element | null { continueScanningMutation.mutate({ forceAccept: true }); } - useEffect(() => { - void (async () => { - setReviewInfo(await fetchNextBallotSheetToReview()); - })(); - }, []); - // eslint-disable-next-line react-hooks/exhaustive-deps const contestIdsWithIssues = new Set(); diff --git a/libs/api/src/index.ts b/libs/api/src/index.ts index 4eb8a8e4f2b..532082f3d51 100644 --- a/libs/api/src/index.ts +++ b/libs/api/src/index.ts @@ -1,3 +1,2 @@ export * from './base'; -export * as Scan from './services/scan'; export * as PrintScan from './services/print-scan'; diff --git a/libs/api/src/services/scan/index.ts b/libs/api/src/services/scan/index.ts deleted file mode 100644 index 69b6a0ca317..00000000000 --- a/libs/api/src/services/scan/index.ts +++ /dev/null @@ -1,46 +0,0 @@ -import { - BallotPageLayout, - BallotPageLayoutSchema, - BallotSheetInfo, - BallotSheetInfoSchema, - Contest, - IdSchema, -} from '@votingworks/types'; -import * as z from 'zod'; - -/** - * @url /scan/hmpb/review/next-sheet - * @method GET - */ -export interface GetNextReviewSheetResponse { - interpreted: BallotSheetInfo; - layouts: { - front?: BallotPageLayout; - back?: BallotPageLayout; - }; - definitions: { - front?: { - contestIds: ReadonlyArray; - }; - back?: { - contestIds: ReadonlyArray; - }; - }; -} - -/** - * @url /scan/hmpb/review/next-sheet - * @method GET - */ -export const GetNextReviewSheetResponseSchema: z.ZodSchema = - z.object({ - interpreted: BallotSheetInfoSchema, - layouts: z.object({ - front: BallotPageLayoutSchema.optional(), - back: BallotPageLayoutSchema.optional(), - }), - definitions: z.object({ - front: z.object({ contestIds: z.array(IdSchema) }).optional(), - back: z.object({ contestIds: z.array(IdSchema) }).optional(), - }), - });