4c2495bb64
- useTheme (next-themes) + mounted-Guard wie changelog-view.tsx - data-color-mode am Wurzel-Div folgt resolvedTheme statt fest 'auto' - zwei neue Tests (dark/light) via vi.hoisted-Themenzustand Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
325 lines
9.9 KiB
TypeScript
325 lines
9.9 KiB
TypeScript
import { act, fireEvent, 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;
|
|
},
|
|
}));
|
|
|
|
// quick-260916-j4f: gehobener Themenzustand, damit die Variable trotz
|
|
// Hoisting der Mocks und des statischen Imports weiter unten sicher
|
|
// initialisiert ist (Muster changelog-page.test.tsx).
|
|
const themeMock = vi.hoisted(() => ({ resolvedTheme: 'light' as 'light' | 'dark' }));
|
|
vi.mock('next-themes', () => ({
|
|
useTheme: () => ({ resolvedTheme: themeMock.resolvedTheme }),
|
|
}));
|
|
|
|
// Mock @uiw/react-md-editor — provide a minimal textarea-based substitute.
|
|
// Im Modus 'preview' wird stattdessen ein Vorschau-Div gerendert, das fuer
|
|
// jede Aufgabenzeile ein Kaestchen enthaelt (quick-260916-iex) — nutzt die
|
|
// echte `components.input`-Komponente aus `previewOptions`, wenn vorhanden,
|
|
// damit auch NoteCheckbox im Klickpfad landet.
|
|
const TASK_MOCK_RE = /^\s*(?:[-*+]|\d+[.)])\s+\[([ xX])\]\s+\S/;
|
|
|
|
vi.mock('@uiw/react-md-editor', () => {
|
|
const MDEditor = ({
|
|
value,
|
|
onChange,
|
|
preview,
|
|
previewOptions,
|
|
'data-testid': testId,
|
|
}: {
|
|
value?: string;
|
|
onChange?: (val?: string) => void;
|
|
preview?: string;
|
|
previewOptions?: { components?: { input?: React.ComponentType<{ checked?: boolean }> } };
|
|
'data-testid'?: string;
|
|
}) => {
|
|
if (preview === 'preview') {
|
|
const InputComponent = previewOptions?.components?.input;
|
|
const lines = (value ?? '').split('\n');
|
|
return (
|
|
<div data-testid={testId ?? 'md-editor'}>
|
|
{lines.map((line, i) => {
|
|
const match = line.match(TASK_MOCK_RE);
|
|
if (!match) return null;
|
|
const checked = match[1] !== ' ';
|
|
return InputComponent ? (
|
|
<InputComponent key={i} checked={checked} />
|
|
) : (
|
|
<input key={i} type="checkbox" readOnly checked={checked} />
|
|
);
|
|
})}
|
|
</div>
|
|
);
|
|
}
|
|
return (
|
|
<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();
|
|
themeMock.resolvedTheme = 'light';
|
|
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} />,
|
|
);
|
|
|
|
// Enable edit mode so onChange is wired up
|
|
await act(async () => {
|
|
fireEvent.click(screen.getByRole('button'));
|
|
});
|
|
|
|
const textarea = screen.getByTestId('md-editor') as HTMLTextAreaElement;
|
|
|
|
// Before debounce fires, fetch should NOT have been called
|
|
fireEvent.change(textarea, { target: { value: '# Hello' } });
|
|
expect(fetchSpy).not.toHaveBeenCalled();
|
|
|
|
// Advance past debounce window
|
|
await act(async () => {
|
|
vi.advanceTimersByTime(1500);
|
|
});
|
|
|
|
expect(fetchSpy).toHaveBeenCalledWith(
|
|
expect.stringContaining('/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} />,
|
|
);
|
|
|
|
// Enable edit mode so onChange is wired up
|
|
await act(async () => {
|
|
fireEvent.click(screen.getByRole('button'));
|
|
});
|
|
|
|
const textarea = screen.getByTestId('md-editor') as HTMLTextAreaElement;
|
|
|
|
// Simulate rapid typing — three changes in quick succession
|
|
for (const char of ['H', 'He', 'Hel']) {
|
|
fireEvent.change(textarea, { target: { value: char } });
|
|
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} />,
|
|
);
|
|
|
|
// Enable edit mode so onChange is wired up
|
|
await act(async () => {
|
|
fireEvent.click(screen.getByRole('button'));
|
|
});
|
|
|
|
const textarea = screen.getByTestId('md-editor') as HTMLTextAreaElement;
|
|
|
|
// First change -> debounce -> save starts
|
|
fireEvent.change(textarea, { target: { value: 'first' } });
|
|
await act(async () => {
|
|
vi.advanceTimersByTime(1500);
|
|
});
|
|
|
|
// First save was called
|
|
expect(fetchSpy).toHaveBeenCalledTimes(1);
|
|
|
|
// Second change -> debounce -> should abort the first
|
|
fireEvent.change(textarea, { target: { value: 'second' } });
|
|
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();
|
|
});
|
|
|
|
// quick-260916-iex: Aufgabenlisten in der Ansicht abhakbar
|
|
it('kippt beim Klick auf ein Kaestchen in der Ansicht genau diese Zeile und speichert sofort', async () => {
|
|
render(
|
|
<NoteWidget
|
|
instanceId="note-1"
|
|
config={{ content: '- [ ] Milch\n- [x] Brot\n- [ ] Eier', title: 'Einkauf' }}
|
|
isEditMode={false}
|
|
/>,
|
|
);
|
|
|
|
const boxes = screen.getAllByRole('checkbox');
|
|
expect(boxes).toHaveLength(3);
|
|
|
|
fireEvent.click(boxes[2]);
|
|
await act(async () => {});
|
|
|
|
expect(fetchSpy).toHaveBeenCalledTimes(1);
|
|
const [url, init] = fetchSpy.mock.calls[0] as [string, RequestInit];
|
|
expect(url).toContain('/dashboard/widgets/note-1/config');
|
|
expect(init.method).toBe('PATCH');
|
|
expect(JSON.parse(init.body as string)).toEqual({
|
|
config: { content: '- [ ] Milch\n- [x] Brot\n- [x] Eier', title: 'Einkauf' },
|
|
});
|
|
|
|
expect((screen.getAllByRole('checkbox')[2] as HTMLInputElement).checked).toBe(true);
|
|
});
|
|
|
|
it('kippt ein angehaktes Kaestchen beim Klick wieder ab', async () => {
|
|
render(
|
|
<NoteWidget
|
|
instanceId="note-1"
|
|
config={{ content: '- [ ] Milch\n- [x] Brot\n- [ ] Eier', title: 'Einkauf' }}
|
|
isEditMode={false}
|
|
/>,
|
|
);
|
|
|
|
const boxes = screen.getAllByRole('checkbox');
|
|
fireEvent.click(boxes[1]);
|
|
await act(async () => {});
|
|
|
|
const [, init] = fetchSpy.mock.calls[0] as [string, RequestInit];
|
|
expect(JSON.parse(init.body as string)).toEqual({
|
|
config: { content: '- [ ] Milch\n- [ ] Brot\n- [ ] Eier', title: 'Einkauf' },
|
|
});
|
|
});
|
|
|
|
it('verwirft einen noch laufenden Entprell-Timer aus dem Tippen beim Abhaken', async () => {
|
|
render(
|
|
<NoteWidget
|
|
instanceId="note-1"
|
|
config={{ content: '- [ ] Milch\n- [ ] Brot', title: 'Einkauf' }}
|
|
isEditMode={false}
|
|
/>,
|
|
);
|
|
|
|
// Stift an: in den Bearbeitungsmodus wechseln und tippen
|
|
await act(async () => {
|
|
fireEvent.click(screen.getByRole('button'));
|
|
});
|
|
const textarea = screen.getByTestId('md-editor') as HTMLTextAreaElement;
|
|
fireEvent.change(textarea, { target: { value: '- [ ] Milch\n- [ ] Brot' } });
|
|
await act(async () => {
|
|
vi.advanceTimersByTime(200);
|
|
});
|
|
expect(fetchSpy).not.toHaveBeenCalled();
|
|
|
|
// Stift wieder aus: zurueck in die Ansicht
|
|
await act(async () => {
|
|
fireEvent.click(screen.getByRole('button'));
|
|
});
|
|
|
|
// Erstes Kaestchen anklicken -> sofortiges Speichern, alter Timer verworfen
|
|
const boxes = screen.getAllByRole('checkbox');
|
|
fireEvent.click(boxes[0]);
|
|
await act(async () => {});
|
|
|
|
expect(fetchSpy).toHaveBeenCalledTimes(1);
|
|
const [, init] = fetchSpy.mock.calls[0] as [string, RequestInit];
|
|
expect(JSON.parse(init.body as string)).toEqual({
|
|
config: { content: '- [x] Milch\n- [ ] Brot', title: 'Einkauf' },
|
|
});
|
|
|
|
// Der alte, eigentlich noch laufende Timer darf nichts mehr nachschieben
|
|
await act(async () => {
|
|
vi.advanceTimersByTime(2000);
|
|
});
|
|
expect(fetchSpy).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it('setzt data-color-mode auf dark, wenn Tessera auf Dunkel steht', () => {
|
|
themeMock.resolvedTheme = 'dark';
|
|
const { container } = render(
|
|
<NoteWidget instanceId="note-t1" config={{}} isEditMode={false} />,
|
|
);
|
|
|
|
expect(container.querySelector('[data-color-mode]')?.getAttribute('data-color-mode')).toBe(
|
|
'dark',
|
|
);
|
|
});
|
|
|
|
it('setzt data-color-mode auf light, wenn Tessera auf Hell steht', () => {
|
|
themeMock.resolvedTheme = 'light';
|
|
const { container } = render(
|
|
<NoteWidget instanceId="note-t1" config={{}} isEditMode={false} />,
|
|
);
|
|
|
|
expect(container.querySelector('[data-color-mode]')?.getAttribute('data-color-mode')).toBe(
|
|
'light',
|
|
);
|
|
});
|
|
});
|