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:
2026-08-02 19:39:27 +08:00
parent 7c0af54897
commit 602ed6a671
203 changed files with 8338 additions and 81 deletions
+310 -1
View File
@@ -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();
});
});