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:
@@ -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();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user