Files
markdown-converter/tests/unit/lib/register-menu-commands.test.tsx
T
amitwh 74ff6afb19 feat(renderer): wire remaining stubbed commands and rebuild
The v5.0.0 React UI shipped with a working surface but had a number of
features left as 'wired in a later phase' or broken by missing IPC
plumbing. This commit completes the wiring end-to-end and proves the
behaviour with the run-desktop driver.

Toolbar format buttons (Toolbar.tsx)
  - Bold, Italic, Unordered/Ordered list, Code, Link now dispatch real
    commands instead of being permanently disabled.

New module: src/renderer/lib/editor-commands.ts
  - Module-level singleton holding the active CodeMirror EditorView.
  - toggleBold/Italic/Code/CodeBlock, toggleUnorderedList/OrderedList,
    insertLink, setHeadingLevel, scrollToLine, undo, redo, insertSnippet.
  - CodeMirrorEditor mounts the view via setActiveView on mount and
    nulls it on unmount, so commands land on the focused buffer.

register-menu-commands.ts
  - file.confirmClose: prompts before closing a dirty tab via the
    confirm modal; otherwise closes silently.
  - app.quit: prompts before quitting if any buffer is dirty; uses
    the new ipc.app.quit channel.
  - short-cuts.show: shows the keyboard shortcut list in a confirm dialog.
  - editor.bold/italic/code/list.*/link/undo/redo/heading.*: drive the
    active editor.
  - find.toggle: dispatches mc:find-toggle for any listening component.
  - view.sidebarPanel/bottomPanel: navigate between sidebar sections
    via the new data-testid buttons; bottom panel toggles the REPL.
  - font.size: increase/decrease/reset on editorFontSize (10..28).
  - theme.loadCustomCss/clearCustomCss: pick and clear the custom CSS
    path on the settings store.
  - template.load: inserts a bundled markdown skeleton at the cursor.
  - print.preview/previewStyled: emit mc:print and mc:print-preview.
  - file.clearRecent: clears open tabs.
  - file.new: creates an untitled buffer and tab.
  - editor.gotoHeading: scrolls to a given line (used by Outline and
    Breadcrumb).
  - view.toggleSidebar: already wired.
  - file.opened: already wired (prior fix).

Sidebar Outline + Breadcrumb
  - Both now click through to 'editor.gotoHeading' with the line
    number extracted from the active buffer.
  - Sidebar exposes data-testid for the 'view.sidebarPanel' menu action.

editor-commands sets the active view; scroll metrics flow through to
Minimap which now reads scrollRatio/visibleRatio from the editor.

Export dialogs (PDF/DOCX/HTML)
  - Replaced the broken ipc.export.{pdf,docx,html,batch} calls (those
    channels were not implemented in main and only CHANNEL_MISSING
    was returned) with renderer-side pipelines.
  - PDF: generateHtml() with @page CSS for size + margins, then
    ipc.print to the main process for native print-to-PDF.
  - DOCX: generateDocx() from the existing lib, then ipc.file.writeBuffer.
  - HTML: generateHtml() then ipc.file.writeBuffer.
  - All three dialogs use ipc.app.showSaveDialog for output path
    (new IPC handler) and ipc.file.writeBuffer for the binary write
    (new preload channel).
  - PrintPreview now uses MarkdownRenderer instead of a raw <pre>.

New settings fields
  - editorFontSize: 10..28 px (drives CodeMirror theme font-size)
  - customCssPath: string|null (Theme menu wires 'load-custom-css' and
    'clear-custom-css' to it).

New IPC channels
  - 'app:quit', 'app:open-external', 'app:show-save-dialog',
    'write-buffer' (already existed; now exposed in preload).

Tests
  - Updated Toolbar test: format buttons are no longer disabled and
    dispatch their command ids.
  - Updated Export*D*Dialog tests to mock the new ipc surface
    (ipc.print, ipc.app.showSaveDialog, ipc.file.writeBuffer) and
    assert the new flow.
  - Updated PrintPreview test to use ipc.print.doPrint.
  - Updated phase8-toasts integration: PDF flow now goes through
    ipc.print and asserts 'Sent <title> to printer'.
  - register-menu-commands test: re-register 'template.load' AFTER
    Harness renders so the captured-args handler is the live one.

Verification
  - npm run build:renderer: success (1.6s)
  - npx vitest run: 308 passed (up from 305; 3 new + unchanged)
  - npx jest: 189 passed (unchanged)
  - .claude/skills/run-desktop/verify-features.mjs: drove the live app
    via Playwright, exercised every wired feature end-to-end, captured
    11 screenshots. All verified working (editor + preview render the
    file content, toolbar buttons dispatch, dialogs open, theme
    toggles, outline shows headings, italic button works in editor).
2026-06-07 23:13:47 +05:30

147 lines
5.0 KiB
TypeScript

import { describe, it, expect, beforeEach, vi } from 'vitest';
import { render, act } from '@testing-library/react';
import { useCommandStore } from '@/stores/command-store';
import { useFileStore } from '@/stores/file-store';
import { useAppStore } from '@/stores/app-store';
import { useRegisterMenuCommands, useBridgeNativeMenu } from '@/lib/commands/register-menu-commands';
type Cleanup = () => void;
const menuListeners = new Map<string, (...args: unknown[]) => void>();
const menuOn = vi.fn(
(channel: string, callback: (...args: unknown[]) => void): Cleanup => {
menuListeners.set(channel, callback);
return () => {
if (menuListeners.get(channel) === callback) menuListeners.delete(channel);
};
}
);
vi.mock('@/lib/ipc', () => ({
ipc: {
menu: {
on: (channel: string, cb: (...args: unknown[]) => void) => menuOn(channel, cb),
},
},
}));
function fireMenu(channel: string, ...args: unknown[]): void {
const listener = menuListeners.get(channel);
if (!listener) throw new Error(`No listener for ${channel}`);
listener(...args);
}
function Harness() {
useRegisterMenuCommands();
useBridgeNativeMenu();
return null;
}
describe('useRegisterMenuCommands + useBridgeNativeMenu', () => {
beforeEach(() => {
menuListeners.clear();
menuOn.mockClear();
useCommandStore.setState({ handlers: {} });
useFileStore.setState({
tree: null,
rootPath: null,
expanded: new Set(),
openTabs: [],
activeTabId: null,
});
useAppStore.setState({
sidebarVisible: true,
previewVisible: true,
zenMode: false,
paneSizes: { sidebar: 20, editor: 50, preview: 30 },
});
});
it('registers file.open, file.save, file.closeTab handlers in the command store', () => {
render(<Harness />);
const handlers = useCommandStore.getState().handlers;
expect(typeof handlers['file.open']).toBe('function');
expect(typeof handlers['file.save']).toBe('function');
expect(typeof handlers['file.closeTab']).toBe('function');
});
it('registers tab.next / tab.prev and view.toggle* commands', () => {
render(<Harness />);
const handlers = useCommandStore.getState().handlers;
expect(typeof handlers['tab.next']).toBe('function');
expect(typeof handlers['tab.prev']).toBe('function');
expect(typeof handlers['view.toggleSidebar']).toBe('function');
expect(typeof handlers['view.togglePreview']).toBe('function');
});
it('view.togglePreview command flips previewVisible', () => {
render(<Harness />);
expect(useAppStore.getState().previewVisible).toBe(true);
act(() => useCommandStore.getState().dispatch('view.togglePreview'));
expect(useAppStore.getState().previewVisible).toBe(false);
});
it('view.toggleSidebar command flips sidebarVisible', () => {
render(<Harness />);
expect(useAppStore.getState().sidebarVisible).toBe(true);
act(() => useCommandStore.getState().dispatch('view.toggleSidebar'));
expect(useAppStore.getState().sidebarVisible).toBe(false);
});
it('file.closeTab with no active tab is a no-op (does not throw)', () => {
render(<Harness />);
expect(() => act(() => useCommandStore.getState().dispatch('file.closeTab'))).not.toThrow();
});
it('tab.next wraps from last tab to first', () => {
useFileStore.setState({
openTabs: [
{ id: '/a.md', path: '/a.md', title: 'a.md', dirty: false },
{ id: '/b.md', path: '/b.md', title: 'b.md', dirty: false },
],
activeTabId: '/b.md',
});
render(<Harness />);
act(() => useCommandStore.getState().dispatch('tab.next'));
expect(useFileStore.getState().activeTabId).toBe('/a.md');
});
it('tab.prev wraps from first tab to last', () => {
useFileStore.setState({
openTabs: [
{ id: '/a.md', path: '/a.md', title: 'a.md', dirty: false },
{ id: '/b.md', path: '/b.md', title: 'b.md', dirty: false },
],
activeTabId: '/a.md',
});
render(<Harness />);
act(() => useCommandStore.getState().dispatch('tab.prev'));
expect(useFileStore.getState().activeTabId).toBe('/b.md');
});
it('file-save IPC event dispatches file.save command', () => {
render(<Harness />);
act(() => fireMenu('file-save'));
// No assertion on side effect (saveActiveBuffer is async) but
// we can verify the command ran by checking no error was thrown.
expect(true).toBe(true);
});
it('toggle-preview IPC event flips previewVisible via the command', () => {
render(<Harness />);
expect(useAppStore.getState().previewVisible).toBe(true);
act(() => fireMenu('toggle-preview'));
expect(useAppStore.getState().previewVisible).toBe(false);
});
it('load-template-menu IPC event forwards the template name as args', () => {
let captured: unknown;
render(<Harness />);
// Re-register after the harness so our capturing handler is the live one.
useCommandStore.getState().register('template.load', (args) => {
captured = args;
});
act(() => fireMenu('load-template-menu', 'blog-post.md'));
expect(captured).toBe('blog-post.md');
});
});