Files
tessera-ctl/apps/web/src/components/dashboard/widgets/note-widget.test.tsx
T
schalli 4c2495bb64 fix(web): Notiz-Widget folgt dem Hell/Dunkel-Schalter von Tessera statt der Systemeinstellung
- 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>
2026-09-16 13:55:18 +02:00

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',
);
});
});