diff --git a/src/index.html b/src/index.html index 63c8979..065fb81 100644 --- a/src/index.html +++ b/src/index.html @@ -352,22 +352,28 @@ `; + // 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(); + }); + }); +}); diff --git a/tests/main/ExportPresets.test.js b/tests/main/ExportPresets.test.js new file mode 100644 index 0000000..c1c1e99 --- /dev/null +++ b/tests/main/ExportPresets.test.js @@ -0,0 +1,196 @@ +/** + * Tests for the export-preset persistence module (Task 21). + * The module is pure list logic over the settings store's `exportPresets` + * array; the store is injected, mirroring the SettingsStore test pattern. + */ + +const ExportPresets = require('../../src/main/ExportPresets'); + +function createStore(initial = {}) { + const data = { ...initial }; + return { + get: (key, defaultValue) => (data[key] === undefined ? defaultValue : data[key]), + set: (key, value) => { + data[key] = value; + }, + data, + }; +} + +describe('ExportPresets', () => { + describe('loadPresets', () => { + test('returns an empty array when nothing is stored', () => { + expect(ExportPresets.loadPresets(createStore())).toEqual([]); + }); + + test('returns the stored presets', () => { + const store = createStore({ + exportPresets: [{ id: 'p1', name: 'Book PDF', format: 'pdf', options: { toc: true } }], + }); + const presets = ExportPresets.loadPresets(store); + expect(presets).toHaveLength(1); + expect(presets[0].name).toBe('Book PDF'); + }); + + test('returns an empty array when the stored value is corrupt (not an array)', () => { + expect(ExportPresets.loadPresets(createStore({ exportPresets: 'garbage' }))).toEqual([]); + expect(ExportPresets.loadPresets(createStore({ exportPresets: { p1: 1 } }))).toEqual([]); + }); + + test('drops malformed entries (missing id or name) from the stored array', () => { + const store = createStore({ + exportPresets: [ + { id: 'p1', name: 'Good', format: 'pdf', options: {} }, + { name: 'No id', options: {} }, + { id: 'p3', options: {} }, + null, + ], + }); + const presets = ExportPresets.loadPresets(store); + expect(presets).toHaveLength(1); + expect(presets[0].id).toBe('p1'); + }); + }); + + describe('savePreset', () => { + test('adds a new preset with a generated id and persists it', () => { + const store = createStore(); + const presets = ExportPresets.savePreset(store, { + name: 'Book PDF', + format: 'pdf', + options: { toc: true, tocDepth: 3 }, + }); + + expect(presets).toHaveLength(1); + expect(presets[0].id).toMatch(/^preset-/); + expect(presets[0].name).toBe('Book PDF'); + expect(presets[0].format).toBe('pdf'); + expect(presets[0].options).toEqual({ toc: true, tocDepth: 3 }); + expect(store.data.exportPresets).toEqual(presets); + }); + + test('upserts by id — saving with an existing id replaces the entry', () => { + const store = createStore({ + exportPresets: [{ id: 'p1', name: 'Old name', format: 'pdf', options: { toc: false } }], + }); + const presets = ExportPresets.savePreset(store, { + id: 'p1', + name: 'New name', + format: 'docx', + options: { toc: true }, + }); + + expect(presets).toHaveLength(1); + expect(presets[0]).toEqual({ + id: 'p1', + name: 'New name', + format: 'docx', + options: { toc: true }, + }); + expect(store.data.exportPresets).toEqual(presets); + }); + + test('appends when the id is new, preserving existing presets', () => { + const store = createStore({ + exportPresets: [{ id: 'p1', name: 'First', format: 'pdf', options: {} }], + }); + const presets = ExportPresets.savePreset(store, { + name: 'Second', + format: 'html', + options: {}, + }); + + expect(presets).toHaveLength(2); + expect(presets.map((p) => p.name)).toEqual(['First', 'Second']); + }); + + test('throws when the preset is not an object', () => { + const store = createStore(); + expect(() => ExportPresets.savePreset(store, null)).toThrow('Preset must be an object'); + expect(() => ExportPresets.savePreset(store, 'nope')).toThrow('Preset must be an object'); + }); + + test('throws when the name is missing or empty after trimming', () => { + const store = createStore(); + expect(() => ExportPresets.savePreset(store, { name: ' ', options: {} })).toThrow( + 'Preset name is required' + ); + expect(() => ExportPresets.savePreset(store, { options: {} })).toThrow( + 'Preset name is required' + ); + }); + + test('trims the name and caps it at 100 characters', () => { + const store = createStore(); + const presets = ExportPresets.savePreset(store, { name: ' Spaced ', options: {} }); + expect(presets[0].name).toBe('Spaced'); + + const long = ExportPresets.savePreset(store, { name: 'x'.repeat(150), options: {} }); + expect(long[1].name).toHaveLength(100); + }); + + test('defaults a missing options object to {} and a missing format to null', () => { + const store = createStore(); + const presets = ExportPresets.savePreset(store, { name: 'Bare' }); + expect(presets[0].options).toEqual({}); + expect(presets[0].format).toBeNull(); + }); + + test('refuses to add beyond the preset cap', () => { + const full = Array.from({ length: ExportPresets.MAX_PRESETS }, (_, i) => ({ + id: `p${i}`, + name: `Preset ${i}`, + format: 'pdf', + options: {}, + })); + const store = createStore({ exportPresets: full }); + expect(() => ExportPresets.savePreset(store, { name: 'One too many' })).toThrow( + /more than \d+ export presets/ + ); + }); + + test('still allows updating an existing preset when the list is full', () => { + const full = Array.from({ length: ExportPresets.MAX_PRESETS }, (_, i) => ({ + id: `p${i}`, + name: `Preset ${i}`, + format: 'pdf', + options: {}, + })); + const store = createStore({ exportPresets: full }); + const presets = ExportPresets.savePreset(store, { id: 'p7', name: 'Updated' }); + expect(presets).toHaveLength(ExportPresets.MAX_PRESETS); + expect(presets.find((p) => p.id === 'p7').name).toBe('Updated'); + }); + + test('generates distinct ids for successive new presets', () => { + const store = createStore(); + const a = ExportPresets.savePreset(store, { name: 'A' }); + const b = ExportPresets.savePreset(store, { name: 'B' }); + expect(a[0].id).not.toBe(b[1].id); + }); + }); + + describe('deletePreset', () => { + test('removes the preset with the given id and returns the updated list', () => { + const store = createStore({ + exportPresets: [ + { id: 'p1', name: 'Keep', format: 'pdf', options: {} }, + { id: 'p2', name: 'Drop', format: 'html', options: {} }, + ], + }); + const presets = ExportPresets.deletePreset(store, 'p2'); + expect(presets).toHaveLength(1); + expect(presets[0].id).toBe('p1'); + expect(store.data.exportPresets).toEqual(presets); + }); + + test('is idempotent when the id does not exist', () => { + const store = createStore({ + exportPresets: [{ id: 'p1', name: 'Keep', format: 'pdf', options: {} }], + }); + const presets = ExportPresets.deletePreset(store, 'missing'); + expect(presets).toHaveLength(1); + expect(store.data.exportPresets).toHaveLength(1); + }); + }); +}); diff --git a/tests/preload.test.js b/tests/preload.test.js index e75fe08..e1a5139 100644 --- a/tests/preload.test.js +++ b/tests/preload.test.js @@ -42,6 +42,9 @@ describe('Preload Security', () => { 'browse-header-footer-logo', 'save-header-footer-logo', 'clear-header-footer-logo', + 'get-export-presets', + 'save-export-preset', + 'delete-export-preset', 'get-page-settings', 'update-page-settings', 'process-pdf-operation',