Files
markdown-converter/tests/export-presets-dialog.test.js
T

674 lines
26 KiB
JavaScript

/**
* Tests for the Export Presets dialog UI (Task 21).
* Exercises the preset dropdown / save / delete flows of the export-options
* dialog against a jsdom replica of the dialog's markup, with the electron
* IPC surface mocked — following the jest.mock('electron') pattern in
* document-compare-dialog.test.js.
*/
jest.mock('electron', () => ({
ipcRenderer: {
invoke: jest.fn(),
send: jest.fn(),
on: jest.fn(),
once: jest.fn(),
removeAllListeners: jest.fn(),
},
}));
const { ipcRenderer } = require('electron');
const {
initExportPresets,
refreshExportPresets,
captureDialogOptions,
applyPresetToDialog,
} = require('../src/renderer/export-presets');
const flush = () => new Promise((resolve) => setTimeout(resolve, 20));
function metadataRow(key = '', value = '') {
const row = document.createElement('div');
row.className = 'metadata-field';
const keyInput = document.createElement('input');
keyInput.type = 'text';
keyInput.className = 'metadata-key';
keyInput.value = key;
const valueInput = document.createElement('input');
valueInput.type = 'text';
valueInput.className = 'metadata-value';
valueInput.value = value;
row.append(keyInput, valueInput);
return row;
}
/**
* Minimal replica of the #export-dialog markup in src/index.html — only the
* elements the presets module reads or writes.
*/
function buildDialogFixture(format = 'pdf') {
document.body.innerHTML = `
<div id="export-dialog" data-format="${format}">
<div class="export-section export-presets">
<div class="preset-controls">
<div class="preset-dropdown">
<button type="button" id="preset-dropdown-toggle">Custom Settings</button>
<div id="preset-dropdown-list" class="preset-dropdown-list hidden"></div>
</div>
<button id="save-preset-btn" type="button">Save as preset</button>
</div>
</div>
<input type="checkbox" id="advanced-export-toggle" />
<div id="advanced-export-options" class="advanced-options hidden"></div>
<div class="export-section pdf-only">
<select id="pdf-engine">
<option value="xelatex">XeLaTeX</option>
<option value="pdflatex">PDFLaTeX</option>
<option value="lualatex">LuaLaTeX</option>
</select>
<select id="pdf-geometry">
<option value="margin=1in">1in</option>
<option value="margin=2in">2in</option>
<option value="custom">Custom</option>
</select>
<input type="text" id="custom-geometry" style="display: none" />
</div>
<div class="export-section revealjs-only">
<select id="reveal-theme"><option value="black">black</option><option value="white">white</option></select>
<select id="reveal-transition"><option value="slide">slide</option><option value="fade">fade</option></select>
<select id="reveal-speed"><option value="default">default</option><option value="fast">fast</option></select>
<input type="checkbox" id="reveal-slide-number" />
<input type="checkbox" id="reveal-controls" />
<input type="checkbox" id="reveal-progress" />
<input type="checkbox" id="reveal-history" />
<input type="checkbox" id="reveal-center" />
</div>
<div class="export-section">
<select id="export-template">
<option value="default">Default</option>
<option value="custom">Custom</option>
</select>
<input type="file" id="template-file-input" style="display: none" />
<input type="text" id="custom-template-path" style="display: none" />
<div class="metadata-container"></div>
<input type="checkbox" id="export-toc" />
<input type="number" id="export-toc-depth" value="3" min="1" max="6" />
<input type="checkbox" id="export-number-sections" />
<input type="checkbox" id="export-citeproc" />
<input type="text" id="bibliography-file" />
<input type="text" id="csl-file" />
</div>
<div class="export-section">
<input type="checkbox" id="basic-toc" />
<input type="checkbox" id="basic-number-sections" />
<select id="page-size">
<option value="a4">A4</option>
<option value="letter">Letter</option>
<option value="custom">Custom</option>
</select>
<select id="page-orientation">
<option value="portrait">Portrait</option>
<option value="landscape">Landscape</option>
</select>
<div id="custom-page-size" style="display: none">
<input type="text" id="custom-width" />
<input type="text" id="custom-height" />
</div>
</div>
</div>`;
// Seed the four default metadata rows the dialog ships with.
const container = document.querySelector('.metadata-container');
['title', 'author', 'date', 'subject'].forEach((key) => {
container.appendChild(metadataRow(key, ''));
});
}
const advancedPdfPreset = {
id: 'preset-pdf1',
name: 'Book PDF',
format: 'pdf',
options: {
advancedMode: true,
template: '/home/user/templates/book.tex',
metadata: { title: 'My Book', author: 'Jane' },
toc: true,
tocDepth: 4,
numberSections: true,
citeproc: false,
pdfEngine: 'lualatex',
geometry: 'margin=2.5cm',
bibliography: '/refs.bib',
pageSize: 'custom',
pageOrientation: 'landscape',
customWidth: '210mm',
customHeight: '297mm',
},
};
const basicPreset = {
id: 'preset-basic1',
name: 'Quick HTML',
format: 'html',
options: {
advancedMode: false,
toc: true,
numberSections: false,
pageSize: 'letter',
pageOrientation: 'portrait',
},
};
function mockInvoke(presets) {
ipcRenderer.invoke.mockImplementation((channel) => {
if (channel === 'get-export-presets') return Promise.resolve(presets);
return Promise.resolve(presets);
});
}
function rows() {
return Array.from(document.querySelectorAll('#preset-dropdown-list .preset-row'));
}
describe('Export presets dialog', () => {
let notify;
beforeEach(() => {
buildDialogFixture('pdf');
ipcRenderer.invoke.mockReset();
notify = jest.fn();
initExportPresets({ notify });
});
describe('refreshExportPresets', () => {
it('loads presets via get-export-presets and renders one row per preset with a delete icon', async () => {
mockInvoke([advancedPdfPreset, basicPreset]);
await refreshExportPresets();
await flush();
expect(ipcRenderer.invoke).toHaveBeenCalledWith('get-export-presets');
expect(rows()).toHaveLength(2);
expect(rows()[0].textContent).toContain('Book PDF');
expect(rows()[0].querySelector('.preset-delete')).not.toBeNull();
expect(rows()[1].textContent).toContain('Quick HTML');
});
it('shows the format badge next to each preset name', async () => {
mockInvoke([advancedPdfPreset]);
await refreshExportPresets();
await flush();
expect(rows()[0].querySelector('.preset-format').textContent).toBe('pdf');
});
it('renders an empty notice when there are no presets', async () => {
mockInvoke([]);
await refreshExportPresets();
await flush();
expect(rows()).toHaveLength(0);
expect(document.getElementById('preset-dropdown-list').textContent).toContain(
'No saved presets'
);
});
it('survives a rejected get-export-presets call without crashing', async () => {
ipcRenderer.invoke.mockRejectedValue(new Error('boom'));
await refreshExportPresets();
await flush();
expect(rows()).toHaveLength(0);
});
});
describe('selecting a preset', () => {
it('pre-fills every dialog field from an advanced PDF preset', async () => {
mockInvoke([advancedPdfPreset]);
await refreshExportPresets();
await flush();
rows()[0].querySelector('.preset-row-select').click();
// Advanced mode
expect(document.getElementById('advanced-export-toggle').checked).toBe(true);
expect(document.getElementById('advanced-export-options').classList.contains('hidden')).toBe(
false
);
// Template: custom path
expect(document.getElementById('export-template').value).toBe('custom');
expect(document.getElementById('custom-template-path').value).toBe(
'/home/user/templates/book.tex'
);
expect(document.getElementById('custom-template-path').style.display).toBe('block');
// Metadata rows rebuilt from the preset
const keys = Array.from(document.querySelectorAll('.metadata-key')).map((i) => i.value);
const values = Array.from(document.querySelectorAll('.metadata-value')).map((i) => i.value);
expect(keys).toEqual(['title', 'author']);
expect(values).toEqual(['My Book', 'Jane']);
// Document options
expect(document.getElementById('export-toc').checked).toBe(true);
expect(document.getElementById('export-toc-depth').value).toBe('4');
expect(document.getElementById('export-number-sections').checked).toBe(true);
expect(document.getElementById('export-citeproc').checked).toBe(false);
// PDF options with a non-preset geometry -> custom
expect(document.getElementById('pdf-engine').value).toBe('lualatex');
expect(document.getElementById('pdf-geometry').value).toBe('custom');
expect(document.getElementById('custom-geometry').value).toBe('margin=2.5cm');
expect(document.getElementById('custom-geometry').style.display).toBe('block');
// Bibliography
expect(document.getElementById('bibliography-file').value).toBe('/refs.bib');
// Page setup
expect(document.getElementById('page-size').value).toBe('custom');
expect(document.getElementById('custom-page-size').style.display).toBe('block');
expect(document.getElementById('custom-width').value).toBe('210mm');
expect(document.getElementById('custom-height').value).toBe('297mm');
expect(document.getElementById('page-orientation').value).toBe('landscape');
});
it('pre-fills basic-mode checkboxes and leaves advanced options hidden', async () => {
mockInvoke([basicPreset]);
await refreshExportPresets();
await flush();
rows()[0].querySelector('.preset-row-select').click();
expect(document.getElementById('advanced-export-toggle').checked).toBe(false);
expect(document.getElementById('advanced-export-options').classList.contains('hidden')).toBe(
true
);
expect(document.getElementById('basic-toc').checked).toBe(true);
expect(document.getElementById('basic-number-sections').checked).toBe(false);
expect(document.getElementById('page-size').value).toBe('letter');
});
it('marks the selected row, updates the toggle label and closes the dropdown', async () => {
mockInvoke([advancedPdfPreset, basicPreset]);
await refreshExportPresets();
await flush();
document.getElementById('preset-dropdown-toggle').click();
expect(document.getElementById('preset-dropdown-list').classList.contains('hidden')).toBe(
false
);
rows()[1].querySelector('.preset-row-select').click();
expect(rows()[1].classList.contains('selected')).toBe(true);
expect(rows()[0].classList.contains('selected')).toBe(false);
expect(document.getElementById('preset-dropdown-toggle').textContent).toBe('Quick HTML');
expect(document.getElementById('preset-dropdown-list').classList.contains('hidden')).toBe(
true
);
});
it('resets stale field values when switching from a rich preset to a plain one', async () => {
mockInvoke([advancedPdfPreset, basicPreset]);
await refreshExportPresets();
await flush();
rows()[0].querySelector('.preset-row-select').click();
rows()[1].querySelector('.preset-row-select').click();
// The basic preset has no bibliography — the field must be cleared, not left over.
expect(document.getElementById('bibliography-file').value).toBe('');
expect(document.getElementById('advanced-export-toggle').checked).toBe(false);
});
});
describe('saving the current dialog state as a preset', () => {
it('prompts for a name and invokes save-export-preset with the captured options', async () => {
mockInvoke([]);
await refreshExportPresets();
await flush();
jest.spyOn(window, 'prompt').mockReturnValue('My Preset');
// Configure the dialog the way a user would before saving.
document.getElementById('advanced-export-toggle').checked = true;
document.getElementById('export-toc').checked = true;
document.getElementById('pdf-engine').value = 'pdflatex';
document.getElementById('page-size').value = 'letter';
document.getElementById('save-preset-btn').click();
await flush();
expect(ipcRenderer.invoke).toHaveBeenCalledWith(
'save-export-preset',
expect.objectContaining({
name: 'My Preset',
format: 'pdf',
})
);
const saved = ipcRenderer.invoke.mock.calls.find((c) => c[0] === 'save-export-preset')[1];
expect(saved.id).toMatch(/^preset-/);
expect(saved.options.advancedMode).toBe(true);
expect(saved.options.toc).toBe(true);
expect(saved.options.pdfEngine).toBe('pdflatex');
expect(saved.options.pageSize).toBe('letter');
expect(notify).toHaveBeenCalledWith('Preset "My Preset" saved.', 'success');
});
it('re-renders the dropdown from the list returned by save-export-preset', async () => {
// Mirror the main process: the saved preset echoes back with the id that was sent.
ipcRenderer.invoke.mockImplementation((channel, payload) => {
if (channel === 'get-export-presets') return Promise.resolve([]);
return Promise.resolve([
{ id: payload.id, name: payload.name, format: payload.format, options: payload.options },
]);
});
await refreshExportPresets();
await flush();
jest.spyOn(window, 'prompt').mockReturnValue('My Preset');
document.getElementById('save-preset-btn').click();
await flush();
expect(rows()).toHaveLength(1);
expect(document.getElementById('preset-dropdown-toggle').textContent).toBe('My Preset');
});
it('reuses the selected preset id so saving overwrites instead of duplicating', async () => {
mockInvoke([advancedPdfPreset]);
await refreshExportPresets();
await flush();
rows()[0].querySelector('.preset-row-select').click();
jest.spyOn(window, 'prompt').mockReturnValue('Book PDF v2');
document.getElementById('save-preset-btn').click();
await flush();
const saved = ipcRenderer.invoke.mock.calls.find((c) => c[0] === 'save-export-preset')[1];
expect(saved.id).toBe('preset-pdf1');
expect(saved.name).toBe('Book PDF v2');
});
it('does nothing when the user cancels the name prompt', async () => {
mockInvoke([]);
await refreshExportPresets();
await flush();
jest.spyOn(window, 'prompt').mockReturnValue(null);
document.getElementById('save-preset-btn').click();
await flush();
expect(ipcRenderer.invoke).toHaveBeenCalledTimes(1); // only the initial get
expect(notify).not.toHaveBeenCalled();
});
it('warns and skips the save when the name is empty', async () => {
mockInvoke([]);
await refreshExportPresets();
await flush();
jest.spyOn(window, 'prompt').mockReturnValue(' ');
document.getElementById('save-preset-btn').click();
await flush();
expect(ipcRenderer.invoke).toHaveBeenCalledTimes(1);
expect(notify).toHaveBeenCalledWith('Preset name cannot be empty.', 'warning');
});
it('warns without throwing when the save IPC call rejects', async () => {
ipcRenderer.invoke.mockImplementation((channel) =>
channel === 'get-export-presets' ? Promise.resolve([]) : Promise.reject(new Error('disk'))
);
await refreshExportPresets();
await flush();
jest.spyOn(window, 'prompt').mockReturnValue('My Preset');
document.getElementById('save-preset-btn').click();
await flush();
expect(notify).toHaveBeenCalledWith('Failed to save preset. Please try again.', 'warning');
});
});
describe('deleting a preset', () => {
it('asks for confirmation and invokes delete-export-preset with the row id', async () => {
mockInvoke([advancedPdfPreset, basicPreset]);
await refreshExportPresets();
await flush();
jest.spyOn(window, 'confirm').mockReturnValue(true);
ipcRenderer.invoke.mockImplementation((channel) =>
channel === 'delete-export-preset'
? Promise.resolve([basicPreset])
: Promise.resolve([advancedPdfPreset, basicPreset])
);
rows()[0].querySelector('.preset-delete').click();
await flush();
expect(ipcRenderer.invoke).toHaveBeenCalledWith('delete-export-preset', 'preset-pdf1');
expect(rows()).toHaveLength(1);
expect(rows()[0].textContent).toContain('Quick HTML');
});
it('clears the selection when the deleted preset was selected', async () => {
mockInvoke([advancedPdfPreset]);
await refreshExportPresets();
await flush();
rows()[0].querySelector('.preset-row-select').click();
jest.spyOn(window, 'confirm').mockReturnValue(true);
ipcRenderer.invoke.mockImplementation((channel) =>
channel === 'delete-export-preset'
? Promise.resolve([])
: Promise.resolve([advancedPdfPreset])
);
rows()[0].querySelector('.preset-delete').click();
await flush();
expect(document.getElementById('preset-dropdown-toggle').textContent).toBe('Custom Settings');
});
it('does not invoke delete when the user cancels the confirmation', async () => {
mockInvoke([advancedPdfPreset]);
await refreshExportPresets();
await flush();
jest.spyOn(window, 'confirm').mockReturnValue(false);
rows()[0].querySelector('.preset-delete').click();
await flush();
expect(ipcRenderer.invoke).toHaveBeenCalledTimes(1); // only the initial get
expect(rows()).toHaveLength(1);
});
});
describe('captureDialogOptions / applyPresetToDialog round-trip', () => {
it('restores exactly what was captured, including reveal.js fields', () => {
buildDialogFixture('revealjs');
initExportPresets({ notify });
document.getElementById('advanced-export-toggle').checked = true;
document.getElementById('reveal-theme').value = 'white';
document.getElementById('reveal-transition').value = 'fade';
document.getElementById('reveal-speed').value = 'fast';
document.getElementById('reveal-slide-number').checked = true;
document.getElementById('reveal-controls').checked = false;
document.getElementById('reveal-progress').checked = false;
document.getElementById('reveal-history').checked = false;
document.getElementById('reveal-center').checked = false;
document.getElementById('csl-file').value = '/styles.csl';
const captured = captureDialogOptions();
buildDialogFixture('revealjs');
initExportPresets({ notify });
applyPresetToDialog({ options: captured });
expect(document.getElementById('reveal-theme').value).toBe('white');
expect(document.getElementById('reveal-transition').value).toBe('fade');
expect(document.getElementById('reveal-speed').value).toBe('fast');
expect(document.getElementById('reveal-slide-number').checked).toBe(true);
expect(document.getElementById('reveal-controls').checked).toBe(false);
expect(document.getElementById('reveal-progress').checked).toBe(false);
expect(document.getElementById('reveal-history').checked).toBe(false);
expect(document.getElementById('reveal-center').checked).toBe(false);
expect(document.getElementById('csl-file').value).toBe('/styles.csl');
});
it('captures a preset geometry as-is and restores it back onto the select', () => {
document.getElementById('advanced-export-toggle').checked = true;
document.getElementById('pdf-geometry').value = 'margin=2in';
const captured = captureDialogOptions();
expect(captured.geometry).toBe('margin=2in');
buildDialogFixture('pdf');
initExportPresets({ notify });
applyPresetToDialog({ options: captured });
expect(document.getElementById('pdf-geometry').value).toBe('margin=2in');
expect(document.getElementById('custom-geometry').style.display).toBe('none');
});
it('captures basic mode without any advanced keys leaking in', () => {
const captured = captureDialogOptions();
expect(captured.advancedMode).toBe(false);
expect(captured.toc).toBe(false);
expect(captured.template).toBeUndefined();
expect(captured.pdfEngine).toBeUndefined();
});
});
describe('one-time import of legacy localStorage export profiles', () => {
const legacyBlob = JSON.stringify({
'Quick HTML': {
format: 'html',
advancedMode: false,
pageSize: 'letter',
pageOrientation: 'portrait',
basicToc: true,
basicNumberSections: false,
},
'Book PDF': {
format: 'pdf',
advancedMode: true,
pageSize: 'a4',
pageOrientation: 'landscape',
basicToc: false,
basicNumberSections: false,
template: 'custom',
toc: true,
tocDepth: '4',
numberSections: true,
citeproc: false,
pdfEngine: 'lualatex',
pdfGeometry: 'custom',
},
});
function saveCalls() {
return ipcRenderer.invoke.mock.calls.filter((call) => call[0] === 'save-export-preset');
}
function mockStoredValue(value) {
jest.spyOn(window.Storage.prototype, 'getItem').mockReturnValue(value);
return jest.spyOn(window.Storage.prototype, 'removeItem');
}
afterEach(() => {
jest.restoreAllMocks();
});
it('imports every legacy profile through save-export-preset with the mapped shape', async () => {
const removeItem = mockStoredValue(legacyBlob);
ipcRenderer.invoke.mockResolvedValue([]);
initExportPresets({ notify });
await flush();
await flush();
const calls = saveCalls();
expect(calls).toHaveLength(2);
const quickHtml = calls.find(([, preset]) => preset.name === 'Quick HTML')[1];
expect(quickHtml.id).toBe('preset-legacy-Quick HTML');
expect(quickHtml.format).toBe('html');
expect(quickHtml.options).toEqual({
advancedMode: false,
pageSize: 'letter',
pageOrientation: 'portrait',
toc: true, // basicToc -> toc (basic branch of collectExportOptions)
numberSections: false, // basicNumberSections -> numberSections
});
const bookPdf = calls.find(([, preset]) => preset.name === 'Book PDF')[1];
expect(bookPdf.id).toBe('preset-legacy-Book PDF');
expect(bookPdf.format).toBe('pdf');
expect(bookPdf.options).toEqual({
advancedMode: true,
pageSize: 'a4',
pageOrientation: 'landscape',
template: 'default', // legacy stored the raw select value; path was never persisted
metadata: {},
toc: true,
tocDepth: '4',
numberSections: true,
citeproc: false,
pdfEngine: 'lualatex',
geometry: 'margin=1in', // legacy 'custom' literal had no restorable text -> default
});
// Import must never run twice.
expect(removeItem).toHaveBeenCalledWith('exportProfiles');
// Presets are refreshed after a successful import so the dropdown is current.
expect(ipcRenderer.invoke).toHaveBeenCalledWith('get-export-presets');
});
it('keeps the legacy key when a save fails, so a retry upserts instead of duplicating', async () => {
const removeItem = mockStoredValue(legacyBlob);
ipcRenderer.invoke.mockRejectedValue(new Error('disk full'));
const errorSpy = jest.spyOn(console, 'error').mockImplementation(() => {});
initExportPresets({ notify });
await flush();
await flush();
expect(saveCalls()).toHaveLength(1);
expect(removeItem).not.toHaveBeenCalled();
expect(errorSpy).toHaveBeenCalled();
errorSpy.mockRestore();
});
it('skips gracefully on a malformed legacy blob without breaking dialog init', async () => {
mockStoredValue('{"Quick HTML": not valid json');
const errorSpy = jest.spyOn(console, 'error').mockImplementation(() => {});
jest.spyOn(window, 'prompt').mockReturnValue(null);
initExportPresets({ notify });
await flush();
await flush();
expect(saveCalls()).toHaveLength(0);
expect(jest.mocked(window.Storage.prototype.removeItem)).not.toHaveBeenCalled();
expect(errorSpy).toHaveBeenCalled();
// Dialog init unaffected: the save button still works.
document.getElementById('save-preset-btn').click();
await flush();
expect(ipcRenderer.invoke).toHaveBeenCalledTimes(0);
expect(notify).not.toHaveBeenCalled();
errorSpy.mockRestore();
});
it('skips a legacy blob that is valid JSON but not a profile map', async () => {
mockStoredValue(JSON.stringify(['not', 'a', 'map']));
initExportPresets({ notify });
await flush();
await flush();
expect(saveCalls()).toHaveLength(0);
expect(jest.mocked(window.Storage.prototype.removeItem)).not.toHaveBeenCalled();
expect(ipcRenderer.invoke).toHaveBeenCalledTimes(0);
});
it('is a no-op when the legacy key is absent', async () => {
mockStoredValue(null);
initExportPresets({ notify });
await flush();
await flush();
expect(ipcRenderer.invoke).toHaveBeenCalledTimes(0);
expect(jest.mocked(window.Storage.prototype.removeItem)).not.toHaveBeenCalled();
});
});
});