Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .jules/testing-progress.md
Original file line number Diff line number Diff line change
@@ -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.
4 changes: 2 additions & 2 deletions src/components/MarkdownRenderer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -153,8 +153,8 @@ const ImageComponent = (props: JSX.IntrinsicElements['img'] & ExtraProps) => {
<img
loading={isHighPriority ? 'eager' : 'lazy'}
decoding="async"
// Using lowercase fetchpriority for exact DOM output, with TS definition in vite-env.d.ts
fetchpriority={isHighPriority ? 'high' : undefined}
// Using camelCase fetchPriority for React 19 / TS compatibility, renders as fetchpriority in DOM
fetchPriority={isHighPriority ? 'high' : undefined}
alt={rest.alt || 'Course image'}
{...rest}
/>
Expand Down
157 changes: 157 additions & 0 deletions src/components/__tests__/CustomCursor.test.tsx
Original file line number Diff line number Diff line change
@@ -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(<CustomCursor />)
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(<CustomCursor />)
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(<CustomCursor />)

// 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(<CustomCursor />)
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()
})
})
107 changes: 107 additions & 0 deletions src/components/__tests__/EmptyStateIllustrations.test.tsx
Original file line number Diff line number Diff line change
@@ -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(<SearchEmptyIllustration className="test-class" />)
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(<SearchEmptyIllustration />)
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(<SearchEmptyIllustration />)
const line = container.querySelector('.empty-state-line')
expect(line).toBeNull()
})
})

describe('ExercisesEmptyIllustration', () => {
it('renders with correct className', () => {
mockUseReducedMotion.mockReturnValue(false)
const { container } = render(<ExercisesEmptyIllustration className="test-class" />)
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(<ExercisesEmptyIllustration />)
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(<ExercisesEmptyIllustration />)
const dot = container.querySelector('.empty-state-dot')
expect(dot).toBeNull()
})
})

describe('FreshStartIllustration', () => {
it('renders with correct className', () => {
mockUseReducedMotion.mockReturnValue(false)
const { container } = render(<FreshStartIllustration className="test-class" />)
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(<FreshStartIllustration />)
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(<FreshStartIllustration />)
const dot = container.querySelector('.empty-state-dot')
expect(dot).toBeNull()
const line = container.querySelector('.empty-state-line')
expect(line).toBeNull()
})
})
})
2 changes: 1 addition & 1 deletion src/components/__tests__/MarkdownRendererImages.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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')
})
})
23 changes: 23 additions & 0 deletions src/context/__tests__/useTheme.test.tsx
Original file line number Diff line number Diff line change
@@ -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 }) => (
<ThemeContext.Provider value={mockContextValue}>{children}</ThemeContext.Provider>
)

const { result } = renderHook(() => useTheme(), { wrapper })
expect(result.current).toBe(mockContextValue)
})
})
Loading