mirror of
https://github.com/amitwh/markdown-converter.git
synced 2026-08-23 23:10:17 +05:30
feat(pdf): add bulk PDF operations (watermark/compress/rotate/etc.) to batch converter
This commit is contained in:
@@ -0,0 +1,338 @@
|
||||
/**
|
||||
* @jest-environment node
|
||||
*
|
||||
* PDFBatchOperations.js tests for Task 22's batch PDF operations: the folder
|
||||
* loop that applies one PDFOperations.executeOperation() op to every .pdf in an
|
||||
* input folder (optionally recursive) and mirrors the folder structure into the
|
||||
* output folder. Mirrors the real-PDF fixture conventions of
|
||||
* tests/main/PDFOperations.test.js (pdf-lib-built fixtures in a tmp dir).
|
||||
*
|
||||
* The watermark test doubles as the automated stand-in for the brief's manual
|
||||
* verification step ("batch-watermark a folder of 2-3 test PDFs, confirm each
|
||||
* output file has the watermark applied") — GUI batch runs are not possible in
|
||||
* this sandbox, so the assertion extracts the text back out of each output and
|
||||
* checks the watermark string is present.
|
||||
*/
|
||||
const fs = require('fs');
|
||||
const os = require('os');
|
||||
const path = require('path');
|
||||
const sharp = require('sharp');
|
||||
const { PDFDocument, StandardFonts, rgb } = require('pdf-lib');
|
||||
const PDFOperations = require('../../src/main/PDFOperations');
|
||||
const {
|
||||
runPDFBatchOperation,
|
||||
PDF_BATCH_OUTPUT_SPEC,
|
||||
} = require('../../src/main/PDFBatchOperations');
|
||||
|
||||
// Builds a small text PDF fixture with `pageCount` pages at the given path.
|
||||
async function writePdfFixture(filePath, pageCount = 2, label = 'Batch Fixture') {
|
||||
const doc = await PDFDocument.create();
|
||||
const font = await doc.embedFont(StandardFonts.Helvetica);
|
||||
for (let i = 1; i <= pageCount; i++) {
|
||||
const page = doc.addPage([600, 800]);
|
||||
page.drawText(`${label} Page ${i}`, { x: 50, y: 700, size: 20, font, color: rgb(0, 0, 0) });
|
||||
}
|
||||
fs.writeFileSync(filePath, await doc.save());
|
||||
}
|
||||
|
||||
describe('PDFBatchOperations - runPDFBatchOperation', () => {
|
||||
let tmpDir, inputDir, outputDir;
|
||||
|
||||
beforeEach(() => {
|
||||
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'pdfbatch_'));
|
||||
inputDir = path.join(tmpDir, 'in');
|
||||
outputDir = path.join(tmpDir, 'out');
|
||||
fs.mkdirSync(inputDir);
|
||||
fs.mkdirSync(path.join(inputDir, 'sub'), { recursive: true });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
fs.rmSync(tmpDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
// Runs a batch and returns { progress, completion } recorded from the
|
||||
// injected callbacks (same wiring main.js performs for the IPC handler).
|
||||
async function runBatch(args) {
|
||||
const progress = [];
|
||||
let completion = null;
|
||||
await runPDFBatchOperation({
|
||||
inputFolder: inputDir,
|
||||
outputFolder: outputDir,
|
||||
includeSubfolders: true,
|
||||
onProgress: (p) => progress.push(p),
|
||||
onComplete: (c) => {
|
||||
completion = c;
|
||||
},
|
||||
...args,
|
||||
});
|
||||
return { progress, completion };
|
||||
}
|
||||
|
||||
describe('watermark across a folder (brief manual-verification stand-in)', () => {
|
||||
beforeEach(async () => {
|
||||
await writePdfFixture(path.join(inputDir, 'a.pdf'), 2, 'Alpha');
|
||||
await writePdfFixture(path.join(inputDir, 'b.pdf'), 3, 'Beta');
|
||||
await writePdfFixture(path.join(inputDir, 'sub', 'c.pdf'), 2, 'Gamma');
|
||||
fs.writeFileSync(path.join(inputDir, 'notes.txt'), 'not a pdf');
|
||||
});
|
||||
|
||||
it('watermarks every PDF including subfolders, mirroring the folder structure', async () => {
|
||||
// 'DRAFT' extracts back out cleanly via pdfjs; wider centered strings
|
||||
// (e.g. 'CONFIDENTIAL') hit a pdfjs-dist text-extraction quirk that
|
||||
// truncates the returned item even though the full text is drawn.
|
||||
const { completion } = await runBatch({
|
||||
operation: 'watermark',
|
||||
data: {
|
||||
text: 'DRAFT',
|
||||
fontSize: 48,
|
||||
opacity: 0.5,
|
||||
position: 'center',
|
||||
color: '#000000',
|
||||
pages: 'all',
|
||||
},
|
||||
});
|
||||
|
||||
expect(completion).toEqual({
|
||||
success: true,
|
||||
completed: 3,
|
||||
failed: 0,
|
||||
total: 3,
|
||||
outputFolder: outputDir,
|
||||
});
|
||||
|
||||
const outputs = [
|
||||
path.join(outputDir, 'a.pdf'),
|
||||
path.join(outputDir, 'b.pdf'),
|
||||
path.join(outputDir, 'sub', 'c.pdf'),
|
||||
];
|
||||
for (const outPath of outputs) {
|
||||
expect(fs.existsSync(outPath)).toBe(true);
|
||||
const saved = await PDFDocument.load(fs.readFileSync(outPath));
|
||||
expect(saved.getPageCount()).toBeGreaterThan(0);
|
||||
const extracted = await PDFOperations.pdfExtractText({ inputPath: outPath });
|
||||
expect(extracted.success).toBe(true);
|
||||
expect(extracted.text).toContain('DRAFT');
|
||||
}
|
||||
});
|
||||
|
||||
it('ignores non-PDF files', async () => {
|
||||
const { completion } = await runBatch({
|
||||
operation: 'compress',
|
||||
data: {},
|
||||
});
|
||||
expect(completion.total).toBe(3); // notes.txt excluded
|
||||
});
|
||||
|
||||
it('skips subfolder files when includeSubfolders is false', async () => {
|
||||
const { completion } = await runBatch({
|
||||
operation: 'compress',
|
||||
includeSubfolders: false,
|
||||
data: {},
|
||||
});
|
||||
expect(completion.total).toBe(2); // sub/c.pdf excluded
|
||||
});
|
||||
});
|
||||
|
||||
describe('per-op output mapping (PDF_BATCH_OUTPUT_SPEC)', () => {
|
||||
it('exposes exactly the batchable per-file operations', () => {
|
||||
expect(Object.keys(PDF_BATCH_OUTPUT_SPEC).sort()).toEqual(
|
||||
[
|
||||
'split',
|
||||
'compress',
|
||||
'rotate',
|
||||
'delete',
|
||||
'watermark',
|
||||
'extractText',
|
||||
'pageNumbers',
|
||||
'crop',
|
||||
'extractImages',
|
||||
].sort()
|
||||
);
|
||||
});
|
||||
|
||||
it('split writes part files into the mirrored output folder', async () => {
|
||||
await writePdfFixture(path.join(inputDir, 'a.pdf'), 4, 'Split Me');
|
||||
await writePdfFixture(path.join(inputDir, 'sub', 'b.pdf'), 2, 'Split Sub');
|
||||
|
||||
const { completion } = await runBatch({
|
||||
operation: 'split',
|
||||
data: { splitMode: 'interval', interval: 2 },
|
||||
});
|
||||
|
||||
expect(completion).toMatchObject({ success: true, completed: 2, failed: 0 });
|
||||
|
||||
const part1 = await PDFDocument.load(fs.readFileSync(path.join(outputDir, 'a_part_1.pdf')));
|
||||
expect(part1.getPageCount()).toBe(2);
|
||||
const subPart = await PDFDocument.load(
|
||||
fs.readFileSync(path.join(outputDir, 'sub', 'b_part_1.pdf'))
|
||||
);
|
||||
expect(subPart.getPageCount()).toBe(2);
|
||||
});
|
||||
|
||||
it('extractText writes one .txt per PDF', async () => {
|
||||
await writePdfFixture(path.join(inputDir, 'a.pdf'), 2, 'Text Alpha');
|
||||
|
||||
const { completion } = await runBatch({ operation: 'extractText', data: {} });
|
||||
|
||||
expect(completion).toMatchObject({ success: true, completed: 1 });
|
||||
const txt = fs.readFileSync(path.join(outputDir, 'a.txt'), 'utf8');
|
||||
expect(txt).toContain('Text Alpha Page 1');
|
||||
expect(txt).toContain('Text Alpha Page 2');
|
||||
});
|
||||
|
||||
it('extractImages writes images into a per-PDF output directory', async () => {
|
||||
const imgPath = path.join(tmpDir, 'red.png');
|
||||
await sharp({
|
||||
create: { width: 20, height: 20, channels: 3, background: { r: 255, g: 0, b: 0 } },
|
||||
})
|
||||
.png()
|
||||
.toFile(imgPath);
|
||||
|
||||
const doc = await PDFDocument.create();
|
||||
const page = doc.addPage([300, 300]);
|
||||
const png = await doc.embedPng(fs.readFileSync(imgPath));
|
||||
page.drawImage(png, { x: 50, y: 50, width: 100, height: 100 });
|
||||
fs.writeFileSync(path.join(inputDir, 'img.pdf'), await doc.save());
|
||||
|
||||
const { completion } = await runBatch({ operation: 'extractImages', data: {} });
|
||||
|
||||
expect(completion).toMatchObject({ success: true, completed: 1 });
|
||||
const expectedDir = path.join(outputDir, 'img');
|
||||
const files = fs.readdirSync(expectedDir);
|
||||
expect(files.length).toBeGreaterThanOrEqual(1);
|
||||
expect(files[0]).toMatch(/^img_page1_img1\.png$/);
|
||||
});
|
||||
|
||||
it.each([
|
||||
['compress', {}],
|
||||
['rotate', { angle: 90 }],
|
||||
['delete', { pages: '1' }],
|
||||
['pageNumbers', { position: 'bottom-center', startNumber: 1 }],
|
||||
['crop', { margins: { top: 10, bottom: 10, left: 10, right: 10 } }],
|
||||
])('applies %s to every file via executeOperation', async (operation, data) => {
|
||||
await writePdfFixture(path.join(inputDir, 'a.pdf'), 2, 'Op Check');
|
||||
await writePdfFixture(path.join(inputDir, 'sub', 'b.pdf'), 2, 'Op Check Sub');
|
||||
|
||||
const { completion } = await runBatch({ operation, data });
|
||||
|
||||
expect(completion).toMatchObject({ success: true, completed: 2, failed: 0 });
|
||||
expect(fs.existsSync(path.join(outputDir, 'a.pdf'))).toBe(true);
|
||||
expect(fs.existsSync(path.join(outputDir, 'sub', 'b.pdf'))).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('failure handling', () => {
|
||||
it('counts a corrupt PDF as failed and continues with the rest', async () => {
|
||||
await writePdfFixture(path.join(inputDir, 'good.pdf'), 1, 'Good');
|
||||
fs.writeFileSync(path.join(inputDir, 'corrupt.pdf'), 'this is not a pdf at all');
|
||||
|
||||
const { completion } = await runBatch({ operation: 'compress', data: {} });
|
||||
|
||||
expect(completion).toMatchObject({ success: true, completed: 1, failed: 1, total: 2 });
|
||||
expect(fs.existsSync(path.join(outputDir, 'good.pdf'))).toBe(true);
|
||||
});
|
||||
|
||||
it('counts files over maxFileSize as failed without processing them', async () => {
|
||||
await writePdfFixture(path.join(inputDir, 'big.pdf'), 1, 'Big');
|
||||
|
||||
const { completion } = await runBatch({
|
||||
operation: 'compress',
|
||||
data: {},
|
||||
maxFileSize: 10, // fixture is larger than 10 bytes
|
||||
});
|
||||
|
||||
expect(completion).toMatchObject({ success: true, completed: 0, failed: 1, total: 1 });
|
||||
expect(fs.existsSync(path.join(outputDir, 'big.pdf'))).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects an operation that is not batchable', async () => {
|
||||
const { completion } = await runBatch({ operation: 'merge', data: {} });
|
||||
expect(completion.success).toBe(false);
|
||||
expect(completion.error).toMatch(/not supported/i);
|
||||
});
|
||||
|
||||
it('rejects a missing input folder', async () => {
|
||||
const { completion } = await runBatch({
|
||||
operation: 'compress',
|
||||
inputFolder: path.join(tmpDir, 'does-not-exist'),
|
||||
data: {},
|
||||
});
|
||||
expect(completion).toEqual({ success: false, error: 'Input folder does not exist.' });
|
||||
});
|
||||
|
||||
it('rejects when no PDFs are found', async () => {
|
||||
const { completion } = await runBatch({ operation: 'compress', data: {} });
|
||||
expect(completion).toEqual({
|
||||
success: false,
|
||||
error: 'No matching files found in the selected folder.',
|
||||
});
|
||||
});
|
||||
|
||||
it('creates the output folder when it does not exist', async () => {
|
||||
await writePdfFixture(path.join(inputDir, 'a.pdf'), 1, 'Mkdir');
|
||||
const nestedOutput = path.join(tmpDir, 'deeply', 'nested', 'out');
|
||||
|
||||
const { completion } = await runBatch({
|
||||
operation: 'compress',
|
||||
outputFolder: nestedOutput,
|
||||
data: {},
|
||||
});
|
||||
|
||||
expect(completion).toMatchObject({ success: true, completed: 1 });
|
||||
expect(fs.existsSync(path.join(nestedOutput, 'a.pdf'))).toBe(true);
|
||||
});
|
||||
|
||||
it('sanitizes output-folder creation errors through the injected sanitizer', async () => {
|
||||
// A regular file in the middle of the output path makes recursive mkdir
|
||||
// fail with ENOTDIR.
|
||||
const blocker = path.join(tmpDir, 'blocker');
|
||||
fs.writeFileSync(blocker, 'not a directory');
|
||||
await writePdfFixture(path.join(inputDir, 'a.pdf'), 1, 'Mkdir Fail');
|
||||
|
||||
const { completion } = await runBatch({
|
||||
operation: 'compress',
|
||||
outputFolder: path.join(blocker, 'child'),
|
||||
data: {},
|
||||
sanitizeError: (message) => message.replace(new RegExp(path.sep, 'g'), '_SANITIZED_'),
|
||||
});
|
||||
|
||||
expect(completion.success).toBe(false);
|
||||
expect(completion.error).toContain('Failed to create output folder');
|
||||
expect(completion.error).toContain('_SANITIZED_');
|
||||
});
|
||||
});
|
||||
|
||||
describe('progress reporting', () => {
|
||||
it('reports one event per file plus a final event, following the batch-progress shape', async () => {
|
||||
await writePdfFixture(path.join(inputDir, 'a.pdf'), 1, 'A');
|
||||
await writePdfFixture(path.join(inputDir, 'b.pdf'), 1, 'B');
|
||||
|
||||
const { progress } = await runBatch({ operation: 'compress', data: {} });
|
||||
|
||||
expect(progress).toHaveLength(3);
|
||||
expect(progress[0]).toEqual({
|
||||
completed: 0,
|
||||
failed: 0,
|
||||
total: 2,
|
||||
currentFile: expect.stringMatching(/^[ab]\.pdf$/),
|
||||
});
|
||||
expect(progress[1]).toMatchObject({ completed: 1, failed: 0, total: 2 });
|
||||
expect(progress[2]).toEqual({ completed: 2, failed: 0, total: 2, currentFile: null });
|
||||
});
|
||||
|
||||
it('carries the running failed count in progress events', async () => {
|
||||
await writePdfFixture(path.join(inputDir, 'good.pdf'), 1, 'Good');
|
||||
fs.writeFileSync(path.join(inputDir, 'corrupt.pdf'), 'not a pdf');
|
||||
|
||||
const { progress } = await runBatch({ operation: 'compress', data: {} });
|
||||
|
||||
// Final event reflects the failure; earlier events carry the running count.
|
||||
expect(progress[progress.length - 1]).toEqual({
|
||||
completed: 1,
|
||||
failed: 1,
|
||||
total: 2,
|
||||
currentFile: null,
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,491 @@
|
||||
/**
|
||||
* Tests for the PDF batch operations dialog (Task 22). Exercises the real
|
||||
* dialog DOM in jsdom with the electron IPC surface mocked, following the
|
||||
* jest.mock('electron') pattern in document-compare-dialog.test.js.
|
||||
*
|
||||
* These jsdom tests plus tests/main/PDFBatchOperations.test.js substitute for
|
||||
* the brief's manual GUI verification step (batch-watermarking a folder of
|
||||
* PDFs), which is not possible in this sandbox.
|
||||
*/
|
||||
jest.mock('electron', () => ({
|
||||
ipcRenderer: {
|
||||
invoke: jest.fn(),
|
||||
send: jest.fn(),
|
||||
on: jest.fn(),
|
||||
once: jest.fn(),
|
||||
removeAllListeners: jest.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
require('../src/utils/ModalManager'); // sets window.ModalManager for the dialog
|
||||
const { ipcRenderer } = require('electron');
|
||||
const { showPdfBatchDialog } = require('../src/renderer/pdf-batch-dialog');
|
||||
|
||||
// Captures the listeners the dialog registers (folder-selected, batch-progress,
|
||||
// pdf-batch-complete) so tests can fire them like the main process would.
|
||||
const listeners = {};
|
||||
ipcRenderer.on.mockImplementation((channel, callback) => {
|
||||
listeners[channel] = callback;
|
||||
return () => delete listeners[channel];
|
||||
});
|
||||
|
||||
const EXPECTED_OPERATIONS = [
|
||||
'watermark',
|
||||
'split',
|
||||
'compress',
|
||||
'rotate',
|
||||
'delete',
|
||||
'extractText',
|
||||
'pageNumbers',
|
||||
'crop',
|
||||
'extractImages',
|
||||
];
|
||||
|
||||
function openDialog(onConvertFormat = jest.fn()) {
|
||||
showPdfBatchDialog({ onConvertFormat });
|
||||
return onConvertFormat;
|
||||
}
|
||||
|
||||
function selectBatchType(type) {
|
||||
const select = document.getElementById('pdf-batch-type');
|
||||
select.value = type;
|
||||
select.dispatchEvent(new Event('change'));
|
||||
}
|
||||
|
||||
function selectOperation(op) {
|
||||
const select = document.getElementById('pdf-batch-operation');
|
||||
select.value = op;
|
||||
select.dispatchEvent(new Event('change'));
|
||||
}
|
||||
|
||||
function setField(name, value) {
|
||||
document.getElementById(`pdf-batch-field-${name}`).value = value;
|
||||
}
|
||||
|
||||
// Conditional fields are hidden by toggling their wrapper section.
|
||||
function fieldWrapperHidden(name) {
|
||||
return document.getElementById(`pdf-batch-field-${name}-wrapper`).classList.contains('hidden');
|
||||
}
|
||||
|
||||
function setFolders(input = '/batch/in', output = '/batch/out') {
|
||||
setField('inputFolder', input);
|
||||
setField('outputFolder', output);
|
||||
}
|
||||
|
||||
function clickProcess() {
|
||||
document.getElementById('pdf-batch-process').click();
|
||||
}
|
||||
|
||||
function statusText() {
|
||||
return document.getElementById('pdf-batch-status').textContent;
|
||||
}
|
||||
|
||||
function lastSentPayload() {
|
||||
const calls = ipcRenderer.send.mock.calls.filter((c) => c[0] === 'batch-pdf-operation');
|
||||
return calls.length ? calls[calls.length - 1][1] : null;
|
||||
}
|
||||
|
||||
describe('PDF batch operations dialog', () => {
|
||||
beforeEach(() => {
|
||||
ipcRenderer.send.mockReset();
|
||||
ipcRenderer.invoke.mockReset();
|
||||
});
|
||||
|
||||
describe('batch type selector', () => {
|
||||
it('defaults to Convert Format and hides the bulk-operation controls', () => {
|
||||
const onConvertFormat = openDialog();
|
||||
|
||||
expect(document.getElementById('pdf-batch-type').value).toBe('convert');
|
||||
expect(
|
||||
document.getElementById('pdf-batch-operation-panel').classList.contains('hidden')
|
||||
).toBe(true);
|
||||
expect(document.getElementById('pdf-batch-process').textContent).toContain('Batch Converter');
|
||||
expect(document.getElementById('pdf-batch-operation').children.length).toBe(
|
||||
EXPECTED_OPERATIONS.length
|
||||
);
|
||||
expect(onConvertFormat).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('delegates Convert Format to the existing batch converter without sending an operation', () => {
|
||||
const onConvertFormat = openDialog();
|
||||
|
||||
clickProcess();
|
||||
|
||||
expect(onConvertFormat).toHaveBeenCalledTimes(1);
|
||||
expect(ipcRenderer.send).not.toHaveBeenCalledWith('batch-pdf-operation', expect.anything());
|
||||
});
|
||||
|
||||
it('shows the bulk-operation controls when Bulk PDF Operation is selected', () => {
|
||||
openDialog();
|
||||
selectBatchType('operation');
|
||||
|
||||
const opSelect = document.getElementById('pdf-batch-operation');
|
||||
expect(
|
||||
document.getElementById('pdf-batch-operation-panel').classList.contains('hidden')
|
||||
).toBe(false);
|
||||
expect(Array.from(opSelect.options).map((o) => o.value)).toEqual(EXPECTED_OPERATIONS);
|
||||
expect(document.getElementById('pdf-batch-process').textContent).toBe('Process');
|
||||
});
|
||||
});
|
||||
|
||||
describe('bulk operation validation', () => {
|
||||
it('warns when the input folder is missing and sends nothing', () => {
|
||||
openDialog();
|
||||
selectBatchType('operation');
|
||||
setField('outputFolder', '/batch/out');
|
||||
|
||||
clickProcess();
|
||||
|
||||
expect(statusText()).toBe('Select an input folder.');
|
||||
expect(lastSentPayload()).toBeNull();
|
||||
});
|
||||
|
||||
it('warns when the output folder is missing and sends nothing', () => {
|
||||
openDialog();
|
||||
selectBatchType('operation');
|
||||
setField('inputFolder', '/batch/in');
|
||||
|
||||
clickProcess();
|
||||
|
||||
expect(statusText()).toBe('Select an output folder.');
|
||||
expect(lastSentPayload()).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('watermark operation', () => {
|
||||
it('sends the batch-pdf-operation payload with the single-file dialog option shapes', () => {
|
||||
openDialog();
|
||||
selectBatchType('operation');
|
||||
selectOperation('watermark');
|
||||
setFolders();
|
||||
setField('text', 'DRAFT');
|
||||
|
||||
clickProcess();
|
||||
|
||||
expect(lastSentPayload()).toEqual({
|
||||
operation: 'watermark',
|
||||
inputFolder: '/batch/in',
|
||||
outputFolder: '/batch/out',
|
||||
includeSubfolders: true,
|
||||
data: {
|
||||
text: 'DRAFT',
|
||||
fontSize: 48,
|
||||
opacity: 0.3,
|
||||
position: 'center',
|
||||
color: '#000000',
|
||||
pages: 'all',
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('warns when the watermark text is empty', () => {
|
||||
openDialog();
|
||||
selectBatchType('operation');
|
||||
selectOperation('watermark');
|
||||
setFolders();
|
||||
|
||||
clickProcess();
|
||||
|
||||
expect(statusText()).toBe('Enter watermark text.');
|
||||
expect(lastSentPayload()).toBeNull();
|
||||
});
|
||||
|
||||
it('warns when the font size is cleared', () => {
|
||||
openDialog();
|
||||
selectBatchType('operation');
|
||||
selectOperation('watermark');
|
||||
setFolders();
|
||||
setField('text', 'DRAFT');
|
||||
setField('fontSize', '');
|
||||
|
||||
clickProcess();
|
||||
|
||||
expect(statusText()).toBe('Enter a font size.');
|
||||
expect(lastSentPayload()).toBeNull();
|
||||
});
|
||||
|
||||
it('shows the custom-pages field only for custom pages and sends customPages', () => {
|
||||
openDialog();
|
||||
selectBatchType('operation');
|
||||
selectOperation('watermark');
|
||||
setFolders();
|
||||
setField('text', 'DRAFT');
|
||||
|
||||
expect(fieldWrapperHidden('customPages')).toBe(true);
|
||||
|
||||
setField('pages', 'custom');
|
||||
document.getElementById('pdf-batch-field-pages').dispatchEvent(new Event('change'));
|
||||
expect(fieldWrapperHidden('customPages')).toBe(false);
|
||||
|
||||
setField('customPages', '1-2');
|
||||
clickProcess();
|
||||
|
||||
expect(lastSentPayload().data).toMatchObject({ pages: 'custom', customPages: '1-2' });
|
||||
});
|
||||
|
||||
it('requires custom pages when Pages is set to custom', () => {
|
||||
openDialog();
|
||||
selectBatchType('operation');
|
||||
selectOperation('watermark');
|
||||
setFolders();
|
||||
setField('text', 'DRAFT');
|
||||
setField('pages', 'custom');
|
||||
document.getElementById('pdf-batch-field-pages').dispatchEvent(new Event('change'));
|
||||
|
||||
clickProcess();
|
||||
|
||||
expect(statusText()).toBe('Enter the custom pages to watermark.');
|
||||
expect(lastSentPayload()).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('split operation', () => {
|
||||
it('shows page-range or interval fields per split mode and validates them', () => {
|
||||
openDialog();
|
||||
selectBatchType('operation');
|
||||
selectOperation('split');
|
||||
setFolders();
|
||||
|
||||
// Default mode "pages": ranges required, interval hidden.
|
||||
expect(fieldWrapperHidden('interval')).toBe(true);
|
||||
clickProcess();
|
||||
expect(statusText()).toBe('Enter page ranges (e.g. 1-5, 6-10).');
|
||||
expect(lastSentPayload()).toBeNull();
|
||||
|
||||
// Interval mode: interval required, ranges hidden.
|
||||
setField('splitMode', 'interval');
|
||||
document.getElementById('pdf-batch-field-splitMode').dispatchEvent(new Event('change'));
|
||||
expect(fieldWrapperHidden('pageRanges')).toBe(true);
|
||||
setField('interval', '');
|
||||
clickProcess();
|
||||
expect(statusText()).toBe('Enter the number of pages per split file.');
|
||||
expect(lastSentPayload()).toBeNull();
|
||||
|
||||
// Valid interval payload.
|
||||
setField('interval', '2');
|
||||
clickProcess();
|
||||
expect(lastSentPayload().data).toEqual({ splitMode: 'interval', interval: 2 });
|
||||
});
|
||||
|
||||
it('sends pageRanges in pages mode', () => {
|
||||
openDialog();
|
||||
selectBatchType('operation');
|
||||
selectOperation('split');
|
||||
setFolders();
|
||||
setField('pageRanges', '1-2, 3');
|
||||
|
||||
clickProcess();
|
||||
|
||||
expect(lastSentPayload().data).toEqual({ splitMode: 'pages', pageRanges: '1-2, 3' });
|
||||
});
|
||||
|
||||
it('sends only the mode for size splits', () => {
|
||||
openDialog();
|
||||
selectBatchType('operation');
|
||||
selectOperation('split');
|
||||
setFolders();
|
||||
setField('splitMode', 'size');
|
||||
document.getElementById('pdf-batch-field-splitMode').dispatchEvent(new Event('change'));
|
||||
|
||||
clickProcess();
|
||||
|
||||
expect(lastSentPayload().data).toEqual({ splitMode: 'size' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('other operations', () => {
|
||||
it('sends delete pages', () => {
|
||||
openDialog();
|
||||
selectBatchType('operation');
|
||||
selectOperation('delete');
|
||||
setFolders();
|
||||
setField('pages', '2');
|
||||
|
||||
clickProcess();
|
||||
|
||||
expect(lastSentPayload().data).toEqual({ pages: '2' });
|
||||
});
|
||||
|
||||
it('warns when delete pages is empty', () => {
|
||||
openDialog();
|
||||
selectBatchType('operation');
|
||||
selectOperation('delete');
|
||||
setFolders();
|
||||
|
||||
clickProcess();
|
||||
|
||||
expect(statusText()).toBe('Enter the pages to delete (e.g. 1-3, 5).');
|
||||
expect(lastSentPayload()).toBeNull();
|
||||
});
|
||||
|
||||
it('sends rotate with angle and optional pages', () => {
|
||||
openDialog();
|
||||
selectBatchType('operation');
|
||||
selectOperation('rotate');
|
||||
setFolders();
|
||||
setField('pages', '1');
|
||||
|
||||
clickProcess();
|
||||
|
||||
expect(lastSentPayload().data).toEqual({ angle: 90, pages: '1' });
|
||||
});
|
||||
|
||||
it('sends page numbers options', () => {
|
||||
openDialog();
|
||||
selectBatchType('operation');
|
||||
selectOperation('pageNumbers');
|
||||
setFolders();
|
||||
|
||||
clickProcess();
|
||||
|
||||
expect(lastSentPayload().data).toEqual({ position: 'bottom-center', startNumber: 1 });
|
||||
});
|
||||
|
||||
it('sends crop margins', () => {
|
||||
openDialog();
|
||||
selectBatchType('operation');
|
||||
selectOperation('crop');
|
||||
setFolders();
|
||||
setField('margins.top', '10');
|
||||
setField('margins.left', '5');
|
||||
|
||||
clickProcess();
|
||||
|
||||
expect(lastSentPayload().data).toEqual({
|
||||
margins: { top: 10, bottom: 0, left: 5, right: 0 },
|
||||
});
|
||||
});
|
||||
|
||||
it('sends an empty data object for parameterless operations', () => {
|
||||
openDialog();
|
||||
selectBatchType('operation');
|
||||
selectOperation('compress');
|
||||
setFolders();
|
||||
|
||||
clickProcess();
|
||||
|
||||
expect(lastSentPayload()).toEqual({
|
||||
operation: 'compress',
|
||||
inputFolder: '/batch/in',
|
||||
outputFolder: '/batch/out',
|
||||
includeSubfolders: true,
|
||||
data: {},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('progress and completion events', () => {
|
||||
it('sends only one operation while a run is in flight', () => {
|
||||
openDialog();
|
||||
selectBatchType('operation');
|
||||
selectOperation('compress');
|
||||
setFolders();
|
||||
|
||||
clickProcess();
|
||||
clickProcess(); // second click while the first run is still active
|
||||
|
||||
const calls = ipcRenderer.send.mock.calls.filter((c) => c[0] === 'batch-pdf-operation');
|
||||
expect(calls).toHaveLength(1);
|
||||
|
||||
// After completion, a new run can be started.
|
||||
listeners['pdf-batch-complete'](
|
||||
{},
|
||||
{ success: true, completed: 1, failed: 0, total: 1, outputFolder: '/batch/out' }
|
||||
);
|
||||
clickProcess();
|
||||
expect(
|
||||
ipcRenderer.send.mock.calls.filter((c) => c[0] === 'batch-pdf-operation')
|
||||
).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('updates the progress bar from batch-progress events', () => {
|
||||
openDialog();
|
||||
selectBatchType('operation');
|
||||
selectOperation('compress');
|
||||
setFolders();
|
||||
clickProcess();
|
||||
|
||||
listeners['batch-progress']({}, { completed: 1, failed: 0, total: 3, currentFile: 'a.pdf' });
|
||||
|
||||
const fill = document.getElementById('pdf-batch-progress-fill');
|
||||
expect(fill.style.width).toBe('33%');
|
||||
expect(document.getElementById('pdf-batch-progress-text').textContent).toContain('a.pdf');
|
||||
expect(document.getElementById('pdf-batch-process').disabled).toBe(true);
|
||||
});
|
||||
|
||||
it('re-enables Process and reports success on batch completion', () => {
|
||||
openDialog();
|
||||
selectBatchType('operation');
|
||||
selectOperation('compress');
|
||||
setFolders();
|
||||
clickProcess();
|
||||
|
||||
listeners['pdf-batch-complete'](
|
||||
{},
|
||||
{ success: true, completed: 3, failed: 0, total: 3, outputFolder: '/batch/out' }
|
||||
);
|
||||
|
||||
expect(statusText()).toContain('Batch complete: 3/3 file(s) processed');
|
||||
expect(document.getElementById('pdf-batch-process').disabled).toBe(false);
|
||||
expect(document.getElementById('pdf-batch-progress').classList.contains('hidden')).toBe(true);
|
||||
});
|
||||
|
||||
it('reports failures in the completion status', () => {
|
||||
openDialog();
|
||||
selectBatchType('operation');
|
||||
selectOperation('compress');
|
||||
setFolders();
|
||||
clickProcess();
|
||||
|
||||
listeners['pdf-batch-complete'](
|
||||
{},
|
||||
{ success: true, completed: 2, failed: 1, total: 3, outputFolder: '/batch/out' }
|
||||
);
|
||||
|
||||
expect(statusText()).toContain('2/3 file(s) processed');
|
||||
expect(statusText()).toContain('1 failed');
|
||||
});
|
||||
|
||||
it('surfaces early errors (e.g. no matching files) as a warning', () => {
|
||||
openDialog();
|
||||
selectBatchType('operation');
|
||||
selectOperation('compress');
|
||||
setFolders();
|
||||
clickProcess();
|
||||
|
||||
listeners['pdf-batch-complete'](
|
||||
{},
|
||||
{ success: false, error: 'No matching files found in the selected folder.' }
|
||||
);
|
||||
|
||||
expect(statusText()).toBe('Error: No matching files found in the selected folder.');
|
||||
expect(document.getElementById('pdf-batch-process').disabled).toBe(false);
|
||||
});
|
||||
|
||||
it('ignores progress events while the dialog has no run in flight', () => {
|
||||
openDialog();
|
||||
selectBatchType('operation');
|
||||
selectOperation('compress');
|
||||
|
||||
listeners['batch-progress']({}, { completed: 1, failed: 0, total: 3, currentFile: 'a.pdf' });
|
||||
|
||||
// Still the reset value — the event was ignored because no run is active.
|
||||
expect(document.getElementById('pdf-batch-progress-fill').style.width).toBe('0%');
|
||||
});
|
||||
});
|
||||
|
||||
describe('folder picker replies', () => {
|
||||
it('routes folder-selected events for its own pick types only', () => {
|
||||
openDialog();
|
||||
selectBatchType('operation');
|
||||
selectOperation('watermark');
|
||||
|
||||
listeners['folder-selected']({}, { type: 'pdf-batch-input-dir', path: '/picked/in' });
|
||||
listeners['folder-selected']({}, { type: 'pdf-batch-output-dir', path: '/picked/out' });
|
||||
listeners['folder-selected']({}, { type: 'unrelated-type', path: '/elsewhere' });
|
||||
|
||||
expect(document.getElementById('pdf-batch-field-inputFolder').value).toBe('/picked/in');
|
||||
expect(document.getElementById('pdf-batch-field-outputFolder').value).toBe('/picked/out');
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user