diff --git a/apps/admin/backend/src/store.ts b/apps/admin/backend/src/store.ts index 850891e151..deb018bac6 100644 --- a/apps/admin/backend/src/store.ts +++ b/apps/admin/backend/src/store.ts @@ -40,8 +40,11 @@ import { join } from 'path'; import { Buffer } from 'buffer'; import { v4 as uuid } from 'uuid'; import { - OfficialCandidateNameLookup, + asSqliteBool, + fromSqliteBool, getOfficialCandidateNameLookup, + OfficialCandidateNameLookup, + SqliteBool, } from '@votingworks/utils'; import { CastVoteRecordFileRecord, @@ -97,16 +100,6 @@ function convertSqliteTimestampToIso8601( return new Date(sqliteTimestamp).toISOString(); } -type SqlBool = 0 | 1; - -function asSqlBool(bool: boolean): SqlBool { - return bool ? 1 : 0; -} - -function fromSqlBool(sqlBool: SqlBool): boolean { - return sqlBool === 1; -} - function asQueryPlaceholders(list: unknown[]): string { const questionMarks = list.map(() => '?'); return `(${questionMarks.join(', ')})`; @@ -124,7 +117,7 @@ interface WriteInTallyRow { interface CastVoteRecordVoteAdjudication { contestId: ContestId; optionId: ContestOptionId; - isVote: SqlBool; + isVote: SqliteBool; } /** @@ -212,7 +205,7 @@ export class Store { id: Id; electionData: string; createdAt: string; - isOfficialResults: SqlBool; + isOfficialResults: SqliteBool; }> ).map((r) => ({ id: r.id, @@ -244,7 +237,7 @@ export class Store { id: Id; electionData: string; createdAt: string; - isOfficialResults: SqlBool; + isOfficialResults: SqliteBool; } | undefined; if (!result) { @@ -772,7 +765,7 @@ export class Store { `, id, electionId, - asSqlBool(isTestMode), + asSqliteBool(isTestMode), filename, exportedTimestamp, JSON.stringify([]), @@ -899,7 +892,7 @@ export class Store { cvr.precinctId, cvrSheetNumber, serializedVotes, - asSqlBool(isBlankSheet(cvr.votes)) + asSqliteBool(isBlankSheet(cvr.votes)) ); } @@ -1059,7 +1052,7 @@ export class Store { side, contestId, optionId, - asSqlBool(isUnmarked) + asSqliteBool(isUnmarked) ); return id; @@ -1895,8 +1888,8 @@ export class Store { castVoteRecordId: Id; contestId: ContestId; optionId: ContestOptionId; - isInvalid: SqlBool; - isUnmarked: SqlBool; + isInvalid: SqliteBool; + isUnmarked: SqliteBool; officialCandidateId: string | null; writeInCandidateId: Id | null; adjudicatedAt: Iso8601Timestamp | null; @@ -1914,7 +1907,7 @@ export class Store { status: 'adjudicated', adjudicationType: 'official-candidate', candidateId: row.officialCandidateId, - isUnmarked: fromSqlBool(row.isUnmarked), + isUnmarked: fromSqliteBool(row.isUnmarked), }); } @@ -1928,7 +1921,7 @@ export class Store { status: 'adjudicated', adjudicationType: 'write-in-candidate', candidateId: row.writeInCandidateId, - isUnmarked: fromSqlBool(row.isUnmarked), + isUnmarked: fromSqliteBool(row.isUnmarked), }); } @@ -1941,7 +1934,7 @@ export class Store { optionId: row.optionId, status: 'adjudicated', adjudicationType: 'invalid', - isUnmarked: fromSqlBool(row.isUnmarked), + isUnmarked: fromSqliteBool(row.isUnmarked), }); } @@ -1952,7 +1945,7 @@ export class Store { contestId: row.contestId, optionId: row.optionId, status: 'pending', - isUnmarked: fromSqlBool(row.isUnmarked), + isUnmarked: fromSqliteBool(row.isUnmarked), }); }); } @@ -2099,13 +2092,13 @@ export class Store { cvrId: Id; contestId: Id; optionId: Id; - isVote: SqlBool; + isVote: SqliteBool; }>; return row ? { ...row, - isVote: fromSqlBool(row.isVote), + isVote: fromSqliteBool(row.isVote), } : undefined; } @@ -2150,7 +2143,7 @@ export class Store { cvrId, contestId, optionId, - asSqlBool(isVote) + asSqliteBool(isVote) ); } @@ -2477,7 +2470,7 @@ export class Store { set is_official_results = ? where id = ? `, - asSqlBool(isOfficialResults), + asSqliteBool(isOfficialResults), electionId ); } diff --git a/apps/central-scan/backend/src/store.ts b/apps/central-scan/backend/src/store.ts index 529762fa93..96fab58cf2 100644 --- a/apps/central-scan/backend/src/store.ts +++ b/apps/central-scan/backend/src/store.ts @@ -133,6 +133,10 @@ function dateTimeFromNoOffsetSqliteDate(noOffsetSqliteDate: string): DateTime { export class Store { private constructor(private readonly client: DbClient) {} + // Used by shared CVR export logic in libs/backend + // eslint-disable-next-line vx/gts-no-public-class-fields + readonly scannerType = 'central'; + getDbPath(): string { return this.client.getDatabasePath(); } diff --git a/apps/scan/backend/schema.sql b/apps/scan/backend/schema.sql index 0ad02632ac..4c29497b7c 100644 --- a/apps/scan/backend/schema.sql +++ b/apps/scan/backend/schema.sql @@ -51,13 +51,19 @@ create table sheets ( ); create table system_settings ( - -- enforce singleton table + -- Enforce singleton table id integer primary key check (id = 1), data text not null -- JSON blob ); +create table is_continuous_export_operation_in_progress ( + -- Enforce singleton table + id integer primary key check (id = 1), + is_continuous_export_operation_in_progress boolean not null +); + create table export_directory_name ( - -- enforce singleton table + -- Enforce singleton table id integer primary key check (id = 1), export_directory_name text not null ); diff --git a/apps/scan/backend/src/app_config.test.ts b/apps/scan/backend/src/app_config.test.ts index 91d90d6d93..105f0694dd 100644 --- a/apps/scan/backend/src/app_config.test.ts +++ b/apps/scan/backend/src/app_config.test.ts @@ -164,9 +164,9 @@ test("if there's only one precinct in the election, it's selected automatically test('continuous CVR export', async () => { await withApp( {}, - async ({ apiClient, mockAuth, mockScanner, mockUsbDrive }) => { + async ({ apiClient, mockAuth, mockScanner, mockUsbDrive, workspace }) => { await configureApp(apiClient, mockAuth, mockUsbDrive, { testMode: true }); - await scanBallot(mockScanner, mockUsbDrive, apiClient, 0); + await scanBallot(mockScanner, apiClient, workspace.store, 0); const exportDirectoryPaths = await getCastVoteRecordExportDirectoryPaths( mockUsbDrive.usbDrive @@ -216,10 +216,10 @@ test('continuous CVR export', async () => { test('continuous CVR export, including polls closing, followed by a full export', async () => { await withApp( {}, - async ({ apiClient, mockAuth, mockScanner, mockUsbDrive }) => { + async ({ apiClient, mockAuth, mockScanner, mockUsbDrive, workspace }) => { await configureApp(apiClient, mockAuth, mockUsbDrive, { testMode: true }); - await scanBallot(mockScanner, mockUsbDrive, apiClient, 0); - await scanBallot(mockScanner, mockUsbDrive, apiClient, 1); + await scanBallot(mockScanner, apiClient, workspace.store, 0); + await scanBallot(mockScanner, apiClient, workspace.store, 1); expect( await apiClient.exportCastVoteRecordsToUsbDrive({ @@ -239,9 +239,9 @@ test('continuous CVR export, including polls closing, followed by a full export' test('CVR resync', async () => { await withApp( {}, - async ({ apiClient, mockAuth, mockScanner, mockUsbDrive }) => { + async ({ apiClient, mockAuth, mockScanner, mockUsbDrive, workspace }) => { await configureApp(apiClient, mockAuth, mockUsbDrive, { testMode: true }); - await scanBallot(mockScanner, mockUsbDrive, apiClient, 0); + await scanBallot(mockScanner, apiClient, workspace.store, 0); // When a CVR resync is required, the CVR resync modal appears on the "insert your ballot" // screen, i.e. the screen displayed when no card is inserted @@ -300,8 +300,8 @@ test('ballot batching', async () => { } // Scan two ballots, which should have the same batch - await scanBallot(mockScanner, mockUsbDrive, apiClient, 0); - await scanBallot(mockScanner, mockUsbDrive, apiClient, 1); + await scanBallot(mockScanner, apiClient, workspace.store, 0); + await scanBallot(mockScanner, apiClient, workspace.store, 1); let batchIds = getBatchIds(); expect(getCvrIds()).toHaveLength(2); expect(batchIds).toHaveLength(1); @@ -338,8 +338,8 @@ test('ballot batching', async () => { }); // Confirm there is a new, second batch distinct from the first - await scanBallot(mockScanner, mockUsbDrive, apiClient, 2); - await scanBallot(mockScanner, mockUsbDrive, apiClient, 3); + await scanBallot(mockScanner, apiClient, workspace.store, 2); + await scanBallot(mockScanner, apiClient, workspace.store, 3); batchIds = getBatchIds(); expect(getCvrIds()).toHaveLength(4); expect(batchIds).toHaveLength(2); @@ -376,8 +376,8 @@ test('ballot batching', async () => { }); // Confirm there is a third batch, distinct from the second - await scanBallot(mockScanner, mockUsbDrive, apiClient, 4); - await scanBallot(mockScanner, mockUsbDrive, apiClient, 5); + await scanBallot(mockScanner, apiClient, workspace.store, 4); + await scanBallot(mockScanner, apiClient, workspace.store, 5); batchIds = getBatchIds(); expect(getCvrIds()).toHaveLength(6); expect(batchIds).toHaveLength(3); diff --git a/apps/scan/backend/src/app_results.test.ts b/apps/scan/backend/src/app_results.test.ts index d399c90c98..1eb5dfa2e2 100644 --- a/apps/scan/backend/src/app_results.test.ts +++ b/apps/scan/backend/src/app_results.test.ts @@ -28,13 +28,13 @@ beforeEach(() => { test('end-to-end tabulated results', async () => { await withApp( {}, - async ({ apiClient, mockScanner, mockUsbDrive, mockAuth }) => { + async ({ apiClient, mockScanner, mockUsbDrive, mockAuth, workspace }) => { await configureApp(apiClient, mockAuth, mockUsbDrive, { testMode: true }); // scan a few ballots - await scanBallot(mockScanner, mockUsbDrive, apiClient, 0); - await scanBallot(mockScanner, mockUsbDrive, apiClient, 1); - await scanBallot(mockScanner, mockUsbDrive, apiClient, 2); + await scanBallot(mockScanner, apiClient, workspace.store, 0); + await scanBallot(mockScanner, apiClient, workspace.store, 1); + await scanBallot(mockScanner, apiClient, workspace.store, 2); const allResults = await apiClient.getScannerResultsByParty(); expect(allResults).toHaveLength(1); diff --git a/apps/scan/backend/src/app_usb_drive.test.ts b/apps/scan/backend/src/app_usb_drive.test.ts index fb051da204..763654ac4c 100644 --- a/apps/scan/backend/src/app_usb_drive.test.ts +++ b/apps/scan/backend/src/app_usb_drive.test.ts @@ -46,7 +46,7 @@ test('ejectUsbDrive', async () => { test('doesUsbDriveRequireCastVoteRecordSync is properly populated', async () => { await withApp( {}, - async ({ apiClient, mockAuth, mockUsbDrive, mockScanner }) => { + async ({ apiClient, mockAuth, mockUsbDrive, mockScanner, workspace }) => { await configureApp(apiClient, mockAuth, mockUsbDrive, { testMode: true }); const mountedUsbDriveStatus = { status: 'mounted', @@ -57,7 +57,7 @@ test('doesUsbDriveRequireCastVoteRecordSync is properly populated', async () => mountedUsbDriveStatus ); - await scanBallot(mockScanner, mockUsbDrive, apiClient, 0); + await scanBallot(mockScanner, apiClient, workspace.store, 0); await expect(apiClient.getUsbDriveStatus()).resolves.toEqual( mountedUsbDriveStatus ); diff --git a/apps/scan/backend/src/scanners/custom/state_machine.ts b/apps/scan/backend/src/scanners/custom/state_machine.ts index c6e126b7c7..f6aabf0ba1 100644 --- a/apps/scan/backend/src/scanners/custom/state_machine.ts +++ b/apps/scan/backend/src/scanners/custom/state_machine.ts @@ -434,11 +434,15 @@ async function recordAcceptedSheet( const { sheetId } = interpretation; store.withTransaction(() => { storeInterpretedSheet(store, sheetId, interpretation); + // If we're storing an accepted sheet that needed review, that means that it was "adjudicated" // (i.e. the voter said to count it without changing anything). if (interpretation.type === 'NeedsReviewSheet') { store.adjudicateSheet(sheetId); } + + // Gets reset to false within exportCastVoteRecordsToUsbDrive + store.setIsContinuousExportOperationInProgress(true); }); ( await exportCastVoteRecordsToUsbDrive( @@ -460,10 +464,14 @@ async function recordRejectedSheet( const { sheetId } = interpretation; store.withTransaction(() => { storeInterpretedSheet(store, sheetId, interpretation); + // We want to keep rejected ballots in the store, but not count them. We accomplish this by // "deleting" them, which just marks them as deleted and is how we indicate that an interpreted // ballot wasn't counted. store.deleteSheet(sheetId); + + // Gets reset to false within exportCastVoteRecordsToUsbDrive + store.setIsContinuousExportOperationInProgress(true); }); ( await exportCastVoteRecordsToUsbDrive( diff --git a/apps/scan/backend/src/store.test.ts b/apps/scan/backend/src/store.test.ts index c436764a39..b6c2906a60 100644 --- a/apps/scan/backend/src/store.test.ts +++ b/apps/scan/backend/src/store.test.ts @@ -720,6 +720,16 @@ test('getBallotsCounted', () => { expect(store.getBallotsCounted()).toEqual(1); }); +test('isContinuousExportOperationInProgress and setIsContinuousExportOperationInProgress', () => { + const store = Store.memoryStore(); + + expect(store.isContinuousExportOperationInProgress()).toEqual(false); + store.setIsContinuousExportOperationInProgress(true); + expect(store.isContinuousExportOperationInProgress()).toEqual(true); + store.setIsContinuousExportOperationInProgress(false); + expect(store.isContinuousExportOperationInProgress()).toEqual(false); +}); + test('getExportDirectoryName and setExportDirectoryName', () => { const store = Store.memoryStore(); diff --git a/apps/scan/backend/src/store.ts b/apps/scan/backend/src/store.ts index d8750663e1..9d27c443d9 100644 --- a/apps/scan/backend/src/store.ts +++ b/apps/scan/backend/src/store.ts @@ -44,6 +44,7 @@ import { getCastVoteRecordRootHash, updateCastVoteRecordHashes, } from '@votingworks/auth'; +import { SqliteBool, asSqliteBool, fromSqliteBool } from '@votingworks/utils'; import { sheetRequiresAdjudication } from './sheet_requires_adjudication'; import { rootDebug } from './util/debug'; @@ -129,6 +130,10 @@ export class Store { private readonly uiStringsStore: UiStringsStore ) {} + // Used by shared CVR export logic in libs/backend + // eslint-disable-next-line vx/gts-no-public-class-fields + readonly scannerType = 'precinct'; + getDbPath(): string { return this.client.getDatabasePath(); } @@ -823,15 +828,46 @@ export class Store { return safeParseSystemSettings(result.data).unsafeUnwrap(); } + isContinuousExportOperationInProgress(): boolean { + const row = this.client.one( + ` + select + is_continuous_export_operation_in_progress as isContinuousExportOperationInProgress + from is_continuous_export_operation_in_progress + ` + ) as { isContinuousExportOperationInProgress: SqliteBool } | undefined; + return row + ? fromSqliteBool(row.isContinuousExportOperationInProgress) + : false; + } + + setIsContinuousExportOperationInProgress( + isContinuousExportOperationInProgress: boolean + ): void { + this.client.run('delete from is_continuous_export_operation_in_progress'); + this.client.run( + ` + insert into is_continuous_export_operation_in_progress ( + is_continuous_export_operation_in_progress + ) values (?) + `, + asSqliteBool(isContinuousExportOperationInProgress) + ); + } + /** * Gets the name of the directory that we're continuously exporting to, e.g. * TEST__machine_SCAN-0001__2023-08-16_17-02-24. Returns undefined if not yet set. */ getExportDirectoryName(): string | undefined { - const result = this.client.one( - 'select export_directory_name as exportDirectoryName from export_directory_name' + const row = this.client.one( + ` + select + export_directory_name as exportDirectoryName + from export_directory_name + ` ) as { exportDirectoryName: string } | undefined; - return result?.exportDirectoryName; + return row?.exportDirectoryName; } /** @@ -840,7 +876,11 @@ export class Store { setExportDirectoryName(exportDirectoryName: string): void { this.client.run('delete from export_directory_name'); this.client.run( - 'insert into export_directory_name (export_directory_name) values (?)', + ` + insert into export_directory_name ( + export_directory_name + ) values (?) + `, exportDirectoryName ); } diff --git a/apps/scan/backend/test/helpers/custom_helpers.ts b/apps/scan/backend/test/helpers/custom_helpers.ts index 45ea324aa3..d61265427b 100644 --- a/apps/scan/backend/test/helpers/custom_helpers.ts +++ b/apps/scan/backend/test/helpers/custom_helpers.ts @@ -39,6 +39,7 @@ import { waitForContinuousExportToUsbDrive, waitForStatus, } from './shared_helpers'; +import { Store } from '../../src/store'; export async function withApp( { @@ -123,7 +124,7 @@ export async function withApp( }); mockUsbDrive.assertComplete(); } finally { - await waitForContinuousExportToUsbDrive(mockUsbDrive); + await waitForContinuousExportToUsbDrive(workspace.store); await new Promise((resolve, reject) => { server.close((error) => (error ? reject(error) : resolve())); }); @@ -216,8 +217,8 @@ export function simulateScan( export async function scanBallot( mockScanner: jest.Mocked, - mockUsbDrive: MockUsbDrive, apiClient: grout.Client, + store: Store, initialBallotsCounted: number ): Promise { mockScanner.getStatus.mockResolvedValue(ok(mocks.MOCK_READY_TO_SCAN)); @@ -242,5 +243,5 @@ export async function scanBallot( ballotsCounted: initialBallotsCounted + 1, }); - await waitForContinuousExportToUsbDrive(mockUsbDrive); + await waitForContinuousExportToUsbDrive(store); } diff --git a/apps/scan/backend/test/helpers/shared_helpers.ts b/apps/scan/backend/test/helpers/shared_helpers.ts index 2ec3ceec1b..fee991985d 100644 --- a/apps/scan/backend/test/helpers/shared_helpers.ts +++ b/apps/scan/backend/test/helpers/shared_helpers.ts @@ -1,9 +1,6 @@ import { InsertedSmartCardAuthApi } from '@votingworks/auth'; import { ok } from '@votingworks/basics'; -import { - areOrWereCastVoteRecordsBeingExportedToUsbDrive, - mockBallotPackageFileTree, -} from '@votingworks/backend'; +import { mockBallotPackageFileTree } from '@votingworks/backend'; import { electionFamousNames2021Fixtures } from '@votingworks/fixtures'; import * as grout from '@votingworks/grout'; import { @@ -24,6 +21,7 @@ import waitForExpect from 'wait-for-expect'; import { MockUsbDrive } from '@votingworks/usb-drive'; import { Api } from '../../src/app'; import { PrecinctScannerStatus } from '../../src/types'; +import { Store } from '../../src/store'; export async function expectStatus( apiClient: grout.Client, @@ -99,18 +97,11 @@ export async function configureApp( * while they're still being read from / written to. */ export async function waitForContinuousExportToUsbDrive( - mockUsbDrive: MockUsbDrive + store: Store ): Promise { - // Check that mockUsbDrive.usbDrive.status has been configured before calling it - if (mockUsbDrive.usbDrive.status.hasExpectedCalls()) { - const usbDriveStatus = await mockUsbDrive.usbDrive.status(); - await waitForExpect( - () => - expect( - areOrWereCastVoteRecordsBeingExportedToUsbDrive(usbDriveStatus) - ).toEqual(false), - 10000, - 250 - ); - } + await waitForExpect( + () => expect(store.isContinuousExportOperationInProgress()).toEqual(false), + 10000, + 250 + ); } diff --git a/libs/backend/src/cast_vote_records/export.test.ts b/libs/backend/src/cast_vote_records/export.test.ts index 6f036ac9f6..4cc20f0a5d 100644 --- a/libs/backend/src/cast_vote_records/export.test.ts +++ b/libs/backend/src/cast_vote_records/export.test.ts @@ -31,12 +31,10 @@ import { } from '../../test/utils'; import { AcceptedSheet, - areOrWereCastVoteRecordsBeingExportedToUsbDrive, clearDoesUsbDriveRequireCastVoteRecordSyncCachedResult, doesUsbDriveRequireCastVoteRecordSync, exportCastVoteRecordsToUsbDrive, ExportOptions, - markCastVoteRecordExportAsInProgress, RejectedSheet, Sheet, } from './export'; @@ -794,7 +792,7 @@ test.each<{ mockPrecinctScannerStore.setBallotsCounted(1); const usbDriveStatus = await mockUsbDrive.usbDrive.status(); assert(usbDriveStatus.status === 'mounted'); - await markCastVoteRecordExportAsInProgress(usbDriveStatus.mountPoint); + mockPrecinctScannerStore.setIsContinuousExportOperationInProgress(true); }, shouldUsbDriveRequireCastVoteRecordSync: true, }, @@ -1017,12 +1015,6 @@ test('cast vote record export clears doesUsbDriveRequireCastVoteRecordSync cache ).toEqual(false); }); -test('areOrWereCastVoteRecordsBeingExportedToUsbDrive returns false when no USB drive', () => { - expect( - areOrWereCastVoteRecordsBeingExportedToUsbDrive({ status: 'no_drive' }) - ).toEqual(false); -}); - test('export and subsequent import of that export', async () => { // Export process.env['VX_MACHINE_TYPE'] = 'scan'; diff --git a/libs/backend/src/cast_vote_records/export.ts b/libs/backend/src/cast_vote_records/export.ts index 5db71eff42..ea96e78594 100644 --- a/libs/backend/src/cast_vote_records/export.ts +++ b/libs/backend/src/cast_vote_records/export.ts @@ -1,5 +1,4 @@ import crypto from 'crypto'; -import { existsSync } from 'fs'; import fs from 'fs/promises'; import path from 'path'; import { @@ -59,9 +58,9 @@ import { readCastVoteRecordExportMetadata } from './import'; import { buildElectionOptionPositionMap } from './option_map'; /** - * The subset of scanner store methods relevant to exporting cast vote records + * Methods shared by both {@link CentralScannerStore} and {@link PrecinctScannerStore} */ -export interface ScannerStore { +export interface ScannerStoreBase { clearCastVoteRecordHashes(): void; getBatches(): BatchInfo[]; getCastVoteRecordRootHash(): string; @@ -72,12 +71,36 @@ export interface ScannerStore { castVoteRecordId: string, castVoteRecordHash: string ): void; +} - getExportDirectoryName?(): string | undefined; - getPollsState?(): PollsState; - setExportDirectoryName?(exportDirectoryName: string): void; +/** + * The subset of central scanner store methods relevant to exporting cast vote records + */ +export interface CentralScannerStore extends ScannerStoreBase { + scannerType: 'central'; } +/** + * The subset of precinct scanner store methods relevant to exporting cast vote records + */ +export interface PrecinctScannerStore extends ScannerStoreBase { + scannerType: 'precinct'; + + getBallotsCounted(): number; + getExportDirectoryName(): string | undefined; + getPollsState(): PollsState; + isContinuousExportOperationInProgress(): boolean; + setExportDirectoryName(exportDirectoryName: string): void; + setIsContinuousExportOperationInProgress( + isContinuousExportOperationInProgress: boolean + ): void; +} + +/** + * The subset of scanner store methods relevant to exporting cast vote records + */ +export type ScannerStore = CentralScannerStore | PrecinctScannerStore; + /** * State that can be retrieved via the ScannerStore interface and that is unchanged by exporting * cast vote records @@ -215,8 +238,7 @@ async function getExportDirectoryPathRelativeToUsbMountPoint( break; } case 'precinct': { - assert(scannerStore.getExportDirectoryName !== undefined); - assert(scannerStore.setExportDirectoryName !== undefined); + assert(scannerStore.scannerType === 'precinct'); exportDirectoryName = scannerStore.getExportDirectoryName(); if (!exportDirectoryName || exportOptions.isFullExport) { exportDirectoryName = generateCastVoteRecordExportDirectoryName({ @@ -604,35 +626,6 @@ async function randomlyUpdateCreationTimestamps( } } -function getCastVoteRecordExportInProgressMarkerFilePath( - usbMountPoint: string -): string { - return path.join(usbMountPoint, '.vx-export-in-progress'); -} - -/** - * Marks a cast vote record export as in progress, by writing a hidden file to the USB drive - */ -export async function markCastVoteRecordExportAsInProgress( - usbMountPoint: string -): Promise { - await fs.writeFile( - getCastVoteRecordExportInProgressMarkerFilePath(usbMountPoint), - '' - ); -} - -/** - * The counterpart to {@link markCastVoteRecordExportAsInProgress} - */ -async function markCastVoteRecordExportAsComplete( - usbMountPoint: string -): Promise { - await fs.rm(getCastVoteRecordExportInProgressMarkerFilePath(usbMountPoint), { - force: true, - }); -} - // // Top-level functions // @@ -647,6 +640,7 @@ export async function exportCastVoteRecordsToUsbDrive( sheets: Iterable, exportOptions: ExportOptions ): Promise> { + assert(scannerStore.scannerType === exportOptions.scannerType); const usbDriveStatus = await usbDrive.status(); const usbMountPoint = usbDriveStatus.status === 'mounted' ? usbDriveStatus.mountPoint : undefined; @@ -664,14 +658,15 @@ export async function exportCastVoteRecordsToUsbDrive( electionDefinition: assertDefined(scannerStore.getElectionDefinition()), inTestMode: scannerStore.getTestMode(), markThresholds: scannerStore.getMarkThresholds(), - pollsState: scannerStore.getPollsState?.(), + pollsState: + scannerStore.scannerType === 'precinct' + ? scannerStore.getPollsState() + : undefined, }, scannerStore, usbMountPoint, }; - await markCastVoteRecordExportAsInProgress(usbMountPoint); - // Before a full export, clear cast vote record hashes so that they can be recomputed from // scratch. This is particularly important for VxCentralScan, where batches can be deleted // between exports. @@ -781,27 +776,20 @@ export async function exportCastVoteRecordsToUsbDrive( return exportSignatureFileResult; } - await markCastVoteRecordExportAsComplete(usbMountPoint); + /** + * Perform the scanner store update before clearing the cache for + * {@link doesUsbDriveRequireCastVoteRecordSync} because + * {@link doesUsbDriveRequireCastVoteRecordSync} considers the state modified by the scanner + * store update + */ + if (scannerStore.scannerType === 'precinct') { + scannerStore.setIsContinuousExportOperationInProgress(false); + } clearDoesUsbDriveRequireCastVoteRecordSyncCachedResult(); return ok(); } -/** - * Checks whether cast vote records are being exported to a USB drive (or were being exported - * to the USB drive before it was last removed) - */ -export function areOrWereCastVoteRecordsBeingExportedToUsbDrive( - usbDriveStatus: UsbDriveStatus -): boolean { - if (usbDriveStatus.status !== 'mounted') { - return false; - } - return existsSync( - getCastVoteRecordExportInProgressMarkerFilePath(usbDriveStatus.mountPoint) - ); -} - /** * Returns whether a USB drive is inserted and requires a cast vote record sync because the cast * vote records on it don't match the cast vote records on the scanner. Only relevant for scanners @@ -814,11 +802,7 @@ export function areOrWereCastVoteRecordsBeingExportedToUsbDrive( * recomputation. */ export async function doesUsbDriveRequireCastVoteRecordSync( - scannerStore: ScannerStore & { - getBallotsCounted: () => number; - getExportDirectoryName: NonNullable; - getPollsState: NonNullable; - }, + scannerStore: PrecinctScannerStore, usbDriveStatus: UsbDriveStatus ): Promise { if ( @@ -852,7 +836,7 @@ export async function doesUsbDriveRequireCastVoteRecordSync( } // A previous export operation may have failed midway - if (areOrWereCastVoteRecordsBeingExportedToUsbDrive(usbDriveStatus)) { + if (scannerStore.isContinuousExportOperationInProgress()) { return true; } diff --git a/libs/backend/test/utils.ts b/libs/backend/test/utils.ts index 1e13696410..c0c1980a86 100644 --- a/libs/backend/test/utils.ts +++ b/libs/backend/test/utils.ts @@ -14,21 +14,23 @@ import { PollsState, } from '@votingworks/types'; -import { ScannerStore } from '../src/cast_vote_records/export'; +import { + CentralScannerStore, + PrecinctScannerStore, + ScannerStoreBase, +} from '../src/cast_vote_records/export'; import { FileSystemEntryType, listDirectoryRecursive, } from '../src/list_directory'; -class MockScannerStore implements ScannerStore { - private ballotsCounted: number; +class MockScannerStoreBase implements ScannerStoreBase { private batches: BatchInfo[]; private readonly client: Client; private electionDefinition?: ElectionDefinition; private testMode: boolean; constructor() { - this.ballotsCounted = 0; this.batches = []; this.client = Client.memoryClient(); this.electionDefinition = undefined; @@ -37,37 +39,18 @@ class MockScannerStore implements ScannerStore { this.client.exec(CAST_VOTE_RECORD_HASHES_TABLE_SCHEMA); } - // - // Cast vote record hash methods - // - - getCastVoteRecordRootHash(): string { - return getCastVoteRecordRootHash(this.client); - } - - updateCastVoteRecordHashes( - castVoteRecordId: string, - castVoteRecordHash: string - ): void { - return updateCastVoteRecordHashes( - this.client, - castVoteRecordId, - castVoteRecordHash - ); - } - clearCastVoteRecordHashes(): void { return clearCastVoteRecordHashes(this.client); } - // - // Other getters - // - getBatches(): BatchInfo[] { return this.batches; } + getCastVoteRecordRootHash(): string { + return getCastVoteRecordRootHash(this.client); + } + getElectionDefinition(): ElectionDefinition | undefined { return this.electionDefinition; } @@ -80,18 +63,21 @@ class MockScannerStore implements ScannerStore { return this.testMode; } + updateCastVoteRecordHashes( + castVoteRecordId: string, + castVoteRecordHash: string + ): void { + return updateCastVoteRecordHashes( + this.client, + castVoteRecordId, + castVoteRecordHash + ); + } + // // Methods to facilitate testing, beyond the ScannerStore interface // - getBallotsCounted(): number { - return this.ballotsCounted; - } - - setBallotsCounted(ballotsCounted: number): void { - this.ballotsCounted = ballotsCounted; - } - setBatches(batches: BatchInfo[]): void { this.batches = batches; } @@ -108,31 +94,70 @@ class MockScannerStore implements ScannerStore { /** * A mock central scanner store */ -export class MockCentralScannerStore extends MockScannerStore {} +export class MockCentralScannerStore + extends MockScannerStoreBase + implements CentralScannerStore +{ + // eslint-disable-next-line vx/gts-no-public-class-fields + readonly scannerType = 'central'; +} /** * A mock precinct scanner store */ -export class MockPrecinctScannerStore extends MockScannerStore { +export class MockPrecinctScannerStore + extends MockScannerStoreBase + implements PrecinctScannerStore +{ + // eslint-disable-next-line vx/gts-no-public-class-fields + readonly scannerType = 'precinct'; + + private ballotsCounted: number; private exportDirectoryName?: string; + private isContinuousExportOperationInProgressValue: boolean; private pollsState: PollsState; constructor() { super(); + this.ballotsCounted = 0; this.exportDirectoryName = undefined; + this.isContinuousExportOperationInProgressValue = false; this.pollsState = 'polls_closed_initial'; } + getBallotsCounted(): number { + return this.ballotsCounted; + } + getExportDirectoryName(): string | undefined { return this.exportDirectoryName; } + getPollsState(): PollsState { + return this.pollsState; + } + + isContinuousExportOperationInProgress(): boolean { + return this.isContinuousExportOperationInProgressValue; + } + setExportDirectoryName(exportDirectoryName: string): void { this.exportDirectoryName = exportDirectoryName; } - getPollsState(): PollsState { - return this.pollsState; + setIsContinuousExportOperationInProgress( + isContinuousExportOperationInProgress: boolean + ): void { + this.isContinuousExportOperationInProgressValue = + isContinuousExportOperationInProgress; + } + + // + // Methods to facilitate testing, beyond the ScannerStore interface + // + + setBallotsCounted(ballotsCounted: number): void { + this.ballotsCounted = ballotsCounted; } setPollsState(pollsState: PollsState): void { diff --git a/libs/utils/src/index.ts b/libs/utils/src/index.ts index 5d10ad0345..6c44ac108d 100644 --- a/libs/utils/src/index.ts +++ b/libs/utils/src/index.ts @@ -25,6 +25,7 @@ export * from './precinct_selection'; export * from './Printer'; export * from './random'; export * from './Storage'; +export * from './sqlite'; export * from './tabulation'; export * from './types'; export * from './votes'; diff --git a/libs/utils/src/sqlite.test.ts b/libs/utils/src/sqlite.test.ts new file mode 100644 index 0000000000..4880ecb980 --- /dev/null +++ b/libs/utils/src/sqlite.test.ts @@ -0,0 +1,11 @@ +import { asSqliteBool, fromSqliteBool } from './sqlite'; + +test('asSqliteBool', () => { + expect(asSqliteBool(false)).toEqual(0); + expect(asSqliteBool(true)).toEqual(1); +}); + +test('fromSqliteBool', () => { + expect(fromSqliteBool(0)).toEqual(false); + expect(fromSqliteBool(1)).toEqual(true); +}); diff --git a/libs/utils/src/sqlite.ts b/libs/utils/src/sqlite.ts new file mode 100644 index 0000000000..b48065eaa4 --- /dev/null +++ b/libs/utils/src/sqlite.ts @@ -0,0 +1,9 @@ +export type SqliteBool = 0 | 1; + +export function asSqliteBool(bool: boolean): SqliteBool { + return bool ? 1 : 0; +} + +export function fromSqliteBool(sqliteBool: SqliteBool): boolean { + return sqliteBool === 1; +}