Skip to content

Commit

Permalink
Add vertical streak detection controls (#5549)
Browse files Browse the repository at this point in the history
* fix(ballot-interpreter): correct JS argument marshaling

If `options` was given as `{ scoreWriteIns: undefined }`, the interpreter would fail to coerce the value as a `bool` but would ignore the error until some point in the future when interacting with the JavaScript context. This is because the `get::<JsBoolean>` call would put the context into a throwing state, but would not then bubble that `Throw` up to the caller. As a result, subsequent calls to the context would also yield `Throw` for operations that should have succeeded. In this case the error would be "failed to downcast any to boolean".

To fix this we need to not trigger throws when we don't intend to bubble them up. In particular, instead of `get::<JsBoolean>` we now `get_value()?.downcast::<JsBoolean>` so that we can bubble possible errors from the getter but then handle a mismatched type in Rust appopriately.

* feat(scan): allow disabling streak detection

Closes #5538

Adds a system setting that disables streak detection for an election. We might want this in case of a ballot design that triggers false positives when looking for streaks. Because we support taking someone else's AccuVote-style ballot, we may not be able to ensure no streaks are naturally present in the original image. This is unlikely, but this gives us an escape hatch.

* refactor(scan): access system settings directly

* fix(ballot-interpreter): pass `disableVerticalStreakDetection`

Ensures that the `disableVerticalStreakDetection` system setting is passed through from the CLI. Also adds a CLI option to disable streak detection.

* test(scan): ensure disabling streak detection works
  • Loading branch information
eventualbuddha authored Oct 24, 2024
1 parent 5800d86 commit 9801fad
Show file tree
Hide file tree
Showing 20 changed files with 481 additions and 73 deletions.
95 changes: 93 additions & 2 deletions apps/central-scan/backend/src/app.scanning.test.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,17 @@
import { electionFamousNames2021Fixtures } from '@votingworks/fixtures';
import {
electionFamousNames2021Fixtures,
electionGridLayoutNewHampshireTestBallotFixtures,
} from '@votingworks/fixtures';
import { loadImageData, writeImageData } from '@votingworks/image-utils';
import {
BallotPageInfo,
BatchInfo,
DEFAULT_SYSTEM_SETTINGS,
TEST_JURISDICTION,
} from '@votingworks/types';
import { mockElectionManagerAuth } from '../test/helpers/auth';
import { withApp } from '../test/helpers/setup_app';
import { generateBmdBallotFixture } from '../test/helpers/ballots';
import { withApp } from '../test/helpers/setup_app';
import { ScannedSheetInfo } from './fujitsu_scanner';

const jurisdiction = TEST_JURISDICTION;
Expand Down Expand Up @@ -111,3 +116,89 @@ test('continueScanning after invalid ballot', async () => {
}
});
});

test('scanBatch with streaked page', async () => {
const { electionDefinition, scanMarkedFront, scanMarkedBack } =
electionGridLayoutNewHampshireTestBallotFixtures;

const frontPath = scanMarkedFront.asFilePath();
const backPath = scanMarkedBack.asFilePath();

const frontImageData = await loadImageData(frontPath);

// add a vertical streak
for (
let offset = 500;
offset < frontImageData.data.length;
offset += frontImageData.width * 4
) {
frontImageData.data[offset] = 0;
frontImageData.data[offset + 1] = 0;
frontImageData.data[offset + 2] = 0;
frontImageData.data[offset + 3] = 255;
}

await writeImageData(frontPath, frontImageData);

const scannedBallot: ScannedSheetInfo = {
frontPath,
backPath,
};

// try with vertical streak detection enabled
await withApp(async ({ auth, apiClient, scanner, importer, workspace }) => {
mockElectionManagerAuth(auth, electionDefinition);
importer.configure(
electionDefinition,
jurisdiction,
'test-election-package-hash'
);
workspace.store.setSystemSettings({
...DEFAULT_SYSTEM_SETTINGS,
// enable vertical streak detection
disableVerticalStreakDetection: false,
});
await apiClient.setTestMode({ testMode: true });

scanner.withNextScannerSession().sheet(scannedBallot).end();

await apiClient.scanBatch();
await importer.waitForEndOfBatchOrScanningPause();

const nextAdjudicationSheet = workspace.store.getNextAdjudicationSheet();

// adjudication should be needed because of the vertical streak
expect(nextAdjudicationSheet?.front).toMatchObject<Partial<BallotPageInfo>>(
{
interpretation: {
type: 'UnreadablePage',
reason: 'verticalStreaksDetected',
},
}
);
});

// try again with vertical streak detection disabled
await withApp(async ({ auth, apiClient, scanner, importer, workspace }) => {
mockElectionManagerAuth(auth, electionDefinition);
importer.configure(
electionDefinition,
jurisdiction,
'test-election-package-hash'
);
workspace.store.setSystemSettings({
...DEFAULT_SYSTEM_SETTINGS,
// disable vertical streak detection
disableVerticalStreakDetection: true,
});
await apiClient.setTestMode({ testMode: true });

scanner.withNextScannerSession().sheet(scannedBallot).end();

await apiClient.scanBatch();
await importer.waitForEndOfBatchOrScanningPause();

// no adjudication should be needed
expect(workspace.store.getNextAdjudicationSheet()).toBeUndefined();
});
});
12 changes: 8 additions & 4 deletions apps/central-scan/backend/src/importer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -188,18 +188,22 @@ export class Importer {
): Promise<Result<SheetOf<PageInterpretationWithFiles>, Error>> {
const electionDefinition = this.getElectionDefinition();
const { store } = this.workspace;
const {
allowOfficialBallotsInTestMode,
disableVerticalStreakDetection,
markThresholds,
} = assertDefined(store.getSystemSettings());

return ok(
await interpretSheetAndSaveImages(
{
electionDefinition,
precinctSelection: ALL_PRECINCTS_SELECTION,
testMode: store.getTestMode(),
disableVerticalStreakDetection,
adjudicationReasons: store.getAdjudicationReasons(),
markThresholds: store.getMarkThresholds(),
allowOfficialBallotsInTestMode: assertDefined(
store.getSystemSettings()
).allowOfficialBallotsInTestMode,
markThresholds,
allowOfficialBallotsInTestMode,
},
[frontImageData, backImageData],
sheetId,
Expand Down
5 changes: 0 additions & 5 deletions apps/central-scan/backend/src/store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,6 @@ import {
BatchInfo,
Iso8601Timestamp,
mapSheet,
MarkThresholds,
PageInterpretationSchema,
PageInterpretationWithFiles,
PollsState as PollsStateType,
Expand Down Expand Up @@ -382,10 +381,6 @@ export class Store {
);
}

getMarkThresholds(): MarkThresholds {
return assertDefined(this.getSystemSettings()).markThresholds;
}

getAdjudicationReasons(): readonly AdjudicationReason[] {
return assertDefined(this.getSystemSettings())
.centralScanAdjudicationReasons;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -707,3 +707,54 @@ test('does not allow tabulating the overvote if disallowCastingOvervotes is set'
apiMock.expectContinueScanning({ forceAccept: false });
userEvent.click(screen.getByText('Confirm Ballot Removed'));
});

test('says the scanner needs cleaning if a streak is detected', async () => {
fetchMock.getOnce(
'/central-scanner/scan/hmpb/review/next-sheet',
typedAs<Scan.GetNextReviewSheetResponse>({
interpreted: {
id: 'mock-sheet-id',
front: {
image: { url: '/front/url' },
interpretation: {
type: 'UnreadablePage',
reason: 'verticalStreaksDetected',
},
},
back: {
image: { url: '/back/url' },
interpretation: {
type: 'UnreadablePage',
reason: 'verticalStreaksDetected',
},
},
},
layouts: {},
definitions: {},
})
);

const logger = mockBaseLogger();

renderInAppContext(<BallotEjectScreen isTestMode />, { apiMock, logger });

await screen.findByText('Streak Detected');
screen.getByText(
'The last scanned ballot was not tabulated because the scanner needs to be cleaned.'
);
screen.getByText('Clean the scanner before continuing to scan ballots.');
expect(screen.getByRole('button').textContent).toEqual(
'Confirm Ballot Removed'
);

expect(logger.log).toHaveBeenCalledTimes(1);
expect(logger.log).toHaveBeenCalledWith(
LogEventId.ScanAdjudicationInfo,
'election_manager',
expect.objectContaining({
adjudicationTypes: 'UnreadablePage',
})
);
apiMock.expectContinueScanning({ forceAccept: false });
userEvent.click(screen.getByText('Confirm Ballot Removed'));
});
24 changes: 23 additions & 1 deletion apps/central-scan/frontend/src/screens/ballot_eject_screen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -156,6 +156,7 @@ export function BallotEjectScreen({ isTestMode }: Props): JSX.Element | null {

let isOvervotedSheet = false;
let isUndervotedSheet = false;
let verticalStreaksDetected = false;
let isFrontBlank = false;
let isBackBlank = false;
let isInvalidTestModeSheet = false;
Expand Down Expand Up @@ -186,7 +187,12 @@ export function BallotEjectScreen({ isTestMode }: Props): JSX.Element | null {
reviewInfo.interpreted.back.adjudicationFinishedAt,
},
]) {
if (reviewPageInfo.interpretation.type === 'InvalidTestModePage') {
if (
reviewPageInfo.interpretation.type === 'UnreadablePage' &&
reviewPageInfo.interpretation.reason === 'verticalStreaksDetected'
) {
verticalStreaksDetected = true;
} else if (reviewPageInfo.interpretation.type === 'InvalidTestModePage') {
isInvalidTestModeSheet = true;
} else if (reviewPageInfo.interpretation.type === 'InvalidBallotHashPage') {
isInvalidBallotHashSheet = true;
Expand Down Expand Up @@ -233,6 +239,22 @@ export function BallotEjectScreen({ isTestMode }: Props): JSX.Element | null {
}

const ejectInfo: EjectInformation = (() => {
if (verticalStreaksDetected) {
return {
header: 'Streak Detected',
body: (
<React.Fragment>
<P>
The last scanned ballot was not tabulated because the scanner
needs to be cleaned.
</P>
<P>Clean the scanner before continuing to scan ballots.</P>
</React.Fragment>
),
allowBallotDuplication: false,
};
}

if (isInvalidTestModeSheet) {
return isTestMode
? {
Expand Down
132 changes: 132 additions & 0 deletions apps/scan/backend/src/app_scanning.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
import { electionGridLayoutNewHampshireTestBallotFixtures } from '@votingworks/fixtures';
import { DEFAULT_SYSTEM_SETTINGS } from '@votingworks/types';
import {
BooleanEnvironmentVariableName,
getFeatureFlagMock,
} from '@votingworks/utils';
import { simulateScan, withApp } from '../test/helpers/pdi_helpers';
import { configureApp, waitForStatus } from '../test/helpers/shared_helpers';
import { delays } from './scanners/pdi/state_machine';

const mockFeatureFlagger = getFeatureFlagMock();

jest.mock('@votingworks/utils', (): typeof import('@votingworks/utils') => {
return {
...jest.requireActual('@votingworks/utils'),
isFeatureFlagEnabled: (flag) => mockFeatureFlagger.isEnabled(flag),
};
});

beforeEach(() => {
mockFeatureFlagger.resetFeatureFlags();
mockFeatureFlagger.enableFeatureFlag(
BooleanEnvironmentVariableName.SKIP_ELECTION_PACKAGE_AUTHENTICATION
);
});

test('scanBatch with streaked page', async () => {
const { scanMarkedFront, scanMarkedBack } =
electionGridLayoutNewHampshireTestBallotFixtures;

const frontImageData = await scanMarkedFront.asImageData();
const backImageData = await scanMarkedBack.asImageData();

// add a vertical streak
for (
let offset = 500;
offset < frontImageData.data.length;
offset += frontImageData.width * 4
) {
frontImageData.data[offset] = 0;
frontImageData.data[offset + 1] = 0;
frontImageData.data[offset + 2] = 0;
frontImageData.data[offset + 3] = 255;
}

// try with vertical streak detection enabled
await withApp(
async ({
apiClient,
clock,
mockAuth,
mockScanner,
mockUsbDrive,
workspace,
}) => {
await configureApp(apiClient, mockAuth, mockUsbDrive, {
electionPackage:
electionGridLayoutNewHampshireTestBallotFixtures.electionJson.toElectionPackage(),
testMode: true,
});

workspace.store.setSystemSettings({
...DEFAULT_SYSTEM_SETTINGS,
// enable vertical streak detection
disableVerticalStreakDetection: false,
});

clock.increment(delays.DELAY_SCANNING_ENABLED_POLLING_INTERVAL);
await waitForStatus(apiClient, { state: 'no_paper' });
expect(mockScanner.client.enableScanning).toHaveBeenCalledWith({
doubleFeedDetectionEnabled: true,
paperLengthInches: 11,
});

await simulateScan(apiClient, mockScanner, [
frontImageData,
backImageData,
]);

await waitForStatus(apiClient, {
state: 'rejecting',
interpretation: {
type: 'InvalidSheet',
reason: 'vertical_streaks_detected',
},
});
}
);

// try again with vertical streak detection disabled
await withApp(
async ({
apiClient,
clock,
mockAuth,
mockScanner,
mockUsbDrive,
workspace,
}) => {
await configureApp(apiClient, mockAuth, mockUsbDrive, {
electionPackage:
electionGridLayoutNewHampshireTestBallotFixtures.electionJson.toElectionPackage(),
testMode: true,
});

workspace.store.setSystemSettings({
...DEFAULT_SYSTEM_SETTINGS,
// disable vertical streak detection
disableVerticalStreakDetection: true,
});

clock.increment(delays.DELAY_SCANNING_ENABLED_POLLING_INTERVAL);
await waitForStatus(apiClient, { state: 'no_paper' });
expect(mockScanner.client.enableScanning).toHaveBeenCalledWith({
doubleFeedDetectionEnabled: true,
paperLengthInches: 11,
});

await simulateScan(apiClient, mockScanner, [
frontImageData,
backImageData,
]);

await waitForStatus(apiClient, {
state: 'accepting',
interpretation: {
type: 'ValidSheet',
},
});
}
);
});
Loading

0 comments on commit 9801fad

Please sign in to comment.