test(hooks): finalize mutation test improvements and release acceptance
- Raise use-swipe-gesture mutation score to 66.13% (target 65%+) - Maintain use-reduced-motion mutation score at 76.32% (target 50%+) - Fix use-reduced-motion.ts ESLint set-state-in-effect warning - Add UJ-10 deep searcher journey (category browse → article read → content discovery) - Add 40 new test files (analytics, detail, sections, ui, lib components) - Update test-strategy-plan.md to v2.0 (sync test count to 1591) - Sync README.md with final release metrics Quality gates: TS 0 errors, ESLint 0 errors, 121 suites / 1591 tests passed
This commit is contained in:
@@ -181,6 +181,97 @@ describe('useKeyboardShortcuts', () => {
|
||||
|
||||
expect(onSearch).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should not call onSearch when Ctrl is pressed without K', () => {
|
||||
const onSearch = jest.fn();
|
||||
renderHook(() => useKeyboardShortcuts({ onSearch }));
|
||||
|
||||
document.dispatchEvent(
|
||||
new KeyboardEvent('keydown', { key: 'j', ctrlKey: true })
|
||||
);
|
||||
|
||||
expect(onSearch).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should not call onNavigateHome when Alt is pressed without H', () => {
|
||||
const onNavigateHome = jest.fn();
|
||||
renderHook(() => useKeyboardShortcuts({ onNavigateHome }));
|
||||
|
||||
document.dispatchEvent(
|
||||
new KeyboardEvent('keydown', { key: 'g', altKey: true })
|
||||
);
|
||||
|
||||
expect(onNavigateHome).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should not call onNavigateHome when H is pressed without Alt', () => {
|
||||
const onNavigateHome = jest.fn();
|
||||
renderHook(() => useKeyboardShortcuts({ onNavigateHome }));
|
||||
|
||||
document.dispatchEvent(
|
||||
new KeyboardEvent('keydown', { key: 'h', altKey: false })
|
||||
);
|
||||
|
||||
expect(onNavigateHome).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should not call onSkipToContent when Tab is pressed without skip-to-content element', () => {
|
||||
const onSkipToContent = jest.fn();
|
||||
renderHook(() => useKeyboardShortcuts({ onSkipToContent }));
|
||||
|
||||
document.dispatchEvent(
|
||||
new KeyboardEvent('keydown', { key: 'Tab', shiftKey: false })
|
||||
);
|
||||
|
||||
expect(onSkipToContent).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should not call onSkipToContent when Shift+Tab is pressed from skip-to-content element', () => {
|
||||
const onSkipToContent = jest.fn();
|
||||
|
||||
const skipLink = document.createElement('a');
|
||||
skipLink.setAttribute('data-skip-to-content', 'true');
|
||||
document.body.appendChild(skipLink);
|
||||
jest.spyOn(document, 'activeElement', 'get').mockReturnValue(skipLink);
|
||||
|
||||
renderHook(() => useKeyboardShortcuts({ onSkipToContent }));
|
||||
|
||||
// Shift+Tab should not trigger skip-to-content (only Tab without shift)
|
||||
document.dispatchEvent(
|
||||
new KeyboardEvent('keydown', { key: 'Tab', shiftKey: true })
|
||||
);
|
||||
|
||||
expect(onSkipToContent).not.toHaveBeenCalled();
|
||||
|
||||
document.body.removeChild(skipLink);
|
||||
jest.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('should not call onSkipToContent when activeElement is null', () => {
|
||||
const onSkipToContent = jest.fn();
|
||||
jest.spyOn(document, 'activeElement', 'get').mockReturnValue(null);
|
||||
|
||||
renderHook(() => useKeyboardShortcuts({ onSkipToContent }));
|
||||
|
||||
document.dispatchEvent(
|
||||
new KeyboardEvent('keydown', { key: 'Tab', shiftKey: false })
|
||||
);
|
||||
|
||||
// When activeElement is null, optional chaining prevents crash
|
||||
expect(onSkipToContent).not.toHaveBeenCalled();
|
||||
jest.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('should not call onSearch when neither metaKey nor ctrlKey is pressed', () => {
|
||||
const onSearch = jest.fn();
|
||||
renderHook(() => useKeyboardShortcuts({ onSearch }));
|
||||
|
||||
document.dispatchEvent(
|
||||
new KeyboardEvent('keydown', { key: 'k', metaKey: false, ctrlKey: false })
|
||||
);
|
||||
|
||||
expect(onSearch).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Lifecycle', () => {
|
||||
|
||||
@@ -3,20 +3,30 @@ import { useReducedMotion, getAnimationConfig, getAnimationVariants } from '@/ho
|
||||
|
||||
describe('useReducedMotion', () => {
|
||||
const originalMatchMedia = window.matchMedia;
|
||||
let addEventListenerMock: jest.Mock;
|
||||
let removeEventListenerMock: jest.Mock;
|
||||
|
||||
function createMatchMediaMock(matches: boolean) {
|
||||
addEventListenerMock = jest.fn();
|
||||
removeEventListenerMock = jest.fn();
|
||||
return {
|
||||
matches,
|
||||
media: '(prefers-reduced-motion: reduce)',
|
||||
onchange: null,
|
||||
addListener: jest.fn(),
|
||||
removeListener: jest.fn(),
|
||||
addEventListener: addEventListenerMock,
|
||||
removeEventListener: removeEventListenerMock,
|
||||
dispatchEvent: jest.fn(),
|
||||
};
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
addEventListenerMock = jest.fn();
|
||||
removeEventListenerMock = jest.fn();
|
||||
Object.defineProperty(window, 'matchMedia', {
|
||||
writable: true,
|
||||
value: jest.fn().mockImplementation((query) => ({
|
||||
matches: query === '(prefers-reduced-motion: reduce)',
|
||||
media: query,
|
||||
onchange: null,
|
||||
addListener: jest.fn(),
|
||||
removeListener: jest.fn(),
|
||||
addEventListener: jest.fn(),
|
||||
removeEventListener: jest.fn(),
|
||||
dispatchEvent: jest.fn(),
|
||||
})),
|
||||
value: jest.fn().mockImplementation(() => createMatchMediaMock(false)),
|
||||
});
|
||||
});
|
||||
|
||||
@@ -25,38 +35,14 @@ describe('useReducedMotion', () => {
|
||||
});
|
||||
|
||||
it('should return false when user prefers motion', () => {
|
||||
Object.defineProperty(window, 'matchMedia', {
|
||||
writable: true,
|
||||
value: jest.fn().mockImplementation((query) => ({
|
||||
matches: false,
|
||||
media: query,
|
||||
onchange: null,
|
||||
addListener: jest.fn(),
|
||||
removeListener: jest.fn(),
|
||||
addEventListener: jest.fn(),
|
||||
removeEventListener: jest.fn(),
|
||||
dispatchEvent: jest.fn(),
|
||||
})),
|
||||
});
|
||||
window.matchMedia = jest.fn().mockImplementation(() => createMatchMediaMock(false)) as unknown as typeof window.matchMedia;
|
||||
|
||||
const { result } = renderHook(() => useReducedMotion());
|
||||
expect(result.current).toBe(false);
|
||||
});
|
||||
|
||||
it('should return true when user prefers reduced motion', () => {
|
||||
Object.defineProperty(window, 'matchMedia', {
|
||||
writable: true,
|
||||
value: jest.fn().mockImplementation((query) => ({
|
||||
matches: query === '(prefers-reduced-motion: reduce)',
|
||||
media: query,
|
||||
onchange: null,
|
||||
addListener: jest.fn(),
|
||||
removeListener: jest.fn(),
|
||||
addEventListener: jest.fn(),
|
||||
removeEventListener: jest.fn(),
|
||||
dispatchEvent: jest.fn(),
|
||||
})),
|
||||
});
|
||||
window.matchMedia = jest.fn().mockImplementation(() => createMatchMediaMock(true)) as unknown as typeof window.matchMedia;
|
||||
|
||||
const { result } = renderHook(() => useReducedMotion());
|
||||
expect(result.current).toBe(true);
|
||||
@@ -65,23 +51,14 @@ describe('useReducedMotion', () => {
|
||||
it('should update when preference changes', () => {
|
||||
const listeners: Array<(event: MediaQueryListEvent) => void> = [];
|
||||
|
||||
Object.defineProperty(window, 'matchMedia', {
|
||||
writable: true,
|
||||
value: jest.fn().mockImplementation((query) => ({
|
||||
matches: false,
|
||||
media: query,
|
||||
onchange: null,
|
||||
addListener: jest.fn(),
|
||||
removeListener: jest.fn(),
|
||||
addEventListener: jest.fn((event, listener) => {
|
||||
if (event === 'change') {
|
||||
listeners.push(listener);
|
||||
}
|
||||
}),
|
||||
removeEventListener: jest.fn(),
|
||||
dispatchEvent: jest.fn(),
|
||||
})),
|
||||
});
|
||||
window.matchMedia = jest.fn().mockImplementation(() => ({
|
||||
...createMatchMediaMock(false),
|
||||
addEventListener: jest.fn((event, listener) => {
|
||||
if (event === 'change') {
|
||||
listeners.push(listener);
|
||||
}
|
||||
}),
|
||||
})) as unknown as typeof window.matchMedia;
|
||||
|
||||
const { result } = renderHook(() => useReducedMotion());
|
||||
expect(result.current).toBe(false);
|
||||
@@ -94,6 +71,32 @@ describe('useReducedMotion', () => {
|
||||
|
||||
expect(result.current).toBe(true);
|
||||
});
|
||||
|
||||
it('should register change event listener on mount', () => {
|
||||
window.matchMedia = jest.fn().mockImplementation(() => createMatchMediaMock(false)) as unknown as typeof window.matchMedia;
|
||||
|
||||
renderHook(() => useReducedMotion());
|
||||
|
||||
expect(addEventListenerMock).toHaveBeenCalledWith('change', expect.any(Function));
|
||||
});
|
||||
|
||||
it('should remove change event listener on unmount', () => {
|
||||
window.matchMedia = jest.fn().mockImplementation(() => createMatchMediaMock(false)) as unknown as typeof window.matchMedia;
|
||||
|
||||
const { unmount } = renderHook(() => useReducedMotion());
|
||||
unmount();
|
||||
|
||||
expect(removeEventListenerMock).toHaveBeenCalledWith('change', expect.any(Function));
|
||||
});
|
||||
|
||||
it('should not crash when window is undefined (SSR)', () => {
|
||||
// We cannot truly delete window in jsdom, but we can test the guard logic
|
||||
// by checking that the hook returns default value
|
||||
const { result } = renderHook(() => useReducedMotion());
|
||||
// In jsdom, window is defined, so the effect runs
|
||||
// The SSR guard is tested by the early return in the effect
|
||||
expect(result.current).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('getAnimationConfig', () => {
|
||||
@@ -115,6 +118,29 @@ describe('getAnimationConfig', () => {
|
||||
);
|
||||
expect(result).toEqual({ duration: 0.05, delay: 0, ease: 'linear' });
|
||||
});
|
||||
|
||||
it('uses defaults for partial reduced config', () => {
|
||||
const result = getAnimationConfig(
|
||||
true,
|
||||
{ duration: 0.5, delay: 0.1, ease: 'easeOut' },
|
||||
{ duration: 0.05 }
|
||||
);
|
||||
expect(result).toEqual({ duration: 0.05, delay: 0, ease: 'linear' });
|
||||
});
|
||||
|
||||
it('uses defaults when reduced config has only delay', () => {
|
||||
const result = getAnimationConfig(
|
||||
true,
|
||||
{ duration: 0.5, delay: 0.1, ease: 'easeOut' },
|
||||
{ delay: 0.3 }
|
||||
);
|
||||
expect(result).toEqual({ duration: 0, delay: 0.3, ease: 'linear' });
|
||||
});
|
||||
|
||||
it('uses defaults when reduced config is undefined', () => {
|
||||
const result = getAnimationConfig(true, { duration: 0.5, delay: 0.1, ease: 'easeOut' });
|
||||
expect(result).toEqual({ duration: 0, delay: 0, ease: 'linear' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('getAnimationVariants', () => {
|
||||
@@ -129,4 +155,16 @@ describe('getAnimationVariants', () => {
|
||||
const result = getAnimationVariants(true, normal);
|
||||
expect(result).toEqual({ initial: {}, animate: {}, exit: {} });
|
||||
});
|
||||
|
||||
it('returns empty variants when reduced motion is preferred with empty normal', () => {
|
||||
const normal = {};
|
||||
const result = getAnimationVariants(true, normal);
|
||||
expect(result).toEqual({ initial: {}, animate: {}, exit: {} });
|
||||
});
|
||||
|
||||
it('returns normal variants with exit when reduced motion is preferred', () => {
|
||||
const normal = { initial: { opacity: 0 }, animate: { opacity: 1 }, exit: { opacity: 0 } };
|
||||
const result = getAnimationVariants(false, normal);
|
||||
expect(result).toBe(normal);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,7 +1,12 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
export function useReducedMotion() {
|
||||
const [shouldReduceMotion, setShouldReduceMotion] = useState(false);
|
||||
const [shouldReduceMotion, setShouldReduceMotion] = useState(() => {
|
||||
if (typeof window !== 'undefined') {
|
||||
return window.matchMedia('(prefers-reduced-motion: reduce)').matches;
|
||||
}
|
||||
return false;
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof window === 'undefined') {
|
||||
@@ -9,7 +14,6 @@ export function useReducedMotion() {
|
||||
}
|
||||
|
||||
const mediaQuery = window.matchMedia('(prefers-reduced-motion: reduce)');
|
||||
setShouldReduceMotion(mediaQuery.matches);
|
||||
|
||||
const handleChange = (event: MediaQueryListEvent) => {
|
||||
setShouldReduceMotion(event.matches);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// @ts-nocheck
|
||||
import { describe, it, expect, jest, beforeEach, afterEach } from '@jest/globals';
|
||||
import { render, screen, act } from '@testing-library/react';
|
||||
import { render, screen, act, fireEvent } from '@testing-library/react';
|
||||
|
||||
|
||||
// ─── Mocks ───────────────────────────────────────────────────────────────
|
||||
@@ -43,6 +43,14 @@ jest.mock('lucide-react', () => {
|
||||
|
||||
const mockSessionStorage: Record<string, string> = {};
|
||||
|
||||
// Mock navigator.vibrate for haptic feedback tests
|
||||
const mockVibrate = jest.fn();
|
||||
Object.defineProperty(navigator, 'vibrate', {
|
||||
value: mockVibrate,
|
||||
configurable: true,
|
||||
writable: true,
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
mockSessionStorage['swipe-hint-shown'] = 'true'; // prevent onboarding hint by default
|
||||
jest.spyOn(Storage.prototype, 'getItem').mockImplementation(
|
||||
@@ -52,6 +60,7 @@ beforeEach(() => {
|
||||
(key: string, value: string) => { mockSessionStorage[key] = value; }
|
||||
);
|
||||
jest.useFakeTimers();
|
||||
mockVibrate.mockClear();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
@@ -151,6 +160,25 @@ describe('SwipeNavigation', () => {
|
||||
expect(dotsContainer).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows swipe dots when only prevRoute provided', async () => {
|
||||
const { SwipeNavigation } = await import('./use-swipe-gesture');
|
||||
const { container } = render(
|
||||
<SwipeNavigation prevRoute="/prev" />
|
||||
);
|
||||
// Should still show dots indicator with only one route
|
||||
const dotsContainer = container.querySelector('.fixed');
|
||||
expect(dotsContainer).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows swipe dots when only nextRoute provided', async () => {
|
||||
const { SwipeNavigation } = await import('./use-swipe-gesture');
|
||||
const { container } = render(
|
||||
<SwipeNavigation nextRoute="/next" />
|
||||
);
|
||||
const dotsContainer = container.querySelector('.fixed');
|
||||
expect(dotsContainer).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('does not show swipe dots when no routes provided', async () => {
|
||||
const { SwipeNavigation } = await import('./use-swipe-gesture');
|
||||
const { container } = render(<SwipeNavigation />);
|
||||
@@ -165,11 +193,105 @@ describe('SwipeNavigation', () => {
|
||||
const div = container.querySelector('.custom-class');
|
||||
expect(div).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('does not show onboarding hint when already visited', async () => {
|
||||
// swipe-hint-shown is already 'true' from beforeEach
|
||||
const { SwipeNavigation } = await import('./use-swipe-gesture');
|
||||
render(
|
||||
<SwipeNavigation prevRoute="/prev" nextRoute="/next" />
|
||||
);
|
||||
|
||||
// Advance timers - should NOT show hint since already visited
|
||||
act(() => {
|
||||
jest.advanceTimersByTime(1500);
|
||||
});
|
||||
|
||||
expect(screen.queryByText('提示')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows swipe hint (prev direction) when only prevRoute exists', async () => {
|
||||
mockSessionStorage['swipe-hint-shown'] = '';
|
||||
const { SwipeNavigation } = await import('./use-swipe-gesture');
|
||||
render(
|
||||
<SwipeNavigation prevRoute="/prev" prevLabel="上一页" />
|
||||
);
|
||||
|
||||
act(() => {
|
||||
jest.advanceTimersByTime(1500);
|
||||
});
|
||||
|
||||
// The hint text should show the prev label
|
||||
expect(screen.getByText('上一页')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows swipe hint (next direction) when only nextRoute exists', async () => {
|
||||
mockSessionStorage['swipe-hint-shown'] = '';
|
||||
const { SwipeNavigation } = await import('./use-swipe-gesture');
|
||||
render(
|
||||
<SwipeNavigation nextRoute="/next" nextLabel="下一页" />
|
||||
);
|
||||
|
||||
act(() => {
|
||||
jest.advanceTimersByTime(1500);
|
||||
});
|
||||
|
||||
expect(screen.getByText('下一页')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('auto-dismisses onboarding hint after 3 seconds', async () => {
|
||||
mockSessionStorage['swipe-hint-shown'] = '';
|
||||
const { SwipeNavigation } = await import('./use-swipe-gesture');
|
||||
render(
|
||||
<SwipeNavigation prevRoute="/prev" nextRoute="/next" />
|
||||
);
|
||||
|
||||
// Show hint
|
||||
act(() => {
|
||||
jest.advanceTimersByTime(1500);
|
||||
});
|
||||
expect(screen.getByText('提示')).toBeInTheDocument();
|
||||
|
||||
// Wait for auto-dismiss
|
||||
act(() => {
|
||||
jest.advanceTimersByTime(3000);
|
||||
});
|
||||
expect(screen.queryByText('提示')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('sets sessionStorage on mount when not visited', async () => {
|
||||
mockSessionStorage['swipe-hint-shown'] = '';
|
||||
const setItemSpy = jest.spyOn(Storage.prototype, 'setItem');
|
||||
const { SwipeNavigation } = await import('./use-swipe-gesture');
|
||||
render(
|
||||
<SwipeNavigation prevRoute="/prev" />
|
||||
);
|
||||
|
||||
expect(setItemSpy).toHaveBeenCalledWith('swipe-hint-shown', 'true');
|
||||
setItemSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Tests: PullToRefresh ────────────────────────────────────────────────
|
||||
|
||||
describe('PullToRefresh', () => {
|
||||
let originalScrollY: PropertyDescriptor | undefined;
|
||||
|
||||
beforeEach(() => {
|
||||
// Mock scrollY = 0 (at top of page) by default
|
||||
originalScrollY = Object.getOwnPropertyDescriptor(window, 'scrollY');
|
||||
Object.defineProperty(window, 'scrollY', {
|
||||
value: 0,
|
||||
configurable: true,
|
||||
writable: true,
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
if (originalScrollY) {
|
||||
Object.defineProperty(window, 'scrollY', originalScrollY);
|
||||
}
|
||||
});
|
||||
|
||||
it('renders children content', async () => {
|
||||
const { PullToRefresh } = await import('./use-swipe-gesture');
|
||||
render(
|
||||
@@ -205,4 +327,191 @@ describe('PullToRefresh', () => {
|
||||
// The outer div is rendered with className="relative" (from cn('relative', className))
|
||||
expect(container.querySelector('.relative')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('starts pulling when touch starts at scrollY=0', async () => {
|
||||
const onRefresh = jest.fn() as unknown as () => Promise<void>;
|
||||
const { PullToRefresh } = await import('./use-swipe-gesture');
|
||||
const { container } = render(
|
||||
<PullToRefresh onRefresh={onRefresh}>
|
||||
<div>content</div>
|
||||
</PullToRefresh>
|
||||
);
|
||||
const outer = container.firstChild as HTMLElement;
|
||||
|
||||
// Simulate touch start at top of page
|
||||
fireEvent.touchStart(outer, {
|
||||
touches: [{ clientY: 0, clientX: 0, identifier: 0 }],
|
||||
});
|
||||
|
||||
// Touch move downward
|
||||
fireEvent.touchMove(outer, {
|
||||
touches: [{ clientY: 150, clientX: 0, identifier: 0 }],
|
||||
});
|
||||
|
||||
// Touch end - pull distance > 60 should trigger refresh
|
||||
fireEvent.touchEnd(outer);
|
||||
|
||||
expect(onRefresh).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does not pull when scrollY > 0', async () => {
|
||||
Object.defineProperty(window, 'scrollY', {
|
||||
value: 100,
|
||||
configurable: true,
|
||||
writable: true,
|
||||
});
|
||||
const onRefresh = jest.fn() as unknown as () => Promise<void>;
|
||||
const { PullToRefresh } = await import('./use-swipe-gesture');
|
||||
const { container } = render(
|
||||
<PullToRefresh onRefresh={onRefresh}>
|
||||
<div>content</div>
|
||||
</PullToRefresh>
|
||||
);
|
||||
const outer = container.firstChild as HTMLElement;
|
||||
|
||||
fireEvent.touchStart(outer, {
|
||||
touches: [{ clientY: 0, clientX: 0, identifier: 0 }],
|
||||
});
|
||||
fireEvent.touchMove(outer, {
|
||||
touches: [{ clientY: 150, clientX: 0, identifier: 0 }],
|
||||
});
|
||||
fireEvent.touchEnd(outer);
|
||||
|
||||
expect(onRefresh).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does not refresh when pull distance is below threshold', async () => {
|
||||
const onRefresh = jest.fn() as unknown as () => Promise<void>;
|
||||
const { PullToRefresh } = await import('./use-swipe-gesture');
|
||||
const { container } = render(
|
||||
<PullToRefresh onRefresh={onRefresh}>
|
||||
<div>content</div>
|
||||
</PullToRefresh>
|
||||
);
|
||||
const outer = container.firstChild as HTMLElement;
|
||||
|
||||
// Small pull (distance = 50 * 0.5 = 25 < 60)
|
||||
fireEvent.touchStart(outer, {
|
||||
touches: [{ clientY: 0, clientX: 0, identifier: 0 }],
|
||||
});
|
||||
fireEvent.touchMove(outer, {
|
||||
touches: [{ clientY: 50, clientX: 0, identifier: 0 }],
|
||||
});
|
||||
fireEvent.touchEnd(outer);
|
||||
|
||||
expect(onRefresh).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('handles touch move without touches after pull started', async () => {
|
||||
const onRefresh = jest.fn() as unknown as () => Promise<void>;
|
||||
const { PullToRefresh } = await import('./use-swipe-gesture');
|
||||
const { container } = render(
|
||||
<PullToRefresh onRefresh={onRefresh}>
|
||||
<div>content</div>
|
||||
</PullToRefresh>
|
||||
);
|
||||
const outer = container.firstChild as HTMLElement;
|
||||
|
||||
fireEvent.touchStart(outer, {
|
||||
touches: [{ clientY: 0, clientX: 0, identifier: 0 }],
|
||||
});
|
||||
|
||||
// Touch move without touches should not throw
|
||||
expect(() => {
|
||||
fireEvent.touchMove(outer, {});
|
||||
}).not.toThrow();
|
||||
});
|
||||
|
||||
it('caps pull distance at 100', async () => {
|
||||
const onRefresh = jest.fn() as unknown as () => Promise<void>;
|
||||
const { PullToRefresh } = await import('./use-swipe-gesture');
|
||||
const { container } = render(
|
||||
<PullToRefresh onRefresh={onRefresh}>
|
||||
<div>content</div>
|
||||
</PullToRefresh>
|
||||
);
|
||||
const outer = container.firstChild as HTMLElement;
|
||||
|
||||
fireEvent.touchStart(outer, {
|
||||
touches: [{ clientY: 0, clientX: 0, identifier: 0 }],
|
||||
});
|
||||
|
||||
// Very large pull (distance = 300 * 0.5 = 150, capped at 100)
|
||||
fireEvent.touchMove(outer, {
|
||||
touches: [{ clientY: 300, clientX: 0, identifier: 0 }],
|
||||
});
|
||||
|
||||
// Should still trigger refresh since pull > 60
|
||||
fireEvent.touchEnd(outer);
|
||||
expect(onRefresh).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('resets pullDistance after touch end', async () => {
|
||||
const onRefresh = jest.fn() as unknown as () => Promise<void>;
|
||||
const { PullToRefresh } = await import('./use-swipe-gesture');
|
||||
const { container } = render(
|
||||
<PullToRefresh onRefresh={onRefresh}>
|
||||
<div>content</div>
|
||||
</PullToRefresh>
|
||||
);
|
||||
const outer = container.firstChild as HTMLElement;
|
||||
|
||||
fireEvent.touchStart(outer, {
|
||||
touches: [{ clientY: 0, clientX: 0, identifier: 0 }],
|
||||
});
|
||||
fireEvent.touchMove(outer, {
|
||||
touches: [{ clientY: 50, clientX: 0, identifier: 0 }],
|
||||
});
|
||||
fireEvent.touchEnd(outer);
|
||||
|
||||
// After touch end, pullDistance resets to 0, onRefresh not called
|
||||
expect(onRefresh).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does not pull when starting with negative Y (scroll up)', async () => {
|
||||
const onRefresh = jest.fn() as unknown as () => Promise<void>;
|
||||
const { PullToRefresh } = await import('./use-swipe-gesture');
|
||||
const { container } = render(
|
||||
<PullToRefresh onRefresh={onRefresh}>
|
||||
<div>content</div>
|
||||
</PullToRefresh>
|
||||
);
|
||||
const outer = container.firstChild as HTMLElement;
|
||||
|
||||
// Touch start at Y=0, but touch move goes up (negative)
|
||||
fireEvent.touchStart(outer, {
|
||||
touches: [{ clientY: 0, clientX: 0, identifier: 0 }],
|
||||
});
|
||||
// Moving up should not trigger refresh
|
||||
fireEvent.touchMove(outer, {
|
||||
touches: [{ clientY: -50, clientX: 0, identifier: 0 }],
|
||||
});
|
||||
fireEvent.touchEnd(outer);
|
||||
|
||||
expect(onRefresh).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('triggers medium haptic feedback on successful pull refresh', async () => {
|
||||
const onRefresh = jest.fn() as unknown as () => Promise<void>;
|
||||
const { PullToRefresh } = await import('./use-swipe-gesture');
|
||||
const { container } = render(
|
||||
<PullToRefresh onRefresh={onRefresh}>
|
||||
<div>content</div>
|
||||
</PullToRefresh>
|
||||
);
|
||||
const outer = container.firstChild as HTMLElement;
|
||||
|
||||
// Pull past threshold (distance = 150 * 0.5 = 75 > 60)
|
||||
fireEvent.touchStart(outer, {
|
||||
touches: [{ clientY: 0, clientX: 0, identifier: 0 }],
|
||||
});
|
||||
fireEvent.touchMove(outer, {
|
||||
touches: [{ clientY: 150, clientX: 0, identifier: 0 }],
|
||||
});
|
||||
fireEvent.touchEnd(outer);
|
||||
|
||||
// Should trigger medium haptic (25ms vibration)
|
||||
expect(mockVibrate).toHaveBeenCalledWith(25);
|
||||
expect(onRefresh).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -429,5 +429,233 @@ describe('useSwipeGesture', () => {
|
||||
|
||||
expect(result.current.swipeState.current.isSwiping).toBe(true);
|
||||
});
|
||||
|
||||
it('should not set direction when deltaX is 0', () => {
|
||||
Object.defineProperty(window, 'innerWidth', { value: 1024, configurable: true });
|
||||
const { result } = renderHook(() => useSwipeGesture({ edgeSize: 50 }));
|
||||
|
||||
// Start near right edge
|
||||
act(() => {
|
||||
touchStartHandler!(mockTouchEvent('touchstart', 1000));
|
||||
});
|
||||
|
||||
// Move to same position (deltaX = 0, |deltaX| = 0 < 10)
|
||||
act(() => {
|
||||
touchMoveHandler!(mockTouchEvent('touchmove', 1000));
|
||||
});
|
||||
|
||||
expect(result.current.swipeState.current.direction).toBeNull();
|
||||
expect(result.current.swipeState.current.progress).toBe(0);
|
||||
});
|
||||
|
||||
it('should not set direction when absDeltaX is exactly 0', () => {
|
||||
Object.defineProperty(window, 'innerWidth', { value: 1024, configurable: true });
|
||||
const { result } = renderHook(() => useSwipeGesture({ edgeSize: 50 }));
|
||||
|
||||
act(() => {
|
||||
touchStartHandler!(mockTouchEvent('touchstart', 1000));
|
||||
});
|
||||
|
||||
// Move exactly 0 pixels
|
||||
act(() => {
|
||||
touchMoveHandler!(mockTouchEvent('touchmove', 1000));
|
||||
});
|
||||
|
||||
expect(result.current.swipeState.current.direction).toBeNull();
|
||||
});
|
||||
|
||||
it('should not trigger callback when progress <= 0.3 but direction is set', () => {
|
||||
Object.defineProperty(window, 'innerWidth', { value: 375, configurable: true });
|
||||
|
||||
const onSwipeLeft = jest.fn();
|
||||
const onSwipeRight = jest.fn();
|
||||
renderHook(() => useSwipeGesture({ onSwipeLeft, onSwipeRight, edgeSize: 50 }));
|
||||
|
||||
// Start near left edge
|
||||
act(() => {
|
||||
touchStartHandler!(mockTouchEvent('touchstart', 20));
|
||||
});
|
||||
|
||||
// Move slightly: absDeltaX = 25 > 10 (direction is set), but progress = 25/(375*0.35) ≈ 0.19 < 0.3
|
||||
// This tests the logical operator: progress > 0.3 && direction
|
||||
// mutation: progress > 0.3 || direction would incorrectly trigger
|
||||
act(() => {
|
||||
touchMoveHandler!(mockTouchEvent('touchmove', 45));
|
||||
});
|
||||
|
||||
act(() => {
|
||||
touchEndHandler!(new Event('touchend'));
|
||||
});
|
||||
|
||||
expect(onSwipeLeft).not.toHaveBeenCalled();
|
||||
expect(onSwipeRight).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should handle touch end without onSwipeLeft and onSwipeRight callbacks', () => {
|
||||
Object.defineProperty(window, 'innerWidth', { value: 375, configurable: true });
|
||||
const { result } = renderHook(() => useSwipeGesture({ edgeSize: 50 }));
|
||||
|
||||
act(() => {
|
||||
touchStartHandler!(mockTouchEvent('touchstart', 350));
|
||||
});
|
||||
act(() => {
|
||||
touchMoveHandler!(mockTouchEvent('touchmove', 250));
|
||||
});
|
||||
act(() => {
|
||||
touchEndHandler!(new Event('touchend'));
|
||||
});
|
||||
|
||||
// Should not throw, state should be reset
|
||||
expect(result.current.swipeState.current.isSwiping).toBe(false);
|
||||
expect(result.current.swipeState.current.progress).toBe(0);
|
||||
});
|
||||
|
||||
it('should not call callback when onSwipeRight is not provided but swipe right occurs', () => {
|
||||
Object.defineProperty(window, 'innerWidth', { value: 375, configurable: true });
|
||||
const onSwipeLeft = jest.fn();
|
||||
|
||||
// Only provide onSwipeLeft, not onSwipeRight
|
||||
// Swipe right should not call onSwipeLeft, and should not crash
|
||||
renderHook(() => useSwipeGesture({ onSwipeLeft, edgeSize: 50 }));
|
||||
|
||||
act(() => {
|
||||
touchStartHandler!(mockTouchEvent('touchstart', 20));
|
||||
});
|
||||
act(() => {
|
||||
touchMoveHandler!(mockTouchEvent('touchmove', 150));
|
||||
});
|
||||
act(() => {
|
||||
touchEndHandler!(new Event('touchend'));
|
||||
});
|
||||
|
||||
// onSwipeRight is undefined, but optional chaining prevents crash
|
||||
expect(onSwipeLeft).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should not call callback when onSwipeLeft is not provided but swipe left occurs', () => {
|
||||
Object.defineProperty(window, 'innerWidth', { value: 375, configurable: true });
|
||||
const onSwipeRight = jest.fn();
|
||||
|
||||
// Only provide onSwipeRight, not onSwipeLeft
|
||||
renderHook(() => useSwipeGesture({ onSwipeRight, edgeSize: 50 }));
|
||||
|
||||
act(() => {
|
||||
touchStartHandler!(mockTouchEvent('touchstart', 350));
|
||||
});
|
||||
act(() => {
|
||||
touchMoveHandler!(mockTouchEvent('touchmove', 250));
|
||||
});
|
||||
act(() => {
|
||||
touchEndHandler!(new Event('touchend'));
|
||||
});
|
||||
|
||||
expect(onSwipeRight).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Boundary Conditions', () => {
|
||||
it('should not start swipe at exact left edge boundary (x === edgeSize)', () => {
|
||||
const { result } = renderHook(() => useSwipeGesture({ edgeSize: 50 }));
|
||||
|
||||
// Touch at x = 50, which is exactly at edgeSize (x < edgeSize is false)
|
||||
act(() => {
|
||||
touchStartHandler!(mockTouchEvent('touchstart', 50));
|
||||
});
|
||||
|
||||
// x < edgeSize is false, so swipe should not start
|
||||
expect(result.current.swipeState.current.isSwiping).toBe(false);
|
||||
});
|
||||
|
||||
it('should not start swipe at exact right edge boundary (x === window.innerWidth - edgeSize)', () => {
|
||||
Object.defineProperty(window, 'innerWidth', { value: 1024, configurable: true });
|
||||
const { result } = renderHook(() => useSwipeGesture({ edgeSize: 50 }));
|
||||
|
||||
// Touch at x = 974, which is exactly at window.innerWidth - edgeSize
|
||||
act(() => {
|
||||
touchStartHandler!(mockTouchEvent('touchstart', 974));
|
||||
});
|
||||
|
||||
// x > window.innerWidth - edgeSize is false, so swipe should not start
|
||||
expect(result.current.swipeState.current.isSwiping).toBe(false);
|
||||
});
|
||||
|
||||
it('should not set direction when absDeltaX is exactly 10', () => {
|
||||
Object.defineProperty(window, 'innerWidth', { value: 1024, configurable: true });
|
||||
const { result } = renderHook(() => useSwipeGesture({ edgeSize: 50 }));
|
||||
|
||||
// Start near right edge
|
||||
act(() => {
|
||||
touchStartHandler!(mockTouchEvent('touchstart', 1000));
|
||||
});
|
||||
|
||||
// Move left by exactly 10 pixels (absDeltaX = 10, which is not > 10)
|
||||
act(() => {
|
||||
touchMoveHandler!(mockTouchEvent('touchmove', 990));
|
||||
});
|
||||
|
||||
// absDeltaX > 10 is false, so direction should not be set
|
||||
expect(result.current.swipeState.current.direction).toBeNull();
|
||||
});
|
||||
|
||||
it('should set direction when absDeltaX is just above 10', () => {
|
||||
Object.defineProperty(window, 'innerWidth', { value: 1024, configurable: true });
|
||||
const { result } = renderHook(() => useSwipeGesture({ edgeSize: 50 }));
|
||||
|
||||
act(() => {
|
||||
touchStartHandler!(mockTouchEvent('touchstart', 1000));
|
||||
});
|
||||
|
||||
// Move left by 11 pixels (absDeltaX = 11 > 10)
|
||||
act(() => {
|
||||
touchMoveHandler!(mockTouchEvent('touchmove', 989));
|
||||
});
|
||||
|
||||
expect(result.current.swipeState.current.direction).toBe('left');
|
||||
});
|
||||
|
||||
it('should not trigger callback when progress is exactly 0.3', () => {
|
||||
Object.defineProperty(window, 'innerWidth', { value: 375, configurable: true });
|
||||
const onSwipeLeft = jest.fn();
|
||||
renderHook(() => useSwipeGesture({ onSwipeLeft, edgeSize: 50 }));
|
||||
|
||||
// Start near right edge
|
||||
act(() => {
|
||||
touchStartHandler!(mockTouchEvent('touchstart', 350));
|
||||
});
|
||||
|
||||
// Move: absDeltaX = 350 - 311 = 39
|
||||
// progress = 39 / (375 * 0.35) = 39 / 131.25 = 0.297... < 0.3
|
||||
act(() => {
|
||||
touchMoveHandler!(mockTouchEvent('touchmove', 311));
|
||||
});
|
||||
|
||||
act(() => {
|
||||
touchEndHandler!(new Event('touchend'));
|
||||
});
|
||||
|
||||
// progress ≈ 0.297 < 0.3, should NOT trigger
|
||||
expect(onSwipeLeft).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Real DOM Events', () => {
|
||||
beforeEach(() => {
|
||||
jest.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('should not start swipe when disabled via real DOM event', () => {
|
||||
const { result } = renderHook(() => useSwipeGesture({ enabled: false, edgeSize: 50 }));
|
||||
|
||||
act(() => {
|
||||
const event = new Event('touchstart', { bubbles: true });
|
||||
Object.defineProperty(event, 'touches', {
|
||||
value: [{ clientX: 20, clientY: 0, identifier: 0 }],
|
||||
});
|
||||
document.dispatchEvent(event);
|
||||
});
|
||||
|
||||
// Swipe should not start because enabled is false
|
||||
expect(result.current.swipeState.current.isSwiping).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user