+
-
+
No results found
-
- Try expanding your search parameters or checking for typos.
+
+ We couldn't find any matches for your search. Try adjusting your query keywords or
+ filters to find what you're looking for.
);
diff --git a/src/components/search/__tests__/SearchResultsVisualizer.test.tsx b/src/components/search/__tests__/SearchResultsVisualizer.test.tsx
new file mode 100644
index 00000000..1b78c4cc
--- /dev/null
+++ b/src/components/search/__tests__/SearchResultsVisualizer.test.tsx
@@ -0,0 +1,100 @@
+import React from 'react';
+import { describe, it, expect, vi } from 'vitest';
+import { render, screen, fireEvent } from '@testing-library/react';
+import { SearchResultsVisualizer } from '../SearchResultsVisualizer';
+import type { SearchResult } from '../../utils/searchUtils';
+
+const mockResults: SearchResult[] = [
+ {
+ id: 'res-1',
+ type: 'course',
+ title: 'Starknet Cairo Essentials',
+ description: 'Learn Cairo and Starknet development from scratch.',
+ createdAt: '2024-05-10T12:00:00.000Z',
+ relevanceScore: 0.95,
+ author: 'Alice',
+ rating: 4.8,
+ price: 0,
+ topic: 'Cairo',
+ difficulty: 'beginner',
+ reputation: 99,
+ },
+];
+
+describe('SearchResultsVisualizer', () => {
+ it('renders a distinct, accessible empty state when there are zero results and not searching', () => {
+ const onSortChange = vi.fn();
+ render(
+
,
+ );
+
+ const emptyState = screen.getByTestId('search-empty-state');
+ expect(emptyState).toBeInTheDocument();
+ expect(emptyState).toHaveAttribute('role', 'status');
+ expect(emptyState).toHaveAttribute('aria-live', 'polite');
+
+ expect(screen.getByRole('heading', { level: 3, name: /no results found/i })).toBeInTheDocument();
+ expect(
+ screen.getByText(/we couldn't find any matches for your search/i),
+ ).toBeInTheDocument();
+ expect(
+ screen.getByText(/try adjusting your query keywords or filters/i),
+ ).toBeInTheDocument();
+ });
+
+ it('renders loading skeleton when isSearching is true', () => {
+ const onSortChange = vi.fn();
+ const { container } = render(
+
,
+ );
+
+ expect(screen.queryByTestId('search-empty-state')).not.toBeInTheDocument();
+ expect(container.querySelectorAll('.animate-pulse').length).toBe(3);
+ });
+
+ it('renders search results when results are provided', () => {
+ const onSortChange = vi.fn();
+ render(
+
,
+ );
+
+ expect(screen.queryByTestId('search-empty-state')).not.toBeInTheDocument();
+ expect(screen.getByText('1 results')).toBeInTheDocument();
+ expect(screen.getByText('Starknet Cairo Essentials')).toBeInTheDocument();
+ expect(screen.getByText('Learn Cairo and Starknet development from scratch.')).toBeInTheDocument();
+ expect(screen.getByText('Alice')).toBeInTheDocument();
+ expect(screen.getByText('FREE')).toBeInTheDocument();
+ });
+
+ it('calls onSortChange when user changes the sort option', () => {
+ const onSortChange = vi.fn();
+ render(
+
,
+ );
+
+ const sortSelect = screen.getByRole('combobox');
+ fireEvent.change(sortSelect, { target: { value: 'newest' } });
+
+ expect(onSortChange).toHaveBeenCalledWith('newest');
+ });
+});
diff --git a/src/hooks/useSearchFilters.tsx b/src/hooks/useSearchFilters.tsx
index c2060a11..de752816 100644
--- a/src/hooks/useSearchFilters.tsx
+++ b/src/hooks/useSearchFilters.tsx
@@ -2,6 +2,7 @@
import { useEffect, useState, useCallback, useMemo, useRef } from 'react';
import { useSearchParams, useRouter, usePathname } from 'next/navigation';
+import { debounce } from '../utils/formUtils';
export interface FilterState {
difficulty: string[];
@@ -76,7 +77,7 @@ export const useSearchFilters = () => {
}, [router, pathname, searchParams]);
useEffect(() => {
- const timer = setTimeout(() => {
+ const debouncedSync = debounce(() => {
const params = new URLSearchParams();
if (filters.difficulty && filters.difficulty.length > 0) {
@@ -123,7 +124,11 @@ export const useSearchFilters = () => {
pRouter.replace(newUrl, { scroll: false });
}, 300);
- return () => clearTimeout(timer);
+ debouncedSync();
+
+ return () => {
+ debouncedSync.cancel();
+ };
}, [filters]);
const setFilters = useCallback((newFilters: Partial
) => {
diff --git a/src/utils/accessibilityUtils.ts b/src/utils/accessibilityUtils.ts
index 4f49eae7..874841ae 100644
--- a/src/utils/accessibilityUtils.ts
+++ b/src/utils/accessibilityUtils.ts
@@ -37,7 +37,15 @@ function getLuminance(r: number, g: number, b: number): number {
* Parse hex color to RGB
*/
function hexToRgb(hex: string): { r: number; g: number; b: number } | null {
- const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex);
+ const cleanHex = hex.trim().replace(/^#/, '');
+ if (cleanHex.length === 3) {
+ const r = parseInt(cleanHex[0] + cleanHex[0], 16);
+ const g = parseInt(cleanHex[1] + cleanHex[1], 16);
+ const b = parseInt(cleanHex[2] + cleanHex[2], 16);
+ if (isNaN(r) || isNaN(g) || isNaN(b)) return null;
+ return { r, g, b };
+ }
+ const result = /^([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(cleanHex);
return result
? {
r: parseInt(result[1], 16),
diff --git a/src/utils/formUtils.ts b/src/utils/formUtils.ts
index 7938ca9e..dd2d91f5 100644
--- a/src/utils/formUtils.ts
+++ b/src/utils/formUtils.ts
@@ -350,16 +350,21 @@ export function formDataToObject(formData: FormData): Record {
return obj;
}
+export interface DebouncedFunction any> {
+ (...args: Parameters): void;
+ cancel: () => void;
+}
+
/**
* Debounce function for form operations
*/
export function debounce any>(
func: T,
wait: number,
-): (...args: Parameters) => void {
+): DebouncedFunction {
let timeout: ReturnType | null = null;
- return function executedFunction(...args: Parameters) {
+ const executedFunction = function (...args: Parameters) {
const later = () => {
timeout = null;
func(...args);
@@ -370,6 +375,15 @@ export function debounce any>(
}
timeout = setTimeout(later, wait);
};
+
+ executedFunction.cancel = () => {
+ if (timeout) {
+ clearTimeout(timeout);
+ timeout = null;
+ }
+ };
+
+ return executedFunction;
}
/**
diff --git a/src/utils/notificationUtils.ts b/src/utils/notificationUtils.ts
index 9b8ea016..86413c31 100644
--- a/src/utils/notificationUtils.ts
+++ b/src/utils/notificationUtils.ts
@@ -73,8 +73,14 @@ export function generateNotificationId(): string {
*/
export function formatNotificationTime(timestamp: string): string {
const date = new Date(timestamp);
+ if (isNaN(date.getTime())) {
+ return '';
+ }
const now = new Date();
const diffMs = now.getTime() - date.getTime();
+ if (diffMs < 0) {
+ return 'Just now';
+ }
const diffMins = Math.floor(diffMs / 60000);
const diffHours = Math.floor(diffMs / 3600000);
const diffDays = Math.floor(diffMs / 86400000);
@@ -99,13 +105,25 @@ export function isWithinQuietHours(quietHours: {
end: string;
timezone: string;
}): boolean {
+ if (!quietHours?.start || !quietHours?.end || !quietHours?.timezone) {
+ return false;
+ }
const now = new Date();
- const currentTime = now.toLocaleTimeString('en-US', {
- hour12: false,
- hour: '2-digit',
- minute: '2-digit',
- timeZone: quietHours.timezone,
- });
+ let currentTime: string;
+ try {
+ currentTime = now.toLocaleTimeString('en-US', {
+ hour12: false,
+ hour: '2-digit',
+ minute: '2-digit',
+ timeZone: quietHours.timezone,
+ });
+ } catch {
+ currentTime = now.toLocaleTimeString('en-US', {
+ hour12: false,
+ hour: '2-digit',
+ minute: '2-digit',
+ });
+ }
const start = quietHours.start;
const end = quietHours.end;
@@ -126,6 +144,10 @@ export function shouldSendNotification(
channel: NotificationChannel,
preferences: UserNotificationPreferences,
): boolean {
+ if (!preferences?.channels || !preferences?.categories) {
+ return false;
+ }
+
// Check if channel is enabled globally
if (!preferences.channels[channel === 'in-app' ? 'inApp' : channel]) {
return false;
@@ -138,19 +160,19 @@ export function shouldSendNotification(
}
// Check if channel is enabled for this category
- if (!categoryPrefs.channels.includes(channel)) {
+ if (!categoryPrefs.channels?.includes(channel)) {
return false;
}
// Check quiet hours
- if (preferences.quietHours.enabled && channel !== 'in-app') {
+ if (preferences.quietHours?.enabled && channel !== 'in-app') {
if (isWithinQuietHours(preferences.quietHours)) {
return false;
}
}
// Check category-specific quiet hours
- if (categoryPrefs.quietHours) {
+ if (categoryPrefs.quietHours?.start && categoryPrefs.quietHours?.end) {
const now = new Date();
const currentTime = now.toLocaleTimeString('en-US', {
hour12: false,
@@ -159,8 +181,11 @@ export function shouldSendNotification(
});
if (
- currentTime >= categoryPrefs.quietHours.start &&
- currentTime <= categoryPrefs.quietHours.end
+ categoryPrefs.quietHours.start > categoryPrefs.quietHours.end
+ ? currentTime >= categoryPrefs.quietHours.start ||
+ currentTime <= categoryPrefs.quietHours.end
+ : currentTime >= categoryPrefs.quietHours.start &&
+ currentTime <= categoryPrefs.quietHours.end
) {
return false;
}
diff --git a/src/utils/tests/accessibilityUtils.test.ts b/src/utils/tests/accessibilityUtils.test.ts
new file mode 100644
index 00000000..fe76d194
--- /dev/null
+++ b/src/utils/tests/accessibilityUtils.test.ts
@@ -0,0 +1,444 @@
+import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
+import {
+ calculateContrastRatio,
+ getComputedColor,
+ isFocusable,
+ getFocusableElements,
+ getRovingFocusCandidates,
+ trapFocus,
+ hasAccessibleName,
+ generateAriaId,
+ announceToScreenReader,
+ checkAccessibilityIssues,
+ runAccessibilityAudit,
+ getWCAGLevel,
+ AccessibilityIssue,
+} from '../accessibilityUtils';
+
+describe('accessibilityUtils', () => {
+ describe('calculateContrastRatio', () => {
+ it('calculates maximum contrast ratio for black and white', () => {
+ const result = calculateContrastRatio('#000000', '#ffffff');
+ expect(result.ratio).toBe(21);
+ expect(result.passes.aa).toBe(true);
+ expect(result.passes.aaa).toBe(true);
+ expect(result.passes.aaLarge).toBe(true);
+ expect(result.passes.aaaLarge).toBe(true);
+ });
+
+ it('calculates contrast ratio for 3-digit shorthand hex codes', () => {
+ const result = calculateContrastRatio('#000', '#fff');
+ expect(result.ratio).toBe(21);
+ expect(result.passes.aa).toBe(true);
+ });
+
+ it('calculates minimum contrast ratio for identical colors', () => {
+ const result = calculateContrastRatio('#ffffff', '#ffffff');
+ expect(result.ratio).toBe(1);
+ expect(result.passes.aa).toBe(false);
+ expect(result.passes.aaa).toBe(false);
+ expect(result.passes.aaLarge).toBe(false);
+ expect(result.passes.aaaLarge).toBe(false);
+ });
+
+ it('handles intermediate contrast ratios accurately', () => {
+ const result = calculateContrastRatio('#767676', '#ffffff');
+ expect(result.ratio).toBeGreaterThanOrEqual(4.5);
+ expect(result.passes.aa).toBe(true);
+ });
+
+ it('returns zero ratio and false passes for invalid color values', () => {
+ const result1 = calculateContrastRatio('invalid', '#ffffff');
+ expect(result1.ratio).toBe(0);
+ expect(result1.passes.aa).toBe(false);
+
+ const result2 = calculateContrastRatio('#ffffff', 'not-a-color');
+ expect(result2.ratio).toBe(0);
+ expect(result2.passes.aa).toBe(false);
+ });
+ });
+
+ describe('getComputedColor', () => {
+ it('converts computed RGB style to hex', () => {
+ const div = document.createElement('div');
+ vi.spyOn(window, 'getComputedStyle').mockReturnValue({
+ getPropertyValue: vi.fn().mockReturnValue('rgb(255, 0, 128)'),
+ } as unknown as CSSStyleDeclaration);
+
+ const color = getComputedColor(div, 'color');
+ expect(color).toBe('#ff0080');
+ });
+
+ it('returns #000000 when computed color does not match rgb pattern', () => {
+ const div = document.createElement('div');
+ vi.spyOn(window, 'getComputedStyle').mockReturnValue({
+ getPropertyValue: vi.fn().mockReturnValue('transparent'),
+ } as unknown as CSSStyleDeclaration);
+
+ const color = getComputedColor(div, 'background-color');
+ expect(color).toBe('#000000');
+ });
+ });
+
+ describe('isFocusable', () => {
+ it('returns true for naturally focusable tags', () => {
+ const button = document.createElement('button');
+ const anchor = document.createElement('a');
+ const input = document.createElement('input');
+ const select = document.createElement('select');
+ const textarea = document.createElement('textarea');
+
+ expect(isFocusable(button)).toBe(true);
+ expect(isFocusable(anchor)).toBe(true);
+ expect(isFocusable(input)).toBe(true);
+ expect(isFocusable(select)).toBe(true);
+ expect(isFocusable(textarea)).toBe(true);
+ });
+
+ it('returns true for elements with non-negative tabindex', () => {
+ const div = document.createElement('div');
+ div.setAttribute('tabindex', '0');
+ expect(isFocusable(div)).toBe(true);
+
+ const span = document.createElement('span');
+ span.setAttribute('tabindex', '1');
+ expect(isFocusable(span)).toBe(true);
+ });
+
+ it('returns true for contenteditable elements', () => {
+ const div = document.createElement('div');
+ div.setAttribute('contenteditable', 'true');
+ expect(isFocusable(div)).toBe(true);
+ });
+
+ it('returns false for non-focusable elements', () => {
+ const div = document.createElement('div');
+ const p = document.createElement('p');
+ const span = document.createElement('span');
+
+ expect(isFocusable(div)).toBe(false);
+ expect(isFocusable(p)).toBe(false);
+ expect(isFocusable(span)).toBe(false);
+ });
+ });
+
+ describe('getFocusableElements', () => {
+ it('finds and filters focusable elements within container', () => {
+ const container = document.createElement('div');
+ container.innerHTML = `
+ Link
+ Anchor without href
+
+
+
+
+
+
+ Custom focusable
+ Excluded tabindex
+
+
+
+
+
+
+ `;
+ document.body.appendChild(container);
+
+ const focusable = getFocusableElements(container);
+ expect(focusable.length).toBe(6);
+ expect(focusable.map((el) => el.tagName.toLowerCase())).toEqual([
+ 'a',
+ 'button',
+ 'input',
+ 'select',
+ 'textarea',
+ 'div',
+ ]);
+
+ document.body.removeChild(container);
+ });
+ });
+
+ describe('getRovingFocusCandidates', () => {
+ it('returns candidates including roving items and menu items', () => {
+ const container = document.createElement('div');
+ container.innerHTML = `
+
+ Link
+
+
+ Item 1
+ Disabled item
+ Tab 1
+ Radio 1
+ Roving
+ Hidden item
+ `;
+
+ const candidates = getRovingFocusCandidates(container);
+ expect(candidates.length).toBe(6);
+ });
+ });
+
+ describe('trapFocus', () => {
+ it('ignores non-Tab key events', () => {
+ const container = document.createElement('div');
+ const event = new KeyboardEvent('keydown', { key: 'Enter' });
+ const preventDefaultSpy = vi.spyOn(event, 'preventDefault');
+
+ trapFocus(container, event);
+ expect(preventDefaultSpy).not.toHaveBeenCalled();
+ });
+
+ it('wraps focus to first element when tabbing from last element', () => {
+ const container = document.createElement('div');
+ container.innerHTML = `
+
+
+ `;
+ document.body.appendChild(container);
+
+ const first = container.querySelector('#first') as HTMLElement;
+ const last = container.querySelector('#last') as HTMLElement;
+ last.focus();
+
+ const focusSpy = vi.spyOn(first, 'focus');
+ const event = new KeyboardEvent('keydown', { key: 'Tab', shiftKey: false });
+ const preventDefaultSpy = vi.spyOn(event, 'preventDefault');
+
+ trapFocus(container, event);
+
+ expect(preventDefaultSpy).toHaveBeenCalled();
+ expect(focusSpy).toHaveBeenCalled();
+
+ document.body.removeChild(container);
+ });
+
+ it('wraps focus to last element when shift-tabbing from first element', () => {
+ const container = document.createElement('div');
+ container.innerHTML = `
+
+
+ `;
+ document.body.appendChild(container);
+
+ const first = container.querySelector('#first') as HTMLElement;
+ const last = container.querySelector('#last') as HTMLElement;
+ first.focus();
+
+ const focusSpy = vi.spyOn(last, 'focus');
+ const event = new KeyboardEvent('keydown', { key: 'Tab', shiftKey: true });
+ const preventDefaultSpy = vi.spyOn(event, 'preventDefault');
+
+ trapFocus(container, event);
+
+ expect(preventDefaultSpy).toHaveBeenCalled();
+ expect(focusSpy).toHaveBeenCalled();
+
+ document.body.removeChild(container);
+ });
+ });
+
+ describe('hasAccessibleName', () => {
+ it('detects accessible names across aria-label, aria-labelledby, textContent, and title', () => {
+ const el1 = document.createElement('button');
+ el1.setAttribute('aria-label', 'Close');
+ expect(hasAccessibleName(el1)).toBe(true);
+
+ const el2 = document.createElement('button');
+ el2.setAttribute('aria-labelledby', 'heading-1');
+ expect(hasAccessibleName(el2)).toBe(true);
+
+ const el3 = document.createElement('button');
+ el3.textContent = 'Submit';
+ expect(hasAccessibleName(el3)).toBe(true);
+
+ const el4 = document.createElement('button');
+ el4.setAttribute('title', 'Helpful info');
+ expect(hasAccessibleName(el4)).toBe(true);
+
+ const elEmpty = document.createElement('button');
+ expect(hasAccessibleName(elEmpty)).toBe(false);
+ });
+ });
+
+ describe('generateAriaId', () => {
+ it('generates unique ARIA ids with default or custom prefix', () => {
+ const id1 = generateAriaId();
+ const id2 = generateAriaId();
+ const customId = generateAriaId('dialog');
+
+ expect(id1.startsWith('aria-')).toBe(true);
+ expect(customId.startsWith('dialog-')).toBe(true);
+ expect(id1).not.toBe(id2);
+ });
+ });
+
+ describe('announceToScreenReader', () => {
+ beforeEach(() => {
+ vi.useFakeTimers();
+ });
+
+ afterEach(() => {
+ vi.useRealTimers();
+ });
+
+ it('creates polite live region announcement and cleans it up after 1s', () => {
+ announceToScreenReader('Changes saved');
+
+ const el = document.body.querySelector('.sr-only');
+ expect(el).not.toBeNull();
+ expect(el?.getAttribute('role')).toBe('status');
+ expect(el?.getAttribute('aria-live')).toBe('polite');
+ expect(el?.textContent).toBe('Changes saved');
+
+ vi.advanceTimersByTime(1000);
+ expect(document.body.querySelector('.sr-only')).toBeNull();
+ });
+
+ it('creates assertive live region when requested', () => {
+ announceToScreenReader('Error occurred', 'assertive');
+
+ const el = document.body.querySelector('.sr-only');
+ expect(el?.getAttribute('role')).toBe('alert');
+ expect(el?.getAttribute('aria-live')).toBe('assertive');
+
+ vi.advanceTimersByTime(1000);
+ expect(document.body.querySelector('.sr-only')).toBeNull();
+ });
+ });
+
+ describe('checkAccessibilityIssues and runAccessibilityAudit', () => {
+ it('detects missing alt attributes on images', () => {
+ const container = document.createElement('div');
+ container.innerHTML = `
+
+
+ `;
+
+ const issues = checkAccessibilityIssues(container);
+ expect(issues.some((i) => i.type === 'missing-alt')).toBe(true);
+ expect(issues.filter((i) => i.type === 'missing-alt')).toHaveLength(1);
+ });
+
+ it('detects unlabelled form inputs', () => {
+ const container = document.createElement('div');
+ container.innerHTML = `
+
+
+
+
+
+
+ `;
+
+ const issues = checkAccessibilityIssues(container);
+ const labelIssues = issues.filter((i) => i.type === 'missing-label');
+ expect(labelIssues).toHaveLength(1);
+ });
+
+ it('detects buttons and links missing accessible names', () => {
+ const container = document.createElement('div');
+ container.innerHTML = `
+
+
+
+ About
+ `;
+
+ const issues = checkAccessibilityIssues(container);
+ expect(issues.some((i) => i.type === 'missing-accessible-name' && i.element === 'button')).toBe(true);
+ expect(issues.some((i) => i.type === 'missing-accessible-name' && i.element === 'a')).toBe(true);
+ });
+
+ it('detects skipped heading levels', () => {
+ const container = document.createElement('div');
+ container.innerHTML = `
+ Title
+ Skipped subheader
+ `;
+
+ const issues = checkAccessibilityIssues(container);
+ expect(issues.some((i) => i.type === 'heading-hierarchy')).toBe(true);
+ });
+
+ it('detects duplicate id attributes', () => {
+ const container = document.createElement('div');
+ container.innerHTML = `
+ One
+ Two
+ `;
+
+ const issues = checkAccessibilityIssues(container);
+ expect(issues.some((i) => i.type === 'duplicate-id')).toBe(true);
+ });
+
+ it('runs audit with runAccessibilityAudit', () => {
+ const container = document.createElement('div');
+ container.innerHTML = `Page
Clean markup
`;
+ const issues = runAccessibilityAudit(container);
+ expect(Array.isArray(issues)).toBe(true);
+ });
+ });
+
+ describe('getWCAGLevel', () => {
+ it('returns Fail when critical issues are present', () => {
+ const issues: AccessibilityIssue[] = [
+ {
+ id: '1',
+ severity: 'critical',
+ type: 'missing-alt',
+ element: 'img',
+ message: 'Error',
+ wcagCriteria: ['1.1.1'],
+ suggestion: 'Fix',
+ },
+ ];
+ expect(getWCAGLevel(issues)).toBe('Fail');
+ });
+
+ it('returns A when serious issues are present', () => {
+ const issues: AccessibilityIssue[] = [
+ {
+ id: '1',
+ severity: 'serious',
+ type: 'duplicate-id',
+ element: '#id',
+ message: 'Error',
+ wcagCriteria: ['4.1.1'],
+ suggestion: 'Fix',
+ },
+ ];
+ expect(getWCAGLevel(issues)).toBe('A');
+ });
+
+ it('returns AA when there are more than 5 moderate/minor issues', () => {
+ const issues: AccessibilityIssue[] = Array.from({ length: 6 }, (_, i) => ({
+ id: `issue-${i}`,
+ severity: 'moderate' as const,
+ type: 'heading-hierarchy',
+ element: 'h3',
+ message: 'Skipped',
+ wcagCriteria: ['1.3.1'],
+ suggestion: 'Fix',
+ }));
+ expect(getWCAGLevel(issues)).toBe('AA');
+ });
+
+ it('returns AAA when issues are 5 or fewer and no critical or serious issues', () => {
+ const issues: AccessibilityIssue[] = [
+ {
+ id: '1',
+ severity: 'minor',
+ type: 'misc',
+ element: 'div',
+ message: 'Minor warning',
+ wcagCriteria: [],
+ suggestion: 'Fix',
+ },
+ ];
+ expect(getWCAGLevel(issues)).toBe('AAA');
+ });
+ });
+});
diff --git a/src/utils/tests/formUtils.test.ts b/src/utils/tests/formUtils.test.ts
new file mode 100644
index 00000000..91ae0b3b
--- /dev/null
+++ b/src/utils/tests/formUtils.test.ts
@@ -0,0 +1,84 @@
+import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
+import { debounce, throttle } from '../formUtils';
+
+describe('formUtils - debounce', () => {
+ beforeEach(() => {
+ vi.useFakeTimers();
+ });
+
+ afterEach(() => {
+ vi.useRealTimers();
+ });
+
+ it('debounces calls within the specified delay', () => {
+ const callback = vi.fn();
+ const debounced = debounce(callback, 300);
+
+ debounced('first');
+ debounced('second');
+ debounced('third');
+
+ expect(callback).not.toHaveBeenCalled();
+
+ vi.advanceTimersByTime(299);
+ expect(callback).not.toHaveBeenCalled();
+
+ vi.advanceTimersByTime(1);
+ expect(callback).toHaveBeenCalledTimes(1);
+ expect(callback).toHaveBeenCalledWith('third');
+ });
+
+ it('allows cancelling pending debounced execution', () => {
+ const callback = vi.fn();
+ const debounced = debounce(callback, 300);
+
+ debounced('call-1');
+ vi.advanceTimersByTime(150);
+
+ debounced.cancel();
+
+ vi.advanceTimersByTime(200);
+ expect(callback).not.toHaveBeenCalled();
+ });
+
+ it('can be invoked again after cancellation', () => {
+ const callback = vi.fn();
+ const debounced = debounce(callback, 300);
+
+ debounced('call-1');
+ debounced.cancel();
+
+ debounced('call-2');
+ vi.advanceTimersByTime(300);
+
+ expect(callback).toHaveBeenCalledTimes(1);
+ expect(callback).toHaveBeenCalledWith('call-2');
+ });
+});
+
+describe('formUtils - throttle', () => {
+ beforeEach(() => {
+ vi.useFakeTimers();
+ });
+
+ afterEach(() => {
+ vi.useRealTimers();
+ });
+
+ it('throttles calls within the specified limit', () => {
+ const callback = vi.fn();
+ const throttled = throttle(callback, 200);
+
+ throttled('first');
+ throttled('second');
+
+ expect(callback).toHaveBeenCalledTimes(1);
+ expect(callback).toHaveBeenCalledWith('first');
+
+ vi.advanceTimersByTime(200);
+
+ throttled('third');
+ expect(callback).toHaveBeenCalledTimes(2);
+ expect(callback).toHaveBeenCalledWith('third');
+ });
+});
diff --git a/src/utils/tests/notificationUtils.test.ts b/src/utils/tests/notificationUtils.test.ts
new file mode 100644
index 00000000..a391b8a1
--- /dev/null
+++ b/src/utils/tests/notificationUtils.test.ts
@@ -0,0 +1,377 @@
+import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
+import {
+ generateNotificationId,
+ formatNotificationTime,
+ isWithinQuietHours,
+ shouldSendNotification,
+ calculateAnalytics,
+ sortNotifications,
+ filterNotifications,
+ groupNotificationsByDate,
+ truncateMessage,
+ getNotificationIcon,
+ getNotificationColor,
+ validatePreferences,
+ createDefaultPreferences,
+ NotificationCategory,
+ NotificationChannel,
+ NotificationPriority,
+ UserNotificationPreferences,
+} from '../notificationUtils';
+
+describe('notificationUtils', () => {
+ describe('generateNotificationId', () => {
+ it('generates unique IDs prefixed with ntf_', () => {
+ const id1 = generateNotificationId();
+ const id2 = generateNotificationId();
+
+ expect(id1.startsWith('ntf_')).toBe(true);
+ expect(id2.startsWith('ntf_')).toBe(true);
+ expect(id1).not.toBe(id2);
+ });
+ });
+
+ describe('formatNotificationTime', () => {
+ it('returns empty string for invalid timestamp strings', () => {
+ expect(formatNotificationTime('invalid-date')).toBe('');
+ });
+
+ it('returns "Just now" for timestamps less than a minute ago or in the future', () => {
+ const now = new Date();
+ expect(formatNotificationTime(now.toISOString())).toBe('Just now');
+
+ const thirtySecsAgo = new Date(Date.now() - 30 * 1000);
+ expect(formatNotificationTime(thirtySecsAgo.toISOString())).toBe('Just now');
+
+ const future = new Date(Date.now() + 5000);
+ expect(formatNotificationTime(future.toISOString())).toBe('Just now');
+ });
+
+ it('returns minutes ago for timestamps within the past hour', () => {
+ const tenMinsAgo = new Date(Date.now() - 10 * 60 * 1000);
+ expect(formatNotificationTime(tenMinsAgo.toISOString())).toBe('10m ago');
+ });
+
+ it('returns hours ago for timestamps within 24 hours', () => {
+ const fiveHoursAgo = new Date(Date.now() - 5 * 60 * 60 * 1000);
+ expect(formatNotificationTime(fiveHoursAgo.toISOString())).toBe('5h ago');
+ });
+
+ it('returns days ago for timestamps within 7 days', () => {
+ const threeDaysAgo = new Date(Date.now() - 3 * 24 * 60 * 60 * 1000);
+ expect(formatNotificationTime(threeDaysAgo.toISOString())).toBe('3d ago');
+ });
+
+ it('returns localized date for timestamps older than 7 days', () => {
+ const oldDate = new Date('2020-01-15T12:00:00.000Z');
+ const formatted = formatNotificationTime(oldDate.toISOString());
+ expect(formatted).toContain('Jan');
+ expect(formatted).toContain('2020');
+ });
+ });
+
+ describe('isWithinQuietHours', () => {
+ it('returns false for invalid quiet hours object', () => {
+ expect(isWithinQuietHours({ start: '', end: '', timezone: '' })).toBe(false);
+ });
+
+ it('handles same-day quiet hours correctly', () => {
+ const now = new Date();
+ const currentHour = now.getHours();
+ const pad = (n: number) => n.toString().padStart(2, '0');
+
+ const start = pad(Math.max(0, currentHour - 1)) + ':00';
+ const end = pad(Math.min(23, currentHour + 1)) + ':59';
+
+ const timezone = Intl.DateTimeFormat().resolvedOptions().timeZone || 'UTC';
+ expect(isWithinQuietHours({ start, end, timezone })).toBe(true);
+
+ const pastStart = '01:00';
+ const pastEnd = '02:00';
+ // If current hour is outside 01:00-02:00
+ if (currentHour < 1 || currentHour > 2) {
+ expect(isWithinQuietHours({ start: pastStart, end: pastEnd, timezone })).toBe(false);
+ }
+ });
+
+ it('handles overnight quiet hours (e.g. 22:00 to 08:00)', () => {
+ const timezone = 'UTC';
+ // If start > end, checks if current >= start OR current <= end
+ const isNight = isWithinQuietHours({ start: '00:00', end: '23:59', timezone });
+ expect(isNight).toBe(true);
+ });
+ });
+
+ describe('shouldSendNotification', () => {
+ let basePrefs: UserNotificationPreferences;
+
+ beforeEach(() => {
+ basePrefs = createDefaultPreferences('user_123');
+ });
+
+ it('returns false when preferences are invalid or incomplete', () => {
+ expect(
+ shouldSendNotification('message', 'in-app', null as unknown as UserNotificationPreferences),
+ ).toBe(false);
+ });
+
+ it('returns false if channel is disabled globally', () => {
+ basePrefs.channels.email = false;
+ expect(shouldSendNotification('course_update', 'email', basePrefs)).toBe(false);
+
+ basePrefs.channels.inApp = false;
+ expect(shouldSendNotification('message', 'in-app', basePrefs)).toBe(false);
+ });
+
+ it('returns false if category is disabled', () => {
+ basePrefs.categories.message.enabled = false;
+ expect(shouldSendNotification('message', 'in-app', basePrefs)).toBe(false);
+ });
+
+ it('returns false if channel is not enabled for specific category', () => {
+ basePrefs.categories.course_update.channels = ['email'];
+ expect(shouldSendNotification('course_update', 'push', basePrefs)).toBe(false);
+ });
+
+ it('allows in-app notifications during global quiet hours', () => {
+ basePrefs.quietHours = {
+ enabled: true,
+ start: '00:00',
+ end: '23:59',
+ timezone: 'UTC',
+ };
+ expect(shouldSendNotification('message', 'in-app', basePrefs)).toBe(true);
+ });
+
+ it('blocks external push notifications during global quiet hours', () => {
+ basePrefs.quietHours = {
+ enabled: true,
+ start: '00:00',
+ end: '23:59',
+ timezone: Intl.DateTimeFormat().resolvedOptions().timeZone || 'UTC',
+ };
+ expect(shouldSendNotification('course_update', 'push', basePrefs)).toBe(false);
+ });
+
+ it('returns true when all conditions pass', () => {
+ basePrefs.quietHours.enabled = false;
+ expect(shouldSendNotification('course_update', 'email', basePrefs)).toBe(true);
+ expect(shouldSendNotification('message', 'in-app', basePrefs)).toBe(true);
+ });
+ });
+
+ describe('calculateAnalytics', () => {
+ it('calculates 0 rates for empty notification list', () => {
+ const analytics = calculateAnalytics([]);
+ expect(analytics.totalSent).toBe(0);
+ expect(analytics.totalRead).toBe(0);
+ expect(analytics.totalClicked).toBe(0);
+ expect(analytics.readRate).toBe(0);
+ expect(analytics.clickRate).toBe(0);
+ });
+
+ it('computes correct metrics and breakdowns for notification items', () => {
+ const notifications = [
+ {
+ read: true,
+ clicked: true,
+ channel: 'in-app' as NotificationChannel,
+ category: 'message' as NotificationCategory,
+ },
+ {
+ read: true,
+ clicked: false,
+ channel: 'push' as NotificationChannel,
+ category: 'course_update' as NotificationCategory,
+ },
+ {
+ read: false,
+ clicked: false,
+ channel: 'email' as NotificationChannel,
+ category: 'system' as NotificationCategory,
+ },
+ {
+ read: false,
+ channel: 'sms' as NotificationChannel,
+ category: 'payment' as NotificationCategory,
+ },
+ ];
+
+ const analytics = calculateAnalytics(notifications);
+ expect(analytics.totalSent).toBe(4);
+ expect(analytics.totalRead).toBe(2);
+ expect(analytics.totalClicked).toBe(1);
+ expect(analytics.readRate).toBe(50);
+ expect(analytics.clickRate).toBe(25);
+
+ expect(analytics.byChannel['in-app'].sent).toBe(1);
+ expect(analytics.byChannel['in-app'].read).toBe(1);
+ expect(analytics.byChannel['in-app'].clicked).toBe(1);
+
+ expect(analytics.byCategory.message.sent).toBe(1);
+ expect(analytics.byCategory.message.read).toBe(1);
+ expect(analytics.byCategory.message.clicked).toBe(1);
+
+ expect(analytics.byCategory.system.sent).toBe(1);
+ expect(analytics.byCategory.system.read).toBe(0);
+ });
+ });
+
+ describe('sortNotifications', () => {
+ it('sorts unread notifications before read, then by priority, then by newest date', () => {
+ const items = [
+ { id: '1', read: true, priority: 'urgent' as NotificationPriority, createdAt: '2024-01-01T10:00:00Z' },
+ { id: '2', read: false, priority: 'low' as NotificationPriority, createdAt: '2024-01-01T10:00:00Z' },
+ { id: '3', read: false, priority: 'urgent' as NotificationPriority, createdAt: '2024-01-01T08:00:00Z' },
+ { id: '4', read: false, priority: 'urgent' as NotificationPriority, createdAt: '2024-01-01T12:00:00Z' },
+ ];
+
+ const sorted = sortNotifications(items);
+ expect(sorted.map((item) => item.id)).toEqual(['4', '3', '2', '1']);
+ });
+ });
+
+ describe('filterNotifications', () => {
+ const list = [
+ {
+ id: '1',
+ type: 'alert',
+ category: 'message' as NotificationCategory,
+ read: false,
+ createdAt: '2024-01-02T10:00:00Z',
+ },
+ {
+ id: '2',
+ type: 'info',
+ category: 'course_update' as NotificationCategory,
+ read: true,
+ createdAt: '2024-01-05T10:00:00Z',
+ },
+ {
+ id: '3',
+ type: 'alert',
+ category: 'system' as NotificationCategory,
+ read: true,
+ createdAt: '2024-01-10T10:00:00Z',
+ },
+ ];
+
+ it('filters by type', () => {
+ const result = filterNotifications(list, { type: 'alert' });
+ expect(result.map((i) => i.id)).toEqual(['1', '3']);
+ });
+
+ it('filters by category', () => {
+ const result = filterNotifications(list, { category: 'course_update' });
+ expect(result.map((i) => i.id)).toEqual(['2']);
+ });
+
+ it('filters by read status', () => {
+ const result = filterNotifications(list, { read: false });
+ expect(result.map((i) => i.id)).toEqual(['1']);
+ });
+
+ it('filters by dateRange', () => {
+ const result = filterNotifications(list, {
+ dateRange: {
+ start: new Date('2024-01-04T00:00:00Z'),
+ end: new Date('2024-01-06T00:00:00Z'),
+ },
+ });
+ expect(result.map((i) => i.id)).toEqual(['2']);
+ });
+ });
+
+ describe('groupNotificationsByDate', () => {
+ it('groups notifications correctly by date string', () => {
+ const list = [
+ { id: '1', createdAt: '2024-03-01T10:00:00Z' },
+ { id: '2', createdAt: '2024-03-01T15:00:00Z' },
+ { id: '3', createdAt: '2024-03-02T10:00:00Z' },
+ ];
+
+ const groups = groupNotificationsByDate(list);
+ expect(groups.size).toBe(2);
+ const values = Array.from(groups.values());
+ expect(values[0].length).toBe(2);
+ expect(values[1].length).toBe(1);
+ });
+ });
+
+ describe('truncateMessage', () => {
+ it('returns original message if within maxLength', () => {
+ expect(truncateMessage('Hello World', 20)).toBe('Hello World');
+ expect(truncateMessage('Hello World', 11)).toBe('Hello World');
+ });
+
+ it('truncates message and appends ellipsis if exceeding maxLength', () => {
+ expect(truncateMessage('Hello Wonderful World', 10)).toBe('Hello W...');
+ });
+ });
+
+ describe('getNotificationIcon and getNotificationColor', () => {
+ it('returns appropriate icon for known and unknown categories', () => {
+ expect(getNotificationIcon('course_update')).toBe('📚');
+ expect(getNotificationIcon('message')).toBe('💬');
+ expect(getNotificationIcon('achievement')).toBe('🏆');
+ expect(getNotificationIcon('reminder')).toBe('⏰');
+ expect(getNotificationIcon('system')).toBe('⚙️');
+ expect(getNotificationIcon('social')).toBe('👥');
+ expect(getNotificationIcon('payment')).toBe('💳');
+ expect(getNotificationIcon('unknown' as NotificationCategory)).toBe('🔔');
+ });
+
+ it('returns style classes for priority levels', () => {
+ expect(getNotificationColor('urgent')).toContain('bg-red-100');
+ expect(getNotificationColor('high')).toContain('bg-orange-100');
+ expect(getNotificationColor('medium')).toContain('bg-blue-100');
+ expect(getNotificationColor('low')).toContain('bg-gray-100');
+ });
+ });
+
+ describe('validatePreferences', () => {
+ it('validates correct preferences', () => {
+ const result = validatePreferences({
+ quietHours: { enabled: true, start: '22:00', end: '08:00', timezone: 'UTC' },
+ frequency: { digest: 'realtime', maxPerDay: 50 },
+ });
+ expect(result.valid).toBe(true);
+ expect(result.errors).toHaveLength(0);
+ });
+
+ it('detects invalid quiet hours start and end formats', () => {
+ const result = validatePreferences({
+ quietHours: { enabled: true, start: '25:99', end: '8pm', timezone: 'UTC' },
+ });
+ expect(result.valid).toBe(false);
+ expect(result.errors.length).toBe(2);
+ });
+
+ it('detects out-of-range maxPerDay values', () => {
+ const result1 = validatePreferences({
+ frequency: { digest: 'daily', maxPerDay: -1 },
+ });
+ expect(result1.valid).toBe(false);
+ expect(result1.errors[0]).toContain('between 0 and 100');
+
+ const result2 = validatePreferences({
+ frequency: { digest: 'daily', maxPerDay: 101 },
+ });
+ expect(result2.valid).toBe(false);
+ });
+ });
+
+ describe('createDefaultPreferences', () => {
+ it('creates full defaults with expected user id and default channels/categories', () => {
+ const prefs = createDefaultPreferences('user_999');
+ expect(prefs.userId).toBe('user_999');
+ expect(prefs.channels.push).toBe(true);
+ expect(prefs.channels.email).toBe(true);
+ expect(prefs.channels.inApp).toBe(true);
+ expect(prefs.channels.sms).toBe(false);
+ expect(prefs.categories.course_update.enabled).toBe(true);
+ expect(prefs.frequency.maxPerDay).toBe(20);
+ expect(prefs.quietHours.enabled).toBe(false);
+ });
+ });
+});