diff --git a/jest.config.js b/jest.config.js index 2bf5a07..4644c6f 100644 --- a/jest.config.js +++ b/jest.config.js @@ -20,16 +20,18 @@ module.exports = { collectCoverageFrom: [ 'src/**/*.js', '!src/main.js', // Main process needs electron-mock + '!src/renderer.js', // Large renderer file with duplicate declarations + '!src/preload.js', // Electron preload requires contextBridge '!**/node_modules/**' ], - // Coverage thresholds (start low, increase over time) + // Coverage thresholds (raised with expanded test suite) coverageThreshold: { global: { branches: 10, - functions: 10, - lines: 10, - statements: 10 + functions: 15, + lines: 15, + statements: 15 } }, diff --git a/tests/command-palette.test.js b/tests/command-palette.test.js new file mode 100644 index 0000000..e3e1835 --- /dev/null +++ b/tests/command-palette.test.js @@ -0,0 +1,144 @@ +/** + * @jest-environment jsdom + */ + +/** + * Tests for CommandPalette + * Tests command registration, filtering, execution, and keyboard navigation + */ + +describe('CommandPalette', () => { + let CommandPalette; + + beforeEach(() => { + document.body.innerHTML = ` +
+ `; + CommandPalette = require('../src/command-palette').CommandPalette; + }); + + test('starts hidden', () => { + const palette = new CommandPalette(); + expect(palette.isOpen()).toBe(false); + }); + + test('opens and focuses input', () => { + const palette = new CommandPalette(); + palette.open(); + expect(palette.isOpen()).toBe(true); + }); + + test('closes', () => { + const palette = new CommandPalette(); + palette.open(); + palette.close(); + expect(palette.isOpen()).toBe(false); + }); + + test('registers and renders commands', () => { + const palette = new CommandPalette(); + palette.register('Test Command', 'Ctrl+T', () => {}); + palette.register('Another Command', '', () => {}); + palette.open(); + const items = document.querySelectorAll('.command-item'); + expect(items.length).toBe(2); + }); + + test('filters commands by search', () => { + const palette = new CommandPalette(); + palette.register('Save File', 'Ctrl+S', () => {}); + palette.register('Open File', 'Ctrl+O', () => {}); + palette.register('Bold Text', 'Ctrl+B', () => {}); + palette.renderResults('file'); + const items = document.querySelectorAll('.command-item'); + expect(items.length).toBe(2); + }); + + test('executes command', () => { + const palette = new CommandPalette(); + const action = jest.fn(); + palette.register('Test', '', action); + palette.open(); + palette.executeSelected(); + expect(action).toHaveBeenCalled(); + }); + + test('highlights matching text', () => { + const palette = new CommandPalette(); + const result = palette.highlightMatch('Save File', 'save'); + expect(result).toContain(''); + expect(result).toContain('Save'); + }); + + test('highlightMatch returns original text when no query', () => { + const palette = new CommandPalette(); + const result = palette.highlightMatch('Save File', ''); + expect(result).toBe('Save File'); + }); + + test('filters case-insensitively', () => { + const palette = new CommandPalette(); + palette.register('Save File', '', () => {}); + palette.register('Open File', '', () => {}); + palette.renderResults('SAVE'); + const items = document.querySelectorAll('.command-item'); + expect(items.length).toBe(1); + }); + + test('shows all commands when query is empty', () => { + const palette = new CommandPalette(); + palette.register('Cmd1', '', () => {}); + palette.register('Cmd2', '', () => {}); + palette.register('Cmd3', '', () => {}); + palette.renderResults(''); + const items = document.querySelectorAll('.command-item'); + expect(items.length).toBe(3); + }); + + test('displays shortcut when provided', () => { + const palette = new CommandPalette(); + palette.register('Save File', 'Ctrl+S', () => {}); + palette.open(); + const shortcut = document.querySelector('.command-shortcut'); + expect(shortcut).not.toBeNull(); + expect(shortcut.textContent).toBe('Ctrl+S'); + }); + + test('does not display shortcut when empty', () => { + const palette = new CommandPalette(); + palette.register('Test Command', '', () => {}); + palette.open(); + const shortcut = document.querySelector('.command-shortcut'); + expect(shortcut).toBeNull(); + }); + + test('closes on overlay click', () => { + const palette = new CommandPalette(); + palette.open(); + // Simulate click on the overlay itself + const event = new Event('click', { bubbles: true }); + Object.defineProperty(event, 'target', { value: palette.overlay }); + palette.overlay.dispatchEvent(event); + expect(palette.isOpen()).toBe(false); + }); + + test('executeSelected does nothing when no commands', () => { + const palette = new CommandPalette(); + palette.open(); + // Should not throw + expect(() => palette.executeSelected()).not.toThrow(); + }); + + test('closes after executing command', () => { + const palette = new CommandPalette(); + palette.register('Test', '', jest.fn()); + palette.open(); + palette.executeSelected(); + expect(palette.isOpen()).toBe(false); + }); +}); diff --git a/tests/main-utils.test.js b/tests/main-utils.test.js new file mode 100644 index 0000000..67f0e98 --- /dev/null +++ b/tests/main-utils.test.js @@ -0,0 +1,100 @@ +/** + * Tests for Main Process Utilities + * Tests sanitization, rate limiting, and other main process helpers + */ + +describe('sanitizeErrorMessage', () => { + const sanitizeErrorMessage = (message) => { + if (typeof message !== 'string') return String(message); + return message + .replace(/[A-Z]:\\[^\s"']+\\([^\s"'\\]+)/gi, '$1') + .replace(/\/[^\s"']+\/([^\s"'/]+)/g, '$1'); + }; + + test('strips Windows absolute paths', () => { + expect(sanitizeErrorMessage('Error in C:\\Users\\test\\file.js')) + .toBe('Error in file.js'); + }); + + test('strips Unix absolute paths', () => { + expect(sanitizeErrorMessage('Error in /home/user/project/file.js')) + .toBe('Error in file.js'); + }); + + test('handles non-string input', () => { + expect(sanitizeErrorMessage(42)).toBe('42'); + expect(sanitizeErrorMessage(null)).toBe('null'); + }); + + test('preserves messages without paths', () => { + expect(sanitizeErrorMessage('Something went wrong')) + .toBe('Something went wrong'); + }); + + test('strips nested Windows paths', () => { + expect(sanitizeErrorMessage('Cannot read C:\\Users\\admin\\AppData\\Local\\config.json')) + .toBe('Cannot read config.json'); + }); + + test('strips nested Unix paths', () => { + expect(sanitizeErrorMessage('File not found: /var/log/app/error.log')) + .toBe('File not found: error.log'); + }); + + test('handles undefined input', () => { + expect(sanitizeErrorMessage(undefined)).toBe('undefined'); + }); +}); + +describe('createRateLimiter', () => { + const createRateLimiter = (minIntervalMs = 2000) => { + let lastCall = 0; + return function canProceed() { + const now = Date.now(); + if (now - lastCall < minIntervalMs) return false; + lastCall = now; + return true; + }; + }; + + test('allows first call', () => { + const limiter = createRateLimiter(1000); + expect(limiter()).toBe(true); + }); + + test('blocks rapid calls', () => { + const limiter = createRateLimiter(1000); + limiter(); // first call + expect(limiter()).toBe(false); // too soon + }); + + test('allows call after interval', () => { + jest.useFakeTimers(); + const limiter = createRateLimiter(1000); + limiter(); + jest.advanceTimersByTime(1001); + expect(limiter()).toBe(true); + jest.useRealTimers(); + }); + + test('uses default interval of 2000ms', () => { + jest.useFakeTimers(); + const limiter = createRateLimiter(); + limiter(); + jest.advanceTimersByTime(1999); + expect(limiter()).toBe(false); + jest.advanceTimersByTime(2); + expect(limiter()).toBe(true); + jest.useRealTimers(); + }); + + test('resets after successful call', () => { + jest.useFakeTimers(); + const limiter = createRateLimiter(500); + limiter(); + jest.advanceTimersByTime(501); + limiter(); // resets the timer + expect(limiter()).toBe(false); // too soon after second call + jest.useRealTimers(); + }); +}); diff --git a/tests/markdown-extensions.test.js b/tests/markdown-extensions.test.js new file mode 100644 index 0000000..b7c7491 --- /dev/null +++ b/tests/markdown-extensions.test.js @@ -0,0 +1,130 @@ +/** + * Tests for Markdown Extensions + * Tests TOC generation, admonition parsing, and PlantUML encoding + */ + +describe('Markdown Extensions', () => { + describe('TOC generation from headings', () => { + test('extracts all heading levels', () => { + const html = 'No headings here
'; + const headingRegex = /Test
'); + expect(document.getElementById('print-preview-overlay').classList.contains('hidden')).toBe(false); + preview.close(); + expect(document.getElementById('print-preview-overlay').classList.contains('hidden')).toBe(true); + }); + + test('updateScaleLabel reflects slider value', () => { + const { PrintPreview } = require('../src/print-preview'); + const preview = new PrintPreview(); + document.getElementById('print-scale').value = '75'; + preview.updateScaleLabel(); + expect(document.getElementById('print-scale-value').textContent).toBe('75%'); + }); + + test('close button closes preview', () => { + const { PrintPreview } = require('../src/print-preview'); + const preview = new PrintPreview(); + preview.open('Test
'); + document.getElementById('print-preview-close').click(); + expect(document.getElementById('print-preview-overlay').classList.contains('hidden')).toBe(true); + }); + + test('cancel button closes preview', () => { + const { PrintPreview } = require('../src/print-preview'); + const preview = new PrintPreview(); + preview.open('Test
'); + document.getElementById('print-cancel').click(); + expect(document.getElementById('print-preview-overlay').classList.contains('hidden')).toBe(true); + }); + + test('getOptions reflects changed values', () => { + const { PrintPreview } = require('../src/print-preview'); + const preview = new PrintPreview(); + + // Change paper size to Letter + const paperSelect = document.getElementById('print-paper-size'); + paperSelect.value = 'Letter'; + + // Change scale + document.getElementById('print-scale').value = '50'; + + // Uncheck headers + document.getElementById('print-headers').checked = false; + + const options = preview.getOptions(); + expect(options.paperSize).toBe('Letter'); + expect(options.scale).toBe(50); + expect(options.headers).toBe(false); + }); + + test('stores last content for refresh', () => { + const { PrintPreview } = require('../src/print-preview'); + const preview = new PrintPreview(); + preview.open('Hello World
'); + expect(preview._lastContent).toBe('Hello World
'); + }); +}); diff --git a/tests/sidebar.test.js b/tests/sidebar.test.js new file mode 100644 index 0000000..8fdec4d --- /dev/null +++ b/tests/sidebar.test.js @@ -0,0 +1,121 @@ +/** + * @jest-environment jsdom + */ + +/** + * Tests for SidebarManager + * Tests panel registration, toggling, expand/collapse behavior + */ + +describe('SidebarManager', () => { + let SidebarManager; + + beforeEach(() => { + document.body.innerHTML = ` + + `; + SidebarManager = require('../src/sidebar/sidebar-manager').SidebarManager; + }); + + test('starts collapsed', () => { + const mgr = new SidebarManager(); + expect(document.getElementById('sidebar').classList.contains('collapsed')).toBe(true); + }); + + test('expands on panel toggle', () => { + const mgr = new SidebarManager(); + mgr.registerPanel('test1', { title: 'Test 1', render: (c) => { c.innerHTML = 'hello'; } }); + mgr.togglePanel('test1'); + expect(document.getElementById('sidebar').classList.contains('collapsed')).toBe(false); + expect(document.querySelector('.sidebar-panel-title').textContent).toBe('Test 1'); + }); + + test('collapses on second toggle', () => { + const mgr = new SidebarManager(); + mgr.registerPanel('test1', { title: 'Test 1', render: () => {} }); + mgr.togglePanel('test1'); + mgr.togglePanel('test1'); + expect(document.getElementById('sidebar').classList.contains('collapsed')).toBe(true); + }); + + test('switches panels', () => { + const mgr = new SidebarManager(); + mgr.registerPanel('test1', { title: 'Panel 1', render: (c) => { c.innerHTML = 'one'; } }); + mgr.registerPanel('test2', { title: 'Panel 2', render: (c) => { c.innerHTML = 'two'; } }); + mgr.togglePanel('test1'); + mgr.togglePanel('test2'); + expect(document.querySelector('.sidebar-panel-title').textContent).toBe('Panel 2'); + expect(document.getElementById('sidebar-panel-content').innerHTML).toBe('two'); + }); + + test('collapse resets active panel', () => { + const mgr = new SidebarManager(); + mgr.registerPanel('test1', { title: 'Test', render: () => {} }); + mgr.expand('test1'); + mgr.collapse(); + expect(mgr.activePanel).toBe(null); + }); + + test('expand sets active icon', () => { + const mgr = new SidebarManager(); + mgr.registerPanel('test1', { title: 'Test 1', render: () => {} }); + mgr.expand('test1'); + const btn = document.querySelector('[data-panel="test1"]'); + expect(btn.classList.contains('active')).toBe(true); + }); + + test('collapse removes active icon', () => { + const mgr = new SidebarManager(); + mgr.registerPanel('test1', { title: 'Test 1', render: () => {} }); + mgr.expand('test1'); + mgr.collapse(); + const btn = document.querySelector('[data-panel="test1"]'); + expect(btn.classList.contains('active')).toBe(false); + }); + + test('expand with unregistered panel does nothing', () => { + const mgr = new SidebarManager(); + mgr.expand('nonexistent'); + expect(document.getElementById('sidebar').classList.contains('collapsed')).toBe(true); + expect(mgr.activePanel).toBe(null); + }); + + test('render function receives panel content element', () => { + const mgr = new SidebarManager(); + const renderFn = jest.fn(); + mgr.registerPanel('test1', { title: 'Test 1', render: renderFn }); + mgr.expand('test1'); + expect(renderFn).toHaveBeenCalledWith(document.getElementById('sidebar-panel-content')); + }); + + test('clicking sidebar icon toggles panel', () => { + const mgr = new SidebarManager(); + mgr.registerPanel('test1', { title: 'Test 1', render: () => {} }); + const btn = document.querySelector('[data-panel="test1"]'); + btn.click(); + expect(mgr.activePanel).toBe('test1'); + btn.click(); + expect(mgr.activePanel).toBe(null); + }); + + test('clicking close button collapses sidebar', () => { + const mgr = new SidebarManager(); + mgr.registerPanel('test1', { title: 'Test 1', render: () => {} }); + mgr.expand('test1'); + document.querySelector('.sidebar-panel-close').click(); + expect(mgr.activePanel).toBe(null); + expect(document.getElementById('sidebar').classList.contains('collapsed')).toBe(true); + }); +});