feat: enhance WelcomeDialog, export dialogs, GitStatusPanel, add CommandPalette, FindReplaceBar, and new converter dialogs

- WelcomeDialog: add quick start, feature showcase, keyboard shortcuts, recent files, version display
- ExportDocxDialog/ExportHtmlDialog/ExportPdfDialog: add header/footer, paper size, margin controls
- GitStatusPanel: full staging/unstaging, discard, commit workflow
- CommandPalette: fuzzy command search with keyboard navigation
- FindReplaceBar: find/replace with regex, case, whole-word options
- New dialogs: BatchMediaConverterDialog, HeaderFooterDialog, PdfEditorDialog, UniversalConverterDialog
- Tests: comprehensive coverage for all new/updated components
This commit is contained in:
2026-06-13 19:34:42 +05:30
parent 6b564a4569
commit f2398e6e1a
52 changed files with 4309 additions and 250 deletions
@@ -0,0 +1,113 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { BatchMediaConverterDialog } from '@/components/modals/BatchMediaConverterDialog';
import { useAppStore } from '@/stores/app-store';
import { useSettingsStore } from '@/stores/settings-store';
const mockPickFolder = vi.fn().mockResolvedValue('/some/folder');
const mockOn = vi.fn().mockReturnValue(() => {});
const mockConvertBatch = vi.fn().mockResolvedValue(undefined);
vi.mock('@/lib/ipc', () => ({
ipc: { file: { pickFolder: mockPickFolder } },
}));
beforeEach(() => {
localStorage.clear();
vi.clearAllMocks();
mockPickFolder.mockClear();
useSettingsStore.setState(useSettingsStore.getInitialState());
useAppStore.setState({ modal: { kind: null } } as any);
(window.electronAPI as any) = {
file: {
pickFolder: mockPickFolder,
},
on: mockOn,
converter: { convertBatch: mockConvertBatch },
};
});
describe('BatchMediaConverterDialog', () => {
it('renders with ImageMagick and FFmpeg tabs', () => {
render(<BatchMediaConverterDialog />);
expect(screen.getByText(/batch media converter/i)).toBeInTheDocument();
expect(screen.getByRole('tab', { name: 'ImageMagick' })).toBeInTheDocument();
expect(screen.getByRole('tab', { name: 'FFmpeg' })).toBeInTheDocument();
});
it('shows format dropdowns for the active tool', () => {
render(<BatchMediaConverterDialog />);
expect(screen.getByRole('combobox', { name: /source format/i })).toBeInTheDocument();
expect(screen.getByRole('combobox', { name: /target format/i })).toBeInTheDocument();
});
it('validates both formats are selected', async () => {
render(<BatchMediaConverterDialog />);
await userEvent.click(screen.getByRole('button', { name: /^convert$/i }));
expect(screen.getByText(/select both source and target formats/i)).toBeInTheDocument();
});
it('validates folders are selected', async () => {
render(<BatchMediaConverterDialog />);
await userEvent.click(screen.getByRole('combobox', { name: /source format/i }));
await userEvent.click(screen.getByRole('option', { name: 'PNG' }));
await userEvent.click(screen.getByRole('combobox', { name: /target format/i }));
await userEvent.click(screen.getByRole('option', { name: 'JPEG' }));
await userEvent.click(screen.getByRole('button', { name: /^convert$/i }));
expect(screen.getByText(/select both input and output folders/i)).toBeInTheDocument();
});
it('browse buttons set folder paths', async () => {
render(<BatchMediaConverterDialog />);
const browseButtons = screen.getAllByRole('button', { name: /^browse$/i });
await userEvent.click(browseButtons[0]);
await waitFor(() => expect(screen.getByLabelText(/input folder/i)).toHaveValue('/some/folder'));
await userEvent.click(browseButtons[1]);
await waitFor(() =>
expect(screen.getByLabelText(/output folder/i)).toHaveValue('/some/folder')
);
});
it('switches to FFmpeg tab and resets formats', async () => {
render(<BatchMediaConverterDialog />);
await userEvent.click(screen.getByRole('combobox', { name: /source format/i }));
await userEvent.click(screen.getByRole('option', { name: 'PNG' }));
await userEvent.click(screen.getByRole('tab', { name: 'FFmpeg' }));
expect(screen.getByRole('tab', { name: 'FFmpeg' })).toHaveAttribute('data-state', 'active');
});
it('shows include subdirectories toggle', () => {
render(<BatchMediaConverterDialog />);
expect(screen.getByRole('switch', { name: /include subdirectories/i })).toBeInTheDocument();
});
it('subscribes to batch-progress and conversion-complete IPC events', () => {
render(<BatchMediaConverterDialog />);
expect(mockOn).toHaveBeenCalledWith('batch-progress', expect.any(Function));
expect(mockOn).toHaveBeenCalledWith('conversion-complete', expect.any(Function));
});
it('submits with includeSubfolders when enabled', async () => {
render(<BatchMediaConverterDialog />);
await userEvent.click(screen.getByRole('combobox', { name: /source format/i }));
await userEvent.click(screen.getByRole('option', { name: 'PNG' }));
await userEvent.click(screen.getByRole('combobox', { name: /target format/i }));
await userEvent.click(screen.getByRole('option', { name: 'JPEG' }));
const browseButtons = screen.getAllByRole('button', { name: /^browse$/i });
await userEvent.click(browseButtons[0]);
await waitFor(() => expect(screen.getByLabelText(/input folder/i)).toHaveValue('/some/folder'));
await userEvent.click(browseButtons[1]);
await waitFor(() =>
expect(screen.getByLabelText(/output folder/i)).toHaveValue('/some/folder')
);
await userEvent.click(screen.getByRole('switch', { name: /include subdirectories/i }));
await userEvent.click(screen.getByRole('button', { name: /^convert$/i }));
await waitFor(() => expect(mockConvertBatch).toHaveBeenCalledTimes(1));
const callArg = mockConvertBatch.mock.calls[0][0];
expect(callArg.tool).toBe('imagemagick');
expect(callArg.fromFormat).toBe('png');
expect(callArg.toFormat).toBe('jpg');
expect(callArg.includeSubfolders).toBe(true);
});
});
@@ -0,0 +1,90 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { CommandPalette } from '@/components/modals/CommandPalette';
import { useCommandStore } from '@/stores/command-store';
beforeEach(() => {
localStorage.clear();
useCommandStore.setState({ handlers: {}, userBindings: {} } as any);
useCommandStore.getState().register('file.open', vi.fn());
useCommandStore.getState().register('file.save', vi.fn());
useCommandStore.getState().register('file.new', vi.fn());
useCommandStore.getState().register('file.exportPdf', vi.fn());
useCommandStore.getState().register('settings.open', vi.fn());
});
describe('CommandPalette', () => {
it('does not render when closed', () => {
render(<CommandPalette />);
expect(screen.queryByPlaceholderText(/type a command/i)).not.toBeInTheDocument();
});
it('opens on Ctrl+Shift+P', async () => {
render(<CommandPalette />);
await userEvent.keyboard('{Control>}{Shift>}{p}{/Shift}{/Control}');
expect(screen.getByPlaceholderText(/type a command/i)).toBeInTheDocument();
});
it('closes on Escape', async () => {
render(<CommandPalette />);
await userEvent.keyboard('{Control>}{Shift>}{p}{/Shift}{/Control}');
await waitFor(() => expect(screen.getByPlaceholderText(/type a command/i)).toBeInTheDocument());
const input = screen.getByPlaceholderText(/type a command/i);
await userEvent.type(input, '{Escape}');
expect(screen.queryByPlaceholderText(/type a command/i)).not.toBeInTheDocument();
});
it('filters commands based on query', async () => {
render(<CommandPalette />);
await userEvent.keyboard('{Control>}{Shift>}{p}{/Shift}{/Control}');
const input = screen.getByPlaceholderText(/type a command/i);
await userEvent.type(input, 'open');
const items = screen.getAllByRole('option');
expect(items.length).toBeGreaterThan(0);
items.forEach((item) => {
expect(item.textContent?.toLowerCase()).toContain('open');
});
});
it('navigates with arrow keys', async () => {
render(<CommandPalette />);
await userEvent.keyboard('{Control>}{Shift>}{p}{/Shift}{/Control}');
const input = screen.getByPlaceholderText(/type a command/i);
await userEvent.type(input, '{ArrowDown}{ArrowDown}');
const items = screen.getAllByRole('option');
const selected = items.find((el) => el.getAttribute('aria-selected') === 'true');
expect(selected).toBe(items[2]);
});
it('executes command on Enter', async () => {
const handler = vi.fn();
useCommandStore.getState().register('test.command', handler);
render(<CommandPalette />);
await userEvent.keyboard('{Control>}{Shift>}{p}{/Shift}{/Control}');
const input = screen.getByPlaceholderText(/type a command/i);
await userEvent.type(input, 'test');
await userEvent.keyboard('{Enter}');
expect(handler).toHaveBeenCalled();
});
it('shows no results message for non-matching query', async () => {
render(<CommandPalette />);
await userEvent.keyboard('{Control>}{Shift>}{p}{/Shift}{/Control}');
const input = screen.getByPlaceholderText(/type a command/i);
await userEvent.type(input, 'zzznonexistent');
expect(screen.getByText(/no matching commands/i)).toBeInTheDocument();
});
it('limits results to 8', async () => {
for (let i = 0; i < 20; i++) {
useCommandStore.getState().register(`test.command.${i}`, vi.fn());
}
render(<CommandPalette />);
await userEvent.keyboard('{Control>}{Shift>}{p}{/Shift}{/Control}');
await waitFor(() => {
const items = screen.queryAllByRole('option');
expect(items.length).toBeLessThanOrEqual(8);
});
});
});
@@ -8,12 +8,10 @@ describe('ExportBatchDialog', () => {
localStorage.clear();
window.electronAPI = {
export: {
batch: vi
.fn()
.mockResolvedValue({
ok: true,
data: { total: 2, succeeded: 2, failed: 0, results: [] },
}),
batch: vi.fn().mockResolvedValue({
ok: true,
data: { total: 2, succeeded: 2, failed: 0, results: [] },
}),
},
} as any;
});
@@ -14,46 +14,65 @@ vi.mock('@/lib/ipc', () => ({
}));
vi.mock('@/lib/docx-export', () => ({
generateDocx: vi
.fn()
.mockResolvedValue(
new Blob([new Uint8Array(8)], {
type: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
})
),
generateDocx: vi.fn().mockResolvedValue(
new Blob([new Uint8Array(8)], {
type: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
})
),
}));
import { ipc } from '@/lib/ipc';
describe('ExportDocxDialog', () => {
beforeEach(() => {
localStorage.clear();
vi.clearAllMocks();
// The dialog passes the source path with .docx extension as the default
// path, and the test mock returns that same path (echoing the default).
(ipc.app.showSaveDialog as any).mockImplementation(async (args) => ({
ok: true,
data: args?.defaultPath ?? '/out.docx',
}));
(ipc.file.writeBuffer as any).mockResolvedValue({ ok: true });
useSettingsStore.setState(useSettingsStore.getInitialState());
useFileStore.setState({
activeTabId: '/test.md',
openTabs: [{ id: '/test.md', path: '/test.md', title: 'test.md', dirty: false }],
} as any);
useEditorStore.setState({
buffers: new Map([
['/test.md', { id: '/test.md', path: '/test.md', content: '# hi', dirty: false }],
]),
} as any);
});
beforeEach(() => {
localStorage.clear();
vi.clearAllMocks();
(window.electronAPI as any) = {};
(ipc.app.showSaveDialog as any).mockImplementation(async (args) => ({
ok: true,
data: args?.defaultPath ?? '/out.docx',
}));
(ipc.file.writeBuffer as any).mockResolvedValue({ ok: true });
useSettingsStore.setState(useSettingsStore.getInitialState());
useFileStore.setState({
activeTabId: '/test.md',
openTabs: [{ id: '/test.md', path: '/test.md', title: 'test.md', dirty: false }],
} as any);
useEditorStore.setState({
buffers: new Map([
['/test.md', { id: '/test.md', path: '/test.md', content: '# hi', dirty: false }],
]),
} as any);
});
describe('ExportDocxDialog', () => {
it('renders with standard template selected by default', () => {
render(<ExportDocxDialog sourcePath="/test.md" />);
expect(screen.getByText(/export to docx/i)).toBeInTheDocument();
expect(screen.getByRole('combobox', { name: /template/i })).toBeInTheDocument();
});
it('shows reference doc input', () => {
render(<ExportDocxDialog sourcePath="/test.md" />);
expect(screen.getByLabelText(/reference document path/i)).toBeInTheDocument();
});
it('shows TOC toggle and number sections toggle', () => {
render(<ExportDocxDialog sourcePath="/test.md" />);
expect(screen.getByRole('switch', { name: /table of contents/i })).toBeInTheDocument();
expect(screen.getByRole('switch', { name: /number sections/i })).toBeInTheDocument();
});
it('shows TOC depth when TOC is enabled', async () => {
render(<ExportDocxDialog sourcePath="/test.md" />);
await userEvent.click(screen.getByRole('switch', { name: /table of contents/i }));
expect(screen.getByLabelText(/toc depth/i)).toBeInTheDocument();
});
it('shows bibliography input', () => {
render(<ExportDocxDialog sourcePath="/test.md" />);
expect(screen.getByLabelText(/bibliography file path/i)).toBeInTheDocument();
});
it('submitting with default options writes a docx buffer', async () => {
render(<ExportDocxDialog sourcePath="/test.md" />);
await userEvent.click(screen.getByRole('button', { name: /^export$/i }));
@@ -71,4 +90,16 @@ describe('ExportDocxDialog', () => {
await userEvent.click(screen.getByRole('option', { name: /modern/i }));
expect(screen.getByRole('combobox', { name: /template/i })).toHaveTextContent(/modern/i);
});
it('sends options via electronAPI.export.withOptions when available', async () => {
const mockWithOptions = vi.fn().mockResolvedValue({ ok: true });
(window.electronAPI as any) = {
export: { withOptions: mockWithOptions },
};
render(<ExportDocxDialog sourcePath="/test.md" />);
await userEvent.click(screen.getByRole('button', { name: /^export$/i }));
await waitFor(() => expect(mockWithOptions).toHaveBeenCalledWith('docx', expect.any(Object)));
expect(mockWithOptions.mock.calls[0][1].toc).toBe(false);
expect(mockWithOptions.mock.calls[0][1].numberSections).toBe(false);
});
});
@@ -15,27 +15,28 @@ vi.mock('@/lib/ipc', () => ({
import { ipc } from '@/lib/ipc';
describe('ExportHtmlDialog', () => {
beforeEach(() => {
localStorage.clear();
vi.clearAllMocks();
(ipc.app.showSaveDialog as any).mockImplementation(async (args) => ({
ok: true,
data: args?.defaultPath ?? '/out.html',
}));
(ipc.file.writeBuffer as any).mockResolvedValue({ ok: true });
useSettingsStore.setState(useSettingsStore.getInitialState());
useFileStore.setState({
activeTabId: '/test.md',
openTabs: [{ id: '/test.md', path: '/test.md', title: 'test.md', dirty: false }],
} as any);
useEditorStore.setState({
buffers: new Map([
['/test.md', { id: '/test.md', path: '/test.md', content: '# hi', dirty: false }],
]),
} as any);
});
beforeEach(() => {
localStorage.clear();
vi.clearAllMocks();
(window.electronAPI as any) = {};
(ipc.app.showSaveDialog as any).mockImplementation(async (args) => ({
ok: true,
data: args?.defaultPath ?? '/out.html',
}));
(ipc.file.writeBuffer as any).mockResolvedValue({ ok: true });
useSettingsStore.setState(useSettingsStore.getInitialState());
useFileStore.setState({
activeTabId: '/test.md',
openTabs: [{ id: '/test.md', path: '/test.md', title: 'test.md', dirty: false }],
} as any);
useEditorStore.setState({
buffers: new Map([
['/test.md', { id: '/test.md', path: '/test.md', content: '# hi', dirty: false }],
]),
} as any);
});
describe('ExportHtmlDialog', () => {
it('renders with default github highlight style', () => {
render(<ExportHtmlDialog sourcePath="/test.md" />);
expect(screen.getByText(/export to html/i)).toBeInTheDocument();
@@ -49,9 +50,41 @@ describe('ExportHtmlDialog', () => {
expect(ipc.file.writeBuffer).toHaveBeenCalledTimes(1);
const callArg = (ipc.file.writeBuffer as any).mock.calls[0][0];
expect(callArg.path).toBe('/test.html');
// The buffer is a Uint8Array; in JSDOM cross-realm the prototype check
// is unreliable, so verify shape instead.
expect(callArg.buffer).toBeDefined();
expect(callArg.buffer.length).toBeGreaterThan(0);
});
it('shows self-contained toggle', () => {
render(<ExportHtmlDialog sourcePath="/test.md" />);
expect(screen.getByRole('switch', { name: /self-contained/i })).toBeInTheDocument();
});
it('shows TOC toggle and number sections toggle', () => {
render(<ExportHtmlDialog sourcePath="/test.md" />);
expect(screen.getByRole('switch', { name: /table of contents/i })).toBeInTheDocument();
expect(screen.getByRole('switch', { name: /number sections/i })).toBeInTheDocument();
});
it('shows TOC depth when TOC is enabled', async () => {
render(<ExportHtmlDialog sourcePath="/test.md" />);
await userEvent.click(screen.getByRole('switch', { name: /table of contents/i }));
expect(screen.getByLabelText(/toc depth/i)).toBeInTheDocument();
});
it('shows custom CSS input', () => {
render(<ExportHtmlDialog sourcePath="/test.md" />);
expect(screen.getByLabelText(/custom css file path/i)).toBeInTheDocument();
});
it('sends options via electronAPI.export.withOptions when available', async () => {
const mockWithOptions = vi.fn().mockResolvedValue({ ok: true });
(window.electronAPI as any) = {
export: { withOptions: mockWithOptions },
};
render(<ExportHtmlDialog sourcePath="/test.md" />);
await userEvent.click(screen.getByRole('button', { name: /^export$/i }));
await waitFor(() => expect(mockWithOptions).toHaveBeenCalledWith('html', expect.any(Object)));
expect(mockWithOptions.mock.calls[0][1].standalone).toBe(true);
expect(mockWithOptions.mock.calls[0][1].selfContained).toBe(false);
});
});
+69 -20
View File
@@ -17,28 +17,63 @@ vi.mock('@/lib/ipc', () => ({
import { ipc } from '@/lib/ipc';
describe('ExportPdfDialog', () => {
beforeEach(() => {
localStorage.clear();
vi.clearAllMocks();
useSettingsStore.setState(useSettingsStore.getInitialState());
useFileStore.setState({
activeTabId: '/test.md',
openTabs: [{ id: '/test.md', path: '/test.md', title: 'test.md', dirty: false }],
} as any);
useEditorStore.setState({
buffers: new Map([
['/test.md', { id: '/test.md', path: '/test.md', content: '# hi', dirty: false }],
]),
} as any);
});
beforeEach(() => {
localStorage.clear();
vi.clearAllMocks();
delete (window as any).electronAPI;
useSettingsStore.setState(useSettingsStore.getInitialState());
useFileStore.setState({
activeTabId: '/test.md',
openTabs: [{ id: '/test.md', path: '/test.md', title: 'test.md', dirty: false }],
} as any);
useEditorStore.setState({
buffers: new Map([
['/test.md', { id: '/test.md', path: '/test.md', content: '# hi', dirty: false }],
]),
} as any);
});
describe('ExportPdfDialog', () => {
it('renders with default PDF options from settings', () => {
render(<ExportPdfDialog sourcePath="/test.md" />);
expect(screen.getByText(/export to pdf/i)).toBeInTheDocument();
expect(screen.getByRole('combobox', { name: /format/i })).toBeInTheDocument();
});
it('shows PDF engine selector', () => {
render(<ExportPdfDialog sourcePath="/test.md" />);
expect(screen.getByRole('combobox', { name: /pdf engine/i })).toBeInTheDocument();
});
it('shows TOC toggle and number sections toggle', () => {
render(<ExportPdfDialog sourcePath="/test.md" />);
expect(screen.getByRole('switch', { name: /table of contents/i })).toBeInTheDocument();
expect(screen.getByRole('switch', { name: /number sections/i })).toBeInTheDocument();
});
it('shows TOC depth input when TOC is enabled', async () => {
render(<ExportPdfDialog sourcePath="/test.md" />);
await userEvent.click(screen.getByRole('switch', { name: /table of contents/i }));
expect(screen.getByLabelText(/toc depth/i)).toBeInTheDocument();
});
it('shows page geometry dropdown', () => {
render(<ExportPdfDialog sourcePath="/test.md" />);
expect(screen.getByRole('combobox', { name: /page geometry/i })).toBeInTheDocument();
});
it('shows bibliography and font inputs', () => {
render(<ExportPdfDialog sourcePath="/test.md" />);
expect(screen.getByLabelText(/bibliography file path/i)).toBeInTheDocument();
expect(screen.getByLabelText(/main font/i)).toBeInTheDocument();
expect(screen.getByLabelText(/cjk font/i)).toBeInTheDocument();
});
it('shows highlight style selector', () => {
render(<ExportPdfDialog sourcePath="/test.md" />);
expect(screen.getByRole('combobox', { name: /highlight style/i })).toBeInTheDocument();
});
it('toggles ASCII tables and submits via ipc.print.show', async () => {
render(<ExportPdfDialog sourcePath="/test.md" />);
await userEvent.click(screen.getByRole('checkbox', { name: /ascii/i }));
@@ -46,16 +81,30 @@ describe('ExportPdfDialog', () => {
await waitFor(() => expect(ipc.print.show).toHaveBeenCalledTimes(1));
const [arg] = (ipc.print.show as any).mock.calls[0];
expect(arg.html).toContain('<!DOCTYPE html>');
// The Markdown source is rendered to HTML inside the document body.
expect(arg.html).toContain('<h1>hi</h1>');
// @page CSS for size + margins must be present
expect(arg.html).toMatch(/@page\s*\{\s*size:\s*210mm\s+297mm/);
});
it('renders an error banner when IPC fails', async () => {
(ipc.print.show as any).mockRejectedValueOnce(new Error('Pandoc not found'));
it('uses electronAPI.export.withOptions when available', async () => {
const mockWithOptions = vi.fn().mockResolvedValue({ ok: true });
(window.electronAPI as any) = {
export: { withOptions: mockWithOptions },
};
render(<ExportPdfDialog sourcePath="/test.md" />);
await userEvent.click(screen.getByRole('button', { name: /^export$/i }));
expect(await screen.findByText(/pandoc not found/i)).toBeInTheDocument();
await waitFor(() => expect(mockWithOptions).toHaveBeenCalledWith('pdf', expect.any(Object)));
expect(mockWithOptions.mock.calls[0][1].engine).toBe('pdflatex');
expect(mockWithOptions.mock.calls[0][1].toc).toBe(false);
expect(mockWithOptions.mock.calls[0][1].highlightStyle).toBe('tango');
});
it('renders an error banner when IPC fails', async () => {
(window as any).electronAPI = undefined;
(ipc.print.show as any).mockReturnValueOnce({ ok: false, error: { message: 'Pandoc not found' } });
render(<ExportPdfDialog sourcePath="/test.md" />);
await userEvent.click(screen.getByRole('button', { name: /^export$/i }));
await waitFor(() => {
expect(screen.getByRole('alert')).toHaveTextContent(/pandoc not found/i);
});
});
});
@@ -0,0 +1,91 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { HeaderFooterDialog } from '@/components/modals/HeaderFooterDialog';
import { useAppStore } from '@/stores/app-store';
import { useSettingsStore } from '@/stores/settings-store';
const defaultSettings = {
headerEnabled: false,
footerEnabled: false,
headerLeft: '',
headerCenter: '',
headerRight: '',
footerLeft: '',
footerCenter: '',
footerRight: '',
logoPosition: 'none',
logoPath: null,
};
const mockGetSettings = vi.fn().mockResolvedValue(defaultSettings);
const mockSaveSettings = vi.fn().mockResolvedValue(undefined);
const mockBrowseLogo = vi.fn().mockResolvedValue('/path/to/logo.png');
beforeEach(() => {
localStorage.clear();
vi.clearAllMocks();
useSettingsStore.setState(useSettingsStore.getInitialState());
useAppStore.setState({ modal: { kind: null } } as any);
(window.electronAPI as any) = {
headerFooter: {
getSettings: mockGetSettings,
saveSettings: mockSaveSettings,
browseLogo: mockBrowseLogo,
},
};
});
describe('HeaderFooterDialog', () => {
it('loads settings from electronAPI on mount', async () => {
render(<HeaderFooterDialog />);
await waitFor(() => expect(mockGetSettings).toHaveBeenCalled());
expect(screen.getByText(/header & footer/i)).toBeInTheDocument();
});
it('toggles header enabled checkbox', async () => {
render(<HeaderFooterDialog />);
await waitFor(() => expect(screen.getByText(/header & footer/i)).toBeInTheDocument());
await userEvent.click(screen.getByRole('checkbox', { name: /enable header/i }));
expect(screen.getByRole('checkbox', { name: /enable header/i })).toBeChecked();
});
it('toggles footer enabled checkbox', async () => {
render(<HeaderFooterDialog />);
await waitFor(() => expect(screen.getByText(/header & footer/i)).toBeInTheDocument());
await userEvent.click(screen.getByRole('checkbox', { name: /enable footer/i }));
expect(screen.getByRole('checkbox', { name: /enable footer/i })).toBeChecked();
});
it('saves settings via electronAPI on Save', async () => {
render(<HeaderFooterDialog />);
await waitFor(() => expect(screen.getByText(/header & footer/i)).toBeInTheDocument());
await userEvent.click(screen.getByRole('button', { name: /^save$/i }));
await waitFor(() => expect(mockSaveSettings).toHaveBeenCalled());
});
it('closes modal on Cancel', async () => {
render(<HeaderFooterDialog />);
await waitFor(() => expect(screen.getByText(/header & footer/i)).toBeInTheDocument());
await userEvent.click(screen.getByRole('button', { name: /^cancel$/i }));
expect(useAppStore.getState().modal).toEqual({ kind: null });
});
it('inserts dynamic field token into header input', async () => {
mockGetSettings.mockResolvedValue({ ...defaultSettings, headerEnabled: true });
render(<HeaderFooterDialog />);
await waitFor(() => expect(screen.getByText(/header & footer/i)).toBeInTheDocument());
const tokenButton = screen.getAllByTitle('Page')[0];
await userEvent.click(tokenButton);
expect(screen.getByLabelText(/header left/i)).toHaveValue('$PAGE$');
});
it('shows logo browse when logo position is set to left', async () => {
render(<HeaderFooterDialog />);
await waitFor(() => expect(screen.getByText(/header & footer/i)).toBeInTheDocument());
const logoSelect = screen.getByRole('combobox', { name: /logo position/i });
await userEvent.click(logoSelect);
await userEvent.click(screen.getByRole('option', { name: 'Left' }));
expect(screen.getByRole('button', { name: /^browse$/i })).toBeInTheDocument();
});
});
@@ -29,4 +29,10 @@ describe('ModalLayer', () => {
expect(screen.queryByText(/about markdownconverter/i)).not.toBeInTheDocument();
expect(screen.getByText(/settings/i)).toBeInTheDocument();
});
it('renders BatchMediaConverterDialog when kind is "batch-media-converter"', () => {
useAppStore.getState().openModal('batch-media-converter');
render(<ModalLayer />);
expect(screen.getByText(/batch media converter/i)).toBeInTheDocument();
});
});
@@ -0,0 +1,297 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { render, screen, waitFor, fireEvent } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { PdfEditorDialog } from '@/components/modals/PdfEditorDialog';
const mockProcessOperation = vi.fn().mockResolvedValue(undefined);
const mockPickFile = vi.fn();
const mockPickFolder = vi.fn();
const mockShowSaveDialog = vi.fn();
beforeEach(() => {
localStorage.clear();
vi.clearAllMocks();
mockPickFile.mockResolvedValue({ ok: true, data: null });
mockPickFolder.mockResolvedValue({ ok: true, data: null });
mockShowSaveDialog.mockResolvedValue({ ok: true, data: null });
window.electronAPI = {
file: {
pickFile: mockPickFile,
pickFolder: mockPickFolder,
},
app: {
showSaveDialog: mockShowSaveDialog,
},
pdf: {
processOperation: mockProcessOperation,
},
} as any;
});
function dispatchComplete(success: boolean, error?: string) {
window.dispatchEvent(
new CustomEvent('pdf-operation-complete', {
detail: { success, error, message: success ? 'Done' : error },
})
);
}
describe('PdfEditorDialog', () => {
it('renders all operation tabs', () => {
render(<PdfEditorDialog onClose={() => {}} />);
expect(screen.getByText('Merge')).toBeInTheDocument();
expect(screen.getByText('Split')).toBeInTheDocument();
expect(screen.getByText('Compress')).toBeInTheDocument();
expect(screen.getByText('Rotate')).toBeInTheDocument();
expect(screen.getByText('Delete')).toBeInTheDocument();
expect(screen.getByText('Reorder')).toBeInTheDocument();
expect(screen.getByText('Watermark')).toBeInTheDocument();
expect(screen.getByText('Encrypt')).toBeInTheDocument();
expect(screen.getByText('Decrypt')).toBeInTheDocument();
expect(screen.getByText('Permissions')).toBeInTheDocument();
});
it('defaults to merge tab and shows file list', () => {
render(<PdfEditorDialog onClose={() => {}} />);
expect(screen.getByText('PDF files')).toBeInTheDocument();
expect(screen.getByText('Output path')).toBeInTheDocument();
});
it('pre-fills input path from initialFilePath', async () => {
render(<PdfEditorDialog onClose={() => {}} initialFilePath="/test.pdf" />);
await userEvent.click(screen.getByText('Split'));
await waitFor(() => {
const inputs = screen.getAllByRole('textbox');
const match = inputs.find((el) => (el as HTMLInputElement).value === '/test.pdf');
expect(match).toBeTruthy();
});
});
it('validates merge requires at least 2 files', async () => {
render(<PdfEditorDialog onClose={() => {}} />);
await userEvent.click(screen.getByRole('button', { name: /^apply$/i }));
expect(screen.getByText(/add at least 2 pdf files/i)).toBeInTheDocument();
});
it('validates input path for operations that need it', async () => {
render(<PdfEditorDialog onClose={() => {}} />);
await userEvent.click(screen.getByText('Compress'));
await userEvent.click(screen.getByRole('button', { name: /^apply$/i }));
expect(screen.getByText(/select an input pdf/i)).toBeInTheDocument();
});
it('validates watermark text is required', async () => {
render(<PdfEditorDialog onClose={() => {}} initialFilePath="/test.pdf" />);
mockShowSaveDialog.mockResolvedValue({ ok: true, data: '/out.pdf' });
await userEvent.click(screen.getByText('Watermark'));
const outputInputs = screen.getAllByPlaceholderText('output.pdf');
await userEvent.type(outputInputs[0], '/out.pdf');
await userEvent.click(screen.getByRole('button', { name: /^apply$/i }));
await waitFor(() => expect(screen.getByText(/enter watermark text/i)).toBeInTheDocument());
});
it('validates decrypt password is required', async () => {
render(<PdfEditorDialog onClose={() => {}} initialFilePath="/test.pdf" />);
mockShowSaveDialog.mockResolvedValue({ ok: true, data: '/out.pdf' });
await userEvent.click(screen.getByText('Decrypt'));
const outputInputs = screen.getAllByPlaceholderText('output.pdf');
await userEvent.type(outputInputs[0], '/out.pdf');
await userEvent.click(screen.getByRole('button', { name: /^apply$/i }));
await waitFor(() => expect(screen.getByText(/enter the password/i)).toBeInTheDocument());
});
it('calls onClose on cancel click', () => {
const onClose = vi.fn();
render(<PdfEditorDialog onClose={onClose} />);
const cancelBtn = screen.getAllByRole('button').find((b) => b.textContent === 'Cancel');
expect(cancelBtn).toBeDefined();
fireEvent.click(cancelBtn!);
expect(onClose).toHaveBeenCalled();
});
it('merge operation sends correct payload and shows success', async () => {
const onClose = vi.fn();
render(<PdfEditorDialog onClose={onClose} />);
mockPickFile
.mockResolvedValueOnce({ ok: true, data: '/a.pdf' })
.mockResolvedValueOnce({ ok: true, data: '/b.pdf' });
await userEvent.click(screen.getByRole('button', { name: /add file/i }));
await userEvent.click(screen.getByRole('button', { name: /add file/i }));
expect(screen.getByText('/a.pdf')).toBeInTheDocument();
expect(screen.getByText('/b.pdf')).toBeInTheDocument();
const outputInput = screen.getByPlaceholderText('output.pdf');
await userEvent.type(outputInput, '/merged.pdf');
await userEvent.click(screen.getByRole('button', { name: /^apply$/i }));
expect(mockProcessOperation).toHaveBeenCalledWith(
expect.objectContaining({
operation: 'merge',
inputPaths: ['/a.pdf', '/b.pdf'],
outputPath: '/merged.pdf',
})
);
dispatchComplete(true);
await waitFor(() => expect(onClose).toHaveBeenCalled());
});
it('rotate operation sends angle and pages', async () => {
const onClose = vi.fn();
render(<PdfEditorDialog onClose={onClose} initialFilePath="/in.pdf" />);
await userEvent.click(screen.getByText('Rotate'));
const pagesInput = screen.getAllByPlaceholderText('All pages')[0];
await userEvent.type(pagesInput, '1,3-5');
const outputInputs = screen.getAllByPlaceholderText('output.pdf');
await userEvent.type(outputInputs[0], '/out.pdf');
await userEvent.click(screen.getByRole('button', { name: /^apply$/i }));
expect(mockProcessOperation).toHaveBeenCalledWith(
expect.objectContaining({
operation: 'rotate',
inputPath: '/in.pdf',
pages: '1,3-5',
angle: 90,
})
);
dispatchComplete(true);
await waitFor(() => expect(onClose).toHaveBeenCalled());
});
it('delete operation sends pages', async () => {
const onClose = vi.fn();
render(<PdfEditorDialog onClose={onClose} initialFilePath="/in.pdf" />);
await userEvent.click(screen.getByText('Delete'));
const pagesInput = screen.getByPlaceholderText('1,3,5-8');
await userEvent.type(pagesInput, '2,4');
const outputInputs = screen.getAllByPlaceholderText('output.pdf');
await userEvent.type(outputInputs[0], '/out.pdf');
await userEvent.click(screen.getByRole('button', { name: /^apply$/i }));
expect(mockProcessOperation).toHaveBeenCalledWith(
expect.objectContaining({ operation: 'delete', inputPath: '/in.pdf', pages: '2,4' })
);
dispatchComplete(true);
await waitFor(() => expect(onClose).toHaveBeenCalled());
});
it('encrypt operation sends passwords and permissions', async () => {
const onClose = vi.fn();
render(<PdfEditorDialog onClose={onClose} initialFilePath="/in.pdf" />);
await userEvent.click(screen.getByText('Encrypt'));
await userEvent.type(screen.getByPlaceholderText('Password to open'), 'user123');
await userEvent.type(screen.getByPlaceholderText('Password for permissions'), 'owner123');
await userEvent.click(screen.getByRole('checkbox', { name: /printing/i }));
await userEvent.click(screen.getByRole('checkbox', { name: /copying/i }));
const outputInputs = screen.getAllByPlaceholderText('output.pdf');
await userEvent.type(outputInputs[0], '/encrypted.pdf');
await userEvent.click(screen.getByRole('button', { name: /^apply$/i }));
expect(mockProcessOperation).toHaveBeenCalledWith(
expect.objectContaining({
operation: 'encrypt',
inputPath: '/in.pdf',
userPassword: 'user123',
ownerPassword: 'owner123',
permissions: { printing: true, copying: true },
})
);
dispatchComplete(true);
await waitFor(() => expect(onClose).toHaveBeenCalled());
});
it('split with interval mode sends correct payload', async () => {
const onClose = vi.fn();
render(<PdfEditorDialog onClose={onClose} initialFilePath="/in.pdf" />);
mockPickFolder.mockResolvedValue({ ok: true, data: '/splits' });
await userEvent.click(screen.getByText('Split'));
const folderInputs = screen.getAllByPlaceholderText('Select output folder');
await userEvent.type(folderInputs[0], '/splits');
await userEvent.click(screen.getByRole('combobox'));
await userEvent.click(screen.getByRole('option', { name: /every n pages/i }));
const intervalInputs = screen.getAllByPlaceholderText('5');
if (intervalInputs.length > 0) {
await userEvent.clear(intervalInputs[0]);
await userEvent.type(intervalInputs[0], '10');
}
await userEvent.click(screen.getByRole('button', { name: /^apply$/i }));
expect(mockProcessOperation).toHaveBeenCalledWith(
expect.objectContaining({
operation: 'split',
inputPath: '/in.pdf',
outputFolder: '/splits',
mode: 'interval',
interval: 10,
})
);
dispatchComplete(true);
await waitFor(() => expect(onClose).toHaveBeenCalled());
});
it('shows error toast on operation failure', async () => {
render(<PdfEditorDialog onClose={() => {}} initialFilePath="/in.pdf" />);
await userEvent.click(screen.getByText('Compress'));
const outputInputs = screen.getAllByPlaceholderText('output.pdf');
await userEvent.type(outputInputs[0], '/out.pdf');
await userEvent.click(screen.getByRole('button', { name: /^apply$/i }));
dispatchComplete(false, 'Compression failed');
await waitFor(() => expect(screen.getByText(/compression failed/i)).toBeInTheDocument());
});
it('shows error when IPC throws', async () => {
mockProcessOperation.mockRejectedValueOnce(new Error('IPC channel missing'));
const onClose = vi.fn();
render(<PdfEditorDialog onClose={onClose} initialFilePath="/in.pdf" />);
await userEvent.click(screen.getByText('Compress'));
const outputInputs = screen.getAllByPlaceholderText('output.pdf');
await userEvent.type(outputInputs[0], '/out.pdf');
await userEvent.click(screen.getByRole('button', { name: /^apply$/i }));
await waitFor(() => expect(screen.getByText(/ipc channel missing/i)).toBeInTheDocument());
expect(onClose).not.toHaveBeenCalled();
});
it('can remove a merge file from the list', async () => {
render(<PdfEditorDialog onClose={() => {}} />);
mockPickFile.mockResolvedValue({ ok: true, data: '/a.pdf' });
await userEvent.click(screen.getByRole('button', { name: /add file/i }));
expect(screen.getByText('/a.pdf')).toBeInTheDocument();
const removeBtns = screen.getAllByRole('button');
const removeBtn = removeBtns.find((btn) => btn.querySelector('svg.lucide-x'));
if (removeBtn) {
await userEvent.click(removeBtn);
expect(screen.queryByText('/a.pdf')).not.toBeInTheDocument();
}
});
it('switches tabs and clears error', async () => {
render(<PdfEditorDialog onClose={() => {}} />);
await userEvent.click(screen.getByRole('button', { name: /^apply$/i }));
expect(screen.getByText(/add at least 2 pdf files/i)).toBeInTheDocument();
await userEvent.click(screen.getByText('Compress'));
expect(screen.queryByText(/add at least 2 pdf files/i)).not.toBeInTheDocument();
});
});
@@ -0,0 +1,89 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { UniversalConverterDialog } from '@/components/modals/UniversalConverterDialog';
import { useAppStore } from '@/stores/app-store';
import { useSettingsStore } from '@/stores/settings-store';
const mockPickFile = vi.fn().mockResolvedValue('/path/to/image.png');
const mockPickFolder = vi
.fn()
.mockResolvedValueOnce('/input/folder')
.mockResolvedValueOnce('/output/folder');
const mockOn = vi.fn().mockReturnValue(() => {});
const mockConvert = vi.fn().mockResolvedValue(undefined);
const mockConvertBatch = vi.fn().mockResolvedValue(undefined);
vi.mock('@/lib/ipc', () => ({
ipc: { file: { pickFile: mockPickFile, pickFolder: mockPickFolder } },
}));
beforeEach(() => {
localStorage.clear();
vi.clearAllMocks();
useSettingsStore.setState(useSettingsStore.getInitialState());
useAppStore.setState({ modal: { kind: null } } as any);
(window.electronAPI as any) = {
file: {
pickFile: mockPickFile,
pickFolder: mockPickFolder,
},
on: mockOn,
converter: { convert: mockConvert, convertBatch: mockConvertBatch },
};
});
describe('UniversalConverterDialog', () => {
it('renders with tool selector and format dropdowns', () => {
render(<UniversalConverterDialog />);
expect(screen.getByText(/universal converter/i)).toBeInTheDocument();
expect(screen.getByRole('combobox', { name: /conversion tool/i })).toBeInTheDocument();
expect(screen.getByRole('combobox', { name: /source format/i })).toBeInTheDocument();
expect(screen.getByRole('combobox', { name: /target format/i })).toBeInTheDocument();
});
it('validates that both formats are selected before converting', async () => {
render(<UniversalConverterDialog />);
await userEvent.click(screen.getByRole('button', { name: /^convert$/i }));
expect(screen.getByText(/select both source and target formats/i)).toBeInTheDocument();
});
it('validates file selection in single-file mode after formats are selected', async () => {
render(<UniversalConverterDialog />);
await userEvent.click(screen.getByRole('combobox', { name: /source format/i }));
await userEvent.click(screen.getByRole('option', { name: 'Markdown' }));
await userEvent.click(screen.getByRole('combobox', { name: /target format/i }));
await userEvent.click(screen.getByRole('option', { name: 'PDF' }));
await userEvent.click(screen.getByRole('button', { name: /^convert$/i }));
expect(screen.getByText(/select a file to convert/i)).toBeInTheDocument();
});
it('browse button calls pickFile and sets path', async () => {
render(<UniversalConverterDialog />);
await userEvent.click(screen.getByRole('button', { name: /^browse$/i }));
await waitFor(() => expect(mockPickFile).toHaveBeenCalled());
expect(await screen.findByDisplayValue('/path/to/image.png')).toBeInTheDocument();
});
it('switches tool and resets format selections', async () => {
render(<UniversalConverterDialog />);
const toolSelect = screen.getByRole('combobox', { name: /conversion tool/i });
await userEvent.click(toolSelect);
await userEvent.click(screen.getByRole('option', { name: 'FFmpeg' }));
expect(toolSelect).toHaveTextContent('FFmpeg');
});
it('enables batch mode with folder inputs', async () => {
render(<UniversalConverterDialog />);
await userEvent.click(screen.getByRole('switch', { name: /batch mode/i }));
expect(screen.getByLabelText(/input folder/i)).toBeInTheDocument();
expect(screen.getByLabelText(/output folder/i)).toBeInTheDocument();
});
it('subscribes to conversion IPC events', () => {
render(<UniversalConverterDialog />);
expect(mockOn).toHaveBeenCalledWith('conversion-status', expect.any(Function));
expect(mockOn).toHaveBeenCalledWith('conversion-complete', expect.any(Function));
expect(mockOn).toHaveBeenCalledWith('batch-progress', expect.any(Function));
});
});
+81 -32
View File
@@ -1,21 +1,79 @@
import { describe, it, expect, beforeEach } from 'vitest';
import { render, screen } from '@testing-library/react';
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { WelcomeDialog } from '@/components/modals/WelcomeDialog';
import { useSettingsStore } from '@/stores/settings-store';
import { useAppStore } from '@/stores/app-store';
import { useCommandStore } from '@/stores/command-store';
beforeEach(() => {
localStorage.clear();
useSettingsStore.setState(useSettingsStore.getInitialState());
useAppStore.setState({ modal: { kind: null } } as any);
useCommandStore.setState({ handlers: {}, userBindings: {} } as any);
useCommandStore.getState().register('file.new', vi.fn());
useCommandStore.getState().register('file.open', vi.fn());
useCommandStore.getState().register('file.openFolder', vi.fn());
useCommandStore.getState().register('shortcuts.show', vi.fn());
useCommandStore.getState().register('file.opened', vi.fn());
(window.electronAPI as any) = {
app: {
getVersion: vi.fn().mockResolvedValue({ data: '1.2.3' }),
},
};
});
describe('WelcomeDialog', () => {
beforeEach(() => {
localStorage.clear();
useSettingsStore.setState(useSettingsStore.getInitialState());
useAppStore.setState({ modal: { kind: null } } as any);
});
it('renders a heading and quick-start content', () => {
it('renders a heading with app name', () => {
render(<WelcomeDialog />);
expect(screen.getByRole('heading', { name: /welcome/i })).toBeInTheDocument();
expect(screen.getByText(/open a folder/i)).toBeInTheDocument();
});
it('renders the 3-column layout: Quick Start, Features, Recent Files', () => {
render(<WelcomeDialog />);
expect(screen.getByText(/quick start/i)).toBeInTheDocument();
expect(screen.getByText(/features/i)).toBeInTheDocument();
expect(screen.getAllByText(/recent files/i).length).toBeGreaterThanOrEqual(1);
});
it('renders all 4 quick start items with shortcuts', () => {
render(<WelcomeDialog />);
expect(screen.getByText('New File')).toBeInTheDocument();
expect(screen.getByText('Open File')).toBeInTheDocument();
expect(screen.getByText('Open Folder')).toBeInTheDocument();
expect(screen.getByText('Command Palette')).toBeInTheDocument();
expect(screen.getByText('Ctrl+N')).toBeInTheDocument();
expect(screen.getByText('Ctrl+O')).toBeInTheDocument();
});
it('renders feature cards', () => {
render(<WelcomeDialog />);
expect(screen.getByText('CodeMirror 6')).toBeInTheDocument();
expect(screen.getByText('Live Preview')).toBeInTheDocument();
expect(screen.getByText('PDF Editing')).toBeInTheDocument();
expect(screen.getByText('25+ Themes')).toBeInTheDocument();
});
it('renders keyboard shortcuts section', () => {
render(<WelcomeDialog />);
expect(screen.getByText(/shortcuts/i)).toBeInTheDocument();
expect(screen.getByText('Ctrl+S')).toBeInTheDocument();
expect(screen.getByText('Toggle sidebar')).toBeInTheDocument();
});
it('shows no recent files when none exist', () => {
render(<WelcomeDialog />);
expect(screen.getByText('No recent files')).toBeInTheDocument();
});
it('shows recent files from localStorage', () => {
localStorage.setItem(
'mc-recent-files',
JSON.stringify(['/home/user/doc1.md', '/home/user/doc2.md'])
);
render(<WelcomeDialog />);
expect(screen.getByText('doc1.md')).toBeInTheDocument();
expect(screen.getByText('doc2.md')).toBeInTheDocument();
});
it('closing without the checkbox does not dismiss future welcome dialogs', async () => {
@@ -25,33 +83,24 @@ describe('WelcomeDialog', () => {
expect(useAppStore.getState().modal).toEqual({ kind: null });
});
it('checking "don\'t show again" persists the flag', async () => {
it('checking "don\'t show on startup" persists the flag', async () => {
render(<WelcomeDialog />);
await userEvent.click(screen.getByRole('checkbox', { name: /don't show again/i }));
await userEvent.click(screen.getByRole('checkbox', { name: /don't show/i }));
await userEvent.click(screen.getByRole('button', { name: /get started/i }));
expect(useSettingsStore.getState().welcomeDismissed).toBe(true);
});
// Regression guard: Radix Dialog emits a console.warn when the Content's
// aria-describedby points at an id that isn't in the DOM. The default
// shadcn/ui pattern (custom id="X-desc" on DialogDescription) breaks that
// link because Radix's internal descriptionId is auto-generated via useId.
// The fix is to drop the override and let Radix manage the id itself.
it('does not emit the Radix "Missing Description" warning', async () => {
const warnings: string[] = [];
const spy = vi.spyOn(console, 'warn').mockImplementation((...args) => {
warnings.push(args.map(String).join(' '));
it('quick start buttons dispatch commands and close', async () => {
render(<WelcomeDialog />);
await userEvent.click(screen.getByText('New File'));
expect(useCommandStore.getState().handlers['file.new']).toBeDefined();
expect(useAppStore.getState().modal).toEqual({ kind: null });
});
it('fetches and displays version number', async () => {
render(<WelcomeDialog />);
await waitFor(() => {
expect(screen.getByText(/v1\.2\.3/)).toBeInTheDocument();
});
try {
render(<WelcomeDialog />);
// Effects that fire the warning run in a microtask; one tick is enough.
await Promise.resolve();
const offending = warnings.filter((w) =>
/Missing\s+`?Description`?|aria-describedby=\{undefined\}/.test(w)
);
expect(offending).toEqual([]);
} finally {
spy.mockRestore();
}
});
});
@@ -7,13 +7,11 @@ import { useEditorStore } from '@/stores/editor-store';
import { useSettingsStore } from '@/stores/settings-store';
vi.mock('@/lib/docx-export', () => ({
generateDocx: vi
.fn()
.mockResolvedValue(
new Blob([new Uint8Array([1, 2, 3])], {
type: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
})
),
generateDocx: vi.fn().mockResolvedValue(
new Blob([new Uint8Array([1, 2, 3])], {
type: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
})
),
}));
vi.mock('@/lib/toast', () => ({