feat(api): 增强 API 测试框架的断言功能
- 修改 ApiCase 和 ApiResponse 结构,新增 Verify 方法以支持自定义校验函数 - 更新 Get、Post、Put、Delete 方法,允许使用 expect 参数进行更灵活的响应断言 - 添加 ExpectResult 方法,支持对响应结果的结构和类型进行后置校验 - 更新文档,详细说明新功能的使用方法和示例,提升测试框架的可用性 - 增强 Swagger 生成逻辑,支持输出新的 expect 和 verifyError 字段
This commit is contained in:
+104
-2
@@ -1,4 +1,4 @@
|
||||
# HoTime API 测试框架使用说明
|
||||
# HoTime API 测试框架使用说明
|
||||
|
||||
不启动 HTTP 服务、自动事务回滚、链式 API、覆盖率报告、API 调试控制台生成。
|
||||
|
||||
@@ -229,6 +229,7 @@ func (app *TestApp) DB() *HoTimeDB
|
||||
| `c.File(field, name, content)` | 设置上传文件 | `*ApiCase` |
|
||||
| `c.Note(note)` | 为本条用例添加可选备注,备注显示在调试控制台用例详情中 | `*ApiCase` |
|
||||
| `c.WithSession(s)` | 覆盖本次请求的 session | `*ApiCase` |
|
||||
| `c.Verify(fn)` | 设置请求后的自定义校验函数(详见 [Verify 数据库校验](#verify-数据库校验)) | `*ApiCase` |
|
||||
| `c.Get(desc, status, msg...)` | 终端:发送 GET 请求并断言 | `*ApiResponse` |
|
||||
| `c.Post(desc, status, msg...)` | 终端:发送 POST 请求并断言 | `*ApiResponse` |
|
||||
| `c.Put(desc, status, msg...)` | 终端:发送 PUT 请求并断言 | `*ApiResponse` |
|
||||
@@ -240,7 +241,48 @@ func (app *TestApp) DB() *HoTimeDB
|
||||
|------|------|------|
|
||||
| `desc` | string | 用例描述,作为 `t.Run` 的子测试名称 |
|
||||
| `status` | int | 期望的 JSON 响应体中 `status` 字段值(0=成功) |
|
||||
| `msg` | ...string | 可选,期望的 `msg` 字段值(传空则不校验) |
|
||||
| `expect` | ...interface{} | 可选,行为取决于 status(见下方说明) |
|
||||
|
||||
**expect 参数解析规则:**
|
||||
|
||||
- `status == 0`(成功):
|
||||
- **不传 expect** → 仅校验 status=0,不校验 result 内容(如 `a.Post("描述", 0)`)
|
||||
- 传任意类型 → result 类型/结构校验(因为成功响应没有 msg)
|
||||
- `status != 0`(错误):
|
||||
- 传 `string` → 用该字符串校验错误消息
|
||||
- **不传 expect** → 自动用 `desc`(第一参数)作为期望的错误消息(简写模式)
|
||||
- 传非 string 类型 → 按 status=0 的规则做 result 结构校验(罕见但支持)
|
||||
|
||||
**简写示例**(错误用例 desc 即 msg):
|
||||
|
||||
```go
|
||||
// 完整写法
|
||||
a.Post("密码为空", 4, "用户名或密码不能为空") // desc="密码为空", 校验 msg="用户名或密码不能为空"
|
||||
|
||||
// 简写:desc 与错误消息相同时可省略第三参数
|
||||
a.Post("用户名或密码不能为空", 4) // desc 直接作为期望 msg
|
||||
a.Get("请选择店铺", 3) // 等价于 a.Get("请选择店铺", 3, "请选择店铺")
|
||||
```
|
||||
|
||||
**result 类型/结构校验:**
|
||||
|
||||
expect 参数作为**类型样本**,只比较类型大类,不比较具体值。result 不一定是 Map,可以是任意类型:
|
||||
|
||||
| 样本值类型 | 匹配的实际类型 | 说明 |
|
||||
|-----------|--------------|------|
|
||||
| `int64` / `int` 等整数 | 整数类型 | number(整数) |
|
||||
| `float64` / `float32` | 浮点类型 | float(小数) |
|
||||
| `string` | `string` | 字符串 |
|
||||
| `bool` | `bool` | 布尔值 |
|
||||
| `Map{...}` | `Map` / `map[string]interface{}` | 对象,递归校验子字段 |
|
||||
| `Slice{...}` | `Slice` / `[]interface{}` | 数组,非空时校验首元素结构 |
|
||||
|
||||
校验规则:
|
||||
- result 是原始类型(string、int、float 等)→ 直接比较类型大类
|
||||
- result 是 Map → **多了**字段不管,**少了**预设字段报错「字段缺失」
|
||||
- 字段存在但**类型不一致** → 报错「类型不匹配」
|
||||
- Map 类型的值 → 递归验证子字段
|
||||
- Slice 类型的值且样本非空 → 取实际首元素与样本首元素递归验证
|
||||
|
||||
### ApiResponse — 响应对象
|
||||
|
||||
@@ -255,6 +297,10 @@ resp.GetMsg() // string:业务 msg
|
||||
resp.GetResult() // interface{}:result 字段
|
||||
resp.GetBody() // Map:完整响应体
|
||||
|
||||
// 后置结构校验(支持 Map、Slice、string、int64 等任意类型样本)
|
||||
resp.ExpectResult(Map{"id": int64(1), "token": "sample"})
|
||||
resp.ExpectResult("操作成功") // 校验 result 是 string 类型
|
||||
|
||||
// 自定义断言
|
||||
token := resp.GetBody().GetMap("result").GetString("token")
|
||||
if token == "" {
|
||||
@@ -314,6 +360,62 @@ resp := a.JSON(Map{"name": "138", "password": "123"}).Post("正常登录", 0)
|
||||
if resp.GetBody().GetMap("result").GetString("token") == "" {
|
||||
resp.Fail("响应中缺少 token 字段")
|
||||
}
|
||||
|
||||
// ========== Result 类型/结构校验 ==========
|
||||
|
||||
// result 是原始类型:校验 result 是 string
|
||||
a.Post("操作成功", 0, "操作成功")
|
||||
|
||||
// result 是整数
|
||||
a.Get("获取数量", 0, int64(1))
|
||||
|
||||
// result 是小数
|
||||
a.Get("获取金额", 0, float64(1.5))
|
||||
|
||||
// result 是 Map:校验字段名 + 类型
|
||||
a.Query(Map{"shop_id": "1", "id": "1"}).Get("正常获取订单", 0, Map{
|
||||
"id": int64(1), "sn": "sample", "customer_id": int64(1),
|
||||
})
|
||||
|
||||
// 嵌套对象:递归校验子字段
|
||||
a.Get("获取详情", 0, Map{
|
||||
"id": int64(1),
|
||||
"customer": Map{"name": "张三", "id": int64(1)},
|
||||
})
|
||||
|
||||
// result 直接是数组:校验 result 是 Slice 且首元素结构匹配
|
||||
a.Get("获取标签", 0, Slice{Map{"id": int64(1), "name": "test"}})
|
||||
|
||||
// Map 中包含数组字段
|
||||
a.JSON(Map{"page": 1, "pageSize": 10}).Post("获取列表", 0, Map{
|
||||
"total": int64(10),
|
||||
"data": Slice{Map{"id": int64(1), "name": "test"}},
|
||||
})
|
||||
|
||||
// 后置校验
|
||||
resp = a.Get("获取配置", 0)
|
||||
resp.ExpectResult(Map{"version": "1.0", "features": Slice{}})
|
||||
|
||||
// ========== Verify 数据库校验 ==========
|
||||
|
||||
// 请求后自动查库验证,返回 nil 表示通过,返回 error 则用例失败
|
||||
a.Form(Map{"name": "test"}).
|
||||
Verify(func(a *Api) error {
|
||||
row := a.DB().Get("order", "*", Map{"name": "test"})
|
||||
if row == nil {
|
||||
return fmt.Errorf("未找到 name=test 的记录")
|
||||
}
|
||||
if row.GetCeilInt64("state") != 0 {
|
||||
return fmt.Errorf("state 期望 0, 实际 %d", row.GetCeilInt64("state"))
|
||||
}
|
||||
// 校验关联表数量
|
||||
count := a.DB().Count("order_goods", Map{"order_id": row.GetCeilInt64("id")})
|
||||
if count < 1 {
|
||||
return fmt.Errorf("order_goods 期望至少 1 条, 实际 %d 条", count)
|
||||
}
|
||||
return nil
|
||||
}).
|
||||
Post("创建并验证DB", 0, Map{"id": int64(1)})
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
@@ -8,10 +8,11 @@ import (
|
||||
)
|
||||
|
||||
var ProjectTest = ProjTest{
|
||||
"test": TestTest,
|
||||
"mysql": MysqlTest,
|
||||
"dmdb": DmdbTest,
|
||||
"cache": CacheTest,
|
||||
"test": TestTest,
|
||||
"mysql": MysqlTest,
|
||||
"dmdb": DmdbTest,
|
||||
"cache": CacheTest,
|
||||
"expect_demo": ExpectDemoTest,
|
||||
}
|
||||
|
||||
var testApp *TestApp
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
. "code.hoteas.com/golang/hotime"
|
||||
. "code.hoteas.com/golang/hotime/common"
|
||||
)
|
||||
|
||||
var ExpectDemoTest = CtrTest{
|
||||
"string_result": {Desc: "返回字符串", Func: func(a *Api) {
|
||||
a.Get("result是字符串", 0, "操作成功")
|
||||
}},
|
||||
"number_result": {Desc: "返回整数", Func: func(a *Api) {
|
||||
a.Get("result是整数", 0, int64(1))
|
||||
}},
|
||||
"float_result": {Desc: "返回小数", Func: func(a *Api) {
|
||||
a.Get("result是小数", 0, float64(1.0))
|
||||
}},
|
||||
"bool_result": {Desc: "返回布尔", Func: func(a *Api) {
|
||||
a.Get("result是布尔", 0, true)
|
||||
}},
|
||||
"map_result": {Desc: "返回对象", Func: func(a *Api) {
|
||||
a.Get("result是Map-校验字段名和类型", 0, Map{
|
||||
"id": int64(1), "name": "sample", "price": float64(1.0), "in_stock": true,
|
||||
})
|
||||
}},
|
||||
"nested_result": {Desc: "返回嵌套对象", Func: func(a *Api) {
|
||||
a.Get("深层嵌套Map+Slice+Map", 0, Map{
|
||||
"id": int64(1), "title": "sample",
|
||||
"customer": Map{"id": int64(1), "name": "sample", "phone": "sample"},
|
||||
"items": Slice{Map{
|
||||
"id": int64(1), "goods_name": "sample", "quantity": int64(1), "price": float64(1.0),
|
||||
}},
|
||||
"extra": Map{
|
||||
"delivery": Map{"address": "sample", "fee": int64(1)},
|
||||
},
|
||||
})
|
||||
}},
|
||||
"slice_result": {Desc: "返回数组", Func: func(a *Api) {
|
||||
a.Get("result直接是Slice", 0, Slice{Map{"id": int64(1), "name": "sample"}})
|
||||
}},
|
||||
"error_demo": {Desc: "错误+成功混合", Func: func(a *Api) {
|
||||
a.Form(Map{"name": ""}).Post("名称为空-错误断言", 3, "名称不能为空")
|
||||
a.Form(Map{"name": "测试"}).Post("名称正确-结构校验", 0, Map{
|
||||
"id": int64(1), "name": "sample", "created": true,
|
||||
})
|
||||
}},
|
||||
"no_expect": {Desc: "不传预期(仅校验status)", Func: func(a *Api) {
|
||||
a.Get("只校验status=0", 0)
|
||||
}},
|
||||
"verify_demo": {Desc: "Verify数据库校验", Func: func(a *Api) {
|
||||
a.Form(Map{"name": ""}).Post("名称为空", 3, "名称不能为空")
|
||||
|
||||
a.Form(Map{"name": "verify_test"}).
|
||||
Verify(func(a *Api) error {
|
||||
row := a.DB().Get("test_batch", "*", Map{"AND": Map{"name": "verify_test", "title": "verify_demo"}})
|
||||
if row == nil {
|
||||
return fmt.Errorf("test_batch 表未找到 name=verify_test 的记录")
|
||||
}
|
||||
if row.GetCeilInt64("state") != 0 {
|
||||
return fmt.Errorf("test_batch.state 期望 0, 实际 %d", row.GetCeilInt64("state"))
|
||||
}
|
||||
return nil
|
||||
}).
|
||||
Post("写入并校验DB-正确", 0, Map{"id": int64(1), "name": "sample"})
|
||||
|
||||
a.Form(Map{"name": "verify_fail"}).
|
||||
Verify(func(a *Api) error {
|
||||
row := a.DB().Get("test_batch", "*", Map{"AND": Map{"name": "verify_fail", "title": "verify_demo"}})
|
||||
if row == nil {
|
||||
return fmt.Errorf("test_batch 表未找到 name=verify_fail 的记录")
|
||||
}
|
||||
if row.GetCeilInt64("state") != 99 {
|
||||
return fmt.Errorf("test_batch.state 期望 99, 实际 %d(接口写入的是0,这里故意校验99来演示失败效果)", row.GetCeilInt64("state"))
|
||||
}
|
||||
return nil
|
||||
}).
|
||||
Post("写入并校验DB-故意失败", 0, Map{"id": int64(1), "name": "sample"})
|
||||
}},
|
||||
}
|
||||
+5
-4
@@ -5,8 +5,9 @@ import (
|
||||
)
|
||||
|
||||
var Project = Proj{
|
||||
"test": TestCtr,
|
||||
"mysql": MysqlCtr,
|
||||
"dmdb": DmdbCtr,
|
||||
"cache": CacheCtr,
|
||||
"test": TestCtr,
|
||||
"mysql": MysqlCtr,
|
||||
"dmdb": DmdbCtr,
|
||||
"cache": CacheCtr,
|
||||
"expect_demo": ExpectDemoCtr,
|
||||
}
|
||||
|
||||
@@ -43,6 +43,67 @@ func nowFunc(that *Context) string {
|
||||
return "NOW()"
|
||||
}
|
||||
|
||||
// ExpectDemoCtr 用于展示 result 结构校验的各种场景
|
||||
var ExpectDemoCtr = Ctr{
|
||||
"string_result": func(that *Context) {
|
||||
that.Display(0, "操作成功")
|
||||
},
|
||||
"number_result": func(that *Context) {
|
||||
that.Display(0, 42)
|
||||
},
|
||||
"float_result": func(that *Context) {
|
||||
that.Display(0, 3.14)
|
||||
},
|
||||
"bool_result": func(that *Context) {
|
||||
that.Display(0, true)
|
||||
},
|
||||
"map_result": func(that *Context) {
|
||||
that.Display(0, Map{
|
||||
"id": 1, "name": "示例商品", "price": 19.9, "in_stock": true,
|
||||
})
|
||||
},
|
||||
"nested_result": func(that *Context) {
|
||||
that.Display(0, Map{
|
||||
"id": 100, "title": "测试订单",
|
||||
"customer": Map{"id": 1, "name": "张三", "phone": "13800138000"},
|
||||
"items": Slice{
|
||||
Map{"id": 1, "goods_name": "苹果", "quantity": 5, "price": 3.5},
|
||||
Map{"id": 2, "goods_name": "香蕉", "quantity": 10, "price": 2.0},
|
||||
},
|
||||
"extra": Map{
|
||||
"delivery": Map{"address": "XX路1号", "fee": 5},
|
||||
},
|
||||
})
|
||||
},
|
||||
"slice_result": func(that *Context) {
|
||||
that.Display(0, Slice{
|
||||
Map{"id": 1, "name": "标签A"},
|
||||
Map{"id": 2, "name": "标签B"},
|
||||
})
|
||||
},
|
||||
"error_demo": func(that *Context) {
|
||||
name := that.Req.FormValue("name")
|
||||
if name == "" {
|
||||
that.Display(3, "名称不能为空")
|
||||
return
|
||||
}
|
||||
that.Display(0, Map{"id": 1, "name": name, "created": true})
|
||||
},
|
||||
"no_expect": func(that *Context) {
|
||||
that.Display(0, Map{"id": 1, "data": "无预期校验"})
|
||||
},
|
||||
"verify_demo": func(that *Context) {
|
||||
name := that.Req.FormValue("name")
|
||||
if name == "" {
|
||||
that.Display(3, "名称不能为空")
|
||||
return
|
||||
}
|
||||
initTestTables(that)
|
||||
id := that.Db.Insert("test_batch", Map{"name": name, "title": "verify_demo", "state": 0})
|
||||
that.Display(0, Map{"id": id, "name": name})
|
||||
},
|
||||
}
|
||||
|
||||
// TestCtr 通用 ORM 测试控制器(数据库无关)
|
||||
var TestCtr = Ctr{
|
||||
"test": func(that *Context) {
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -53,6 +53,20 @@ input,select,textarea,button{font:inherit}pre{margin:0}
|
||||
.csc h5{color:var(--accent);margin:0 0 4px;font-size:11px;letter-spacing:.5px;display:flex;align-items:center}
|
||||
.csc h5 .cp-sm{margin-left:auto}.csc h5:not(:first-child){margin-top:8px}
|
||||
pre.j{background:#0a0a1a;padding:6px 8px;border-radius:var(--r);font-size:12px;line-height:1.4;color:#aaa;white-space:pre-wrap;word-break:break-all;max-height:260px;overflow:auto;font-family:Consolas,Monaco,monospace}
|
||||
.resp-wrap{display:flex;gap:0}.resp-main{flex:1;min-width:0}
|
||||
.resp-main .rsp-hd{display:flex;align-items:center;margin-bottom:3px;font-size:10px;color:var(--fg2);letter-spacing:.5px}
|
||||
.resp-main .rsp-hd .cp-sm{margin-left:auto}
|
||||
.exp-col-btn{flex-shrink:0;width:22px;display:flex;align-items:stretch;justify-content:center;margin:0 2px}
|
||||
.exp-col-btn button{writing-mode:vertical-rl;background:var(--accent);border:none;color:#fff;font-size:10px;padding:6px 3px;border-radius:var(--r);cursor:pointer;user-select:none;line-height:1.2;letter-spacing:1px;opacity:.85}
|
||||
.exp-col-btn button:hover{opacity:1}
|
||||
.exp-panel{flex:1;min-width:0;display:none;padding-left:6px}
|
||||
.resp-wrap.exp-on .exp-panel{display:block}
|
||||
.exp-panel .ep-hd{display:flex;align-items:center;margin-bottom:3px;font-size:10px;color:var(--fg2);letter-spacing:.5px}
|
||||
.exp-panel .ep-hd .cp-sm{margin-left:auto}
|
||||
.exp-panel pre.j{border-left:2px solid var(--accent)}
|
||||
pre.j.resp-j{max-height:calc(100vh - 280px);min-height:120px}
|
||||
.verify-err{margin-top:8px;padding:8px 10px;background:rgba(255,77,79,0.08);border:1px solid rgba(255,77,79,0.3);border-radius:var(--r);font-size:12px;color:#ff6b6b;line-height:1.5;white-space:pre-wrap;word-break:break-all}
|
||||
.verify-label{display:inline-block;background:var(--red);color:#fff;font-size:10px;padding:1px 6px;border-radius:var(--r);margin-right:8px;vertical-align:middle}
|
||||
.sec{margin-top:10px;padding-top:10px;border-top:1px solid var(--border)}.sec-title{color:var(--fg2);font-size:11px;letter-spacing:.5px;margin-bottom:4px}
|
||||
.row-hd{display:flex;align-items:center;margin-bottom:4px;padding:4px 8px;background:var(--bg2);border-radius:var(--r)}.row-hd .sec-title{flex:1;margin:0}
|
||||
.kv{display:flex;gap:4px;margin-bottom:3px;align-items:center}.kv input{flex:1;padding:4px 6px;border:1px solid var(--border);border-radius:var(--r);background:var(--bg2);color:var(--fg);font-size:12px;outline:none}
|
||||
@@ -187,12 +201,18 @@ function cases(e){
|
||||
h+='<div class="cs"><div class="csh" onclick="accordion(this)"><span class="ar'+(i===0?' o':'')+'">▶</span><span class="dot '+(c.passed?'ok':'er')+'"></span>'+esc(c.name)+'<span class="ml">'+c.method+'</span></div>';
|
||||
h+='<div class="csb'+(i===0?' o':'')+'"><div style="padding:8px 10px">';
|
||||
if(c.note)h+='<div style="color:#aaa;font-size:12px;margin-bottom:8px;line-height:1.5">备注: '+esc(c.note)+'</div>';
|
||||
if(c.verifyError)h+='<div class="verify-err"><span class="verify-label">Verify 校验失败</span>'+esc(c.verifyError)+'</div>';
|
||||
const curlId='cc'+i;const respId='cr'+i;
|
||||
h+='<div class="row-hd" style="margin-bottom:4px"><span class="sec-title">cURL</span><button class="cp-sm" onclick="event.stopPropagation();copyEl(\''+curlId+'\')">复制</button></div>';
|
||||
h+='<div class="curl" id="'+curlId+'">'+esc(caseCurl(e,c))+'</div>';
|
||||
h+='<div class="row-hd" style="margin:8px 0 4px"><span class="sec-title">响应</span><span class="'+(c.passed?'tg tg-ok':'tg tg-err')+'" style="margin-left:6px">'+(c.passed?'通过':'失败')+'</span><button class="cp-sm" onclick="event.stopPropagation();copyEl(\''+respId+'\')" style="margin-left:auto">复制</button></div>';
|
||||
if(c.response)h+='<pre class="j" id="'+respId+'">'+fj(c.response)+'</pre>';
|
||||
else h+='<span style="color:#555" id="'+respId+'">无响应</span>';
|
||||
const hasDetail=c.expect&&c.expect.result!==undefined;const expId='ce'+i;
|
||||
h+='<div class="row-hd" style="margin:8px 0 4px"><span class="sec-title">响应</span><span class="'+(c.passed?'tg tg-ok':'tg tg-err')+'" style="margin-left:6px">'+(c.passed?'通过':'失败')+'</span></div>';
|
||||
if(hasDetail){const vis=getExpVis();h+='<div class="resp-wrap'+(vis?' exp-on':'')+'">';
|
||||
h+='<div class="resp-main"><div class="rsp-hd"><span>实际响应</span><button class="cp-sm" onclick="event.stopPropagation();copyEl(\''+respId+'\')">复制</button></div>';if(c.response)h+='<pre class="j resp-j" id="'+respId+'">'+fj(c.response)+'</pre>';else h+='<span style="color:#555" id="'+respId+'">无响应</span>';h+='</div>';
|
||||
h+='<div class="exp-col-btn"><button class="exp-btn" onclick="event.stopPropagation();toggleExpect()">'+(vis?'收起 预期响应':'展开 预期响应')+'</button></div>';
|
||||
h+='<div class="exp-panel"><div class="ep-hd"><span>预期响应</span><button class="cp-sm" onclick="event.stopPropagation();copyEl(\''+expId+'\')">复制</button></div><pre class="j resp-j" id="'+expId+'">'+fj(c.expect)+'</pre></div>';
|
||||
h+='</div>';}
|
||||
else{if(c.response)h+='<pre class="j" id="'+respId+'">'+fj(c.response)+'</pre>';else h+='<span style="color:#555" id="'+respId+'">无响应</span>';}
|
||||
h+='</div></div></div>';
|
||||
}
|
||||
return h;
|
||||
@@ -330,11 +350,17 @@ function updateCasePanel(e,i){
|
||||
const c=e.cases[i];
|
||||
let h='<div style="padding:8px 10px">';
|
||||
if(c.note)h+='<div style="color:#aaa;font-size:12px;margin-bottom:8px;line-height:1.5">备注: '+esc(c.note)+'</div>';
|
||||
if(c.verifyError)h+='<div class="verify-err"><span class="verify-label">Verify 校验失败</span>'+esc(c.verifyError)+'</div>';
|
||||
h+='<div class="row-hd" style="margin-bottom:4px"><span class="sec-title">cURL</span><span class="'+(c.passed?'tg tg-ok':'tg tg-err')+'" style="margin-left:6px">'+(c.passed?'通过':'失败')+'</span><button class="cp-sm" onclick="copyEl(\'c-curl\')" style="margin-left:auto">复制</button></div>';
|
||||
h+='<div class="curl" id="c-curl">'+esc(caseCurl(e,c))+'</div>';
|
||||
h+='<div class="row-hd" style="margin:8px 0 4px"><span class="sec-title">响应</span><button class="cp-sm" onclick="copyEl(\'c-resp\')" style="margin-left:auto">复制</button></div>';
|
||||
if(c.response)h+='<pre class="j" id="c-resp">'+fj(c.response)+'</pre>';
|
||||
else h+='<span style="color:#555" id="c-resp">无响应</span>';
|
||||
const hasDetail=c.expect&&c.expect.result!==undefined;
|
||||
h+='<div class="row-hd" style="margin:8px 0 4px"><span class="sec-title">响应</span></div>';
|
||||
if(hasDetail){const vis=getExpVis();h+='<div class="resp-wrap'+(vis?' exp-on':'')+'">';
|
||||
h+='<div class="resp-main"><div class="rsp-hd"><span>实际响应</span><button class="cp-sm" onclick="copyEl(\'c-resp\')">复制</button></div>';if(c.response)h+='<pre class="j resp-j" id="c-resp">'+fj(c.response)+'</pre>';else h+='<span style="color:#555" id="c-resp">无响应</span>';h+='</div>';
|
||||
h+='<div class="exp-col-btn"><button class="exp-btn" onclick="toggleExpect()">'+(vis?'收起 预期响应':'展开 预期响应')+'</button></div>';
|
||||
h+='<div class="exp-panel"><div class="ep-hd"><span>预期响应</span><button class="cp-sm" onclick="copyEl(\'c-exp\')">复制</button></div><pre class="j resp-j" id="c-exp">'+fj(c.expect)+'</pre></div>';
|
||||
h+='</div>';}
|
||||
else{if(c.response)h+='<pre class="j" id="c-resp">'+fj(c.response)+'</pre>';else h+='<span style="color:#555" id="c-resp">无响应</span>';}
|
||||
h+='</div>';
|
||||
panel.innerHTML=h;
|
||||
}
|
||||
@@ -460,6 +486,8 @@ async function send(){
|
||||
|
||||
function copyEl(id){navigator.clipboard.writeText(document.getElementById(id).textContent).then(()=>{const b=event.target;b.textContent='已复制';setTimeout(()=>{b.textContent='复制'},1200);});}
|
||||
function cpNext(btn){let el=btn.closest('h5').nextElementSibling;let t='';while(el&&el.tagName!=='H5'){if(el.tagName==='PRE')t+=(t?'\n':'')+el.textContent;el=el.nextElementSibling;}navigator.clipboard.writeText(t).then(()=>{btn.textContent='已复制';setTimeout(()=>{btn.textContent='复制'},1200);});}
|
||||
function getExpVis(){const v=localStorage.getItem('expect_vis');return v===null||v==='1';}
|
||||
function toggleExpect(){const vis=getExpVis();const nv=!vis;localStorage.setItem('expect_vis',nv?'1':'0');document.querySelectorAll('.resp-wrap').forEach(w=>{w.classList.toggle('exp-on',nv);});document.querySelectorAll('.exp-btn').forEach(b=>{b.textContent=nv?'收起 预期响应':'展开 预期响应';});}
|
||||
function fj(o){return esc(JSON.stringify(o,null,2));}
|
||||
function fjp(o,params){
|
||||
if(!params||!params.length||typeof o!=='object'||o===null||Array.isArray(o))return fj(o);
|
||||
|
||||
+207
-32
@@ -90,24 +90,25 @@ func (a *Api) File(field, name string, content []byte) *ApiCase {
|
||||
return c
|
||||
}
|
||||
|
||||
// Get 直接发送 GET 请求(无数据场景)
|
||||
func (a *Api) Get(desc string, status int, msg ...string) *ApiResponse {
|
||||
return a.newCase().Get(desc, status, msg...)
|
||||
// Get 直接发送 GET 请求
|
||||
// expect 可选参数: string 为期望 msg,Map 为期望 result 结构(按样本值类型校验)
|
||||
func (a *Api) Get(desc string, status int, expect ...interface{}) *ApiResponse {
|
||||
return a.newCase().Get(desc, status, expect...)
|
||||
}
|
||||
|
||||
// Post 直接发送 POST 请求(无数据场景)
|
||||
func (a *Api) Post(desc string, status int, msg ...string) *ApiResponse {
|
||||
return a.newCase().Post(desc, status, msg...)
|
||||
// Post 直接发送 POST 请求
|
||||
func (a *Api) Post(desc string, status int, expect ...interface{}) *ApiResponse {
|
||||
return a.newCase().Post(desc, status, expect...)
|
||||
}
|
||||
|
||||
// Put 直接发送 PUT 请求(无数据场景)
|
||||
func (a *Api) Put(desc string, status int, msg ...string) *ApiResponse {
|
||||
return a.newCase().Put(desc, status, msg...)
|
||||
// Put 直接发送 PUT 请求
|
||||
func (a *Api) Put(desc string, status int, expect ...interface{}) *ApiResponse {
|
||||
return a.newCase().Put(desc, status, expect...)
|
||||
}
|
||||
|
||||
// Delete 直接发送 DELETE 请求(无数据场景)
|
||||
func (a *Api) Delete(desc string, status int, msg ...string) *ApiResponse {
|
||||
return a.newCase().Delete(desc, status, msg...)
|
||||
// Delete 直接发送 DELETE 请求
|
||||
func (a *Api) Delete(desc string, status int, expect ...interface{}) *ApiResponse {
|
||||
return a.newCase().Delete(desc, status, expect...)
|
||||
}
|
||||
|
||||
// Note 为下一条用例设置备注,返回可继续链式的 ApiCase
|
||||
@@ -142,6 +143,7 @@ type ApiCase struct {
|
||||
fileName string
|
||||
fileContent []byte
|
||||
note string
|
||||
verifyFn func(a *Api) error
|
||||
}
|
||||
|
||||
// JSON 叠加 JSON body
|
||||
@@ -194,27 +196,53 @@ func (c *ApiCase) Note(note string) *ApiCase {
|
||||
return c
|
||||
}
|
||||
|
||||
// Verify 设置请求后的自定义校验函数(如查库验证数据状态)
|
||||
// 函数返回 nil 表示校验通过,返回 error 则用例失败并记录错误原因
|
||||
func (c *ApiCase) Verify(fn func(a *Api) error) *ApiCase {
|
||||
c.verifyFn = fn
|
||||
return c
|
||||
}
|
||||
|
||||
// Get 终端操作:GET 请求 + 断言
|
||||
func (c *ApiCase) Get(desc string, status int, msg ...string) *ApiResponse {
|
||||
return c.execute("GET", desc, status, msg...)
|
||||
func (c *ApiCase) Get(desc string, status int, expect ...interface{}) *ApiResponse {
|
||||
return c.execute("GET", desc, status, expect...)
|
||||
}
|
||||
|
||||
// Post 终端操作:POST 请求 + 断言
|
||||
func (c *ApiCase) Post(desc string, status int, msg ...string) *ApiResponse {
|
||||
return c.execute("POST", desc, status, msg...)
|
||||
func (c *ApiCase) Post(desc string, status int, expect ...interface{}) *ApiResponse {
|
||||
return c.execute("POST", desc, status, expect...)
|
||||
}
|
||||
|
||||
// Put 终端操作:PUT 请求 + 断言
|
||||
func (c *ApiCase) Put(desc string, status int, msg ...string) *ApiResponse {
|
||||
return c.execute("PUT", desc, status, msg...)
|
||||
func (c *ApiCase) Put(desc string, status int, expect ...interface{}) *ApiResponse {
|
||||
return c.execute("PUT", desc, status, expect...)
|
||||
}
|
||||
|
||||
// Delete 终端操作:DELETE 请求 + 断言
|
||||
func (c *ApiCase) Delete(desc string, status int, msg ...string) *ApiResponse {
|
||||
return c.execute("DELETE", desc, status, msg...)
|
||||
func (c *ApiCase) Delete(desc string, status int, expect ...interface{}) *ApiResponse {
|
||||
return c.execute("DELETE", desc, status, expect...)
|
||||
}
|
||||
|
||||
func (c *ApiCase) execute(method, desc string, expectStatus int, expectMsg ...string) *ApiResponse {
|
||||
func (c *ApiCase) execute(method, desc string, expectStatus int, expectArgs ...interface{}) *ApiResponse {
|
||||
// status!=0 时: 第一个 string → msg 校验,其余 → result 结构校验
|
||||
// status==0 时: 所有参数 → result 类型/结构校验(因为成功响应没有 msg)
|
||||
var expectMsg string
|
||||
var expectResult interface{}
|
||||
var hasExpectResult bool
|
||||
for _, arg := range expectArgs {
|
||||
if s, ok := arg.(string); ok && expectStatus != 0 && expectMsg == "" {
|
||||
expectMsg = s
|
||||
continue
|
||||
}
|
||||
if !hasExpectResult {
|
||||
expectResult = arg
|
||||
hasExpectResult = true
|
||||
}
|
||||
}
|
||||
if expectStatus != 0 && expectMsg == "" {
|
||||
expectMsg = desc
|
||||
}
|
||||
|
||||
t := c.api.t
|
||||
var resp *ApiResponse
|
||||
|
||||
@@ -229,7 +257,11 @@ func (c *ApiCase) execute(method, desc string, expectStatus int, expectMsg ...st
|
||||
|
||||
body := Map{}
|
||||
if len(w.Body.Bytes()) > 0 {
|
||||
_ = json.Unmarshal(w.Body.Bytes(), &body)
|
||||
if obj, err := JsonToObj(w.Body.String()); err == nil {
|
||||
if m, ok := obj.(map[string]interface{}); ok {
|
||||
body = Map(m)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
resp = &ApiResponse{
|
||||
@@ -246,22 +278,38 @@ func (c *ApiCase) execute(method, desc string, expectStatus int, expectMsg ...st
|
||||
passed = false
|
||||
}
|
||||
|
||||
if passed && len(expectMsg) > 0 && expectMsg[0] != "" {
|
||||
if passed && expectMsg != "" {
|
||||
actualMsg := resp.GetMsg()
|
||||
if actualMsg != expectMsg[0] {
|
||||
t.Errorf("消息不匹配: 期望 %q, 实际 %q\n响应: %s", expectMsg[0], actualMsg, w.Body.String())
|
||||
if actualMsg != expectMsg {
|
||||
t.Errorf("消息不匹配: 期望 %q, 实际 %q\n响应: %s", expectMsg, actualMsg, w.Body.String())
|
||||
passed = false
|
||||
}
|
||||
}
|
||||
|
||||
if passed && hasExpectResult {
|
||||
if !validateShape(body["result"], expectResult, "result", t) {
|
||||
passed = false
|
||||
}
|
||||
}
|
||||
|
||||
var verifyError string
|
||||
if passed && c.verifyFn != nil {
|
||||
if err := c.verifyFn(c.api); err != nil {
|
||||
t.Errorf("Verify 校验失败: %s", err.Error())
|
||||
passed = false
|
||||
verifyError = err.Error()
|
||||
}
|
||||
}
|
||||
|
||||
record := TestRecord{
|
||||
Path: c.api.path,
|
||||
Desc: desc,
|
||||
CaseName: desc,
|
||||
Passed: passed,
|
||||
Duration: duration,
|
||||
Method: method,
|
||||
Note: c.note,
|
||||
Path: c.api.path,
|
||||
Desc: desc,
|
||||
CaseName: desc,
|
||||
Passed: passed,
|
||||
Duration: duration,
|
||||
Method: method,
|
||||
Note: c.note,
|
||||
VerifyError: verifyError,
|
||||
}
|
||||
if c.query != nil {
|
||||
record.Query = c.query
|
||||
@@ -279,6 +327,21 @@ func (c *ApiCase) execute(method, desc string, expectStatus int, expectMsg ...st
|
||||
if len(body) > 0 {
|
||||
record.ResponseBody = body
|
||||
}
|
||||
expectResp := Map{"status": expectStatus}
|
||||
if expectStatus != 0 {
|
||||
if expectMsg != "" {
|
||||
errorInfo := Map{"msg": expectMsg}
|
||||
errorType := c.api.app.Config.GetMap("error").GetString(ObjToStr(expectStatus))
|
||||
if errorType != "" {
|
||||
errorInfo["type"] = errorType
|
||||
}
|
||||
expectResp["result"] = errorInfo
|
||||
expectResp["error"] = errorInfo
|
||||
}
|
||||
} else if hasExpectResult {
|
||||
expectResp["result"] = expectResult
|
||||
}
|
||||
record.ExpectResult = expectResp
|
||||
c.api.app.collector.Add(record)
|
||||
})
|
||||
|
||||
@@ -390,3 +453,115 @@ func (r *ApiResponse) Fail(msg string) {
|
||||
fmt.Printf("自定义断言失败: %s\n", msg)
|
||||
}
|
||||
}
|
||||
|
||||
// ExpectResult 后置校验 result 结构,expected 作为类型样本(只比类型,不比值)
|
||||
// expected 可以是 Map(校验字段)、Slice(校验数组)、或 string/int64/float64 等原始类型
|
||||
//
|
||||
// res.ExpectResult(Map{"id": int64(1), "name": "样本"})
|
||||
// res.ExpectResult("操作成功") // 校验 result 是 string 类型
|
||||
// res.ExpectResult(Slice{Map{"id": int64(1)}}) // 校验 result 是数组
|
||||
func (r *ApiResponse) ExpectResult(expected interface{}) *ApiResponse {
|
||||
if !validateShape(r.Body["result"], expected, "result", r.t) && r.t == nil {
|
||||
fmt.Printf("ExpectResult 校验失败\n")
|
||||
}
|
||||
return r
|
||||
}
|
||||
|
||||
// === 结构校验 ===
|
||||
|
||||
// validateShape 递归校验 actual 是否符合 expected 的结构(字段名+类型大类)
|
||||
func validateShape(actual, expected interface{}, path string, t *testing.T) bool {
|
||||
if expected == nil {
|
||||
return true
|
||||
}
|
||||
if actual == nil {
|
||||
if t != nil {
|
||||
t.Errorf("字段 %s 缺失", path)
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
switch exp := expected.(type) {
|
||||
case Map:
|
||||
return validateMapShape(actual, exp, path, t)
|
||||
case map[string]interface{}:
|
||||
return validateMapShape(actual, Map(exp), path, t)
|
||||
case Slice:
|
||||
return validateSliceShape(actual, exp, path, t)
|
||||
case []interface{}:
|
||||
return validateSliceShape(actual, Slice(exp), path, t)
|
||||
default:
|
||||
if !sameTypeCategory(actual, expected) {
|
||||
if t != nil {
|
||||
t.Errorf("字段 %s 类型不匹配: 期望 %s, 实际 %s(%v)",
|
||||
path, typeCategory(expected), typeCategory(actual), actual)
|
||||
}
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
func validateMapShape(actual interface{}, exp Map, path string, t *testing.T) bool {
|
||||
actualMap := ObjToMap(actual)
|
||||
if actualMap == nil {
|
||||
if t != nil {
|
||||
t.Errorf("字段 %s 类型不匹配: 期望 map, 实际 %s", path, typeCategory(actual))
|
||||
}
|
||||
return false
|
||||
}
|
||||
ok := true
|
||||
for field, expectedVal := range exp {
|
||||
fieldPath := path + "." + field
|
||||
actualVal, exists := actualMap[field]
|
||||
if !exists {
|
||||
if t != nil {
|
||||
t.Errorf("字段 %s 缺失", fieldPath)
|
||||
}
|
||||
ok = false
|
||||
continue
|
||||
}
|
||||
if !validateShape(actualVal, expectedVal, fieldPath, t) {
|
||||
ok = false
|
||||
}
|
||||
}
|
||||
return ok
|
||||
}
|
||||
|
||||
func validateSliceShape(actual interface{}, exp Slice, path string, t *testing.T) bool {
|
||||
actualSlice := ObjToSlice(actual)
|
||||
if actualSlice == nil {
|
||||
if t != nil {
|
||||
t.Errorf("字段 %s 类型不匹配: 期望 slice, 实际 %s", path, typeCategory(actual))
|
||||
}
|
||||
return false
|
||||
}
|
||||
if len(exp) > 0 && len(actualSlice) > 0 {
|
||||
return validateShape(actualSlice[0], exp[0], path+"[0]", t)
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func sameTypeCategory(a, b interface{}) bool {
|
||||
return typeCategory(a) == typeCategory(b)
|
||||
}
|
||||
|
||||
func typeCategory(v interface{}) string {
|
||||
switch v.(type) {
|
||||
case int, int8, int16, int32, int64,
|
||||
uint, uint8, uint16, uint32, uint64:
|
||||
return "number"
|
||||
case float32, float64:
|
||||
return "float"
|
||||
case string:
|
||||
return "string"
|
||||
case bool:
|
||||
return "bool"
|
||||
case Map, map[string]interface{}:
|
||||
return "map"
|
||||
case Slice, []interface{}:
|
||||
return "slice"
|
||||
default:
|
||||
return fmt.Sprintf("%T", v)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -59,6 +59,8 @@ type TestRecord struct {
|
||||
FileField string
|
||||
ResponseBody map[string]interface{}
|
||||
Note string
|
||||
ExpectResult interface{}
|
||||
VerifyError string
|
||||
}
|
||||
|
||||
// TestCollector 线程安全的测试记录收集器
|
||||
|
||||
+50
-20
@@ -12,16 +12,18 @@ import (
|
||||
)
|
||||
|
||||
type swaggerTestCase struct {
|
||||
Name string `json:"name"`
|
||||
Note string `json:"note,omitempty"`
|
||||
Method string `json:"method"`
|
||||
Passed bool `json:"passed"`
|
||||
Query map[string]interface{} `json:"query,omitempty"`
|
||||
Json interface{} `json:"json,omitempty"`
|
||||
Form map[string]interface{} `json:"form,omitempty"`
|
||||
HasFile bool `json:"hasFile,omitempty"`
|
||||
FileField string `json:"fileField,omitempty"`
|
||||
Response map[string]interface{} `json:"response,omitempty"`
|
||||
Name string `json:"name"`
|
||||
Note string `json:"note,omitempty"`
|
||||
Method string `json:"method"`
|
||||
Passed bool `json:"passed"`
|
||||
Query map[string]interface{} `json:"query,omitempty"`
|
||||
Json interface{} `json:"json,omitempty"`
|
||||
Form map[string]interface{} `json:"form,omitempty"`
|
||||
HasFile bool `json:"hasFile,omitempty"`
|
||||
FileField string `json:"fileField,omitempty"`
|
||||
Response map[string]interface{} `json:"response,omitempty"`
|
||||
Expect interface{} `json:"expect,omitempty"`
|
||||
VerifyError string `json:"verifyError,omitempty"`
|
||||
}
|
||||
|
||||
type paramSpec struct {
|
||||
@@ -228,10 +230,10 @@ func (that *TestApp) GenerateSwagger(title, version, outputDir string) error {
|
||||
|
||||
var cases []swaggerTestCase
|
||||
for _, r := range recs {
|
||||
tc := swaggerTestCase{
|
||||
Name: r.CaseName, Note: r.Note, Method: r.Method, Passed: r.Passed,
|
||||
Response: r.ResponseBody,
|
||||
}
|
||||
tc := swaggerTestCase{
|
||||
Name: r.CaseName, Note: r.Note, Method: r.Method, Passed: r.Passed,
|
||||
Response: r.ResponseBody, Expect: r.ExpectResult, VerifyError: r.VerifyError,
|
||||
}
|
||||
if r.Query != nil {
|
||||
tc.Query = r.Query
|
||||
}
|
||||
@@ -401,6 +403,20 @@ input,select,textarea,button{font:inherit}pre{margin:0}
|
||||
.csc h5{color:var(--accent);margin:0 0 4px;font-size:11px;letter-spacing:.5px;display:flex;align-items:center}
|
||||
.csc h5 .cp-sm{margin-left:auto}.csc h5:not(:first-child){margin-top:8px}
|
||||
pre.j{background:#0a0a1a;padding:6px 8px;border-radius:var(--r);font-size:12px;line-height:1.4;color:#aaa;white-space:pre-wrap;word-break:break-all;max-height:260px;overflow:auto;font-family:Consolas,Monaco,monospace}
|
||||
.resp-wrap{display:flex;gap:0}.resp-main{flex:1;min-width:0}
|
||||
.resp-main .rsp-hd{display:flex;align-items:center;margin-bottom:3px;font-size:10px;color:var(--fg2);letter-spacing:.5px}
|
||||
.resp-main .rsp-hd .cp-sm{margin-left:auto}
|
||||
.exp-col-btn{flex-shrink:0;width:22px;display:flex;align-items:stretch;justify-content:center;margin:0 2px}
|
||||
.exp-col-btn button{writing-mode:vertical-rl;background:var(--accent);border:none;color:#fff;font-size:10px;padding:6px 3px;border-radius:var(--r);cursor:pointer;user-select:none;line-height:1.2;letter-spacing:1px;opacity:.85}
|
||||
.exp-col-btn button:hover{opacity:1}
|
||||
.exp-panel{flex:1;min-width:0;display:none;padding-left:6px}
|
||||
.resp-wrap.exp-on .exp-panel{display:block}
|
||||
.exp-panel .ep-hd{display:flex;align-items:center;margin-bottom:3px;font-size:10px;color:var(--fg2);letter-spacing:.5px}
|
||||
.exp-panel .ep-hd .cp-sm{margin-left:auto}
|
||||
.exp-panel pre.j{border-left:2px solid var(--accent)}
|
||||
pre.j.resp-j{max-height:calc(100vh - 280px);min-height:120px}
|
||||
.verify-err{margin-top:8px;padding:8px 10px;background:rgba(255,77,79,0.08);border:1px solid rgba(255,77,79,0.3);border-radius:var(--r);font-size:12px;color:#ff6b6b;line-height:1.5;white-space:pre-wrap;word-break:break-all}
|
||||
.verify-label{display:inline-block;background:var(--red);color:#fff;font-size:10px;padding:1px 6px;border-radius:var(--r);margin-right:8px;vertical-align:middle}
|
||||
.sec{margin-top:10px;padding-top:10px;border-top:1px solid var(--border)}.sec-title{color:var(--fg2);font-size:11px;letter-spacing:.5px;margin-bottom:4px}
|
||||
.row-hd{display:flex;align-items:center;margin-bottom:4px;padding:4px 8px;background:var(--bg2);border-radius:var(--r)}.row-hd .sec-title{flex:1;margin:0}
|
||||
.kv{display:flex;gap:4px;margin-bottom:3px;align-items:center}.kv input{flex:1;padding:4px 6px;border:1px solid var(--border);border-radius:var(--r);background:var(--bg2);color:var(--fg);font-size:12px;outline:none}
|
||||
@@ -535,12 +551,18 @@ function cases(e){
|
||||
h+='<div class="cs"><div class="csh" onclick="accordion(this)"><span class="ar'+(i===0?' o':'')+'">▶</span><span class="dot '+(c.passed?'ok':'er')+'"></span>'+esc(c.name)+'<span class="ml">'+c.method+'</span></div>';
|
||||
h+='<div class="csb'+(i===0?' o':'')+'"><div style="padding:8px 10px">';
|
||||
if(c.note)h+='<div style="color:#aaa;font-size:12px;margin-bottom:8px;line-height:1.5">备注: '+esc(c.note)+'</div>';
|
||||
if(c.verifyError)h+='<div class="verify-err"><span class="verify-label">Verify 校验失败</span>'+esc(c.verifyError)+'</div>';
|
||||
const curlId='cc'+i;const respId='cr'+i;
|
||||
h+='<div class="row-hd" style="margin-bottom:4px"><span class="sec-title">cURL</span><button class="cp-sm" onclick="event.stopPropagation();copyEl(\''+curlId+'\')">复制</button></div>';
|
||||
h+='<div class="curl" id="'+curlId+'">'+esc(caseCurl(e,c))+'</div>';
|
||||
h+='<div class="row-hd" style="margin:8px 0 4px"><span class="sec-title">响应</span><span class="'+(c.passed?'tg tg-ok':'tg tg-err')+'" style="margin-left:6px">'+(c.passed?'通过':'失败')+'</span><button class="cp-sm" onclick="event.stopPropagation();copyEl(\''+respId+'\')" style="margin-left:auto">复制</button></div>';
|
||||
if(c.response)h+='<pre class="j" id="'+respId+'">'+fj(c.response)+'</pre>';
|
||||
else h+='<span style="color:#555" id="'+respId+'">无响应</span>';
|
||||
const hasDetail=c.expect&&c.expect.result!==undefined;const expId='ce'+i;
|
||||
h+='<div class="row-hd" style="margin:8px 0 4px"><span class="sec-title">响应</span><span class="'+(c.passed?'tg tg-ok':'tg tg-err')+'" style="margin-left:6px">'+(c.passed?'通过':'失败')+'</span></div>';
|
||||
if(hasDetail){const vis=getExpVis();h+='<div class="resp-wrap'+(vis?' exp-on':'')+'">';
|
||||
h+='<div class="resp-main"><div class="rsp-hd"><span>实际响应</span><button class="cp-sm" onclick="event.stopPropagation();copyEl(\''+respId+'\')">复制</button></div>';if(c.response)h+='<pre class="j resp-j" id="'+respId+'">'+fj(c.response)+'</pre>';else h+='<span style="color:#555" id="'+respId+'">无响应</span>';h+='</div>';
|
||||
h+='<div class="exp-col-btn"><button class="exp-btn" onclick="event.stopPropagation();toggleExpect()">'+(vis?'收起 预期响应':'展开 预期响应')+'</button></div>';
|
||||
h+='<div class="exp-panel"><div class="ep-hd"><span>预期响应</span><button class="cp-sm" onclick="event.stopPropagation();copyEl(\''+expId+'\')">复制</button></div><pre class="j resp-j" id="'+expId+'">'+fj(c.expect)+'</pre></div>';
|
||||
h+='</div>';}
|
||||
else{if(c.response)h+='<pre class="j" id="'+respId+'">'+fj(c.response)+'</pre>';else h+='<span style="color:#555" id="'+respId+'">无响应</span>';}
|
||||
h+='</div></div></div>';
|
||||
}
|
||||
return h;
|
||||
@@ -678,11 +700,17 @@ function updateCasePanel(e,i){
|
||||
const c=e.cases[i];
|
||||
let h='<div style="padding:8px 10px">';
|
||||
if(c.note)h+='<div style="color:#aaa;font-size:12px;margin-bottom:8px;line-height:1.5">备注: '+esc(c.note)+'</div>';
|
||||
if(c.verifyError)h+='<div class="verify-err"><span class="verify-label">Verify 校验失败</span>'+esc(c.verifyError)+'</div>';
|
||||
h+='<div class="row-hd" style="margin-bottom:4px"><span class="sec-title">cURL</span><span class="'+(c.passed?'tg tg-ok':'tg tg-err')+'" style="margin-left:6px">'+(c.passed?'通过':'失败')+'</span><button class="cp-sm" onclick="copyEl(\'c-curl\')" style="margin-left:auto">复制</button></div>';
|
||||
h+='<div class="curl" id="c-curl">'+esc(caseCurl(e,c))+'</div>';
|
||||
h+='<div class="row-hd" style="margin:8px 0 4px"><span class="sec-title">响应</span><button class="cp-sm" onclick="copyEl(\'c-resp\')" style="margin-left:auto">复制</button></div>';
|
||||
if(c.response)h+='<pre class="j" id="c-resp">'+fj(c.response)+'</pre>';
|
||||
else h+='<span style="color:#555" id="c-resp">无响应</span>';
|
||||
const hasDetail=c.expect&&c.expect.result!==undefined;
|
||||
h+='<div class="row-hd" style="margin:8px 0 4px"><span class="sec-title">响应</span></div>';
|
||||
if(hasDetail){const vis=getExpVis();h+='<div class="resp-wrap'+(vis?' exp-on':'')+'">';
|
||||
h+='<div class="resp-main"><div class="rsp-hd"><span>实际响应</span><button class="cp-sm" onclick="copyEl(\'c-resp\')">复制</button></div>';if(c.response)h+='<pre class="j resp-j" id="c-resp">'+fj(c.response)+'</pre>';else h+='<span style="color:#555" id="c-resp">无响应</span>';h+='</div>';
|
||||
h+='<div class="exp-col-btn"><button class="exp-btn" onclick="toggleExpect()">'+(vis?'收起 预期响应':'展开 预期响应')+'</button></div>';
|
||||
h+='<div class="exp-panel"><div class="ep-hd"><span>预期响应</span><button class="cp-sm" onclick="copyEl(\'c-exp\')">复制</button></div><pre class="j resp-j" id="c-exp">'+fj(c.expect)+'</pre></div>';
|
||||
h+='</div>';}
|
||||
else{if(c.response)h+='<pre class="j" id="c-resp">'+fj(c.response)+'</pre>';else h+='<span style="color:#555" id="c-resp">无响应</span>';}
|
||||
h+='</div>';
|
||||
panel.innerHTML=h;
|
||||
}
|
||||
@@ -808,6 +836,8 @@ async function send(){
|
||||
|
||||
function copyEl(id){navigator.clipboard.writeText(document.getElementById(id).textContent).then(()=>{const b=event.target;b.textContent='已复制';setTimeout(()=>{b.textContent='复制'},1200);});}
|
||||
function cpNext(btn){let el=btn.closest('h5').nextElementSibling;let t='';while(el&&el.tagName!=='H5'){if(el.tagName==='PRE')t+=(t?'\n':'')+el.textContent;el=el.nextElementSibling;}navigator.clipboard.writeText(t).then(()=>{btn.textContent='已复制';setTimeout(()=>{btn.textContent='复制'},1200);});}
|
||||
function getExpVis(){const v=localStorage.getItem('expect_vis');return v===null||v==='1';}
|
||||
function toggleExpect(){const vis=getExpVis();const nv=!vis;localStorage.setItem('expect_vis',nv?'1':'0');document.querySelectorAll('.resp-wrap').forEach(w=>{w.classList.toggle('exp-on',nv);});document.querySelectorAll('.exp-btn').forEach(b=>{b.textContent=nv?'收起 预期响应':'展开 预期响应';});}
|
||||
function fj(o){return esc(JSON.stringify(o,null,2));}
|
||||
function fjp(o,params){
|
||||
if(!params||!params.length||typeof o!=='object'||o===null||Array.isArray(o))return fj(o);
|
||||
|
||||
Reference in New Issue
Block a user