feat: complete master feature parity with interactive PDF editing, Reveal.js export dialog, Large File Mode, and scoped CSS

This commit is contained in:
2026-06-15 01:22:17 +05:30
parent 7eb90d467a
commit 36422a9ab3
18 changed files with 917 additions and 66 deletions
+2 -4
View File
@@ -7,10 +7,8 @@ import { useAppStore } from '@/stores/app-store';
describe('AboutDialog', () => {
beforeEach(() => {
window.electronAPI = {
app: {
getVersion: vi.fn().mockResolvedValue('5.0.0'),
openExternal: vi.fn().mockResolvedValue({ ok: true }),
},
getAppVersion: vi.fn().mockResolvedValue('5.0.0'),
send: vi.fn().mockResolvedValue({ ok: true }),
} as any;
// Reset store to about modal so dialog renders
useAppStore.setState({ modal: { kind: 'about' } } as any);
@@ -7,12 +7,12 @@ describe('ExportBatchDialog', () => {
beforeEach(() => {
localStorage.clear();
window.electronAPI = {
export: {
batch: vi.fn().mockResolvedValue({
ok: true,
data: { total: 2, succeeded: 2, failed: 0, results: [] },
}),
},
invoke: vi.fn().mockResolvedValue({
total: 2,
succeeded: 2,
failed: 0,
results: [],
}),
} as any;
});
@@ -27,11 +27,12 @@ describe('ExportBatchDialog', () => {
await userEvent.click(screen.getByRole('combobox', { name: /format/i }));
await userEvent.click(screen.getByRole('option', { name: /^pdf$/i }));
await userEvent.click(screen.getByRole('button', { name: /^export$/i }));
const call = (window.electronAPI.export.batch as any).mock.calls[0];
expect(call[0]).toEqual([
const call = (window.electronAPI.invoke as any).mock.calls[0];
expect(call[0]).toBe('batch-convert');
expect(call[1].items).toEqual([
{ inputPath: '/a.md', outputPath: expect.any(String) },
{ inputPath: '/b.md', outputPath: expect.any(String) },
]);
expect(call[1].format).toBe('pdf');
expect(call[1].options.format).toBe('pdf');
});
});
@@ -18,28 +18,45 @@ const defaultSettings = {
logoPath: null,
};
const mockGetSettings = vi.fn().mockResolvedValue(defaultSettings);
const mockSaveSettings = vi.fn().mockResolvedValue(undefined);
const mockBrowseLogo = vi.fn().mockResolvedValue('/path/to/logo.png');
let registeredCallback: ((data: any) => void) | null = null;
const mockSend = vi.fn((channel, ...args) => {
if (channel === 'get-header-footer-settings') {
setTimeout(() => {
if (registeredCallback) registeredCallback(defaultSettings);
}, 0);
}
});
const mockOn = vi.fn((channel, cb) => {
if (channel === 'header-footer-settings-data') {
registeredCallback = cb as any;
// Trigger immediately with default settings to satisfy initial mount loading
setTimeout(() => {
cb(defaultSettings);
}, 0);
}
return vi.fn(); // cleanup/unsubscribe fn
});
beforeEach(() => {
localStorage.clear();
vi.clearAllMocks();
registeredCallback = null;
useSettingsStore.setState(useSettingsStore.getInitialState());
useAppStore.setState({ modal: { kind: null } } as any);
(window.electronAPI as any) = {
headerFooter: {
getSettings: mockGetSettings,
saveSettings: mockSaveSettings,
browseLogo: mockBrowseLogo,
},
send: mockSend,
on: mockOn,
once: vi.fn(),
invoke: vi.fn(() => Promise.resolve(null)),
removeAllListeners: vi.fn(),
};
});
describe('HeaderFooterDialog', () => {
it('loads settings from electronAPI on mount', async () => {
render(<HeaderFooterDialog />);
await waitFor(() => expect(mockGetSettings).toHaveBeenCalled());
await waitFor(() => expect(mockSend).toHaveBeenCalledWith('get-header-footer-settings'));
expect(screen.getByText(/header & footer/i)).toBeInTheDocument();
});
@@ -61,7 +78,7 @@ describe('HeaderFooterDialog', () => {
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());
await waitFor(() => expect(mockSend).toHaveBeenCalledWith('save-header-footer-settings', expect.any(Object)));
});
it('closes modal on Cancel', async () => {
@@ -72,7 +89,16 @@ describe('HeaderFooterDialog', () => {
});
it('inserts dynamic field token into header input', async () => {
mockGetSettings.mockResolvedValue({ ...defaultSettings, headerEnabled: true });
// Override registered callback to return enabled header
mockOn.mockImplementationOnce((channel, cb) => {
if (channel === 'header-footer-settings-data') {
setTimeout(() => {
cb({ ...defaultSettings, headerEnabled: true });
}, 0);
}
return vi.fn();
});
render(<HeaderFooterDialog />);
await waitFor(() => expect(screen.getByText(/header & footer/i)).toBeInTheDocument());
const tokenButton = screen.getAllByTitle('Page')[0];
@@ -7,6 +7,28 @@ const mockProcessOperation = vi.fn().mockResolvedValue(undefined);
const mockPickFile = vi.fn();
const mockPickFolder = vi.fn();
const mockShowSaveDialog = vi.fn();
const mockReadBuffer = vi.fn();
// Mock pdfjs-dist to avoid rendering/worker issues in node test environment
vi.mock('pdfjs-dist', () => {
const mockRenderPromise = Promise.resolve();
const mockPage = {
getViewport: vi.fn().mockReturnValue({ width: 100, height: 150 }),
render: vi.fn().mockReturnValue({ promise: mockRenderPromise }),
};
const mockDoc = {
numPages: 3,
getPage: vi.fn().mockResolvedValue(mockPage),
};
return {
GlobalWorkerOptions: {
workerSrc: '',
},
getDocument: vi.fn().mockReturnValue({
promise: Promise.resolve(mockDoc),
}),
};
});
beforeEach(() => {
localStorage.clear();
@@ -14,10 +36,12 @@ beforeEach(() => {
mockPickFile.mockResolvedValue({ ok: true, data: null });
mockPickFolder.mockResolvedValue({ ok: true, data: null });
mockShowSaveDialog.mockResolvedValue({ ok: true, data: null });
mockReadBuffer.mockResolvedValue({ ok: true, data: new Uint8Array([1, 2, 3]) });
window.electronAPI = {
file: {
pickFile: mockPickFile,
pickFolder: mockPickFolder,
readBuffer: mockReadBuffer,
},
app: {
showSaveDialog: mockShowSaveDialog,
@@ -294,4 +318,69 @@ describe('PdfEditorDialog', () => {
await userEvent.click(screen.getByText('Compress'));
expect(screen.queryByText(/add at least 2 pdf files/i)).not.toBeInTheDocument();
});
it('renders interactive page thumbnails in rotate tab', async () => {
render(<PdfEditorDialog onClose={() => {}} initialFilePath="/test.pdf" />);
// Switch to Rotate tab
await userEvent.click(screen.getByText('Rotate'));
// Wait for the thumbnails to load and render
await waitFor(() => {
expect(screen.getByText('Page 1')).toBeInTheDocument();
expect(screen.getByText('Page 2')).toBeInTheDocument();
expect(screen.getByText('Page 3')).toBeInTheDocument();
});
// Check Rotate button on page 1 thumbnail
const rotateBtns = screen.getAllByTitle('Rotate 90°');
expect(rotateBtns).toHaveLength(3);
// Click rotate on page 1
await userEvent.click(rotateBtns[0]);
// Check if the form's "Pages" input is updated to "1"
const pagesInput = screen.getByPlaceholderText('All pages') as HTMLInputElement;
expect(pagesInput.value).toBe('1');
});
it('toggles page deletion status in delete tab', async () => {
render(<PdfEditorDialog onClose={() => {}} initialFilePath="/test.pdf" />);
// Switch to Delete tab
await userEvent.click(screen.getByText('Delete'));
// Wait for the thumbnails
await waitFor(() => {
expect(screen.getByText('Page 1')).toBeInTheDocument();
});
// Click Delete button on page 1
const deleteBtns = screen.getAllByTitle('Delete');
await userEvent.click(deleteBtns[0]);
// Check if the pages to delete input is updated to "1"
const pagesInput = screen.getByPlaceholderText('1,3,5-8') as HTMLInputElement;
expect(pagesInput.value).toBe('1');
});
it('reorders pages in reorder tab', async () => {
render(<PdfEditorDialog onClose={() => {}} initialFilePath="/test.pdf" />);
// Switch to Reorder tab
await userEvent.click(screen.getByText('Reorder'));
// Wait for the thumbnails
await waitFor(() => {
expect(screen.getByText('Page 1')).toBeInTheDocument();
});
// Move Page 1 Down
const moveDownBtns = screen.getAllByTitle('Move Down');
await userEvent.click(moveDownBtns[0]);
// Check if the new order input is updated
const newOrderInput = screen.getByPlaceholderText('3,1,2,5,4') as HTMLTextAreaElement;
expect(newOrderInput.value).toBe('2,1,3');
});
});
+12 -30
View File
@@ -3,38 +3,20 @@ import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { GitStatusPanel } from '@/components/sidebar/GitStatusPanel';
import { useFileStore } from '@/stores/file-store';
vi.mock('@/lib/ipc', () => ({
ipc: {
file: {
gitStatus: vi.fn(),
},
},
}));
vi.mock('@/lib/toast', () => ({
toast: {
success: vi.fn(),
error: vi.fn(),
info: vi.fn(),
warning: vi.fn(),
promise: vi.fn(),
dismiss: vi.fn(),
},
}));
import { ipc } from '@/lib/ipc';
const mockGitStage = vi.fn();
const mockGitCommit = vi.fn();
Object.defineProperty(window, 'electronAPI', {
value: {
gitStage: mockGitStage,
gitCommit: mockGitCommit,
vi.mock('@/lib/ipc', () => ({
ipc: {
file: {
gitStatus: vi.fn(),
gitStage: (args: any) => mockGitStage(args),
gitCommit: (args: any) => mockGitCommit(args),
},
},
writable: true,
});
}));
describe('GitStatusPanel', () => {
beforeEach(() => {
@@ -126,7 +108,7 @@ describe('GitStatusPanel', () => {
});
it('stages selected files', async () => {
mockGitStage.mockResolvedValueOnce(undefined);
mockGitStage.mockResolvedValueOnce({ ok: true });
(ipc.file.gitStatus as any).mockResolvedValue({
ok: true,
data: [{ filePath: '/project/a.md', status: 'modified' }],
@@ -137,11 +119,11 @@ describe('GitStatusPanel', () => {
await userEvent.click(checkbox);
const stageBtn = screen.getByTestId('git-stage-selected');
await userEvent.click(stageBtn);
expect(mockGitStage).toHaveBeenCalledWith(['/project/a.md']);
expect(mockGitStage).toHaveBeenCalledWith({ rootPath: '/project', files: ['/project/a.md'] });
});
it('commits with message', async () => {
mockGitCommit.mockResolvedValueOnce(undefined);
mockGitCommit.mockResolvedValueOnce({ ok: true });
(ipc.file.gitStatus as any).mockResolvedValue({
ok: true,
data: [{ filePath: '/project/a.md', status: 'modified' }],
@@ -152,6 +134,6 @@ describe('GitStatusPanel', () => {
await userEvent.type(input, 'fix typo');
const commitBtn = screen.getByTestId('git-commit-button');
await userEvent.click(commitBtn);
expect(mockGitCommit).toHaveBeenCalledWith('fix typo');
expect(mockGitCommit).toHaveBeenCalledWith({ rootPath: '/project', message: 'fix typo' });
});
});