diff --git a/.jules/testing-progress.md b/.jules/testing-progress.md new file mode 100644 index 00000000..b095b580 --- /dev/null +++ b/.jules/testing-progress.md @@ -0,0 +1,4 @@ +- Increased test coverage for UI components by adding rigorous unit tests for CustomCursor, useTheme, and EmptyStateIllustrations. +- Tests fully mock required dependencies like requestAnimationFrame and matchMedia, implementing act blocks appropriately. +- Fixed React 19/TS compatibility warnings for `fetchPriority` vs `fetchpriority` in `MarkdownRenderer` component and tests, improving component prop type safety and standard HTML compliance. +- Verified test suite passes without regressions and 100% coverage thresholds met for newly tested files. diff --git a/src/components/MarkdownRenderer.tsx b/src/components/MarkdownRenderer.tsx index f2ab036b..d3a722e2 100644 --- a/src/components/MarkdownRenderer.tsx +++ b/src/components/MarkdownRenderer.tsx @@ -153,8 +153,8 @@ const ImageComponent = (props: JSX.IntrinsicElements['img'] & ExtraProps) => { {rest.alt diff --git a/src/components/__tests__/CustomCursor.test.tsx b/src/components/__tests__/CustomCursor.test.tsx new file mode 100644 index 00000000..3414fcba --- /dev/null +++ b/src/components/__tests__/CustomCursor.test.tsx @@ -0,0 +1,157 @@ +import { render, act } from '@testing-library/react' +import { vi, describe, it, expect, beforeEach, afterEach } from 'vitest' +import CustomCursor from '../CustomCursor' + +vi.mock('motion/react', () => ({ + useReducedMotion: vi.fn().mockReturnValue(false), +})) + +describe('CustomCursor', () => { + beforeEach(() => { + Object.defineProperty(window, 'matchMedia', { + writable: true, + value: vi.fn().mockImplementation((query) => ({ + matches: query === '(pointer: fine)', + media: query, + onchange: null, + addListener: vi.fn(), // Deprecated + removeListener: vi.fn(), // Deprecated + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + dispatchEvent: vi.fn(), + })), + }) + }) + + afterEach(() => { + vi.clearAllMocks() + document.documentElement.classList.remove('custom-cursor-active') + vi.unstubAllGlobals() + }) + + it('renders custom cursor on fine pointer devices', () => { + const { container } = render() + expect(container.querySelector('.custom-cursor-dot')).not.toBeNull() + expect(container.querySelector('.custom-cursor-ring')).not.toBeNull() + expect(document.documentElement.classList.contains('custom-cursor-active')).toBe(true) + }) + + it('does not render on touch devices', () => { + window.matchMedia = vi.fn().mockImplementation(() => ({ + matches: false, + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + })) + + const { container } = render() + expect(container.querySelector('.custom-cursor-dot')).toBeNull() + }) + + it('handles mouse movements and interactions', async () => { + // Make sure we simulate a fine pointer + window.matchMedia = vi.fn().mockImplementation((query) => ({ + matches: query === '(pointer: fine)', + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + })) + + const { container } = render() + + // Test mouse movement + const moveEvent = new MouseEvent('mousemove', { + clientX: 100, + clientY: 200, + bubbles: true, + }) + + // Create RAF mock + let rafCallback: FrameRequestCallback | null = null + vi.stubGlobal('requestAnimationFrame', (cb: FrameRequestCallback) => { + rafCallback = cb + return 1 + }) + + act(() => { + window.dispatchEvent(moveEvent) + if (rafCallback) { + // @ts-ignore + rafCallback(1) + } + }) + + const dot = container.querySelector('.custom-cursor-dot') as HTMLElement + expect(dot).not.toBeNull() + expect(dot.style.transform).toBe('translate(100px, 200px)') + + const ring = container.querySelector('.custom-cursor-ring') as HTMLElement + expect(ring.style.transform).toBe('translate(100px, 200px) scale(1)') + + // Test interactive element hover + const button = document.createElement('button') + button.className = 'interactive' + document.body.appendChild(button) + + const mouseOverEvent = new MouseEvent('mouseover', { bubbles: true }) + Object.defineProperty(mouseOverEvent, 'target', { value: button }) + + act(() => { + window.dispatchEvent(mouseOverEvent) + }) + + // trigger move again to update ring scale + act(() => { + window.dispatchEvent(moveEvent) + if (rafCallback) { + // @ts-ignore + rafCallback(1) + } + }) + + expect(ring.style.transform).toBe('translate(100px, 200px) scale(1.6)') + + // trigger mouseout + const mouseOutEvent = new MouseEvent('mouseout', { bubbles: true }) + Object.defineProperty(mouseOutEvent, 'target', { value: button }) + + act(() => { + window.dispatchEvent(mouseOutEvent) + }) + + // trigger move again to verify scale is back to 1 + act(() => { + window.dispatchEvent(moveEvent) + if (rafCallback) { + // @ts-ignore + rafCallback(1) + } + }) + + expect(ring.style.transform).toBe('translate(100px, 200px) scale(1)') + + document.body.removeChild(button) + }) + + it('updates when matchMedia changes', () => { + let changeListener: ((e: any) => void) | null = null + window.matchMedia = vi.fn().mockImplementation((query) => ({ + matches: query === '(pointer: fine)', + addEventListener: vi.fn((event, cb) => { + if (event === 'change') changeListener = cb + }), + removeEventListener: vi.fn(), + })) + + const { container } = render() + expect(container.querySelector('.custom-cursor-dot')).not.toBeNull() + + act(() => { + if (changeListener) { + // @ts-ignore + changeListener({ matches: false }) + } + }) + + // The component state updated, so it should unmount the cursor elements + expect(container.querySelector('.custom-cursor-dot')).toBeNull() + }) +}) diff --git a/src/components/__tests__/EmptyStateIllustrations.test.tsx b/src/components/__tests__/EmptyStateIllustrations.test.tsx new file mode 100644 index 00000000..778be9f4 --- /dev/null +++ b/src/components/__tests__/EmptyStateIllustrations.test.tsx @@ -0,0 +1,107 @@ +import { render } from '@testing-library/react' +import { vi, describe, it, expect, afterEach } from 'vitest' +import { + SearchEmptyIllustration, + ExercisesEmptyIllustration, + FreshStartIllustration, +} from '../EmptyStateIllustrations' + +const mockUseReducedMotion = vi.fn() +vi.mock('motion/react', () => ({ + useReducedMotion: () => mockUseReducedMotion(), +})) + +describe('EmptyStateIllustrations', () => { + afterEach(() => { + vi.clearAllMocks() + }) + + describe('SearchEmptyIllustration', () => { + it('renders with correct className', () => { + mockUseReducedMotion.mockReturnValue(false) + const { container } = render() + const svg = container.querySelector('svg') + expect(svg).not.toBeNull() + expect(svg?.classList.contains('test-class')).toBe(true) + expect(svg?.classList.contains('empty-state-illustration')).toBe(true) + + const line = container.querySelector('.empty-state-line') + expect(line).not.toBeNull() + }) + + it('renders default class if not provided', () => { + mockUseReducedMotion.mockReturnValue(false) + const { container } = render() + const svg = container.querySelector('svg') + expect(svg).not.toBeNull() + expect(svg?.classList.contains('empty-state-illustration')).toBe(true) + }) + + it('respects reduced motion', () => { + mockUseReducedMotion.mockReturnValue(true) + const { container } = render() + const line = container.querySelector('.empty-state-line') + expect(line).toBeNull() + }) + }) + + describe('ExercisesEmptyIllustration', () => { + it('renders with correct className', () => { + mockUseReducedMotion.mockReturnValue(false) + const { container } = render() + const svg = container.querySelector('svg') + expect(svg).not.toBeNull() + expect(svg?.classList.contains('test-class')).toBe(true) + expect(svg?.classList.contains('empty-state-illustration')).toBe(true) + + const dot = container.querySelector('.empty-state-dot') + expect(dot).not.toBeNull() + }) + + it('renders default class if not provided', () => { + mockUseReducedMotion.mockReturnValue(false) + const { container } = render() + const svg = container.querySelector('svg') + expect(svg).not.toBeNull() + expect(svg?.classList.contains('empty-state-illustration')).toBe(true) + }) + + it('respects reduced motion', () => { + mockUseReducedMotion.mockReturnValue(true) + const { container } = render() + const dot = container.querySelector('.empty-state-dot') + expect(dot).toBeNull() + }) + }) + + describe('FreshStartIllustration', () => { + it('renders with correct className', () => { + mockUseReducedMotion.mockReturnValue(false) + const { container } = render() + const svg = container.querySelector('svg') + expect(svg).not.toBeNull() + expect(svg?.classList.contains('test-class')).toBe(true) + expect(svg?.classList.contains('empty-state-illustration')).toBe(true) + + const dot = container.querySelector('.empty-state-dot') + expect(dot).not.toBeNull() + }) + + it('renders default class if not provided', () => { + mockUseReducedMotion.mockReturnValue(false) + const { container } = render() + const svg = container.querySelector('svg') + expect(svg).not.toBeNull() + expect(svg?.classList.contains('empty-state-illustration')).toBe(true) + }) + + it('respects reduced motion', () => { + mockUseReducedMotion.mockReturnValue(true) + const { container } = render() + const dot = container.querySelector('.empty-state-dot') + expect(dot).toBeNull() + const line = container.querySelector('.empty-state-line') + expect(line).toBeNull() + }) + }) +}) diff --git a/src/components/__tests__/MarkdownRendererImages.test.tsx b/src/components/__tests__/MarkdownRendererImages.test.tsx index f0b02b74..32ac1b45 100644 --- a/src/components/__tests__/MarkdownRendererImages.test.tsx +++ b/src/components/__tests__/MarkdownRendererImages.test.tsx @@ -52,6 +52,6 @@ describe('MarkdownRenderer Images', () => { // loading='lazy' is added by ImageComponent default, but last prop wins if passed? // Wait, ImageComponent receives props from rehype-raw -> react-markdown. // Let's see what happens. - expect(img?.getAttribute('fetchPriority')).toBe('high') + expect(img?.getAttribute('fetchpriority') || img?.getAttribute('fetchPriority')).toBe('high') }) }) diff --git a/src/context/__tests__/useTheme.test.tsx b/src/context/__tests__/useTheme.test.tsx new file mode 100644 index 00000000..5a5576ab --- /dev/null +++ b/src/context/__tests__/useTheme.test.tsx @@ -0,0 +1,23 @@ +import { renderHook } from '@testing-library/react' +import { vi, describe, it, expect } from 'vitest' +import { useTheme } from '../useTheme' +import { ThemeContext } from '../ThemeContext' + +describe('useTheme', () => { + it('returns context value', () => { + const mockContextValue = { + palette: 'neon-party' as const, + setPalette: vi.fn(), + isDark: true, + toggleDark: vi.fn(), + setDark: vi.fn(), + } + + const wrapper = ({ children }: { children: React.ReactNode }) => ( + {children} + ) + + const { result } = renderHook(() => useTheme(), { wrapper }) + expect(result.current).toBe(mockContextValue) + }) +})