diff --git a/apps/mark-scan/backend/src/custom-paper-handler/constants.ts b/apps/mark-scan/backend/src/custom-paper-handler/constants.ts index 429861d0f2..9a855b2bee 100644 --- a/apps/mark-scan/backend/src/custom-paper-handler/constants.ts +++ b/apps/mark-scan/backend/src/custom-paper-handler/constants.ts @@ -10,8 +10,9 @@ export const AUTH_STATUS_POLLING_TIMEOUT_MS = 30_000; export const RESET_DELAY_MS = 8_000; export const RESET_AFTER_JAM_DELAY_MS = 3_000; // The delay the state machine will wait for paper to eject before -// declaring a jam state during rear ejection -export const DELAY_BEFORE_DECLARING_REAR_JAM_MS = 3_000; +// declaring a jam state during rear ejection. Expected time for a successful +// ballot cast is is about 3.5 seconds. +export const DELAY_BEFORE_DECLARING_REAR_JAM_MS = 7_000; export const SCAN_DPI = 72; export const PRINT_DPI = 200; diff --git a/apps/mark-scan/frontend/src/__snapshots__/app_contest_candidate_no_party.test.tsx.snap b/apps/mark-scan/frontend/src/__snapshots__/app_contest_candidate_no_party.test.tsx.snap index d62e6fb670..c1a4ab8db2 100644 --- a/apps/mark-scan/frontend/src/__snapshots__/app_contest_candidate_no_party.test.tsx.snap +++ b/apps/mark-scan/frontend/src/__snapshots__/app_contest_candidate_no_party.test.tsx.snap @@ -8,16 +8,16 @@ exports[`Single Seat Contest 1`] = ` margin: 0; } -.c9 { +.c23 { font-size: 1em; + font-weight: 300; line-height: 1.15; margin: 0; font-size: 0.75rem; } -.c5 { +.c9 { font-size: 1em; - font-weight: 500; line-height: 1.15; margin: 0; font-size: 0.75rem; @@ -31,9 +31,9 @@ exports[`Single Seat Contest 1`] = ` font-size: 0.75rem; } -.c23 { +.c5 { font-size: 1em; - font-weight: 300; + font-weight: 500; line-height: 1.15; margin: 0; font-size: 0.75rem; diff --git a/apps/mark-scan/frontend/src/__snapshots__/app_contest_multi_seat.test.tsx.snap b/apps/mark-scan/frontend/src/__snapshots__/app_contest_multi_seat.test.tsx.snap index c6d30f74c7..af695fc672 100644 --- a/apps/mark-scan/frontend/src/__snapshots__/app_contest_multi_seat.test.tsx.snap +++ b/apps/mark-scan/frontend/src/__snapshots__/app_contest_multi_seat.test.tsx.snap @@ -8,16 +8,16 @@ exports[`Single Seat Contest 1`] = ` margin: 0; } -.c9 { +.c23 { font-size: 1em; + font-weight: 300; line-height: 1.15; margin: 0; font-size: 0.75rem; } -.c5 { +.c9 { font-size: 1em; - font-weight: 500; line-height: 1.15; margin: 0; font-size: 0.75rem; @@ -31,9 +31,9 @@ exports[`Single Seat Contest 1`] = ` font-size: 0.75rem; } -.c23 { +.c5 { font-size: 1em; - font-weight: 300; + font-weight: 500; line-height: 1.15; margin: 0; font-size: 0.75rem; diff --git a/apps/mark-scan/frontend/src/__snapshots__/app_contest_single_seat.test.tsx.snap b/apps/mark-scan/frontend/src/__snapshots__/app_contest_single_seat.test.tsx.snap index 7850441413..029ab9397e 100644 --- a/apps/mark-scan/frontend/src/__snapshots__/app_contest_single_seat.test.tsx.snap +++ b/apps/mark-scan/frontend/src/__snapshots__/app_contest_single_seat.test.tsx.snap @@ -8,16 +8,16 @@ exports[`Single Seat Contest 1`] = ` margin: 0; } -.c9 { +.c23 { font-size: 1em; + font-weight: 300; line-height: 1.15; margin: 0; font-size: 0.75rem; } -.c5 { +.c9 { font-size: 1em; - font-weight: 500; line-height: 1.15; margin: 0; font-size: 0.75rem; @@ -31,9 +31,9 @@ exports[`Single Seat Contest 1`] = ` font-size: 0.75rem; } -.c23 { +.c5 { font-size: 1em; - font-weight: 300; + font-weight: 500; line-height: 1.15; margin: 0; font-size: 0.75rem; diff --git a/apps/mark-scan/frontend/src/__snapshots__/app_contest_write_in.test.tsx.snap b/apps/mark-scan/frontend/src/__snapshots__/app_contest_write_in.test.tsx.snap index bb4bb8a0e5..6a265ff49f 100644 --- a/apps/mark-scan/frontend/src/__snapshots__/app_contest_write_in.test.tsx.snap +++ b/apps/mark-scan/frontend/src/__snapshots__/app_contest_write_in.test.tsx.snap @@ -8,16 +8,16 @@ exports[`Single Seat Contest with Write In 1`] = ` margin: 0; } -.c9 { +.c23 { font-size: 1em; + font-weight: 300; line-height: 1.15; margin: 0; font-size: 0.75rem; } -.c5 { +.c9 { font-size: 1em; - font-weight: 500; line-height: 1.15; margin: 0; font-size: 0.75rem; @@ -31,9 +31,9 @@ exports[`Single Seat Contest with Write In 1`] = ` font-size: 0.75rem; } -.c23 { +.c5 { font-size: 1em; - font-weight: 300; + font-weight: 500; line-height: 1.15; margin: 0; font-size: 0.75rem; diff --git a/apps/mark-scan/frontend/src/api.ts b/apps/mark-scan/frontend/src/api.ts index 8121a08f3c..520935aebf 100644 --- a/apps/mark-scan/frontend/src/api.ts +++ b/apps/mark-scan/frontend/src/api.ts @@ -9,7 +9,6 @@ import { useQueryClient, } from '@tanstack/react-query'; import { - AUTH_STATUS_POLLING_INTERVAL_MS, QUERY_CLIENT_DEFAULT_OPTIONS, USB_DRIVE_STATUS_POLLING_INTERVAL_MS, createUiStringsApi, @@ -18,7 +17,10 @@ import { import type { UsbDriveStatus } from '@votingworks/usb-drive'; import isEqual from 'lodash.isequal'; import { typedAs } from '@votingworks/basics'; -import { STATE_MACHINE_POLLING_INTERVAL_MS } from './constants'; +import { + AUTH_STATUS_POLLING_INTERVAL_MS_OVERRIDE, + STATE_MACHINE_POLLING_INTERVAL_MS, +} from './constants'; export type ApiClient = grout.Client; @@ -151,7 +153,7 @@ export const getAuthStatus = { useQuery() { const apiClient = useApiClient(); return useQuery(this.queryKey(), () => apiClient.getAuthStatus(), { - refetchInterval: AUTH_STATUS_POLLING_INTERVAL_MS, + refetchInterval: AUTH_STATUS_POLLING_INTERVAL_MS_OVERRIDE, }); }, } as const; diff --git a/apps/mark-scan/frontend/src/app_cardless_voting.test.tsx b/apps/mark-scan/frontend/src/app_cardless_voting.test.tsx index 339b403d4c..ffefa70fe8 100644 --- a/apps/mark-scan/frontend/src/app_cardless_voting.test.tsx +++ b/apps/mark-scan/frontend/src/app_cardless_voting.test.tsx @@ -17,7 +17,6 @@ import { screen, within, } from '../test/react_testing_library'; -import * as GLOBALS from './config/globals'; import { App } from './app'; @@ -37,6 +36,7 @@ beforeEach(() => { apiMock = createApiMock(); kiosk = fakeKiosk(); window.kiosk = kiosk; + apiMock.setPaperHandlerState('waiting_for_ballot_data'); }); afterEach(() => { @@ -257,16 +257,9 @@ test('Cardless Voting Flow', async () => { apiMock.expectGetInterpretation(mockInterpretation); userEvent.click(screen.getByText('My Ballot is Correct')); - // Reset Ballot is called - // Show Verify and Scan Instructions - await screen.findByText('You’re Almost Done'); - expect( - screen.queryByText('3. Return the card to a poll worker.') - ).toBeFalsy(); + apiMock.setPaperHandlerState('ejecting_to_rear'); + await screen.findByText('Casting Ballot...'); - // Wait for timeout to return to Insert Card screen - apiMock.mockApiClient.endCardlessVoterSession.expectCallWith().resolves(); - await advanceTimersAndPromises(GLOBALS.BALLOT_INSTRUCTIONS_TIMEOUT_SECONDS); apiMock.setAuthStatusLoggedOut(); await screen.findByText('Insert Card'); }); @@ -411,14 +404,9 @@ test('Voter can submit a blank ballot', async () => { apiMock.expectGetInterpretation(mockInterpretation); userEvent.click(screen.getByText('My Ballot is Correct')); - // Reset Ballot is called - // Show Verify and Scan Instructions - await screen.findByText('You’re Almost Done'); - expect(screen.queryByText('3. Return the card.')).toBeFalsy(); + apiMock.setPaperHandlerState('ejecting_to_rear'); + await screen.findByText('Casting Ballot...'); - // Click "Done" to get back to Insert Card screen - apiMock.mockApiClient.endCardlessVoterSession.expectCallWith().resolves(); - fireEvent.click(screen.getByText('Done')); apiMock.setAuthStatusLoggedOut(); await screen.findByText('Insert Card'); }); @@ -635,16 +623,9 @@ test('poll worker must select a precinct first', async () => { apiMock.expectGetInterpretation(mockInterpretation); userEvent.click(screen.getByText('My Ballot is Correct')); - // Reset Ballot is called - // Show Verify and Scan Instructions - await screen.findByText('You’re Almost Done'); - expect( - screen.queryByText('3. Return the card to a poll worker.') - ).toBeFalsy(); + apiMock.setPaperHandlerState('ejecting_to_rear'); + await screen.findByText('Casting Ballot...'); - // Wait for timeout to return to Insert Card screen - apiMock.mockApiClient.endCardlessVoterSession.expectCallWith().resolves(); - await advanceTimersAndPromises(GLOBALS.BALLOT_INSTRUCTIONS_TIMEOUT_SECONDS); apiMock.setAuthStatusLoggedOut(); await screen.findByText('Insert Card'); }); 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 a0cbcbff10..c80c4abcc6 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 @@ -42,6 +42,7 @@ beforeEach(() => { apiMock = createApiMock(); apiMock.expectGetSystemSettings(); apiMock.expectGetElectionDefinition(null); + apiMock.setPaperHandlerState('waiting_for_ballot_data'); }); afterEach(() => { 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 3eb7b2f318..498f9b5c22 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 @@ -42,6 +42,7 @@ beforeEach(() => { jest.useFakeTimers(); window.location.href = '/'; apiMock = createApiMock(); + apiMock.setPaperHandlerState('waiting_for_ballot_data'); }); afterEach(() => { 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 5242cf5c16..efcd55a9ce 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 @@ -22,6 +22,7 @@ beforeEach(() => { apiMock = createApiMock(); apiMock.expectGetSystemSettings(); apiMock.expectGetElectionDefinition(null); + apiMock.setPaperHandlerState('waiting_for_ballot_data'); }); afterEach(() => { diff --git a/apps/mark-scan/frontend/src/app_contest_single_seat.test.tsx b/apps/mark-scan/frontend/src/app_contest_single_seat.test.tsx index cc0ac6ab38..cf2eeb5d3f 100644 --- a/apps/mark-scan/frontend/src/app_contest_single_seat.test.tsx +++ b/apps/mark-scan/frontend/src/app_contest_single_seat.test.tsx @@ -22,6 +22,7 @@ beforeEach(() => { apiMock = createApiMock(); apiMock.expectGetSystemSettings(); apiMock.expectGetElectionDefinition(null); + apiMock.setPaperHandlerState('waiting_for_ballot_data'); }); afterEach(() => { diff --git a/apps/mark-scan/frontend/src/app_contest_write_in.test.tsx b/apps/mark-scan/frontend/src/app_contest_write_in.test.tsx index 2713966c29..946f74ee20 100644 --- a/apps/mark-scan/frontend/src/app_contest_write_in.test.tsx +++ b/apps/mark-scan/frontend/src/app_contest_write_in.test.tsx @@ -50,6 +50,7 @@ beforeEach(() => { apiMock = createApiMock(); apiMock.expectGetSystemSettings(); apiMock.expectGetElectionDefinition(null); + apiMock.setPaperHandlerState('waiting_for_ballot_data'); }); afterEach(() => { diff --git a/apps/mark-scan/frontend/src/app_contest_yes_no.test.tsx b/apps/mark-scan/frontend/src/app_contest_yes_no.test.tsx index 468227ae5c..0c37ed189e 100644 --- a/apps/mark-scan/frontend/src/app_contest_yes_no.test.tsx +++ b/apps/mark-scan/frontend/src/app_contest_yes_no.test.tsx @@ -32,6 +32,7 @@ beforeEach(() => { apiMock = createApiMock(); apiMock.expectGetSystemSettings(); apiMock.expectGetElectionDefinition(null); + apiMock.setPaperHandlerState('waiting_for_ballot_data'); }); afterEach(() => { 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 f220ad1e86..63de673711 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 @@ -293,10 +293,9 @@ test('MarkAndPrint end-to-end flow', async () => { // Validate Ballot page await screen.findByText('Review Your Votes'); apiMock.expectValidateBallot(); - apiMock.mockApiClient.endCardlessVoterSession.expectCallWith().resolves(); apiMock.expectGetInterpretation(mockInterpretation); userEvent.click(screen.getByText('My Ballot is Correct')); - userEvent.click(await screen.findByText('Done')); + apiMock.setAuthStatusLoggedOut(); // --------------- 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 79d87f1042..32fe857fed 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 @@ -32,6 +32,7 @@ beforeEach(() => { apiMock.expectGetSystemSettings(); apiMock.expectGetElectionDefinition(null); apiMock.expectGetPrecinctSelectionResolvesDefault(election); + apiMock.setPaperHandlerState('waiting_for_ballot_data'); }); afterEach(() => { diff --git a/apps/mark-scan/frontend/src/app_root.tsx b/apps/mark-scan/frontend/src/app_root.tsx index 7714963da8..bb00dfd47d 100644 --- a/apps/mark-scan/frontend/src/app_root.tsx +++ b/apps/mark-scan/frontend/src/app_root.tsx @@ -1,4 +1,4 @@ -import React, { useCallback, useEffect, useReducer, useRef } from 'react'; +import React, { useCallback, useEffect, useReducer } from 'react'; import { ElectionDefinition, OptionalElectionDefinition, @@ -42,7 +42,6 @@ import { import { assert, throwIllegalValue } from '@votingworks/basics'; import { mergeMsEitherNeitherContests, - CastBallotPage, useDisplaySettingsManager, } from '@votingworks/mark-flow-ui'; import { @@ -85,10 +84,10 @@ import { BallotInvalidatedPage } from './pages/ballot_invalidated_page'; import { BlankPageInterpretationPage } from './pages/blank_page_interpretation_page'; import { PaperReloadedPage } from './pages/paper_reloaded_page'; import { PatDeviceCalibrationPage } from './pages/pat_device_identification/pat_device_calibration_page'; +import { CastingBallotPage } from './pages/casting_ballot_page'; interface UserState { votes?: VotesDict; - showPostVotingInstructions?: boolean; } interface SharedState { @@ -128,7 +127,6 @@ export const blankBallotVotes: VotesDict = {}; const initialVoterState: Readonly = { votes: undefined, - showPostVotingInstructions: undefined, }; const initialSharedState: Readonly = { @@ -162,7 +160,7 @@ type AppAction = | { type: 'unconfigure' } | { type: 'updateVote'; contestId: ContestId; vote: OptionalVote } | { type: 'forceSaveVote' } - | { type: 'resetBallot'; showPostVotingInstructions?: boolean } + | { type: 'resetBallot' } | { type: 'enableLiveMode' } | { type: 'toggleLiveMode' } | { type: 'updatePollsState'; pollsState: PollsState } @@ -203,7 +201,6 @@ function appReducer(state: State, action: AppAction): State { return { ...state, ...initialVoterState, - showPostVotingInstructions: action.showPostVotingInstructions, }; case 'enableLiveMode': return { @@ -256,7 +253,6 @@ export function AppRoot({ reload, logger, }: Props): JSX.Element | null { - const PostVotingInstructionsTimeout = useRef(0); const [appState, dispatchAppState] = useReducer(appReducer, initialAppState); const { ballotsPrintedCount, @@ -264,7 +260,6 @@ export function AppRoot({ isLiveMode, pollsState, initializedFromStorage, - showPostVotingInstructions, votes, } = appState; @@ -367,7 +362,6 @@ export function AppRoot({ (newShowPostVotingInstructions?: boolean) => { dispatchAppState({ type: 'resetBallot', - showPostVotingInstructions: newShowPostVotingInstructions, }); history.push('/'); @@ -380,33 +374,6 @@ export function AppRoot({ [history, themeManager] ); - const hidePostVotingInstructions = useCallback(() => { - clearTimeout(PostVotingInstructionsTimeout.current); - endCardlessVoterSessionMutate(undefined, { - onSuccess() { - resetBallot(); - }, - }); - }, [endCardlessVoterSessionMutate, resetBallot]); - - // Hide Verify and Scan Instructions - useEffect(() => { - if (showPostVotingInstructions) { - PostVotingInstructionsTimeout.current = window.setTimeout( - hidePostVotingInstructions, - GLOBALS.BALLOT_INSTRUCTIONS_TIMEOUT_SECONDS * 1000 - ); - } - return () => { - clearTimeout(PostVotingInstructionsTimeout.current); - }; - /* We don't include hidePostVotingInstructions because it is updated - * frequently due to its dependency on auth, which causes this effect to - * run/cleanup,clearing the timeout. - */ - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [showPostVotingInstructions]); - const unconfigure = useCallback(async () => { await storage.clear(); @@ -756,14 +723,6 @@ export function AppRoot({ ); } - if (pollsState === 'polls_open' && showPostVotingInstructions) { - return ( - - ); - } - if (pollsState === 'polls_open') { if ( isCardlessVoterAuth(authStatus) || @@ -771,6 +730,17 @@ export function AppRoot({ (isPollWorkerAuth(authStatus) && stateMachineState === 'waiting_for_invalidated_ballot_confirmation') ) { + if ( + stateMachineState === 'ejecting_to_rear' || + stateMachineState === 'resetting_state_machine_after_success' || + // Cardless voter auth is ended in the backend when the voting session ends but the frontend + // may have a stale value. Cardless voter auth + 'not_accepting_paper' state means the frontend + // is stale, so we want to render the previous loading screen until the frontend auth status updates. + stateMachineState === 'not_accepting_paper' + ) { + return ; + } + let ballotContextProviderChild = ; // Pages that condition on state machine state aren't nested under Ballot because Ballot uses // frontend browser routing for flow control and is completely independent of the state machine. diff --git a/apps/mark-scan/frontend/src/constants.ts b/apps/mark-scan/frontend/src/constants.ts index 593ba45615..2c2060138e 100644 --- a/apps/mark-scan/frontend/src/constants.ts +++ b/apps/mark-scan/frontend/src/constants.ts @@ -1,2 +1,4 @@ -/* istanbul ignore next */ -export const STATE_MACHINE_POLLING_INTERVAL_MS = 100; +/* istanbul ignore file */ +export const STATE_MACHINE_POLLING_INTERVAL_MS = 300; +// Overrides shorter libs/ui polling interval +export const AUTH_STATUS_POLLING_INTERVAL_MS_OVERRIDE = 300; diff --git a/apps/mark-scan/frontend/src/lib/gamepad.test.tsx b/apps/mark-scan/frontend/src/lib/gamepad.test.tsx index 5bfc90a753..5f07037245 100644 --- a/apps/mark-scan/frontend/src/lib/gamepad.test.tsx +++ b/apps/mark-scan/frontend/src/lib/gamepad.test.tsx @@ -40,6 +40,7 @@ beforeEach(() => { apiMock = createApiMock(); apiMock.expectGetSystemSettings(); apiMock.expectGetElectionDefinition(null); + apiMock.setPaperHandlerState('waiting_for_ballot_data'); }); afterEach(() => { diff --git a/apps/mark-scan/frontend/src/pages/casting_ballot_page.tsx b/apps/mark-scan/frontend/src/pages/casting_ballot_page.tsx new file mode 100644 index 0000000000..834740c76e --- /dev/null +++ b/apps/mark-scan/frontend/src/pages/casting_ballot_page.tsx @@ -0,0 +1,12 @@ +import { Main, Screen, InsertBallotImage, P } from '@votingworks/ui'; + +export function CastingBallotPage(): JSX.Element { + return ( + +
+ +

Casting Ballot...

+
+
+ ); +} diff --git a/apps/mark-scan/frontend/test/helpers/timers.ts b/apps/mark-scan/frontend/test/helpers/timers.ts index e6bc58caed..43a2e5caa1 100644 --- a/apps/mark-scan/frontend/test/helpers/timers.ts +++ b/apps/mark-scan/frontend/test/helpers/timers.ts @@ -1,9 +1,9 @@ import { advanceTimers as advanceTimersBase } from '@votingworks/test-utils'; -import { AUTH_STATUS_POLLING_INTERVAL_MS } from '@votingworks/ui'; import { waitFor } from '../react_testing_library'; +import { AUTH_STATUS_POLLING_INTERVAL_MS_OVERRIDE } from '../../src/constants'; export function advanceTimers(seconds = 0): void { - advanceTimersBase(seconds || AUTH_STATUS_POLLING_INTERVAL_MS / 1000); + advanceTimersBase(seconds || AUTH_STATUS_POLLING_INTERVAL_MS_OVERRIDE / 1000); } export async function advanceTimersAndPromises(seconds = 0): Promise {