diff --git a/apps/mark-scan/backend/src/custom-paper-handler/cli/state_machine_cli.ts b/apps/mark-scan/backend/src/custom-paper-handler/cli/state_machine_cli.ts index 3d7e0e9201..e815b15b12 100644 --- a/apps/mark-scan/backend/src/custom-paper-handler/cli/state_machine_cli.ts +++ b/apps/mark-scan/backend/src/custom-paper-handler/cli/state_machine_cli.ts @@ -12,7 +12,7 @@ import { MARK_SCAN_WORKSPACE } from '../../globals'; import { AUTH_STATUS_POLLING_INTERVAL_MS, DEV_DEVICE_STATUS_POLLING_INTERVAL_MS, - SUCCESS_NOTIFICATION_DURATION_MS, + NOTIFICATION_DURATION_MS, } from '../constants'; import { PaperHandlerStateMachine, @@ -95,7 +95,7 @@ export async function main(): Promise { patConnectionStatusReader, devicePollingIntervalMs: DEV_DEVICE_STATUS_POLLING_INTERVAL_MS, authPollingIntervalMs: AUTH_STATUS_POLLING_INTERVAL_MS, - notificationDurationMs: SUCCESS_NOTIFICATION_DURATION_MS, + notificationDurationMs: NOTIFICATION_DURATION_MS, }); assert(stateMachine !== undefined, 'Unexpected undefined state machine'); 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 5d246554ab..8400798e96 100644 --- a/apps/mark-scan/backend/src/custom-paper-handler/constants.ts +++ b/apps/mark-scan/backend/src/custom-paper-handler/constants.ts @@ -7,13 +7,15 @@ export const DEV_AUTH_STATUS_POLLING_INTERVAL_MS = 1000; export const DEVICE_STATUS_POLLING_TIMEOUT_MS = 30_000; export const AUTH_STATUS_POLLING_TIMEOUT_MS = 30_000; +// The delay the state machine will wait after issuing a reset command. +// The reset command resolves immediately but the hardware takes about 7 +// seconds to become available again. 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. 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 SUCCESS_NOTIFICATION_DURATION_MS = 5_000; +export const NOTIFICATION_DURATION_MS = 5_000; export const MAX_BALLOT_BOX_CAPACITY = 200; diff --git a/apps/mark-scan/backend/src/custom-paper-handler/state_machine.test.ts b/apps/mark-scan/backend/src/custom-paper-handler/state_machine.test.ts index 80f7e1f72d..f617e544dc 100644 --- a/apps/mark-scan/backend/src/custom-paper-handler/state_machine.test.ts +++ b/apps/mark-scan/backend/src/custom-paper-handler/state_machine.test.ts @@ -27,6 +27,7 @@ import { assert } from 'console'; import { PaperHandlerStateMachine, getPaperHandlerStateMachine, + paperHandlerStatusToEvent, } from './state_machine'; import { Workspace, createWorkspace } from '../util/workspace'; import { @@ -443,3 +444,33 @@ describe('PAT device', () => { ); }); }); + +test('ending poll worker auth in accepting_paper returns to initial state', async () => { + machine.setAcceptingPaper(); + const ballotStyle = electionGeneralDefinition.election.ballotStyles[1]; + mockCardlessVoterAuth(auth, { + ballotStyleId: ballotStyle.id, + precinctId, + }); + await waitForStatus('not_accepting_paper'); +}); + +test('poll_worker_auth_ended_unexpectedly', async () => { + machine.setAcceptingPaper(); + const ballotStyle = electionGeneralDefinition.election.ballotStyles[1]; + setMockDeviceStatus(getPaperInFrontStatus()); + await waitForStatus('loading_paper'); + mockCardlessVoterAuth(auth, { + ballotStyleId: ballotStyle.id, + precinctId, + }); + await waitForStatus('poll_worker_auth_ended_unexpectedly'); +}); + +describe('paperHandlerStatusToEvent', () => { + test('paper in output', () => { + expect(paperHandlerStatusToEvent(getPaperInRearStatus())).toEqual({ + type: 'PAPER_IN_OUTPUT', + }); + }); +}); diff --git a/apps/mark-scan/backend/src/custom-paper-handler/state_machine.ts b/apps/mark-scan/backend/src/custom-paper-handler/state_machine.ts index c3ce077e5d..470175fc17 100644 --- a/apps/mark-scan/backend/src/custom-paper-handler/state_machine.ts +++ b/apps/mark-scan/backend/src/custom-paper-handler/state_machine.ts @@ -43,8 +43,7 @@ import { DEVICE_STATUS_POLLING_INTERVAL_MS, DEVICE_STATUS_POLLING_TIMEOUT_MS, MAX_BALLOT_BOX_CAPACITY, - RESET_AFTER_JAM_DELAY_MS, - SUCCESS_NOTIFICATION_DURATION_MS, + NOTIFICATION_DURATION_MS, } from './constants'; import { isPaperInScanner, @@ -403,383 +402,401 @@ export function buildMachine( BaseActionObject, ServiceMap > { - return createMachine({ - schema: { - /* eslint-disable-next-line vx/gts-object-literal-types */ - context: {} as Context, - /* eslint-disable-next-line vx/gts-object-literal-types */ - events: {} as PaperHandlerStatusEvent, - }, - id: 'bmd', - initial: 'voting_flow', - context: initialContext, - on: { - PAPER_JAM: 'voting_flow.jammed', - JAMMED_STATUS_NO_PAPER: 'voting_flow.jam_physically_cleared', - PAT_DEVICE_CONNECTED: { - // Performing the assign here ensures the PAT device observable will - // have an updated value for isPatDeviceConnected - actions: assign({ - isPatDeviceConnected: true, - }), - target: 'pat_device_connected', - }, - PAT_DEVICE_DISCONNECTED: { - // Performing the assign here ensures the PAT device observable will - // have an updated value for isPatDeviceConnected - actions: assign({ - isPatDeviceConnected: false, - }), - target: 'pat_device_disconnected', + return createMachine( + { + schema: { + /* eslint-disable-next-line vx/gts-object-literal-types */ + context: {} as Context, + /* eslint-disable-next-line vx/gts-object-literal-types */ + events: {} as PaperHandlerStatusEvent, }, - SET_INTERPRETATION_FIXTURE: { - actions: assign({ - scannedImagePaths: getSampleBallotFilepaths(), - }), - target: 'voting_flow.interpreting', + id: 'bmd', + initial: 'voting_flow', + context: initialContext, + on: { + PAPER_JAM: 'voting_flow.jammed', + JAMMED_STATUS_NO_PAPER: 'voting_flow.jam_physically_cleared', + PAT_DEVICE_CONNECTED: { + // Performing the assign here ensures the PAT device observable will + // have an updated value for isPatDeviceConnected + actions: assign({ + isPatDeviceConnected: true, + }), + target: 'pat_device_connected', + }, + PAT_DEVICE_DISCONNECTED: { + // Performing the assign here ensures the PAT device observable will + // have an updated value for isPatDeviceConnected + actions: assign({ + isPatDeviceConnected: false, + }), + target: 'pat_device_disconnected', + }, + SET_INTERPRETATION_FIXTURE: { + actions: assign({ + scannedImagePaths: getSampleBallotFilepaths(), + }), + target: 'voting_flow.interpreting', + }, }, - }, - invoke: [pollPatDeviceConnectionStatus()], - states: { - voting_flow: { - initial: 'not_accepting_paper', - states: { - history: { - type: 'history', - history: 'shallow', - }, - // Initial state. Doesn't accept paper and transitions away when the frontend says it's ready to accept - not_accepting_paper: { - invoke: pollPaperStatus(), - on: { - // Paper may be inside the machine from previous testing or machine failure. We should eject - // the paper (not to ballot bin) because we don't know whether the page has been printed. - PAPER_INSIDE_NO_JAM: 'eject_to_front', - PAPER_PARKED: 'eject_to_front', - BEGIN_ACCEPTING_PAPER: 'accepting_paper', + invoke: [pollPatDeviceConnectionStatus()], + states: { + voting_flow: { + initial: 'not_accepting_paper', + states: { + history: { + type: 'history', + history: 'shallow', }, - }, - accepting_paper: { - invoke: pollPaperStatus(), - on: { - PAPER_READY_TO_LOAD: 'loading_paper', - PAPER_PARKED: 'waiting_for_ballot_data', + // Initial state. Doesn't accept paper and transitions away when the frontend says it's ready to accept + not_accepting_paper: { + invoke: pollPaperStatus(), + on: { + // Paper may be inside the machine from previous testing or machine failure. We should eject + // the paper (not to ballot bin) because we don't know whether the page has been printed. + PAPER_INSIDE_NO_JAM: 'eject_to_front', + PAPER_PARKED: 'eject_to_front', + BEGIN_ACCEPTING_PAPER: 'accepting_paper', + }, }, - }, - loading_paper: { - invoke: [ - pollPaperStatus(), - { - id: 'loadAndPark', - src: (context) => { - return loadAndParkPaper(context.driver); + accepting_paper: { + invoke: [pollPaperStatus(), pollAuthStatus()], + on: { + PAPER_READY_TO_LOAD: 'loading_paper', + PAPER_PARKED: 'waiting_for_ballot_data', + AUTH_STATUS_CARDLESS_VOTER: 'resetting_state_machine_no_delay', + }, + }, + loading_paper: { + invoke: [ + pollPaperStatus(), + pollAuthStatus(), + { + id: 'loadAndPark', + src: (context) => { + return loadAndParkPaper(context.driver); + }, }, + ], + on: { + PAPER_PARKED: 'waiting_for_ballot_data', + NO_PAPER_ANYWHERE: 'accepting_paper', + // The poll worker pulled their card too early + AUTH_STATUS_CARDLESS_VOTER: + 'poll_worker_auth_ended_unexpectedly', }, - ], - on: { - PAPER_PARKED: 'waiting_for_ballot_data', - NO_PAPER_ANYWHERE: 'accepting_paper', }, - }, - waiting_for_ballot_data: { - on: { - VOTER_INITIATED_PRINT: 'printing_ballot', + waiting_for_ballot_data: { + on: { + VOTER_INITIATED_PRINT: 'printing_ballot', + }, }, - }, - printing_ballot: { - invoke: [ - { - id: 'printBallot', - src: (context, event) => { - assert(event.type === 'VOTER_INITIATED_PRINT'); - return printBallotChunks(context.driver, event.pdfData, {}); + printing_ballot: { + invoke: [ + { + id: 'printBallot', + src: (context, event) => { + assert(event.type === 'VOTER_INITIATED_PRINT'); + return printBallotChunks(context.driver, event.pdfData, {}); + }, + onDone: 'scanning', }, - onDone: 'scanning', - }, - pollPaperStatus(), - ], - }, - scanning: { - invoke: [ - { - id: 'scanAndSave', - src: (context) => { - return scanAndSave(context.driver); + pollPaperStatus(), + ], + }, + scanning: { + invoke: [ + { + id: 'scanAndSave', + src: (context) => { + return scanAndSave(context.driver); + }, + onDone: { + target: 'interpreting', + actions: assign({ + scannedImagePaths: (_, event) => event.data, + }), + }, }, + pollPaperStatus(), + ], + }, + interpreting: { + // Paper is in the paper handler for the duration of the interpreting stage and paper handler + // motors are never moved, so we don't need to poll paper status or handle jams. + invoke: { + id: 'interpretScannedBallot', + src: loadMetadataAndInterpretBallot, onDone: { - target: 'interpreting', + target: 'transition_interpretation', actions: assign({ - scannedImagePaths: (_, event) => event.data, + interpretation: (_, event) => event.data, }), }, }, - pollPaperStatus(), - ], - }, - interpreting: { - // Paper is in the paper handler for the duration of the interpreting stage and paper handler - // motors are never moved, so we don't need to poll paper status or handle jams. - invoke: { - id: 'interpretScannedBallot', - src: loadMetadataAndInterpretBallot, - onDone: { - target: 'transition_interpretation', - actions: assign({ - interpretation: (_, event) => event.data, - }), - }, - }, - }, - // Intermediate state to conditionally transition based on ballot interpretation - transition_interpretation: { - entry: (context) => { - const interpretationType = assertDefined( - context.interpretation - )[0].interpretation.type; - assert( - interpretationType === 'InterpretedBmdPage' || - interpretationType === 'BlankPage', - `Unexpected interpretation type: ${interpretationType}` - ); }, - always: [ - { - target: 'presenting_ballot', - cond: (context) => - // context.interpretation is already asserted in the entry function but Typescript is unaware - assertDefined(context.interpretation)[0].interpretation - .type === 'InterpretedBmdPage', + // Intermediate state to conditionally transition based on ballot interpretation + transition_interpretation: { + entry: (context) => { + const interpretationType = assertDefined( + context.interpretation + )[0].interpretation.type; + assert( + interpretationType === 'InterpretedBmdPage' || + interpretationType === 'BlankPage', + `Unexpected interpretation type: ${interpretationType}` + ); }, - { - target: 'blank_page_interpretation', - cond: (context) => - assertDefined(context.interpretation)[0].interpretation - .type === 'BlankPage', - }, - ], - }, - blank_page_interpretation: { - invoke: pollPaperStatus(), - initial: 'presenting_paper', - // These nested states differ slightly from the top-level paper load states so we can't reuse the latter. - states: { - presenting_paper: { - // Heavier paper can fail to eject completely and trigger a jam state even though the paper isn't physically jammed. - // To work around this, we avoid ejecting to front. Instead, we present the paper so it's held by the device in a - // stable non-jam state. We instruct the poll worker to remove the paper directly from the 'presenting' state. - entry: async (context) => { - await context.logger.log( - LogEventId.BlankInterpretation, - 'system' - ); - await context.driver.presentPaper(); + always: [ + { + target: 'presenting_ballot', + cond: (context) => + // context.interpretation is already asserted in the entry function but Typescript is unaware + assertDefined(context.interpretation)[0].interpretation + .type === 'InterpretedBmdPage', }, - on: { NO_PAPER_ANYWHERE: 'accepting_paper' }, - }, - accepting_paper: { - on: { - PAPER_READY_TO_LOAD: 'load_paper', + { + target: 'blank_page_interpretation', + cond: (context) => + assertDefined(context.interpretation)[0].interpretation + .type === 'BlankPage', }, - }, - load_paper: { - entry: async (context) => { - await context.driver.loadPaper(); - await context.driver.parkPaper(); + ], + }, + blank_page_interpretation: { + invoke: pollPaperStatus(), + initial: 'presenting_paper', + // These nested states differ slightly from the top-level paper load states so we can't reuse the latter. + states: { + presenting_paper: { + // Heavier paper can fail to eject completely and trigger a jam state even though the paper isn't physically jammed. + // To work around this, we avoid ejecting to front. Instead, we present the paper so it's held by the device in a + // stable non-jam state. We instruct the poll worker to remove the paper directly from the 'presenting' state. + entry: async (context) => { + await context.logger.log( + LogEventId.BlankInterpretation, + 'system' + ); + await context.driver.presentPaper(); + }, + on: { NO_PAPER_ANYWHERE: 'accepting_paper' }, }, - on: { - PAPER_PARKED: { - target: 'done', - actions: () => { - assign({ - interpretation: undefined, - scannedImagePaths: undefined, - }); + accepting_paper: { + on: { + PAPER_READY_TO_LOAD: 'load_paper', + }, + }, + load_paper: { + entry: async (context) => { + await context.driver.loadPaper(); + await context.driver.parkPaper(); + }, + on: { + PAPER_PARKED: { + target: 'done', + actions: () => { + assign({ + interpretation: undefined, + scannedImagePaths: undefined, + }); + }, }, + NO_PAPER_ANYWHERE: 'accepting_paper', }, - NO_PAPER_ANYWHERE: 'accepting_paper', + }, + done: { + type: 'final', }, }, - done: { - type: 'final', - }, - }, - onDone: 'paper_reloaded', - }, - // `paper_reloaded` could be a substate of `blank_page_interpretation` but by keeping - // them separate we can avoid exposing all the substates of `blank_page_interpretation` - // to the frontend. - paper_reloaded: { - invoke: pollAuthStatus(), - on: { - AUTH_STATUS_CARDLESS_VOTER: 'waiting_for_ballot_data', + onDone: 'paper_reloaded', }, - }, - presenting_ballot: { - invoke: pollPaperStatus(), - entry: async (context) => { - await context.driver.presentPaper(); - }, - on: { - VOTER_VALIDATED_BALLOT: 'eject_to_rear', - VOTER_INVALIDATED_BALLOT: - 'waiting_for_invalidated_ballot_confirmation', - NO_PAPER_ANYWHERE: { - cond: () => - !isFeatureFlagEnabled( - BooleanEnvironmentVariableName.USE_MOCK_PAPER_HANDLER - ), - target: 'resetting_state_machine_after_success', + // `paper_reloaded` could be a substate of `blank_page_interpretation` but by keeping + // them separate we can avoid exposing all the substates of `blank_page_interpretation` + // to the frontend. + paper_reloaded: { + invoke: pollAuthStatus(), + on: { + AUTH_STATUS_CARDLESS_VOTER: 'waiting_for_ballot_data', }, }, - }, - waiting_for_invalidated_ballot_confirmation: { - initial: 'paper_present', - states: { - paper_present: { - invoke: pollPaperStatus(), - on: { - NO_PAPER_ANYWHERE: 'paper_absent', - }, + presenting_ballot: { + invoke: pollPaperStatus(), + entry: async (context) => { + await context.driver.presentPaper(); }, - paper_absent: { - on: { - POLL_WORKER_CONFIRMED_INVALIDATED_BALLOT: 'done', + on: { + VOTER_VALIDATED_BALLOT: 'eject_to_rear', + VOTER_INVALIDATED_BALLOT: + 'waiting_for_invalidated_ballot_confirmation', + NO_PAPER_ANYWHERE: { + cond: () => + !isFeatureFlagEnabled( + BooleanEnvironmentVariableName.USE_MOCK_PAPER_HANDLER + ), + target: 'resetting_state_machine_after_success', }, }, - done: { - type: 'final', + }, + waiting_for_invalidated_ballot_confirmation: { + initial: 'paper_present', + states: { + paper_present: { + invoke: pollPaperStatus(), + on: { + NO_PAPER_ANYWHERE: 'paper_absent', + }, + }, + paper_absent: { + on: { + POLL_WORKER_CONFIRMED_INVALIDATED_BALLOT: 'done', + }, + }, + done: { + type: 'final', + }, }, + onDone: 'accepting_paper', }, - onDone: 'accepting_paper', - }, - // Eject-to-rear jam handling is a little clunky. It - // 1. Tries to transition to success if no paper is detected - // 2. If after a timeout we have not transitioned away (because paper still present), transition to jammed state - // 3. Jam detection state transitions to jam reset state once it confirms no paper present - eject_to_rear: { - invoke: pollPaperStatus(), - entry: async (context) => { - if ( - isFeatureFlagEnabled( - BooleanEnvironmentVariableName.USE_MOCK_PAPER_HANDLER - ) - ) { - return; - } + // Eject-to-rear jam handling is a little clunky. It + // 1. Tries to transition to success if no paper is detected + // 2. If after a timeout we have not transitioned away (because paper still present), transition to jammed state + // 3. Jam detection state transitions to jam reset state once it confirms no paper present + eject_to_rear: { + invoke: pollPaperStatus(), + entry: async (context) => { + if ( + isFeatureFlagEnabled( + BooleanEnvironmentVariableName.USE_MOCK_PAPER_HANDLER + ) + ) { + return; + } - await context.driver.parkPaper(); - await context.driver.ejectBallotToRear(); - }, - on: { - NO_PAPER_ANYWHERE: 'ballot_accepted', - PAPER_JAM: 'jammed', - }, - after: { - [DELAY_BEFORE_DECLARING_REAR_JAM_MS]: 'jammed', + await context.driver.parkPaper(); + await context.driver.ejectBallotToRear(); + }, + on: { + NO_PAPER_ANYWHERE: 'ballot_accepted', + PAPER_JAM: 'jammed', + }, + after: { + [DELAY_BEFORE_DECLARING_REAR_JAM_MS]: 'jammed', + }, }, - }, - ballot_accepted: { - entry: (context) => { - const { store } = context.workspace; - context.workspace.store.setBallotsCastSinceLastBoxChange( - store.getBallotsCastSinceLastBoxChange() + 1 - ); + ballot_accepted: { + entry: (context) => { + const { store } = context.workspace; + context.workspace.store.setBallotsCastSinceLastBoxChange( + store.getBallotsCastSinceLastBoxChange() + 1 + ); + }, + after: { + [initialContext.notificationDurationMs]: + 'resetting_state_machine_after_success', + }, }, - after: { - [initialContext.notificationDurationMs]: - 'resetting_state_machine_after_success', + eject_to_front: { + invoke: pollPaperStatus(), + entry: ['ejectPaperToFront'], + on: { + NO_PAPER_ANYWHERE: 'resetting_state_machine_after_success', + }, }, - }, - eject_to_front: { - invoke: pollPaperStatus(), - entry: async (context) => { - await context.driver.ejectPaperToFront(); + jammed: { + invoke: pollPaperStatus(), + on: { + NO_PAPER_ANYWHERE: 'jam_physically_cleared', + }, }, - on: { - NO_PAPER_ANYWHERE: 'resetting_state_machine_after_success', + jam_physically_cleared: { + invoke: { + id: 'resetScanAndDriver', + src: (context) => { + // Issues `reset scan` command, creates a new WebUSBDevice, and reconnects + return resetAndReconnect(context.driver); + }, + onDone: { + target: 'resetting_state_machine_after_jam', + actions: assign({ + // Overwrites the old nonfunctional driver in context with the new functional one + driver: (_, event) => event.data, + }), + }, + }, }, - }, - jammed: { - invoke: pollPaperStatus(), - on: { - NO_PAPER_ANYWHERE: 'jam_physically_cleared', + resetting_state_machine_no_delay: { + entry: ['resetContext', 'endCardlessVoterAuth'], + always: 'not_accepting_paper', }, - }, - jam_physically_cleared: { - invoke: { - id: 'resetScanAndDriver', - src: (context) => { - // Issues `reset scan` command, creates a new WebUSBDevice, and reconnects - return resetAndReconnect(context.driver); + resetting_state_machine_after_jam: { + entry: ['resetContext', 'endCardlessVoterAuth'], + after: { + [NOTIFICATION_DURATION_MS]: 'not_accepting_paper', }, - onDone: { - target: 'resetting_state_machine_after_jam', - actions: assign({ - // Overwrites the old nonfunctional driver in context with the new functional one - driver: (_, event) => event.data, - }), - }, - }, - }, - resetting_state_machine_after_jam: { - entry: async (context) => { - await auth.endCardlessVoterSession( - constructAuthMachineState(context.workspace) - ); - - assign({ - interpretation: undefined, - scannedImagePaths: undefined, - isPatDeviceConnected: false, - }); }, - after: { - // The frontend needs time to idle in this state so the user can read the status message - [RESET_AFTER_JAM_DELAY_MS]: 'not_accepting_paper', + resetting_state_machine_after_success: { + entry: ['resetContext', 'endCardlessVoterAuth'], + always: [ + { + target: 'empty_ballot_box', + cond: (context) => + context.workspace.store.getBallotsCastSinceLastBoxChange() >= + MAX_BALLOT_BOX_CAPACITY, + }, + { target: 'not_accepting_paper' }, + ], }, - }, - resetting_state_machine_after_success: { - entry: async (context) => { - await auth.endCardlessVoterSession( - constructAuthMachineState(context.workspace) - ); - - assign({ - interpretation: undefined, - scannedImagePaths: undefined, - isPatDeviceConnected: false, - }); + poll_worker_auth_ended_unexpectedly: { + entry: [ + 'resetContext', + 'endCardlessVoterAuth', + 'ejectPaperToFront', + ], + after: { + // The frontend needs time to idle in this state so the user can read the status message + [NOTIFICATION_DURATION_MS]: 'not_accepting_paper', + }, }, - always: [ - { - target: 'empty_ballot_box', - cond: (context) => - context.workspace.store.getBallotsCastSinceLastBoxChange() >= - MAX_BALLOT_BOX_CAPACITY, + // The flow to empty a full ballot box. Can only occur at the end of a voting session. + empty_ballot_box: { + on: { + POLL_WORKER_CONFIRMED_BALLOT_BOX_EMPTIED: 'not_accepting_paper', }, - { target: 'not_accepting_paper' }, - ], - }, - // The flow to empty a full ballot box. Can only occur at the end of a voting session. - empty_ballot_box: { - on: { - POLL_WORKER_CONFIRMED_BALLOT_BOX_EMPTIED: 'not_accepting_paper', }, }, }, - }, - pat_device_disconnected: { - always: 'voting_flow.history', - }, - pat_device_connected: { - on: { - VOTER_CONFIRMED_PAT_DEVICE_CALIBRATION: 'voting_flow.history', - PAT_DEVICE_DISCONNECTED: 'pat_device_disconnected', - PAT_DEVICE_CONNECTED: undefined, + pat_device_disconnected: { + always: 'voting_flow.history', + }, + pat_device_connected: { + on: { + VOTER_CONFIRMED_PAT_DEVICE_CALIBRATION: 'voting_flow.history', + PAT_DEVICE_DISCONNECTED: 'pat_device_disconnected', + PAT_DEVICE_CONNECTED: undefined, + }, }, }, }, - }); + { + actions: { + ejectPaperToFront: async (context) => { + await context.driver.ejectPaperToFront(); + }, + resetContext: () => { + assign({ + interpretation: undefined, + scannedImagePaths: undefined, + isPatDeviceConnected: false, + }); + }, + endCardlessVoterAuth: async (context) => { + await auth.endCardlessVoterSession( + constructAuthMachineState(context.workspace) + ); + }, + }, + } + ); } function setUpLogging( @@ -863,7 +880,7 @@ export async function getPaperHandlerStateMachine({ deviceTimeoutMs = DEVICE_STATUS_POLLING_TIMEOUT_MS, devicePollingIntervalMs = DEVICE_STATUS_POLLING_INTERVAL_MS, authPollingIntervalMs = AUTH_STATUS_POLLING_INTERVAL_MS, - notificationDurationMs = SUCCESS_NOTIFICATION_DURATION_MS, + notificationDurationMs = NOTIFICATION_DURATION_MS, }: { workspace: Workspace; auth: InsertedSmartCardAuthApi; @@ -908,6 +925,9 @@ export async function getPaperHandlerStateMachine({ switch (true) { case state.matches('voting_flow.not_accepting_paper'): + case state.matches('voting_flow.resetting_state_machine_no_delay'): + // Frontend has nothing to render for resetting_state_machine_no_delay + // so to avoid flicker we just return the state it's guaranteed to transition to return 'not_accepting_paper'; case state.matches('voting_flow.accepting_paper'): return 'accepting_paper'; @@ -946,6 +966,8 @@ export async function getPaperHandlerStateMachine({ case state.matches('voting_flow.resetting_state_machine_after_success'): /* istanbul ignore next - nonblocking state can't be reliably asserted on. Assert on business logic eg. jest mock function calls instead */ return 'resetting_state_machine_after_success'; + case state.matches('voting_flow.poll_worker_auth_ended_unexpectedly'): + return 'poll_worker_auth_ended_unexpectedly'; case state.matches('voting_flow.empty_ballot_box'): return 'empty_ballot_box'; case state.matches('voting_flow.transition_interpretation'): diff --git a/apps/mark-scan/backend/src/custom-paper-handler/types.ts b/apps/mark-scan/backend/src/custom-paper-handler/types.ts index 9fba8c491d..9b6fdb18b2 100644 --- a/apps/mark-scan/backend/src/custom-paper-handler/types.ts +++ b/apps/mark-scan/backend/src/custom-paper-handler/types.ts @@ -16,8 +16,10 @@ export type SimpleStatus = | 'pat_device_connected' | 'presenting_ballot' | 'printing_ballot' + | 'resetting_state_machine_no_delay' | 'resetting_state_machine_after_jam' | 'resetting_state_machine_after_success' + | 'poll_worker_auth_ended_unexpectedly' | 'scanning' | 'transition_interpretation' | 'waiting_for_ballot_data' @@ -40,8 +42,10 @@ export const SimpleStatusSchema: z.ZodSchema = z.union([ z.literal('pat_device_connected'), z.literal('presenting_ballot'), z.literal('printing_ballot'), + z.literal('resetting_state_machine_no_delay'), z.literal('resetting_state_machine_after_jam'), z.literal('resetting_state_machine_after_success'), + z.literal('poll_worker_auth_ended_unexpectedly'), z.literal('scanning'), z.literal('transition_interpretation'), z.literal('waiting_for_ballot_data'), diff --git a/apps/mark-scan/backend/src/server.ts b/apps/mark-scan/backend/src/server.ts index b4a7aa6af5..65d97137c6 100644 --- a/apps/mark-scan/backend/src/server.ts +++ b/apps/mark-scan/backend/src/server.ts @@ -20,7 +20,7 @@ import { getDefaultAuth, getUserRole } from './util/auth'; import { DEV_AUTH_STATUS_POLLING_INTERVAL_MS, DEV_DEVICE_STATUS_POLLING_INTERVAL_MS, - SUCCESS_NOTIFICATION_DURATION_MS, + NOTIFICATION_DURATION_MS, } from './custom-paper-handler/constants'; import { PatConnectionStatusReader } from './pat-input/connection_status_reader'; import { MockPatConnectionStatusReader } from './pat-input/mock_connection_status_reader'; @@ -93,7 +93,7 @@ export async function start({ patConnectionStatusReader, devicePollingIntervalMs: DEV_DEVICE_STATUS_POLLING_INTERVAL_MS, authPollingIntervalMs: DEV_AUTH_STATUS_POLLING_INTERVAL_MS, - notificationDurationMs: SUCCESS_NOTIFICATION_DURATION_MS, + notificationDurationMs: NOTIFICATION_DURATION_MS, }); } diff --git a/apps/mark-scan/backend/test/app_helpers.ts b/apps/mark-scan/backend/test/app_helpers.ts index 1011a78ab5..4a106d575a 100644 --- a/apps/mark-scan/backend/test/app_helpers.ts +++ b/apps/mark-scan/backend/test/app_helpers.ts @@ -37,7 +37,7 @@ import { import { AUTH_STATUS_POLLING_INTERVAL_MS, DEVICE_STATUS_POLLING_INTERVAL_MS, - SUCCESS_NOTIFICATION_DURATION_MS, + NOTIFICATION_DURATION_MS, } from '../src/custom-paper-handler/constants'; import { PatConnectionStatusReaderInterface } from '../src/pat-input/connection_status_reader'; import { getUserRole } from '../src/util/auth'; @@ -70,8 +70,7 @@ export async function getMockStateMachine( devicePollingIntervalMs: pollingIntervalMs ?? DEVICE_STATUS_POLLING_INTERVAL_MS, authPollingIntervalMs: pollingIntervalMs ?? AUTH_STATUS_POLLING_INTERVAL_MS, - notificationDurationMs: - pollingIntervalMs ?? SUCCESS_NOTIFICATION_DURATION_MS, + notificationDurationMs: pollingIntervalMs ?? NOTIFICATION_DURATION_MS, }); assert(stateMachine); diff --git a/apps/mark-scan/frontend/src/app_root.tsx b/apps/mark-scan/frontend/src/app_root.tsx index ec9ef0ac7b..d6a98989f9 100644 --- a/apps/mark-scan/frontend/src/app_root.tsx +++ b/apps/mark-scan/frontend/src/app_root.tsx @@ -77,6 +77,7 @@ import { PatDeviceCalibrationPage } from './pages/pat_device_identification/pat_ import { CastingBallotPage } from './pages/casting_ballot_page'; import { BallotSuccessfullyCastPage } from './pages/ballot_successfully_cast_page'; import { EmptyBallotBoxPage } from './pages/empty_ballot_box_page'; +import { PollWorkerAuthEndedUnexpectedlyPage } from './pages/poll_worker_auth_ended_unexpectedly_page'; import { LOW_BATTERY_THRESHOLD } from './constants'; interface VotingState { @@ -430,6 +431,15 @@ export function AppRoot({ reload }: Props): JSX.Element | null { return ; } + if ( + stateMachineState === 'poll_worker_auth_ended_unexpectedly' || + // Handle when the frontend auth state is up to date but the state machine state is not + (stateMachineState === 'loading_paper' && + (isCardlessVoterAuth(authStatus) || authStatus.status === 'logged_out')) + ) { + return ; + } + if (optionalElectionDefinition && precinctSelection) { if ( authStatus.status === 'logged_out' && @@ -513,7 +523,13 @@ export function AppRoot({ reload }: Props): JSX.Element | null { } if (pollsState === 'polls_open') { - if (isCardlessVoterAuth(authStatus)) { + if ( + isCardlessVoterAuth(authStatus) && + // accepting_paper expects poll worker auth. If the frontend sees accepting_paper but has cardless voter auth, + // it means the state hasn't caught up to auth changes. We check that edge case here to avoid flicker ie. + // rendering the ballot briefly before rendering the correct "Insert Card" screen + stateMachineState !== 'accepting_paper' + ) { 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/app_states.test.tsx b/apps/mark-scan/frontend/src/app_states.test.tsx index 9837513656..7c86e2f710 100644 --- a/apps/mark-scan/frontend/src/app_states.test.tsx +++ b/apps/mark-scan/frontend/src/app_states.test.tsx @@ -159,14 +159,14 @@ test('`empty_ballot_box` state renders EmptyBallotBoxPage', async () => { await screen.findByText('Ballot Box Full'); }); -const testSpecs: Array<{ +const ballotCastPageTestSpecs: Array<{ state: SimpleServerStatus; }> = [ { state: 'ballot_accepted' }, { state: 'resetting_state_machine_after_success' }, ]; -test.each(testSpecs)( +test.each(ballotCastPageTestSpecs)( '$state state renders BallotSuccessfullyCastPage', async ({ state }) => { apiMock.mockApiClient.getElectionState.reset(); @@ -184,3 +184,33 @@ test.each(testSpecs)( await screen.findByText('Thank you for voting.'); } ); + +const authEndedEarlyPageTestSpecs: Array<{ + state: SimpleServerStatus; + auth: 'cardless_voter' | 'logged_out'; +}> = [ + { state: 'poll_worker_auth_ended_unexpectedly', auth: 'cardless_voter' }, + { state: 'poll_worker_auth_ended_unexpectedly', auth: 'logged_out' }, + { state: 'loading_paper', auth: 'cardless_voter' }, + { state: 'loading_paper', auth: 'logged_out' }, +]; + +test.each(authEndedEarlyPageTestSpecs)( + '$state state renders PollWorkerAuthEndedUnexpectedlyPage', + async ({ state, auth }) => { + apiMock.setPaperHandlerState(state); + if (auth === 'cardless_voter') { + apiMock.setAuthStatusCardlessVoterLoggedInWithDefaults( + electionDefinition + ); + } else { + apiMock.setAuthStatusLoggedOut(); + } + + render(); + + await screen.findByText( + 'The poll worker card was removed before paper loading completed. Please try again.' + ); + } +); diff --git a/apps/mark-scan/frontend/src/pages/poll_worker_auth_ended_unexpectedly_page.tsx b/apps/mark-scan/frontend/src/pages/poll_worker_auth_ended_unexpectedly_page.tsx new file mode 100644 index 0000000000..616e8d1c41 --- /dev/null +++ b/apps/mark-scan/frontend/src/pages/poll_worker_auth_ended_unexpectedly_page.tsx @@ -0,0 +1,10 @@ +import { appStrings, P } from '@votingworks/ui'; +import { CenteredPageLayout } from '../components/centered_page_layout'; + +export function PollWorkerAuthEndedUnexpectedlyPage(): JSX.Element { + return ( + +

{appStrings.notePollWorkerAuthEndedBeforePaperLoadComplete()}

+
+ ); +} diff --git a/libs/ui/src/ui_strings/app_strings.tsx b/libs/ui/src/ui_strings/app_strings.tsx index 4b67dc9484..d3eafa8a4d 100644 --- a/libs/ui/src/ui_strings/app_strings.tsx +++ b/libs/ui/src/ui_strings/app_strings.tsx @@ -780,6 +780,13 @@ export const appStrings = { Audio is on ), + notePollWorkerAuthEndedBeforePaperLoadComplete: () => ( + + The poll worker card was removed before paper loading completed. Please + try again. + + ), + promptBmdConfirmRemoveWriteIn: () => ( Do you want to deselect and remove your write-in candidate? diff --git a/libs/ui/src/ui_strings/app_strings_catalog/latest.json b/libs/ui/src/ui_strings/app_strings_catalog/latest.json index 94ef7eda46..e71151787d 100644 --- a/libs/ui/src/ui_strings/app_strings_catalog/latest.json +++ b/libs/ui/src/ui_strings/app_strings_catalog/latest.json @@ -158,6 +158,7 @@ "noteBmdPatCalibrationStep2": "Step 2 of 3", "noteBmdPatCalibrationStep3": "Step 3 of 3", "noteBmdSessionRestart": "Your voting session will restart shortly.", + "notePollWorkerAuthEndedBeforePaperLoadComplete": "The poll worker card was removed before paper loading completed. Please try again.", "noteScannerBlankContestsCardPlural": "Did you mean to leave these contests blank?", "noteScannerBlankContestsCardSingular": "Did you mean to leave this contest blank?", "noteScannerOvervoteContestsCardPlural": "Your votes in these contests will not be counted.",