diff --git a/apps/web/src/components/dashboard/widget-registry.test.tsx b/apps/web/src/components/dashboard/widget-registry.test.tsx
new file mode 100644
index 0000000..6f90e61
--- /dev/null
+++ b/apps/web/src/components/dashboard/widget-registry.test.tsx
@@ -0,0 +1,55 @@
+import { describe, expect, it } from 'vitest';
+import { WIDGET_CONSTRAINTS, type WidgetType } from './widget-registry';
+
+/**
+ * DASH-11: Every WidgetType entry in WIDGET_CONSTRAINTS must have
+ * the four numeric grid-constraint fields: minW, minH, defaultW, defaultH.
+ */
+
+const ALL_WIDGET_TYPES: WidgetType[] = [
+ // Existing types
+ 'clock',
+ 'search',
+ 'calendar',
+ 'note',
+ // New types (Phase 8)
+ 'calculator',
+ 'favorites',
+ 'link',
+ 'stopwatch',
+];
+
+describe('WIDGET_CONSTRAINTS (DASH-11)', () => {
+ it('contains entries for all expected widget types', () => {
+ for (const type of ALL_WIDGET_TYPES) {
+ expect(
+ WIDGET_CONSTRAINTS,
+ `WIDGET_CONSTRAINTS must contain key "${type}"`,
+ ).toHaveProperty(type);
+ }
+ });
+
+ it.each(ALL_WIDGET_TYPES)(
+ 'WIDGET_CONSTRAINTS["%s"] has numeric minW, minH, defaultW, defaultH',
+ (type) => {
+ const constraints = WIDGET_CONSTRAINTS[type];
+
+ expect(typeof constraints.minW, `${type}.minW must be a number`).toBe('number');
+ expect(typeof constraints.minH, `${type}.minH must be a number`).toBe('number');
+ expect(typeof constraints.defaultW, `${type}.defaultW must be a number`).toBe('number');
+ expect(typeof constraints.defaultH, `${type}.defaultH must be a number`).toBe('number');
+
+ expect(constraints.minW, `${type}.minW must be >= 1`).toBeGreaterThanOrEqual(1);
+ expect(constraints.minH, `${type}.minH must be >= 1`).toBeGreaterThanOrEqual(1);
+ expect(constraints.defaultW, `${type}.defaultW >= minW`).toBeGreaterThanOrEqual(constraints.minW);
+ expect(constraints.defaultH, `${type}.defaultH >= minH`).toBeGreaterThanOrEqual(constraints.minH);
+ },
+ );
+
+ it('WIDGET_CONSTRAINTS contains new Phase-8 keys', () => {
+ expect(Object.keys(WIDGET_CONSTRAINTS)).toContain('calculator');
+ expect(Object.keys(WIDGET_CONSTRAINTS)).toContain('favorites');
+ expect(Object.keys(WIDGET_CONSTRAINTS)).toContain('link');
+ expect(Object.keys(WIDGET_CONSTRAINTS)).toContain('stopwatch');
+ });
+});
diff --git a/apps/web/src/components/dashboard/widgets/calculator-widget.test.tsx b/apps/web/src/components/dashboard/widgets/calculator-widget.test.tsx
new file mode 100644
index 0000000..5f57548
--- /dev/null
+++ b/apps/web/src/components/dashboard/widgets/calculator-widget.test.tsx
@@ -0,0 +1,85 @@
+import { render, screen, fireEvent } from '@testing-library/react';
+import { describe, expect, it, vi } from 'vitest';
+
+// Mock next-intl — returns the key so tests are locale-agnostic
+vi.mock('next-intl', () => ({
+ useTranslations: () => (key: string) => key,
+}));
+
+// Must import after mocks
+import { CalculatorWidget } from './calculator-widget';
+
+const defaultProps = {
+ instanceId: 'calc-1',
+ config: {},
+ isEditMode: false,
+};
+
+describe('CalculatorWidget', () => {
+ it('renders with initial display "0"', () => {
+ render();
+ const display = screen.getByLabelText('Anzeige');
+ expect(display).toHaveTextContent('0');
+ });
+
+ it('calculates 7 + 3 = 10', () => {
+ render();
+
+ fireEvent.click(screen.getByRole('button', { name: '7' }));
+ fireEvent.click(screen.getByRole('button', { name: '+' }));
+ fireEvent.click(screen.getByRole('button', { name: '3' }));
+ fireEvent.click(screen.getByRole('button', { name: /gleich/i }));
+
+ expect(screen.getByLabelText('Anzeige')).toHaveTextContent('10');
+ });
+
+ it('calculates 8 × 2 = 16', () => {
+ render();
+
+ fireEvent.click(screen.getByRole('button', { name: '8' }));
+ fireEvent.click(screen.getByRole('button', { name: '×' }));
+ fireEvent.click(screen.getByRole('button', { name: '2' }));
+ fireEvent.click(screen.getByRole('button', { name: /gleich/i }));
+
+ expect(screen.getByLabelText('Anzeige')).toHaveTextContent('16');
+ });
+
+ it('shows "Fehler" on division by zero', () => {
+ render();
+
+ fireEvent.click(screen.getByRole('button', { name: '5' }));
+ fireEvent.click(screen.getByRole('button', { name: '÷' }));
+ fireEvent.click(screen.getByRole('button', { name: '0' }));
+ fireEvent.click(screen.getByRole('button', { name: /gleich/i }));
+
+ expect(screen.getByLabelText('Anzeige')).toHaveTextContent('Fehler');
+ });
+
+ it('handles keyboard input: 5 * 6 Enter = 30', () => {
+ render();
+
+ const container = screen.getByRole('application');
+
+ fireEvent.keyDown(container, { key: '5' });
+ fireEvent.keyDown(container, { key: '*' });
+ fireEvent.keyDown(container, { key: '6' });
+ fireEvent.keyDown(container, { key: 'Enter' });
+
+ expect(screen.getByLabelText('Anzeige')).toHaveTextContent('30');
+ });
+
+ it('handles decimal comma: 1,5 + 1,5 = 3', () => {
+ render();
+
+ fireEvent.click(screen.getByRole('button', { name: '1' }));
+ fireEvent.click(screen.getByRole('button', { name: ',' }));
+ fireEvent.click(screen.getByRole('button', { name: '5' }));
+ fireEvent.click(screen.getByRole('button', { name: '+' }));
+ fireEvent.click(screen.getByRole('button', { name: '1' }));
+ fireEvent.click(screen.getByRole('button', { name: ',' }));
+ fireEvent.click(screen.getByRole('button', { name: '5' }));
+ fireEvent.click(screen.getByRole('button', { name: /gleich/i }));
+
+ expect(screen.getByLabelText('Anzeige')).toHaveTextContent('3');
+ });
+});