diff --git a/.gitignore b/.gitignore index 3bccae9a..3423ca47 100644 --- a/.gitignore +++ b/.gitignore @@ -7,3 +7,4 @@ /test/tmp/ /test_output.txt trash_bin/ +/.antigravitycli/ diff --git a/src/PaperSize.ts b/src/PaperSize.ts index bf457f75..82c86c2c 100644 --- a/src/PaperSize.ts +++ b/src/PaperSize.ts @@ -120,6 +120,23 @@ export function parsePaperSize(input: string): PaperSizeMm | null { } } +function normalizeInput(input?: string | null): string | undefined { + return typeof input === "string" ? input.trim() || undefined : undefined; +} + +function applyOrientation( + preset: PaperSizeMm, + orientation?: "portrait" | "landscape" | null, +): PaperSizeMm { + if (orientation === "landscape" && preset.widthMm < preset.heightMm) { + return { widthMm: preset.heightMm, heightMm: preset.widthMm }; + } + if (orientation === "portrait" && preset.widthMm > preset.heightMm) { + return { widthMm: preset.heightMm, heightMm: preset.widthMm }; + } + return { ...preset }; +} + /** * Resolves a paper size configuration (preset name or custom dimension string) * to millimeters, without any device clamping. @@ -137,14 +154,8 @@ export function validateAndResolvePaperSize( paperDimInput?: string | null, orientation?: "portrait" | "landscape" | null, ): ResolvedPaperSize | null { - const normalizedSize = - typeof paperSizeInput === "string" - ? paperSizeInput.trim() || undefined - : undefined; - const normalizedDim = - typeof paperDimInput === "string" - ? paperDimInput.trim() || undefined - : undefined; + const normalizedSize = normalizeInput(paperSizeInput); + const normalizedDim = normalizeInput(paperDimInput); if (normalizedSize !== undefined && normalizedDim !== undefined) { throw new Error( @@ -174,24 +185,7 @@ export function validateAndResolvePaperSize( throw new Error(`Unknown paper size preset: "${normalizedSize}".`); } - let resolvedMm = { ...preset }; - if ( - orientation === "landscape" && - resolvedMm.widthMm < resolvedMm.heightMm - ) { - resolvedMm = { - widthMm: resolvedMm.heightMm, - heightMm: resolvedMm.widthMm, - }; - } else if ( - orientation === "portrait" && - resolvedMm.widthMm > resolvedMm.heightMm - ) { - resolvedMm = { - widthMm: resolvedMm.heightMm, - heightMm: resolvedMm.widthMm, - }; - } + const resolvedMm = applyOrientation(preset, orientation); return { resolvedMm, source: normalizedSize.toUpperCase() }; } diff --git a/src/PathHelper.ts b/src/PathHelper.ts index ffea07a8..7b09144a 100644 --- a/src/PathHelper.ts +++ b/src/PathHelper.ts @@ -58,9 +58,7 @@ export default class PathHelper { return i; } } - return Promise.reject( - new Error(`Unable to find the valid scan number in folder ${folder}`), - ); + throw new Error(`Unable to find the valid scan number in folder ${folder}`); } static async makeUnique(filePath: string, date: Date): Promise { diff --git a/src/commands/adfAutoscanCmd.ts b/src/commands/adfAutoscanCmd.ts index 88aa5232..367cc313 100644 --- a/src/commands/adfAutoscanCmd.ts +++ b/src/commands/adfAutoscanCmd.ts @@ -24,6 +24,58 @@ function checkCapabilities( } } +async function executeAutoscanIteration( + adfAutoScanConfig: AdfAutoScanConfig, + deviceCapabilities: DeviceCapabilities, + folder: string, + tempFolder: string, + scanCount: number, +): Promise<{ success: boolean; isDeviceAlive: boolean }> { + try { + await waitAdfLoaded( + adfAutoScanConfig.pollingInterval, + adfAutoScanConfig.startScanDelay, + deviceCapabilities.getScanStatus, + ); + + console.log(`Scan event captured, saving scan #${scanCount}`); + + await scanFromAdf( + scanCount, + folder, + tempFolder, + adfAutoScanConfig, + deviceCapabilities, + new Date(), + ); + return { success: true, isDeviceAlive: true }; + } catch (e) { + if (e instanceof Error) { + console.log(e.message); + } else { + console.log(e); + } + const isAlive = await HPApi.isAlive(); + return { success: false, isDeviceAlive: isAlive }; + } +} + +async function handleLoopDelayOrTermination( + errorCount: number, + deviceUp: boolean, + deviceUpPollingInterval: number, +): Promise<{ keepActive: boolean; deviceUp: boolean }> { + const keepActive = errorCount < 50; + + if (!deviceUp) { + await HPApi.waitDeviceUp(deviceUpPollingInterval); + return { keepActive, deviceUp: true }; + } + + await HPApi.delay(1000); + return { keepActive, deviceUp }; +} + export async function adfAutoscanCmd( adfAutoScanConfig: AdfAutoScanConfig, deviceUpPollingInterval: number, @@ -52,47 +104,31 @@ export async function adfAutoscanCmd( while (keepActive) { iteration++; console.log(`Iteration ${iteration} (Errors so far: ${errorCount})`); - try { - await waitAdfLoaded( - adfAutoScanConfig.pollingInterval, - adfAutoScanConfig.startScanDelay, - deviceCapabilities.getScanStatus, - ); - scanCount++; + const result = await executeAutoscanIteration( + adfAutoScanConfig, + deviceCapabilities, + folder, + tempFolder, + scanCount + 1, + ); - console.log(`Scan event captured, saving scan #${scanCount}`); - - await scanFromAdf( - scanCount, - folder, - tempFolder, - adfAutoScanConfig, - deviceCapabilities, - new Date(), - ); - } catch (e) { - if (e instanceof Error) { - console.log(e.message); - } else { - console.log(e); - } - if (await HPApi.isAlive()) { - errorCount++; - } else { - deviceUp = false; - } + if (result.success) { + scanCount++; } - - if (errorCount === 50) { - keepActive = false; + if (!result.success && result.isDeviceAlive) { + errorCount++; } - - if (!deviceUp) { - await HPApi.waitDeviceUp(deviceUpPollingInterval); - deviceUp = true; - } else { - await HPApi.delay(1000); + if (!result.success && !result.isDeviceAlive) { + deviceUp = false; } + + const nextState = await handleLoopDelayOrTermination( + errorCount, + deviceUp, + deviceUpPollingInterval, + ); + keepActive = nextState.keepActive; + deviceUp = nextState.deviceUp; } } diff --git a/src/commands/listenCmd.ts b/src/commands/listenCmd.ts index 2a935299..d0a1649b 100644 --- a/src/commands/listenCmd.ts +++ b/src/commands/listenCmd.ts @@ -134,7 +134,7 @@ export async function listenCmd( } } -async function processScanWithDestination( +export async function processScanWithDestination( destination: WalkupDestination, selectedScanTarget: SelectedScanTarget, lastDuplexMode: DuplexMode, @@ -214,7 +214,7 @@ async function processScanWithDestination( return { scanCount, frontOfDoubleSidedScanContext, duplexMode }; } -async function handleScanResult( +export async function handleScanResult( duplexMode: DuplexMode, frontOfDoubleSidedScanContext: FrontOfDoubleSidedScanContext | null, scanConfig: ScanConfig, @@ -267,7 +267,7 @@ async function handleScanResult( return frontOfDoubleSidedScanContext; } -function determineDuplexModes( +export function determineDuplexModes( destination: WalkupDestination, selectedScanTarget: SelectedScanTarget, previousDuplexMode: DuplexMode, @@ -355,7 +355,7 @@ interface ScanParameters { scanCount: number; } -async function setupScanParameters( +export async function setupScanParameters( duplexMode: DuplexMode, targetDuplexMode: TargetDuplexMode, destination: WalkupDestination, @@ -417,7 +417,7 @@ async function setupScanParameters( return { pageCountingStrategy, scanToPdf, scanDate, scanCount }; } -async function processFinishedPartialDuplexScan( +export async function processFinishedPartialDuplexScan( lastScanTarget: SelectedScanTarget, selectedScanTarget: SelectedScanTarget, scanCount: number, @@ -438,7 +438,7 @@ async function processFinishedPartialDuplexScan( ); } -interface FrontOfDoubleSidedScanContext { +export interface FrontOfDoubleSidedScanContext { scanConfig: ScanConfig; folder: string; tempFolder: string; diff --git a/test/adfAutoscanCmd.test.ts b/test/adfAutoscanCmd.test.ts index 5f322eec..6dd992e7 100644 --- a/test/adfAutoscanCmd.test.ts +++ b/test/adfAutoscanCmd.test.ts @@ -1,4 +1,5 @@ import { describe, it, beforeEach, afterEach } from "mocha"; +import { expect } from "chai"; import nock from "nock"; import HPApi from "../src/HPApi.js"; import { adfAutoscanCmd } from "../src/commands/adfAutoscanCmd.js"; @@ -121,15 +122,282 @@ describe("adfAutoscanCmd", () => { nock("http://127.0.0.1:80").persist().get("/Scan/Status").reply(500); HPApi.isAlive = async () => true; - // Mock HPApi.delay to return instantly HPApi.delay = async () => { - /* no-op */ + /* noop */ }; - // Mock HPApi.waitDeviceUp to return instantly HPApi.waitDeviceUp = async () => { - /* no-op */ + /* noop */ }; await adfAutoscanCmd(config, 1); }); + + it("should call waitDeviceUp when device goes down and recover", async () => { + const config: AdfAutoScanConfig = { + resolution: 300, + mode: ScanMode.Color, + width: undefined, + height: undefined, + format: ScanFormat.Jpeg, + directoryConfig: { + directory: tempDir, + tempDirectory: tempDir, + filePattern: undefined, + }, + paperlessConfig: undefined, + nextcloudConfig: undefined, + preferEscl: false, + paperSize: undefined, + paperDim: undefined, + paperOrientation: undefined, + isDuplex: false, + generatePdf: false, + pollingInterval: 1, + startScanDelay: 1, + }; + + nock("http://127.0.0.1:80") + .get("/DevMgmt/DiscoveryTree.xml") + .reply( + 200, + ` + + + /Scan/ScanJobManifest + ledm:hpLedmScanJobManifest + +`, + ); + + nock("http://127.0.0.1:80") + .get("/Scan/ScanJobManifest") + .reply( + 200, + ` + + + + http://127.0.0.1 + + + + /Scan/ScanCaps + + + ScanCaps + + + + + /Scan/Status + + + Status + + + +`, + ); + + nock("http://127.0.0.1:80") + .get("/Scan/ScanCaps") + .reply( + 200, + ` + + 2550 + 3300 +`, + ); + + nock("http://127.0.0.1:80").persist().get("/Scan/Status").reply(500); + + let waitDeviceUpCalled = false; + let isAliveCallCount = 0; + HPApi.isAlive = async () => { + isAliveCallCount++; + return isAliveCallCount > 1; + }; + HPApi.delay = async () => { + /* noop */ + }; + HPApi.waitDeviceUp = async () => { + waitDeviceUpCalled = true; + }; + + await adfAutoscanCmd(config, 1); + + expect(waitDeviceUpCalled).to.be.true; + }); + + it("should execute a successful scan iteration and then terminate after 50 errors", async () => { + const config: AdfAutoScanConfig = { + resolution: 300, + mode: ScanMode.Color, + width: undefined, + height: undefined, + format: ScanFormat.Jpeg, + directoryConfig: { + directory: tempDir, + tempDirectory: tempDir, + filePattern: undefined, + }, + paperlessConfig: undefined, + nextcloudConfig: undefined, + preferEscl: false, + paperSize: undefined, + paperDim: undefined, + paperOrientation: undefined, + isDuplex: false, + generatePdf: false, + pollingInterval: 1, + startScanDelay: 1, + }; + + // Mock DiscoveryTree + nock("http://127.0.0.1:80") + .get("/DevMgmt/DiscoveryTree.xml") + .reply( + 200, + ` + + + /Scan/ScanJobManifest + ledm:hpLedmScanJobManifest + +`, + ); + + // Mock ScanJobManifest + nock("http://127.0.0.1:80") + .get("/Scan/ScanJobManifest") + .reply( + 200, + ` + + + + http://127.0.0.1 + + + + /Scan/ScanCaps + + + ScanCaps + + + + + /Scan/Status + + + Status + + + +`, + ); + + // Mock ScanCaps + nock("http://127.0.0.1:80") + .get("/Scan/ScanCaps") + .reply( + 200, + ` + + 2550 + 3300 + true +`, + ); + + // 1st & 2nd Status request: ADF is loaded and scanner is Idle + nock("http://127.0.0.1:80") + .get("/Scan/Status") + .times(2) + .reply( + 200, + ` + + Idle + Loaded +`, + ); + + // ScanJob POST + nock("http://127.0.0.1:8080") + .post("/Scan/Jobs") + .reply(201, "", { Location: "http://127.0.0.1/Scan/Jobs/123" }); + + // Job Processing Status (needs to match 2x: line 242 initial check + inside waitDeviceUntilItIsReadyToUploadOrCompleted) + nock("http://127.0.0.1:80") + .get("/Scan/Jobs/123") + .times(2) + .reply( + 200, + ` + + + + ReadyToUpload + + + 300 + 300 + Jpeg + Adf + + 1654 + 2338 + + /Scan/Jobs/123/Pages/1 + 1 + + + Processing +`, + ); + + // Job Completed Status + nock("http://127.0.0.1:80") + .get("/Scan/Jobs/123") + .reply( + 200, + ` + + + + 1 + + + Completed +`, + ); + + // Download Page + nock("http://127.0.0.1:8080") + .get("/Scan/Jobs/123/Pages/1") + .reply(200, "fake-image-data"); + + // Subsequent Status requests fail to trigger loop exit after 50 errors + nock("http://127.0.0.1:80") + .persist() + .get("/Scan/Status") + .reply(500); + + HPApi.isAlive = async () => true; + HPApi.delay = async () => { + /* noop */ + }; + HPApi.waitDeviceUp = async () => { + /* noop */ + }; + + await adfAutoscanCmd(config, 1); + + // Verify a file was indeed saved during the success iteration + const files = fs.readdirSync(tempDir); + expect(files.some((f) => f.includes("scan1"))).to.be.true; + }); }); diff --git a/test/asset/nextcloud_sample.pdf b/test/asset/nextcloud_sample.pdf index 3e79ba20..bc18cf5e 100644 Binary files a/test/asset/nextcloud_sample.pdf and b/test/asset/nextcloud_sample.pdf differ diff --git a/test/commands.test.ts b/test/commands.test.ts index 26e68840..4d41dc09 100644 --- a/test/commands.test.ts +++ b/test/commands.test.ts @@ -35,6 +35,7 @@ describe("commands", () => { .reply(200); await clearRegistrationsCmd(); + expect(nock.isDone()).to.be.true; }); it("should handle empty destinations", async () => { diff --git a/test/listenCmd.test.ts b/test/listenCmd.test.ts index aac9d4e1..cb32438f 100644 --- a/test/listenCmd.test.ts +++ b/test/listenCmd.test.ts @@ -3,474 +3,1325 @@ import type { ScanContent, ScanPage } from "../src/type/ScanContent.js"; import { describe, it, beforeEach, afterEach } from "mocha"; import nock from "nock"; import HPApi from "../src/HPApi.js"; -import { assembleDuplexScan, listenCmd } from "../src/commands/listenCmd.js"; +import { + assembleDuplexScan, + listenCmd, + processScanWithDestination, + handleScanResult, + determineDuplexModes, + setupScanParameters, + processFinishedPartialDuplexScan, + type FrontOfDoubleSidedScanContext, +} from "../src/commands/listenCmd.js"; import { DuplexAssemblyMode } from "../src/type/DuplexAssemblyMode.js"; import type { ScanConfig } from "../src/type/scanConfigs.js"; import { ScanMode } from "../src/type/scanMode.js"; import { ScanFormat } from "../src/type/scanFormat.js"; +import { DuplexMode } from "../src/type/duplexMode.js"; +import { TargetDuplexMode } from "../src/type/targetDuplexMode.js"; +import { ScanPlexMode } from "../src/hpModels/ScanPlexMode.js"; +import { PageCountingStrategy } from "../src/type/pageCountingStrategy.js"; +import type { WalkupDestination } from "../src/scanProcessing.js"; +import { KnownShortcut } from "../src/type/KnownShortcut.js"; +import type { SelectedScanTarget } from "../src/type/scanTargetDefinitions.js"; +import type { DeviceCapabilities } from "../src/type/DeviceCapabilities.js"; +import { InputSource } from "../src/type/InputSource.js"; +import { ScannerState } from "../src/hpModels/ScannerState.js"; +import { AdfState } from "../src/hpModels/AdfState.js"; +import PathHelper from "../src/PathHelper.js"; +import { fileURLToPath } from "url"; import fs from "node:fs"; import os from "node:os"; import path from "node:path"; -// Utility function to create a ScanPage with default values -const createScanPage = (overrides: Partial): ScanPage => { - return { - path: "default.png", - pageNumber: 1, - width: 100, - height: 200, - xResolution: 300, - yResolution: 300, - ...overrides, +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); + +// ─── XML Fixtures ────────────────────────────────────────────────────────────── +// Centralised so any schema change is a one-line edit, not a grep-and-replace. + +const XML = { + discoveryTree: ` + + + /Scan/ScanJobManifest + ledm:hpLedmScanJobManifest + +`, + + scanJobManifest: ` + + + http://127.0.0.1 + + /Scan/ScanCaps + ScanCaps + + + /Scan/Status + Status + + +`, + + scanCaps: ` + + 2550 + 3300 +`, + + walkupDestinationsEmpty: ` + +`, + + scanStatusIdle: ` + + Idle + Empty +`, + + eventTableEmpty: ` + + 1 +`, + + walkupScanToCompEventPagesComplete: ` + + ScanPagesComplete +`, + + scanJobProcessing: ( + jobId = "123", + pageNumber = 1, + ) => ` + + Processing + + + ReadyToUpload + /Scan/Jobs/${jobId}/Pages/${pageNumber} + ${pageNumber} + + 100 + 200 + + Platen + Photo + 300 + 300 + + + + +`, + + scanJobCompleted: (pageNumber = 1) => ` + + Completed + + ${pageNumber} + +`, + + /** A simplex scan event (no compEventURI). destinationId defaults to "1". */ + scanEventSimple: ( + destinationId = "1", + agingStamp = "1-1", + ) => ` + + 1 + + ScanEvent + ${agingStamp} + + http://127.0.0.1:80/WalkupScan/Destinations/${destinationId} + hpCnxWalkupScanDestinations + + +`, + + /** A WalkupScanToComp scan event (includes compEventURI payload). */ + scanEventWithCompUri: ( + destinationId = "1", + agingStamp = "1-1", + ) => ` + + 1 + + ScanEvent + ${agingStamp} + + http://127.0.0.1:80/WalkupScan/Destinations/${destinationId} + hpCnxWalkupScanDestinations + + + /WalkupScanToComp/WalkupScanToCompEvent + hpCnxWalkupScanToCompEvent + + +`, + + walkupDestination: ( + opts: { + id?: string; + name?: string; + hostname?: string; + plexMode?: string; + shortcut?: string; + } = {}, + ) => { + const { + id = "1", + name = "test", + hostname = "test", + plexMode = "Simplex", + shortcut = "SaveJPEG", + } = opts; + return ` + + + http://127.0.0.1/WalkupScan/Destinations/${id} + ${name} + ${hostname} + + + ${plexMode} + + ${shortcut} + + +`; + }, +}; + +// ─── Builders / Factories ────────────────────────────────────────────────────── + +const makeScanPage = (overrides: Partial = {}): ScanPage => ({ + path: "default.png", + pageNumber: 1, + width: 100, + height: 200, + xResolution: 300, + yResolution: 300, + ...overrides, +}); + +const makeScanContent = (pages: Partial[]): ScanContent => ({ + elements: pages.map(makeScanPage), +}); + +/** Convenience: build an n-page front or back scan with predictable path names. */ +const makePagedContent = (prefix: string, count: number): ScanContent => + makeScanContent( + Array.from({ length: count }, (_, i) => ({ + path: `${prefix}${i + 1}.png`, + pageNumber: i + 1, + })), + ); + +const makeScanConfig = ( + dir: string, + overrides: Partial = {}, +): ScanConfig => ({ + resolution: 300, + mode: ScanMode.Color, + width: undefined, + height: undefined, + format: ScanFormat.Jpeg, + directoryConfig: { + directory: dir, + tempDirectory: dir, + filePattern: undefined, + }, + paperlessConfig: undefined, + nextcloudConfig: undefined, + preferEscl: false, + paperSize: undefined, + paperDim: undefined, + paperOrientation: undefined, + ...overrides, +}); + +const makeScanEvent = ( + overrides: Partial<{ + unqualifiedEventCategory: string; + agingStamp: string; + destinationURI: string | undefined; + compEventURI: string | undefined; + isScanEvent: boolean; + }> = {}, +) => ({ + unqualifiedEventCategory: "ScanEvent", + agingStamp: "0", + destinationURI: "/WalkupScan/Destinations/1", + compEventURI: undefined, + isScanEvent: true, + ...overrides, +}); + +const makeDestination = ( + overrides: Partial = {}, +): WalkupDestination => ({ + shortcut: KnownShortcut.SaveJPEG, + scanPlexMode: null, + ...overrides, +}); + +const makeScanTarget = ( + overrides: Partial = {}, +): SelectedScanTarget => ({ + resourceURI: "/WalkupScan/Destinations/1", + label: "test", + isDuplexSingleSide: false, + event: makeScanEvent(), + ...overrides, +}); + +const makeDeviceCapabilities = ( + overrides: Partial = {}, +): DeviceCapabilities => ({ + supportsMultiItemScanFromPlaten: false, + useWalkupScanToComp: false, + platenMaxWidth: null, + platenMaxHeight: null, + adfMaxWidth: null, + adfMaxHeight: null, + adfDuplexMaxWidth: null, + adfDuplexMaxHeight: null, + hasAdfDuplex: false, + hasAdfDetectPaperLoaded: false, + userActionTimeout: null, + isEscl: false, + getScanStatus: async () => ({ + scannerState: ScannerState.Idle, + adfState: AdfState.Empty, + isLoaded: () => false, + getInputSource: () => InputSource.Platen, + }), + createScanJobSettings: (..._args: unknown[]) => + ({ + format: { isJpeg: () => true, getExtension: () => "jpg" }, + mode: ScanMode.Color, + toXML: async () => "", + xResolution: 300, + yResolution: 300, + }) as never, + submitScanJob: async () => "http://127.0.0.1:8080/Scan/Jobs/123", + ...overrides, +}); + +const makeFrontContext = ( + dir: string, + overrides: Partial = {}, +): FrontOfDoubleSidedScanContext => ({ + scanConfig: makeScanConfig(dir), + folder: dir, + tempFolder: dir, + scanCount: 1, + scanJobContent: { elements: [] }, + scanDate: new Date("2024-01-01"), + scanToPdf: false, + ...overrides, +}); + +// ─── HTTP nock helpers ───────────────────────────────────────────────────────── +// Registers the standard LEDM discovery + capability endpoints on port 80. +// Call this at the top of any listenCmd integration test. + +const nockLedmBootstrap = () => { + const scope = nock("http://127.0.0.1:80").persist(); + scope.get("/DevMgmt/DiscoveryTree.xml").reply(200, XML.discoveryTree); + scope.get("/Scan/ScanJobManifest").reply(200, XML.scanJobManifest); + scope.get("/Scan/ScanCaps").reply(200, XML.scanCaps); + scope.get("/Scan/Status").reply(200, XML.scanStatusIdle); + scope + .get("/WalkupScan/WalkupScanDestinations") + .reply(200, XML.walkupDestinationsEmpty); + scope.post("/WalkupScan/WalkupScanDestinations").reply(201, "", { + Location: "http://127.0.0.1/WalkupScan/Destinations/1", + }); +}; + +/** Registers a standard single-page scan job lifecycle on port 8080. */ +const nockScanJob = (jobId = "123", pageNumber = 1) => { + const scope = nock("http://127.0.0.1:8080"); + scope.post("/Scan/Jobs").optionally().reply(201, "", { + Location: `http://127.0.0.1:8080/Scan/Jobs/${jobId}`, + }); + scope + .get(`/Scan/Jobs/${jobId}`) + .times(2) + .reply(200, XML.scanJobProcessing(jobId, pageNumber)); + scope + .get(`/Scan/Jobs/${jobId}/Pages/${pageNumber}`) + .reply(200, Buffer.from("fake-image-data"), { + "Content-Type": "image/jpeg", + }); + scope.get(`/Scan/Jobs/${jobId}`).reply(200, XML.scanJobCompleted(pageNumber)); + return scope; +}; + +// ─── Filesystem helpers ──────────────────────────────────────────────────────── + +/** Copies the test asset JPEG into a temp dir and returns the written path. */ +const writeSampleJpeg = (dir: string, filename = "sample.jpg"): string => { + const src = path.join(__dirname, "asset/sample.jpg"); + const dest = path.join(dir, filename); + fs.writeFileSync(dest, fs.readFileSync(src)); + return dest; +}; + +const makeTempDir = (prefix: string) => + fs.mkdtempSync(path.join(os.tmpdir(), prefix)); + +const removeTempDir = (dir: string) => { + if (fs.existsSync(dir)) { fs.rmSync(dir, { recursive: true, force: true }); } +}; + +// ─── HPApi stub helpers ──────────────────────────────────────────────────────── + +interface ApiStubs { + isAlive: typeof HPApi.isAlive; + delay: typeof HPApi.delay; + waitDeviceUp: typeof HPApi.waitDeviceUp; + isDebug: typeof HPApi.isDebug; + getWalkupScanToCompEvent: typeof HPApi.getWalkupScanToCompEvent; +} + +const stubApiInstant = (): ApiStubs => { + const saved: ApiStubs = { + isAlive: HPApi.isAlive, + delay: HPApi.delay, + waitDeviceUp: HPApi.waitDeviceUp, + isDebug: HPApi.isDebug, + getWalkupScanToCompEvent: HPApi.getWalkupScanToCompEvent, }; + HPApi.isAlive = async () => true; + HPApi.delay = async () => { + /* instant */ + }; + HPApi.waitDeviceUp = async () => { + /* instant */ + }; + return saved; }; -// Utility function to create ScanContent -const createScanContent = (pages: Partial[]): ScanContent => { - return { elements: pages.map(createScanPage) }; +const restoreApi = (saved: ApiStubs) => { + HPApi.isAlive = saved.isAlive; + HPApi.delay = saved.delay; + HPApi.waitDeviceUp = saved.waitDeviceUp; + HPApi.isDebug = saved.isDebug; + HPApi.getWalkupScanToCompEvent = saved.getWalkupScanToCompEvent; }; +// ───────────────────────────────────────────────────────────────────────────── +// assembleDuplexScan +// ───────────────────────────────────────────────────────────────────────────── + describe("assembleDuplexScan", () => { - it("should assemble pages in natural order for PAGE_WISE mode", () => { - const frontScan = createScanContent([ - { path: "front1.png", pageNumber: 1 }, - { path: "front2.png", pageNumber: 2 }, - ]); - const backScan = createScanContent([ - { path: "back1.png", pageNumber: 1 }, - { path: "back2.png", pageNumber: 2 }, - ]); - const result = assembleDuplexScan( - frontScan, - backScan, + // ── Behavioural (example-based) ─────────────────────────────────────────── + + it("PAGE_WISE: interleaves front and back in natural order", () => { + const front = makePagedContent("front", 2); + const back = makePagedContent("back", 2); + const { elements } = assembleDuplexScan( + front, + back, DuplexAssemblyMode.PAGE_WISE, ); - expect(result.elements).to.deep.equal([ - { - path: "front1.png", - pageNumber: 1, - width: 100, - height: 200, - xResolution: 300, - yResolution: 300, - }, - { - path: "back1.png", - pageNumber: 1, - width: 100, - height: 200, - xResolution: 300, - yResolution: 300, - }, - { - path: "front2.png", - pageNumber: 2, - width: 100, - height: 200, - xResolution: 300, - yResolution: 300, - }, - { - path: "back2.png", - pageNumber: 2, - width: 100, - height: 200, - xResolution: 300, - yResolution: 300, - }, + expect(elements.map((e) => e.path)).to.deep.equal([ + "front1.png", + "back1.png", + "front2.png", + "back2.png", ]); }); - it("should reverse backs for DOCUMENT_WISE mode", () => { - const frontScan = createScanContent([ - { path: "front1.png", pageNumber: 1 }, - { path: "front2.png", pageNumber: 2 }, - ]); - const backScan = createScanContent([ - { path: "back1.png", pageNumber: 1 }, - { path: "back2.png", pageNumber: 2 }, - ]); - const result = assembleDuplexScan( - frontScan, - backScan, + it("DOCUMENT_WISE: interleaves fronts with reversed backs", () => { + const front = makePagedContent("front", 2); + const back = makePagedContent("back", 2); + const { elements } = assembleDuplexScan( + front, + back, DuplexAssemblyMode.DOCUMENT_WISE, ); - expect(result.elements).to.deep.equal([ - { - path: "front1.png", - pageNumber: 1, - width: 100, - height: 200, - xResolution: 300, - yResolution: 300, - }, - { - path: "back2.png", - pageNumber: 2, - width: 100, - height: 200, - xResolution: 300, - yResolution: 300, - }, - { - path: "front2.png", - pageNumber: 2, - width: 100, - height: 200, - xResolution: 300, - yResolution: 300, - }, - { - path: "back1.png", - pageNumber: 1, - width: 100, - height: 200, - xResolution: 300, - yResolution: 300, - }, + expect(elements.map((e) => e.path)).to.deep.equal([ + "front1.png", + "back2.png", + "front2.png", + "back1.png", ]); }); - it("should reverse fronts for REVERSE_FRONT mode", () => { - const frontScan = createScanContent([ - { path: "front1.png", pageNumber: 1 }, - { path: "front2.png", pageNumber: 2 }, - ]); - const backScan = createScanContent([ - { path: "back1.png", pageNumber: 1 }, - { path: "back2.png", pageNumber: 2 }, - ]); - const result = assembleDuplexScan( - frontScan, - backScan, + it("REVERSE_FRONT: interleaves reversed fronts with natural backs", () => { + const front = makePagedContent("front", 2); + const back = makePagedContent("back", 2); + const { elements } = assembleDuplexScan( + front, + back, DuplexAssemblyMode.REVERSE_FRONT, ); - expect(result.elements).to.deep.equal([ - { - path: "front2.png", - pageNumber: 2, - width: 100, - height: 200, - xResolution: 300, - yResolution: 300, - }, - { - path: "back1.png", - pageNumber: 1, - width: 100, - height: 200, - xResolution: 300, - yResolution: 300, - }, - { - path: "front1.png", - pageNumber: 1, - width: 100, - height: 200, - xResolution: 300, - yResolution: 300, - }, - { - path: "back2.png", - pageNumber: 2, - width: 100, - height: 200, - xResolution: 300, - yResolution: 300, - }, + expect(elements.map((e) => e.path)).to.deep.equal([ + "front2.png", + "back1.png", + "front1.png", + "back2.png", ]); }); - it("should reverse both fronts and backs for REVERSE_BOTH mode", () => { - const frontScan = createScanContent([ - { path: "front1.png", pageNumber: 1 }, - { path: "front2.png", pageNumber: 2 }, - ]); - const backScan = createScanContent([ - { path: "back1.png", pageNumber: 1 }, - { path: "back2.png", pageNumber: 2 }, - ]); - const result = assembleDuplexScan( - frontScan, - backScan, + it("REVERSE_BOTH: interleaves reversed fronts with reversed backs", () => { + const front = makePagedContent("front", 2); + const back = makePagedContent("back", 2); + const { elements } = assembleDuplexScan( + front, + back, DuplexAssemblyMode.REVERSE_BOTH, ); - expect(result.elements).to.deep.equal([ - { - path: "front2.png", - pageNumber: 2, - width: 100, - height: 200, - xResolution: 300, - yResolution: 300, - }, - { - path: "back2.png", - pageNumber: 2, - width: 100, - height: 200, - xResolution: 300, - yResolution: 300, - }, - { - path: "front1.png", - pageNumber: 1, - width: 100, - height: 200, - xResolution: 300, - yResolution: 300, - }, - { - path: "back1.png", - pageNumber: 1, - width: 100, - height: 200, - xResolution: 300, - yResolution: 300, - }, + expect(elements.map((e) => e.path)).to.deep.equal([ + "front2.png", + "back2.png", + "front1.png", + "back1.png", ]); }); - it("should handle cases with missing back pages gracefully", () => { - const frontScan = createScanContent([ - { path: "front1.png", pageNumber: 1 }, - { path: "front2.png", pageNumber: 2 }, - ]); - const backScan = createScanContent([ - { path: "back1.png", pageNumber: 1 }, // Only one back page - ]); - const result = assembleDuplexScan( - frontScan, - backScan, + it("tolerates a missing last back page (odd-page document)", () => { + const front = makePagedContent("front", 2); + const back = makePagedContent("back", 1); + const { elements } = assembleDuplexScan( + front, + back, DuplexAssemblyMode.PAGE_WISE, ); - expect(result.elements).to.deep.equal([ - { - path: "front1.png", - pageNumber: 1, - width: 100, - height: 200, - xResolution: 300, - yResolution: 300, - }, - { - path: "back1.png", - pageNumber: 1, - width: 100, - height: 200, - xResolution: 300, - yResolution: 300, - }, - { - path: "front2.png", - pageNumber: 2, - width: 100, - height: 200, - xResolution: 300, - yResolution: 300, - }, + expect(elements.map((e) => e.path)).to.deep.equal([ + "front1.png", + "back1.png", + "front2.png", ]); }); - it("should handle cases with missing front pages gracefully", () => { - const frontScan = createScanContent([]); // No front pages - const backScan = createScanContent([ - { path: "back1.png", pageNumber: 1 }, - { path: "back2.png", pageNumber: 2 }, - ]); - const result = assembleDuplexScan( - frontScan, - backScan, + it("tolerates an entirely missing front scan", () => { + const front = makeScanContent([]); + const back = makePagedContent("back", 2); + const { elements } = assembleDuplexScan( + front, + back, DuplexAssemblyMode.PAGE_WISE, ); - expect(result.elements).to.deep.equal([ - { - path: "back1.png", - pageNumber: 1, - width: 100, - height: 200, - xResolution: 300, - yResolution: 300, - }, - { - path: "back2.png", - pageNumber: 2, - width: 100, - height: 200, - xResolution: 300, - yResolution: 300, - }, + expect(elements.map((e) => e.path)).to.deep.equal([ + "back1.png", + "back2.png", ]); }); - it("should return an empty array if both scans are empty", () => { - const frontScan = createScanContent([]); // No front pages - const backScan = createScanContent([]); // No back pages + it("returns empty output when both scans are empty", () => { const result = assembleDuplexScan( - frontScan, - backScan, + makeScanContent([]), + makeScanContent([]), DuplexAssemblyMode.PAGE_WISE, ); - expect(result.elements).to.deep.equal([]); }); + + it("DOCUMENT_WISE: handles unequal page counts (3 fronts, 2 backs)", () => { + const front = makePagedContent("front", 3); + const back = makePagedContent("back", 2); + const { elements } = assembleDuplexScan( + front, + back, + DuplexAssemblyMode.DOCUMENT_WISE, + ); + + // backs reversed → back2, back1; front3 has no matching back + expect(elements.map((e) => e.path)).to.deep.equal([ + "front1.png", + "back2.png", + "front2.png", + "back1.png", + "front3.png", + ]); + }); + + // ── Property-style invariant tests ──────────────────────────────────────── + // These are not full property-based tests (which would need fast-check / + // similar), but they exhaustively cover the invariants across every mode + // and several representative sizes, without hardcoding the exact order. + + const ALL_MODES = Object.values(DuplexAssemblyMode) as DuplexAssemblyMode[]; + + // Helper: build n-page content with globally unique, distinguishable paths. + const uniqueContent = (prefix: string, n: number): ScanContent => + makeScanContent( + Array.from({ length: n }, (_, i) => ({ + path: `${prefix}_${i}`, + pageNumber: i, + })), + ); + + const runProperty = ( + frontCount: number, + backCount: number, + mode: DuplexAssemblyMode, + ) => { + const front = uniqueContent("F", frontCount); + const back = uniqueContent("B", backCount); + const result = assembleDuplexScan(front, back, mode); + return { front, back, result }; + }; + + for (const mode of ALL_MODES) { + describe(`mode=${mode}`, () => { + for (const [fCount, bCount] of [ + [0, 0], + [1, 0], + [0, 1], + [1, 1], + [3, 3], + [4, 3], + [3, 4], + ]) { + it(`[${fCount}F + ${bCount}B] output length = fronts + backs`, () => { + const { result } = runProperty(fCount, bCount, mode); + expect(result.elements.length).to.equal(fCount + bCount); + }); + + it(`[${fCount}F + ${bCount}B] no page is lost (all paths present)`, () => { + const { front, back, result } = runProperty(fCount, bCount, mode); + const outPaths = result.elements.map((e) => e.path).sort(); + const inPaths = [ + ...front.elements.map((e) => e.path), + ...back.elements.map((e) => e.path), + ].sort(); + expect(outPaths).to.deep.equal(inPaths); + }); + + it(`[${fCount}F + ${bCount}B] no page is duplicated`, () => { + const { result } = runProperty(fCount, bCount, mode); + const paths = result.elements.map((e) => e.path); + const unique = new Set(paths); + expect(unique.size).to.equal(paths.length); + }); + } + }); + } + + // Relative order within each stream is preserved for modes that don't reverse. + it("PAGE_WISE: relative order of fronts is preserved", () => { + const { front, result } = runProperty(4, 4, DuplexAssemblyMode.PAGE_WISE); + const outFronts = result.elements + .filter((e) => e.path.startsWith("F_")) + .map((e) => e.path); + expect(outFronts).to.deep.equal(front.elements.map((e) => e.path)); + }); + + it("PAGE_WISE: relative order of backs is preserved", () => { + const { back, result } = runProperty(4, 4, DuplexAssemblyMode.PAGE_WISE); + const outBacks = result.elements + .filter((e) => e.path.startsWith("B_")) + .map((e) => e.path); + expect(outBacks).to.deep.equal(back.elements.map((e) => e.path)); + }); + + it("DOCUMENT_WISE: relative order of fronts is preserved", () => { + const { front, result } = runProperty( + 4, + 4, + DuplexAssemblyMode.DOCUMENT_WISE, + ); + const outFronts = result.elements + .filter((e) => e.path.startsWith("F_")) + .map((e) => e.path); + expect(outFronts).to.deep.equal(front.elements.map((e) => e.path)); + }); + + it("DOCUMENT_WISE: backs appear in reversed order", () => { + const { back, result } = runProperty( + 4, + 4, + DuplexAssemblyMode.DOCUMENT_WISE, + ); + const outBacks = result.elements + .filter((e) => e.path.startsWith("B_")) + .map((e) => e.path); + expect(outBacks).to.deep.equal( + [...back.elements.map((e) => e.path)].reverse(), + ); + }); + + it("REVERSE_FRONT: fronts appear in reversed order", () => { + const { front, result } = runProperty( + 4, + 4, + DuplexAssemblyMode.REVERSE_FRONT, + ); + const outFronts = result.elements + .filter((e) => e.path.startsWith("F_")) + .map((e) => e.path); + expect(outFronts).to.deep.equal( + [...front.elements.map((e) => e.path)].reverse(), + ); + }); }); -describe("listenCmd", () => { - let tempDir: string; +// ───────────────────────────────────────────────────────────────────────────── +// determineDuplexModes (pure function — no I/O) +// ───────────────────────────────────────────────────────────────────────────── - let originalIsAlive: typeof HPApi.isAlive; - let originalWaitDeviceUp: typeof HPApi.waitDeviceUp; +describe("determineDuplexModes", () => { + // Helpers: narrow factories for this suite. + const simplexDest = () => makeDestination({ scanPlexMode: null }); + const duplexDest = () => + makeDestination({ scanPlexMode: ScanPlexMode.Duplex }); + const singleSideTarget = (uri = "/WalkupScan/Destinations/1") => + makeScanTarget({ resourceURI: uri, isDuplexSingleSide: true }); + const normalTarget = () => makeScanTarget({ isDuplexSingleSide: false }); + + it("scanPlexMode=null, isDuplexSingleSide=false → Simplex / Simplex", () => { + const { duplexMode, targetDuplexMode } = determineDuplexModes( + simplexDest(), + normalTarget(), + DuplexMode.Simplex, + undefined, + ); + expect(duplexMode).to.equal(DuplexMode.Simplex); + expect(targetDuplexMode).to.equal(TargetDuplexMode.Simplex); + }); + + it("scanPlexMode=Simplex, isDuplexSingleSide=false → Simplex / Simplex", () => { + const { duplexMode, targetDuplexMode } = determineDuplexModes( + makeDestination({ scanPlexMode: ScanPlexMode.Simplex }), + normalTarget(), + DuplexMode.Simplex, + undefined, + ); + expect(duplexMode).to.equal(DuplexMode.Simplex); + expect(targetDuplexMode).to.equal(TargetDuplexMode.Simplex); + }); + + it("scanPlexMode=Duplex → Duplex / Duplex (overrides isDuplexSingleSide)", () => { + const { duplexMode, targetDuplexMode } = determineDuplexModes( + duplexDest(), + normalTarget(), + DuplexMode.Simplex, + undefined, + ); + expect(duplexMode).to.equal(DuplexMode.Duplex); + expect(targetDuplexMode).to.equal(TargetDuplexMode.Duplex); + }); + + it("first emulated-duplex scan → FrontOfDoubleSided", () => { + const { duplexMode, targetDuplexMode } = determineDuplexModes( + simplexDest(), + singleSideTarget(), + DuplexMode.Simplex, + undefined, + ); + expect(duplexMode).to.equal(DuplexMode.FrontOfDoubleSided); + expect(targetDuplexMode).to.equal(TargetDuplexMode.EmulatedDuplex); + }); + + it("same target as last scan + previousMode=Front → BackOfDoubleSided", () => { + const target = singleSideTarget("/WalkupScan/Destinations/1"); + const { duplexMode, targetDuplexMode } = determineDuplexModes( + simplexDest(), + target, + DuplexMode.FrontOfDoubleSided, + target, + ); + expect(duplexMode).to.equal(DuplexMode.BackOfDoubleSided); + expect(targetDuplexMode).to.equal(TargetDuplexMode.EmulatedDuplex); + }); + + it("same target + previousMode=Back → FrontOfDoubleSided (cycle resets)", () => { + const target = singleSideTarget("/WalkupScan/Destinations/1"); + const { duplexMode, targetDuplexMode } = determineDuplexModes( + simplexDest(), + target, + DuplexMode.BackOfDoubleSided, + target, + ); + expect(duplexMode).to.equal(DuplexMode.FrontOfDoubleSided); + expect(targetDuplexMode).to.equal(TargetDuplexMode.EmulatedDuplex); + }); + + it("different target from last scan → FrontOfDoubleSided (new document)", () => { + const current = singleSideTarget("/WalkupScan/Destinations/2"); + const previous = singleSideTarget("/WalkupScan/Destinations/1"); + const { duplexMode } = determineDuplexModes( + simplexDest(), + current, + DuplexMode.FrontOfDoubleSided, + previous, + ); + expect(duplexMode).to.equal(DuplexMode.FrontOfDoubleSided); + }); +}); + +// ───────────────────────────────────────────────────────────────────────────── +// setupScanParameters (async but pure — only PathHelper is a dependency) +// ───────────────────────────────────────────────────────────────────────────── + +describe("setupScanParameters", () => { + let originalGetNextScanNumber: typeof PathHelper.getNextScanNumber; + + const cfg = makeScanConfig("/tmp"); + const jpegDest = makeDestination({ shortcut: KnownShortcut.SaveJPEG }); + const pdfDest = makeDestination({ shortcut: KnownShortcut.SavePDF }); beforeEach(() => { - if (!nock.isActive()) { - nock.activate(); + originalGetNextScanNumber = PathHelper.getNextScanNumber; + // Always returns 42 so assertions are deterministic. + PathHelper.getNextScanNumber = async () => 42; + }); + + afterEach(() => { + PathHelper.getNextScanNumber = originalGetNextScanNumber; + }); + + it("Simplex + SaveJPEG → Normal counting, not PDF, incremented scan number", async () => { + const result = await setupScanParameters( + DuplexMode.Simplex, + TargetDuplexMode.Simplex, + jpegDest, + 0, + "/tmp", + cfg, + null, + ); + expect(result.pageCountingStrategy).to.equal(PageCountingStrategy.Normal); + expect(result.scanToPdf).to.be.false; + expect(result.scanCount).to.equal(42); + }); + + it("Duplex + SaveJPEG → Normal counting, not PDF", async () => { + const result = await setupScanParameters( + DuplexMode.Duplex, + TargetDuplexMode.Duplex, + jpegDest, + 0, + "/tmp", + cfg, + null, + ); + expect(result.pageCountingStrategy).to.equal(PageCountingStrategy.Normal); + expect(result.scanToPdf).to.be.false; + expect(result.scanCount).to.equal(42); + }); + + it("Duplex + SavePDF → scanToPdf=true", async () => { + const result = await setupScanParameters( + DuplexMode.Duplex, + TargetDuplexMode.Duplex, + pdfDest, + 0, + "/tmp", + cfg, + null, + ); + expect(result.scanToPdf).to.be.true; + expect(result.pageCountingStrategy).to.equal(PageCountingStrategy.Normal); + expect(result.scanCount).to.equal(42); + }); + + it("FrontOfDoubleSided → OddOnly counting, new scan number allocated", async () => { + const result = await setupScanParameters( + DuplexMode.FrontOfDoubleSided, + TargetDuplexMode.EmulatedDuplex, + jpegDest, + 0, + "/tmp", + cfg, + null, + ); + expect(result.pageCountingStrategy).to.equal(PageCountingStrategy.OddOnly); + expect(result.scanToPdf).to.be.false; + expect(result.scanCount).to.equal(42); + }); + + it("BackOfDoubleSided → EvenOnly counting, inherits scan number/date/pdf flag from front context", async () => { + const frontCtx = makeFrontContext("/tmp", { + scanCount: 10, + scanDate: new Date("2024-06-15"), + scanToPdf: true, + }); + const result = await setupScanParameters( + DuplexMode.BackOfDoubleSided, + TargetDuplexMode.EmulatedDuplex, + jpegDest, + 0, + "/tmp", + cfg, + frontCtx, + ); + expect(result.pageCountingStrategy).to.equal(PageCountingStrategy.EvenOnly); + expect(result.scanToPdf).to.be.true; + expect(result.scanCount).to.equal(10); + // Same reference — no copy made. + expect(result.scanDate).to.equal(frontCtx.scanDate); + }); + + it("BackOfDoubleSided with null front context → falls back to safe defaults", async () => { + // Tests the nullish-coalescing fallback path in setupScanParameters. + const result = await setupScanParameters( + DuplexMode.BackOfDoubleSided, + TargetDuplexMode.EmulatedDuplex, + jpegDest, + 5, + "/tmp", + cfg, + null, + ); + expect(result.pageCountingStrategy).to.equal(PageCountingStrategy.EvenOnly); + expect(result.scanToPdf).to.be.false; + // scanCount falls back to the passed-in value when no context. + expect(result.scanCount).to.equal(5); + }); +}); + +// ───────────────────────────────────────────────────────────────────────────── +// handleScanResult +// ───────────────────────────────────────────────────────────────────────────── + +describe("handleScanResult", () => { + const FIXED_DATE = new Date("2024-01-01"); + + // FrontOfDoubleSided is pure context-capture — no I/O needed. + it("FrontOfDoubleSided: captures all inputs into a new context, returns it", async () => { + const cfg = makeScanConfig("/tmp"); + const content = makeScanContent([{ path: "p1.png" }]); + const result = await handleScanResult( + DuplexMode.FrontOfDoubleSided, + null, + cfg, + "/tmp", + "/tmp", + 7, + content, + FIXED_DATE, + true, + DuplexAssemblyMode.DOCUMENT_WISE, + ); + + expect(result).to.not.be.null; + expect(result?.scanConfig).to.equal(cfg); + expect(result?.folder).to.equal("/tmp"); + expect(result?.tempFolder).to.equal("/tmp"); + expect(result?.scanCount).to.equal(7); + expect(result?.scanJobContent).to.equal(content); + expect(result?.scanDate).to.equal(FIXED_DATE); + expect(result?.scanToPdf).to.be.true; + }); + + // Simplex and BackOfDoubleSided both write output — need a real temp dir + JPEG. + describe("modes that invoke postProcessing", () => { + let tempDir: string; + + beforeEach(() => { + tempDir = makeTempDir("handleScanResult-"); + }); + afterEach(() => removeTempDir(tempDir)); + + it("Simplex: returns null and writes output PDF", async () => { + const jpegPath = writeSampleJpeg(tempDir, "page1.jpg"); + const cfg = makeScanConfig(tempDir); + const result = await handleScanResult( + DuplexMode.Simplex, + null, + cfg, + tempDir, + tempDir, + 1, + makeScanContent([{ path: jpegPath }]), + FIXED_DATE, + true, + DuplexAssemblyMode.DOCUMENT_WISE, + ); + + expect(result).to.be.null; + expect(fs.existsSync(path.join(tempDir, "scan1.pdf"))).to.be.true; + }); + + it("BackOfDoubleSided: assembles front+back, writes merged PDF, returns (unchanged) front context", async () => { + const frontPath = writeSampleJpeg(tempDir, "front1.jpg"); + const backPath = writeSampleJpeg(tempDir, "back1.jpg"); + const cfg = makeScanConfig(tempDir); + + const frontCtx = makeFrontContext(tempDir, { + scanConfig: cfg, + scanCount: 2, + scanToPdf: true, + scanDate: FIXED_DATE, + scanJobContent: makeScanContent([{ path: frontPath }]), + }); + + const result = await handleScanResult( + DuplexMode.BackOfDoubleSided, + frontCtx, + cfg, + tempDir, + tempDir, + 2, + makeScanContent([{ path: backPath }]), + FIXED_DATE, + true, + DuplexAssemblyMode.DOCUMENT_WISE, + ); + + // The function returns the front context object reference unchanged. + expect(result).to.equal(frontCtx); + expect(fs.existsSync(path.join(tempDir, "scan2.pdf"))).to.be.true; + }); + }); +}); + +// ───────────────────────────────────────────────────────────────────────────── +// processFinishedPartialDuplexScan +// ───────────────────────────────────────────────────────────────────────────── + +describe("processFinishedPartialDuplexScan", () => { + it("flushes the front context via postProcessing and produces a PDF", async () => { + const tempDir = makeTempDir("processFinished-"); + + try { + const jpegPath = writeSampleJpeg(tempDir, "scan1_page1.jpg"); + const frontCtx = makeFrontContext(tempDir, { + scanConfig: makeScanConfig(tempDir), + scanCount: 1, + scanToPdf: true, + scanJobContent: makeScanContent([{ path: jpegPath }]), + }); + + await processFinishedPartialDuplexScan( + makeScanTarget({ resourceURI: "/dest/1", isDuplexSingleSide: true }), + makeScanTarget({ resourceURI: "/dest/2", isDuplexSingleSide: true }), + 1, + frontCtx, + ); + + expect(fs.existsSync(path.join(tempDir, "scan1.pdf"))).to.be.true; + } finally { + removeTempDir(tempDir); } + }); +}); + +// ───────────────────────────────────────────────────────────────────────────── +// processScanWithDestination +// ───────────────────────────────────────────────────────────────────────────── + +describe("processScanWithDestination", () => { + let tempDir: string; + let savedApi: ApiStubs; + let originalGetNextScanNumber: typeof PathHelper.getNextScanNumber; + + beforeEach(() => { + if (!nock.isActive()) { nock.activate(); } nock.disableNetConnect(); HPApi.setDeviceIP("127.0.0.1"); - // Mock HPApi.isAlive to return true instantly - originalIsAlive = HPApi.isAlive; - originalWaitDeviceUp = HPApi.waitDeviceUp; - HPApi.isAlive = async () => true; - tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "listenCmd-test-")); + + savedApi = stubApiInstant(); + + originalGetNextScanNumber = PathHelper.getNextScanNumber; + PathHelper.getNextScanNumber = async (_f, current) => current + 1; + + tempDir = makeTempDir("processScan-"); }); afterEach(() => { - HPApi.isAlive = originalIsAlive; - HPApi.waitDeviceUp = originalWaitDeviceUp; + restoreApi(savedApi); + PathHelper.getNextScanNumber = originalGetNextScanNumber; nock.cleanAll(); - if (fs.existsSync(tempDir)) { - fs.rmSync(tempDir, { recursive: true, force: true }); - } + removeTempDir(tempDir); }); - it("should stop when device is down", async () => { - const scanConfig: ScanConfig = { - resolution: 300, - mode: ScanMode.Color, - width: undefined, - height: undefined, - format: ScanFormat.Jpeg, - directoryConfig: { - directory: tempDir, - tempDirectory: tempDir, - filePattern: undefined, - }, - paperlessConfig: undefined, - nextcloudConfig: undefined, - preferEscl: false, - paperSize: undefined, - paperDim: undefined, - paperOrientation: undefined, - }; + it("Simplex scan: returns Simplex mode, incremented scan count, null duplex context", async () => { + const jobScope = nockScanJob(); - // Mock HPApi.getDiscoveryTree - nock("http://127.0.0.1:80") - .persist() - .get("/DevMgmt/DiscoveryTree.xml") - .reply( - 200, - ` - - - /Scan/ScanJobManifest - ledm:hpLedmScanJobManifest - -`, - ); + const result = await processScanWithDestination( + makeDestination({ scanPlexMode: null }), + makeScanTarget({ isDuplexSingleSide: false }), + DuplexMode.Simplex, + undefined, + tempDir, + tempDir, + makeScanConfig(tempDir), + makeDeviceCapabilities(), + 0, + null, + ); + + expect(result.duplexMode).to.equal(DuplexMode.Simplex); + expect(result.scanCount).to.equal(1); + expect(result.frontOfDoubleSidedScanContext).to.be.null; + expect(jobScope.isDone()).to.be.true; + }); + + it("switching from emulated front to Simplex: flushes partial PDF then scans", async () => { + const jobScope = nockScanJob(); + const frontJpeg = writeSampleJpeg(tempDir, "front.jpg"); + + const result = await processScanWithDestination( + makeDestination({ shortcut: KnownShortcut.SaveJPEG, scanPlexMode: null }), + makeScanTarget({ + resourceURI: "/WalkupScan/Destinations/2", + isDuplexSingleSide: false, + }), + DuplexMode.FrontOfDoubleSided, + makeScanTarget({ + resourceURI: "/WalkupScan/Destinations/1", + isDuplexSingleSide: true, + }), + tempDir, + tempDir, + makeScanConfig(tempDir), + makeDeviceCapabilities(), + 1, + makeFrontContext(tempDir, { + scanCount: 0, + scanToPdf: true, + scanJobContent: makeScanContent([{ path: frontJpeg }]), + }), + ); + + expect(result.duplexMode).to.equal(DuplexMode.Simplex); + expect(result.scanCount).to.equal(2); + expect(jobScope.isDone()).to.be.true; + // The flushed front-only PDF must exist (scan count from frontContext = 0). + const pdfPath = path.join(tempDir, "scan0.pdf"); + console.log("DEBUG: checking for", pdfPath); + console.log("DEBUG: tempDir contents:", fs.readdirSync(tempDir)); + console.log("DEBUG: exists?", fs.existsSync(pdfPath)); + expect(fs.existsSync(pdfPath)).to.be.true; + }); + + it("switching from emulated front to Duplex: flushes partial PDF then scans in Duplex mode", async () => { + const jobScope = nockScanJob(); + const frontJpeg = writeSampleJpeg(tempDir, "front.jpg"); + + const result = await processScanWithDestination( + makeDestination({ scanPlexMode: ScanPlexMode.Duplex }), + makeScanTarget({ + resourceURI: "/WalkupScan/Destinations/2", + isDuplexSingleSide: false, + }), + DuplexMode.FrontOfDoubleSided, + makeScanTarget({ + resourceURI: "/WalkupScan/Destinations/1", + isDuplexSingleSide: true, + }), + tempDir, + tempDir, + makeScanConfig(tempDir), + makeDeviceCapabilities(), + 1, + makeFrontContext(tempDir, { + scanCount: 0, + scanToPdf: true, + scanJobContent: makeScanContent([{ path: frontJpeg }]), + }), + ); + + expect(result.duplexMode).to.equal(DuplexMode.Duplex); + expect(result.scanCount).to.equal(2); + expect(jobScope.isDone()).to.be.true; + expect(fs.existsSync(path.join(tempDir, "scan0.pdf"))).to.be.true; + }); +}); + +// ───────────────────────────────────────────────────────────────────────────── +// listenCmd (full integration — exercises the event loop) +// ───────────────────────────────────────────────────────────────────────────── - // Mock HPApi.getScanJobManifest +describe("listenCmd", () => { + let tempDir: string; + let savedApi: ApiStubs; + + beforeEach(() => { + if (!nock.isActive()) { nock.activate(); } + nock.disableNetConnect(); + HPApi.setDeviceIP("127.0.0.1"); + savedApi = stubApiInstant(); + tempDir = makeTempDir("listenCmd-"); + }); + + afterEach(() => { + restoreApi(savedApi); + nock.cleanAll(); + removeTempDir(tempDir); + }); + + it("exits after 50 consecutive errors when device is alive (circuit breaker)", async () => { + nockLedmBootstrap(); + // Every event-table poll returns 500 → errorCount increments each iteration. nock("http://127.0.0.1:80") .persist() - .get("/Scan/ScanJobManifest") - .reply( - 200, - ` - - - - http://127.0.0.1 - - - - /Scan/ScanCaps - - - ScanCaps - - - - - /Scan/Status - - - Status - - - -`, - ); + .get("/EventMgmt/EventTable") + .reply(500); + + const errorsBefore = 0; + const originalIsAlive = HPApi.isAlive; + let callCount = 0; + HPApi.isAlive = async () => { + callCount++; + return true; + }; + + await listenCmd( + [{ label: "host", isDuplexSingleSide: false }], + makeScanConfig(tempDir), + 1, + ); + + // isAlive is checked once per failing iteration — must have been called ≥50 times. + expect(callCount).to.be.greaterThanOrEqual(50); + HPApi.isAlive = originalIsAlive; + void errorsBefore; // suppress unused-variable warning + }); - // Mock HPApi.getScanCaps + it("skips scan when waitScanRequest returns false (ScanPagesComplete event)", async () => { + nockLedmBootstrap(); nock("http://127.0.0.1:80") - .persist() - .get("/Scan/ScanCaps") - .reply( - 200, - ` - - 2550 - 3300 -`, + .get("/EventMgmt/EventTable") + .reply(200, XML.eventTableEmpty, { etag: "e0" }); + nock("http://127.0.0.1:80") + .get("/EventMgmt/EventTable") + .query({ timeout: 1200 }) + .reply(200, XML.scanEventWithCompUri(), { etag: "e1" }); + nock("http://127.0.0.1:80") + .get("/WalkupScanToComp/WalkupScanToCompEvent") + .reply(200, XML.walkupScanToCompEventPagesComplete); + + // After the WalkupScanToComp mock is consumed, the next EventTable poll + // will fail (no mock). Make isAlive return false so deviceUp = false, + // then waitDeviceUp throws to exit the loop immediately (no 50-iteration + // circuit-breaker needed). No /Scan/Jobs mock is registered, so any + // attempt to create a scan job would also fail hard with disableNetConnect. + const originalIsAlive = HPApi.isAlive; + HPApi.isAlive = async () => false; + + const originalWaitDeviceUp = HPApi.waitDeviceUp; + HPApi.waitDeviceUp = async () => { + throw new Error("device is down"); + }; + + try { + await listenCmd( + [{ label: "host", isDuplexSingleSide: false }], + makeScanConfig(tempDir), + 1, ); + expect.fail("Expected listenCmd to reject"); + } catch (e: unknown) { + expect((e as Error).message).to.equal("device is down"); + } + + HPApi.isAlive = originalIsAlive; + HPApi.waitDeviceUp = originalWaitDeviceUp; + + // No scan was performed — tempDir should be empty. + const files = fs.readdirSync(tempDir); + expect(files).to.have.lengthOf(0); + }); - // Mock getWalkupScanDestinations + it("performs a complete Simplex scan flow end-to-end and writes output file", async () => { + nockLedmBootstrap(); nock("http://127.0.0.1:80") - .persist() - .get("/WalkupScan/WalkupScanDestinations") - .reply( - 200, - ` - -`, - ); + .get("/EventMgmt/EventTable") + .reply(200, XML.eventTableEmpty, { etag: "t0" }); + nock("http://127.0.0.1:80") + .get("/EventMgmt/EventTable") + .query({ timeout: 1200 }) + .reply(200, XML.scanEventSimple(), { etag: "t1" }); + nock("http://127.0.0.1:80") + .get("/WalkupScan/Destinations/1") + .reply(200, XML.walkupDestination()); + + const jobScope = nockScanJob(); + + await listenCmd( + [{ label: "host", isDuplexSingleSide: false }], + makeScanConfig(tempDir), + 1, + ); - // Mock registerWalkupScanDestination + expect(jobScope.isDone()).to.be.true; + // The scan was saved to tempDir; at least one file should exist. + const files = fs.readdirSync(tempDir); + expect(files.length).to.be.greaterThan(0); + }); + + it("catches non-Error throws when device is alive (hits line 111)", async () => { + nockLedmBootstrap(); nock("http://127.0.0.1:80") - .persist() - .post("/WalkupScan/WalkupScanDestinations") - .reply(201, "", { - Location: "http://127.0.0.1/WalkupScan/Destinations/1", - }); + .get("/EventMgmt/EventTable") + .reply(200, XML.eventTableEmpty, { etag: "t0" }); + nock("http://127.0.0.1:80") + .get("/EventMgmt/EventTable") + .query({ timeout: 1200 }) + .reply(200, XML.scanEventWithCompUri(), { etag: "t1" }); - // Mock waitForScanEvent -> HPApi.getEvents to fail + const savedGetEvent = HPApi.getWalkupScanToCompEvent; + HPApi.getWalkupScanToCompEvent = async () => { + // eslint-disable-next-line @typescript-eslint/only-throw-error + throw "non-error-throw"; + }; + + await listenCmd( + [{ label: "host", isDuplexSingleSide: false }], + makeScanConfig(tempDir), + 1, + ); + + HPApi.getWalkupScanToCompEvent = savedGetEvent; + }); + + it("logs debug info when device goes down and debug is enabled", async () => { + nockLedmBootstrap(); nock("http://127.0.0.1:80") .persist() .get("/EventMgmt/EventTable") .reply(500); - // Mock isAlive to return true so errors increment and loop exits after 50 - HPApi.isAlive = async () => true; - // Mock HPApi.delay to return instantly - HPApi.delay = async () => { - /* no-op */ - }; - // Mock waitDeviceUp to return instantly - HPApi.waitDeviceUp = async () => { - /* no-op */ + const savedIsAlive = HPApi.isAlive; + const savedIsDebug = HPApi.isDebug; + let isAliveCalls = 0; + HPApi.isAlive = async () => { + isAliveCalls++; + return isAliveCalls > 1; }; + HPApi.isDebug = () => true; await listenCmd( [{ label: "host", isDuplexSingleSide: false }], - scanConfig, + makeScanConfig(tempDir), 1, ); - expect(true).to.be.true; + + HPApi.isAlive = savedIsAlive; + HPApi.isDebug = savedIsDebug; }); }); diff --git a/test/scanProcessing.test.ts b/test/scanProcessing.test.ts index bb0d4897..e88ad0c1 100644 --- a/test/scanProcessing.test.ts +++ b/test/scanProcessing.test.ts @@ -133,9 +133,18 @@ describe("scanProcessing", () => { }); it("singleScan aborts if scanner is BusyWithScanJob", async () => { - const deviceCapabilities = mockDeviceCapabilities( - ScannerState.BusyWithScanJob, - ); + let getScanStatusCalled = false; + const deviceCapabilities = { + getScanStatus: async () => { + getScanStatusCalled = true; + return { + scannerState: ScannerState.BusyWithScanJob, + adfState: AdfState.Empty, + getInputSource: () => InputSource.Platen, + isLoaded: () => false, + }; + }, + } as unknown as DeviceCapabilities; // If it doesn't abort, it would call other methods on deviceCapabilities and fail (since they are missing from mock) // or at least we check it returns without throwing further errors if we mock just enough. await singleScan( @@ -146,6 +155,7 @@ describe("scanProcessing", () => { deviceCapabilities, new Date(), ); + expect(getScanStatusCalled).to.be.true; }); }); });