From a3113deb21016ad60923c38e640d4f97b7740c3a Mon Sep 17 00:00:00 2001 From: Kevin Shen Date: Thu, 7 Mar 2024 11:32:27 -0800 Subject: [PATCH 1/9] add state for when poll worker auth is ended too early --- .../cli/state_machine_cli.ts | 4 +- .../src/custom-paper-handler/constants.ts | 6 +- .../state_machine.test.ts | 10 + .../src/custom-paper-handler/state_machine.ts | 673 +++++++++--------- .../backend/src/custom-paper-handler/types.ts | 2 + 5 files changed, 360 insertions(+), 335 deletions(-) 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..0264fdf9a2 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 @@ -443,3 +443,13 @@ describe('PAT device', () => { ); }); }); + +test('poll_worker_auth_ended_unexpectedly', async () => { + machine.setAcceptingPaper(); + const ballotStyle = electionGeneralDefinition.election.ballotStyles[1]; + mockCardlessVoterAuth(auth, { + ballotStyleId: ballotStyle.id, + precinctId, + }); + await waitForStatus('poll_worker_auth_ended_unexpectedly'); +}); 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..b4dc973dce 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,393 @@ 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: + 'poll_worker_auth_ended_unexpectedly', + }, + }, + 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', - }, - { - target: 'blank_page_interpretation', - cond: (context) => - assertDefined(context.interpretation)[0].interpretation - .type === 'BlankPage', + // 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}` + ); }, - ], - }, - 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' }, + }, + accepting_paper: { + on: { + PAPER_READY_TO_LOAD: 'load_paper', + }, }, - on: { - PAPER_PARKED: { - target: 'done', - actions: () => { - assign({ - interpretation: undefined, - scannedImagePaths: undefined, - }); + 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', }, - 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', - }, - }, - 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', - }, - }, - ballot_accepted: { - entry: (context) => { - const { store } = context.workspace; - context.workspace.store.setBallotsCastSinceLastBoxChange( - store.getBallotsCastSinceLastBoxChange() + 1 - ); - }, - after: { - [initialContext.notificationDurationMs]: - 'resetting_state_machine_after_success', - }, - }, - eject_to_front: { - invoke: pollPaperStatus(), - entry: async (context) => { - await context.driver.ejectPaperToFront(); + 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', + }, }, - on: { - NO_PAPER_ANYWHERE: 'resetting_state_machine_after_success', + ballot_accepted: { + entry: (context) => { + const { store } = context.workspace; + context.workspace.store.setBallotsCastSinceLastBoxChange( + store.getBallotsCastSinceLastBoxChange() + 1 + ); + }, + after: { + [initialContext.notificationDurationMs]: + 'resetting_state_machine_after_success', + }, }, - }, - jammed: { - invoke: pollPaperStatus(), - on: { - NO_PAPER_ANYWHERE: 'jam_physically_cleared', + eject_to_front: { + invoke: pollPaperStatus(), + entry: async (context) => { + await context.driver.ejectPaperToFront(); + }, + 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); + jammed: { + invoke: pollPaperStatus(), + on: { + NO_PAPER_ANYWHERE: 'jam_physically_cleared', }, - 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, - }), + }, + 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, + }), + }, }, }, - }, - resetting_state_machine_after_jam: { - entry: async (context) => { - await auth.endCardlessVoterSession( - constructAuthMachineState(context.workspace) - ); - - assign({ - interpretation: undefined, - scannedImagePaths: undefined, - isPatDeviceConnected: false, - }); + resetting_state_machine_after_jam: { + entry: ['resetContext', 'endCardlessVoterAuth'], + after: { + [NOTIFICATION_DURATION_MS]: 'not_accepting_paper', + }, }, - 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'], + 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: { + resetContext: () => { + assign({ + interpretation: undefined, + scannedImagePaths: undefined, + isPatDeviceConnected: false, + }); + }, + endCardlessVoterAuth: async (context) => { + await auth.endCardlessVoterSession( + constructAuthMachineState(context.workspace) + ); + }, + }, + } + ); } function setUpLogging( @@ -863,7 +872,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; @@ -946,6 +955,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..c04e9d9d31 100644 --- a/apps/mark-scan/backend/src/custom-paper-handler/types.ts +++ b/apps/mark-scan/backend/src/custom-paper-handler/types.ts @@ -18,6 +18,7 @@ export type SimpleStatus = | 'printing_ballot' | 'resetting_state_machine_after_jam' | 'resetting_state_machine_after_success' + | 'poll_worker_auth_ended_unexpectedly' | 'scanning' | 'transition_interpretation' | 'waiting_for_ballot_data' @@ -42,6 +43,7 @@ export const SimpleStatusSchema: z.ZodSchema = z.union([ z.literal('printing_ballot'), 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'), From 7e3220ef906e47f9b68e69105b675fe931da78b7 Mon Sep 17 00:00:00 2001 From: Kevin Shen Date: Thu, 7 Mar 2024 11:42:41 -0800 Subject: [PATCH 2/9] update references to changed const name --- apps/mark-scan/backend/src/server.ts | 4 ++-- apps/mark-scan/backend/test/app_helpers.ts | 5 ++--- 2 files changed, 4 insertions(+), 5 deletions(-) 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); From 7143038198ac01c5c834279de49bde4091b2e9e0 Mon Sep 17 00:00:00 2001 From: Kevin Shen Date: Thu, 7 Mar 2024 11:47:27 -0800 Subject: [PATCH 3/9] add frontend state --- apps/mark-scan/frontend/src/app_root.tsx | 13 +++++++ .../frontend/src/app_states.test.tsx | 36 +++++++++++++++++-- ...ll_worker_auth_ended_unexpectedly_page.tsx | 10 ++++++ libs/ui/src/ui_strings/app_strings.tsx | 7 ++++ .../app_strings_catalog/latest.json | 1 + 5 files changed, 65 insertions(+), 2 deletions(-) create mode 100644 apps/mark-scan/frontend/src/pages/poll_worker_auth_ended_unexpectedly_page.tsx diff --git a/apps/mark-scan/frontend/src/app_root.tsx b/apps/mark-scan/frontend/src/app_root.tsx index ec9ef0ac7b..24e65a9fd6 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,18 @@ export function AppRoot({ reload }: Props): JSX.Element | null { return ; } + const isInPaperLoadingStage = + stateMachineState === 'accepting_paper' || + stateMachineState === 'loading_paper'; + if ( + stateMachineState === 'poll_worker_auth_ended_unexpectedly' || + // Handle when the frontend auth state is up to date but the state machine state is not + (isInPaperLoadingStage && + (isCardlessVoterAuth(authStatus) || authStatus.status === 'logged_out')) + ) { + return ; + } + if (optionalElectionDefinition && precinctSelection) { if ( authStatus.status === 'logged_out' && diff --git a/apps/mark-scan/frontend/src/app_states.test.tsx b/apps/mark-scan/frontend/src/app_states.test.tsx index 9837513656..2984cac976 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,35 @@ 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: 'accepting_paper', auth: 'cardless_voter' }, + { state: 'accepting_paper', 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.", From 6d08701109d824c136e9b895cd1a871bec87a2eb Mon Sep 17 00:00:00 2001 From: Kevin Shen Date: Mon, 18 Mar 2024 13:36:38 -0700 Subject: [PATCH 4/9] return to previous screen if paper load hasn't been attempted --- .../backend/src/custom-paper-handler/state_machine.ts | 3 +-- apps/mark-scan/frontend/src/app_root.tsx | 5 +---- 2 files changed, 2 insertions(+), 6 deletions(-) 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 b4dc973dce..92a6c30801 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 @@ -464,8 +464,7 @@ export function buildMachine( on: { PAPER_READY_TO_LOAD: 'loading_paper', PAPER_PARKED: 'waiting_for_ballot_data', - AUTH_STATUS_CARDLESS_VOTER: - 'poll_worker_auth_ended_unexpectedly', + AUTH_STATUS_CARDLESS_VOTER: 'not_accepting_paper', }, }, loading_paper: { diff --git a/apps/mark-scan/frontend/src/app_root.tsx b/apps/mark-scan/frontend/src/app_root.tsx index 24e65a9fd6..7de8dd7c6c 100644 --- a/apps/mark-scan/frontend/src/app_root.tsx +++ b/apps/mark-scan/frontend/src/app_root.tsx @@ -431,13 +431,10 @@ export function AppRoot({ reload }: Props): JSX.Element | null { return ; } - const isInPaperLoadingStage = - stateMachineState === 'accepting_paper' || - stateMachineState === 'loading_paper'; if ( stateMachineState === 'poll_worker_auth_ended_unexpectedly' || // Handle when the frontend auth state is up to date but the state machine state is not - (isInPaperLoadingStage && + (stateMachineState === 'loading_paper' && (isCardlessVoterAuth(authStatus) || authStatus.status === 'logged_out')) ) { return ; From 94a4f3e73ccf7fc197de8ad9f2d7a075ea35e1b2 Mon Sep 17 00:00:00 2001 From: Kevin Shen Date: Mon, 18 Mar 2024 13:59:21 -0700 Subject: [PATCH 5/9] reset auth and context --- .../backend/src/custom-paper-handler/state_machine.ts | 8 +++++++- apps/mark-scan/backend/src/custom-paper-handler/types.ts | 2 ++ apps/mark-scan/frontend/src/app_root.tsx | 3 +++ 3 files changed, 12 insertions(+), 1 deletion(-) 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 92a6c30801..d215879101 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 @@ -464,7 +464,7 @@ export function buildMachine( on: { PAPER_READY_TO_LOAD: 'loading_paper', PAPER_PARKED: 'waiting_for_ballot_data', - AUTH_STATUS_CARDLESS_VOTER: 'not_accepting_paper', + AUTH_STATUS_CARDLESS_VOTER: 'resetting_state_machine_no_delay', }, }, loading_paper: { @@ -726,6 +726,10 @@ export function buildMachine( }, }, }, + resetting_state_machine_no_delay: { + entry: ['resetContext', 'endCardlessVoterAuth'], + always: 'not_accepting_paper', + }, resetting_state_machine_after_jam: { entry: ['resetContext', 'endCardlessVoterAuth'], after: { @@ -947,6 +951,8 @@ export async function getPaperHandlerStateMachine({ return 'jammed'; case state.matches('voting_flow.jam_physically_cleared'): return 'jam_cleared'; + case state.matches('voting_flow.resetting_state_machine_no_delay'): + return 'resetting_state_machine_no_delay'; case state.matches('voting_flow.resetting_state_machine_after_jam'): return 'resetting_state_machine_after_jam'; case state.matches('voting_flow.ballot_accepted'): 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 c04e9d9d31..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,6 +16,7 @@ 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' @@ -41,6 +42,7 @@ 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'), diff --git a/apps/mark-scan/frontend/src/app_root.tsx b/apps/mark-scan/frontend/src/app_root.tsx index 7de8dd7c6c..cf9f2f94a7 100644 --- a/apps/mark-scan/frontend/src/app_root.tsx +++ b/apps/mark-scan/frontend/src/app_root.tsx @@ -555,6 +555,9 @@ export function AppRoot({ reload }: Props): JSX.Element | null { } } + // Implicitly map resetting_state_machine_no_delay to return InsertCardScreen + // because that state implies we are returning to the starting (default) state. + return ( /* istanbul ignore next */ window.kiosk?.quit()} From 3ba5c479b8a4617e1de988fc521302f75fff3303 Mon Sep 17 00:00:00 2001 From: Kevin Shen Date: Mon, 18 Mar 2024 14:10:50 -0700 Subject: [PATCH 6/9] handle case where state lags behind auth --- apps/mark-scan/frontend/src/app_root.tsx | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/apps/mark-scan/frontend/src/app_root.tsx b/apps/mark-scan/frontend/src/app_root.tsx index cf9f2f94a7..b515e1ee0e 100644 --- a/apps/mark-scan/frontend/src/app_root.tsx +++ b/apps/mark-scan/frontend/src/app_root.tsx @@ -523,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. From 09ffd911c38fca67f459a4f03af81b9d5dc6b32b Mon Sep 17 00:00:00 2001 From: Kevin Shen Date: Mon, 18 Mar 2024 15:23:30 -0700 Subject: [PATCH 7/9] update tests --- .../state_machine.test.ts | 21 +++++++++++++++++++ .../src/custom-paper-handler/state_machine.ts | 1 + .../frontend/src/app_states.test.tsx | 2 -- 3 files changed, 22 insertions(+), 2 deletions(-) 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 0264fdf9a2..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 { @@ -444,12 +445,32 @@ 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 d215879101..25ec58c580 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 @@ -952,6 +952,7 @@ export async function getPaperHandlerStateMachine({ case state.matches('voting_flow.jam_physically_cleared'): return 'jam_cleared'; case state.matches('voting_flow.resetting_state_machine_no_delay'): + /* 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_no_delay'; case state.matches('voting_flow.resetting_state_machine_after_jam'): return 'resetting_state_machine_after_jam'; diff --git a/apps/mark-scan/frontend/src/app_states.test.tsx b/apps/mark-scan/frontend/src/app_states.test.tsx index 2984cac976..7c86e2f710 100644 --- a/apps/mark-scan/frontend/src/app_states.test.tsx +++ b/apps/mark-scan/frontend/src/app_states.test.tsx @@ -191,8 +191,6 @@ const authEndedEarlyPageTestSpecs: Array<{ }> = [ { state: 'poll_worker_auth_ended_unexpectedly', auth: 'cardless_voter' }, { state: 'poll_worker_auth_ended_unexpectedly', auth: 'logged_out' }, - { state: 'accepting_paper', auth: 'cardless_voter' }, - { state: 'accepting_paper', auth: 'logged_out' }, { state: 'loading_paper', auth: 'cardless_voter' }, { state: 'loading_paper', auth: 'logged_out' }, ]; From 8b2a91a33d4702ac7ef4fa9f2723a8aab9f9376d Mon Sep 17 00:00:00 2001 From: Kevin Shen Date: Tue, 19 Mar 2024 11:00:49 -0700 Subject: [PATCH 8/9] eject paper to front as soon as poll worker card is pulled --- .../src/custom-paper-handler/state_machine.ts | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) 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 25ec58c580..9e096566aa 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 @@ -697,9 +697,7 @@ export function buildMachine( }, eject_to_front: { invoke: pollPaperStatus(), - entry: async (context) => { - await context.driver.ejectPaperToFront(); - }, + entry: ['ejectPaperToFront'], on: { NO_PAPER_ANYWHERE: 'resetting_state_machine_after_success', }, @@ -749,7 +747,11 @@ export function buildMachine( ], }, poll_worker_auth_ended_unexpectedly: { - entry: ['resetContext', 'endCardlessVoterAuth'], + 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', @@ -777,6 +779,9 @@ export function buildMachine( }, { actions: { + ejectPaperToFront: async (context) => { + await context.driver.ejectPaperToFront(); + }, resetContext: () => { assign({ interpretation: undefined, From 80b0193548f15f61b0685553efcd675edfe4155b Mon Sep 17 00:00:00 2001 From: Kevin Shen Date: Tue, 19 Mar 2024 11:05:05 -0700 Subject: [PATCH 9/9] mask resetting_state_machine_no_delay --- .../backend/src/custom-paper-handler/state_machine.ts | 6 +++--- apps/mark-scan/frontend/src/app_root.tsx | 3 --- 2 files changed, 3 insertions(+), 6 deletions(-) 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 9e096566aa..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 @@ -925,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'; @@ -956,9 +959,6 @@ export async function getPaperHandlerStateMachine({ return 'jammed'; case state.matches('voting_flow.jam_physically_cleared'): return 'jam_cleared'; - case state.matches('voting_flow.resetting_state_machine_no_delay'): - /* 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_no_delay'; case state.matches('voting_flow.resetting_state_machine_after_jam'): return 'resetting_state_machine_after_jam'; case state.matches('voting_flow.ballot_accepted'): diff --git a/apps/mark-scan/frontend/src/app_root.tsx b/apps/mark-scan/frontend/src/app_root.tsx index b515e1ee0e..d6a98989f9 100644 --- a/apps/mark-scan/frontend/src/app_root.tsx +++ b/apps/mark-scan/frontend/src/app_root.tsx @@ -561,9 +561,6 @@ export function AppRoot({ reload }: Props): JSX.Element | null { } } - // Implicitly map resetting_state_machine_no_delay to return InsertCardScreen - // because that state implies we are returning to the starting (default) state. - return ( /* istanbul ignore next */ window.kiosk?.quit()}