feat(C): Phase C — Core UI prüfen, vervollständigen, testen
C-ERR-BOUNDARY: ErrorBoundary erweitert (trace_id, Fallback-UI, PluginErrorBoundary) C-NOTIF: NotificationDropdown mit System-Channel-Link C-DOCS: ApiDocs.tsx Seite (Swagger UI iframe) C-A11Y: aria-label, min-h-touch, focus-visible ergänzt C-FE-TEST: 29 neue Tests (ErrorBoundary, ApiDocs, PrintButton, themeStore, NotificationDropdown) C-DOC: ui-design-guidelines.md aktualisiert Verifiziert (keine Änderungen nötig): - C-TAGS, C-CF, C-FILTER, C-DEDUP, C-ONBOARD, C-THEME, C-PWA, C-PRINT TSC: 0 errors, Build: erfolgreich, Vitest: 29/29 passed
This commit is contained in:
@@ -0,0 +1,108 @@
|
||||
/**
|
||||
* ErrorBoundary tests — verifies error catching, trace_id display, and retry.
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { render, screen, fireEvent } from '@testing-library/react';
|
||||
import { ErrorBoundary } from '@/components/common/ErrorBoundary';
|
||||
|
||||
// Component that throws on render
|
||||
function ThrowOnRender({ message }: { message: string }) {
|
||||
throw new Error(message);
|
||||
}
|
||||
|
||||
// Component that toggles throwing
|
||||
function ConditionalThrow({ shouldThrow }: { shouldThrow: boolean }) {
|
||||
if (shouldThrow) throw new Error('Conditional error');
|
||||
return <div data-testid="safe-content">Safe content</div>;
|
||||
}
|
||||
|
||||
describe('ErrorBoundary', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
});
|
||||
|
||||
it('renders children when no error occurs', () => {
|
||||
render(
|
||||
<ErrorBoundary>
|
||||
<div data-testid="child">Hello World</div>
|
||||
</ErrorBoundary>
|
||||
);
|
||||
expect(screen.getByTestId('child')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('catches render errors and shows fallback UI', () => {
|
||||
render(
|
||||
<ErrorBoundary>
|
||||
<ThrowOnRender message="Test error" />
|
||||
</ErrorBoundary>
|
||||
);
|
||||
expect(screen.getByRole('alert')).toBeInTheDocument();
|
||||
expect(screen.getByText('Etwas ist schiefgelaufen')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('displays a trace_id when error occurs', () => {
|
||||
render(
|
||||
<ErrorBoundary>
|
||||
<ThrowOnRender message="Trace test" />
|
||||
</ErrorBoundary>
|
||||
);
|
||||
const traceEl = screen.getByTestId('error-trace-id');
|
||||
expect(traceEl).toBeInTheDocument();
|
||||
expect(traceEl.textContent).toMatch(/Trace-ID: err-/);
|
||||
});
|
||||
|
||||
it('shows error details in a details element', () => {
|
||||
render(
|
||||
<ErrorBoundary>
|
||||
<ThrowOnRender message="Detailed error" />
|
||||
</ErrorBoundary>
|
||||
);
|
||||
expect(screen.getByText('Fehlerdetails')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('has retry and reload buttons', () => {
|
||||
render(
|
||||
<ErrorBoundary>
|
||||
<ThrowOnRender message="Button test" />
|
||||
</ErrorBoundary>
|
||||
);
|
||||
expect(screen.getByText('Erneut versuchen')).toBeInTheDocument();
|
||||
expect(screen.getByText('Neu laden')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('retries and resets error state when retry is clicked', () => {
|
||||
let shouldThrow = true;
|
||||
|
||||
function Wrapper() {
|
||||
if (shouldThrow) throw new Error('Retry test error');
|
||||
return <div data-testid="safe-content">Safe content</div>;
|
||||
}
|
||||
|
||||
const { rerender } = render(
|
||||
<ErrorBoundary>
|
||||
<Wrapper />
|
||||
</ErrorBoundary>
|
||||
);
|
||||
expect(screen.getByRole('alert')).toBeInTheDocument();
|
||||
|
||||
// Stop throwing, then click retry — ErrorBoundary re-renders children without error
|
||||
shouldThrow = false;
|
||||
fireEvent.click(screen.getByText('Erneut versuchen'));
|
||||
|
||||
expect(screen.getByTestId('safe-content')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('uses custom fallback when provided', () => {
|
||||
const customFallback = vi.fn(() => <div data-testid="custom-fallback">Custom</div>);
|
||||
render(
|
||||
<ErrorBoundary fallback={customFallback}>
|
||||
<ThrowOnRender message="Custom fallback" />
|
||||
</ErrorBoundary>
|
||||
);
|
||||
expect(screen.getByTestId('custom-fallback')).toBeInTheDocument();
|
||||
expect(customFallback).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,62 @@
|
||||
/**
|
||||
* PrintButton tests — verifies dropdown, print and PDF export actions.
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { render, screen, fireEvent } from '@testing-library/react';
|
||||
import { PrintButton } from '@/components/common/PrintButton';
|
||||
|
||||
// Mock print utilities
|
||||
vi.mock('@/utils/print', () => ({
|
||||
printElement: vi.fn(),
|
||||
printCurrentPage: vi.fn(),
|
||||
exportToPDF: vi.fn(),
|
||||
}));
|
||||
|
||||
import { printElement, printCurrentPage, exportToPDF } from '@/utils/print';
|
||||
|
||||
describe('PrintButton', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('renders the trigger button', () => {
|
||||
render(<PrintButton />);
|
||||
expect(screen.getByTestId('print-button-trigger')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('opens dropdown on click', () => {
|
||||
render(<PrintButton />);
|
||||
fireEvent.click(screen.getByTestId('print-button-trigger'));
|
||||
expect(screen.getByTestId('print-button-dropdown')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('calls printElement when targetId is set and print is clicked', () => {
|
||||
render(<PrintButton targetId="my-section" />);
|
||||
fireEvent.click(screen.getByTestId('print-button-trigger'));
|
||||
fireEvent.click(screen.getByTestId('print-button-print'));
|
||||
expect(printElement).toHaveBeenCalledWith('my-section');
|
||||
});
|
||||
|
||||
it('calls printCurrentPage when no targetId and print is clicked', () => {
|
||||
render(<PrintButton />);
|
||||
fireEvent.click(screen.getByTestId('print-button-trigger'));
|
||||
fireEvent.click(screen.getByTestId('print-button-print'));
|
||||
expect(printCurrentPage).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('calls exportToPDF with targetId and filename', () => {
|
||||
render(<PrintButton targetId="report" filename="monthly-report" />);
|
||||
fireEvent.click(screen.getByTestId('print-button-trigger'));
|
||||
fireEvent.click(screen.getByTestId('print-button-pdf'));
|
||||
expect(exportToPDF).toHaveBeenCalledWith('report', 'monthly-report');
|
||||
});
|
||||
|
||||
it('has aria-haspopup and aria-expanded attributes', () => {
|
||||
render(<PrintButton />);
|
||||
const btn = screen.getByTestId('print-button-trigger');
|
||||
expect(btn).toHaveAttribute('aria-haspopup', 'menu');
|
||||
expect(btn).toHaveAttribute('aria-expanded', 'false');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,84 @@
|
||||
/**
|
||||
* NotificationDropdown tests — verifies list, mark-read, system channel link.
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
|
||||
import { MemoryRouter } from 'react-router-dom';
|
||||
import { NotificationDropdown } from '@/components/notifications/NotificationDropdown';
|
||||
|
||||
// Mock react-i18next
|
||||
vi.mock('react-i18next', () => ({
|
||||
useTranslation: () => ({ t: (key: string, fallback: string) => fallback }),
|
||||
}));
|
||||
|
||||
// Mock notifications API
|
||||
vi.mock('@/api/notifications', () => ({
|
||||
useNotifications: vi.fn(() => ({
|
||||
data: {
|
||||
items: [
|
||||
{ id: 'n1', type: 'info', title: 'Test Notification', body: 'Body text', read_at: null, created_at: '2026-01-01T00:00:00Z' },
|
||||
{ id: 'n2', type: 'warning', title: 'Read Notification', body: 'Already read', read_at: '2026-01-01T01:00:00Z', created_at: '2026-01-01T00:00:00Z' },
|
||||
],
|
||||
},
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
})),
|
||||
useMarkNotificationRead: vi.fn(() => ({
|
||||
mutate: vi.fn(),
|
||||
isPending: false,
|
||||
})),
|
||||
}));
|
||||
|
||||
// Mock NotificationItem to simplify rendering
|
||||
vi.mock('@/components/notifications/NotificationItem', () => ({
|
||||
NotificationItem: ({ notification }: { notification: any }) => (
|
||||
<div data-testid={`notif-item-${notification.id}`}>
|
||||
{notification.title}
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
|
||||
describe('NotificationDropdown', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
function renderDropdown() {
|
||||
return render(
|
||||
<MemoryRouter>
|
||||
<NotificationDropdown />
|
||||
</MemoryRouter>
|
||||
);
|
||||
}
|
||||
|
||||
it('renders notification items from API', () => {
|
||||
renderDropdown();
|
||||
expect(screen.getByTestId('notif-item-n1')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('notif-item-n2')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows mark-all-read button when there are unread notifications', () => {
|
||||
renderDropdown();
|
||||
expect(screen.getByText('Alle als gelesen')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('has a system channel link button', () => {
|
||||
renderDropdown();
|
||||
expect(screen.getByText('System-Channel öffnen')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('has a view-all button', () => {
|
||||
renderDropdown();
|
||||
expect(screen.getByText('Alle anzeigen')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('has proper ARIA role and label', () => {
|
||||
renderDropdown();
|
||||
const menus = screen.getAllByRole('menu');
|
||||
const outerMenu = menus.find((m) => m.getAttribute('aria-label') === 'Benachrichtigungen');
|
||||
expect(outerMenu).toBeDefined();
|
||||
expect(outerMenu).toHaveAttribute('aria-label', 'Benachrichtigungen');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,36 @@
|
||||
/**
|
||||
* ApiDocs page tests — verifies iframe embedding and external link.
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { ApiDocsPage } from '@/pages/ApiDocs';
|
||||
|
||||
// Mock react-i18next
|
||||
vi.mock('react-i18next', () => ({
|
||||
useTranslation: () => ({ t: (key: string, fallback: string) => fallback }),
|
||||
}));
|
||||
|
||||
describe('ApiDocsPage', () => {
|
||||
it('renders the page with title', () => {
|
||||
render(<ApiDocsPage />);
|
||||
expect(screen.getByTestId('api-docs-page')).toBeInTheDocument();
|
||||
expect(screen.getByText('API-Dokumentation')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('embeds an iframe pointing to /docs', () => {
|
||||
render(<ApiDocsPage />);
|
||||
const iframe = screen.getByTitle('API-Dokumentation');
|
||||
expect(iframe.tagName).toBe('IFRAME');
|
||||
expect(iframe).toHaveAttribute('src', '/docs');
|
||||
});
|
||||
|
||||
it('has a link to open /docs in a new tab', () => {
|
||||
render(<ApiDocsPage />);
|
||||
const link = screen.getByRole('link', { name: 'In neuem Tab öffnen' });
|
||||
expect(link).toHaveAttribute('href', '/docs');
|
||||
expect(link).toHaveAttribute('target', '_blank');
|
||||
expect(link).toHaveAttribute('rel', 'noopener noreferrer');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,99 @@
|
||||
/**
|
||||
* themeStore tests — verifies theme application, dark mode, and persistence.
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||
import { useThemeStore } from '@/store/themeStore';
|
||||
|
||||
describe('themeStore', () => {
|
||||
beforeEach(() => {
|
||||
localStorage.clear();
|
||||
// Reset store to defaults
|
||||
useThemeStore.setState({
|
||||
primaryColor: '#2563eb',
|
||||
accentColor: '#d946ef',
|
||||
fontFamily: 'Inter',
|
||||
borderRadius: '0.5rem',
|
||||
darkMode: false,
|
||||
});
|
||||
// Clear CSS custom properties
|
||||
const root = document.documentElement;
|
||||
root.className = '';
|
||||
// Clear all custom properties
|
||||
for (let i = root.style.length - 1; i >= 0; i--) {
|
||||
const prop = root.style[i];
|
||||
if (prop && prop.startsWith('--')) {
|
||||
root.style.removeProperty(prop);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it('has default theme values', () => {
|
||||
const state = useThemeStore.getState();
|
||||
expect(state.primaryColor).toBe('#2563eb');
|
||||
expect(state.accentColor).toBe('#d946ef');
|
||||
expect(state.fontFamily).toBe('Inter');
|
||||
expect(state.borderRadius).toBe('0.5rem');
|
||||
expect(state.darkMode).toBe(false);
|
||||
});
|
||||
|
||||
it('setTheme updates primary color and applies CSS variables', () => {
|
||||
useThemeStore.getState().setTheme({ primaryColor: '#ff0000' });
|
||||
expect(useThemeStore.getState().primaryColor).toBe('#ff0000');
|
||||
// Check CSS variable was set
|
||||
const root = document.documentElement;
|
||||
expect(root.style.getPropertyValue('--color-primary-default')).toBe('#ff0000');
|
||||
});
|
||||
|
||||
it('toggleDarkMode adds dark class to html element', () => {
|
||||
expect(document.documentElement.classList.contains('dark')).toBe(false);
|
||||
useThemeStore.getState().toggleDarkMode();
|
||||
expect(useThemeStore.getState().darkMode).toBe(true);
|
||||
expect(document.documentElement.classList.contains('dark')).toBe(true);
|
||||
});
|
||||
|
||||
it('toggleDarkMode twice removes dark class', () => {
|
||||
useThemeStore.getState().toggleDarkMode();
|
||||
expect(document.documentElement.classList.contains('dark')).toBe(true);
|
||||
useThemeStore.getState().toggleDarkMode();
|
||||
expect(useThemeStore.getState().darkMode).toBe(false);
|
||||
expect(document.documentElement.classList.contains('dark')).toBe(false);
|
||||
});
|
||||
|
||||
it('saves theme to localStorage', () => {
|
||||
useThemeStore.getState().setTheme({ primaryColor: '#00ff00' });
|
||||
const stored = localStorage.getItem('leocrm-theme');
|
||||
expect(stored).not.toBeNull();
|
||||
const parsed = JSON.parse(stored!);
|
||||
expect(parsed.primaryColor).toBe('#00ff00');
|
||||
});
|
||||
|
||||
it('loadFromStorage restores saved theme', () => {
|
||||
localStorage.setItem('leocrm-theme', JSON.stringify({
|
||||
primaryColor: '#123456',
|
||||
accentColor: '#abcdef',
|
||||
fontFamily: 'Roboto',
|
||||
borderRadius: '1rem',
|
||||
darkMode: true,
|
||||
}));
|
||||
useThemeStore.getState().loadFromStorage();
|
||||
const state = useThemeStore.getState();
|
||||
expect(state.primaryColor).toBe('#123456');
|
||||
expect(state.accentColor).toBe('#abcdef');
|
||||
expect(state.fontFamily).toBe('Roboto');
|
||||
expect(state.borderRadius).toBe('1rem');
|
||||
expect(state.darkMode).toBe(true);
|
||||
});
|
||||
|
||||
it('setTheme applies font family CSS variable', () => {
|
||||
useThemeStore.getState().setTheme({ fontFamily: 'Roboto' });
|
||||
const root = document.documentElement;
|
||||
expect(root.style.getPropertyValue('--font-sans')).toContain('Roboto');
|
||||
});
|
||||
|
||||
it('setTheme applies border radius CSS variable', () => {
|
||||
useThemeStore.getState().setTheme({ borderRadius: '1rem' });
|
||||
const root = document.documentElement;
|
||||
expect(root.style.getPropertyValue('--radius-md')).toBe('1rem');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user