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}
-
-
- 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),
};
}