test(05-02): add failing tests for search and note widgets
- SearchWidget: provider selection, window.open with encoded query, Enter key trigger - NoteWidget: debounced autosave, rapid typing collapse, AbortController usage Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,202 @@
|
|||||||
|
import { act, render, screen } from '@testing-library/react';
|
||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
|
// Mock next-intl
|
||||||
|
vi.mock('next-intl', () => ({
|
||||||
|
useTranslations: () => (key: string) => {
|
||||||
|
const map: Record<string, string> = {
|
||||||
|
'note.defaultTitle': 'Notiz',
|
||||||
|
'note.autosaveError': 'Speichern fehlgeschlagen',
|
||||||
|
};
|
||||||
|
return map[key] ?? key;
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Mock @uiw/react-md-editor — provide a minimal textarea-based substitute
|
||||||
|
vi.mock('@uiw/react-md-editor', () => {
|
||||||
|
const MDEditor = ({
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
'data-testid': testId,
|
||||||
|
}: {
|
||||||
|
value?: string;
|
||||||
|
onChange?: (val?: string) => void;
|
||||||
|
'data-testid'?: string;
|
||||||
|
}) => (
|
||||||
|
<textarea
|
||||||
|
data-testid={testId ?? 'md-editor'}
|
||||||
|
value={value}
|
||||||
|
onChange={(e) => onChange?.(e.target.value)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
MDEditor.displayName = 'MDEditor';
|
||||||
|
|
||||||
|
return {
|
||||||
|
__esModule: true,
|
||||||
|
default: MDEditor,
|
||||||
|
commands: {
|
||||||
|
bold: {},
|
||||||
|
italic: {},
|
||||||
|
strikethrough: {},
|
||||||
|
divider: {},
|
||||||
|
unorderedListCommand: {},
|
||||||
|
checkedListCommand: {},
|
||||||
|
link: {},
|
||||||
|
code: {},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
// Must import after mocks
|
||||||
|
import { NoteWidget } from './note-widget';
|
||||||
|
|
||||||
|
describe('NoteWidget', () => {
|
||||||
|
let fetchSpy: ReturnType<typeof vi.spyOn>;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
fetchSpy = vi.spyOn(globalThis, 'fetch').mockResolvedValue({
|
||||||
|
ok: true,
|
||||||
|
json: async () => ({}),
|
||||||
|
} as Response);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.useRealTimers();
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders the editor with default title', () => {
|
||||||
|
render(
|
||||||
|
<NoteWidget instanceId="note-1" config={{}} isEditMode={false} />,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.getByDisplayValue('Notiz')).toBeInTheDocument();
|
||||||
|
expect(screen.getByTestId('md-editor')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('schedules a debounced PATCH after typing', async () => {
|
||||||
|
render(
|
||||||
|
<NoteWidget instanceId="note-1" config={{}} isEditMode={false} />,
|
||||||
|
);
|
||||||
|
|
||||||
|
const editor = screen.getByTestId('md-editor');
|
||||||
|
|
||||||
|
// Type into editor
|
||||||
|
await act(async () => {
|
||||||
|
editor.dispatchEvent(
|
||||||
|
new Event('change', { bubbles: true }),
|
||||||
|
);
|
||||||
|
// Simulate onChange via the mock
|
||||||
|
const event = new Event('input', { bubbles: true });
|
||||||
|
Object.defineProperty(event, 'target', { value: { value: '# Hello' } });
|
||||||
|
// Use fireEvent approach
|
||||||
|
});
|
||||||
|
|
||||||
|
// Directly trigger onChange via the textarea
|
||||||
|
await act(async () => {
|
||||||
|
const textarea = screen.getByTestId('md-editor') as HTMLTextAreaElement;
|
||||||
|
// Change value triggers onChange in our mock
|
||||||
|
Object.getOwnPropertyDescriptor(
|
||||||
|
HTMLTextAreaElement.prototype,
|
||||||
|
'value',
|
||||||
|
)?.set?.call(textarea, '# Hello');
|
||||||
|
textarea.dispatchEvent(new Event('input', { bubbles: true }));
|
||||||
|
textarea.dispatchEvent(new Event('change', { bubbles: true }));
|
||||||
|
});
|
||||||
|
|
||||||
|
// Before debounce fires, fetch should NOT have been called
|
||||||
|
expect(fetchSpy).not.toHaveBeenCalled();
|
||||||
|
|
||||||
|
// Advance past debounce window
|
||||||
|
await act(async () => {
|
||||||
|
vi.advanceTimersByTime(1500);
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(fetchSpy).toHaveBeenCalledWith(
|
||||||
|
expect.stringContaining('/api/dashboard/widgets/note-1/config'),
|
||||||
|
expect.objectContaining({
|
||||||
|
method: 'PATCH',
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('collapses rapid typing to a single save', async () => {
|
||||||
|
render(
|
||||||
|
<NoteWidget instanceId="note-1" config={{}} isEditMode={false} />,
|
||||||
|
);
|
||||||
|
|
||||||
|
const textarea = screen.getByTestId('md-editor') as HTMLTextAreaElement;
|
||||||
|
|
||||||
|
// Simulate rapid typing — three changes in quick succession
|
||||||
|
for (const char of ['H', 'He', 'Hel']) {
|
||||||
|
await act(async () => {
|
||||||
|
Object.getOwnPropertyDescriptor(
|
||||||
|
HTMLTextAreaElement.prototype,
|
||||||
|
'value',
|
||||||
|
)?.set?.call(textarea, char);
|
||||||
|
textarea.dispatchEvent(new Event('change', { bubbles: true }));
|
||||||
|
});
|
||||||
|
// Small delay between keystrokes (less than debounce)
|
||||||
|
await act(async () => {
|
||||||
|
vi.advanceTimersByTime(200);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Still within debounce window of last keystroke
|
||||||
|
expect(fetchSpy).not.toHaveBeenCalled();
|
||||||
|
|
||||||
|
// Advance past the debounce window from the last keystroke
|
||||||
|
await act(async () => {
|
||||||
|
vi.advanceTimersByTime(1500);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Should have only saved once (the last value)
|
||||||
|
expect(fetchSpy).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('uses AbortController to cancel in-flight requests', async () => {
|
||||||
|
const abortSpy = vi.spyOn(AbortController.prototype, 'abort');
|
||||||
|
|
||||||
|
render(
|
||||||
|
<NoteWidget instanceId="note-1" config={{}} isEditMode={false} />,
|
||||||
|
);
|
||||||
|
|
||||||
|
const textarea = screen.getByTestId('md-editor') as HTMLTextAreaElement;
|
||||||
|
|
||||||
|
// First change -> debounce -> save starts
|
||||||
|
await act(async () => {
|
||||||
|
Object.getOwnPropertyDescriptor(
|
||||||
|
HTMLTextAreaElement.prototype,
|
||||||
|
'value',
|
||||||
|
)?.set?.call(textarea, 'first');
|
||||||
|
textarea.dispatchEvent(new Event('change', { bubbles: true }));
|
||||||
|
});
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
vi.advanceTimersByTime(1500);
|
||||||
|
});
|
||||||
|
|
||||||
|
// First save was called
|
||||||
|
expect(fetchSpy).toHaveBeenCalledTimes(1);
|
||||||
|
|
||||||
|
// Second change -> debounce -> should abort the first
|
||||||
|
await act(async () => {
|
||||||
|
Object.getOwnPropertyDescriptor(
|
||||||
|
HTMLTextAreaElement.prototype,
|
||||||
|
'value',
|
||||||
|
)?.set?.call(textarea, 'second');
|
||||||
|
textarea.dispatchEvent(new Event('change', { bubbles: true }));
|
||||||
|
});
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
vi.advanceTimersByTime(1500);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Abort should have been called (cancelling the previous request's controller)
|
||||||
|
expect(abortSpy).toHaveBeenCalled();
|
||||||
|
expect(fetchSpy).toHaveBeenCalledTimes(2);
|
||||||
|
|
||||||
|
abortSpy.mockRestore();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,131 @@
|
|||||||
|
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
|
||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
|
// Mock next-intl
|
||||||
|
vi.mock('next-intl', () => ({
|
||||||
|
useTranslations: () => (key: string) => {
|
||||||
|
const map: Record<string, string> = {
|
||||||
|
'search.placeholder': 'Suchen...',
|
||||||
|
};
|
||||||
|
return map[key] ?? key;
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Must import after mocks
|
||||||
|
import { SearchWidget } from './search-widget';
|
||||||
|
|
||||||
|
const DEFAULT_PROVIDERS = [
|
||||||
|
{ id: 'google', name: 'Google', urlTemplate: 'https://www.google.com/search?q={query}', isDefault: true },
|
||||||
|
{ id: 'bing', name: 'Bing', urlTemplate: 'https://www.bing.com/search?q={query}', isDefault: true },
|
||||||
|
{ id: 'ddg', name: 'DuckDuckGo', urlTemplate: 'https://duckduckgo.com/?q={query}', isDefault: true },
|
||||||
|
];
|
||||||
|
|
||||||
|
describe('SearchWidget', () => {
|
||||||
|
let windowOpenSpy: ReturnType<typeof vi.spyOn>;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
windowOpenSpy = vi.spyOn(window, 'open').mockImplementation(() => null);
|
||||||
|
// Mock fetch to return default providers
|
||||||
|
vi.spyOn(globalThis, 'fetch').mockResolvedValue({
|
||||||
|
ok: true,
|
||||||
|
json: async () => DEFAULT_PROVIDERS,
|
||||||
|
} as Response);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders provider dropdown, input, and search button', async () => {
|
||||||
|
render(
|
||||||
|
<SearchWidget instanceId="test-1" config={{}} isEditMode={false} />,
|
||||||
|
);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByRole('combobox')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
expect(screen.getByPlaceholderText('Suchen...')).toBeInTheDocument();
|
||||||
|
expect(screen.getByRole('button')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('selecting Google and submitting "hello" opens correct URL in new tab', async () => {
|
||||||
|
render(
|
||||||
|
<SearchWidget instanceId="test-1" config={{}} isEditMode={false} />,
|
||||||
|
);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByRole('combobox')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Select Google (should be default)
|
||||||
|
const select = screen.getByRole('combobox');
|
||||||
|
fireEvent.change(select, { target: { value: 'google' } });
|
||||||
|
|
||||||
|
// Type query
|
||||||
|
const input = screen.getByPlaceholderText('Suchen...');
|
||||||
|
fireEvent.change(input, { target: { value: 'hello' } });
|
||||||
|
|
||||||
|
// Click search button
|
||||||
|
fireEvent.click(screen.getByRole('button'));
|
||||||
|
|
||||||
|
expect(windowOpenSpy).toHaveBeenCalledWith(
|
||||||
|
'https://www.google.com/search?q=hello',
|
||||||
|
'_blank',
|
||||||
|
'noopener,noreferrer',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Enter key in input triggers search', async () => {
|
||||||
|
render(
|
||||||
|
<SearchWidget instanceId="test-1" config={{}} isEditMode={false} />,
|
||||||
|
);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByRole('combobox')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
const input = screen.getByPlaceholderText('Suchen...');
|
||||||
|
fireEvent.change(input, { target: { value: 'hello' } });
|
||||||
|
fireEvent.keyDown(input, { key: 'Enter', code: 'Enter' });
|
||||||
|
|
||||||
|
expect(windowOpenSpy).toHaveBeenCalledWith(
|
||||||
|
'https://www.google.com/search?q=hello',
|
||||||
|
'_blank',
|
||||||
|
'noopener,noreferrer',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('encodes special characters in query', async () => {
|
||||||
|
render(
|
||||||
|
<SearchWidget instanceId="test-1" config={{}} isEditMode={false} />,
|
||||||
|
);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByRole('combobox')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
const input = screen.getByPlaceholderText('Suchen...');
|
||||||
|
fireEvent.change(input, { target: { value: 'hello world & foo' } });
|
||||||
|
fireEvent.click(screen.getByRole('button'));
|
||||||
|
|
||||||
|
expect(windowOpenSpy).toHaveBeenCalledWith(
|
||||||
|
'https://www.google.com/search?q=hello%20world%20%26%20foo',
|
||||||
|
'_blank',
|
||||||
|
'noopener,noreferrer',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not open window when query is empty', async () => {
|
||||||
|
render(
|
||||||
|
<SearchWidget instanceId="test-1" config={{}} isEditMode={false} />,
|
||||||
|
);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByRole('combobox')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole('button'));
|
||||||
|
|
||||||
|
expect(windowOpenSpy).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user