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) => {
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)
+ })
+})