From 6ca7223dc6f855107e5bc14fc8bff5792ebd8e79 Mon Sep 17 00:00:00 2001 From: Johannes Millan Date: Mon, 26 Jan 2026 20:55:29 +0100 Subject: [PATCH] refactor: add markdown renderer tests and remove dead IPC code - Add 18 unit tests for marked-options-factory covering: - Image renderer (sizing syntax, lazy loading, dimensions) - Paragraph renderer (headings h1./h2. syntax) - parseImageDimensionsFromTitle helper - preprocessMarkdown hook - Export parseImageDimensionsFromTitle and preprocessMarkdown for testability - Remove unused IPC.TOGGLE_FULLSCREEN event and handler (F11 fullscreen is handled directly in main-window.ts) --- electron/ipc-handlers/app-control.ts | 7 - .../shared-with-frontend/ipc-events.const.ts | 2 - src/app/ui/marked-options-factory.spec.ts | 195 +++++++++++++++++- src/app/ui/marked-options-factory.ts | 4 +- 4 files changed, 196 insertions(+), 12 deletions(-) diff --git a/electron/ipc-handlers/app-control.ts b/electron/ipc-handlers/app-control.ts index 202fd72a72..a50bee1799 100644 --- a/electron/ipc-handlers/app-control.ts +++ b/electron/ipc-handlers/app-control.ts @@ -78,11 +78,4 @@ export const initAppControlIpc = (): void => { }); } }); - - ipcMain.on(IPC.TOGGLE_FULLSCREEN, () => { - const mainWin = getWin(); - if (mainWin) { - mainWin.setFullScreen(!mainWin.isFullScreen()); - } - }); }; diff --git a/electron/shared-with-frontend/ipc-events.const.ts b/electron/shared-with-frontend/ipc-events.const.ts index 42edcbdcea..8411540d0b 100644 --- a/electron/shared-with-frontend/ipc-events.const.ts +++ b/electron/shared-with-frontend/ipc-events.const.ts @@ -72,8 +72,6 @@ export enum IPC { UPDATE_SETTINGS = 'UPDATE_SETTINGS', UPDATE_TITLE_BAR_DARK_MODE = 'UPDATE_TITLE_BAR_DARK_MODE', - TOGGLE_FULLSCREEN = 'TOGGLE_FULLSCREEN', - // maybe_UPDATE_CURRENT_TASK = 'UPDATE_CURRENT_TASK', // maybe_IS_IDLE = 'IS_IDLE', // maybe_IS_BUSY = 'IS_BUSY', diff --git a/src/app/ui/marked-options-factory.spec.ts b/src/app/ui/marked-options-factory.spec.ts index 52e3bac2f8..01a515c433 100644 --- a/src/app/ui/marked-options-factory.spec.ts +++ b/src/app/ui/marked-options-factory.spec.ts @@ -1,4 +1,8 @@ -import { markedOptionsFactory } from './marked-options-factory'; +import { + markedOptionsFactory, + parseImageDimensionsFromTitle, + preprocessMarkdown, +} from './marked-options-factory'; describe('markedOptionsFactory', () => { let options: ReturnType; @@ -125,7 +129,196 @@ describe('markedOptionsFactory', () => { }); }); + describe('image renderer', () => { + it('should render basic image with alt text', () => { + const result = options.renderer!.image({ + href: 'http://example.com/image.png', + title: null, + text: 'Alt text', + } as any); + expect(result).toContain('src="http://example.com/image.png"'); + expect(result).toContain('alt="Alt text"'); + expect(result).toContain('loading="lazy"'); + }); + + it('should render image with width and height from title', () => { + const result = options.renderer!.image({ + href: 'http://example.com/image.png', + title: '200|150', + text: 'Sized image', + } as any); + expect(result).toContain('width="200"'); + expect(result).toContain('height="150"'); + expect(result).not.toContain('title='); + }); + + it('should render image with width only', () => { + const result = options.renderer!.image({ + href: 'http://example.com/image.png', + title: '300|', + text: 'Width only', + } as any); + expect(result).toContain('width="300"'); + expect(result).not.toContain('height='); + }); + + it('should render image with height only', () => { + const result = options.renderer!.image({ + href: 'http://example.com/image.png', + title: '|250', + text: 'Height only', + } as any); + expect(result).toContain('height="250"'); + expect(result).not.toContain('width='); + }); + + it('should render regular title when not in dimension format', () => { + const result = options.renderer!.image({ + href: 'http://example.com/image.png', + title: 'A descriptive title', + text: 'Image', + } as any); + expect(result).toContain('title="A descriptive title"'); + expect(result).not.toContain('width='); + expect(result).not.toContain('height='); + }); + + it('should handle null title', () => { + const result = options.renderer!.image({ + href: 'http://example.com/image.png', + title: null, + text: 'No title', + } as any); + expect(result).not.toContain('title='); + expect(result).toContain('alt="No title"'); + }); + }); + + describe('paragraph renderer', () => { + it('should render simple paragraph', () => { + const mockParser = { + parseInline: (tokens: any[]) => + tokens.map((t: any) => t.raw || t.text || '').join(''), + }; + const paragraphRenderer = options.renderer!.paragraph.bind({ parser: mockParser }); + + const result = paragraphRenderer({ + tokens: [{ type: 'text', raw: 'Simple paragraph', text: 'Simple paragraph' }], + } as any); + expect(result).toBe('

Simple paragraph

'); + }); + + it('should convert h1. syntax to heading', () => { + const mockParser = { + parseInline: (tokens: any[]) => + tokens.map((t: any) => t.raw || t.text || '').join(''), + }; + const paragraphRenderer = options.renderer!.paragraph.bind({ parser: mockParser }); + + const result = paragraphRenderer({ + tokens: [{ type: 'text', raw: 'h1. My Heading', text: 'h1. My Heading' }], + } as any); + expect(result).toBe('

My Heading

'); + }); + + it('should convert h2. syntax to heading', () => { + const mockParser = { + parseInline: (tokens: any[]) => + tokens.map((t: any) => t.raw || t.text || '').join(''), + }; + const paragraphRenderer = options.renderer!.paragraph.bind({ parser: mockParser }); + + const result = paragraphRenderer({ + tokens: [{ type: 'text', raw: 'h2. Subheading', text: 'h2. Subheading' }], + } as any); + expect(result).toBe('

Subheading

'); + }); + }); + // Note: URL auto-linking is handled automatically by marked v17 with gfm: true. // We intentionally do NOT override renderer.text for this - the lexer converts // URLs to link tokens before they reach the text renderer. }); + +describe('parseImageDimensionsFromTitle', () => { + it('should parse width and height from title', () => { + const result = parseImageDimensionsFromTitle('200|150'); + expect(result).toEqual({ width: '200', height: '150' }); + }); + + it('should parse width only', () => { + const result = parseImageDimensionsFromTitle('300|'); + expect(result).toEqual({ width: '300', height: undefined }); + }); + + it('should parse height only', () => { + const result = parseImageDimensionsFromTitle('|250'); + expect(result).toEqual({ width: undefined, height: '250' }); + }); + + it('should return empty object for null title', () => { + const result = parseImageDimensionsFromTitle(null); + expect(result).toEqual({}); + }); + + it('should return empty object for non-dimension format', () => { + const result = parseImageDimensionsFromTitle('A regular title'); + expect(result).toEqual({}); + }); + + it('should return empty object for empty string', () => { + const result = parseImageDimensionsFromTitle(''); + expect(result).toEqual({}); + }); + + it('should handle pipe only', () => { + const result = parseImageDimensionsFromTitle('|'); + expect(result).toEqual({ width: undefined, height: undefined }); + }); +}); + +describe('preprocessMarkdown', () => { + it('should convert image sizing syntax to title format', () => { + const input = '![alt text](http://example.com/image.png =200x150)'; + const result = preprocessMarkdown(input); + expect(result).toBe('![alt text](http://example.com/image.png "200|150")'); + }); + + it('should handle width only', () => { + const input = '![alt](url.png =300x)'; + const result = preprocessMarkdown(input); + expect(result).toBe('![alt](url.png "300|")'); + }); + + it('should handle height only', () => { + const input = '![alt](url.png =x250)'; + const result = preprocessMarkdown(input); + expect(result).toBe('![alt](url.png "|250")'); + }); + + it('should handle multiple images in text', () => { + const input = 'Some text ![img1](a.png =100x100) more ![img2](b.png =200x200) end'; + const result = preprocessMarkdown(input); + expect(result).toBe( + 'Some text ![img1](a.png "100|100") more ![img2](b.png "200|200") end', + ); + }); + + it('should not modify images without sizing syntax', () => { + const input = '![alt](http://example.com/image.png)'; + const result = preprocessMarkdown(input); + expect(result).toBe('![alt](http://example.com/image.png)'); + }); + + it('should not modify images with regular title', () => { + const input = '![alt](http://example.com/image.png "A title")'; + const result = preprocessMarkdown(input); + expect(result).toBe('![alt](http://example.com/image.png "A title")'); + }); + + it('should preserve other markdown content', () => { + const input = '# Header\n\n![img](url.png =50x50)\n\nParagraph'; + const result = preprocessMarkdown(input); + expect(result).toBe('# Header\n\n![img](url.png "50|50")\n\nParagraph'); + }); +}); diff --git a/src/app/ui/marked-options-factory.ts b/src/app/ui/marked-options-factory.ts index acd9bcdccb..1ed6df2376 100644 --- a/src/app/ui/marked-options-factory.ts +++ b/src/app/ui/marked-options-factory.ts @@ -6,7 +6,7 @@ import { Hooks } from 'marked'; * The title format is expected to be "width|height" (e.g., "200|150", "200|", "|150") * This is generated by preprocessing. */ -const parseImageDimensionsFromTitle = ( +export const parseImageDimensionsFromTitle = ( title: string | null, ): { width?: string; height?: string } => { if (!title) { @@ -30,7 +30,7 @@ const parseImageDimensionsFromTitle = ( * Converts: ![alt](url =WIDTHxHEIGHT) -> ![alt](url "WIDTH|HEIGHT") * This allows marked.js to parse it as a valid image with a title. */ -const preprocessMarkdown = (markdown: string): string => { +export const preprocessMarkdown = (markdown: string): string => { // Match: ![alt](url =WIDTHxHEIGHT) or ![alt](url =WIDTHx) or ![alt](url =xHEIGHT) // Capture groups: 1=alt, 2=url, 3=width, 4=height return markdown.replace(