- 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
517 lines
17 KiB
TypeScript
517 lines
17 KiB
TypeScript
// @ts-nocheck
|
|
import { describe, it, expect, jest, beforeEach, afterEach } from '@jest/globals';
|
|
import { render, screen, act, fireEvent } from '@testing-library/react';
|
|
|
|
|
|
// ─── Mocks ───────────────────────────────────────────────────────────────
|
|
|
|
jest.mock('next/navigation', () => ({
|
|
useRouter: () => ({ push: jest.fn() }),
|
|
}));
|
|
|
|
jest.mock('framer-motion', () => ({
|
|
motion: {
|
|
div: ({ children, initial, animate, exit, className, ...props }: any) => (
|
|
<div
|
|
data-testid="motion-div"
|
|
data-initial={JSON.stringify(initial)}
|
|
data-animate={JSON.stringify(animate)}
|
|
data-exit={JSON.stringify(exit)}
|
|
className={className}
|
|
{...props}
|
|
>
|
|
{children}
|
|
</div>
|
|
),
|
|
},
|
|
AnimatePresence: ({ children }: any) => <>{children}</>,
|
|
}));
|
|
|
|
jest.mock('lucide-react', () => {
|
|
const mockIcon = (name: string) => {
|
|
const Icon = (props: any) => <svg data-testid={`icon-${name.toLowerCase()}`} className={props.className} />;
|
|
Icon.displayName = name;
|
|
return Icon;
|
|
};
|
|
return {
|
|
ArrowLeft: mockIcon('arrow-left'),
|
|
ArrowRight: mockIcon('arrow-right'),
|
|
};
|
|
});
|
|
|
|
// ─── SessionStorage Mock ─────────────────────────────────────────────────
|
|
|
|
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(
|
|
(key: string) => mockSessionStorage[key] ?? null
|
|
);
|
|
jest.spyOn(Storage.prototype, 'setItem').mockImplementation(
|
|
(key: string, value: string) => { mockSessionStorage[key] = value; }
|
|
);
|
|
jest.useFakeTimers();
|
|
mockVibrate.mockClear();
|
|
});
|
|
|
|
afterEach(() => {
|
|
jest.restoreAllMocks();
|
|
jest.useRealTimers();
|
|
});
|
|
|
|
// ─── Tests: SwipeNavigation ──────────────────────────────────────────────
|
|
|
|
describe('SwipeNavigation', () => {
|
|
it('renders prev and next labels when both routes provided', async () => {
|
|
// Clear swipe-hint-shown so the onboarding hint triggers
|
|
mockSessionStorage['swipe-hint-shown'] = '';
|
|
const { SwipeNavigation } = await import('./use-swipe-gesture');
|
|
render(
|
|
<SwipeNavigation
|
|
prevRoute="/prev"
|
|
nextRoute="/next"
|
|
prevLabel="上一页"
|
|
nextLabel="下一页"
|
|
/>
|
|
);
|
|
|
|
// Advance timers past the 1500ms onboarding hint delay
|
|
act(() => {
|
|
jest.advanceTimersByTime(1500);
|
|
});
|
|
|
|
expect(screen.getByText('上一页')).toBeInTheDocument();
|
|
expect(screen.getByText('下一页')).toBeInTheDocument();
|
|
expect(screen.getByTestId('icon-arrow-left')).toBeInTheDocument();
|
|
expect(screen.getByTestId('icon-arrow-right')).toBeInTheDocument();
|
|
});
|
|
|
|
it('renders only prev label when only prevRoute provided', async () => {
|
|
mockSessionStorage['swipe-hint-shown'] = '';
|
|
const { SwipeNavigation } = await import('./use-swipe-gesture');
|
|
render(
|
|
<SwipeNavigation
|
|
prevRoute="/prev"
|
|
prevLabel="上一页"
|
|
/>
|
|
);
|
|
|
|
act(() => {
|
|
jest.advanceTimersByTime(1500);
|
|
});
|
|
|
|
expect(screen.getByText('上一页')).toBeInTheDocument();
|
|
expect(screen.queryByText('下一页')).not.toBeInTheDocument();
|
|
expect(screen.getByTestId('icon-arrow-left')).toBeInTheDocument();
|
|
expect(screen.queryByTestId('icon-arrow-right')).not.toBeInTheDocument();
|
|
});
|
|
|
|
it('renders only next label when only nextRoute provided', 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();
|
|
expect(screen.queryByText('上一页')).not.toBeInTheDocument();
|
|
expect(screen.getByTestId('icon-arrow-right')).toBeInTheDocument();
|
|
expect(screen.queryByTestId('icon-arrow-left')).not.toBeInTheDocument();
|
|
});
|
|
|
|
it('renders default labels when no labels provided', async () => {
|
|
mockSessionStorage['swipe-hint-shown'] = '';
|
|
const { SwipeNavigation } = await import('./use-swipe-gesture');
|
|
render(
|
|
<SwipeNavigation prevRoute="/prev" nextRoute="/next" />
|
|
);
|
|
|
|
act(() => {
|
|
jest.advanceTimersByTime(1500);
|
|
});
|
|
|
|
expect(screen.getByText('上一页')).toBeInTheDocument();
|
|
expect(screen.getByText('下一页')).toBeInTheDocument();
|
|
});
|
|
|
|
it('shows swipe dots indicator when routes exist', async () => {
|
|
const { SwipeNavigation } = await import('./use-swipe-gesture');
|
|
const { container } = render(
|
|
<SwipeNavigation prevRoute="/prev" nextRoute="/next" />
|
|
);
|
|
// The swipe dots indicator renders as a fixed-position div
|
|
const dotsContainer = container.querySelector('.fixed');
|
|
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 />);
|
|
expect(container.querySelector('.fixed')).toBeNull();
|
|
});
|
|
|
|
it('applies custom className', async () => {
|
|
const { SwipeNavigation } = await import('./use-swipe-gesture');
|
|
const { container } = render(
|
|
<SwipeNavigation className="custom-class" />
|
|
);
|
|
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(
|
|
<PullToRefresh onRefresh={jest.fn() as unknown as () => Promise<void>}>
|
|
<div data-testid="child-content">Child Content</div>
|
|
</PullToRefresh>
|
|
);
|
|
expect(screen.getByTestId('child-content')).toBeInTheDocument();
|
|
expect(screen.getByText('Child Content')).toBeInTheDocument();
|
|
});
|
|
|
|
it('applies custom className', async () => {
|
|
const { PullToRefresh } = await import('./use-swipe-gesture');
|
|
const { container } = render(
|
|
<PullToRefresh onRefresh={jest.fn() as unknown as () => Promise<void>} className="custom-class">
|
|
<div>content</div>
|
|
</PullToRefresh>
|
|
);
|
|
const outer = container.querySelector('.custom-class');
|
|
expect(outer).toBeInTheDocument();
|
|
});
|
|
|
|
it('has touch event handlers on container', async () => {
|
|
const { PullToRefresh } = await import('./use-swipe-gesture');
|
|
const { container } = render(
|
|
<PullToRefresh onRefresh={jest.fn() as unknown as () => Promise<void>}>
|
|
<div>content</div>
|
|
</PullToRefresh>
|
|
);
|
|
const outer = container.firstChild as HTMLElement;
|
|
expect(outer).toBeInTheDocument();
|
|
expect(outer.tagName).toBe('DIV');
|
|
// 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();
|
|
});
|
|
}); |