diff --git a/src/main/themeMenuBuilder.js b/src/main/themeMenuBuilder.js new file mode 100644 index 0000000..23011d8 --- /dev/null +++ b/src/main/themeMenuBuilder.js @@ -0,0 +1,50 @@ +/** + * Editor Theme menu builder — converts ThemeRegistry output into Electron + * MenuItem[] suitable for the View → Theme submenu. + * + * Pure: takes `setTheme` + `getCurrentThemeId` as injected dependencies so + * the builder never touches electron-store or `mainWindow`. Tested in + * isolation under `@jest-environment node`. + * + * Menu shape (matches the previous hardcoded block in src/main.js:1137-1245): + * [item, item, …, { type: 'separator' }, item, item, …, separator, …] + * grouped by category in registry order, with separators between non-empty + * categories. Each theme item is a radio-style MenuItem so Electron shows a + * checkmark next to the active theme. + * + * @module themeMenuBuilder + */ + +const ThemeRegistry = require('./ThemeRegistry'); + +/** + * @param {object} deps + * @param {(id: string) => void} deps.setTheme + * @param {() => string} deps.getCurrentThemeId + * @returns {Array} Electron MenuItemTemplate[] + */ +function buildThemeMenu({ setTheme, getCurrentThemeId }) { + const currentId = getCurrentThemeId(); + const items = []; + const cats = ThemeRegistry.categories(); + let nonEmptySeen = 0; + + for (const cat of cats) { + const inCat = ThemeRegistry.list().filter((t) => t.category === cat); + if (inCat.length === 0) continue; + if (nonEmptySeen > 0) items.push({ type: 'separator' }); + nonEmptySeen++; + + for (const t of inCat) { + items.push({ + label: t.label, + type: 'radio', + checked: t.id === currentId, + click: () => setTheme(t.id), + }); + } + } + return items; +} + +module.exports = { buildThemeMenu }; diff --git a/tests/main/theme-menu-builder.test.js b/tests/main/theme-menu-builder.test.js new file mode 100644 index 0000000..2e0a0fc --- /dev/null +++ b/tests/main/theme-menu-builder.test.js @@ -0,0 +1,76 @@ +/** + * @jest-environment node + * + * themeMenuBuilder tests — pure module. We pass in a fake `setTheme` and a + * fake `getCurrentThemeId` so we never touch electron-store or Electron. + * + * Note on isolation: ThemeRegistry holds state in a module-scoped Map. + * `jest.resetModules()` drops the cache so each test re-requires a fresh + * ThemeRegistry + buildThemeMenu pair, preventing leakage across tests. + */ +let ThemeRegistry; +let buildThemeMenu; + +beforeEach(() => { + jest.resetModules(); + ThemeRegistry = require('../../src/main/ThemeRegistry'); + buildThemeMenu = require('../../src/main/themeMenuBuilder').buildThemeMenu; + ThemeRegistry.clear(); +}); + +describe('buildThemeMenu', () => { + test('groups themes by category in registry order', () => { + ThemeRegistry.register({ id: 'l1', label: 'L1', category: 'light', isDark: false }); + ThemeRegistry.register({ id: 'l2', label: 'L2', category: 'light', isDark: false }); + ThemeRegistry.register({ id: 'd1', label: 'D1', category: 'dark', isDark: true }); + ThemeRegistry.register({ id: 'd2', label: 'D2', category: 'dark', isDark: true }); + ThemeRegistry.register({ id: 'hc1', label: 'HC1', category: 'high-contrast', isDark: true }); + ThemeRegistry.register({ id: 's1', label: 'S1', category: 'seasonal', isDark: false }); + + const setTheme = jest.fn(); + const items = buildThemeMenu({ setTheme, getCurrentThemeId: () => 'd1' }); + + // Expected order: light (2 items), separator, dark (2 items), separator, hc (1), separator, seasonal (1) + expect(items.map((i) => i.label || i.type)).toEqual([ + 'L1', + 'L2', + 'separator', + 'D1', + 'D2', + 'separator', + 'HC1', + 'separator', + 'S1', + ]); + }); + + test('marks the currently-selected theme as checked=true', () => { + ThemeRegistry.register({ id: 'a', label: 'A', category: 'light', isDark: false }); + ThemeRegistry.register({ id: 'b', label: 'B', category: 'dark', isDark: true }); + + const items = buildThemeMenu({ setTheme: jest.fn(), getCurrentThemeId: () => 'b' }); + const aItem = items.find((i) => i.label === 'A'); + const bItem = items.find((i) => i.label === 'B'); + expect(aItem.type).toBe('radio'); + expect(aItem.checked).toBe(false); + expect(bItem.type).toBe('radio'); + expect(bItem.checked).toBe(true); + }); + + test('click handler calls setTheme with the theme id', () => { + ThemeRegistry.register({ id: 'a', label: 'A', category: 'light', isDark: false }); + const setTheme = jest.fn(); + const items = buildThemeMenu({ setTheme, getCurrentThemeId: () => 'a' }); + items[0].click(); + expect(setTheme).toHaveBeenCalledWith('a'); + }); + + test('falls back to no theme checked when current id unknown', () => { + ThemeRegistry.register({ id: 'a', label: 'A', category: 'light', isDark: false }); + const items = buildThemeMenu({ + setTheme: jest.fn(), + getCurrentThemeId: () => 'deleted-theme', + }); + expect(items.find((i) => i.label === 'A').checked).toBe(false); + }); +});