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:
Agent Zero
2026-08-13 21:57:59 +02:00
parent 0e72d4624d
commit 25b2581653
15 changed files with 713 additions and 62 deletions
@@ -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');
});
});