Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add vertical streak detection controls #5549

Merged
merged 6 commits into from
Oct 24, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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