feat(theme-registry): buildThemeMenu converts registry → Electron MenuItem[]

Adds src/main/themeMenuBuilder.js — a pure module that consumes
ThemeRegistry.list() + ThemeRegistry.categories() and the injected
setTheme/getCurrentThemeId callbacks to produce the View → Theme
submenu's MenuItemTemplate[] in the same shape as the previous
hardcoded block in src/main.js:1137-1245.

- Radio-style items with checked=true on the active theme id
- Grouped by category in registry order with separators between
  non-empty categories
- No Electron / electron-store imports — keeps the module pure and
  unit-testable under @jest-environment node
- Tests use jest.resetModules() + per-test require to avoid the
  module-cache leakage the bootstrap test surfaced in T2

Amit Haridas
This commit is contained in:
2026-09-14 19:46:15 +05:30
parent eaad62d02e
commit a2b4a856f3
2 changed files with 126 additions and 0 deletions
+50
View File
@@ -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<object>} 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 };
+76
View File
@@ -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);
});
});