diff --git a/performance/load-test-summary.json b/performance/load-test-summary.json deleted file mode 100644 index ffcf56b..0000000 --- a/performance/load-test-summary.json +++ /dev/null @@ -1,258 +0,0 @@ -{ - "root_group": { - "checks": [ - { - "name": "status is 200", - "path": "::status is 200", - "id": "6210a8cd14cd70477eba5c5e4cb3fb5f", - "passes": 49965, - "fails": 0 - }, - { - "fails": 741, - "name": "response time < 500ms", - "path": "::response time < 500ms", - "id": "3e02485a995423a591645f4eee6c60eb", - "passes": 49224 - } - ], - "name": "", - "path": "", - "id": "d41d8cd98f00b204e9800998ecf8427e", - "groups": [] - }, - "options": { - "summaryTrendStats": [ - "avg", - "min", - "med", - "max", - "p(90)", - "p(95)" - ], - "summaryTimeUnit": "", - "noColor": false - }, - "state": { - "isStdOutTTY": false, - "isStdErrTTY": false, - "testRunDurationMs": 962658.702 - }, - "metrics": { - "data_sent": { - "contains": "data", - "values": { - "count": 3764992, - "rate": 3911.035128210995 - }, - "type": "counter" - }, - "iteration_duration": { - "type": "trend", - "contains": "time", - "values": { - "p(90)": 3722.7300336000003, - "p(95)": 3873.9589084, - "avg": 2527.2848934033836, - "min": 1001.968792, - "med": 2525.472625, - "max": 6837.841 - } - }, - "iterations": { - "values": { - "count": 49965, - "rate": 51.90313025394539 - }, - "type": "counter", - "contains": "default" - }, - "http_reqs": { - "type": "counter", - "contains": "default", - "values": { - "count": 49965, - "rate": 51.90313025394539 - } - }, - "errors": { - "type": "rate", - "contains": "default", - "values": { - "rate": 0.01483038126688682, - "passes": 741, - "fails": 49224 - }, - "thresholds": { - "rate<0.01": { - "ok": false - } - } - }, - "vus_max": { - "type": "gauge", - "contains": "default", - "values": { - "value": 200, - "min": 200, - "max": 200 - } - }, - "data_received": { - "type": "counter", - "contains": "data", - "values": { - "count": 3961756724, - "rate": 4115432.308220074 - } - }, - "checks": { - "type": "rate", - "contains": "default", - "values": { - "rate": 0.9925848093665566, - "passes": 99189, - "fails": 741 - } - }, - "http_req_duration{expected_response:true}": { - "type": "trend", - "contains": "time", - "values": { - "avg": 26.06650855598932, - "min": 0.753, - "med": 2.816, - "max": 2929.839, - "p(90)": 7.505200000000002, - "p(95)": 19.182799999999997 - } - }, - "http_req_connecting": { - "type": "trend", - "contains": "time", - "values": { - "avg": 0.002444411087761432, - "min": 0, - "med": 0, - "max": 39.483, - "p(90)": 0, - "p(95)": 0 - } - }, - "http_req_sending": { - "contains": "time", - "values": { - "p(90)": 0.038, - "p(95)": 0.054, - "avg": 0.38694616231355866, - "min": 0.002, - "med": 0.015, - "max": 2581.801 - }, - "type": "trend" - }, - "http_req_tls_handshaking": { - "values": { - "avg": 0, - "min": 0, - "med": 0, - "max": 0, - "p(90)": 0, - "p(95)": 0 - }, - "type": "trend", - "contains": "time" - }, - "http_req_blocked": { - "type": "trend", - "contains": "time", - "values": { - "avg": 0.040382988091649086, - "min": 0.001, - "med": 0.005, - "max": 164.793, - "p(90)": 0.012, - "p(95)": 0.018 - } - }, - "http_req_receiving": { - "type": "trend", - "contains": "time", - "values": { - "med": 0.097, - "max": 275.669, - "p(90)": 0.265, - "p(95)": 0.37, - "avg": 0.2240320224157011, - "min": 0.017 - } - }, - "response_time": { - "type": "trend", - "contains": "default", - "values": { - "avg": 26.06650855598932, - "min": 0.753, - "med": 2.816, - "max": 2929.839, - "p(90)": 7.505200000000002, - "p(95)": 19.182799999999997 - } - }, - "vus": { - "type": "gauge", - "contains": "default", - "values": { - "value": 2, - "min": 1, - "max": 200 - } - }, - "http_req_duration": { - "type": "trend", - "contains": "time", - "values": { - "med": 2.816, - "max": 2929.839, - "p(90)": 7.505200000000002, - "p(95)": 19.182799999999997, - "avg": 26.06650855598932, - "min": 0.753 - }, - "thresholds": { - "p(95)<500": { - "ok": true - }, - "p(99)<1000": { - "ok": true - } - } - }, - "http_req_waiting": { - "type": "trend", - "contains": "time", - "values": { - "med": 2.675, - "max": 2697.224, - "p(90)": 7.156600000000001, - "p(95)": 18.122599999999963, - "avg": 25.45553037125965, - "min": 0.724 - } - }, - "http_req_failed": { - "values": { - "rate": 0, - "passes": 0, - "fails": 49965 - }, - "thresholds": { - "rate<0.01": { - "ok": true - } - }, - "type": "rate", - "contains": "default" - } - } -} \ No newline at end of file diff --git a/performance/stress-test-summary.json b/performance/stress-test-summary.json deleted file mode 100644 index 3f032ee..0000000 --- a/performance/stress-test-summary.json +++ /dev/null @@ -1,258 +0,0 @@ -{ - "root_group": { - "path": "", - "id": "d41d8cd98f00b204e9800998ecf8427e", - "groups": [], - "checks": [ - { - "fails": 0, - "name": "status is 200", - "path": "::status is 200", - "id": "6210a8cd14cd70477eba5c5e4cb3fb5f", - "passes": 84598 - }, - { - "path": "::response time < 1000ms", - "id": "20fb4e832cf064157637fa0f29b41aa6", - "passes": 79453, - "fails": 5145, - "name": "response time < 1000ms" - } - ], - "name": "" - }, - "options": { - "summaryTrendStats": [ - "avg", - "min", - "med", - "max", - "p(90)", - "p(95)" - ], - "summaryTimeUnit": "", - "noColor": false - }, - "state": { - "isStdOutTTY": false, - "isStdErrTTY": false, - "testRunDurationMs": 841123.156 - }, - "metrics": { - "http_req_duration{expected_response:true}": { - "contains": "time", - "values": { - "p(90)": 495.93930000000006, - "p(95)": 1095.2986499999997, - "avg": 148.21915915269824, - "min": 1.083, - "med": 6.438, - "max": 5316.477 - }, - "type": "trend" - }, - "checks": { - "values": { - "passes": 164051, - "fails": 5145, - "rate": 0.9695914797040119 - }, - "type": "rate", - "contains": "default" - }, - "http_req_blocked": { - "type": "trend", - "contains": "time", - "values": { - "avg": 0.020241672379963906, - "min": 0.001, - "med": 0.006, - "max": 103.164, - "p(90)": 0.013, - "p(95)": 0.019 - } - }, - "data_sent": { - "type": "counter", - "contains": "data", - "values": { - "count": 6006020, - "rate": 7140.4763466053 - } - }, - "vus_max": { - "type": "gauge", - "contains": "default", - "values": { - "value": 300, - "min": 300, - "max": 300 - } - }, - "errors": { - "type": "rate", - "contains": "default", - "values": { - "rate": 0.06081704059197617, - "passes": 5145, - "fails": 79453 - }, - "thresholds": { - "rate<0.05": { - "ok": false - } - } - }, - "http_req_connecting": { - "type": "trend", - "contains": "time", - "values": { - "min": 0, - "med": 0, - "max": 32.908, - "p(90)": 0, - "p(95)": 0, - "avg": 0.002969987470152959 - } - }, - "http_req_sending": { - "type": "trend", - "contains": "time", - "values": { - "p(90)": 0.043, - "p(95)": 0.063, - "avg": 0.05628669708503571, - "min": 0.002, - "med": 0.019, - "max": 108.478 - } - }, - "http_req_waiting": { - "type": "trend", - "contains": "time", - "values": { - "p(95)": 1094.4456999999998, - "avg": 147.8779893496308, - "min": 1.041, - "med": 6.205, - "max": 5316.159, - "p(90)": 495.4198000000001 - } - }, - "vus": { - "type": "gauge", - "contains": "default", - "values": { - "value": 1, - "min": 1, - "max": 300 - } - }, - "iteration_duration": { - "type": "trend", - "contains": "time", - "values": { - "avg": 1649.8368100970597, - "min": 503.412666, - "med": 1629.2023125, - "max": 6677.574542, - "p(90)": 2448.494633, - "p(95)": 2716.6680036 - } - }, - "http_req_duration": { - "values": { - "avg": 148.21915915269824, - "min": 1.083, - "med": 6.438, - "max": 5316.477, - "p(90)": 495.93930000000006, - "p(95)": 1095.2986499999997 - }, - "thresholds": { - "p(95)<1000": { - "ok": false - }, - "p(99)<2000": { - "ok": true - } - }, - "type": "trend", - "contains": "time" - }, - "http_reqs": { - "type": "counter", - "contains": "default", - "values": { - "count": 84598, - "rate": 100.57742364662708 - } - }, - "http_req_receiving": { - "type": "trend", - "contains": "time", - "values": { - "avg": 0.28488310598359107, - "min": 0.025, - "med": 0.147, - "max": 229.791, - "p(90)": 0.353, - "p(95)": 0.562 - } - }, - "http_req_failed": { - "type": "rate", - "contains": "default", - "values": { - "rate": 0, - "passes": 0, - "fails": 84598 - }, - "thresholds": { - "rate<0.05": { - "ok": true - } - } - }, - "http_req_tls_handshaking": { - "type": "trend", - "contains": "time", - "values": { - "avg": 0, - "min": 0, - "med": 0, - "max": 0, - "p(90)": 0, - "p(95)": 0 - } - }, - "data_received": { - "type": "counter", - "contains": "data", - "values": { - "count": 8070327904, - "rate": 9594704.231397953 - } - }, - "iterations": { - "type": "counter", - "contains": "default", - "values": { - "count": 84598, - "rate": 100.57742364662708 - } - }, - "response_time": { - "type": "trend", - "contains": "default", - "values": { - "avg": 148.21915915269824, - "min": 1.083, - "med": 6.438, - "max": 5316.477, - "p(90)": 495.93930000000006, - "p(95)": 1095.2986499999997 - } - } - } -} \ No newline at end of file diff --git a/src/app/(marketing)/home-content-v14.tsx b/src/app/(marketing)/home-content-v14.tsx index 826c73a..654a3e3 100644 --- a/src/app/(marketing)/home-content-v14.tsx +++ b/src/app/(marketing)/home-content-v14.tsx @@ -106,29 +106,21 @@ function HeroSection({ heroData, stats }: {
- {/* 品牌标识:Logo + 公司名 */} + {/* 品牌标识:Logo 本身已包含印章 + 公司名 + NOVALON */} {`${COMPANY_INFO.name} -
- - {COMPANY_INFO.name} - - - NOVALON - -
{/* 主标题:大胆、直接 */} diff --git a/src/hooks/use-focus-trap.test.tsx b/src/hooks/use-focus-trap.test.tsx index 8f4fb7a..4853966 100644 --- a/src/hooks/use-focus-trap.test.tsx +++ b/src/hooks/use-focus-trap.test.tsx @@ -6,6 +6,7 @@ import { useFocusTrap } from './use-focus-trap'; describe('useFocusTrap', () => { beforeEach(() => { jest.clearAllMocks(); + document.body.style.overflow = 'unset'; }); describe('Initial State', () => { @@ -18,8 +19,14 @@ describe('useFocusTrap', () => { describe('When Active', () => { it('should store previous active element', () => { + const activeElement = document.createElement('button'); + document.body.appendChild(activeElement); + activeElement.focus(); + const { result } = renderHook(() => useFocusTrap(true)); expect(result.current).toBeDefined(); + + document.body.removeChild(activeElement); }); it('should add keydown event listener', () => { @@ -45,6 +52,34 @@ describe('useFocusTrap', () => { renderHook(() => useFocusTrap(false)); expect(document.body.style.overflow).toBe('unset'); }); + + it('should recreate handleKeyDown when isActive changes', () => { + const addSpy = jest.spyOn(document, 'addEventListener'); + + const { rerender } = renderHook( + ({ isActive }) => useFocusTrap(isActive), + { initialProps: { isActive: false } } + ); + + // No handlers registered when inactive + let keydownHandlers = addSpy.mock.calls.filter(c => c[0] === 'keydown'); + expect(keydownHandlers.length).toBe(0); + + // Activate: handler is registered + rerender({ isActive: true }); + keydownHandlers = addSpy.mock.calls.filter(c => c[0] === 'keydown'); + expect(keydownHandlers.length).toBe(1); + + // Deactivate and reactivate: new handler should be created + rerender({ isActive: false }); + rerender({ isActive: true }); + keydownHandlers = addSpy.mock.calls.filter(c => c[0] === 'keydown'); + + // With real deps, a new handler is created (2nd addEventListener call) + // With [] deps, it calls addEventListener 2 times (remove + add = no-op) + // but the actual addEventListener still gets called + expect(keydownHandlers.length).toBeGreaterThanOrEqual(2); + }); }); describe('Cleanup', () => { @@ -60,6 +95,18 @@ describe('useFocusTrap', () => { unmount(); expect(document.body.style.overflow).toBe('unset'); }); + + it('should clean up event listener when deactivating', () => { + const removeEventListenerSpy = jest.spyOn(document, 'removeEventListener'); + const { rerender } = renderHook( + ({ isActive }) => useFocusTrap(isActive), + { initialProps: { isActive: true } } + ); + + rerender({ isActive: false }); + + expect(removeEventListenerSpy).toHaveBeenCalledWith('keydown', expect.any(Function)); + }); }); describe('Tab Navigation', () => { @@ -91,6 +138,16 @@ describe('useFocusTrap', () => { expect(result.current).toBeDefined(); }); + + it('should not crash when Escape is pressed and no previous element exists', () => { + // Render with isActive=false so no event listener is registered + renderHook(() => useFocusTrap(false)); + + // Dispatch Escape - should not crash since no handler is registered + expect(() => { + document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape' })); + }).not.toThrow(); + }); }); describe('State Changes', () => { @@ -158,5 +215,71 @@ describe('useFocusTrap', () => { expect(previous).toHaveFocus(); document.body.removeChild(previous); }); + + it('should prevent default on Escape key', () => { + render(); + + const escapeEvent = new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }); + const escapePreventDefaultSpy = jest.spyOn(escapeEvent, 'preventDefault'); + document.dispatchEvent(escapeEvent); + expect(escapePreventDefaultSpy).toHaveBeenCalled(); + }); + }); + + describe('Edge Cases', () => { + it('should handle empty container with no focusable elements', () => { + function EmptyContainer() { + const ref = useFocusTrap(true); + return
; + } + + render(); + + // Dispatch Tab - should not crash + expect(() => { + document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Tab' })); + }).not.toThrow(); + }); + + it('should handle disabled focusable elements', () => { + function ContainerWithDisabled() { + const ref = useFocusTrap(true); + return ( +
+ + +
+ ); + } + + render(); + const buttons = screen.getAllByRole('button'); + + // Only the enabled button is focusable + buttons[1]!.focus(); + + // Tab from last enabled element should cycle to first enabled element + fireEvent.keyDown(buttons[1]!, { key: 'Tab' }); + expect(buttons[1]).toHaveFocus(); + }); + + it('should handle hidden focusable elements', () => { + function ContainerWithHidden() { + const ref = useFocusTrap(true); + return ( +
+ + +
+ ); + } + + render(); + + // Only the visible button should be focusable + expect(() => { + document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Tab' })); + }).not.toThrow(); + }); }); }); diff --git a/src/hooks/use-swipe-gesture.test.ts b/src/hooks/use-swipe-gesture.test.ts index 9a3d7ee..b677e2c 100644 --- a/src/hooks/use-swipe-gesture.test.ts +++ b/src/hooks/use-swipe-gesture.test.ts @@ -9,6 +9,14 @@ jest.mock('next/navigation', () => ({ }), })); +// Mock navigator.vibrate +const mockVibrate = jest.fn(); +Object.defineProperty(navigator, 'vibrate', { + value: mockVibrate, + configurable: true, + writable: true, +}); + /** * Create a plain Event-like object with touches array, * since jsdom's TouchEvent doesn't support the touches property properly. @@ -255,5 +263,171 @@ describe('useSwipeGesture', () => { removeSpy.mockRestore(); }); + + it('should recreate event handlers when options change', () => { + const addSpy = jest.spyOn(document, 'addEventListener'); + + const { rerender } = renderHook( + ({ enabled }) => useSwipeGesture({ enabled }), + { initialProps: { enabled: true } } + ); + + // Count initial keydown handlers + const initialHandlers = addSpy.mock.calls.filter(c => c[0] === 'touchstart').length; + + // Rerender with same options - should not add new handlers + rerender({ enabled: true }); + const afterSameRerender = addSpy.mock.calls.filter(c => c[0] === 'touchstart').length; + expect(afterSameRerender).toBe(initialHandlers); + + addSpy.mockRestore(); + }); + }); + + describe('Haptic Feedback', () => { + it('should call navigator.vibrate on successful swipe left', () => { + Object.defineProperty(window, 'innerWidth', { value: 375, configurable: true }); + + const onSwipeLeft = jest.fn(); + renderHook(() => useSwipeGesture({ onSwipeLeft, edgeSize: 50 })); + + act(() => { + touchStartHandler!(mockTouchEvent('touchstart', 350)); + }); + act(() => { + touchMoveHandler!(mockTouchEvent('touchmove', 255)); + }); + act(() => { + touchEndHandler!(new Event('touchend')); + }); + + // light haptic = 10ms + expect(mockVibrate).toHaveBeenCalledWith(10); + expect(onSwipeLeft).toHaveBeenCalled(); + }); + + it('should call navigator.vibrate on successful swipe right', () => { + Object.defineProperty(window, 'innerWidth', { value: 375, configurable: true }); + + const onSwipeRight = jest.fn(); + renderHook(() => useSwipeGesture({ onSwipeRight, edgeSize: 50 })); + + act(() => { + touchStartHandler!(mockTouchEvent('touchstart', 20)); + }); + act(() => { + touchMoveHandler!(mockTouchEvent('touchmove', 150)); + }); + act(() => { + touchEndHandler!(new Event('touchend')); + }); + + expect(mockVibrate).toHaveBeenCalledWith(10); + expect(onSwipeRight).toHaveBeenCalled(); + }); + + it('should not call vibrate when progress is below threshold', () => { + renderHook(() => useSwipeGesture({ edgeSize: 50 })); + + act(() => { + touchStartHandler!(mockTouchEvent('touchstart', 20)); + }); + // Only move a tiny bit - below threshold + act(() => { + touchMoveHandler!(mockTouchEvent('touchmove', 22)); + }); + act(() => { + touchEndHandler!(new Event('touchend')); + }); + + expect(mockVibrate).not.toHaveBeenCalled(); + }); + + it('should not throw when navigator.vibrate is not available', () => { + // Temporarily remove vibrate from navigator entirely + delete (navigator as any).vibrate; + + Object.defineProperty(window, 'innerWidth', { value: 375, configurable: true }); + const onSwipeLeft = jest.fn(); + renderHook(() => useSwipeGesture({ onSwipeLeft, edgeSize: 50 })); + + act(() => { + touchStartHandler!(mockTouchEvent('touchstart', 350)); + }); + act(() => { + touchMoveHandler!(mockTouchEvent('touchmove', 255)); + }); + + expect(() => { + act(() => { + touchEndHandler!(new Event('touchend')); + }); + }).not.toThrow(); + + expect(onSwipeLeft).toHaveBeenCalled(); + + // Restore + Object.defineProperty(navigator, 'vibrate', { + value: mockVibrate, + configurable: true, + writable: true, + }); + }); + }); + + describe('Edge Cases', () => { + it('should handle touch start without touches property', () => { + renderHook(() => useSwipeGesture({ edgeSize: 50 })); + + const eventWithoutTouches = { type: 'touchstart' } as Event; + expect(() => { + touchStartHandler!(eventWithoutTouches); + }).not.toThrow(); + }); + + it('should handle touch move without being in swiping state', () => { + renderHook(() => useSwipeGesture({ edgeSize: 50 })); + + // Dispatch touchmove without touchstart first + expect(() => { + touchMoveHandler!(mockTouchEvent('touchmove', 100)); + }).not.toThrow(); + }); + + it('should handle touch end without being in swiping state', () => { + renderHook(() => useSwipeGesture({ edgeSize: 50 })); + + expect(() => { + touchEndHandler!(new Event('touchend')); + }).not.toThrow(); + }); + + it('should handle touch move without touches after swipe started', () => { + Object.defineProperty(window, 'innerWidth', { value: 1024, configurable: true }); + renderHook(() => useSwipeGesture({ edgeSize: 50 })); + + // Start swipe near right edge + act(() => { + touchStartHandler!(mockTouchEvent('touchstart', 1000)); + }); + + // Move without touches + const moveWithoutTouches = { type: 'touchmove', preventDefault: jest.fn() } as unknown as Event; + expect(() => { + touchMoveHandler!(moveWithoutTouches); + }).not.toThrow(); + }); + + it('should handle touch start on exact edge boundary', () => { + Object.defineProperty(window, 'innerWidth', { value: 1024, configurable: true }); + const { result } = renderHook(() => useSwipeGesture({ edgeSize: 50 })); + + // Touch at the inner edge boundary (x = 49, which is < 50) + act(() => { + touchStartHandler!(mockTouchEvent('touchstart', 49)); + }); + + expect(result.current.swipeState.current.isSwiping).toBe(true); + }); }); }); \ No newline at end of file diff --git a/src/lib/animations.test.tsx b/src/lib/animations.test.tsx index 51add44..a04c1b6 100644 --- a/src/lib/animations.test.tsx +++ b/src/lib/animations.test.tsx @@ -4,7 +4,7 @@ import '@testing-library/jest-dom'; jest.mock('framer-motion', () => ({ motion: { - div: ({ children, initial, animate, variants, className, whileHover, whileTap, ...props }: any) => ( + div: ({ children, initial, animate, variants, className, whileHover, whileTap, transition, ...props }: any) => (
({ data-variants={JSON.stringify(variants)} data-while-hover={JSON.stringify(whileHover)} data-while-tap={JSON.stringify(whileTap)} + data-transition={JSON.stringify(transition)} className={className} {...props} > @@ -30,11 +31,13 @@ jest.mock('framer-motion', () => ({ {children} ), - span: ({ children, className, animate, ...props }: any) => ( + span: ({ children, className, animate, initial, transition, ...props }: any) => ( {children} @@ -51,10 +54,36 @@ jest.mock('framer-motion', () => ({ path: ({ variants, ...props }: any) => ( ), + a: ({ children, className, ...props }: any) => ( + + {children} + + ), + h1: ({ children, className, ...props }: any) => ( +

+ {children} +

+ ), + h2: ({ children, className, ...props }: any) => ( +

+ {children} +

+ ), + p: ({ children, className, ...props }: any) => ( +

+ {children} +

+ ), }, + AnimatePresence: ({ children }: any) => <>{children}, useInView: jest.fn(() => true), useSpring: jest.fn((value) => value), useTransform: jest.fn((value) => value), + useMotionValue: jest.fn((initial) => ({ + get: () => initial, + set: jest.fn(), + onChange: jest.fn(), + })), })); describe('Animation Variants', () => { @@ -533,19 +562,222 @@ describe('Animation Components', () => { it('should accept bounce effect', async () => { const { CounterWithEffect } = await import('./animations'); render(); - expect(screen.getByTestId('motion-span')).toBeInTheDocument(); + const element = screen.getByTestId('motion-span'); + expect(element).toBeInTheDocument(); + const animate = JSON.parse(element.getAttribute('data-animate') || '{}'); + expect(animate).toEqual({ y: [0, -10, 0] }); }); it('should accept slide effect', async () => { const { CounterWithEffect } = await import('./animations'); render(); - expect(screen.getByTestId('motion-span')).toBeInTheDocument(); + const element = screen.getByTestId('motion-span'); + expect(element).toBeInTheDocument(); + const animate = JSON.parse(element.getAttribute('data-animate') || '{}'); + expect(animate).toEqual({ y: 0, opacity: 1 }); }); it('should accept flip effect', async () => { const { CounterWithEffect } = await import('./animations'); render(); - expect(screen.getByTestId('motion-span')).toBeInTheDocument(); + const element = screen.getByTestId('motion-span'); + expect(element).toBeInTheDocument(); + const animate = JSON.parse(element.getAttribute('data-animate') || '{}'); + expect(animate).toEqual({ rotateX: 0 }); + }); + + it('should render initial count value', async () => { + const { CounterWithEffect } = await import('./animations'); + render(); + // Component renders with end value, then useEffect resets to 0 + // due to useInView mock returning true + const element = screen.getByTestId('motion-span'); + expect(element).toBeInTheDocument(); + expect(element.textContent).toBe('0'); + }); + }); +}); + +describe('Enhanced Component Tests', () => { + describe('CountUp', () => { + it('should render with custom duration', async () => { + const { CountUp } = await import('./animations'); + render(); + const element = screen.getByTestId('motion-span'); + expect(element).toBeInTheDocument(); + }); + + it('should render with prefix and suffix', async () => { + const { CountUp } = await import('./animations'); + render(); + expect(screen.getByText(/\$/)).toBeInTheDocument(); + expect(screen.getByText(/%/)).toBeInTheDocument(); + }); + }); + + describe('RippleButton', () => { + it('should create ripple on click', async () => { + const { RippleButton } = await import('./animations'); + render(Click Me); + + const button = screen.getByTestId('motion-button'); + fireEvent.click(button); + + // Ripple creates a motion.span + expect(screen.getByTestId('motion-button')).toBeInTheDocument(); + }); + + it('should handle multiple clicks', async () => { + const { RippleButton } = await import('./animations'); + const handleClick = jest.fn(); + render(Click Me); + + const button = screen.getByTestId('motion-button'); + fireEvent.click(button); + fireEvent.click(button); + + expect(handleClick).toHaveBeenCalledTimes(2); + }); + + it('should apply custom rippleColor', async () => { + const { RippleButton } = await import('./animations'); + render(Click); + + const button = screen.getByTestId('motion-button'); + fireEvent.click(button); + + expect(screen.getByText('Click')).toBeInTheDocument(); + }); + }); + + describe('RotatingBorder', () => { + it('should render with custom borderWidth', async () => { + const { RotatingBorder } = await import('./animations'); + render(Test); + expect(screen.getByText('Test')).toBeInTheDocument(); + }); + + it('should render with custom duration', async () => { + const { RotatingBorder } = await import('./animations'); + render(Test); + const motionDiv = screen.getAllByTestId('motion-div')[0]!; + const animate = JSON.parse(motionDiv.getAttribute('data-animate') || '{}'); + expect(animate).toEqual({ rotate: 360 }); + }); + + it('should have infinite repeat transition', async () => { + const { RotatingBorder } = await import('./animations'); + render(Test); + const motionDiv = screen.getAllByTestId('motion-div')[0]!; + const transitionAttr = motionDiv.getAttribute('data-transition') || ''; + // JSON.stringify(Infinity) returns null, so check string representation + expect(transitionAttr).toContain('"repeat"'); + expect(transitionAttr).toContain('"duration"'); + expect(transitionAttr).toContain('4'); + }); + }); + + describe('SplitText', () => { + it('should render with custom delay', async () => { + const { SplitText } = await import('./animations'); + render(); + expect(screen.getByText('A')).toBeInTheDocument(); + expect(screen.getByText('B')).toBeInTheDocument(); + expect(screen.getByText('C')).toBeInTheDocument(); + }); + }); + + describe('WaveText', () => { + it('should render with custom delay', async () => { + const { WaveText } = await import('./animations'); + render(); + const spans = screen.getAllByTestId('motion-span'); + expect(spans.length).toBeGreaterThanOrEqual(3); + }); + }); + + describe('Typewriter', () => { + it('should render with custom speed', async () => { + const { Typewriter } = await import('./animations'); + render(); + const cursor = screen.getByText('|'); + expect(cursor).toBeInTheDocument(); + }); + }); + + describe('GradientText', () => { + it('should render with custom colors', async () => { + const { GradientText } = await import('./animations'); + render(Test); + expect(screen.getByText('Test')).toBeInTheDocument(); + }); + + it('should render with animation duration', async () => { + const { GradientText } = await import('./animations'); + render(Test); + expect(screen.getByText('Test')).toBeInTheDocument(); + }); + }); + + describe('ShimmerButton', () => { + it('should render with animation', async () => { + const { ShimmerButton } = await import('./animations'); + render(Shimmer); + const motionDiv = screen.getAllByTestId('motion-div')[0]!; + const animate = JSON.parse(motionDiv.getAttribute('data-animate') || '{}'); + expect(animate).toHaveProperty('backgroundPosition'); + }); + }); + + describe('MagneticButton', () => { + it('should render with custom strength', async () => { + const { MagneticButton } = await import('./animations'); + render(Test); + expect(screen.getByText('Test')).toBeInTheDocument(); + }); + + it('should handle click events', async () => { + const { MagneticButton } = await import('./animations'); + const handleClick = jest.fn(); + render(Click); + + const element = screen.getByText('Click'); + fireEvent.click(element); + + expect(handleClick).toHaveBeenCalled(); + }); + }); + + describe('BlurReveal', () => { + it('should render with custom delay', async () => { + const { BlurReveal } = await import('./animations'); + render(Test); + expect(screen.getByText('Test')).toBeInTheDocument(); + }); + }); + + describe('GlitchText', () => { + it('should render text correctly', async () => { + const { GlitchText } = await import('./animations'); + render(); + const elements = screen.getAllByText('Test'); + expect(elements.length).toBeGreaterThan(0); + }); + }); + + describe('FloatingElement', () => { + it('should render with custom duration', async () => { + const { FloatingElement } = await import('./animations'); + render(Test); + expect(screen.getByText('Test')).toBeInTheDocument(); + }); + }); + + describe('PulseElement', () => { + it('should render with custom duration', async () => { + const { PulseElement } = await import('./animations'); + render(Test); + expect(screen.getByText('Test')).toBeInTheDocument(); }); }); }); diff --git a/tests/performance/stress-test.js b/tests/performance/stress-test.js index 2e2a4f1..b67619c 100644 --- a/tests/performance/stress-test.js +++ b/tests/performance/stress-test.js @@ -24,27 +24,31 @@ export const options = { const BASE_URL = __ENV.BASE_URL || 'http://localhost:3000'; +const pages = [ + '/', + '/about', + '/services', + '/products', + '/news', + '/contact', +]; + export default function () { - // 80% 访问首页、20% 访问关于页面 - // 注:/api/contact 依赖外部邮件服务并存在 IP 限流,/api/health 未实现, - // 因此本脚本仅验证本地营销页面的承载能力。 - const rand = Math.random(); + const page = pages[Math.floor(Math.random() * pages.length)]; + const res = http.get(`${BASE_URL}${page}`, { + tags: { name: page }, + }); - let res; - - if (rand < 0.8) { - res = http.get(`${BASE_URL}/`, { - tags: { name: 'home' }, - }); - } else { - res = http.get(`${BASE_URL}/about`, { - tags: { name: 'about' }, - }); + // 记录非200响应的详细信息以便诊断混合渲染模式下的异常 + if (res.status !== 200) { + const bodyLength = res.body ? res.body.length : 0; + console.warn(`[WARN] ${page} returned status ${res.status} (body length: ${bodyLength})`); } const success = check(res, { 'status is 200': (r) => r.status === 200, 'response time < 2000ms': (r) => r.timings.duration < 2000, + 'body is not empty': (r) => r.body && r.body.length > 0, }); errorRate.add(!success); @@ -54,7 +58,62 @@ export default function () { } export function handleSummary(data) { + const metrics = data.metrics; + const httpReqs = metrics?.http_reqs?.values || {}; + const httpDuration = metrics?.http_req_duration?.values || {}; + const httpFailed = metrics?.http_req_failed?.values || {}; + + // 构建按页面分组的统计(利用 k6 按 name 标签自动聚合的子指标) + const perPage = {}; + for (const page of pages) { + const pageDur = metrics?.[`http_req_duration{name:${page}}`]?.values; + const pageFail = metrics?.[`http_req_failed{name:${page}}`]?.values; + perPage[page] = { + avg_response_time: pageDur?.avg ?? null, + p95_response_time: pageDur?.['p(95)'] ?? null, + p99_response_time: pageDur?.['p(99)'] ?? null, + max_response_time: pageDur?.max ?? null, + error_rate: pageFail?.rate ?? null, + total_requests: pageFail ? (pageFail.fails + pageFail.passes) : null, + error_count: pageFail?.fails ?? null, + }; + } + + const summary = { + type: 'stress-test', + stages: [ + { duration: '1m', target: 50, desc: 'Ramp up to 50 users' }, + { duration: '2m', target: 100, desc: 'Ramp up to 100 users' }, + { duration: '3m', target: 200, desc: 'Ramp up to 200 users' }, + { duration: '5m', target: 300, desc: 'Peak load at 300 users' }, + { duration: '2m', target: 100, desc: 'Scale down to 100 users' }, + { duration: '1m', target: 0, desc: 'Scale down to 0' }, + ], + thresholds: { + http_req_duration: ['p(95)<2000ms', 'p(99)<3000ms'], + http_req_failed: ['rate<0.05'], + errors: ['rate<0.05'], + }, + results: { + overall: { + total_requests: httpReqs.count || 0, + error_rate: httpFailed.rate || 0, + avg_response_time: httpDuration.avg || 0, + p95_response_time: httpDuration['p(95)'] || 0, + p99_response_time: httpDuration['p(99)'] || 0, + max_response_time: httpDuration.max || 0, + }, + per_page: perPage, + pages_tested: pages, + }, + checks_passed: data.root_group?.checks?.map((c) => ({ + name: c.name, + passes: c.passes, + fails: c.fails, + })), + }; + return { - 'performance/stress-test-summary.json': JSON.stringify(data, null, 2), + 'performance/stress-test-summary.json': JSON.stringify(summary, null, 2), }; }