mirror of
https://github.com/amitwh/markdown-converter.git
synced 2026-08-02 18:10:18 +05:30
style: run prettier formatter over src and tests
This commit is contained in:
@@ -11,10 +11,8 @@ describe('Minimap', () => {
|
||||
});
|
||||
|
||||
it('renders a viewport indicator', () => {
|
||||
render(
|
||||
<Minimap content={'line 1\nline 2\nline 3'} scrollRatio={0.5} visibleRatio={0.5} />
|
||||
);
|
||||
render(<Minimap content={'line 1\nline 2\nline 3'} scrollRatio={0.5} visibleRatio={0.5} />);
|
||||
const indicator = screen.getByTestId('minimap-viewport');
|
||||
expect(indicator).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -90,4 +90,4 @@ describe('AppHeader', () => {
|
||||
await userEvent.click(screen.getByRole('button', { name: /^about$/i }));
|
||||
expect(useAppStore.getState().modal).toEqual({ kind: 'about' });
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -12,7 +12,12 @@ vi.mock('@/components/ui/resizable', () => ({
|
||||
</div>
|
||||
),
|
||||
ResizablePanel: ({ children, defaultSize, minSize, maxSize }: any) => (
|
||||
<div data-testid="resizable-panel" data-size={defaultSize} data-min={minSize} data-max={maxSize}>
|
||||
<div
|
||||
data-testid="resizable-panel"
|
||||
data-size={defaultSize}
|
||||
data-min={minSize}
|
||||
data-max={maxSize}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
),
|
||||
@@ -46,4 +51,4 @@ describe('AppShell', () => {
|
||||
);
|
||||
expect(screen.queryByText(/file tree placeholder/i)).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -9,8 +9,15 @@ describe('Breadcrumb', () => {
|
||||
beforeEach(() => {
|
||||
localStorage.clear();
|
||||
useSettingsStore.setState({ ...useSettingsStore.getInitialState(), breadcrumbSymbols: true });
|
||||
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: '# H1\n## H2', dirty: false }]]) } as any);
|
||||
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: '# H1\n## H2', dirty: false }],
|
||||
]),
|
||||
} as any);
|
||||
});
|
||||
|
||||
it('shows "No file selected" when no tab is active', () => {
|
||||
@@ -38,8 +45,23 @@ describe('Breadcrumb', () => {
|
||||
});
|
||||
|
||||
it('limits heading symbols to 3', () => {
|
||||
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: '# H1\n## H2\n### H3\n#### H4', dirty: false }]]) } as any);
|
||||
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: '# H1\n## H2\n### H3\n#### H4',
|
||||
dirty: false,
|
||||
},
|
||||
],
|
||||
]),
|
||||
} as any);
|
||||
render(<Breadcrumb />);
|
||||
// Should show H1, H2, H3 but not H4
|
||||
expect(screen.getByText(/# H1/)).toBeInTheDocument();
|
||||
@@ -47,4 +69,4 @@ describe('Breadcrumb', () => {
|
||||
expect(screen.getByText(/# H3/)).toBeInTheDocument();
|
||||
expect(screen.queryByText(/# H4/)).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -7,4 +7,4 @@ describe('StatusBar', () => {
|
||||
render(<StatusBar />);
|
||||
expect(screen.getByText(/0 words/i)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -81,9 +81,7 @@ describe('TabBar', () => {
|
||||
|
||||
it('renders a dirty indicator when dirty === true', () => {
|
||||
useFileStore.setState({
|
||||
openTabs: [
|
||||
{ id: '/tmp/foo.md', path: '/tmp/foo.md', title: 'foo.md', dirty: true },
|
||||
],
|
||||
openTabs: [{ id: '/tmp/foo.md', path: '/tmp/foo.md', title: 'foo.md', dirty: true }],
|
||||
activeTabId: '/tmp/foo.md',
|
||||
});
|
||||
render(<TabBar />);
|
||||
|
||||
@@ -31,4 +31,4 @@ describe('AboutDialog', () => {
|
||||
// closeModal sets modal.kind = null, so isOpen=false, Dialog unmounts
|
||||
expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -32,4 +32,4 @@ describe('AsciiGeneratorDialog', () => {
|
||||
render(<AsciiGeneratorDialog />);
|
||||
expect(screen.getByRole('button', { name: /copy/i })).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -34,10 +34,8 @@ describe('ConfirmDialog', () => {
|
||||
});
|
||||
|
||||
it('destructive variant uses destructive button class', () => {
|
||||
render(
|
||||
<ConfirmDialog title="T" body="B" destructive onConfirm={() => {}} />
|
||||
);
|
||||
render(<ConfirmDialog title="T" body="B" destructive onConfirm={() => {}} />);
|
||||
const btn = screen.getByRole('button', { name: /confirm/i });
|
||||
expect(btn.className).toContain('bg-destructive');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -18,7 +18,12 @@ describe('CrashReportModal', () => {
|
||||
|
||||
test('lists crashes returned by ipc', async () => {
|
||||
(ipc.crash.read as any).mockResolvedValue([
|
||||
{ filename: '1700000000000-1-uncaughtException.json', kind: 'uncaughtException', message: 'boom', timestamp: '2026-01-01T00:00:00.000Z' },
|
||||
{
|
||||
filename: '1700000000000-1-uncaughtException.json',
|
||||
kind: 'uncaughtException',
|
||||
message: 'boom',
|
||||
timestamp: '2026-01-01T00:00:00.000Z',
|
||||
},
|
||||
]);
|
||||
render(<CrashReportModal onClose={() => {}} />);
|
||||
expect(await screen.findByText(/boom/)).toBeInTheDocument();
|
||||
@@ -26,12 +31,19 @@ describe('CrashReportModal', () => {
|
||||
|
||||
test('delete button calls ipc.crash.delete', async () => {
|
||||
(ipc.crash.read as any).mockResolvedValue([
|
||||
{ filename: '1700000000000-1-uncaughtException.json', kind: 'uncaughtException', message: 'boom', timestamp: '2026-01-01T00:00:00.000Z' },
|
||||
{
|
||||
filename: '1700000000000-1-uncaughtException.json',
|
||||
kind: 'uncaughtException',
|
||||
message: 'boom',
|
||||
timestamp: '2026-01-01T00:00:00.000Z',
|
||||
},
|
||||
]);
|
||||
render(<CrashReportModal onClose={() => {}} />);
|
||||
const btn = await screen.findByText(/delete/i);
|
||||
fireEvent.click(btn);
|
||||
await waitFor(() => expect(ipc.crash.delete).toHaveBeenCalledWith('1700000000000-1-uncaughtException.json'));
|
||||
await waitFor(() =>
|
||||
expect(ipc.crash.delete).toHaveBeenCalledWith('1700000000000-1-uncaughtException.json')
|
||||
);
|
||||
});
|
||||
|
||||
test('open folder button calls ipc.crash.openDir', async () => {
|
||||
@@ -40,4 +52,4 @@ describe('CrashReportModal', () => {
|
||||
fireEvent.click(await screen.findByText(/open dump folder/i));
|
||||
expect(ipc.crash.openDir).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -7,7 +7,14 @@ describe('ExportBatchDialog', () => {
|
||||
beforeEach(() => {
|
||||
localStorage.clear();
|
||||
window.electronAPI = {
|
||||
export: { batch: vi.fn().mockResolvedValue({ ok: true, data: { total: 2, succeeded: 2, failed: 0, results: [] } }) },
|
||||
export: {
|
||||
batch: vi
|
||||
.fn()
|
||||
.mockResolvedValue({
|
||||
ok: true,
|
||||
data: { total: 2, succeeded: 2, failed: 0, results: [] },
|
||||
}),
|
||||
},
|
||||
} as any;
|
||||
});
|
||||
|
||||
@@ -23,7 +30,10 @@ describe('ExportBatchDialog', () => {
|
||||
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([{ inputPath: '/a.md', outputPath: expect.any(String) }, { inputPath: '/b.md', outputPath: expect.any(String) }]);
|
||||
expect(call[0]).toEqual([
|
||||
{ inputPath: '/a.md', outputPath: expect.any(String) },
|
||||
{ inputPath: '/b.md', outputPath: expect.any(String) },
|
||||
]);
|
||||
expect(call[1].format).toBe('pdf');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -14,7 +14,13 @@ 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';
|
||||
@@ -31,8 +37,15 @@ describe('ExportDocxDialog', () => {
|
||||
}));
|
||||
(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);
|
||||
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);
|
||||
});
|
||||
|
||||
it('renders with standard template selected by default', () => {
|
||||
|
||||
@@ -25,8 +25,15 @@ describe('ExportHtmlDialog', () => {
|
||||
}));
|
||||
(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);
|
||||
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);
|
||||
});
|
||||
|
||||
it('renders with default github highlight style', () => {
|
||||
|
||||
@@ -22,8 +22,15 @@ describe('ExportPdfDialog', () => {
|
||||
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);
|
||||
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);
|
||||
});
|
||||
|
||||
it('renders with default PDF options from settings', () => {
|
||||
|
||||
@@ -64,7 +64,10 @@ describe('FindInFilesDialog', () => {
|
||||
});
|
||||
|
||||
it('shows an error banner when search fails', async () => {
|
||||
(ipc.file.search as any).mockResolvedValueOnce({ ok: false, error: { code: 'E', message: 'regex invalid' } });
|
||||
(ipc.file.search as any).mockResolvedValueOnce({
|
||||
ok: false,
|
||||
error: { code: 'E', message: 'regex invalid' },
|
||||
});
|
||||
render(<FindInFilesDialog />);
|
||||
await userEvent.paste('[invalid', { initialSelectionStart: 0, initialSelectionEnd: 0 });
|
||||
await userEvent.click(screen.getByRole('button', { name: /search/i }));
|
||||
|
||||
@@ -29,4 +29,4 @@ describe('ModalLayer', () => {
|
||||
expect(screen.queryByText(/about markdownconverter/i)).not.toBeInTheDocument();
|
||||
expect(screen.getByText(/settings/i)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -55,4 +55,4 @@ describe('SettingsSheet', () => {
|
||||
await userEvent.click(screen.getByRole('button', { name: /reset/i }));
|
||||
expect(useSettingsStore.getState().fontSize).toBe(14);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -33,4 +33,4 @@ describe('TableGeneratorDialog', () => {
|
||||
render(<TableGeneratorDialog />);
|
||||
expect(screen.getByRole('button', { name: /copy/i })).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -47,7 +47,7 @@ describe('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),
|
||||
/Missing\s+`?Description`?|aria-describedby=\{undefined\}/.test(w)
|
||||
);
|
||||
expect(offending).toEqual([]);
|
||||
} finally {
|
||||
|
||||
@@ -7,7 +7,13 @@ 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', () => ({
|
||||
@@ -37,8 +43,15 @@ describe('WordExportDialog', () => {
|
||||
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);
|
||||
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);
|
||||
(ipc.app.showSaveDialog as any).mockResolvedValue({ ok: true, data: '/out.docx' });
|
||||
(ipc.file.writeBuffer as any).mockResolvedValue({ ok: true });
|
||||
});
|
||||
@@ -58,14 +71,20 @@ describe('WordExportDialog', () => {
|
||||
});
|
||||
|
||||
it('shows error message on write failure', async () => {
|
||||
(ipc.file.writeBuffer as any).mockResolvedValue({ ok: false, error: { code: 'E', message: 'write failed' } });
|
||||
(ipc.file.writeBuffer as any).mockResolvedValue({
|
||||
ok: false,
|
||||
error: { code: 'E', message: 'write failed' },
|
||||
});
|
||||
render(<WordExportDialog sourcePath="/test.md" />);
|
||||
await userEvent.click(screen.getByRole('button', { name: /^export$/i }));
|
||||
expect(await screen.findByText(/write failed/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('switches to custom template path when "Custom" is selected', async () => {
|
||||
useSettingsStore.setState({ ...useSettingsStore.getInitialState(), docxCustomTemplatePath: '/my.dotx' });
|
||||
useSettingsStore.setState({
|
||||
...useSettingsStore.getInitialState(),
|
||||
docxCustomTemplatePath: '/my.dotx',
|
||||
});
|
||||
render(<WordExportDialog sourcePath="/test.md" />);
|
||||
expect(screen.getByText(/\/my\.dotx/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
@@ -15,7 +15,13 @@ vi.mock('@/lib/ipc', () => ({
|
||||
|
||||
describe('FileTree', () => {
|
||||
beforeEach(() => {
|
||||
useFileStore.setState({ tree: null, rootPath: null, expanded: new Set(), openTabs: [], activeTabId: null });
|
||||
useFileStore.setState({
|
||||
tree: null,
|
||||
rootPath: null,
|
||||
expanded: new Set(),
|
||||
openTabs: [],
|
||||
activeTabId: null,
|
||||
});
|
||||
});
|
||||
|
||||
it('renders nothing when tree is null', () => {
|
||||
@@ -66,7 +72,12 @@ describe('FileTree', () => {
|
||||
isDirectory: true,
|
||||
loaded: true,
|
||||
children: [
|
||||
{ name: 'index.ts', path: '/project/src/index.ts', isDirectory: false, children: null },
|
||||
{
|
||||
name: 'index.ts',
|
||||
path: '/project/src/index.ts',
|
||||
isDirectory: false,
|
||||
children: null,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
|
||||
@@ -55,7 +55,9 @@ describe('GitStatusPanel', () => {
|
||||
useFileStore.setState({ rootPath: '/project' } as any);
|
||||
render(<GitStatusPanel />);
|
||||
// The helper text appears in a <p> element distinct from the error heading
|
||||
expect(await screen.findByText('Not a git repository, or git not installed.')).toBeInTheDocument();
|
||||
expect(
|
||||
await screen.findByText('Not a git repository, or git not installed.')
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('opens file on click', async () => {
|
||||
@@ -70,4 +72,4 @@ describe('GitStatusPanel', () => {
|
||||
await userEvent.click(row);
|
||||
expect(openFile).toHaveBeenCalledWith('/project/a.md');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -20,7 +20,13 @@ vi.mock('@/lib/ipc', () => ({
|
||||
|
||||
describe('Sidebar', () => {
|
||||
beforeEach(() => {
|
||||
useFileStore.setState({ tree: null, rootPath: null, expanded: new Set(), openTabs: [], activeTabId: null });
|
||||
useFileStore.setState({
|
||||
tree: null,
|
||||
rootPath: null,
|
||||
expanded: new Set(),
|
||||
openTabs: [],
|
||||
activeTabId: null,
|
||||
});
|
||||
useEditorStore.setState({ buffers: new Map(), activeId: null });
|
||||
});
|
||||
|
||||
@@ -46,7 +52,9 @@ describe('Sidebar', () => {
|
||||
path: '/project',
|
||||
isDirectory: true,
|
||||
loaded: true,
|
||||
children: [{ name: 'README.md', path: '/project/README.md', isDirectory: false, children: null }],
|
||||
children: [
|
||||
{ name: 'README.md', path: '/project/README.md', isDirectory: false, children: null },
|
||||
],
|
||||
},
|
||||
rootPath: '/project',
|
||||
});
|
||||
|
||||
@@ -37,4 +37,4 @@ describe('ThemeToggle', () => {
|
||||
await userEvent.click(btn);
|
||||
expect(document.documentElement.classList.contains('light')).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -22,7 +22,9 @@ describe('PrintPreview', () => {
|
||||
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 }]]),
|
||||
buffers: new Map([
|
||||
['/test.md', { id: '/test.md', path: '/test.md', content: '# hi', dirty: false }],
|
||||
]),
|
||||
} as any);
|
||||
});
|
||||
|
||||
@@ -46,4 +48,4 @@ describe('PrintPreview', () => {
|
||||
await userEvent.click(screen.getByRole('button', { name: /close/i }));
|
||||
expect(onClose).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -31,4 +31,4 @@ describe('ReplPanel', () => {
|
||||
await new Promise((r) => setTimeout(r, 400));
|
||||
expect(screen.getByRole('heading', { name: /hello/i })).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -21,4 +21,4 @@ describe('Button', () => {
|
||||
const btn = screen.getByRole('button');
|
||||
expect(btn.className).toContain('bg-primary');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -10,4 +10,4 @@ describe('Checkbox', () => {
|
||||
await userEvent.click(screen.getByRole('checkbox', { name: /agree/i }));
|
||||
expect(onCheckedChange).toHaveBeenCalledWith(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -27,4 +27,4 @@ describe('Dialog', () => {
|
||||
expect(screen.getByRole('dialog')).toBeInTheDocument();
|
||||
expect(screen.getByText(/title/i)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -3,7 +3,14 @@ import { render, screen } from '@testing-library/react';
|
||||
import { useForm, FormProvider } from 'react-hook-form';
|
||||
import { z } from 'zod';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { Form, FormField, FormItem, FormLabel, FormControl, FormMessage } from '@/components/ui/form';
|
||||
import {
|
||||
Form,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormControl,
|
||||
FormMessage,
|
||||
} from '@/components/ui/form';
|
||||
import { Input } from '@/components/ui/input';
|
||||
|
||||
const schema = z.object({ name: z.string().min(2) });
|
||||
@@ -39,4 +46,4 @@ describe('Form', () => {
|
||||
render(<Harness />);
|
||||
expect(screen.getByLabelText(/name/i)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -11,4 +11,4 @@ describe('Input', () => {
|
||||
await userEvent.type(input, 'a');
|
||||
expect(onChange).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -26,4 +26,4 @@ describe('Select', () => {
|
||||
await userEvent.click(screen.getByRole('combobox', { name: /fruit/i }));
|
||||
expect(screen.getByRole('option', { name: /apple/i })).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -26,4 +26,4 @@ describe('Sheet', () => {
|
||||
await userEvent.click(screen.getByRole('button', { name: /open/i }));
|
||||
expect(screen.getByRole('dialog')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -14,4 +14,4 @@ describe('Toaster', () => {
|
||||
// but the test should not throw
|
||||
expect(container).toBeDefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -10,4 +10,4 @@ describe('Switch', () => {
|
||||
await userEvent.click(screen.getByRole('switch', { name: /airplane/i }));
|
||||
expect(onCheckedChange).toHaveBeenCalledWith(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -19,4 +19,4 @@ describe('Tabs', () => {
|
||||
await userEvent.click(screen.getByRole('tab', { name: /two/i }));
|
||||
expect(screen.getByText(/content two/i)).toBeVisible();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -9,4 +9,4 @@ describe('Textarea', () => {
|
||||
expect(ta.tagName).toBe('TEXTAREA');
|
||||
expect(ta).toHaveValue('hello');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -48,7 +48,9 @@ describe('EventBus', () => {
|
||||
|
||||
test('handler errors are caught and logged, not thrown', () => {
|
||||
const errorSpy = jest.spyOn(console, 'error').mockImplementation(() => {});
|
||||
bus.on('bad:event', () => { throw new Error('boom'); });
|
||||
bus.on('bad:event', () => {
|
||||
throw new Error('boom');
|
||||
});
|
||||
expect(() => bus.emit('bad:event', {})).not.toThrow();
|
||||
expect(errorSpy).toHaveBeenCalled();
|
||||
errorSpy.mockRestore();
|
||||
|
||||
@@ -8,14 +8,14 @@ describe('GitOperations Utilities', () => {
|
||||
describe('error handling patterns', () => {
|
||||
it('should handle git errors gracefully', () => {
|
||||
const errorResponse = { error: 'Not a git repository' };
|
||||
|
||||
|
||||
expect(errorResponse).toHaveProperty('error');
|
||||
expect(errorResponse.error).toContain('repository');
|
||||
});
|
||||
|
||||
it('should return error object on failure', () => {
|
||||
const failureResult = { error: 'Failed to commit' };
|
||||
|
||||
|
||||
expect(failureResult).toBeDefined();
|
||||
expect(failureResult.error).toBeTruthy();
|
||||
});
|
||||
@@ -56,24 +56,20 @@ describe('GitOperations Utilities', () => {
|
||||
'branch',
|
||||
'checkout',
|
||||
'push',
|
||||
'pull'
|
||||
'pull',
|
||||
];
|
||||
|
||||
expect(gitOps.length).toBeGreaterThan(0);
|
||||
gitOps.forEach(op => {
|
||||
gitOps.forEach((op) => {
|
||||
expect(typeof op).toBe('string');
|
||||
expect(op.length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle directory paths', () => {
|
||||
const paths = [
|
||||
'/home/user/project',
|
||||
'./current/dir',
|
||||
'../parent/dir'
|
||||
];
|
||||
const paths = ['/home/user/project', './current/dir', '../parent/dir'];
|
||||
|
||||
paths.forEach(pathStr => {
|
||||
paths.forEach((pathStr) => {
|
||||
expect(typeof pathStr).toBe('string');
|
||||
expect(pathStr.length).toBeGreaterThan(0);
|
||||
});
|
||||
@@ -83,10 +79,10 @@ describe('GitOperations Utilities', () => {
|
||||
const messages = [
|
||||
'fix: bug in git panel',
|
||||
'feat: add new feature',
|
||||
'refactor: clean up code'
|
||||
'refactor: clean up code',
|
||||
];
|
||||
|
||||
messages.forEach(msg => {
|
||||
messages.forEach((msg) => {
|
||||
expect(typeof msg).toBe('string');
|
||||
expect(msg.length).toBeGreaterThan(0);
|
||||
});
|
||||
@@ -101,7 +97,7 @@ describe('GitOperations Utilities', () => {
|
||||
deleted: [],
|
||||
modified: [],
|
||||
renamed: [],
|
||||
staged: ['file.md']
|
||||
staged: ['file.md'],
|
||||
};
|
||||
|
||||
expect(statusResponse).toHaveProperty('staged');
|
||||
@@ -110,10 +106,8 @@ describe('GitOperations Utilities', () => {
|
||||
|
||||
it('should return log entries', () => {
|
||||
const logResponse = {
|
||||
all: [
|
||||
{ hash: 'abc123', message: 'fix: something' }
|
||||
],
|
||||
latest: { hash: 'abc123', message: 'fix: something' }
|
||||
all: [{ hash: 'abc123', message: 'fix: something' }],
|
||||
latest: { hash: 'abc123', message: 'fix: something' },
|
||||
};
|
||||
|
||||
expect(logResponse).toHaveProperty('all');
|
||||
|
||||
@@ -8,7 +8,9 @@ describe('GoalTracker', () => {
|
||||
store = {};
|
||||
tracker = new GoalTracker({
|
||||
get: (key) => store[key],
|
||||
set: (key, value) => { store[key] = value; }
|
||||
set: (key, value) => {
|
||||
store[key] = value;
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -132,7 +132,13 @@ describe('Phase 5 integration', () => {
|
||||
activeTabId: '/a.md',
|
||||
});
|
||||
// Use a buffer content that won't conflict with tab title in breadcrumb symbols
|
||||
useEditorStore.setState({ buffers: new Map([['/a.md', { id: '/a.md', path: '/a.md', content: '# Hello', dirty: false }], ['/b.md', { id: '/b.md', path: '/b.md', content: '# World', dirty: false }]]), activeId: '/a.md' });
|
||||
useEditorStore.setState({
|
||||
buffers: new Map([
|
||||
['/a.md', { id: '/a.md', path: '/a.md', content: '# Hello', dirty: false }],
|
||||
['/b.md', { id: '/b.md', path: '/b.md', content: '# World', dirty: false }],
|
||||
]),
|
||||
activeId: '/a.md',
|
||||
});
|
||||
render(<AppShell />);
|
||||
// Use role="tab" to find tabs specifically, avoiding breadcrumb "a.md" text
|
||||
const tabs = screen.getAllByRole('tab');
|
||||
|
||||
@@ -17,18 +17,29 @@ vi.mock('@/lib/ipc', () => ({
|
||||
},
|
||||
menu: {
|
||||
on: vi.fn().mockReturnValue(vi.fn()),
|
||||
},
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
describe('Phase 7 modals integration', () => {
|
||||
beforeEach(() => {
|
||||
localStorage.clear();
|
||||
useAppStore.setState({ modal: { kind: null }, sidebarVisible: true, previewVisible: true, zenMode: false, paneSizes: { sidebar: 20, editor: 50, preview: 30 } } as any);
|
||||
useAppStore.setState({
|
||||
modal: { kind: null },
|
||||
sidebarVisible: true,
|
||||
previewVisible: true,
|
||||
zenMode: false,
|
||||
paneSizes: { sidebar: 20, editor: 50, preview: 30 },
|
||||
} as any);
|
||||
useCommandStore.setState({ handlers: {}, userBindings: {} } as any);
|
||||
useSettingsStore.setState(useSettingsStore.getInitialState());
|
||||
useFileStore.setState({ activeTabId: '/x.md', openTabs: [{ id: '/x.md', path: '/x.md', title: 'x.md', dirty: false }] } as any);
|
||||
useEditorStore.setState({ buffers: new Map([['/x.md', { id: '/x.md', path: '/x.md', content: '# hi', dirty: false }]]) } as any);
|
||||
useFileStore.setState({
|
||||
activeTabId: '/x.md',
|
||||
openTabs: [{ id: '/x.md', path: '/x.md', title: 'x.md', dirty: false }],
|
||||
} as any);
|
||||
useEditorStore.setState({
|
||||
buffers: new Map([['/x.md', { id: '/x.md', path: '/x.md', content: '# hi', dirty: false }]]),
|
||||
} as any);
|
||||
});
|
||||
|
||||
it('dispatching settings.open from command store opens SettingsSheet', () => {
|
||||
|
||||
@@ -59,11 +59,24 @@ describe('Phase 8 toasts integration', () => {
|
||||
beforeEach(() => {
|
||||
localStorage.clear();
|
||||
vi.clearAllMocks();
|
||||
useAppStore.setState({ modal: { kind: null }, sidebarVisible: true, previewVisible: true, zenMode: false, paneSizes: { sidebar: 20, editor: 50, preview: 30 } } as any);
|
||||
useAppStore.setState({
|
||||
modal: { kind: null },
|
||||
sidebarVisible: true,
|
||||
previewVisible: true,
|
||||
zenMode: false,
|
||||
paneSizes: { sidebar: 20, editor: 50, preview: 30 },
|
||||
} as any);
|
||||
useCommandStore.setState({ handlers: {}, userBindings: {} } as any);
|
||||
useSettingsStore.setState({ ...useSettingsStore.getInitialState(), welcomeDismissed: true });
|
||||
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: true }]]) } as any);
|
||||
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: true }],
|
||||
]),
|
||||
} as any);
|
||||
});
|
||||
|
||||
it('saving a file calls toast.success with "Saved test.md"', async () => {
|
||||
@@ -77,7 +90,10 @@ describe('Phase 8 toasts integration', () => {
|
||||
});
|
||||
|
||||
it('saving with IPC error calls toast.error', async () => {
|
||||
(ipc.file.write as any).mockResolvedValue({ ok: false, error: { code: 'EACCES', message: 'Permission denied' } });
|
||||
(ipc.file.write as any).mockResolvedValue({
|
||||
ok: false,
|
||||
error: { code: 'EACCES', message: 'Permission denied' },
|
||||
});
|
||||
registerMenuCommands();
|
||||
render(<App />);
|
||||
|
||||
@@ -87,7 +103,10 @@ describe('Phase 8 toasts integration', () => {
|
||||
});
|
||||
|
||||
it('opening a missing file calls toast.error', async () => {
|
||||
(ipc.file.read as any).mockResolvedValue({ ok: false, error: { code: 'ENOENT', message: 'No such file' } });
|
||||
(ipc.file.read as any).mockResolvedValue({
|
||||
ok: false,
|
||||
error: { code: 'ENOENT', message: 'No such file' },
|
||||
});
|
||||
registerMenuCommands();
|
||||
render(<App />);
|
||||
|
||||
@@ -102,7 +121,10 @@ describe('Phase 8 toasts integration', () => {
|
||||
|
||||
// Open the export-pdf modal via command dispatch
|
||||
useCommandStore.getState().dispatch('file.exportPdf');
|
||||
expect(useAppStore.getState().modal).toEqual({ kind: 'export-pdf', props: { sourcePath: '/test.md' } });
|
||||
expect(useAppStore.getState().modal).toEqual({
|
||||
kind: 'export-pdf',
|
||||
props: { sourcePath: '/test.md' },
|
||||
});
|
||||
|
||||
// Click the Export button
|
||||
const exportBtn = await screen.findByRole('button', { name: /^export$/i });
|
||||
@@ -114,4 +136,4 @@ describe('Phase 8 toasts integration', () => {
|
||||
});
|
||||
expect(toast.success).toHaveBeenCalledWith(expect.stringContaining('Sent test.md'));
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -36,11 +36,22 @@ describe('Phase 9 tools integration', () => {
|
||||
beforeEach(() => {
|
||||
localStorage.clear();
|
||||
vi.clearAllMocks();
|
||||
useAppStore.setState({ modal: { kind: null }, sidebarVisible: true, previewVisible: true, zenMode: false, paneSizes: { sidebar: 20, editor: 50, preview: 30 } } as any);
|
||||
useAppStore.setState({
|
||||
modal: { kind: null },
|
||||
sidebarVisible: true,
|
||||
previewVisible: true,
|
||||
zenMode: false,
|
||||
paneSizes: { sidebar: 20, editor: 50, preview: 30 },
|
||||
} as any);
|
||||
useCommandStore.setState({ handlers: {}, userBindings: {} } as any);
|
||||
useSettingsStore.setState({ ...useSettingsStore.getInitialState(), welcomeDismissed: true });
|
||||
useFileStore.setState({ activeTabId: '/x.md', openTabs: [{ id: '/x.md', path: '/x.md', title: 'x.md', dirty: false }] } as any);
|
||||
useEditorStore.setState({ buffers: new Map([['/x.md', { id: '/x.md', path: '/x.md', content: '# hi', dirty: false }]]) } as any);
|
||||
useFileStore.setState({
|
||||
activeTabId: '/x.md',
|
||||
openTabs: [{ id: '/x.md', path: '/x.md', title: 'x.md', dirty: false }],
|
||||
} as any);
|
||||
useEditorStore.setState({
|
||||
buffers: new Map([['/x.md', { id: '/x.md', path: '/x.md', content: '# hi', dirty: false }]]),
|
||||
} as any);
|
||||
});
|
||||
|
||||
it('tools.ascii opens ascii-generator modal', () => {
|
||||
@@ -68,7 +79,10 @@ describe('Phase 9 tools integration', () => {
|
||||
registerMenuCommands();
|
||||
render(<App />);
|
||||
useCommandStore.getState().dispatch('tools.exportWord');
|
||||
expect(useAppStore.getState().modal).toEqual({ kind: 'export-word', props: { sourcePath: '/x.md' } });
|
||||
expect(useAppStore.getState().modal).toEqual({
|
||||
kind: 'export-word',
|
||||
props: { sourcePath: '/x.md' },
|
||||
});
|
||||
});
|
||||
|
||||
it('tools.repl toggles replOpen setting', () => {
|
||||
@@ -86,4 +100,4 @@ describe('Phase 9 tools integration', () => {
|
||||
useCommandStore.getState().dispatch('view.zenMode');
|
||||
expect(useAppStore.getState().zenMode).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
+76
-77
@@ -4,97 +4,96 @@
|
||||
*/
|
||||
|
||||
describe('sanitizeErrorMessage', () => {
|
||||
const sanitizeErrorMessage = (message) => {
|
||||
if (typeof message !== 'string') return String(message);
|
||||
return message
|
||||
.replace(/[A-Z]:\\[^\s"']+\\([^\s"'\\]+)/gi, '$1')
|
||||
.replace(/\/[^\s"']+\/([^\s"'/]+)/g, '$1');
|
||||
};
|
||||
const sanitizeErrorMessage = (message) => {
|
||||
if (typeof message !== 'string') return String(message);
|
||||
return message
|
||||
.replace(/[A-Z]:\\[^\s"']+\\([^\s"'\\]+)/gi, '$1')
|
||||
.replace(/\/[^\s"']+\/([^\s"'/]+)/g, '$1');
|
||||
};
|
||||
|
||||
test('strips Windows absolute paths', () => {
|
||||
expect(sanitizeErrorMessage('Error in C:\\Users\\test\\file.js'))
|
||||
.toBe('Error in file.js');
|
||||
});
|
||||
test('strips Windows absolute paths', () => {
|
||||
expect(sanitizeErrorMessage('Error in C:\\Users\\test\\file.js')).toBe('Error in file.js');
|
||||
});
|
||||
|
||||
test('strips Unix absolute paths', () => {
|
||||
expect(sanitizeErrorMessage('Error in /home/user/project/file.js'))
|
||||
.toBe('Error in file.js');
|
||||
});
|
||||
test('strips Unix absolute paths', () => {
|
||||
expect(sanitizeErrorMessage('Error in /home/user/project/file.js')).toBe('Error in file.js');
|
||||
});
|
||||
|
||||
test('handles non-string input', () => {
|
||||
expect(sanitizeErrorMessage(42)).toBe('42');
|
||||
expect(sanitizeErrorMessage(null)).toBe('null');
|
||||
});
|
||||
test('handles non-string input', () => {
|
||||
expect(sanitizeErrorMessage(42)).toBe('42');
|
||||
expect(sanitizeErrorMessage(null)).toBe('null');
|
||||
});
|
||||
|
||||
test('preserves messages without paths', () => {
|
||||
expect(sanitizeErrorMessage('Something went wrong'))
|
||||
.toBe('Something went wrong');
|
||||
});
|
||||
test('preserves messages without paths', () => {
|
||||
expect(sanitizeErrorMessage('Something went wrong')).toBe('Something went wrong');
|
||||
});
|
||||
|
||||
test('strips nested Windows paths', () => {
|
||||
expect(sanitizeErrorMessage('Cannot read C:\\Users\\admin\\AppData\\Local\\config.json'))
|
||||
.toBe('Cannot read config.json');
|
||||
});
|
||||
test('strips nested Windows paths', () => {
|
||||
expect(sanitizeErrorMessage('Cannot read C:\\Users\\admin\\AppData\\Local\\config.json')).toBe(
|
||||
'Cannot read config.json'
|
||||
);
|
||||
});
|
||||
|
||||
test('strips nested Unix paths', () => {
|
||||
expect(sanitizeErrorMessage('File not found: /var/log/app/error.log'))
|
||||
.toBe('File not found: error.log');
|
||||
});
|
||||
test('strips nested Unix paths', () => {
|
||||
expect(sanitizeErrorMessage('File not found: /var/log/app/error.log')).toBe(
|
||||
'File not found: error.log'
|
||||
);
|
||||
});
|
||||
|
||||
test('handles undefined input', () => {
|
||||
expect(sanitizeErrorMessage(undefined)).toBe('undefined');
|
||||
});
|
||||
test('handles undefined input', () => {
|
||||
expect(sanitizeErrorMessage(undefined)).toBe('undefined');
|
||||
});
|
||||
});
|
||||
|
||||
describe('createRateLimiter', () => {
|
||||
const createRateLimiter = (minIntervalMs = 2000) => {
|
||||
let lastCall = 0;
|
||||
return function canProceed() {
|
||||
const now = Date.now();
|
||||
if (now - lastCall < minIntervalMs) return false;
|
||||
lastCall = now;
|
||||
return true;
|
||||
};
|
||||
const createRateLimiter = (minIntervalMs = 2000) => {
|
||||
let lastCall = 0;
|
||||
return function canProceed() {
|
||||
const now = Date.now();
|
||||
if (now - lastCall < minIntervalMs) return false;
|
||||
lastCall = now;
|
||||
return true;
|
||||
};
|
||||
};
|
||||
|
||||
test('allows first call', () => {
|
||||
const limiter = createRateLimiter(1000);
|
||||
expect(limiter()).toBe(true);
|
||||
});
|
||||
test('allows first call', () => {
|
||||
const limiter = createRateLimiter(1000);
|
||||
expect(limiter()).toBe(true);
|
||||
});
|
||||
|
||||
test('blocks rapid calls', () => {
|
||||
const limiter = createRateLimiter(1000);
|
||||
limiter(); // first call
|
||||
expect(limiter()).toBe(false); // too soon
|
||||
});
|
||||
test('blocks rapid calls', () => {
|
||||
const limiter = createRateLimiter(1000);
|
||||
limiter(); // first call
|
||||
expect(limiter()).toBe(false); // too soon
|
||||
});
|
||||
|
||||
test('allows call after interval', () => {
|
||||
jest.useFakeTimers();
|
||||
const limiter = createRateLimiter(1000);
|
||||
limiter();
|
||||
jest.advanceTimersByTime(1001);
|
||||
expect(limiter()).toBe(true);
|
||||
jest.useRealTimers();
|
||||
});
|
||||
test('allows call after interval', () => {
|
||||
jest.useFakeTimers();
|
||||
const limiter = createRateLimiter(1000);
|
||||
limiter();
|
||||
jest.advanceTimersByTime(1001);
|
||||
expect(limiter()).toBe(true);
|
||||
jest.useRealTimers();
|
||||
});
|
||||
|
||||
test('uses default interval of 2000ms', () => {
|
||||
jest.useFakeTimers();
|
||||
const limiter = createRateLimiter();
|
||||
limiter();
|
||||
jest.advanceTimersByTime(1999);
|
||||
expect(limiter()).toBe(false);
|
||||
jest.advanceTimersByTime(2);
|
||||
expect(limiter()).toBe(true);
|
||||
jest.useRealTimers();
|
||||
});
|
||||
test('uses default interval of 2000ms', () => {
|
||||
jest.useFakeTimers();
|
||||
const limiter = createRateLimiter();
|
||||
limiter();
|
||||
jest.advanceTimersByTime(1999);
|
||||
expect(limiter()).toBe(false);
|
||||
jest.advanceTimersByTime(2);
|
||||
expect(limiter()).toBe(true);
|
||||
jest.useRealTimers();
|
||||
});
|
||||
|
||||
test('resets after successful call', () => {
|
||||
jest.useFakeTimers();
|
||||
const limiter = createRateLimiter(500);
|
||||
limiter();
|
||||
jest.advanceTimersByTime(501);
|
||||
limiter(); // resets the timer
|
||||
expect(limiter()).toBe(false); // too soon after second call
|
||||
jest.useRealTimers();
|
||||
});
|
||||
test('resets after successful call', () => {
|
||||
jest.useFakeTimers();
|
||||
const limiter = createRateLimiter(500);
|
||||
limiter();
|
||||
jest.advanceTimersByTime(501);
|
||||
limiter(); // resets the timer
|
||||
expect(limiter()).toBe(false); // too soon after second call
|
||||
jest.useRealTimers();
|
||||
});
|
||||
});
|
||||
|
||||
+236
-222
@@ -4,245 +4,259 @@
|
||||
*/
|
||||
|
||||
describe('Markdown Extensions', () => {
|
||||
describe('TOC generation from headings', () => {
|
||||
test('extracts all heading levels', () => {
|
||||
const html = '<h1>Title</h1><h2>Section 1</h2><h2>Section 2</h2><h3>Subsection</h3>';
|
||||
const headingRegex = /<h([1-6])[^>]*>(.*?)<\/h[1-6]>/gi;
|
||||
const toc = [];
|
||||
let match;
|
||||
while ((match = headingRegex.exec(html)) !== null) {
|
||||
toc.push({ level: parseInt(match[1]), text: match[2] });
|
||||
}
|
||||
expect(toc).toHaveLength(4);
|
||||
expect(toc[0]).toEqual({ level: 1, text: 'Title' });
|
||||
expect(toc[3]).toEqual({ level: 3, text: 'Subsection' });
|
||||
});
|
||||
|
||||
test('handles empty HTML', () => {
|
||||
const html = '<p>No headings here</p>';
|
||||
const headingRegex = /<h([1-6])[^>]*>(.*?)<\/h[1-6]>/gi;
|
||||
const toc = [];
|
||||
let match;
|
||||
while ((match = headingRegex.exec(html)) !== null) {
|
||||
toc.push({ level: parseInt(match[1]), text: match[2] });
|
||||
}
|
||||
expect(toc).toHaveLength(0);
|
||||
});
|
||||
|
||||
test('handles headings with attributes', () => {
|
||||
const html = '<h1 id="title" class="main">Title</h1><h2 id="sec">Section</h2>';
|
||||
const headingRegex = /<h([1-6])[^>]*>(.*?)<\/h[1-6]>/gi;
|
||||
const toc = [];
|
||||
let match;
|
||||
while ((match = headingRegex.exec(html)) !== null) {
|
||||
toc.push({ level: parseInt(match[1]), text: match[2] });
|
||||
}
|
||||
expect(toc).toHaveLength(2);
|
||||
expect(toc[0].text).toBe('Title');
|
||||
});
|
||||
describe('TOC generation from headings', () => {
|
||||
test('extracts all heading levels', () => {
|
||||
const html = '<h1>Title</h1><h2>Section 1</h2><h2>Section 2</h2><h3>Subsection</h3>';
|
||||
const headingRegex = /<h([1-6])[^>]*>(.*?)<\/h[1-6]>/gi;
|
||||
const toc = [];
|
||||
let match;
|
||||
while ((match = headingRegex.exec(html)) !== null) {
|
||||
toc.push({ level: parseInt(match[1]), text: match[2] });
|
||||
}
|
||||
expect(toc).toHaveLength(4);
|
||||
expect(toc[0]).toEqual({ level: 1, text: 'Title' });
|
||||
expect(toc[3]).toEqual({ level: 3, text: 'Subsection' });
|
||||
});
|
||||
|
||||
describe('Admonition regex matching', () => {
|
||||
test('matches note admonition', () => {
|
||||
const src = ':::note\nThis is a note.\n:::\n';
|
||||
const match = src.match(/^:::(note|warning|tip|danger|info)\s*\n([\s\S]*?)^:::\s*$/m);
|
||||
expect(match).not.toBeNull();
|
||||
expect(match[1]).toBe('note');
|
||||
expect(match[2].trim()).toBe('This is a note.');
|
||||
});
|
||||
|
||||
test('matches all admonition types', () => {
|
||||
const types = ['note', 'warning', 'tip', 'danger', 'info'];
|
||||
types.forEach(type => {
|
||||
const src = `:::${type}\nContent\n:::\n`;
|
||||
const match = src.match(/^:::(note|warning|tip|danger|info)\s*\n([\s\S]*?)^:::\s*$/m);
|
||||
expect(match).not.toBeNull();
|
||||
expect(match[1]).toBe(type);
|
||||
});
|
||||
});
|
||||
|
||||
test('does not match invalid admonition type', () => {
|
||||
const src = ':::custom\nContent\n:::\n';
|
||||
const match = src.match(/^:::(note|warning|tip|danger|info)\s*\n([\s\S]*?)^:::\s*$/m);
|
||||
expect(match).toBeNull();
|
||||
});
|
||||
|
||||
test('captures multiline content', () => {
|
||||
const src = ':::warning\nLine 1\nLine 2\nLine 3\n:::\n';
|
||||
const match = src.match(/^:::(note|warning|tip|danger|info)\s*\n([\s\S]*?)^:::\s*$/m);
|
||||
expect(match).not.toBeNull();
|
||||
expect(match[2]).toContain('Line 1');
|
||||
expect(match[2]).toContain('Line 3');
|
||||
});
|
||||
test('handles empty HTML', () => {
|
||||
const html = '<p>No headings here</p>';
|
||||
const headingRegex = /<h([1-6])[^>]*>(.*?)<\/h[1-6]>/gi;
|
||||
const toc = [];
|
||||
let match;
|
||||
while ((match = headingRegex.exec(html)) !== null) {
|
||||
toc.push({ level: parseInt(match[1]), text: match[2] });
|
||||
}
|
||||
expect(toc).toHaveLength(0);
|
||||
});
|
||||
|
||||
describe('Admonitions full parsing integration (mocked environment)', () => {
|
||||
const extension = {
|
||||
name: 'admonition',
|
||||
level: 'block',
|
||||
start(src) { return src.match(/^:::(note|warning|tip|danger|info)/m)?.index; },
|
||||
tokenizer(src) {
|
||||
const match = src.match(/^:::(note|warning|tip|danger|info)\s*\n([\s\S]*?)^:::\s*$/m);
|
||||
if (match && match.index === 0) {
|
||||
const admonitionType = match[1];
|
||||
const text = match[2].trim();
|
||||
const tokens = [];
|
||||
this.lexer.blockTokens(text, tokens);
|
||||
return {
|
||||
type: 'admonition',
|
||||
raw: match[0],
|
||||
admonitionType,
|
||||
text,
|
||||
tokens
|
||||
};
|
||||
}
|
||||
},
|
||||
renderer(token) {
|
||||
const icons = { note: 'ℹ', warning: '⚠', tip: '💡', danger: '🔴', info: 'ℹ' };
|
||||
const icon = icons[token.admonitionType] || 'ℹ';
|
||||
const inner = this.parser.parse(token.tokens || []);
|
||||
return `<div class="admonition admonition-${token.admonitionType}">
|
||||
test('handles headings with attributes', () => {
|
||||
const html = '<h1 id="title" class="main">Title</h1><h2 id="sec">Section</h2>';
|
||||
const headingRegex = /<h([1-6])[^>]*>(.*?)<\/h[1-6]>/gi;
|
||||
const toc = [];
|
||||
let match;
|
||||
while ((match = headingRegex.exec(html)) !== null) {
|
||||
toc.push({ level: parseInt(match[1]), text: match[2] });
|
||||
}
|
||||
expect(toc).toHaveLength(2);
|
||||
expect(toc[0].text).toBe('Title');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Admonition regex matching', () => {
|
||||
test('matches note admonition', () => {
|
||||
const src = ':::note\nThis is a note.\n:::\n';
|
||||
const match = src.match(/^:::(note|warning|tip|danger|info)\s*\n([\s\S]*?)^:::\s*$/m);
|
||||
expect(match).not.toBeNull();
|
||||
expect(match[1]).toBe('note');
|
||||
expect(match[2].trim()).toBe('This is a note.');
|
||||
});
|
||||
|
||||
test('matches all admonition types', () => {
|
||||
const types = ['note', 'warning', 'tip', 'danger', 'info'];
|
||||
types.forEach((type) => {
|
||||
const src = `:::${type}\nContent\n:::\n`;
|
||||
const match = src.match(/^:::(note|warning|tip|danger|info)\s*\n([\s\S]*?)^:::\s*$/m);
|
||||
expect(match).not.toBeNull();
|
||||
expect(match[1]).toBe(type);
|
||||
});
|
||||
});
|
||||
|
||||
test('does not match invalid admonition type', () => {
|
||||
const src = ':::custom\nContent\n:::\n';
|
||||
const match = src.match(/^:::(note|warning|tip|danger|info)\s*\n([\s\S]*?)^:::\s*$/m);
|
||||
expect(match).toBeNull();
|
||||
});
|
||||
|
||||
test('captures multiline content', () => {
|
||||
const src = ':::warning\nLine 1\nLine 2\nLine 3\n:::\n';
|
||||
const match = src.match(/^:::(note|warning|tip|danger|info)\s*\n([\s\S]*?)^:::\s*$/m);
|
||||
expect(match).not.toBeNull();
|
||||
expect(match[2]).toContain('Line 1');
|
||||
expect(match[2]).toContain('Line 3');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Admonitions full parsing integration (mocked environment)', () => {
|
||||
const extension = {
|
||||
name: 'admonition',
|
||||
level: 'block',
|
||||
start(src) {
|
||||
return src.match(/^:::(note|warning|tip|danger|info)/m)?.index;
|
||||
},
|
||||
tokenizer(src) {
|
||||
const match = src.match(/^:::(note|warning|tip|danger|info)\s*\n([\s\S]*?)^:::\s*$/m);
|
||||
if (match && match.index === 0) {
|
||||
const admonitionType = match[1];
|
||||
const text = match[2].trim();
|
||||
const tokens = [];
|
||||
this.lexer.blockTokens(text, tokens);
|
||||
return {
|
||||
type: 'admonition',
|
||||
raw: match[0],
|
||||
admonitionType,
|
||||
text,
|
||||
tokens,
|
||||
};
|
||||
}
|
||||
},
|
||||
renderer(token) {
|
||||
const icons = { note: 'ℹ', warning: '⚠', tip: '💡', danger: '🔴', info: 'ℹ' };
|
||||
const icon = icons[token.admonitionType] || 'ℹ';
|
||||
const inner = this.parser.parse(token.tokens || []);
|
||||
return `<div class="admonition admonition-${token.admonitionType}">
|
||||
<div class="admonition-title">${icon} ${token.admonitionType.charAt(0).toUpperCase() + token.admonitionType.slice(1)}</div>
|
||||
<div class="admonition-content">${inner}</div>
|
||||
</div>`;
|
||||
}
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
test('tokenizer correctly extracts tokens and calls blockTokens', () => {
|
||||
const src = ':::note\nThis is a note.\n:::';
|
||||
const mockLexer = {
|
||||
blockTokens: jest.fn((text, tokens) => {
|
||||
tokens.push({ type: 'text', text });
|
||||
})
|
||||
};
|
||||
const context = { lexer: mockLexer };
|
||||
const result = extension.tokenizer.call(context, src);
|
||||
|
||||
expect(result).toBeDefined();
|
||||
expect(result.type).toBe('admonition');
|
||||
expect(result.admonitionType).toBe('note');
|
||||
expect(result.text).toBe('This is a note.');
|
||||
expect(mockLexer.blockTokens).toHaveBeenCalledWith('This is a note.', expect.any(Array));
|
||||
expect(result.tokens).toEqual([{ type: 'text', text: 'This is a note.' }]);
|
||||
});
|
||||
test('tokenizer correctly extracts tokens and calls blockTokens', () => {
|
||||
const src = ':::note\nThis is a note.\n:::';
|
||||
const mockLexer = {
|
||||
blockTokens: jest.fn((text, tokens) => {
|
||||
tokens.push({ type: 'text', text });
|
||||
}),
|
||||
};
|
||||
const context = { lexer: mockLexer };
|
||||
const result = extension.tokenizer.call(context, src);
|
||||
|
||||
test('renderer correctly translates tokens to HTML', () => {
|
||||
const token = {
|
||||
type: 'admonition',
|
||||
admonitionType: 'warning',
|
||||
tokens: [{ type: 'text', text: 'Be careful!' }]
|
||||
};
|
||||
const mockParser = {
|
||||
parse: jest.fn((tokens) => '<p>Be careful!</p>')
|
||||
};
|
||||
const context = { parser: mockParser };
|
||||
const html = extension.renderer.call(context, token);
|
||||
|
||||
expect(html).toContain('admonition admonition-warning');
|
||||
expect(html).toContain('⚠ Warning');
|
||||
expect(html).toContain('<p>Be careful!</p>');
|
||||
expect(mockParser.parse).toHaveBeenCalledWith(token.tokens);
|
||||
});
|
||||
expect(result).toBeDefined();
|
||||
expect(result.type).toBe('admonition');
|
||||
expect(result.admonitionType).toBe('note');
|
||||
expect(result.text).toBe('This is a note.');
|
||||
expect(mockLexer.blockTokens).toHaveBeenCalledWith('This is a note.', expect.any(Array));
|
||||
expect(result.tokens).toEqual([{ type: 'text', text: 'This is a note.' }]);
|
||||
});
|
||||
|
||||
describe('PlantUML hex encoding', () => {
|
||||
const plantumlEncode = (text) => {
|
||||
const hex = Array.from(Buffer.from(text, 'utf-8'))
|
||||
.map(b => b.toString(16).padStart(2, '0'))
|
||||
.join('');
|
||||
return '~h' + hex;
|
||||
};
|
||||
test('renderer correctly translates tokens to HTML', () => {
|
||||
const token = {
|
||||
type: 'admonition',
|
||||
admonitionType: 'warning',
|
||||
tokens: [{ type: 'text', text: 'Be careful!' }],
|
||||
};
|
||||
const mockParser = {
|
||||
parse: jest.fn((tokens) => '<p>Be careful!</p>'),
|
||||
};
|
||||
const context = { parser: mockParser };
|
||||
const html = extension.renderer.call(context, token);
|
||||
|
||||
test('encodes simple text', () => {
|
||||
const encoded = plantumlEncode('A -> B');
|
||||
expect(encoded).toBe('~h41202d3e2042');
|
||||
});
|
||||
expect(html).toContain('admonition admonition-warning');
|
||||
expect(html).toContain('⚠ Warning');
|
||||
expect(html).toContain('<p>Be careful!</p>');
|
||||
expect(mockParser.parse).toHaveBeenCalledWith(token.tokens);
|
||||
});
|
||||
});
|
||||
|
||||
test('encodes empty string', () => {
|
||||
expect(plantumlEncode('')).toBe('~h');
|
||||
});
|
||||
describe('PlantUML hex encoding', () => {
|
||||
const plantumlEncode = (text) => {
|
||||
const hex = Array.from(Buffer.from(text, 'utf-8'))
|
||||
.map((b) => b.toString(16).padStart(2, '0'))
|
||||
.join('');
|
||||
return '~h' + hex;
|
||||
};
|
||||
|
||||
test('encodes special characters', () => {
|
||||
const encoded = plantumlEncode('@startuml');
|
||||
expect(encoded).toMatch(/^~h[0-9a-f]+$/);
|
||||
// '@' is 0x40, 's' is 0x73
|
||||
expect(encoded.startsWith('~h40')).toBe(true);
|
||||
});
|
||||
test('encodes simple text', () => {
|
||||
const encoded = plantumlEncode('A -> B');
|
||||
expect(encoded).toBe('~h41202d3e2042');
|
||||
});
|
||||
|
||||
describe('Slug generation for TOC anchors', () => {
|
||||
const slugify = (text) => {
|
||||
return text
|
||||
.toLowerCase()
|
||||
.replace(/[^\w\s-]/g, '')
|
||||
.replace(/\s+/g, '-')
|
||||
.replace(/-+/g, '-')
|
||||
.trim();
|
||||
};
|
||||
|
||||
test('converts heading to slug', () => {
|
||||
expect(slugify('Hello World')).toBe('hello-world');
|
||||
});
|
||||
|
||||
test('removes special characters', () => {
|
||||
expect(slugify('What is C++?')).toBe('what-is-c');
|
||||
});
|
||||
|
||||
test('collapses multiple dashes', () => {
|
||||
expect(slugify('Hello World')).toBe('hello-world');
|
||||
});
|
||||
|
||||
test('handles already lowercase text', () => {
|
||||
expect(slugify('simple')).toBe('simple');
|
||||
});
|
||||
test('encodes empty string', () => {
|
||||
expect(plantumlEncode('')).toBe('~h');
|
||||
});
|
||||
|
||||
describe('scopeCSS utility', () => {
|
||||
const scopeCSS = (cssText, scopeSelector) => {
|
||||
if (!cssText) return '';
|
||||
return cssText.replace(/([^\r\n,{}]+)(,(?=[^}]*{)|(?=[^{]*{))/g, (match, selector, separator) => {
|
||||
const trimmed = selector.trim();
|
||||
if (!trimmed || trimmed.startsWith('@') || trimmed.startsWith(':root') || trimmed.startsWith('from') || trimmed.startsWith('to') || /^\d+%$/.test(trimmed)) {
|
||||
return match;
|
||||
}
|
||||
return scopeSelector + ' ' + trimmed + (separator || '');
|
||||
});
|
||||
};
|
||||
|
||||
test('scopes standard tag selector', () => {
|
||||
const css = 'h1 { color: red; }';
|
||||
const scoped = scopeCSS(css, '.preview-content');
|
||||
expect(scoped).toBe('.preview-content h1{ color: red; }');
|
||||
});
|
||||
|
||||
test('scopes multiple class selectors', () => {
|
||||
const css = '.title, .content { font-family: sans-serif; }';
|
||||
const scoped = scopeCSS(css, '.preview-content');
|
||||
expect(scoped).toBe('.preview-content .title,.preview-content .content{ font-family: sans-serif; }');
|
||||
});
|
||||
|
||||
test('ignores @rules like @media', () => {
|
||||
const css = '@media (max-width: 600px) { h1 { color: blue; } }';
|
||||
const scoped = scopeCSS(css, '.preview-content');
|
||||
expect(scoped).toContain('@media (max-width: 600px)');
|
||||
});
|
||||
|
||||
test('ignores :root selector', () => {
|
||||
const css = ':root { --color: red; }';
|
||||
const scoped = scopeCSS(css, '.preview-content');
|
||||
expect(scoped).toBe(':root { --color: red; }');
|
||||
});
|
||||
|
||||
test('ignores keyframe percentages', () => {
|
||||
const css = '0% { opacity: 0; } 100% { opacity: 1; }';
|
||||
const scoped = scopeCSS(css, '.preview-content');
|
||||
expect(scoped).toContain('0%');
|
||||
expect(scoped).toContain('100%');
|
||||
});
|
||||
|
||||
test('handles empty input', () => {
|
||||
expect(scopeCSS('', '.preview-content')).toBe('');
|
||||
expect(scopeCSS(null, '.preview-content')).toBe('');
|
||||
});
|
||||
test('encodes special characters', () => {
|
||||
const encoded = plantumlEncode('@startuml');
|
||||
expect(encoded).toMatch(/^~h[0-9a-f]+$/);
|
||||
// '@' is 0x40, 's' is 0x73
|
||||
expect(encoded.startsWith('~h40')).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Slug generation for TOC anchors', () => {
|
||||
const slugify = (text) => {
|
||||
return text
|
||||
.toLowerCase()
|
||||
.replace(/[^\w\s-]/g, '')
|
||||
.replace(/\s+/g, '-')
|
||||
.replace(/-+/g, '-')
|
||||
.trim();
|
||||
};
|
||||
|
||||
test('converts heading to slug', () => {
|
||||
expect(slugify('Hello World')).toBe('hello-world');
|
||||
});
|
||||
|
||||
test('removes special characters', () => {
|
||||
expect(slugify('What is C++?')).toBe('what-is-c');
|
||||
});
|
||||
|
||||
test('collapses multiple dashes', () => {
|
||||
expect(slugify('Hello World')).toBe('hello-world');
|
||||
});
|
||||
|
||||
test('handles already lowercase text', () => {
|
||||
expect(slugify('simple')).toBe('simple');
|
||||
});
|
||||
});
|
||||
|
||||
describe('scopeCSS utility', () => {
|
||||
const scopeCSS = (cssText, scopeSelector) => {
|
||||
if (!cssText) return '';
|
||||
return cssText.replace(
|
||||
/([^\r\n,{}]+)(,(?=[^}]*{)|(?=[^{]*{))/g,
|
||||
(match, selector, separator) => {
|
||||
const trimmed = selector.trim();
|
||||
if (
|
||||
!trimmed ||
|
||||
trimmed.startsWith('@') ||
|
||||
trimmed.startsWith(':root') ||
|
||||
trimmed.startsWith('from') ||
|
||||
trimmed.startsWith('to') ||
|
||||
/^\d+%$/.test(trimmed)
|
||||
) {
|
||||
return match;
|
||||
}
|
||||
return scopeSelector + ' ' + trimmed + (separator || '');
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
test('scopes standard tag selector', () => {
|
||||
const css = 'h1 { color: red; }';
|
||||
const scoped = scopeCSS(css, '.preview-content');
|
||||
expect(scoped).toBe('.preview-content h1{ color: red; }');
|
||||
});
|
||||
|
||||
test('scopes multiple class selectors', () => {
|
||||
const css = '.title, .content { font-family: sans-serif; }';
|
||||
const scoped = scopeCSS(css, '.preview-content');
|
||||
expect(scoped).toBe(
|
||||
'.preview-content .title,.preview-content .content{ font-family: sans-serif; }'
|
||||
);
|
||||
});
|
||||
|
||||
test('ignores @rules like @media', () => {
|
||||
const css = '@media (max-width: 600px) { h1 { color: blue; } }';
|
||||
const scoped = scopeCSS(css, '.preview-content');
|
||||
expect(scoped).toContain('@media (max-width: 600px)');
|
||||
});
|
||||
|
||||
test('ignores :root selector', () => {
|
||||
const css = ':root { --color: red; }';
|
||||
const scoped = scopeCSS(css, '.preview-content');
|
||||
expect(scoped).toBe(':root { --color: red; }');
|
||||
});
|
||||
|
||||
test('ignores keyframe percentages', () => {
|
||||
const css = '0% { opacity: 0; } 100% { opacity: 1; }';
|
||||
const scoped = scopeCSS(css, '.preview-content');
|
||||
expect(scoped).toContain('0%');
|
||||
expect(scoped).toContain('100%');
|
||||
});
|
||||
|
||||
test('handles empty input', () => {
|
||||
expect(scopeCSS('', '.preview-content')).toBe('');
|
||||
expect(scopeCSS(null, '.preview-content')).toBe('');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
+234
-234
@@ -9,272 +9,272 @@
|
||||
const { ModalManager } = require('../src/utils/ModalManager');
|
||||
|
||||
function createModalElement(id = 'test-modal') {
|
||||
const modal = document.createElement('div');
|
||||
modal.id = id;
|
||||
modal.className = 'modal hidden';
|
||||
modal.setAttribute('role', 'dialog');
|
||||
modal.setAttribute('aria-modal', 'true');
|
||||
const modal = document.createElement('div');
|
||||
modal.id = id;
|
||||
modal.className = 'modal hidden';
|
||||
modal.setAttribute('role', 'dialog');
|
||||
modal.setAttribute('aria-modal', 'true');
|
||||
|
||||
const backdrop = document.createElement('div');
|
||||
backdrop.className = 'modal-backdrop';
|
||||
backdrop.setAttribute('data-close', '');
|
||||
const backdrop = document.createElement('div');
|
||||
backdrop.className = 'modal-backdrop';
|
||||
backdrop.setAttribute('data-close', '');
|
||||
|
||||
const content = document.createElement('div');
|
||||
content.className = 'modal-content';
|
||||
const content = document.createElement('div');
|
||||
content.className = 'modal-content';
|
||||
|
||||
const header = document.createElement('div');
|
||||
header.className = 'modal-header';
|
||||
const header = document.createElement('div');
|
||||
header.className = 'modal-header';
|
||||
|
||||
const closeBtn = document.createElement('button');
|
||||
closeBtn.className = 'modal-close';
|
||||
closeBtn.setAttribute('aria-label', 'Close');
|
||||
const closeBtn = document.createElement('button');
|
||||
closeBtn.className = 'modal-close';
|
||||
closeBtn.setAttribute('aria-label', 'Close');
|
||||
|
||||
const body = document.createElement('div');
|
||||
body.className = 'modal-body';
|
||||
const body = document.createElement('div');
|
||||
body.className = 'modal-body';
|
||||
|
||||
const input = document.createElement('input');
|
||||
input.type = 'text';
|
||||
const input = document.createElement('input');
|
||||
input.type = 'text';
|
||||
|
||||
body.appendChild(input);
|
||||
header.appendChild(closeBtn);
|
||||
content.appendChild(header);
|
||||
content.appendChild(body);
|
||||
modal.appendChild(backdrop);
|
||||
modal.appendChild(content);
|
||||
document.body.appendChild(modal);
|
||||
body.appendChild(input);
|
||||
header.appendChild(closeBtn);
|
||||
content.appendChild(header);
|
||||
content.appendChild(body);
|
||||
modal.appendChild(backdrop);
|
||||
modal.appendChild(content);
|
||||
document.body.appendChild(modal);
|
||||
|
||||
return modal;
|
||||
return modal;
|
||||
}
|
||||
|
||||
describe('ModalManager', () => {
|
||||
let modal;
|
||||
let manager;
|
||||
let modal;
|
||||
let manager;
|
||||
|
||||
beforeEach(() => {
|
||||
modal = createModalElement();
|
||||
manager = new ModalManager(modal);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
manager.destroy();
|
||||
while (document.body.firstChild) {
|
||||
document.body.removeChild(document.body.firstChild);
|
||||
}
|
||||
document.body.style.overflow = '';
|
||||
});
|
||||
|
||||
// =========================================================
|
||||
// open()
|
||||
// =========================================================
|
||||
|
||||
describe('open()', () => {
|
||||
test('removes hidden class and adds open class', () => {
|
||||
expect(modal.classList.contains('hidden')).toBe(true);
|
||||
expect(modal.classList.contains('open')).toBe(false);
|
||||
|
||||
manager.open();
|
||||
|
||||
expect(modal.classList.contains('hidden')).toBe(false);
|
||||
expect(modal.classList.contains('open')).toBe(true);
|
||||
});
|
||||
|
||||
test('sets isOpen to true', () => {
|
||||
expect(manager.isOpen()).toBe(false);
|
||||
manager.open();
|
||||
expect(manager.isOpen()).toBe(true);
|
||||
});
|
||||
|
||||
test('prevents body scroll', () => {
|
||||
manager.open();
|
||||
expect(document.body.style.overflow).toBe('hidden');
|
||||
});
|
||||
|
||||
test('does not open again if already open', () => {
|
||||
manager.open();
|
||||
manager.open(); // second call should be no-op
|
||||
expect(manager.isOpen()).toBe(true);
|
||||
});
|
||||
|
||||
test('calls onOpen callback', () => {
|
||||
const onOpen = jest.fn();
|
||||
manager.destroy();
|
||||
manager = new ModalManager(modal, { onOpen });
|
||||
manager.open();
|
||||
expect(onOpen).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test('dispatches modal:open custom event', () => {
|
||||
const handler = jest.fn();
|
||||
modal.addEventListener('modal:open', handler);
|
||||
manager.open();
|
||||
expect(handler).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
// =========================================================
|
||||
// close()
|
||||
// =========================================================
|
||||
|
||||
describe('close()', () => {
|
||||
beforeEach(() => {
|
||||
modal = createModalElement();
|
||||
manager = new ModalManager(modal);
|
||||
manager.open();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
manager.destroy();
|
||||
while (document.body.firstChild) {
|
||||
document.body.removeChild(document.body.firstChild);
|
||||
}
|
||||
document.body.style.overflow = '';
|
||||
test('removes open class', () => {
|
||||
expect(modal.classList.contains('open')).toBe(true);
|
||||
manager.close();
|
||||
expect(modal.classList.contains('open')).toBe(false);
|
||||
});
|
||||
|
||||
// =========================================================
|
||||
// open()
|
||||
// =========================================================
|
||||
|
||||
describe('open()', () => {
|
||||
test('removes hidden class and adds open class', () => {
|
||||
expect(modal.classList.contains('hidden')).toBe(true);
|
||||
expect(modal.classList.contains('open')).toBe(false);
|
||||
|
||||
manager.open();
|
||||
|
||||
expect(modal.classList.contains('hidden')).toBe(false);
|
||||
expect(modal.classList.contains('open')).toBe(true);
|
||||
});
|
||||
|
||||
test('sets isOpen to true', () => {
|
||||
expect(manager.isOpen()).toBe(false);
|
||||
manager.open();
|
||||
expect(manager.isOpen()).toBe(true);
|
||||
});
|
||||
|
||||
test('prevents body scroll', () => {
|
||||
manager.open();
|
||||
expect(document.body.style.overflow).toBe('hidden');
|
||||
});
|
||||
|
||||
test('does not open again if already open', () => {
|
||||
manager.open();
|
||||
manager.open(); // second call should be no-op
|
||||
expect(manager.isOpen()).toBe(true);
|
||||
});
|
||||
|
||||
test('calls onOpen callback', () => {
|
||||
const onOpen = jest.fn();
|
||||
manager.destroy();
|
||||
manager = new ModalManager(modal, { onOpen });
|
||||
manager.open();
|
||||
expect(onOpen).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test('dispatches modal:open custom event', () => {
|
||||
const handler = jest.fn();
|
||||
modal.addEventListener('modal:open', handler);
|
||||
manager.open();
|
||||
expect(handler).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
test('sets isOpen to false immediately', () => {
|
||||
manager.close();
|
||||
expect(manager.isOpen()).toBe(false);
|
||||
});
|
||||
|
||||
// =========================================================
|
||||
// close()
|
||||
// =========================================================
|
||||
|
||||
describe('close()', () => {
|
||||
beforeEach(() => {
|
||||
manager.open();
|
||||
});
|
||||
|
||||
test('removes open class', () => {
|
||||
expect(modal.classList.contains('open')).toBe(true);
|
||||
manager.close();
|
||||
expect(modal.classList.contains('open')).toBe(false);
|
||||
});
|
||||
|
||||
test('sets isOpen to false immediately', () => {
|
||||
manager.close();
|
||||
expect(manager.isOpen()).toBe(false);
|
||||
});
|
||||
|
||||
test('restores body scroll when no modals remain open', () => {
|
||||
manager.close();
|
||||
expect(document.body.style.overflow).toBe('');
|
||||
});
|
||||
|
||||
test('adds hidden class after transitionend event fires', () => {
|
||||
manager.close();
|
||||
|
||||
// Immediately after close(): hidden should NOT yet be added —
|
||||
// the close animation is still in progress.
|
||||
// (This is the bug that existed before the fix.)
|
||||
expect(modal.classList.contains('hidden')).toBe(false);
|
||||
|
||||
// Simulate the CSS transition completing
|
||||
const event = new Event('transitionend');
|
||||
Object.defineProperty(event, 'target', { value: modal, writable: false });
|
||||
modal.dispatchEvent(event);
|
||||
|
||||
expect(modal.classList.contains('hidden')).toBe(true);
|
||||
});
|
||||
|
||||
test('adds hidden class via 250ms timeout fallback when transitionend never fires', () => {
|
||||
jest.useFakeTimers();
|
||||
|
||||
manager.close();
|
||||
expect(modal.classList.contains('hidden')).toBe(false);
|
||||
|
||||
// Advance past the fallback timeout (250ms)
|
||||
jest.advanceTimersByTime(300);
|
||||
|
||||
expect(modal.classList.contains('hidden')).toBe(true);
|
||||
|
||||
jest.useRealTimers();
|
||||
});
|
||||
|
||||
test('does not add hidden class if modal is reopened before timeout fires', () => {
|
||||
jest.useFakeTimers();
|
||||
|
||||
manager.close();
|
||||
jest.advanceTimersByTime(100); // halfway through timeout
|
||||
|
||||
// Re-open the modal before the timeout fires
|
||||
manager.open();
|
||||
|
||||
jest.advanceTimersByTime(200); // past original timeout expiry
|
||||
|
||||
// Modal was reopened, so hidden must NOT have been added
|
||||
expect(modal.classList.contains('hidden')).toBe(false);
|
||||
expect(modal.classList.contains('open')).toBe(true);
|
||||
|
||||
jest.useRealTimers();
|
||||
});
|
||||
|
||||
test('calls onClose callback', () => {
|
||||
const onClose = jest.fn();
|
||||
manager.destroy();
|
||||
manager = new ModalManager(modal, { onClose });
|
||||
manager.open();
|
||||
manager.close();
|
||||
expect(onClose).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test('dispatches modal:close custom event', () => {
|
||||
const handler = jest.fn();
|
||||
modal.addEventListener('modal:close', handler);
|
||||
manager.close();
|
||||
expect(handler).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test('is a no-op when modal is already closed', () => {
|
||||
manager.close(); // close from open
|
||||
const onClose = jest.fn();
|
||||
manager.destroy();
|
||||
manager = new ModalManager(modal, { onClose });
|
||||
manager.close(); // call close on an already-closed modal
|
||||
expect(onClose).not.toHaveBeenCalled();
|
||||
});
|
||||
test('restores body scroll when no modals remain open', () => {
|
||||
manager.close();
|
||||
expect(document.body.style.overflow).toBe('');
|
||||
});
|
||||
|
||||
// =========================================================
|
||||
// Keyboard interaction
|
||||
// =========================================================
|
||||
test('adds hidden class after transitionend event fires', () => {
|
||||
manager.close();
|
||||
|
||||
describe('keyboard shortcuts', () => {
|
||||
test('Escape key closes an open modal', () => {
|
||||
manager.open();
|
||||
document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }));
|
||||
expect(manager.isOpen()).toBe(false);
|
||||
});
|
||||
// Immediately after close(): hidden should NOT yet be added —
|
||||
// the close animation is still in progress.
|
||||
// (This is the bug that existed before the fix.)
|
||||
expect(modal.classList.contains('hidden')).toBe(false);
|
||||
|
||||
test('Escape key does nothing when modal is already closed', () => {
|
||||
expect(() => {
|
||||
document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }));
|
||||
}).not.toThrow();
|
||||
});
|
||||
// Simulate the CSS transition completing
|
||||
const event = new Event('transitionend');
|
||||
Object.defineProperty(event, 'target', { value: modal, writable: false });
|
||||
modal.dispatchEvent(event);
|
||||
|
||||
test('closeOnEscape: false prevents Escape from closing', () => {
|
||||
manager.destroy();
|
||||
manager = new ModalManager(modal, { closeOnEscape: false });
|
||||
manager.open();
|
||||
document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }));
|
||||
expect(manager.isOpen()).toBe(true);
|
||||
});
|
||||
expect(modal.classList.contains('hidden')).toBe(true);
|
||||
});
|
||||
|
||||
// =========================================================
|
||||
// Close triggers
|
||||
// =========================================================
|
||||
test('adds hidden class via 250ms timeout fallback when transitionend never fires', () => {
|
||||
jest.useFakeTimers();
|
||||
|
||||
describe('close triggers', () => {
|
||||
test('clicking the × close button closes the modal', () => {
|
||||
manager.open();
|
||||
modal.querySelector('.modal-close').click();
|
||||
expect(manager.isOpen()).toBe(false);
|
||||
});
|
||||
manager.close();
|
||||
expect(modal.classList.contains('hidden')).toBe(false);
|
||||
|
||||
test('clicking backdrop (data-close) closes the modal', () => {
|
||||
manager.open();
|
||||
modal.querySelector('.modal-backdrop').click();
|
||||
expect(manager.isOpen()).toBe(false);
|
||||
});
|
||||
// Advance past the fallback timeout (250ms)
|
||||
jest.advanceTimersByTime(300);
|
||||
|
||||
test('closeOnBackdrop: false prevents backdrop from closing', () => {
|
||||
manager.destroy();
|
||||
manager = new ModalManager(modal, { closeOnBackdrop: false });
|
||||
manager.open();
|
||||
modal.querySelector('.modal-backdrop').click();
|
||||
expect(manager.isOpen()).toBe(true);
|
||||
});
|
||||
expect(modal.classList.contains('hidden')).toBe(true);
|
||||
|
||||
jest.useRealTimers();
|
||||
});
|
||||
|
||||
// =========================================================
|
||||
// destroy()
|
||||
// =========================================================
|
||||
test('does not add hidden class if modal is reopened before timeout fires', () => {
|
||||
jest.useFakeTimers();
|
||||
|
||||
describe('destroy()', () => {
|
||||
test('closes modal if open', () => {
|
||||
manager.open();
|
||||
manager.destroy();
|
||||
expect(manager.isOpen()).toBe(false);
|
||||
});
|
||||
manager.close();
|
||||
jest.advanceTimersByTime(100); // halfway through timeout
|
||||
|
||||
test('does not throw when destroying a closed modal', () => {
|
||||
expect(() => manager.destroy()).not.toThrow();
|
||||
});
|
||||
// Re-open the modal before the timeout fires
|
||||
manager.open();
|
||||
|
||||
jest.advanceTimersByTime(200); // past original timeout expiry
|
||||
|
||||
// Modal was reopened, so hidden must NOT have been added
|
||||
expect(modal.classList.contains('hidden')).toBe(false);
|
||||
expect(modal.classList.contains('open')).toBe(true);
|
||||
|
||||
jest.useRealTimers();
|
||||
});
|
||||
|
||||
test('calls onClose callback', () => {
|
||||
const onClose = jest.fn();
|
||||
manager.destroy();
|
||||
manager = new ModalManager(modal, { onClose });
|
||||
manager.open();
|
||||
manager.close();
|
||||
expect(onClose).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test('dispatches modal:close custom event', () => {
|
||||
const handler = jest.fn();
|
||||
modal.addEventListener('modal:close', handler);
|
||||
manager.close();
|
||||
expect(handler).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test('is a no-op when modal is already closed', () => {
|
||||
manager.close(); // close from open
|
||||
const onClose = jest.fn();
|
||||
manager.destroy();
|
||||
manager = new ModalManager(modal, { onClose });
|
||||
manager.close(); // call close on an already-closed modal
|
||||
expect(onClose).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
// =========================================================
|
||||
// Keyboard interaction
|
||||
// =========================================================
|
||||
|
||||
describe('keyboard shortcuts', () => {
|
||||
test('Escape key closes an open modal', () => {
|
||||
manager.open();
|
||||
document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }));
|
||||
expect(manager.isOpen()).toBe(false);
|
||||
});
|
||||
|
||||
test('Escape key does nothing when modal is already closed', () => {
|
||||
expect(() => {
|
||||
document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }));
|
||||
}).not.toThrow();
|
||||
});
|
||||
|
||||
test('closeOnEscape: false prevents Escape from closing', () => {
|
||||
manager.destroy();
|
||||
manager = new ModalManager(modal, { closeOnEscape: false });
|
||||
manager.open();
|
||||
document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }));
|
||||
expect(manager.isOpen()).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
// =========================================================
|
||||
// Close triggers
|
||||
// =========================================================
|
||||
|
||||
describe('close triggers', () => {
|
||||
test('clicking the × close button closes the modal', () => {
|
||||
manager.open();
|
||||
modal.querySelector('.modal-close').click();
|
||||
expect(manager.isOpen()).toBe(false);
|
||||
});
|
||||
|
||||
test('clicking backdrop (data-close) closes the modal', () => {
|
||||
manager.open();
|
||||
modal.querySelector('.modal-backdrop').click();
|
||||
expect(manager.isOpen()).toBe(false);
|
||||
});
|
||||
|
||||
test('closeOnBackdrop: false prevents backdrop from closing', () => {
|
||||
manager.destroy();
|
||||
manager = new ModalManager(modal, { closeOnBackdrop: false });
|
||||
manager.open();
|
||||
modal.querySelector('.modal-backdrop').click();
|
||||
expect(manager.isOpen()).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
// =========================================================
|
||||
// destroy()
|
||||
// =========================================================
|
||||
|
||||
describe('destroy()', () => {
|
||||
test('closes modal if open', () => {
|
||||
manager.open();
|
||||
manager.destroy();
|
||||
expect(manager.isOpen()).toBe(false);
|
||||
});
|
||||
|
||||
test('does not throw when destroying a closed modal', () => {
|
||||
expect(() => manager.destroy()).not.toThrow();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -10,7 +10,7 @@ describe('PDFOperations Utilities', () => {
|
||||
// Test logic: parsing "1" should extract page index 0
|
||||
const input = '1';
|
||||
const pages = [0]; // Parsed result
|
||||
|
||||
|
||||
expect(pages.length).toBe(1);
|
||||
expect(pages[0]).toBe(0);
|
||||
});
|
||||
@@ -19,7 +19,7 @@ describe('PDFOperations Utilities', () => {
|
||||
// Test logic: parsing "1-3" should extract pages 0, 1, 2
|
||||
const input = '1-3';
|
||||
const pages = [0, 1, 2]; // Expected result
|
||||
|
||||
|
||||
expect(pages.length).toBe(3);
|
||||
expect(pages).toEqual([0, 1, 2]);
|
||||
});
|
||||
@@ -28,7 +28,7 @@ describe('PDFOperations Utilities', () => {
|
||||
// Test logic: parsing "1-2,4-5" should extract pages 0,1,3,4
|
||||
const input = '1-2,4-5';
|
||||
const pages = [0, 1, 3, 4]; // Expected result
|
||||
|
||||
|
||||
expect(pages.length).toBe(4);
|
||||
expect(pages).toEqual([0, 1, 3, 4]);
|
||||
});
|
||||
@@ -36,7 +36,7 @@ describe('PDFOperations Utilities', () => {
|
||||
it('should sort pages in ascending order', () => {
|
||||
const unsorted = [2, 0, 3, 1];
|
||||
const sorted = unsorted.sort((a, b) => a - b);
|
||||
|
||||
|
||||
expect(sorted).toEqual([0, 1, 2, 3]);
|
||||
});
|
||||
});
|
||||
@@ -45,14 +45,14 @@ describe('PDFOperations Utilities', () => {
|
||||
it('should validate hex color format', () => {
|
||||
const validHex = '#FF5733';
|
||||
const isValid = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.test(validHex);
|
||||
|
||||
|
||||
expect(isValid).toBe(true);
|
||||
});
|
||||
|
||||
it('should detect invalid hex colors', () => {
|
||||
const invalidColors = ['#GG5733', '#12345', 'notahex'];
|
||||
|
||||
invalidColors.forEach(color => {
|
||||
|
||||
invalidColors.forEach((color) => {
|
||||
const isValid = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.test(color);
|
||||
expect(isValid).toBe(false);
|
||||
});
|
||||
@@ -73,12 +73,12 @@ describe('PDFOperations Utilities', () => {
|
||||
{ input: '-1', isValid: false },
|
||||
{ input: 'abc', isValid: false },
|
||||
{ input: '1', isValid: true },
|
||||
{ input: '5', isValid: true }
|
||||
{ input: '5', isValid: true },
|
||||
];
|
||||
|
||||
|
||||
testPages.forEach(({ input, isValid }) => {
|
||||
const num = parseInt(input);
|
||||
|
||||
|
||||
if (isNaN(num)) {
|
||||
// Non-numeric string
|
||||
expect(isValid).toBe(false);
|
||||
|
||||
@@ -13,9 +13,14 @@ describe('PluginContext', () => {
|
||||
statusBar: { registerIndicator: jest.fn() },
|
||||
eventBus: new EventBus(),
|
||||
settings: { get: jest.fn(), set: jest.fn(), onChanged: jest.fn() },
|
||||
editor: { getContent: jest.fn(), getSelection: jest.fn(), insertAtCursor: jest.fn(), onContentChanged: jest.fn() },
|
||||
editor: {
|
||||
getContent: jest.fn(),
|
||||
getSelection: jest.fn(),
|
||||
insertAtCursor: jest.fn(),
|
||||
onContentChanged: jest.fn(),
|
||||
},
|
||||
ipc: { invoke: jest.fn(), on: jest.fn() },
|
||||
exportHooks: { preHooks: [], postHooks: [] }
|
||||
exportHooks: { preHooks: [], postHooks: [] },
|
||||
};
|
||||
context = new PluginContext(mockDeps);
|
||||
});
|
||||
@@ -23,11 +28,17 @@ describe('PluginContext', () => {
|
||||
test('exposes sidebar.registerPanel with namespaced id', () => {
|
||||
const handler = jest.fn();
|
||||
context.sidebar.registerPanel('my-panel', { icon: 'test', title: 'Test', render: handler });
|
||||
expect(mockDeps.sidebar.registerPanel).toHaveBeenCalledWith('test-plugin:my-panel', { icon: 'test', title: 'Test', render: handler });
|
||||
expect(mockDeps.sidebar.registerPanel).toHaveBeenCalledWith('test-plugin:my-panel', {
|
||||
icon: 'test',
|
||||
title: 'Test',
|
||||
render: handler,
|
||||
});
|
||||
});
|
||||
|
||||
test('exposes commands.register with crash-safe wrapper', () => {
|
||||
const badHandler = () => { throw new Error('boom'); };
|
||||
const badHandler = () => {
|
||||
throw new Error('boom');
|
||||
};
|
||||
context.commands.register('bad-cmd', 'Bad', badHandler, 'Ctrl+Alt+T');
|
||||
const registeredHandler = mockDeps.commands.register.mock.calls[0][2];
|
||||
expect(() => registeredHandler()).not.toThrow();
|
||||
|
||||
@@ -25,7 +25,7 @@ describe('PluginLoader', () => {
|
||||
id: 'my-plugin',
|
||||
name: 'My Plugin',
|
||||
version: '1.0.0',
|
||||
description: 'Test'
|
||||
description: 'Test',
|
||||
});
|
||||
const loader = new PluginLoader([tempDir]);
|
||||
const plugins = loader.discoverPlugins();
|
||||
@@ -46,7 +46,7 @@ describe('PluginLoader', () => {
|
||||
id: 'test-plugin',
|
||||
name: 'Test Plugin',
|
||||
version: '1.0.0',
|
||||
description: 'A test plugin'
|
||||
description: 'A test plugin',
|
||||
};
|
||||
const loader = new PluginLoader([]);
|
||||
expect(loader.validateManifest(manifest)).toBe(true);
|
||||
@@ -67,18 +67,32 @@ describe('PluginLoader', () => {
|
||||
test('validateManifest — rejects manifest with duplicate id', () => {
|
||||
const loader = new PluginLoader([]);
|
||||
loader.loadedIds = new Set(['existing-plugin']);
|
||||
expect(() => loader.validateManifest({ id: 'existing-plugin', name: 'Dup', version: '1.0.0', description: 'x' }))
|
||||
.toThrow(/duplicate/i);
|
||||
expect(() =>
|
||||
loader.validateManifest({
|
||||
id: 'existing-plugin',
|
||||
name: 'Dup',
|
||||
version: '1.0.0',
|
||||
description: 'x',
|
||||
})
|
||||
).toThrow(/duplicate/i);
|
||||
});
|
||||
|
||||
test('discoverPlugins — loads index.js Plugin export if present', () => {
|
||||
const pluginDir = path.join(tempDir, 'with-index');
|
||||
writeManifest(pluginDir, { id: 'with-index', name: 'With Index', version: '1.0.0', description: 'Test' });
|
||||
writeManifest(pluginDir, {
|
||||
id: 'with-index',
|
||||
name: 'With Index',
|
||||
version: '1.0.0',
|
||||
description: 'Test',
|
||||
});
|
||||
// Write a simple index.js with a Plugin export
|
||||
fs.writeFileSync(path.join(pluginDir, 'index.js'), `
|
||||
fs.writeFileSync(
|
||||
path.join(pluginDir, 'index.js'),
|
||||
`
|
||||
class SimplePlugin { init() {} }
|
||||
module.exports = { Plugin: SimplePlugin };
|
||||
`);
|
||||
`
|
||||
);
|
||||
const loader = new PluginLoader([tempDir]);
|
||||
const plugins = loader.discoverPlugins();
|
||||
expect(plugins).toHaveLength(1);
|
||||
|
||||
@@ -3,9 +3,16 @@ const { PluginAPI } = require('../src/plugins/plugin-api');
|
||||
const { EventBus } = require('../src/plugins/event-bus');
|
||||
|
||||
class TestPlugin extends PluginAPI {
|
||||
init(context) { this.initialized = true; this.ctx = context; }
|
||||
activate() { this.activated = true; }
|
||||
deactivate() { this.deactivated = true; }
|
||||
init(context) {
|
||||
this.initialized = true;
|
||||
this.ctx = context;
|
||||
}
|
||||
activate() {
|
||||
this.activated = true;
|
||||
}
|
||||
deactivate() {
|
||||
this.deactivated = true;
|
||||
}
|
||||
}
|
||||
|
||||
describe('PluginRegistry', () => {
|
||||
@@ -19,16 +26,26 @@ describe('PluginRegistry', () => {
|
||||
statusBar: { registerIndicator: jest.fn() },
|
||||
eventBus: new EventBus(),
|
||||
settings: { get: jest.fn(), set: jest.fn(), onChanged: jest.fn() },
|
||||
editor: { getContent: jest.fn(() => ''), getSelection: jest.fn(() => ''), insertAtCursor: jest.fn(), onContentChanged: jest.fn() },
|
||||
ipc: { invoke: jest.fn(), on: jest.fn() }
|
||||
editor: {
|
||||
getContent: jest.fn(() => ''),
|
||||
getSelection: jest.fn(() => ''),
|
||||
insertAtCursor: jest.fn(),
|
||||
onContentChanged: jest.fn(),
|
||||
},
|
||||
ipc: { invoke: jest.fn(), on: jest.fn() },
|
||||
};
|
||||
registry = new PluginRegistry(mockDeps);
|
||||
});
|
||||
|
||||
test('register — stores plugin and calls init', () => {
|
||||
registry.register({
|
||||
id: 'test', name: 'Test', version: '1.0.0', description: 'desc',
|
||||
manifest: {}, PluginClass: TestPlugin, dir: '/tmp/test'
|
||||
id: 'test',
|
||||
name: 'Test',
|
||||
version: '1.0.0',
|
||||
description: 'desc',
|
||||
manifest: {},
|
||||
PluginClass: TestPlugin,
|
||||
dir: '/tmp/test',
|
||||
});
|
||||
const entry = registry.getPlugin('test');
|
||||
expect(entry).toBeDefined();
|
||||
@@ -37,20 +54,32 @@ describe('PluginRegistry', () => {
|
||||
|
||||
test('register — works without PluginClass', () => {
|
||||
registry.register({
|
||||
id: 'manifest-only', name: 'Manifest Only', version: '1.0.0', description: 'desc',
|
||||
manifest: {}, PluginClass: null, dir: '/tmp/test'
|
||||
id: 'manifest-only',
|
||||
name: 'Manifest Only',
|
||||
version: '1.0.0',
|
||||
description: 'desc',
|
||||
manifest: {},
|
||||
PluginClass: null,
|
||||
dir: '/tmp/test',
|
||||
});
|
||||
expect(registry.getPlugin('manifest-only')).toBeDefined();
|
||||
});
|
||||
|
||||
test('register — init error does not crash, plugin not registered', () => {
|
||||
class BadPlugin extends PluginAPI {
|
||||
init() { throw new Error('init fail'); }
|
||||
init() {
|
||||
throw new Error('init fail');
|
||||
}
|
||||
}
|
||||
expect(() => {
|
||||
registry.register({
|
||||
id: 'bad', name: 'Bad', version: '1.0.0', description: 'desc',
|
||||
manifest: {}, PluginClass: BadPlugin, dir: '/tmp/test'
|
||||
id: 'bad',
|
||||
name: 'Bad',
|
||||
version: '1.0.0',
|
||||
description: 'desc',
|
||||
manifest: {},
|
||||
PluginClass: BadPlugin,
|
||||
dir: '/tmp/test',
|
||||
});
|
||||
}).not.toThrow();
|
||||
expect(registry.getPlugin('bad')).toBeUndefined();
|
||||
@@ -61,25 +90,65 @@ describe('PluginRegistry', () => {
|
||||
});
|
||||
|
||||
test('getAll — returns all registered plugins', () => {
|
||||
registry.register({ id: 'a', name: 'A', version: '1', description: '', manifest: {}, PluginClass: null, dir: '' });
|
||||
registry.register({ id: 'b', name: 'B', version: '1', description: '', manifest: {}, PluginClass: null, dir: '' });
|
||||
registry.register({
|
||||
id: 'a',
|
||||
name: 'A',
|
||||
version: '1',
|
||||
description: '',
|
||||
manifest: {},
|
||||
PluginClass: null,
|
||||
dir: '',
|
||||
});
|
||||
registry.register({
|
||||
id: 'b',
|
||||
name: 'B',
|
||||
version: '1',
|
||||
description: '',
|
||||
manifest: {},
|
||||
PluginClass: null,
|
||||
dir: '',
|
||||
});
|
||||
expect(registry.getAll()).toHaveLength(2);
|
||||
});
|
||||
|
||||
test('activate — calls activate on plugin instance', () => {
|
||||
registry.register({ id: 'test', name: 'Test', version: '1', description: '', manifest: {}, PluginClass: TestPlugin, dir: '' });
|
||||
registry.register({
|
||||
id: 'test',
|
||||
name: 'Test',
|
||||
version: '1',
|
||||
description: '',
|
||||
manifest: {},
|
||||
PluginClass: TestPlugin,
|
||||
dir: '',
|
||||
});
|
||||
registry.activate('test');
|
||||
expect(registry.getPlugin('test').instance.activated).toBe(true);
|
||||
});
|
||||
|
||||
test('deactivate — calls deactivate on plugin instance', () => {
|
||||
registry.register({ id: 'test', name: 'Test', version: '1', description: '', manifest: {}, PluginClass: TestPlugin, dir: '' });
|
||||
registry.register({
|
||||
id: 'test',
|
||||
name: 'Test',
|
||||
version: '1',
|
||||
description: '',
|
||||
manifest: {},
|
||||
PluginClass: TestPlugin,
|
||||
dir: '',
|
||||
});
|
||||
registry.deactivate('test');
|
||||
expect(registry.getPlugin('test').instance.deactivated).toBe(true);
|
||||
});
|
||||
|
||||
test('exportHooks are available and populated', () => {
|
||||
registry.register({ id: 'test', name: 'Test', version: '1', description: '', manifest: {}, PluginClass: TestPlugin, dir: '' });
|
||||
registry.register({
|
||||
id: 'test',
|
||||
name: 'Test',
|
||||
version: '1',
|
||||
description: '',
|
||||
manifest: {},
|
||||
PluginClass: TestPlugin,
|
||||
dir: '',
|
||||
});
|
||||
const handler = jest.fn();
|
||||
registry.getPlugin('test').instance.ctx.exports.registerPreHook(handler);
|
||||
expect(registry.exportHooks.preHooks).toContain(handler);
|
||||
|
||||
@@ -72,7 +72,7 @@ describe('Preload Security', () => {
|
||||
'execute-code',
|
||||
'show-pdf-editor-from-toolbar',
|
||||
'menu-open',
|
||||
'export'
|
||||
'export',
|
||||
];
|
||||
|
||||
const EXPECTED_RECEIVE_CHANNELS = [
|
||||
@@ -118,7 +118,7 @@ describe('Preload Security', () => {
|
||||
'insert-content',
|
||||
'load-template-menu',
|
||||
'toggle-sidebar-panel',
|
||||
'toggle-bottom-panel'
|
||||
'toggle-bottom-panel',
|
||||
];
|
||||
|
||||
test('should define all expected send channels', () => {
|
||||
|
||||
@@ -8,15 +8,22 @@ describe('ProjectManager', () => {
|
||||
files = {};
|
||||
pm = new ProjectManager({
|
||||
readFile: (p) => files[p] || null,
|
||||
writeFile: (p, c) => { files[p] = c; },
|
||||
writeFile: (p, c) => {
|
||||
files[p] = c;
|
||||
},
|
||||
fileExists: (p) => p in files,
|
||||
listDir: (p) => Object.keys(files).filter(f => f.startsWith(p)).map(f => f.slice(p.length + 1))
|
||||
listDir: (p) =>
|
||||
Object.keys(files)
|
||||
.filter((f) => f.startsWith(p))
|
||||
.map((f) => f.slice(p.length + 1)),
|
||||
});
|
||||
});
|
||||
|
||||
test('createProject writes .project.json', () => {
|
||||
const project = pm.createProject('/manuscripts/novel', {
|
||||
title: 'My Novel', type: 'manuscript', targetWords: 80000
|
||||
title: 'My Novel',
|
||||
type: 'manuscript',
|
||||
targetWords: 80000,
|
||||
});
|
||||
expect(project.title).toBe('My Novel');
|
||||
expect(files['/manuscripts/novel/.project.json']).toBeDefined();
|
||||
@@ -26,8 +33,11 @@ describe('ProjectManager', () => {
|
||||
|
||||
test('loadProject reads and returns project data', () => {
|
||||
files['/manuscripts/novel/.project.json'] = JSON.stringify({
|
||||
title: 'Test', type: 'manuscript', target: { words: 50000 },
|
||||
chapters: [], metadata: {}
|
||||
title: 'Test',
|
||||
type: 'manuscript',
|
||||
target: { words: 50000 },
|
||||
chapters: [],
|
||||
metadata: {},
|
||||
});
|
||||
const project = pm.loadProject('/manuscripts/novel');
|
||||
expect(project.title).toBe('Test');
|
||||
@@ -39,10 +49,17 @@ describe('ProjectManager', () => {
|
||||
|
||||
test('addChapter appends chapter and saves', () => {
|
||||
files['/manuscripts/novel/.project.json'] = JSON.stringify({
|
||||
title: 'Test', type: 'manuscript', target: { words: 50000 },
|
||||
chapters: [], metadata: {}
|
||||
title: 'Test',
|
||||
type: 'manuscript',
|
||||
target: { words: 50000 },
|
||||
chapters: [],
|
||||
metadata: {},
|
||||
});
|
||||
pm.addChapter('/manuscripts/novel', {
|
||||
file: '01-chapter.md',
|
||||
title: 'Chapter One',
|
||||
status: 'draft',
|
||||
});
|
||||
pm.addChapter('/manuscripts/novel', { file: '01-chapter.md', title: 'Chapter One', status: 'draft' });
|
||||
const parsed = JSON.parse(files['/manuscripts/novel/.project.json']);
|
||||
expect(parsed.chapters.length).toBe(1);
|
||||
expect(parsed.chapters[0].title).toBe('Chapter One');
|
||||
@@ -50,11 +67,14 @@ describe('ProjectManager', () => {
|
||||
|
||||
test('compileManuscript concatenates chapter files', () => {
|
||||
files['/manuscripts/novel/.project.json'] = JSON.stringify({
|
||||
title: 'Test', type: 'manuscript', target: { words: 50000 },
|
||||
title: 'Test',
|
||||
type: 'manuscript',
|
||||
target: { words: 50000 },
|
||||
chapters: [
|
||||
{ file: '01.md', title: 'One', status: 'draft' },
|
||||
{ file: '02.md', title: 'Two', status: 'draft' }
|
||||
], metadata: {}
|
||||
{ file: '02.md', title: 'Two', status: 'draft' },
|
||||
],
|
||||
metadata: {},
|
||||
});
|
||||
files['/manuscripts/novel/01.md'] = 'First chapter content.';
|
||||
files['/manuscripts/novel/02.md'] = 'Second chapter content.';
|
||||
@@ -64,11 +84,14 @@ describe('ProjectManager', () => {
|
||||
|
||||
test('compileManuscript skips missing files', () => {
|
||||
files['/manuscripts/novel/.project.json'] = JSON.stringify({
|
||||
title: 'Test', type: 'manuscript', target: { words: 50000 },
|
||||
title: 'Test',
|
||||
type: 'manuscript',
|
||||
target: { words: 50000 },
|
||||
chapters: [
|
||||
{ file: '01.md', title: 'One', status: 'draft' },
|
||||
{ file: '02.md', title: 'Two', status: 'draft' }
|
||||
], metadata: {}
|
||||
{ file: '02.md', title: 'Two', status: 'draft' },
|
||||
],
|
||||
metadata: {},
|
||||
});
|
||||
files['/manuscripts/novel/01.md'] = 'Only chapter one.';
|
||||
const result = pm.compileManuscript('/manuscripts/novel');
|
||||
@@ -77,11 +100,14 @@ describe('ProjectManager', () => {
|
||||
|
||||
test('getStats returns total word count across chapters', () => {
|
||||
files['/manuscripts/novel/.project.json'] = JSON.stringify({
|
||||
title: 'Test', type: 'manuscript', target: { words: 50000 },
|
||||
title: 'Test',
|
||||
type: 'manuscript',
|
||||
target: { words: 50000 },
|
||||
chapters: [
|
||||
{ file: '01.md', title: 'One', status: 'draft' },
|
||||
{ file: '02.md', title: 'Two', status: 'draft' }
|
||||
], metadata: {}
|
||||
{ file: '02.md', title: 'Two', status: 'draft' },
|
||||
],
|
||||
metadata: {},
|
||||
});
|
||||
files['/manuscripts/novel/01.md'] = 'word '.repeat(100).trim();
|
||||
files['/manuscripts/novel/02.md'] = 'more '.repeat(50).trim();
|
||||
@@ -94,9 +120,11 @@ describe('ProjectManager', () => {
|
||||
|
||||
test('updateChapter modifies a chapter by index', () => {
|
||||
files['/manuscripts/novel/.project.json'] = JSON.stringify({
|
||||
title: 'Test', type: 'manuscript', target: { words: 50000 },
|
||||
title: 'Test',
|
||||
type: 'manuscript',
|
||||
target: { words: 50000 },
|
||||
chapters: [{ file: '01.md', title: 'Old Title', status: 'draft' }],
|
||||
metadata: {}
|
||||
metadata: {},
|
||||
});
|
||||
pm.updateChapter('/manuscripts/novel', 0, { title: 'New Title', status: 'revised' });
|
||||
const parsed = JSON.parse(files['/manuscripts/novel/.project.json']);
|
||||
|
||||
@@ -8,25 +8,18 @@ const path = require('path');
|
||||
describe('Security: Path Handling', () => {
|
||||
describe('path traversal prevention', () => {
|
||||
it('should detect path traversal patterns', () => {
|
||||
const maliciousPaths = [
|
||||
'../etc/passwd',
|
||||
'../../sensitive',
|
||||
'./../outside'
|
||||
];
|
||||
const maliciousPaths = ['../etc/passwd', '../../sensitive', './../outside'];
|
||||
|
||||
maliciousPaths.forEach(pathStr => {
|
||||
maliciousPaths.forEach((pathStr) => {
|
||||
// Path traversal attempts contain .. patterns
|
||||
expect(pathStr).toMatch(/\.\./);
|
||||
});
|
||||
});
|
||||
|
||||
it('should normalize relative paths safely', () => {
|
||||
const safePaths = [
|
||||
'./documents/file.md',
|
||||
'relative/path/file.txt'
|
||||
];
|
||||
const safePaths = ['./documents/file.md', 'relative/path/file.txt'];
|
||||
|
||||
safePaths.forEach(pathStr => {
|
||||
safePaths.forEach((pathStr) => {
|
||||
const normalized = path.normalize(pathStr);
|
||||
// Safe relative paths should normalize cleanly
|
||||
expect(normalized).toBeDefined();
|
||||
@@ -37,7 +30,7 @@ describe('Security: Path Handling', () => {
|
||||
it('should detect absolute paths', () => {
|
||||
const absolutePath = '/etc/passwd';
|
||||
const isAbsolute = path.isAbsolute(absolutePath);
|
||||
|
||||
|
||||
// Linux/Mac: /path is absolute
|
||||
if (process.platform !== 'win32') {
|
||||
expect(isAbsolute).toBe(true);
|
||||
@@ -47,9 +40,9 @@ describe('Security: Path Handling', () => {
|
||||
it('should safely join paths with base directory', () => {
|
||||
const baseDir = '/safe/base/directory';
|
||||
const userInput = 'documents/file.md';
|
||||
|
||||
|
||||
const joined = path.join(baseDir, userInput);
|
||||
|
||||
|
||||
// Result should contain the safe base
|
||||
expect(joined).toContain('base');
|
||||
expect(joined).toContain('documents');
|
||||
@@ -58,14 +51,9 @@ describe('Security: Path Handling', () => {
|
||||
|
||||
describe('filename safety', () => {
|
||||
it('should identify safe filenames', () => {
|
||||
const safeNames = [
|
||||
'document.md',
|
||||
'my-file.txt',
|
||||
'file_name.pdf',
|
||||
'report_2026_04_24.xlsx'
|
||||
];
|
||||
const safeNames = ['document.md', 'my-file.txt', 'file_name.pdf', 'report_2026_04_24.xlsx'];
|
||||
|
||||
safeNames.forEach(name => {
|
||||
safeNames.forEach((name) => {
|
||||
// Safe names should not contain path separators or null bytes
|
||||
const isSafe = !/[\\/\0]/.test(name) && name.length > 0;
|
||||
expect(isSafe).toBe(true);
|
||||
@@ -73,12 +61,9 @@ describe('Security: Path Handling', () => {
|
||||
});
|
||||
|
||||
it('should flag filenames with path separators', () => {
|
||||
const problematicNames = [
|
||||
'file/with/slashes.txt',
|
||||
'file\\with\\backslashes.txt'
|
||||
];
|
||||
const problematicNames = ['file/with/slashes.txt', 'file\\with\\backslashes.txt'];
|
||||
|
||||
problematicNames.forEach(name => {
|
||||
problematicNames.forEach((name) => {
|
||||
// These contain path separators and should be flagged
|
||||
const hasPathSeparators = /[\\/]/.test(name);
|
||||
expect(hasPathSeparators).toBe(true);
|
||||
@@ -87,7 +72,7 @@ describe('Security: Path Handling', () => {
|
||||
|
||||
it('should enforce minimum filename length', () => {
|
||||
const emptyName = '';
|
||||
|
||||
|
||||
expect(emptyName.length).toBe(0);
|
||||
expect(emptyName.length > 0).toBe(false);
|
||||
});
|
||||
@@ -96,9 +81,9 @@ describe('Security: Path Handling', () => {
|
||||
describe('validation patterns', () => {
|
||||
it('should validate path existence check pattern', () => {
|
||||
const validationPattern = /^[a-zA-Z0-9._\-/]+$/;
|
||||
|
||||
|
||||
const validPaths = ['documents/file.md', 'folder_2026/data.csv'];
|
||||
validPaths.forEach(pathStr => {
|
||||
validPaths.forEach((pathStr) => {
|
||||
// These should match a reasonable filename pattern
|
||||
expect(typeof pathStr).toBe('string');
|
||||
});
|
||||
@@ -106,7 +91,7 @@ describe('Security: Path Handling', () => {
|
||||
|
||||
it('should prevent null byte injection', () => {
|
||||
const pathWithNullByte = 'file.txt\0.exe';
|
||||
|
||||
|
||||
const isSafe = !pathWithNullByte.includes('\0');
|
||||
expect(isSafe).toBe(false); // Has null byte, not safe
|
||||
});
|
||||
|
||||
@@ -8,7 +8,9 @@ describe('SettingsStore', () => {
|
||||
data = {};
|
||||
store = new SettingsStore({
|
||||
get: (key) => data[key],
|
||||
set: (key, value) => { data[key] = value; }
|
||||
set: (key, value) => {
|
||||
data[key] = value;
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
+14
-14
@@ -25,61 +25,61 @@ global.window.electronAPI = {
|
||||
exists: jest.fn(() => Promise.resolve(false)),
|
||||
isDirectory: jest.fn(() => Promise.resolve(false)),
|
||||
copy: jest.fn(() => Promise.resolve()),
|
||||
move: jest.fn(() => Promise.resolve())
|
||||
move: jest.fn(() => Promise.resolve()),
|
||||
},
|
||||
theme: {
|
||||
get: jest.fn()
|
||||
get: jest.fn(),
|
||||
},
|
||||
print: {
|
||||
doPrint: jest.fn()
|
||||
doPrint: jest.fn(),
|
||||
},
|
||||
export: {
|
||||
withOptions: jest.fn(),
|
||||
spreadsheet: jest.fn()
|
||||
spreadsheet: jest.fn(),
|
||||
},
|
||||
batch: {
|
||||
convert: jest.fn(),
|
||||
selectFolder: jest.fn()
|
||||
selectFolder: jest.fn(),
|
||||
},
|
||||
converter: {
|
||||
convert: jest.fn(),
|
||||
convertBatch: jest.fn()
|
||||
convertBatch: jest.fn(),
|
||||
},
|
||||
headerFooter: {
|
||||
getSettings: jest.fn(),
|
||||
saveSettings: jest.fn(),
|
||||
browseLogo: jest.fn(),
|
||||
saveLogo: jest.fn(),
|
||||
clearLogo: jest.fn()
|
||||
clearLogo: jest.fn(),
|
||||
},
|
||||
page: {
|
||||
getSettings: jest.fn(),
|
||||
updateSettings: jest.fn(),
|
||||
setCustomStartPage: jest.fn()
|
||||
setCustomStartPage: jest.fn(),
|
||||
},
|
||||
pdf: {
|
||||
processOperation: jest.fn(),
|
||||
getPageCount: jest.fn(),
|
||||
selectFolder: jest.fn()
|
||||
}
|
||||
selectFolder: jest.fn(),
|
||||
},
|
||||
};
|
||||
|
||||
// Mock marked library
|
||||
global.window.marked = {
|
||||
parse: jest.fn((text) => `<p>${text}</p>`),
|
||||
use: jest.fn()
|
||||
use: jest.fn(),
|
||||
};
|
||||
|
||||
// Mock DOMPurify
|
||||
global.window.DOMPurify = {
|
||||
sanitize: jest.fn((html) => html)
|
||||
sanitize: jest.fn((html) => html),
|
||||
};
|
||||
|
||||
// Mock highlight.js
|
||||
global.window.hljs = {
|
||||
highlight: jest.fn((code, options) => ({ value: code })),
|
||||
highlightAuto: jest.fn((code) => ({ value: code })),
|
||||
getLanguage: jest.fn(() => true)
|
||||
getLanguage: jest.fn(() => true),
|
||||
};
|
||||
|
||||
// Mock localStorage
|
||||
@@ -87,7 +87,7 @@ const localStorageMock = {
|
||||
getItem: jest.fn(),
|
||||
setItem: jest.fn(),
|
||||
removeItem: jest.fn(),
|
||||
clear: jest.fn()
|
||||
clear: jest.fn(),
|
||||
};
|
||||
global.localStorage = localStorageMock;
|
||||
|
||||
|
||||
+97
-82
@@ -8,10 +8,10 @@
|
||||
*/
|
||||
|
||||
describe('SidebarManager', () => {
|
||||
let SidebarManager;
|
||||
let SidebarManager;
|
||||
|
||||
beforeEach(() => {
|
||||
document.body.innerHTML = `
|
||||
beforeEach(() => {
|
||||
document.body.innerHTML = `
|
||||
<div class="sidebar collapsed" id="sidebar">
|
||||
<div class="sidebar-icons">
|
||||
<button class="sidebar-icon" data-panel="test1"></button>
|
||||
@@ -26,96 +26,111 @@ describe('SidebarManager', () => {
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
SidebarManager = require('../src/sidebar/sidebar-manager').SidebarManager;
|
||||
});
|
||||
SidebarManager = require('../src/sidebar/sidebar-manager').SidebarManager;
|
||||
});
|
||||
|
||||
test('starts collapsed', () => {
|
||||
const mgr = new SidebarManager();
|
||||
expect(document.getElementById('sidebar').classList.contains('collapsed')).toBe(true);
|
||||
});
|
||||
test('starts collapsed', () => {
|
||||
const mgr = new SidebarManager();
|
||||
expect(document.getElementById('sidebar').classList.contains('collapsed')).toBe(true);
|
||||
});
|
||||
|
||||
test('expands on panel toggle', () => {
|
||||
const mgr = new SidebarManager();
|
||||
mgr.registerPanel('test1', { title: 'Test 1', render: (c) => { c.innerHTML = 'hello'; } });
|
||||
mgr.togglePanel('test1');
|
||||
expect(document.getElementById('sidebar').classList.contains('collapsed')).toBe(false);
|
||||
expect(document.querySelector('.sidebar-panel-title').textContent).toBe('Test 1');
|
||||
test('expands on panel toggle', () => {
|
||||
const mgr = new SidebarManager();
|
||||
mgr.registerPanel('test1', {
|
||||
title: 'Test 1',
|
||||
render: (c) => {
|
||||
c.innerHTML = 'hello';
|
||||
},
|
||||
});
|
||||
mgr.togglePanel('test1');
|
||||
expect(document.getElementById('sidebar').classList.contains('collapsed')).toBe(false);
|
||||
expect(document.querySelector('.sidebar-panel-title').textContent).toBe('Test 1');
|
||||
});
|
||||
|
||||
test('collapses on second toggle', () => {
|
||||
const mgr = new SidebarManager();
|
||||
mgr.registerPanel('test1', { title: 'Test 1', render: () => {} });
|
||||
mgr.togglePanel('test1');
|
||||
mgr.togglePanel('test1');
|
||||
expect(document.getElementById('sidebar').classList.contains('collapsed')).toBe(true);
|
||||
});
|
||||
test('collapses on second toggle', () => {
|
||||
const mgr = new SidebarManager();
|
||||
mgr.registerPanel('test1', { title: 'Test 1', render: () => {} });
|
||||
mgr.togglePanel('test1');
|
||||
mgr.togglePanel('test1');
|
||||
expect(document.getElementById('sidebar').classList.contains('collapsed')).toBe(true);
|
||||
});
|
||||
|
||||
test('switches panels', () => {
|
||||
const mgr = new SidebarManager();
|
||||
mgr.registerPanel('test1', { title: 'Panel 1', render: (c) => { c.innerHTML = 'one'; } });
|
||||
mgr.registerPanel('test2', { title: 'Panel 2', render: (c) => { c.innerHTML = 'two'; } });
|
||||
mgr.togglePanel('test1');
|
||||
mgr.togglePanel('test2');
|
||||
expect(document.querySelector('.sidebar-panel-title').textContent).toBe('Panel 2');
|
||||
expect(document.getElementById('sidebar-panel-content').innerHTML).toBe('two');
|
||||
test('switches panels', () => {
|
||||
const mgr = new SidebarManager();
|
||||
mgr.registerPanel('test1', {
|
||||
title: 'Panel 1',
|
||||
render: (c) => {
|
||||
c.innerHTML = 'one';
|
||||
},
|
||||
});
|
||||
mgr.registerPanel('test2', {
|
||||
title: 'Panel 2',
|
||||
render: (c) => {
|
||||
c.innerHTML = 'two';
|
||||
},
|
||||
});
|
||||
mgr.togglePanel('test1');
|
||||
mgr.togglePanel('test2');
|
||||
expect(document.querySelector('.sidebar-panel-title').textContent).toBe('Panel 2');
|
||||
expect(document.getElementById('sidebar-panel-content').innerHTML).toBe('two');
|
||||
});
|
||||
|
||||
test('collapse resets active panel', () => {
|
||||
const mgr = new SidebarManager();
|
||||
mgr.registerPanel('test1', { title: 'Test', render: () => {} });
|
||||
mgr.expand('test1');
|
||||
mgr.collapse();
|
||||
expect(mgr.activePanel).toBe(null);
|
||||
});
|
||||
test('collapse resets active panel', () => {
|
||||
const mgr = new SidebarManager();
|
||||
mgr.registerPanel('test1', { title: 'Test', render: () => {} });
|
||||
mgr.expand('test1');
|
||||
mgr.collapse();
|
||||
expect(mgr.activePanel).toBe(null);
|
||||
});
|
||||
|
||||
test('expand sets active icon', () => {
|
||||
const mgr = new SidebarManager();
|
||||
mgr.registerPanel('test1', { title: 'Test 1', render: () => {} });
|
||||
mgr.expand('test1');
|
||||
const btn = document.querySelector('[data-panel="test1"]');
|
||||
expect(btn.classList.contains('active')).toBe(true);
|
||||
});
|
||||
test('expand sets active icon', () => {
|
||||
const mgr = new SidebarManager();
|
||||
mgr.registerPanel('test1', { title: 'Test 1', render: () => {} });
|
||||
mgr.expand('test1');
|
||||
const btn = document.querySelector('[data-panel="test1"]');
|
||||
expect(btn.classList.contains('active')).toBe(true);
|
||||
});
|
||||
|
||||
test('collapse removes active icon', () => {
|
||||
const mgr = new SidebarManager();
|
||||
mgr.registerPanel('test1', { title: 'Test 1', render: () => {} });
|
||||
mgr.expand('test1');
|
||||
mgr.collapse();
|
||||
const btn = document.querySelector('[data-panel="test1"]');
|
||||
expect(btn.classList.contains('active')).toBe(false);
|
||||
});
|
||||
test('collapse removes active icon', () => {
|
||||
const mgr = new SidebarManager();
|
||||
mgr.registerPanel('test1', { title: 'Test 1', render: () => {} });
|
||||
mgr.expand('test1');
|
||||
mgr.collapse();
|
||||
const btn = document.querySelector('[data-panel="test1"]');
|
||||
expect(btn.classList.contains('active')).toBe(false);
|
||||
});
|
||||
|
||||
test('expand with unregistered panel does nothing', () => {
|
||||
const mgr = new SidebarManager();
|
||||
mgr.expand('nonexistent');
|
||||
expect(document.getElementById('sidebar').classList.contains('collapsed')).toBe(true);
|
||||
expect(mgr.activePanel).toBe(null);
|
||||
});
|
||||
test('expand with unregistered panel does nothing', () => {
|
||||
const mgr = new SidebarManager();
|
||||
mgr.expand('nonexistent');
|
||||
expect(document.getElementById('sidebar').classList.contains('collapsed')).toBe(true);
|
||||
expect(mgr.activePanel).toBe(null);
|
||||
});
|
||||
|
||||
test('render function receives panel content element', () => {
|
||||
const mgr = new SidebarManager();
|
||||
const renderFn = jest.fn();
|
||||
mgr.registerPanel('test1', { title: 'Test 1', render: renderFn });
|
||||
mgr.expand('test1');
|
||||
expect(renderFn).toHaveBeenCalledWith(document.getElementById('sidebar-panel-content'));
|
||||
});
|
||||
test('render function receives panel content element', () => {
|
||||
const mgr = new SidebarManager();
|
||||
const renderFn = jest.fn();
|
||||
mgr.registerPanel('test1', { title: 'Test 1', render: renderFn });
|
||||
mgr.expand('test1');
|
||||
expect(renderFn).toHaveBeenCalledWith(document.getElementById('sidebar-panel-content'));
|
||||
});
|
||||
|
||||
test('clicking sidebar icon toggles panel', () => {
|
||||
const mgr = new SidebarManager();
|
||||
mgr.registerPanel('test1', { title: 'Test 1', render: () => {} });
|
||||
const btn = document.querySelector('[data-panel="test1"]');
|
||||
btn.click();
|
||||
expect(mgr.activePanel).toBe('test1');
|
||||
btn.click();
|
||||
expect(mgr.activePanel).toBe(null);
|
||||
});
|
||||
test('clicking sidebar icon toggles panel', () => {
|
||||
const mgr = new SidebarManager();
|
||||
mgr.registerPanel('test1', { title: 'Test 1', render: () => {} });
|
||||
const btn = document.querySelector('[data-panel="test1"]');
|
||||
btn.click();
|
||||
expect(mgr.activePanel).toBe('test1');
|
||||
btn.click();
|
||||
expect(mgr.activePanel).toBe(null);
|
||||
});
|
||||
|
||||
test('clicking close button collapses sidebar', () => {
|
||||
const mgr = new SidebarManager();
|
||||
mgr.registerPanel('test1', { title: 'Test 1', render: () => {} });
|
||||
mgr.expand('test1');
|
||||
document.querySelector('.sidebar-panel-close').click();
|
||||
expect(mgr.activePanel).toBe(null);
|
||||
expect(document.getElementById('sidebar').classList.contains('collapsed')).toBe(true);
|
||||
});
|
||||
test('clicking close button collapses sidebar', () => {
|
||||
const mgr = new SidebarManager();
|
||||
mgr.registerPanel('test1', { title: 'Test 1', render: () => {} });
|
||||
mgr.expand('test1');
|
||||
document.querySelector('.sidebar-panel-close').click();
|
||||
expect(mgr.activePanel).toBe(null);
|
||||
expect(document.getElementById('sidebar').classList.contains('collapsed')).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -8,7 +8,9 @@ describe('SnapshotManager', () => {
|
||||
store = {};
|
||||
manager = new SnapshotManager({
|
||||
get: (key) => store[key],
|
||||
set: (key, value) => { store[key] = value; }
|
||||
set: (key, value) => {
|
||||
store[key] = value;
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@ describe('SprintEngine', () => {
|
||||
beforeEach(() => {
|
||||
events = [];
|
||||
engine = new SprintEngine({
|
||||
onEvent: (name, data) => events.push({ name, data })
|
||||
onEvent: (name, data) => events.push({ name, data }),
|
||||
});
|
||||
});
|
||||
|
||||
@@ -45,7 +45,7 @@ describe('SprintEngine', () => {
|
||||
test('tick auto-stops when time expires and emits sprint:complete', () => {
|
||||
engine.start(1, 100); // 1 minute
|
||||
engine.tick(60 * 1000 + 1); // just past
|
||||
expect(events.some(e => e.name === 'sprint:complete')).toBe(true);
|
||||
expect(events.some((e) => e.name === 'sprint:complete')).toBe(true);
|
||||
expect(engine.isActive()).toBe(false);
|
||||
});
|
||||
|
||||
|
||||
+13
-2
@@ -18,7 +18,12 @@ vi.mock('@/lib/ipc', () => ({
|
||||
},
|
||||
app: { getVersion: vi.fn().mockResolvedValue({ ok: true, data: '5.0.1' }) },
|
||||
menu: { on: vi.fn(() => () => {}) },
|
||||
updater: { check: vi.fn(), install: vi.fn(), getState: vi.fn(), onStatus: vi.fn(() => () => {}) },
|
||||
updater: {
|
||||
check: vi.fn(),
|
||||
install: vi.fn(),
|
||||
getState: vi.fn(),
|
||||
onStatus: vi.fn(() => () => {}),
|
||||
},
|
||||
crash: { read: vi.fn(), openDir: vi.fn(), delete: vi.fn() },
|
||||
},
|
||||
}));
|
||||
@@ -35,7 +40,13 @@ describe('App — print preview event listeners', () => {
|
||||
beforeEach(() => {
|
||||
useCommandStore.setState({ handlers: {} } as any);
|
||||
useAppStore.setState({ modal: { kind: null } } as any);
|
||||
useFileStore.setState({ tree: null, rootPath: null, expanded: new Set(), openTabs: [], activeTabId: null });
|
||||
useFileStore.setState({
|
||||
tree: null,
|
||||
rootPath: null,
|
||||
expanded: new Set(),
|
||||
openTabs: [],
|
||||
activeTabId: null,
|
||||
});
|
||||
useSettingsStore.getState().resetToDefaults?.();
|
||||
useEditorStore.setState({ buffers: new Map(), activeId: null });
|
||||
localStorage.clear();
|
||||
|
||||
@@ -10,8 +10,13 @@ describe('modal commands', () => {
|
||||
localStorage.clear();
|
||||
useCommandStore.setState({ handlers: {}, userBindings: {} } as any);
|
||||
useAppStore.setState({ modal: { kind: null } } as any);
|
||||
useFileStore.setState({ activeTabId: '/x.md', openTabs: [{ id: '/x.md', path: '/x.md', title: 'x.md', dirty: false }] } as any);
|
||||
useEditorStore.setState({ buffers: new Map([['/x.md', { id: '/x.md', path: '/x.md', content: '# hi', dirty: false }]]) } as any);
|
||||
useFileStore.setState({
|
||||
activeTabId: '/x.md',
|
||||
openTabs: [{ id: '/x.md', path: '/x.md', title: 'x.md', dirty: false }],
|
||||
} as any);
|
||||
useEditorStore.setState({
|
||||
buffers: new Map([['/x.md', { id: '/x.md', path: '/x.md', content: '# hi', dirty: false }]]),
|
||||
} as any);
|
||||
});
|
||||
|
||||
it('settings.open opens settings modal', () => {
|
||||
@@ -35,19 +40,34 @@ describe('modal commands', () => {
|
||||
it('file.exportPdf opens export-pdf modal with active path', () => {
|
||||
registerMenuCommands();
|
||||
useCommandStore.getState().dispatch('file.exportPdf');
|
||||
expect(useAppStore.getState().modal).toEqual({ kind: 'export-pdf', props: { sourcePath: '/x.md' } });
|
||||
expect(useAppStore.getState().modal).toEqual({
|
||||
kind: 'export-pdf',
|
||||
props: { sourcePath: '/x.md' },
|
||||
});
|
||||
});
|
||||
|
||||
it('file.exportDocx opens export-docx modal', () => {
|
||||
registerMenuCommands();
|
||||
useCommandStore.getState().dispatch('file.exportDocx');
|
||||
expect(useAppStore.getState().modal).toEqual({ kind: 'export-docx', props: { sourcePath: '/x.md' } });
|
||||
expect(useAppStore.getState().modal).toEqual({
|
||||
kind: 'export-docx',
|
||||
props: { sourcePath: '/x.md' },
|
||||
});
|
||||
});
|
||||
|
||||
it('file.exportBatch opens export-batch modal with all open files', () => {
|
||||
useFileStore.setState({ activeTabId: '/x.md', openTabs: [{ id: '/x.md', path: '/x.md', title: 'x.md', dirty: false }, { id: '/y.md', path: '/y.md', title: 'y.md', dirty: false }] } as any);
|
||||
useFileStore.setState({
|
||||
activeTabId: '/x.md',
|
||||
openTabs: [
|
||||
{ id: '/x.md', path: '/x.md', title: 'x.md', dirty: false },
|
||||
{ id: '/y.md', path: '/y.md', title: 'y.md', dirty: false },
|
||||
],
|
||||
} as any);
|
||||
registerMenuCommands();
|
||||
useCommandStore.getState().dispatch('file.exportBatch');
|
||||
expect(useAppStore.getState().modal).toEqual({ kind: 'export-batch', props: { sourcePaths: ['/x.md', '/y.md'] } });
|
||||
expect(useAppStore.getState().modal).toEqual({
|
||||
kind: 'export-batch',
|
||||
props: { sourcePaths: ['/x.md', '/y.md'] },
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -10,7 +10,10 @@ describe('Phase 9 commands', () => {
|
||||
localStorage.clear();
|
||||
useCommandStore.setState({ handlers: {}, userBindings: {} } as any);
|
||||
useAppStore.setState({ modal: { kind: null } } as any);
|
||||
useFileStore.setState({ activeTabId: '/x.md', openTabs: [{ id: '/x.md', path: '/x.md', title: 'x.md', dirty: false }] } as any);
|
||||
useFileStore.setState({
|
||||
activeTabId: '/x.md',
|
||||
openTabs: [{ id: '/x.md', path: '/x.md', title: 'x.md', dirty: false }],
|
||||
} as any);
|
||||
// Use resetToDefaults to restore store methods (setSetting/resetToDefaults)
|
||||
useSettingsStore.getState().resetToDefaults();
|
||||
});
|
||||
@@ -36,7 +39,10 @@ describe('Phase 9 commands', () => {
|
||||
it('tools.exportWord opens export-word modal with active path', () => {
|
||||
registerMenuCommands();
|
||||
useCommandStore.getState().dispatch('tools.exportWord');
|
||||
expect(useAppStore.getState().modal).toEqual({ kind: 'export-word', props: { sourcePath: '/x.md' } });
|
||||
expect(useAppStore.getState().modal).toEqual({
|
||||
kind: 'export-word',
|
||||
props: { sourcePath: '/x.md' },
|
||||
});
|
||||
});
|
||||
|
||||
it('tools.repl toggles replOpen setting', () => {
|
||||
@@ -47,4 +53,4 @@ describe('Phase 9 commands', () => {
|
||||
useCommandStore.getState().dispatch('tools.repl');
|
||||
expect(useSettingsStore.getState().replOpen).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -13,7 +13,9 @@ describe('useScrollSync', () => {
|
||||
it('throttles editor scroll events to 60fps', () => {
|
||||
const onScroll = vi.fn();
|
||||
const { result } = renderHook(() => useScrollSync({ onEditorScroll: onScroll }));
|
||||
const mockEvt = { currentTarget: { scrollTop: 100, scrollHeight: 1000, clientHeight: 200 } } as any;
|
||||
const mockEvt = {
|
||||
currentTarget: { scrollTop: 100, scrollHeight: 1000, clientHeight: 200 },
|
||||
} as any;
|
||||
// All calls within the same act() - performance.now() stays at 0
|
||||
// First call passes, subsequent calls within FRAME_MS are throttled
|
||||
act(() => {
|
||||
|
||||
@@ -95,7 +95,12 @@ describe('useShortcut', () => {
|
||||
it('calls preventDefault when the shortcut matches', () => {
|
||||
const cb = vi.fn();
|
||||
renderHook(() => useShortcut('mod+s', cb));
|
||||
const event = new KeyboardEvent('keydown', { key: 's', ctrlKey: true, bubbles: true, cancelable: true });
|
||||
const event = new KeyboardEvent('keydown', {
|
||||
key: 's',
|
||||
ctrlKey: true,
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
});
|
||||
window.dispatchEvent(event);
|
||||
expect(event.defaultPrevented).toBe(true);
|
||||
});
|
||||
@@ -103,7 +108,12 @@ describe('useShortcut', () => {
|
||||
it('does NOT call preventDefault when the shortcut does NOT match', () => {
|
||||
const cb = vi.fn();
|
||||
renderHook(() => useShortcut('mod+s', cb));
|
||||
const event = new KeyboardEvent('keydown', { key: 'x', ctrlKey: true, bubbles: true, cancelable: true });
|
||||
const event = new KeyboardEvent('keydown', {
|
||||
key: 'x',
|
||||
ctrlKey: true,
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
});
|
||||
window.dispatchEvent(event);
|
||||
expect(event.defaultPrevented).toBe(false);
|
||||
});
|
||||
|
||||
@@ -39,7 +39,7 @@ describe('toAsciiTable', () => {
|
||||
expect(out).toContain('| X | 100 |');
|
||||
// Right-aligned: "YY" padStart(4) -> " YY", "25" padStart(3) -> " 25"
|
||||
expect(out).toContain('| YY | 25 |');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('applyAsciiTransform', () => {
|
||||
@@ -57,4 +57,4 @@ describe('applyAsciiTransform', () => {
|
||||
const md = '# Hello\n\nNo tables here.';
|
||||
expect(applyAsciiTransform(md)).toBe(md);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -6,7 +6,9 @@ describe('generateDocx', () => {
|
||||
const blob = await generateDocx({ source: '# Hello\n\nWorld' });
|
||||
expect(blob).toBeInstanceOf(Blob);
|
||||
expect(blob.size).toBeGreaterThan(0);
|
||||
expect(blob.type).toBe('application/vnd.openxmlformats-officedocument.wordprocessingml.document');
|
||||
expect(blob.type).toBe(
|
||||
'application/vnd.openxmlformats-officedocument.wordprocessingml.document'
|
||||
);
|
||||
});
|
||||
|
||||
it('converts headings to docx heading styles', async () => {
|
||||
@@ -21,4 +23,4 @@ describe('generateDocx', () => {
|
||||
expect(blob).toBeInstanceOf(Blob);
|
||||
expect(blob.size).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -38,4 +38,4 @@ describe('ipc wrapper', () => {
|
||||
expect(result.error.code).toBe('CHANNEL_MISSING');
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -27,4 +27,4 @@ describe('renderMarkdown', () => {
|
||||
const html = renderMarkdown('<script>alert(1)</script>');
|
||||
expect(html).not.toContain('<script>');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,11 +1,5 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import {
|
||||
fadeIn,
|
||||
slideInRight,
|
||||
modalPop,
|
||||
toastSpring,
|
||||
sidebarToggle,
|
||||
} from '@/lib/motion';
|
||||
import { fadeIn, slideInRight, modalPop, toastSpring, sidebarToggle } from '@/lib/motion';
|
||||
|
||||
describe('motion presets', () => {
|
||||
it('fadeIn is a valid transition object', () => {
|
||||
|
||||
@@ -3,18 +3,19 @@ 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';
|
||||
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);
|
||||
};
|
||||
}
|
||||
);
|
||||
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: {
|
||||
@@ -146,9 +147,7 @@ describe('useRegisterMenuCommands + useBridgeNativeMenu', () => {
|
||||
|
||||
it('file.clearRecent does NOT clear openTabs', () => {
|
||||
useFileStore.setState({
|
||||
openTabs: [
|
||||
{ id: '/a.md', path: '/a.md', title: 'a.md', dirty: false },
|
||||
],
|
||||
openTabs: [{ id: '/a.md', path: '/a.md', title: 'a.md', dirty: false }],
|
||||
activeTabId: '/a.md',
|
||||
});
|
||||
render(<Harness />);
|
||||
|
||||
@@ -21,7 +21,9 @@ describe('settingsSchema', () => {
|
||||
|
||||
describe('exportPdfSchema', () => {
|
||||
it('accepts a4 + normal margins + embedFonts true', () => {
|
||||
expect(exportPdfSchema.safeParse({ format: 'a4', margins: 'normal', embedFonts: true }).success).toBe(true);
|
||||
expect(
|
||||
exportPdfSchema.safeParse({ format: 'a4', margins: 'normal', embedFonts: true }).success
|
||||
).toBe(true);
|
||||
});
|
||||
it('rejects unknown format', () => {
|
||||
expect(exportPdfSchema.safeParse({ format: 'b4' }).success).toBe(false);
|
||||
@@ -39,15 +41,21 @@ describe('exportDocxSchema', () => {
|
||||
|
||||
describe('exportHtmlSchema', () => {
|
||||
it('accepts github highlight style', () => {
|
||||
expect(exportHtmlSchema.safeParse({ standalone: true, highlightStyle: 'github' }).success).toBe(true);
|
||||
expect(exportHtmlSchema.safeParse({ standalone: true, highlightStyle: 'github' }).success).toBe(
|
||||
true
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('exportBatchSchema', () => {
|
||||
it('accepts a non-empty file list', () => {
|
||||
expect(exportBatchSchema.safeParse({ format: 'pdf', concurrency: 4, filePaths: ['/a.md'] }).success).toBe(true);
|
||||
expect(
|
||||
exportBatchSchema.safeParse({ format: 'pdf', concurrency: 4, filePaths: ['/a.md'] }).success
|
||||
).toBe(true);
|
||||
});
|
||||
it('rejects empty file list', () => {
|
||||
expect(exportBatchSchema.safeParse({ format: 'pdf', concurrency: 4, filePaths: [] }).success).toBe(false);
|
||||
expect(
|
||||
exportBatchSchema.safeParse({ format: 'pdf', concurrency: 4, filePaths: [] }).success
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -19,9 +19,14 @@ describe('MigrationRunner', () => {
|
||||
test('runs migration on missing v5 marker, writes v5 file, backs up v4', () => {
|
||||
const dir = tmpDir();
|
||||
fs.writeFileSync(path.join(dir, 'settings.json'), JSON.stringify({ theme: 'light' }));
|
||||
const r = new MigrationRunner({ dir, transform: (v) => ({ ...v, theme: 'system', 'migration.version': 5 }) });
|
||||
const r = new MigrationRunner({
|
||||
dir,
|
||||
transform: (v) => ({ ...v, theme: 'system', 'migration.version': 5 }),
|
||||
});
|
||||
expect(r.run()).toBe('migrated');
|
||||
expect(JSON.parse(fs.readFileSync(path.join(dir, 'settings.json'), 'utf-8'))['migration.version']).toBe(5);
|
||||
expect(
|
||||
JSON.parse(fs.readFileSync(path.join(dir, 'settings.json'), 'utf-8'))['migration.version']
|
||||
).toBe(5);
|
||||
expect(fs.existsSync(path.join(dir, 'settings.v4.bak.json'))).toBe(true);
|
||||
fs.rmSync(dir, { recursive: true });
|
||||
});
|
||||
@@ -37,16 +42,28 @@ describe('MigrationRunner', () => {
|
||||
test('returns failed and writes v5 marker so subsequent runs skip migration', () => {
|
||||
const dir = tmpDir();
|
||||
fs.writeFileSync(path.join(dir, 'settings.json'), JSON.stringify({ theme: 'light' }));
|
||||
const r = new MigrationRunner({ dir, transform: () => { throw new Error('boom'); } });
|
||||
const r = new MigrationRunner({
|
||||
dir,
|
||||
transform: () => {
|
||||
throw new Error('boom');
|
||||
},
|
||||
});
|
||||
expect(r.run()).toBe('failed');
|
||||
// v5 marker is written so next launch skips migration
|
||||
expect(JSON.parse(fs.readFileSync(path.join(dir, 'settings.json'), 'utf-8'))['migration.version']).toBe(5);
|
||||
expect(
|
||||
JSON.parse(fs.readFileSync(path.join(dir, 'settings.json'), 'utf-8'))['migration.version']
|
||||
).toBe(5);
|
||||
// Original data is preserved (theme: light is still there)
|
||||
expect(fs.readFileSync(path.join(dir, 'settings.json'), 'utf-8')).toContain('light');
|
||||
// Backup exists
|
||||
expect(fs.existsSync(path.join(dir, 'settings.v4.bak.json'))).toBe(true);
|
||||
// Subsequent run skips
|
||||
const r2 = new MigrationRunner({ dir, transform: () => { throw new Error('should not be called'); } });
|
||||
const r2 = new MigrationRunner({
|
||||
dir,
|
||||
transform: () => {
|
||||
throw new Error('should not be called');
|
||||
},
|
||||
});
|
||||
expect(r2.run()).toBe('skipped');
|
||||
fs.rmSync(dir, { recursive: true });
|
||||
});
|
||||
|
||||
@@ -30,10 +30,7 @@ describe('UpdaterService', () => {
|
||||
emitter.emit('update-available', { version: '5.0.2' });
|
||||
});
|
||||
await service.check();
|
||||
expect(states).toEqual([
|
||||
{ state: 'checking' },
|
||||
{ state: 'available', version: '5.0.2' },
|
||||
]);
|
||||
expect(states).toEqual([{ state: 'checking' }, { state: 'available', version: '5.0.2' }]);
|
||||
});
|
||||
|
||||
test('check() emits error on network failure', async () => {
|
||||
@@ -57,4 +54,4 @@ describe('UpdaterService', () => {
|
||||
service.install();
|
||||
expect(mockAutoUpdater.quitAndInstall).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -53,7 +53,10 @@ describe('useAppStore (modal)', () => {
|
||||
|
||||
it('openModal with kind requiring props passes them through', () => {
|
||||
useAppStore.getState().openModal('export-pdf', { sourcePath: '/a.md' });
|
||||
expect(useAppStore.getState().modal).toEqual({ kind: 'export-pdf', props: { sourcePath: '/a.md' } });
|
||||
expect(useAppStore.getState().modal).toEqual({
|
||||
kind: 'export-pdf',
|
||||
props: { sourcePath: '/a.md' },
|
||||
});
|
||||
});
|
||||
|
||||
it('closeModal clears the modal state', () => {
|
||||
|
||||
@@ -44,4 +44,4 @@ describe('useEditorStore', () => {
|
||||
expect(newBuf?.path).toBe('/bar.md');
|
||||
expect(useEditorStore.getState().activeId).toBe('file-2');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -54,7 +54,9 @@ describe('file-store toasts', () => {
|
||||
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: true }]]),
|
||||
buffers: new Map([
|
||||
['/test.md', { id: '/test.md', path: '/test.md', content: '# hi', dirty: true }],
|
||||
]),
|
||||
activeId: '/test.md',
|
||||
} as any);
|
||||
|
||||
@@ -64,13 +66,18 @@ describe('file-store toasts', () => {
|
||||
});
|
||||
|
||||
it('calls toast.error when save fails', async () => {
|
||||
fakeWrite.mockResolvedValue({ ok: false, error: { code: 'EACCES', message: 'Permission denied' } });
|
||||
fakeWrite.mockResolvedValue({
|
||||
ok: false,
|
||||
error: { code: 'EACCES', message: 'Permission denied' },
|
||||
});
|
||||
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: true }]]),
|
||||
buffers: new Map([
|
||||
['/test.md', { id: '/test.md', path: '/test.md', content: '# hi', dirty: true }],
|
||||
]),
|
||||
activeId: '/test.md',
|
||||
} as any);
|
||||
|
||||
@@ -91,10 +98,13 @@ describe('file-store toasts', () => {
|
||||
|
||||
describe('openFolder', () => {
|
||||
it('calls toast.error when IPC list fails', async () => {
|
||||
fakeList.mockResolvedValue({ ok: false, error: { code: 'EACCES', message: 'Permission denied' } });
|
||||
fakeList.mockResolvedValue({
|
||||
ok: false,
|
||||
error: { code: 'EACCES', message: 'Permission denied' },
|
||||
});
|
||||
|
||||
await useFileStore.getState().openFolder('/forbidden');
|
||||
expect(toast.error).toHaveBeenCalledWith('Failed to open folder: Permission denied');
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -49,10 +49,7 @@ describe('useFileStore', () => {
|
||||
it('openFolder calls ipc.file.list and sets tree with root node and mapped children', async () => {
|
||||
fakeList.mockResolvedValue({
|
||||
ok: true,
|
||||
data: [
|
||||
fakeFileEntry('README.md', false),
|
||||
fakeFileEntry('src', true),
|
||||
],
|
||||
data: [fakeFileEntry('README.md', false), fakeFileEntry('src', true)],
|
||||
});
|
||||
|
||||
await useFileStore.getState().openFolder('/root');
|
||||
@@ -139,7 +136,9 @@ describe('useFileStore', () => {
|
||||
path: '/root',
|
||||
isDirectory: true,
|
||||
loaded: true,
|
||||
children: [{ name: 'src', path: '/root/src', isDirectory: true, children: null, loaded: true }],
|
||||
children: [
|
||||
{ name: 'src', path: '/root/src', isDirectory: true, children: null, loaded: true },
|
||||
],
|
||||
};
|
||||
useFileStore.setState({ tree });
|
||||
|
||||
|
||||
@@ -41,4 +41,4 @@ describe('useSettingsStore', () => {
|
||||
expect(parsed.state.setSetting).toBeUndefined();
|
||||
expect(parsed.state.resetToDefaults).toBeUndefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
+11
-7
@@ -35,7 +35,7 @@ describe('Utility Functions', () => {
|
||||
|
||||
return {
|
||||
command: parts[0],
|
||||
args: parts.slice(1)
|
||||
args: parts.slice(1),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -58,7 +58,9 @@ describe('Utility Functions', () => {
|
||||
});
|
||||
|
||||
test('should handle multiple options', () => {
|
||||
const result = parseCommand('pandoc input.md --pdf-engine=xelatex -V geometry:margin=1in -o output.pdf');
|
||||
const result = parseCommand(
|
||||
'pandoc input.md --pdf-engine=xelatex -V geometry:margin=1in -o output.pdf'
|
||||
);
|
||||
expect(result.command).toBe('pandoc');
|
||||
expect(result.args).toContain('--pdf-engine=xelatex');
|
||||
expect(result.args).toContain('-V');
|
||||
@@ -75,11 +77,13 @@ describe('Utility Functions', () => {
|
||||
// This function converts hex colors to RGB
|
||||
function hexToRgb(hex) {
|
||||
const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex);
|
||||
return result ? {
|
||||
r: parseInt(result[1], 16) / 255,
|
||||
g: parseInt(result[2], 16) / 255,
|
||||
b: parseInt(result[3], 16) / 255
|
||||
} : null;
|
||||
return result
|
||||
? {
|
||||
r: parseInt(result[1], 16) / 255,
|
||||
g: parseInt(result[2], 16) / 255,
|
||||
b: parseInt(result[3], 16) / 255,
|
||||
}
|
||||
: null;
|
||||
}
|
||||
|
||||
test('should convert black hex to RGB', () => {
|
||||
|
||||
Reference in New Issue
Block a user