test(unit): expand unit tests and fix stress test robustness
- Add comprehensive useFocusTrap tests (edge cases, disabled elements, empty containers) - Add useSwipeGesture tests (haptic feedback, touch events, disabled state, effect deps) - Enhance animations.test.tsx with Framer Motion mock and component tests - Fix stress-test.js body.length undefined error when requests fail - Fix home-content-v14.tsx for consistency - Clean up generated performance test summary files
This commit is contained in:
@@ -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"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -106,29 +106,21 @@ function HeroSection({ heroData, stats }: {
|
|||||||
|
|
||||||
<div className="max-w-container mx-auto px-4 sm:px-6 lg:px-10 relative z-10 w-full py-24 sm:py-32 md:py-40 lg:py-48">
|
<div className="max-w-container mx-auto px-4 sm:px-6 lg:px-10 relative z-10 w-full py-24 sm:py-32 md:py-40 lg:py-48">
|
||||||
<div className="max-w-4xl">
|
<div className="max-w-4xl">
|
||||||
{/* 品牌标识:Logo + 公司名 */}
|
{/* 品牌标识:Logo 本身已包含印章 + 公司名 + NOVALON */}
|
||||||
<motion.div
|
<motion.div
|
||||||
initial={{ opacity: 0, y: 20 }}
|
initial={{ opacity: 0, y: 20 }}
|
||||||
animate={{ opacity: 1, y: 0 }}
|
animate={{ opacity: 1, y: 0 }}
|
||||||
transition={{ duration: 0.6, delay: 0, ease: EASE_OUT }}
|
transition={{ duration: 0.6, delay: 0, ease: EASE_OUT }}
|
||||||
className="flex items-center gap-3 mb-8"
|
className="mb-8"
|
||||||
>
|
>
|
||||||
<Image
|
<Image
|
||||||
src="/logo.svg"
|
src="/logo.svg"
|
||||||
alt={`${COMPANY_INFO.name} Logo`}
|
alt={`${COMPANY_INFO.name} Logo`}
|
||||||
width={192}
|
width={224}
|
||||||
height={48}
|
height={56}
|
||||||
className="h-10 w-auto sm:h-12"
|
className="h-12 w-auto sm:h-14"
|
||||||
priority
|
priority
|
||||||
/>
|
/>
|
||||||
<div className="flex flex-col">
|
|
||||||
<span className="text-sm sm:text-base font-semibold text-ink leading-tight">
|
|
||||||
{COMPANY_INFO.name}
|
|
||||||
</span>
|
|
||||||
<span className="text-[10px] sm:text-xs tracking-[0.2em] text-text-muted uppercase">
|
|
||||||
NOVALON
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</motion.div>
|
</motion.div>
|
||||||
|
|
||||||
{/* 主标题:大胆、直接 */}
|
{/* 主标题:大胆、直接 */}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { useFocusTrap } from './use-focus-trap';
|
|||||||
describe('useFocusTrap', () => {
|
describe('useFocusTrap', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
jest.clearAllMocks();
|
jest.clearAllMocks();
|
||||||
|
document.body.style.overflow = 'unset';
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('Initial State', () => {
|
describe('Initial State', () => {
|
||||||
@@ -18,8 +19,14 @@ describe('useFocusTrap', () => {
|
|||||||
|
|
||||||
describe('When Active', () => {
|
describe('When Active', () => {
|
||||||
it('should store previous active element', () => {
|
it('should store previous active element', () => {
|
||||||
|
const activeElement = document.createElement('button');
|
||||||
|
document.body.appendChild(activeElement);
|
||||||
|
activeElement.focus();
|
||||||
|
|
||||||
const { result } = renderHook(() => useFocusTrap<HTMLDivElement>(true));
|
const { result } = renderHook(() => useFocusTrap<HTMLDivElement>(true));
|
||||||
expect(result.current).toBeDefined();
|
expect(result.current).toBeDefined();
|
||||||
|
|
||||||
|
document.body.removeChild(activeElement);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should add keydown event listener', () => {
|
it('should add keydown event listener', () => {
|
||||||
@@ -45,6 +52,34 @@ describe('useFocusTrap', () => {
|
|||||||
renderHook(() => useFocusTrap<HTMLDivElement>(false));
|
renderHook(() => useFocusTrap<HTMLDivElement>(false));
|
||||||
expect(document.body.style.overflow).toBe('unset');
|
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<HTMLDivElement>(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', () => {
|
describe('Cleanup', () => {
|
||||||
@@ -60,6 +95,18 @@ describe('useFocusTrap', () => {
|
|||||||
unmount();
|
unmount();
|
||||||
expect(document.body.style.overflow).toBe('unset');
|
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<HTMLDivElement>(isActive),
|
||||||
|
{ initialProps: { isActive: true } }
|
||||||
|
);
|
||||||
|
|
||||||
|
rerender({ isActive: false });
|
||||||
|
|
||||||
|
expect(removeEventListenerSpy).toHaveBeenCalledWith('keydown', expect.any(Function));
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('Tab Navigation', () => {
|
describe('Tab Navigation', () => {
|
||||||
@@ -91,6 +138,16 @@ describe('useFocusTrap', () => {
|
|||||||
|
|
||||||
expect(result.current).toBeDefined();
|
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<HTMLDivElement>(false));
|
||||||
|
|
||||||
|
// Dispatch Escape - should not crash since no handler is registered
|
||||||
|
expect(() => {
|
||||||
|
document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape' }));
|
||||||
|
}).not.toThrow();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('State Changes', () => {
|
describe('State Changes', () => {
|
||||||
@@ -158,5 +215,71 @@ describe('useFocusTrap', () => {
|
|||||||
expect(previous).toHaveFocus();
|
expect(previous).toHaveFocus();
|
||||||
document.body.removeChild(previous);
|
document.body.removeChild(previous);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('should prevent default on Escape key', () => {
|
||||||
|
render(<TrapComponent isActive />);
|
||||||
|
|
||||||
|
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<HTMLDivElement>(true);
|
||||||
|
return <div ref={ref} data-testid="empty" />;
|
||||||
|
}
|
||||||
|
|
||||||
|
render(<EmptyContainer />);
|
||||||
|
|
||||||
|
// 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<HTMLDivElement>(true);
|
||||||
|
return (
|
||||||
|
<div ref={ref}>
|
||||||
|
<button disabled>disabled</button>
|
||||||
|
<button>enabled</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
render(<ContainerWithDisabled />);
|
||||||
|
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<HTMLDivElement>(true);
|
||||||
|
return (
|
||||||
|
<div ref={ref}>
|
||||||
|
<button style={{ display: 'none' }}>hidden</button>
|
||||||
|
<button>visible</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
render(<ContainerWithHidden />);
|
||||||
|
|
||||||
|
// Only the visible button should be focusable
|
||||||
|
expect(() => {
|
||||||
|
document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Tab' }));
|
||||||
|
}).not.toThrow();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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,
|
* Create a plain Event-like object with touches array,
|
||||||
* since jsdom's TouchEvent doesn't support the touches property properly.
|
* since jsdom's TouchEvent doesn't support the touches property properly.
|
||||||
@@ -255,5 +263,171 @@ describe('useSwipeGesture', () => {
|
|||||||
|
|
||||||
removeSpy.mockRestore();
|
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);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
+237
-5
@@ -4,7 +4,7 @@ import '@testing-library/jest-dom';
|
|||||||
|
|
||||||
jest.mock('framer-motion', () => ({
|
jest.mock('framer-motion', () => ({
|
||||||
motion: {
|
motion: {
|
||||||
div: ({ children, initial, animate, variants, className, whileHover, whileTap, ...props }: any) => (
|
div: ({ children, initial, animate, variants, className, whileHover, whileTap, transition, ...props }: any) => (
|
||||||
<div
|
<div
|
||||||
data-testid="motion-div"
|
data-testid="motion-div"
|
||||||
data-initial={JSON.stringify(initial)}
|
data-initial={JSON.stringify(initial)}
|
||||||
@@ -12,6 +12,7 @@ jest.mock('framer-motion', () => ({
|
|||||||
data-variants={JSON.stringify(variants)}
|
data-variants={JSON.stringify(variants)}
|
||||||
data-while-hover={JSON.stringify(whileHover)}
|
data-while-hover={JSON.stringify(whileHover)}
|
||||||
data-while-tap={JSON.stringify(whileTap)}
|
data-while-tap={JSON.stringify(whileTap)}
|
||||||
|
data-transition={JSON.stringify(transition)}
|
||||||
className={className}
|
className={className}
|
||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
@@ -30,11 +31,13 @@ jest.mock('framer-motion', () => ({
|
|||||||
{children}
|
{children}
|
||||||
</button>
|
</button>
|
||||||
),
|
),
|
||||||
span: ({ children, className, animate, ...props }: any) => (
|
span: ({ children, className, animate, initial, transition, ...props }: any) => (
|
||||||
<span
|
<span
|
||||||
data-testid="motion-span"
|
data-testid="motion-span"
|
||||||
className={className}
|
className={className}
|
||||||
data-animate={JSON.stringify(animate)}
|
data-animate={JSON.stringify(animate)}
|
||||||
|
data-initial={JSON.stringify(initial)}
|
||||||
|
data-transition={JSON.stringify(transition)}
|
||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
@@ -51,10 +54,36 @@ jest.mock('framer-motion', () => ({
|
|||||||
path: ({ variants, ...props }: any) => (
|
path: ({ variants, ...props }: any) => (
|
||||||
<path data-testid="motion-path" data-variants={JSON.stringify(variants)} {...props} />
|
<path data-testid="motion-path" data-variants={JSON.stringify(variants)} {...props} />
|
||||||
),
|
),
|
||||||
|
a: ({ children, className, ...props }: any) => (
|
||||||
|
<a data-testid="motion-a" className={className} {...props}>
|
||||||
|
{children}
|
||||||
|
</a>
|
||||||
|
),
|
||||||
|
h1: ({ children, className, ...props }: any) => (
|
||||||
|
<h1 data-testid="motion-h1" className={className} {...props}>
|
||||||
|
{children}
|
||||||
|
</h1>
|
||||||
|
),
|
||||||
|
h2: ({ children, className, ...props }: any) => (
|
||||||
|
<h2 data-testid="motion-h2" className={className} {...props}>
|
||||||
|
{children}
|
||||||
|
</h2>
|
||||||
|
),
|
||||||
|
p: ({ children, className, ...props }: any) => (
|
||||||
|
<p data-testid="motion-p" className={className} {...props}>
|
||||||
|
{children}
|
||||||
|
</p>
|
||||||
|
),
|
||||||
},
|
},
|
||||||
|
AnimatePresence: ({ children }: any) => <>{children}</>,
|
||||||
useInView: jest.fn(() => true),
|
useInView: jest.fn(() => true),
|
||||||
useSpring: jest.fn((value) => value),
|
useSpring: jest.fn((value) => value),
|
||||||
useTransform: jest.fn((value) => value),
|
useTransform: jest.fn((value) => value),
|
||||||
|
useMotionValue: jest.fn((initial) => ({
|
||||||
|
get: () => initial,
|
||||||
|
set: jest.fn(),
|
||||||
|
onChange: jest.fn(),
|
||||||
|
})),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
describe('Animation Variants', () => {
|
describe('Animation Variants', () => {
|
||||||
@@ -533,19 +562,222 @@ describe('Animation Components', () => {
|
|||||||
it('should accept bounce effect', async () => {
|
it('should accept bounce effect', async () => {
|
||||||
const { CounterWithEffect } = await import('./animations');
|
const { CounterWithEffect } = await import('./animations');
|
||||||
render(<CounterWithEffect end={100} effect="bounce" />);
|
render(<CounterWithEffect end={100} effect="bounce" />);
|
||||||
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 () => {
|
it('should accept slide effect', async () => {
|
||||||
const { CounterWithEffect } = await import('./animations');
|
const { CounterWithEffect } = await import('./animations');
|
||||||
render(<CounterWithEffect end={100} effect="slide" />);
|
render(<CounterWithEffect end={100} effect="slide" />);
|
||||||
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 () => {
|
it('should accept flip effect', async () => {
|
||||||
const { CounterWithEffect } = await import('./animations');
|
const { CounterWithEffect } = await import('./animations');
|
||||||
render(<CounterWithEffect end={100} effect="flip" />);
|
render(<CounterWithEffect end={100} effect="flip" />);
|
||||||
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(<CounterWithEffect end={42} />);
|
||||||
|
// 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(<CountUp end={1000} duration={3000} />);
|
||||||
|
const element = screen.getByTestId('motion-span');
|
||||||
|
expect(element).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should render with prefix and suffix', async () => {
|
||||||
|
const { CountUp } = await import('./animations');
|
||||||
|
render(<CountUp end={100} prefix="$" suffix="%" />);
|
||||||
|
expect(screen.getByText(/\$/)).toBeInTheDocument();
|
||||||
|
expect(screen.getByText(/%/)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('RippleButton', () => {
|
||||||
|
it('should create ripple on click', async () => {
|
||||||
|
const { RippleButton } = await import('./animations');
|
||||||
|
render(<RippleButton>Click Me</RippleButton>);
|
||||||
|
|
||||||
|
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(<RippleButton onClick={handleClick}>Click Me</RippleButton>);
|
||||||
|
|
||||||
|
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(<RippleButton rippleColor="rgba(0, 0, 255, 0.5)">Click</RippleButton>);
|
||||||
|
|
||||||
|
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(<RotatingBorder borderWidth={4}>Test</RotatingBorder>);
|
||||||
|
expect(screen.getByText('Test')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should render with custom duration', async () => {
|
||||||
|
const { RotatingBorder } = await import('./animations');
|
||||||
|
render(<RotatingBorder duration={8}>Test</RotatingBorder>);
|
||||||
|
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(<RotatingBorder duration={4}>Test</RotatingBorder>);
|
||||||
|
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(<SplitText text="ABC" delay={0.5} />);
|
||||||
|
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(<WaveText text="ABC" delay={0.5} />);
|
||||||
|
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(<Typewriter text="Hello" speed={100} />);
|
||||||
|
const cursor = screen.getByText('|');
|
||||||
|
expect(cursor).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('GradientText', () => {
|
||||||
|
it('should render with custom colors', async () => {
|
||||||
|
const { GradientText } = await import('./animations');
|
||||||
|
render(<GradientText colors={['#ff0000', '#00ff00', '#0000ff']}>Test</GradientText>);
|
||||||
|
expect(screen.getByText('Test')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should render with animation duration', async () => {
|
||||||
|
const { GradientText } = await import('./animations');
|
||||||
|
render(<GradientText duration={5}>Test</GradientText>);
|
||||||
|
expect(screen.getByText('Test')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('ShimmerButton', () => {
|
||||||
|
it('should render with animation', async () => {
|
||||||
|
const { ShimmerButton } = await import('./animations');
|
||||||
|
render(<ShimmerButton>Shimmer</ShimmerButton>);
|
||||||
|
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(<MagneticButton strength={0.5}>Test</MagneticButton>);
|
||||||
|
expect(screen.getByText('Test')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle click events', async () => {
|
||||||
|
const { MagneticButton } = await import('./animations');
|
||||||
|
const handleClick = jest.fn();
|
||||||
|
render(<MagneticButton onClick={handleClick}>Click</MagneticButton>);
|
||||||
|
|
||||||
|
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(<BlurReveal delay={0.5}>Test</BlurReveal>);
|
||||||
|
expect(screen.getByText('Test')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('GlitchText', () => {
|
||||||
|
it('should render text correctly', async () => {
|
||||||
|
const { GlitchText } = await import('./animations');
|
||||||
|
render(<GlitchText text="Test" />);
|
||||||
|
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(<FloatingElement duration={5}>Test</FloatingElement>);
|
||||||
|
expect(screen.getByText('Test')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('PulseElement', () => {
|
||||||
|
it('should render with custom duration', async () => {
|
||||||
|
const { PulseElement } = await import('./animations');
|
||||||
|
render(<PulseElement duration={3}>Test</PulseElement>);
|
||||||
|
expect(screen.getByText('Test')).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -24,27 +24,31 @@ export const options = {
|
|||||||
|
|
||||||
const BASE_URL = __ENV.BASE_URL || 'http://localhost:3000';
|
const BASE_URL = __ENV.BASE_URL || 'http://localhost:3000';
|
||||||
|
|
||||||
|
const pages = [
|
||||||
|
'/',
|
||||||
|
'/about',
|
||||||
|
'/services',
|
||||||
|
'/products',
|
||||||
|
'/news',
|
||||||
|
'/contact',
|
||||||
|
];
|
||||||
|
|
||||||
export default function () {
|
export default function () {
|
||||||
// 80% 访问首页、20% 访问关于页面
|
const page = pages[Math.floor(Math.random() * pages.length)];
|
||||||
// 注:/api/contact 依赖外部邮件服务并存在 IP 限流,/api/health 未实现,
|
const res = http.get(`${BASE_URL}${page}`, {
|
||||||
// 因此本脚本仅验证本地营销页面的承载能力。
|
tags: { name: page },
|
||||||
const rand = Math.random();
|
});
|
||||||
|
|
||||||
let res;
|
// 记录非200响应的详细信息以便诊断混合渲染模式下的异常
|
||||||
|
if (res.status !== 200) {
|
||||||
if (rand < 0.8) {
|
const bodyLength = res.body ? res.body.length : 0;
|
||||||
res = http.get(`${BASE_URL}/`, {
|
console.warn(`[WARN] ${page} returned status ${res.status} (body length: ${bodyLength})`);
|
||||||
tags: { name: 'home' },
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
res = http.get(`${BASE_URL}/about`, {
|
|
||||||
tags: { name: 'about' },
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const success = check(res, {
|
const success = check(res, {
|
||||||
'status is 200': (r) => r.status === 200,
|
'status is 200': (r) => r.status === 200,
|
||||||
'response time < 2000ms': (r) => r.timings.duration < 2000,
|
'response time < 2000ms': (r) => r.timings.duration < 2000,
|
||||||
|
'body is not empty': (r) => r.body && r.body.length > 0,
|
||||||
});
|
});
|
||||||
|
|
||||||
errorRate.add(!success);
|
errorRate.add(!success);
|
||||||
@@ -54,7 +58,62 @@ export default function () {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function handleSummary(data) {
|
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 {
|
return {
|
||||||
'performance/stress-test-summary.json': JSON.stringify(data, null, 2),
|
'performance/stress-test-summary.json': JSON.stringify(summary, null, 2),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user