diff --git a/apps/mark-scan/backend/schema.sql b/apps/mark-scan/backend/schema.sql index 6d439a01f..4fbf453d8 100644 --- a/apps/mark-scan/backend/schema.sql +++ b/apps/mark-scan/backend/schema.sql @@ -3,6 +3,7 @@ create table election ( id integer primary key check (id = 1), election_data text not null, jurisdiction text not null, + precinct_selection text, created_at timestamp not null default current_timestamp ); diff --git a/apps/mark-scan/backend/src/app.test.ts b/apps/mark-scan/backend/src/app.test.ts index 87ab53cdc..8cef45341 100644 --- a/apps/mark-scan/backend/src/app.test.ts +++ b/apps/mark-scan/backend/src/app.test.ts @@ -1,6 +1,7 @@ import { assert } from '@votingworks/basics'; import { electionFamousNames2021Fixtures, + electionMinimalExhaustiveSampleSinglePrecinctDefinition, electionSampleDefinition, systemSettings, } from '@votingworks/fixtures'; @@ -11,7 +12,10 @@ import { suppressingConsoleOutput, } from '@votingworks/test-utils'; import { InsertedSmartCardAuthApi } from '@votingworks/auth'; -import { safeParseSystemSettings } from '@votingworks/utils'; +import { + safeParseSystemSettings, + singlePrecinctSelectionFor, +} from '@votingworks/utils'; import { Buffer } from 'buffer'; import { createBallotPackageZipArchive, MockUsb } from '@votingworks/backend'; @@ -19,7 +23,9 @@ import { Server } from 'http'; import * as grout from '@votingworks/grout'; import { DEFAULT_SYSTEM_SETTINGS, + ElectionDefinition, safeParseJson, + SinglePrecinctSelection, SystemSettingsSchema, } from '@votingworks/types'; import { createApp } from '../test/app_helpers'; @@ -46,6 +52,36 @@ afterEach(() => { server?.close(); }); +async function setUpUsbAndConfigureElection( + electionDefinition: ElectionDefinition +) { + const zipBuffer = await createBallotPackageZipArchive({ + electionDefinition, + systemSettings: safeParseJson( + systemSettings.asText(), + SystemSettingsSchema + ).unsafeUnwrap(), + }); + mockUsb.insertUsbDrive({ + 'ballot-packages': { + 'test-ballot-package.zip': zipBuffer, + }, + }); + + const writeResult = await apiClient.configureBallotPackageFromUsb(); + assert(writeResult.isOk()); +} + +function mockElectionManagerAuth(electionDefinition: ElectionDefinition) { + mockOf(mockAuth.getAuthStatus).mockImplementation(() => + Promise.resolve({ + status: 'logged_in', + user: fakeElectionManagerUser(electionDefinition), + sessionExpiresAt: fakeSessionExpiresAt(), + }) + ); +} + test('uses machine config from env', async () => { const originalEnv = process.env; process.env = { @@ -72,33 +108,29 @@ test('uses default machine config if not set', async () => { }); }); -test('configureBallotPackageFromUsb reads to and writes from store', async () => { +test('precinct selection can be written/read to/from store', async () => { const { electionDefinition } = electionFamousNames2021Fixtures; + mockElectionManagerAuth(electionDefinition); + await setUpUsbAndConfigureElection(electionDefinition); - // Mock election manager - mockOf(mockAuth.getAuthStatus).mockImplementation(() => - Promise.resolve({ - status: 'logged_in', - user: fakeElectionManagerUser(electionDefinition), - sessionExpiresAt: fakeSessionExpiresAt(), - }) - ); + let precinctSelectionFromStore = await apiClient.getPrecinctSelection(); + expect(precinctSelectionFromStore.unsafeUnwrap()).toEqual(undefined); - const zipBuffer = await createBallotPackageZipArchive({ - electionDefinition, - systemSettings: safeParseJson( - systemSettings.asText(), - SystemSettingsSchema - ).unsafeUnwrap(), - }); - mockUsb.insertUsbDrive({ - 'ballot-packages': { - 'test-ballot-package.zip': zipBuffer, - }, + const precinct = electionDefinition.election.precincts[0].id; + const precinctSelection = singlePrecinctSelectionFor(precinct); + await apiClient.setPrecinctSelection({ + precinctSelection, }); - const writeResult = await apiClient.configureBallotPackageFromUsb(); - assert(writeResult.isOk()); + precinctSelectionFromStore = await apiClient.getPrecinctSelection(); + expect(precinctSelectionFromStore.unsafeUnwrap()).toEqual(precinctSelection); +}); + +test('configureBallotPackageFromUsb reads to and writes from store', async () => { + const { electionDefinition } = electionFamousNames2021Fixtures; + + mockElectionManagerAuth(electionDefinition); + await setUpUsbAndConfigureElection(electionDefinition); const readResult = await apiClient.getSystemSettings(); expect(readResult).toEqual( @@ -108,36 +140,56 @@ test('configureBallotPackageFromUsb reads to and writes from store', async () => expect(electionDefinitionResult).toEqual(electionDefinition); }); +test('configureBallotPackageFromUsb automatically writes precinct selection if only 1 option', async () => { + const electionDefinition = + electionMinimalExhaustiveSampleSinglePrecinctDefinition; + assert( + electionDefinition.election.precincts.length === 1, + 'Expected election to have exactly 1 precinct' + ); + + mockElectionManagerAuth(electionDefinition); + await setUpUsbAndConfigureElection(electionDefinition); + + const precinctSelection = ( + await apiClient.getPrecinctSelection() + ).unsafeUnwrap(); + assert(precinctSelection, 'Expected precinct selection to be defined'); + expect((precinctSelection as SinglePrecinctSelection).precinctId).toEqual( + electionDefinition.election.precincts[0].id + ); +}); + +test('configureBallotPackageFromUsb does not automatically write precinct selection if > 1 option', async () => { + const { electionDefinition } = electionFamousNames2021Fixtures; + assert( + electionDefinition.election.precincts.length > 1, + 'Expected election to have > 1 precinct' + ); + + mockElectionManagerAuth(electionDefinition); + await setUpUsbAndConfigureElection(electionDefinition); + + const precinctSelection = ( + await apiClient.getPrecinctSelection() + ).unsafeUnwrap(); + expect(precinctSelection).toBeUndefined(); +}); + test('unconfigureMachine deletes system settings and election definition', async () => { const { electionDefinition } = electionFamousNames2021Fixtures; - // Mock election manager - mockOf(mockAuth.getAuthStatus).mockImplementation(() => - Promise.resolve({ - status: 'logged_in', - user: fakeElectionManagerUser(electionDefinition), - sessionExpiresAt: fakeSessionExpiresAt(), - }) + mockElectionManagerAuth(electionDefinition); + mockElectionManagerAuth(electionDefinition); + let readResult = await apiClient.getSystemSettings(); + expect(readResult).toEqual( + safeParseSystemSettings(systemSettings.asText()).unsafeUnwrap() ); - const zipBuffer = await createBallotPackageZipArchive({ - electionDefinition, - systemSettings: safeParseJson( - systemSettings.asText(), - SystemSettingsSchema - ).unsafeUnwrap(), - }); - mockUsb.insertUsbDrive({ - 'ballot-packages': { - 'test-ballot-package.zip': zipBuffer, - }, - }); - - const writeResult = await apiClient.configureBallotPackageFromUsb(); - assert(writeResult.isOk(), `writeResult not ok: ${writeResult.err()}`); + await setUpUsbAndConfigureElection(electionDefinition); await apiClient.unconfigureMachine(); - const readResult = await apiClient.getSystemSettings(); + readResult = await apiClient.getSystemSettings(); expect(readResult).toEqual(DEFAULT_SYSTEM_SETTINGS); const electionDefinitionResult = await apiClient.getElectionDefinition(); expect(electionDefinitionResult).toBeNull(); diff --git a/apps/mark-scan/backend/src/app.ts b/apps/mark-scan/backend/src/app.ts index 32c1335bb..2e98cb0cc 100644 --- a/apps/mark-scan/backend/src/app.ts +++ b/apps/mark-scan/backend/src/app.ts @@ -4,7 +4,7 @@ import { InsertedSmartCardAuthApi, InsertedSmartCardAuthMachineState, } from '@votingworks/auth'; -import { assert, ok, Result } from '@votingworks/basics'; +import { assert, ok, Optional, Result } from '@votingworks/basics'; import * as grout from '@votingworks/grout'; import { Buffer } from 'buffer'; import { @@ -15,8 +15,13 @@ import { SystemSettings, DEFAULT_SYSTEM_SETTINGS, TEST_JURISDICTION, + PrecinctSelection, + AllPrecinctsSelection, } from '@votingworks/types'; -import { isElectionManagerAuth } from '@votingworks/utils'; +import { + isElectionManagerAuth, + singlePrecinctSelectionFor, +} from '@votingworks/utils'; import { Usb, readBallotPackageFromUsb } from '@votingworks/backend'; import { Logger } from '@votingworks/logging'; @@ -103,6 +108,16 @@ function buildApi( return workspace.store.getSystemSettings() ?? DEFAULT_SYSTEM_SETTINGS; }, + setPrecinctSelection(input: { + precinctSelection: PrecinctSelection | AllPrecinctsSelection; + }): void { + workspace.store.setPrecinctSelection(input.precinctSelection); + }, + + getPrecinctSelection(): Result, Error> { + return ok(workspace.store.getPrecinctSelection()); + }, + unconfigureMachine() { workspace.store.setElectionAndJurisdiction(undefined); workspace.store.deleteSystemSettings(); @@ -160,6 +175,13 @@ function buildApi( }); workspace.store.setSystemSettings(systemSettings); + const { precincts } = electionDefinition.election; + if (precincts.length === 1) { + workspace.store.setPrecinctSelection( + singlePrecinctSelectionFor(precincts[0].id) + ); + } + return ok(electionDefinition); }, diff --git a/apps/mark-scan/backend/src/store.ts b/apps/mark-scan/backend/src/store.ts index 24e07f577..0ffc17c1b 100644 --- a/apps/mark-scan/backend/src/store.ts +++ b/apps/mark-scan/backend/src/store.ts @@ -2,10 +2,14 @@ // The durable datastore for configuration info. // +import { Optional } from '@votingworks/basics'; import { Client as DbClient } from '@votingworks/db'; import { ElectionDefinition, + PrecinctSelection, + PrecinctSelectionSchema, safeParseElectionDefinition, + safeParseJson, SystemSettings, SystemSettingsDbRow, } from '@votingworks/types'; @@ -91,6 +95,47 @@ export class Store { return electionRow?.jurisdiction; } + /** + * Sets the current precinct `mark-scan` is printing and interpreting ballots for. + */ + setPrecinctSelection(precinctSelection?: PrecinctSelection): void { + if (!this.hasElection()) { + throw new Error('Cannot set precinct selection without an election.'); + } + + this.client.run( + 'update election set precinct_selection = ?', + precinctSelection ? JSON.stringify(precinctSelection) : null + ); + } + + /** + * Gets the current precinct `scan` is accepting ballots for. + */ + getPrecinctSelection(): Optional { + const electionRow = this.client.one( + 'select precinct_selection as rawPrecinctSelection from election' + ) as { rawPrecinctSelection: string } | undefined; + + const rawPrecinctSelection = electionRow?.rawPrecinctSelection; + + if (!rawPrecinctSelection) { + // precinct selection is undefined when there is no election + return undefined; + } + + const precinctSelectionParseResult = safeParseJson( + rawPrecinctSelection, + PrecinctSelectionSchema + ); + + if (precinctSelectionParseResult.isErr()) { + throw new Error('Unable to parse stored precinct selection.'); + } + + return precinctSelectionParseResult.ok(); + } + /** * Sets the current election definition and jurisdiction. */ diff --git a/apps/mark-scan/frontend/src/api.ts b/apps/mark-scan/frontend/src/api.ts index 6b9e4523e..168762892 100644 --- a/apps/mark-scan/frontend/src/api.ts +++ b/apps/mark-scan/frontend/src/api.ts @@ -56,6 +56,16 @@ export const getElectionDefinition = { }, } as const; +export const getPrecinctSelection = { + queryKey(): QueryKey { + return ['getPrecinctSelection']; + }, + useQuery() { + const apiClient = useApiClient(); + return useQuery(this.queryKey(), () => apiClient.getPrecinctSelection()); + }, +} as const; + /* istanbul ignore next */ export const getSystemSettings = { queryKey(): QueryKey { @@ -184,6 +194,7 @@ export const unconfigureMachine = { async onSuccess() { await queryClient.invalidateQueries(getElectionDefinition.queryKey()); await queryClient.invalidateQueries(getSystemSettings.queryKey()); + await queryClient.invalidateQueries(getPrecinctSelection.queryKey()); }, }); }, @@ -200,3 +211,15 @@ export const printBallot = { }); }, } as const; + +export const setPrecinctSelection = { + useMutation() { + const apiClient = useApiClient(); + const queryClient = useQueryClient(); + return useMutation(apiClient.setPrecinctSelection, { + async onSuccess() { + await queryClient.invalidateQueries(getPrecinctSelection.queryKey()); + }, + }); + }, +} as const; diff --git a/apps/mark-scan/frontend/src/apimachine_config.test.tsx b/apps/mark-scan/frontend/src/apimachine_config.test.tsx index 7cd20dbc8..d13bdd779 100644 --- a/apps/mark-scan/frontend/src/apimachine_config.test.tsx +++ b/apps/mark-scan/frontend/src/apimachine_config.test.tsx @@ -28,11 +28,12 @@ test('machineConfig is fetched from api client by default', async () => { codeVersion: 'fake-code-version', }); const storage = new MemoryStorage(); - await setElectionInStorage( - storage, - electionFamousNames2021Fixtures.electionDefinition - ); + const { electionDefinition } = electionFamousNames2021Fixtures; + await setElectionInStorage(storage, electionDefinition); await setStateInStorage(storage); + apiMock.expectGetPrecinctSelectionResolvesDefault( + electionDefinition.election + ); render( apiMock.expectGetSystemSettings(); apiMock.expectGetElectionDefinition(null); apiMock.expectGetMachineConfigToError(); + apiMock.expectGetPrecinctSelection(); const storage = new MemoryStorage(); const hardware = MemoryHardware.buildStandard(); await suppressingConsoleOutput(async () => { @@ -85,6 +82,7 @@ it('prevents context menus from appearing', async () => { apiMock.expectGetMachineConfig(); apiMock.expectGetSystemSettings(); apiMock.expectGetElectionDefinition(null); + apiMock.expectGetPrecinctSelection(); render(); const { oncontextmenu } = window; @@ -106,6 +104,7 @@ it('uses kiosk storage when in kiosk-browser', async () => { apiMock.expectGetMachineConfig(); apiMock.expectGetSystemSettings(); apiMock.expectGetElectionDefinition(null); + apiMock.expectGetPrecinctSelection(); window.kiosk = kiosk; render(); await advanceTimersAndPromises(); @@ -118,6 +117,7 @@ it('changes screen reader settings based on keyboard inputs', async () => { apiMock.expectGetMachineConfig(); apiMock.expectGetSystemSettings(); apiMock.expectGetElectionDefinition(null); + apiMock.expectGetPrecinctSelection(); const screenReader = new AriaScreenReader(mockTts); jest.spyOn(screenReader, 'toggle'); jest.spyOn(screenReader, 'changeVolume'); @@ -145,6 +145,7 @@ it('uses display settings management hook', async () => { apiMock.expectGetMachineConfig(); apiMock.expectGetSystemSettings(); apiMock.expectGetElectionDefinition(null); + apiMock.expectGetPrecinctSelection(); const { storage, renderApp } = buildApp(apiMock); await setElectionInStorage(storage); @@ -174,10 +175,12 @@ it('uses window.location.reload by default', async () => { const electionDefinition = electionSampleDefinition; const hardware = MemoryHardware.buildStandard(); const storage = new MemoryStorage(); + apiMock.expectGetPrecinctSelectionResolvesDefault( + electionDefinition.election + ); await setElectionInStorage(storage, electionDefinition); await setStateInStorage(storage, { - appPrecinct: ALL_PRECINCTS_SELECTION, pollsState: 'polls_closed_initial', }); diff --git a/apps/mark-scan/frontend/src/app_ballot_package_config.test.tsx b/apps/mark-scan/frontend/src/app_ballot_package_config.test.tsx index f6b645c29..94a5dda17 100644 --- a/apps/mark-scan/frontend/src/app_ballot_package_config.test.tsx +++ b/apps/mark-scan/frontend/src/app_ballot_package_config.test.tsx @@ -26,6 +26,7 @@ test('renders an error if ballot package config endpoint returns an error', asyn screenOrientation: 'portrait', }); apiMock.expectGetSystemSettings(); + apiMock.expectGetPrecinctSelection(); apiMock.expectGetElectionDefinition(null); apiMock.setAuthStatusElectionManagerLoggedIn(electionSampleDefinition); diff --git a/apps/mark-scan/frontend/src/app_card_unhappy_paths.test.tsx b/apps/mark-scan/frontend/src/app_card_unhappy_paths.test.tsx index 6b1ab2495..ff193ffaa 100644 --- a/apps/mark-scan/frontend/src/app_card_unhappy_paths.test.tsx +++ b/apps/mark-scan/frontend/src/app_card_unhappy_paths.test.tsx @@ -5,6 +5,7 @@ import { App } from './app'; import { advanceTimersAndPromises } from '../test/helpers/timers'; import { + election, setElectionInStorage, setStateInStorage, } from '../test/helpers/election'; @@ -31,6 +32,7 @@ test('Shows card backwards screen when card connection error occurs', async () = await setElectionInStorage(storage); await setStateInStorage(storage); + apiMock.expectGetPrecinctSelectionResolvesDefault(election); render( { apiMock.expectGetMachineConfig(); apiMock.expectGetSystemSettings(); apiMock.expectGetElectionDefinition(null); + apiMock.expectGetPrecinctSelection(); render( { within(precinctSelect).getByText( 'Center Springfield' ).value; + const precinctSelection = singlePrecinctSelectionFor(precinctId); + apiMock.expectSetPrecinctSelection(precinctSelection); + apiMock.expectGetPrecinctSelection(precinctSelection); fireEvent.change(precinctSelect, { target: { value: precinctId } }); - within(screen.getByTestId('electionInfoBar')).getByText(/Center Springfield/); + await within(screen.getByTestId('electionInfoBar')).findByText( + /Center Springfield/ + ); fireEvent.click( screen.getByRole('option', { @@ -272,6 +284,7 @@ test('Another Voter submits blank ballot and clicks Done', async () => { apiMock.expectGetMachineConfig(); apiMock.expectGetSystemSettings(); apiMock.expectGetElectionDefinition(null); + apiMock.expectGetPrecinctSelectionResolvesDefault(election); await setElectionInStorage(storage, electionSampleDefinition); await setStateInStorage(storage); @@ -365,6 +378,7 @@ test('poll worker must select a precinct first', async () => { apiMock.expectGetMachineConfig(); apiMock.expectGetSystemSettings(); apiMock.expectGetElectionDefinition(null); + apiMock.expectGetPrecinctSelection(undefined); render( { const precinctSelect = screen.getByLabelText('Precinct'); const precinctId = within(precinctSelect).getByText('All Precincts').value; + apiMock.expectSetPrecinctSelection(ALL_PRECINCTS_SELECTION); + apiMock.expectGetPrecinctSelection(ALL_PRECINCTS_SELECTION); fireEvent.change(precinctSelect, { target: { value: precinctId } }); - within(screen.getByTestId('electionInfoBar')).getByText(/All Precincts/); + await within(screen.getByTestId('electionInfoBar')).findByText( + /All Precincts/ + ); fireEvent.click( screen.getByRole('option', { diff --git a/apps/mark-scan/frontend/src/app_contest_candidate_no_party.test.tsx b/apps/mark-scan/frontend/src/app_contest_candidate_no_party.test.tsx index 912aa1e7e..e18bf4034 100644 --- a/apps/mark-scan/frontend/src/app_contest_candidate_no_party.test.tsx +++ b/apps/mark-scan/frontend/src/app_contest_candidate_no_party.test.tsx @@ -54,6 +54,7 @@ it('Single Seat Contest', async () => { const hardware = MemoryHardware.buildStandard(); const storage = new MemoryStorage(); apiMock.expectGetMachineConfig(); + apiMock.expectGetPrecinctSelectionResolvesDefault(election); await storage.set( electionStorageKey, diff --git a/apps/mark-scan/frontend/src/app_contest_ms_either_neither.test.tsx b/apps/mark-scan/frontend/src/app_contest_ms_either_neither.test.tsx index cc67f3dd7..d867c0fb2 100644 --- a/apps/mark-scan/frontend/src/app_contest_ms_either_neither.test.tsx +++ b/apps/mark-scan/frontend/src/app_contest_ms_either_neither.test.tsx @@ -35,6 +35,8 @@ import { import { ApiMock, createApiMock } from '../test/helpers/mock_api_client'; let apiMock: ApiMock; +const electionDefinition = electionWithMsEitherNeitherDefinition; +const { election } = electionDefinition; beforeEach(() => { jest.useFakeTimers(); @@ -46,8 +48,6 @@ afterEach(() => { apiMock.mockApiClient.assertComplete(); }); -const electionDefinition = electionWithMsEitherNeitherDefinition; -const { election } = electionDefinition; const eitherNeitherContestId = '750000015'; const pickOneContestId = '750000016'; const eitherNeitherContest = find( @@ -211,17 +211,15 @@ test('Renders Ballot with EitherNeither: blank & secondOption', async () => { test('Can vote on a Mississippi Either Neither Contest', async () => { // ====================== BEGIN CONTEST SETUP ====================== // - const hardware = MemoryHardware.buildStandard(); const storage = new MemoryStorage(); apiMock.expectGetMachineConfig(); apiMock.expectGetSystemSettings(); apiMock.expectGetElectionDefinition(null); + apiMock.expectGetPrecinctSelection(singlePrecinctSelectionFor(precinctId)); await setElectionInStorage(storage, electionDefinition); - await setStateInStorage(storage, { - appPrecinct: singlePrecinctSelectionFor(precinctId), - }); + await setStateInStorage(storage); render( { reload={jest.fn()} /> ); - await advanceTimersAndPromises(); // Start voter session apiMock.setAuthStatusCardlessVoterLoggedIn({ ballotStyleId: '2', - precinctId: '6526', + precinctId, }); + await advanceTimersAndPromises(); // Go to First Contest userEvent.click(await screen.findByText('Start Voting')); diff --git a/apps/mark-scan/frontend/src/app_contest_multi_seat.test.tsx b/apps/mark-scan/frontend/src/app_contest_multi_seat.test.tsx index b2943021b..5242cf5c1 100644 --- a/apps/mark-scan/frontend/src/app_contest_multi_seat.test.tsx +++ b/apps/mark-scan/frontend/src/app_contest_multi_seat.test.tsx @@ -7,6 +7,7 @@ import { App } from './app'; import { advanceTimersAndPromises } from '../test/helpers/timers'; import { + election, countyCommissionersContest, setElectionInStorage, setStateInStorage, @@ -36,6 +37,7 @@ it('Single Seat Contest', async () => { await setElectionInStorage(storage); await setStateInStorage(storage); + apiMock.expectGetPrecinctSelectionResolvesDefault(election); const { container } = render( { await setElectionInStorage(storage); await setStateInStorage(storage); + apiMock.expectGetPrecinctSelectionResolvesDefault(election); const { container } = render( { await setElectionInStorage(storage); await setStateInStorage(storage); + apiMock.expectGetPrecinctSelectionResolvesDefault(election); const { container } = render( { await setElectionInStorage(storage); await setStateInStorage(storage); + apiMock.expectGetPrecinctSelectionResolvesDefault(election); apiMock.expectGetMachineConfig(); render( diff --git a/apps/mark-scan/frontend/src/app_data.test.tsx b/apps/mark-scan/frontend/src/app_data.test.tsx index adbb98321..e3c547cbb 100644 --- a/apps/mark-scan/frontend/src/app_data.test.tsx +++ b/apps/mark-scan/frontend/src/app_data.test.tsx @@ -28,6 +28,7 @@ afterEach(() => { describe('loads election', () => { it('Machine is not configured by default', async () => { + apiMock.expectGetPrecinctSelection(); const { renderApp } = buildApp(apiMock); renderApp(); @@ -38,6 +39,7 @@ describe('loads election', () => { }); it('from storage', async () => { + apiMock.expectGetPrecinctSelectionResolvesDefault(election); const { storage, renderApp } = buildApp(apiMock); await setElectionInStorage(storage); await setStateInStorage(storage); diff --git a/apps/mark-scan/frontend/src/app_end_to_end.test.tsx b/apps/mark-scan/frontend/src/app_end_to_end.test.tsx index 0082f8de0..e5d802598 100644 --- a/apps/mark-scan/frontend/src/app_end_to_end.test.tsx +++ b/apps/mark-scan/frontend/src/app_end_to_end.test.tsx @@ -4,10 +4,15 @@ import { fakeKiosk, expectPrintToPdf, } from '@votingworks/test-utils'; -import { MemoryStorage, MemoryHardware } from '@votingworks/utils'; +import { + MemoryStorage, + MemoryHardware, + singlePrecinctSelectionFor, +} from '@votingworks/utils'; import { fakeLogger } from '@votingworks/logging'; import { getContestDistrictName } from '@votingworks/types'; import { electionSampleDefinition } from '@votingworks/fixtures'; +import { assert } from '@votingworks/basics'; import { render, screen, waitFor, within } from '../test/react_testing_library'; import { App } from './app'; @@ -52,6 +57,7 @@ test('MarkAndPrint end-to-end flow', async () => { apiMock.expectGetMachineConfig({ screenOrientation: 'portrait', }); + apiMock.expectGetPrecinctSelection(); const expectedElectionHash = electionDefinition.electionHash.substring(0, 10); const reload = jest.fn(); apiMock.expectGetSystemSettings(); @@ -125,18 +131,35 @@ test('MarkAndPrint end-to-end flow', async () => { // Select precinct screen.getByText('State of Hamilton'); + const precinctName = 'Center Springfield'; + const precinct = electionDefinition.election.precincts.find( + (_precinct) => _precinct.name === precinctName + ); + assert(precinct, `Expected to find a precinct for ${precinctName}`); + const precinctSelection = singlePrecinctSelectionFor(precinct.id); + // TODO(kevin) + // This set operation is called twice with the same value. Not a risk + // but we should figure out why when time allows. + apiMock.expectSetPrecinctSelectionRepeated(precinctSelection); + // Expect one call for each rerender from here + apiMock.expectGetPrecinctSelection(precinctSelection); userEvent.selectOptions( screen.getByLabelText('Precinct'), - screen.getByText('Center Springfield') + screen.getByText(precinctName) + ); + await advanceTimersAndPromises(); + await within(screen.getByTestId('electionInfoBar')).findByText( + /Center Springfield/ ); - within(screen.getByTestId('electionInfoBar')).getByText(/Center Springfield/); + apiMock.expectGetPrecinctSelection(precinctSelection); userEvent.click( screen.getByRole('option', { name: 'Official Ballot Mode', selected: false, }) ); + screen.getByRole('option', { name: 'Official Ballot Mode', selected: true }); // Remove card @@ -316,6 +339,7 @@ test('MarkAndPrint end-to-end flow', async () => { apiMock.expectUnconfigureMachine(); apiMock.expectGetSystemSettings(); apiMock.expectGetElectionDefinition(null); + apiMock.expectGetPrecinctSelection(); userEvent.click(screen.getByText('Unconfigure Machine')); await advanceTimersAndPromises(); @@ -350,6 +374,7 @@ test('MarkAndPrint end-to-end flow', async () => { apiMock.expectUnconfigureMachine(); apiMock.expectGetSystemSettings(); apiMock.expectGetElectionDefinition(null); + apiMock.expectGetPrecinctSelection(); userEvent.click( within(modal).getByRole('button', { name: 'Yes, Delete Election Data', diff --git a/apps/mark-scan/frontend/src/app_polls_flows.test.tsx b/apps/mark-scan/frontend/src/app_polls_flows.test.tsx index e79d4d29b..cab14f7ba 100644 --- a/apps/mark-scan/frontend/src/app_polls_flows.test.tsx +++ b/apps/mark-scan/frontend/src/app_polls_flows.test.tsx @@ -32,6 +32,9 @@ test('full polls flow', async () => { apiMock.expectGetMachineConfig(); apiMock.expectGetSystemSettings(); apiMock.expectGetElectionDefinition(null); + apiMock.expectGetPrecinctSelectionResolvesDefault( + electionSampleDefinition.election + ); const { renderApp, storage, logger } = buildApp(apiMock); await setElectionInStorage(storage, electionSampleDefinition); await setStateInStorage(storage, { pollsState: 'polls_closed_initial' }); @@ -108,6 +111,9 @@ test('full polls flow', async () => { }); test('can close from paused', async () => { + apiMock.expectGetPrecinctSelectionResolvesDefault( + electionSampleDefinition.election + ); const { renderApp, storage } = buildApp(apiMock); await setElectionInStorage(storage, electionSampleDefinition); await setStateInStorage(storage, { pollsState: 'polls_paused' }); @@ -129,6 +135,9 @@ test('can close from paused', async () => { }); test('no buttons to change polls from closed final', async () => { + apiMock.expectGetPrecinctSelectionResolvesDefault( + electionSampleDefinition.election + ); const { renderApp, storage } = buildApp(apiMock); await setElectionInStorage(storage, electionSampleDefinition); await setStateInStorage(storage, { pollsState: 'polls_closed_final' }); @@ -150,6 +159,9 @@ test('no buttons to change polls from closed final', async () => { }); test('can reset polls to paused with system administrator card', async () => { + apiMock.expectGetPrecinctSelectionResolvesDefault( + electionSampleDefinition.election + ); const { renderApp, storage } = buildApp(apiMock); await setElectionInStorage(storage, electionSampleDefinition); await setStateInStorage(storage, { pollsState: 'polls_closed_final' }); diff --git a/apps/mark-scan/frontend/src/app_quit_on_idle.test.tsx b/apps/mark-scan/frontend/src/app_quit_on_idle.test.tsx index afb5610eb..813e61d72 100644 --- a/apps/mark-scan/frontend/src/app_quit_on_idle.test.tsx +++ b/apps/mark-scan/frontend/src/app_quit_on_idle.test.tsx @@ -1,7 +1,6 @@ import { fakeKiosk } from '@votingworks/test-utils'; import { MemoryStorage, MemoryHardware } from '@votingworks/utils'; -import { electionSampleDefinition } from '@votingworks/fixtures'; import userEvent from '@testing-library/user-event'; import { createMocks as createReactIdleTimerMocks } from 'react-idle-timer'; import { render, screen, waitFor } from '../test/react_testing_library'; @@ -10,6 +9,7 @@ import { App } from './app'; import { advanceTimersAndPromises } from '../test/helpers/timers'; import { + election, setElectionInStorage, setStateInStorage, } from '../test/helpers/election'; @@ -31,6 +31,7 @@ beforeEach(() => { apiMock = createApiMock(); apiMock.expectGetSystemSettings(); apiMock.expectGetElectionDefinition(null); + apiMock.expectGetPrecinctSelectionResolvesDefault(election); }); afterEach(() => { @@ -73,11 +74,10 @@ test('Insert Card screen idle timeout to quit app', async () => { }); test('Voter idle timeout', async () => { - const electionDefinition = electionSampleDefinition; const hardware = MemoryHardware.buildStandard(); const storage = new MemoryStorage(); apiMock.expectGetMachineConfig(); - await setElectionInStorage(storage, electionDefinition); + await setElectionInStorage(storage); await setStateInStorage(storage); render( = { }; const initialSharedState: Readonly = { - appPrecinct: undefined, ballotsPrintedCount: 0, electionDefinition: undefined, isLiveMode: false, @@ -157,7 +154,6 @@ type AppAction = | { type: 'updateVote'; contestId: ContestId; vote: OptionalVote } | { type: 'forceSaveVote' } | { type: 'resetBallot'; showPostVotingInstructions?: boolean } - | { type: 'updateAppPrecinct'; appPrecinct: PrecinctSelection } | { type: 'enableLiveMode' } | { type: 'toggleLiveMode' } | { type: 'updatePollsState'; pollsState: PollsState } @@ -200,12 +196,6 @@ function appReducer(state: State, action: AppAction): State { ...initialVoterState, showPostVotingInstructions: action.showPostVotingInstructions, }; - case 'updateAppPrecinct': - return { - ...state, - ...resetTally, - appPrecinct: action.appPrecinct, - }; case 'enableLiveMode': return { ...state, @@ -232,16 +222,10 @@ function appReducer(state: State, action: AppAction): State { }; } case 'updateElectionDefinition': { - const { precincts } = action.electionDefinition.election; - let defaultPrecinct: Optional; - if (precincts.length === 1) { - defaultPrecinct = singlePrecinctSelectionFor(precincts[0].id); - } return { ...state, ...initialUserState, electionDefinition: action.electionDefinition, - appPrecinct: defaultPrecinct, }; } case 'initializeAppState': @@ -266,7 +250,6 @@ export function AppRoot({ const PostVotingInstructionsTimeout = useRef(0); const [appState, dispatchAppState] = useReducer(appReducer, initialAppState); const { - appPrecinct, ballotsPrintedCount, electionDefinition: optionalElectionDefinition, isLiveMode, @@ -295,6 +278,8 @@ export function AppRoot({ const stateMachineState = getStateMachineStateQuery.isSuccess ? getStateMachineStateQuery.data : 'no_hardware'; + const getPrecinctSelectionQuery = getPrecinctSelection.useQuery(); + const precinctSelection = getPrecinctSelectionQuery.data?.ok(); const checkPinMutation = checkPin.useMutation(); const startCardlessVoterSessionMutation = @@ -423,13 +408,6 @@ export function AppRoot({ dispatchAppState({ type: 'forceSaveVote' }); }, []); - const updateAppPrecinct = useCallback((newAppPrecinct: PrecinctSelection) => { - dispatchAppState({ - type: 'updateAppPrecinct', - appPrecinct: newAppPrecinct, - }); - }, []); - const enableLiveMode = useCallback(() => { dispatchAppState({ type: 'enableLiveMode' }); }, []); @@ -550,7 +528,6 @@ export function AppRoot({ {}; const { - appPrecinct: storedAppPrecinct = initialAppState.appPrecinct, ballotsPrintedCount: storedBallotsPrintedCount = initialAppState.ballotsPrintedCount, isLiveMode: storedIsLiveMode = initialAppState.isLiveMode, @@ -559,7 +536,6 @@ export function AppRoot({ dispatchAppState({ type: 'initializeAppState', appState: { - appPrecinct: storedAppPrecinct, ballotsPrintedCount: storedBallotsPrintedCount, electionDefinition: storedElectionDefinition, isLiveMode: storedIsLiveMode, @@ -575,7 +551,6 @@ export function AppRoot({ async function storeAppState() { if (initializedFromStorage) { await storage.set(stateStorageKey, { - appPrecinct, ballotsPrintedCount, isLiveMode, pollsState, @@ -585,7 +560,6 @@ export function AppRoot({ void storeAppState(); }, [ - appPrecinct, ballotsPrintedCount, isLiveMode, pollsState, @@ -671,7 +645,6 @@ export function AppRoot({ ) { return ( ); } - if (optionalElectionDefinition && appPrecinct) { + if (optionalElectionDefinition && precinctSelection) { if ( authStatus.status === 'logged_out' && authStatus.reason === 'poll_worker_wrong_election' @@ -713,7 +684,6 @@ export function AppRoot({ pollWorkerAuth={authStatus} activateCardlessVoterSession={activateCardlessBallot} resetCardlessVoterSession={resetCardlessBallot} - appPrecinct={appPrecinct} electionDefinition={optionalElectionDefinition} enableLiveMode={enableLiveMode} isLiveMode={isLiveMode} @@ -729,6 +699,7 @@ export function AppRoot({ /> ); } + if (pollsState === 'polls_open' && showPostVotingInstructions) { return ( ); } + if (pollsState === 'polls_open') { if (isCardlessVoterAuth(authStatus)) { return ( @@ -771,7 +743,6 @@ export function AppRoot({ timeout={GLOBALS.QUIT_KIOSK_IDLE_SECONDS * 1000} > { apiMock = createApiMock(); apiMock.expectGetSystemSettings(); apiMock.expectGetElectionDefinition(null); + apiMock.expectGetPrecinctSelectionResolvesDefault(election); }); afterEach(() => { diff --git a/apps/mark-scan/frontend/src/app_single_precinct_election.test.tsx b/apps/mark-scan/frontend/src/app_single_precinct_election.test.tsx deleted file mode 100644 index f381d19ec..000000000 --- a/apps/mark-scan/frontend/src/app_single_precinct_election.test.tsx +++ /dev/null @@ -1,60 +0,0 @@ -import { MemoryHardware, MemoryStorage } from '@votingworks/utils'; -import { electionMinimalExhaustiveSampleSinglePrecinctDefinition } from '@votingworks/fixtures'; -import { getDisplayElectionHash } from '@votingworks/types'; -import { FakeKiosk, fakeKiosk } from '@votingworks/test-utils'; -import { screen } from '../test/react_testing_library'; -import { render } from '../test/test_utils'; -import { App } from './app'; -import { ApiMock, createApiMock } from '../test/helpers/mock_api_client'; -import { configureFromUsbThenRemove } from '../test/helpers/ballot_package'; - -let apiMock: ApiMock; -let kiosk: FakeKiosk; - -beforeEach(() => { - jest.useFakeTimers(); - window.location.href = '/'; - apiMock = createApiMock(); - kiosk = fakeKiosk(); - window.kiosk = kiosk; -}); - -afterEach(() => { - apiMock.mockApiClient.assertComplete(); -}); - -jest.setTimeout(15000); - -test('loading election with a single precinct automatically sets precinct', async () => { - const electionDefinition = - electionMinimalExhaustiveSampleSinglePrecinctDefinition; - const hardware = MemoryHardware.buildStandard(); - const storage = new MemoryStorage(); - apiMock.expectGetMachineConfig(); - apiMock.expectGetSystemSettings(); - apiMock.expectGetElectionDefinition(null); - - render( - - ); - - await screen.findByText('VxMarkScan is Not Configured'); - - apiMock.setAuthStatusElectionManagerLoggedIn(electionDefinition); - - // Insert a USB with a ballot package - await configureFromUsbThenRemove(apiMock, kiosk, screen, electionDefinition); - await screen.findByText(getDisplayElectionHash(electionDefinition)); - // Should not be able to select a precinct - expect(screen.getByTestId('selectPrecinct')).toBeDisabled(); - screen.getByText( - 'Precinct cannot be changed because there is only one precinct configured for this election.' - ); - apiMock.setAuthStatusLoggedOut(); - await screen.findByText('Precinct 1'); -}); diff --git a/apps/mark-scan/frontend/src/app_test_mode.test.tsx b/apps/mark-scan/frontend/src/app_test_mode.test.tsx index d846ef188..621d9e433 100644 --- a/apps/mark-scan/frontend/src/app_test_mode.test.tsx +++ b/apps/mark-scan/frontend/src/app_test_mode.test.tsx @@ -35,6 +35,9 @@ it('Prompts to change from test mode to live mode on election day', async () => const hardware = MemoryHardware.buildStandard(); const storage = new MemoryStorage(); apiMock.expectGetMachineConfig(); + apiMock.expectGetPrecinctSelectionResolvesDefault( + electionDefinition.election + ); await setElectionInStorage(storage, electionDefinition); await setStateInStorage(storage, { isLiveMode: false, diff --git a/apps/mark-scan/frontend/src/lib/gamepad.test.tsx b/apps/mark-scan/frontend/src/lib/gamepad.test.tsx index 405644ec9..6a7e98e04 100644 --- a/apps/mark-scan/frontend/src/lib/gamepad.test.tsx +++ b/apps/mark-scan/frontend/src/lib/gamepad.test.tsx @@ -9,6 +9,7 @@ import { contest0candidate0, contest0candidate1, contest1candidate0, + election, setElectionInStorage, setStateInStorage, } from '../../test/helpers/election'; @@ -37,6 +38,7 @@ it('gamepad controls work', async () => { await setElectionInStorage(storage); await setStateInStorage(storage); + apiMock.expectGetPrecinctSelectionResolvesDefault(election); render( = {}) { = {}) { } test('renders date and time settings modal', async () => { + apiMock.expectGetPrecinctSelection(); renderScreen(); advanceTimers(); @@ -107,30 +107,37 @@ test('renders date and time settings modal', async () => { screen.getByText(startDate); }); -test('can switch the precinct', () => { - const updateAppPrecinct = jest.fn(); - renderScreen({ updateAppPrecinct }); +test('can switch the precinct', async () => { + const precinctSelection = singlePrecinctSelectionFor( + election.precincts[0].id + ); + apiMock.mockApiClient.getPrecinctSelection + .expectRepeatedCallsWith() + .resolves(ok(precinctSelection)); + + apiMock.expectSetPrecinctSelection(ALL_PRECINCTS_SELECTION); + renderScreen(); - const precinctSelect = screen.getByLabelText('Precinct'); + const precinctSelect = await screen.findByLabelText('Precinct'); const allPrecinctsOption = within(precinctSelect).getByText('All Precincts'); fireEvent.change(precinctSelect, { target: { value: allPrecinctsOption.value }, }); - expect(updateAppPrecinct).toHaveBeenCalledWith(ALL_PRECINCTS_SELECTION); }); -test('precinct change disabled if polls closed', () => { +test('precinct change disabled if polls closed', async () => { + apiMock.expectGetPrecinctSelection(); renderScreen({ pollsState: 'polls_closed_final' }); - const precinctSelect = screen.getByLabelText('Precinct'); + const precinctSelect = await screen.findByLabelText('Precinct'); expect(precinctSelect).toBeDisabled(); }); test('precinct selection disabled if single precinct election', async () => { + apiMock.expectGetPrecinctSelection(); renderScreen({ electionDefinition: electionMinimalExhaustiveSampleSinglePrecinctDefinition, - appPrecinct: singlePrecinctSelectionFor('precinct-1'), }); await screen.findByText('Election Manager Actions'); @@ -141,14 +148,17 @@ test('precinct selection disabled if single precinct election', async () => { }); test('renders a USB controller button', async () => { + apiMock.expectGetPrecinctSelection(); renderScreen({ usbDrive: mockUsbDrive('absent') }); await screen.findByText('No USB'); + apiMock.expectGetPrecinctSelection(); renderScreen({ usbDrive: mockUsbDrive('mounted') }); await screen.findByText('Eject USB'); }); test('USB button calls eject', async () => { + apiMock.expectGetPrecinctSelection(); const usbDrive = mockUsbDrive('mounted'); renderScreen({ usbDrive }); diff --git a/apps/mark-scan/frontend/src/pages/admin_screen.tsx b/apps/mark-scan/frontend/src/pages/admin_screen.tsx index 34965fc22..9ffb8c12b 100644 --- a/apps/mark-scan/frontend/src/pages/admin_screen.tsx +++ b/apps/mark-scan/frontend/src/pages/admin_screen.tsx @@ -1,4 +1,4 @@ -import React, { useEffect } from 'react'; +import React, { useCallback, useEffect } from 'react'; import { P, @@ -29,14 +29,12 @@ import { makeAsync } from '@votingworks/utils'; import { Logger } from '@votingworks/logging'; import type { MachineConfig } from '@votingworks/mark-scan-backend'; import { ScreenReader } from '../config/types'; -import { logOut } from '../api'; +import { getPrecinctSelection, logOut, setPrecinctSelection } from '../api'; export interface AdminScreenProps { - appPrecinct?: PrecinctSelection; ballotsPrintedCount: number; electionDefinition: ElectionDefinition; isLiveMode: boolean; - updateAppPrecinct: (appPrecinct: PrecinctSelection) => void; toggleLiveMode: VoidFunction; unconfigure: () => Promise; machineConfig: MachineConfig; @@ -47,11 +45,9 @@ export interface AdminScreenProps { } export function AdminScreen({ - appPrecinct, ballotsPrintedCount, electionDefinition, isLiveMode, - updateAppPrecinct, toggleLiveMode, unconfigure, machineConfig, @@ -59,9 +55,19 @@ export function AdminScreen({ pollsState, logger, usbDrive, -}: AdminScreenProps): JSX.Element { +}: AdminScreenProps): JSX.Element | null { const { election } = electionDefinition; const logOutMutation = logOut.useMutation(); + const getPrecinctSelectionQuery = getPrecinctSelection.useQuery(); + const setPrecinctSelectionMutation = setPrecinctSelection.useMutation(); + const updatePrecinctSelection = useCallback( + (newPrecinctSelection: PrecinctSelection) => { + setPrecinctSelectionMutation.mutate({ + precinctSelection: newPrecinctSelection, + }); + }, + [setPrecinctSelectionMutation] + ); // Disable the audiotrack when in admin mode useEffect(() => { @@ -70,6 +76,11 @@ export function AdminScreen({ return () => screenReader.toggleMuted(initialMuted); }, [screenReader]); + if (!getPrecinctSelectionQuery.isSuccess) { + return null; + } + const precinctSelection = getPrecinctSelectionQuery.data.ok(); + return ( {election && !isLiveMode && } @@ -95,8 +106,8 @@ export function AdminScreen({

)} diff --git a/apps/mark-scan/frontend/src/pages/insert_card_screen.test.tsx b/apps/mark-scan/frontend/src/pages/insert_card_screen.test.tsx new file mode 100644 index 000000000..dbf66eba1 --- /dev/null +++ b/apps/mark-scan/frontend/src/pages/insert_card_screen.test.tsx @@ -0,0 +1,42 @@ +import { fakeKiosk } from '@votingworks/test-utils'; +import { QueryClientProvider } from '@tanstack/react-query'; +import { err } from '@votingworks/basics'; +import { screen } from '../../test/react_testing_library'; +import { ApiMock, createApiMock } from '../../test/helpers/mock_api_client'; +import { render } from '../../test/test_utils'; +import { ApiClientContext, createQueryClient } from '../api'; +import { InsertCardScreen } from './insert_card_screen'; +import { electionDefinition } from '../../test/helpers/election'; + +let apiMock: ApiMock; + +beforeEach(() => { + window.kiosk = fakeKiosk(); + apiMock = createApiMock(); +}); + +afterEach(() => { + window.kiosk = undefined; + apiMock.mockApiClient.assertComplete(); +}); + +test('returns null if getPrecinctSelectionQuery is not successful', () => { + apiMock.mockApiClient.getPrecinctSelection + .expectCallWith() + .resolves(err(new Error('not ok'))); + + render( + + + + + + ); + expect(screen.queryByText('Election ID')).toBeNull(); +}); diff --git a/apps/mark-scan/frontend/src/pages/insert_card_screen.tsx b/apps/mark-scan/frontend/src/pages/insert_card_screen.tsx index 4d84935f4..9d5d07436 100644 --- a/apps/mark-scan/frontend/src/pages/insert_card_screen.tsx +++ b/apps/mark-scan/frontend/src/pages/insert_card_screen.tsx @@ -1,9 +1,5 @@ import React, { useEffect } from 'react'; -import { - ElectionDefinition, - PollsState, - PrecinctSelection, -} from '@votingworks/types'; +import { ElectionDefinition, PollsState } from '@votingworks/types'; import { Main, Screen, @@ -20,9 +16,9 @@ import { import { throwIllegalValue } from '@votingworks/basics'; import { triggerAudioFocus } from '../utils/trigger_audio_focus'; +import { getPrecinctSelection } from '../api'; interface Props { - appPrecinct: PrecinctSelection; electionDefinition: ElectionDefinition; showNoChargerAttachedWarning: boolean; isLiveMode: boolean; @@ -31,13 +27,13 @@ interface Props { } export function InsertCardScreen({ - appPrecinct, electionDefinition, showNoChargerAttachedWarning, isLiveMode, pollsState, showNoAccessibleControllerWarning, -}: Props): JSX.Element { +}: Props): JSX.Element | null { + const getPrecinctSelectionQuery = getPrecinctSelection.useQuery(); useEffect(triggerAudioFocus, []); const mainText = (() => { @@ -71,6 +67,11 @@ export function InsertCardScreen({ } })(); + if (!getPrecinctSelectionQuery.isSuccess) { + return null; + } + const precinctSelection = getPrecinctSelectionQuery.data.ok(); + return ( {!isLiveMode && } @@ -96,7 +97,7 @@ export function InsertCardScreen({ ); diff --git a/apps/mark-scan/frontend/src/pages/poll_worker_screen.test.tsx b/apps/mark-scan/frontend/src/pages/poll_worker_screen.test.tsx index 60e79aa3e..a87fd628f 100644 --- a/apps/mark-scan/frontend/src/pages/poll_worker_screen.test.tsx +++ b/apps/mark-scan/frontend/src/pages/poll_worker_screen.test.tsx @@ -4,7 +4,7 @@ import { } from '@votingworks/fixtures'; import { ElectionDefinition, InsertedSmartCardAuth } from '@votingworks/types'; -import { singlePrecinctSelectionFor, MemoryHardware } from '@votingworks/utils'; +import { MemoryHardware } from '@votingworks/utils'; import { fakePollWorkerUser, fakeSessionExpiresAt, @@ -17,8 +17,6 @@ import { fireEvent, screen, within } from '../../test/react_testing_library'; import { render } from '../../test/test_utils'; -import { defaultPrecinctId } from '../../test/helpers/election'; - import { PollWorkerScreen, PollworkerScreenProps } from './poll_worker_screen'; import { fakeMachineConfig } from '../../test/helpers/fake_machine_config'; import { fakeDevices } from '../../test/helpers/fake_devices'; @@ -34,6 +32,7 @@ let apiMock: ApiMock; beforeEach(() => { jest.useFakeTimers(); apiMock = createApiMock(); + apiMock.expectGetPrecinctSelection(); }); afterEach(() => { @@ -64,7 +63,6 @@ function renderScreen( pollWorkerAuth={pollWorkerAuth} activateCardlessVoterSession={jest.fn()} resetCardlessVoterSession={jest.fn()} - appPrecinct={singlePrecinctSelectionFor(defaultPrecinctId)} electionDefinition={electionDefinition} enableLiveMode={jest.fn()} hasVotes={false} diff --git a/apps/mark-scan/frontend/src/pages/poll_worker_screen.tsx b/apps/mark-scan/frontend/src/pages/poll_worker_screen.tsx index 230a63e3d..172b4331a 100644 --- a/apps/mark-scan/frontend/src/pages/poll_worker_screen.tsx +++ b/apps/mark-scan/frontend/src/pages/poll_worker_screen.tsx @@ -5,7 +5,6 @@ import { BallotStyleId, ElectionDefinition, PrecinctId, - PrecinctSelection, PollsState, PollsTransition, InsertedSmartCardAuth, @@ -50,7 +49,7 @@ import { ScreenReader } from '../config/types'; import { triggerAudioFocus } from '../utils/trigger_audio_focus'; import { DiagnosticsScreen } from './diagnostics_screen'; -import { getStateMachineState } from '../api'; +import { getPrecinctSelection, getStateMachineState } from '../api'; import { LoadPaperPage } from './load_paper_page'; const VotingSession = styled.div` @@ -143,7 +142,6 @@ export interface PollworkerScreenProps { ballotStyleId: BallotStyleId ) => void; resetCardlessVoterSession: () => void; - appPrecinct: PrecinctSelection; electionDefinition: ElectionDefinition; enableLiveMode: () => void; hasVotes: boolean; @@ -162,7 +160,6 @@ export function PollWorkerScreen({ pollWorkerAuth, activateCardlessVoterSession, resetCardlessVoterSession, - appPrecinct, electionDefinition, enableLiveMode, isLiveMode, @@ -183,9 +180,14 @@ export function PollWorkerScreen({ const getStateMachineStateQuery = getStateMachineState.useQuery(); const stateMachineState = getStateMachineStateQuery.data; + const getPrecinctSelectionQuery = getPrecinctSelection.useQuery(); + const precinctSelection = getPrecinctSelectionQuery.data?.ok(); + const [selectedCardlessVoterPrecinctId, setSelectedCardlessVoterPrecinctId] = useState( - appPrecinct.kind === 'SinglePrecinct' ? appPrecinct.precinctId : undefined + precinctSelection?.kind === 'SinglePrecinct' + ? precinctSelection.precinctId + : undefined ); const precinctBallotStyles = selectedCardlessVoterPrecinctId @@ -335,7 +337,7 @@ export function PollWorkerScreen({

Start a New Voting Session

- {appPrecinct.kind === 'AllPrecincts' && ( + {precinctSelection?.kind === 'AllPrecincts' && (
1. Select Voter’s Precinct
@@ -360,8 +362,8 @@ export function PollWorkerScreen({
)}
- {appPrecinct.kind === 'AllPrecincts' ? '2. ' : ''}Select - Voter’s Ballot Style + {precinctSelection?.kind === 'AllPrecincts' ? '2. ' : ''} + Select Voter’s Ballot Style
{selectedCardlessVoterPrecinctId ? ( @@ -468,7 +470,7 @@ export function PollWorkerScreen({ electionDefinition={electionDefinition} codeVersion={machineConfig.codeVersion} machineId={machineConfig.machineId} - precinctSelection={appPrecinct} + precinctSelection={precinctSelection} />
); diff --git a/apps/mark-scan/frontend/src/pages/replace_election_screen.test.tsx b/apps/mark-scan/frontend/src/pages/replace_election_screen.test.tsx index ba662a3a0..b73d45804 100644 --- a/apps/mark-scan/frontend/src/pages/replace_election_screen.test.tsx +++ b/apps/mark-scan/frontend/src/pages/replace_election_screen.test.tsx @@ -27,6 +27,7 @@ let apiMock: ApiMock; beforeEach(() => { apiMock = createApiMock(); + apiMock.expectGetPrecinctSelection(); }); afterEach(() => { diff --git a/apps/mark-scan/frontend/src/pages/replace_election_screen.tsx b/apps/mark-scan/frontend/src/pages/replace_election_screen.tsx index 3865147f0..1672fb827 100644 --- a/apps/mark-scan/frontend/src/pages/replace_election_screen.tsx +++ b/apps/mark-scan/frontend/src/pages/replace_election_screen.tsx @@ -1,4 +1,4 @@ -import { ElectionDefinition, PrecinctSelection } from '@votingworks/types'; +import { ElectionDefinition } from '@votingworks/types'; import { ElectionInfoBar, H2, @@ -16,9 +16,9 @@ import { DateTime } from 'luxon'; import pluralize from 'pluralize'; import { useEffect } from 'react'; import { ScreenReader } from '../config/types'; +import { getPrecinctSelection } from '../api'; export interface ReplaceElectionScreenProps { - appPrecinct?: PrecinctSelection; ballotsPrintedCount: number; authElectionHash: string; electionDefinition: ElectionDefinition; @@ -29,7 +29,6 @@ export interface ReplaceElectionScreenProps { } export function ReplaceElectionScreen({ - appPrecinct, ballotsPrintedCount, authElectionHash, electionDefinition, @@ -37,7 +36,8 @@ export function ReplaceElectionScreen({ screenReader, isLoading, isError, -}: ReplaceElectionScreenProps): JSX.Element { +}: ReplaceElectionScreenProps): JSX.Element | null { + const getPrecinctSelectionQuery = getPrecinctSelection.useQuery(); const { election, electionHash } = electionDefinition; useEffect(() => { @@ -71,6 +71,11 @@ export function ReplaceElectionScreen({ ); } + if (!getPrecinctSelectionQuery.isSuccess) { + return null; + } + const precinctSelection = getPrecinctSelectionQuery.data.ok(); + return (
@@ -132,7 +137,7 @@ export function ReplaceElectionScreen({ electionDefinition={electionDefinition} codeVersion={machineConfig.codeVersion} machineId={machineConfig.machineId} - precinctSelection={appPrecinct} + precinctSelection={precinctSelection} /> )} diff --git a/apps/mark-scan/frontend/test/helpers/election.ts b/apps/mark-scan/frontend/test/helpers/election.ts index fb4c7941c..370b75d53 100644 --- a/apps/mark-scan/frontend/test/helpers/election.ts +++ b/apps/mark-scan/frontend/test/helpers/election.ts @@ -5,7 +5,7 @@ import { getContests, YesNoContest, } from '@votingworks/types'; -import { singlePrecinctSelectionFor, Storage } from '@votingworks/utils'; +import { Storage } from '@votingworks/utils'; import { electionSampleDefinition } from '@votingworks/fixtures'; import { electionStorageKey, State, stateStorageKey } from '../../src/app_root'; @@ -69,7 +69,6 @@ export async function setStateInStorage( state: Partial = {} ): Promise { const storedState: Partial = { - appPrecinct: singlePrecinctSelectionFor(defaultPrecinctId), ballotsPrintedCount: 0, isLiveMode: true, pollsState: 'polls_open', diff --git a/apps/mark-scan/frontend/test/helpers/mock_api_client.tsx b/apps/mark-scan/frontend/test/helpers/mock_api_client.tsx index c4291f136..eafdb8bd5 100644 --- a/apps/mark-scan/frontend/test/helpers/mock_api_client.tsx +++ b/apps/mark-scan/frontend/test/helpers/mock_api_client.tsx @@ -5,12 +5,15 @@ import { createMockClient, MockClient } from '@votingworks/grout-test-utils'; import type { Api, MachineConfig } from '@votingworks/mark-scan-backend'; import { QueryClientProvider } from '@tanstack/react-query'; import { + AllPrecinctsSelection, BallotPackageConfigurationError, BallotStyleId, DEFAULT_SYSTEM_SETTINGS, + Election, ElectionDefinition, InsertedSmartCardAuth, PrecinctId, + PrecinctSelection, SystemSettings, } from '@votingworks/types'; import { @@ -20,8 +23,9 @@ import { fakeSessionExpiresAt, fakeSystemAdministratorUser, } from '@votingworks/test-utils'; -import { err, ok, Result } from '@votingworks/basics'; +import { assert, err, ok, Result } from '@votingworks/basics'; import { SimpleServerStatus } from '@votingworks/mark-scan-backend'; +import { singlePrecinctSelectionFor } from '@votingworks/utils'; import { ApiClientContext, createQueryClient } from '../../src/api'; import { fakeMachineConfig } from './fake_machine_config'; @@ -138,6 +142,42 @@ export function createApiMock() { .resolves(electionDefinition); }, + /** + * Expects a call to getPrecinctSelection. The call will resolve with the first precinct of the given election. + * @param election The election to reference when choosing the precinct with which getPrecinctSelection() will resolve. + */ + expectGetPrecinctSelectionResolvesDefault(election: Election) { + assert( + election?.precincts[0] !== undefined, + 'Could not mock getPrecinctSelection because the provided election has no precincts' + ); + mockApiClient.getPrecinctSelection + .expectCallWith() + .resolves(ok(singlePrecinctSelectionFor(election.precincts[0].id))); + }, + + expectGetPrecinctSelection(precinctSelection?: PrecinctSelection) { + mockApiClient.getPrecinctSelection + .expectCallWith() + .resolves(ok(precinctSelection)); + }, + + expectSetPrecinctSelection( + precinctSelection: PrecinctSelection | AllPrecinctsSelection + ) { + mockApiClient.setPrecinctSelection + .expectCallWith({ precinctSelection }) + .resolves(); + }, + + expectSetPrecinctSelectionRepeated( + precinctSelection: PrecinctSelection | AllPrecinctsSelection + ) { + mockApiClient.setPrecinctSelection + .expectRepeatedCallsWith({ precinctSelection }) + .resolves(); + }, + expectGetSystemSettings( systemSettings: SystemSettings = DEFAULT_SYSTEM_SETTINGS ) {