一、PC 端图表文字被拉伸(bug)
.spark 折线图把 viewBox 写死成 "0 0 300 48" 又带 preserveAspectRatio="none":
移动端元素宽≈300px 时缩放≈1 看不出问题,PC 上卡片宽约 1030px,横轴被放大 3.4 倍
而纵轴仍是 48px,图里的 <text> 被整体拉扁。现在按元素实际宽度算 viewBox 宽度
(取不到时回落 300),并监听 resize 重画;preserveAspectRatio 保留不影响 1:1 显示。
二、新增「转码」分区(transcode.go,第七个 tab)
原来只有「录播 + 弹幕 → 成片」一条路,已有成片想压小、换 HEVC、降分辨率只能手搓命令行。
现在从文件列表多选加入,每个文件一行独立配置:编码器、码率控制(跟随源片 / 目标码率 /
质量 CRF·ICQ / 源片百分比)、编码速度、输出帧率、分辨率(只列不高于源片的档)、
音频(拷贝 / 128k / 192k / 去掉),并实时预估产物大小与整批合计。
- POST /api/transcode/probe | estimate | transcode,都在既有的口令鉴权之后
- 目标码率模式按「码率 × 时长」出准数;质量模式用单调系数表插值并标注是粗估
- 落盘沿用合并那套:先在源文件所在目录生成,完成后再 placeOutput 到输出目录,
失败或中断删临时文件,不留半成品
- 与合并共用同一张进度卡片和任务位(mergeSlot),串行排队,可暂停 / 停止
三、手动合并改成「一次排多个任务」
原来一次只能合一场。现在多选视频后按「文件名里的录制时间 + 主体名」自动归组
(相邻两段间隔 2 小时内算同一场),每组一个任务卡,各自配弹幕、偏移、输出名和
合成参数(码率 / 帧率 / 字号 / 编码器),可拆组、可移除;服务端串行跑,逐个回传结果。
请求体从 {videos,danmu,...} 扩成 {jobs:[{...}]},老的单任务写法继续可用。
四、审阅中一并修掉 / 加固的点
- 容器总码率含音频,直接当视频码率用会错两处:预估体积时又加一遍音轨(算成双份),
「跟随源片」时把音轨码率也编进视频里。现在分开记「视频流码率」与「容器总码率」,
转码侧按「视频流码率 → 容器码率 − 音轨 → 体积 ÷ 时长 − 音轨」三级取值;
合并的自动码率仍按老口径(容器含音频),单独用 streamOrContainerKbps 锁住
- 源片本来没有音轨时,预估不再凭空加 128 kbps 音频
- 目标码率加上界(200 Mbps),单位写错的数在开跑前就被挡住(合并的逐任务码率同理)
- 探测结果加缓存(ffmpeg + 路径 + 体积 + 修改时间),体积估算跟着输入框重发时不再
每次都起一个 ffmpeg;文件一变(录制中追加、换了文件)缓存自然失效
- 只有音轨 / 只有封面图的文件、以及正在写入的文件,排队时就被挡住并说清原因
- probeMedia / parseMediaInfo 合并了原先重复的两套解析(probeDuration 与
probeVideoStream 各起一次 ffmpeg 解析同一段输出),burnDanmu 现在一次探测拿全
- 前端:估算参数非法时把服务端给的原因显示在合计行(原来静默不动,像是没改成功);
估算请求加序号,慢的旧回包不再覆盖新结果;转码下拉刷新时保留已勾选项;
跑完的行标「完成 / 失败」并淡化;删掉没有对应元素、从未使用的 CSS 规则
验证
- gofmt / go vet 干净;go build(windows)与 GOOS=linux go build 均通过
- go test ./... 20.9s 全过;go test -race ./... 26.1s 全过(182 → 206 个用例,
transcode_test.go 新增 20 个)
- 反向验证 C:\OtherFiles\acf-test\reverse_check.py 从 37 项加到 43 项,全过
- 51899 隔离实例 + 真 ffmpeg 冒烟 29 项全过:401 鉴权、probe 的 hasVideo/hasAudio、
ts 容器(不带每流码率)探测到的视频码率 = 容器 429 − 音轨 127、
HEVC 按 0.65 折算、参数非法 400 带原因、只有音轨的文件排队即被拒、
输出目录不存在被拒、两任务队列真转成功并落到指定目录、缩放成片高度确实是 240、
日志里带「第 n/N 个」和参数摘要
- 页面 jsdom 冒烟 37 项全过、无 JS 报错(分组 / 拆组 / 逐任务参数 / 转码行渲染 /
跳过无视频轨文件 / 估算失败提示 / 结果徽标)
448 lines
14 KiB
Go
448 lines
14 KiB
Go
// web UI 相关
|
||
package main
|
||
|
||
import (
|
||
"bytes"
|
||
"context"
|
||
"encoding/json"
|
||
"fmt"
|
||
"net/http"
|
||
"net/http/httptest"
|
||
"net/url"
|
||
"path/filepath"
|
||
"regexp"
|
||
"testing"
|
||
"time"
|
||
)
|
||
|
||
// 测试期间换一个管理口令
|
||
func withAuthToken(t *testing.T, token string) {
|
||
t.Helper()
|
||
streamers.Lock()
|
||
old := config.AuthToken
|
||
config.AuthToken = token
|
||
streamers.Unlock()
|
||
t.Cleanup(func() {
|
||
streamers.Lock()
|
||
config.AuthToken = old
|
||
streamers.Unlock()
|
||
})
|
||
}
|
||
|
||
// 页面上发给服务端的 X-Auth-Token 是 encodeURIComponent 过的口令,
|
||
// 这里用 url.QueryEscape 造出同样的形状(对中文而言两者产出一致)
|
||
func pageHeader(r *http.Request, token string) *http.Request {
|
||
r.Header.Set("X-Auth-Token", url.QueryEscape(token))
|
||
return r
|
||
}
|
||
|
||
func TestCheckAuthTokenPlainASCII(t *testing.T) {
|
||
withAuthToken(t, "a1b2c3d4e5f6")
|
||
|
||
r := httptest.NewRequest(http.MethodGet, "/api/status", nil)
|
||
r.Header.Set("X-Auth-Token", "a1b2c3d4e5f6")
|
||
if !checkAuthToken(r) {
|
||
t.Fatal("ASCII 口令应该通过")
|
||
}
|
||
|
||
// 带 % 的口令:页面会编码成 %25,服务端解码后要能对上
|
||
withAuthToken(t, "ab%20cd")
|
||
r = pageHeader(httptest.NewRequest(http.MethodGet, "/api/status", nil), "ab%20cd")
|
||
if r.Header.Get("X-Auth-Token") != "ab%2520cd" {
|
||
t.Fatalf("编码结果不对:%q", r.Header.Get("X-Auth-Token"))
|
||
}
|
||
if !checkAuthToken(r) {
|
||
t.Fatal("含 % 的口令应该通过")
|
||
}
|
||
}
|
||
|
||
func TestCheckAuthTokenNonASCII(t *testing.T) {
|
||
// 中文口令是最容易踩的:原先直接把中文塞进请求头,浏览器会抛
|
||
// TypeError(Failed to read the 'headers' property…),请求根本发不出去,
|
||
// 页面上只显示「无法连接到服务器」,用户被彻底锁在门外
|
||
withAuthToken(t, "我的口令abc123")
|
||
|
||
r := pageHeader(httptest.NewRequest(http.MethodGet, "/api/status", nil), "我的口令abc123")
|
||
if r.Header.Get("X-Auth-Token") == "我的口令abc123" {
|
||
t.Fatal("测试前提不成立:头里不该是原文")
|
||
}
|
||
if !checkAuthToken(r) {
|
||
t.Fatal("编码后的中文口令应该通过")
|
||
}
|
||
|
||
// 手写 curl 按文档原样发中文口令,也要能通过
|
||
r = httptest.NewRequest(http.MethodGet, "/api/status", nil)
|
||
r.Header.Set("X-Auth-Token", "我的口令abc123")
|
||
if !checkAuthToken(r) {
|
||
t.Fatal("原样的中文口令也应该通过")
|
||
}
|
||
}
|
||
|
||
func TestCheckAuthTokenAllSources(t *testing.T) {
|
||
withAuthToken(t, "口令abc123")
|
||
|
||
// Authorization: Bearer
|
||
r := httptest.NewRequest(http.MethodGet, "/api/status", nil)
|
||
r.Header.Set("Authorization", "Bearer "+url.QueryEscape("口令abc123"))
|
||
if !checkAuthToken(r) {
|
||
t.Fatal("Bearer 写法应该通过")
|
||
}
|
||
|
||
// ?token=(net/http 已经把查询串解码好了)
|
||
r = httptest.NewRequest(http.MethodGet, "/api/status?token="+url.QueryEscape("口令abc123"), nil)
|
||
if !checkAuthToken(r) {
|
||
t.Fatal("查询参数写法应该通过")
|
||
}
|
||
|
||
// 下载链接里的 ?token= 也是这个路径,混着 % 的口令同样要对上
|
||
withAuthToken(t, "口令%20abc")
|
||
r = httptest.NewRequest(http.MethodGet, "/api/file?token="+url.QueryEscape("口令%20abc"), nil)
|
||
if !checkAuthToken(r) {
|
||
t.Fatal("查询参数里的混合口令应该通过")
|
||
}
|
||
}
|
||
|
||
func TestCheckAuthTokenRejects(t *testing.T) {
|
||
withAuthToken(t, "正确的口令")
|
||
|
||
cases := map[string]*http.Request{
|
||
"口令错了": func() *http.Request {
|
||
return pageHeader(httptest.NewRequest(http.MethodGet, "/api/status", nil), "错误的口令")
|
||
}(),
|
||
"没带口令": httptest.NewRequest(http.MethodGet, "/api/status", nil),
|
||
"空口令头": func() *http.Request {
|
||
r := httptest.NewRequest(http.MethodGet, "/api/status", nil)
|
||
r.Header.Set("X-Auth-Token", "")
|
||
return r
|
||
}(),
|
||
"编码了但不是口令": func() *http.Request {
|
||
r := httptest.NewRequest(http.MethodGet, "/api/status", nil)
|
||
r.Header.Set("X-Auth-Token", "%E5%8F%A3%E4%BB%A4")
|
||
return r
|
||
}(),
|
||
}
|
||
for name, r := range cases {
|
||
if checkAuthToken(r) {
|
||
t.Fatalf("%s:不该通过", name)
|
||
}
|
||
}
|
||
|
||
// 口令没配时一律拒绝,不能因为「都是空」就放行
|
||
withAuthToken(t, "")
|
||
r := httptest.NewRequest(http.MethodGet, "/api/status", nil)
|
||
r.Header.Set("X-Auth-Token", "")
|
||
if checkAuthToken(r) {
|
||
t.Fatal("口令为空时不该通过")
|
||
}
|
||
}
|
||
|
||
func TestAuthTokenCandidatesOrder(t *testing.T) {
|
||
// 三种写法同时出现时按 Bearer → X-Auth-Token → ?token= 的顺序给候选,
|
||
// 顺序本身不影响结果,但别写出重复项(重复比较纯属浪费)
|
||
r := httptest.NewRequest(http.MethodGet, "/api/status?token=qqq", nil)
|
||
r.Header.Set("Authorization", "Bearer aaa")
|
||
r.Header.Set("X-Auth-Token", "bbb")
|
||
got := authTokenCandidates(r)
|
||
want := []string{"aaa", "bbb", "qqq"}
|
||
if len(got) != len(want) {
|
||
t.Fatalf("候选数量不对:%q", got)
|
||
}
|
||
for i := range want {
|
||
if got[i] != want[i] {
|
||
t.Fatalf("候选顺序不对:%q,想要 %q", got, want)
|
||
}
|
||
}
|
||
|
||
// Authorization 不是 Bearer 前缀时不算数
|
||
r = httptest.NewRequest(http.MethodGet, "/api/status", nil)
|
||
r.Header.Set("Authorization", "Basic zzz")
|
||
r.Header.Set("X-Auth-Token", "bbb")
|
||
got = authTokenCandidates(r)
|
||
if len(got) != 1 || got[0] != "bbb" {
|
||
t.Fatalf("非 Bearer 的 Authorization 不该当候选:%q", got)
|
||
}
|
||
}
|
||
|
||
// ==================== 口令错误记录的容量 ====================
|
||
// IP 是外部给的:只靠「过期清理」挡不住换着地址撞口令的情况,
|
||
// 必须要有硬上限。这些用例锁住这一点。
|
||
|
||
// 换着来源持续撞口令,表必须被压在上限以内,不能一直涨
|
||
func TestAuthFailTableHasHardCap(t *testing.T) {
|
||
saved, savedSweep := authFail.m, authFail.sinceSweep
|
||
t.Cleanup(func() {
|
||
authFail.Lock()
|
||
authFail.m, authFail.sinceSweep = saved, savedSweep
|
||
authFail.Unlock()
|
||
})
|
||
authFail.Lock()
|
||
authFail.m, authFail.sinceSweep = nil, 0
|
||
authFail.Unlock()
|
||
|
||
// 每个都是新来源、且都是「刚刚」失败过 —— 过期清理一条都删不掉的情况
|
||
for i := 0; i < authFailMaxEntries*2; i++ {
|
||
authFailDelay(fmt.Sprintf("198.51.100.%d", i))
|
||
}
|
||
got := 0
|
||
authFail.Lock()
|
||
got = len(authFail.m)
|
||
authFail.Unlock()
|
||
if got > authFailMaxEntries {
|
||
t.Errorf("记录数应被压在上限 %d 以内,实际 %d(换 IP 撞口令会把内存写满)",
|
||
authFailMaxEntries, got)
|
||
}
|
||
t.Logf(" %d 个新来源撞完,表内留下 %d 条(上限 %d)", authFailMaxEntries*2, got, authFailMaxEntries)
|
||
}
|
||
|
||
// 超过过期时间的记录要被扫掉,不是在表里一直躺着
|
||
func TestAuthFailExpiredEntriesGetSwept(t *testing.T) {
|
||
saved, savedSweep := authFail.m, authFail.sinceSweep
|
||
t.Cleanup(func() {
|
||
authFail.Lock()
|
||
authFail.m, authFail.sinceSweep = saved, savedSweep
|
||
authFail.Unlock()
|
||
})
|
||
authFail.Lock()
|
||
authFail.m, authFail.sinceSweep = nil, 0
|
||
authFail.Unlock()
|
||
|
||
for i := 0; i < authFailSweepEvery+5; i++ {
|
||
authFailDelay(fmt.Sprintf("203.0.113.%d", i))
|
||
}
|
||
authFail.Lock()
|
||
before := len(authFail.m)
|
||
// 把记录的最后时间推到过期之前
|
||
for k, v := range authFail.m {
|
||
authFail.m[k] = authFailEntry{count: v.count, last: time.Now().Add(-2 * authFailExpire)}
|
||
}
|
||
authFail.Unlock()
|
||
|
||
sweepAuthFail(time.Now())
|
||
authFail.Lock()
|
||
after := len(authFail.m)
|
||
authFail.Unlock()
|
||
if after != 0 {
|
||
t.Errorf("已经全部过期的记录应被扫干净,扫之前 %d 条,扫之后 %d 条", before, after)
|
||
}
|
||
}
|
||
|
||
// 扫表必须是自动发生的:不要求调用方记着手动清理,
|
||
// 混在里面的过期项应该在若干次记录之后自己消失
|
||
func TestAuthFailSweepsPeriodically(t *testing.T) {
|
||
saved, savedSweep := authFail.m, authFail.sinceSweep
|
||
t.Cleanup(func() {
|
||
authFail.Lock()
|
||
authFail.m, authFail.sinceSweep = saved, savedSweep
|
||
authFail.Unlock()
|
||
})
|
||
authFail.Lock()
|
||
authFail.m, authFail.sinceSweep = nil, 0
|
||
authFail.Unlock()
|
||
|
||
// 直接往里塞一条早就过期了的记录(绕过 authFailDelay,模拟「以前留下的」)
|
||
authFail.Lock()
|
||
if authFail.m == nil {
|
||
authFail.m = make(map[string]authFailEntry)
|
||
}
|
||
authFail.m["203.0.113.99"] = authFailEntry{count: 1, last: time.Now().Add(-2 * authFailExpire)}
|
||
authFail.Unlock()
|
||
|
||
// 之后只要还在有人失败,过一阵子它就应该被清掉
|
||
for i := 0; i < authFailSweepEvery+1; i++ {
|
||
authFailDelay(fmt.Sprintf("198.51.100.%d", i))
|
||
}
|
||
|
||
authFail.Lock()
|
||
_, still := authFail.m["203.0.113.99"]
|
||
authFail.Unlock()
|
||
if still {
|
||
t.Errorf("记了 %d 次之后,过期记录应当已经被自动扫掉", authFailSweepEvery+1)
|
||
}
|
||
}
|
||
|
||
// 同一个来源的计数不能无限涨(临界点是延迟早已封顶,再涨只是让数字溢出)
|
||
func TestAuthFailCountCapped(t *testing.T) {
|
||
saved, savedSweep := authFail.m, authFail.sinceSweep
|
||
t.Cleanup(func() {
|
||
authFail.Lock()
|
||
authFail.m, authFail.sinceSweep = saved, savedSweep
|
||
authFail.Unlock()
|
||
})
|
||
authFail.Lock()
|
||
authFail.m, authFail.sinceSweep = nil, 0
|
||
authFail.Unlock()
|
||
|
||
for i := 0; i < authFailMaxCount+50; i++ {
|
||
authFailDelay("192.0.2.9")
|
||
}
|
||
authFail.Lock()
|
||
c := authFail.m["192.0.2.9"].count
|
||
authFail.Unlock()
|
||
if c > authFailMaxCount {
|
||
t.Errorf("同一来源的次数应封顶在 %d,实际 %d", authFailMaxCount, c)
|
||
}
|
||
if d := authFailDelay("192.0.2.9"); d != authMaxDelay {
|
||
t.Errorf("打满之后延迟应封顶在 %v,实际 %v", authMaxDelay, d)
|
||
}
|
||
}
|
||
|
||
// 客户端断开时延迟等待必须立刻返回,不能傻等满 2 秒
|
||
func TestAwaitAuthDelayStopsOnCancel(t *testing.T) {
|
||
ctx, cancel := context.WithCancel(context.Background())
|
||
go func() {
|
||
time.Sleep(80 * time.Millisecond)
|
||
cancel()
|
||
}()
|
||
start := time.Now()
|
||
waited := awaitAuthDelay(ctx, 2*time.Second)
|
||
used := time.Since(start)
|
||
if waited {
|
||
t.Error("上游取消时 awaitAuthDelay 应返回 false")
|
||
}
|
||
if used > time.Second {
|
||
t.Errorf("上游取消后应立刻返回,实际等了 %v", used)
|
||
}
|
||
// 正常等待不受影响
|
||
if !awaitAuthDelay(context.Background(), 10*time.Millisecond) {
|
||
t.Error("没被取消时应当正常等完再返回 true")
|
||
}
|
||
}
|
||
|
||
var (
|
||
webInputIDRe = regexp.MustCompile(`<(?:input|select|textarea)[^>]*\bid="([A-Za-z0-9_]+)"`)
|
||
webSettingFieldsRe = regexp.MustCompile(`var settingFields = \[([^\]]*)\]`)
|
||
webNumberFieldsRe = regexp.MustCompile(`var numberFields = \{([^}]*)\}`)
|
||
webQuotedRe = regexp.MustCompile(`"([A-Za-z0-9_]+)"`)
|
||
)
|
||
|
||
// 设置页里每个能改的输入框都必须在 settingFields 里登记:漏登记的后果是「改了、按了保存,
|
||
// 值却没被提交」,3 秒后刷新又被刷回原值 —— 页面看起来完全正常,也没有任何报错。
|
||
// 反过来,settingFields 里写的 id 如果页面上不存在,refresh 时就静默填不进去。
|
||
//
|
||
// settingFields / numberFields 都是页面里的 JS 变量(整页在 webuiHTML 这个原始串里),
|
||
// 所以这里直接把它们从串里抠出来比对,不另维护一份名单
|
||
func TestWebuiSettingFieldsMatchInputs(t *testing.T) {
|
||
ids := map[string]bool{}
|
||
for _, m := range webInputIDRe.FindAllStringSubmatch(webuiHTML, -1) {
|
||
ids[m[1]] = true
|
||
}
|
||
|
||
m := webSettingFieldsRe.FindStringSubmatch(webuiHTML)
|
||
if m == nil {
|
||
t.Fatal("没能在页面里找到 settingFields 列表")
|
||
}
|
||
settings := map[string]bool{}
|
||
for _, q := range webQuotedRe.FindAllStringSubmatch(m[1], -1) {
|
||
settings[q[1]] = true
|
||
if !ids[q[1]] {
|
||
t.Errorf("settingFields 里的 %q 在页面上找不到对应的输入框", q[1])
|
||
}
|
||
}
|
||
|
||
// 这些输入框刻意不进设置:要么是手动合并卡片自己的参数(随合并请求提交),
|
||
// 要么是路径选择弹窗 / 口令框的内部状态
|
||
notSettings := map[string]string{
|
||
"authTokenNew": "重新生成口令,走单独的接口",
|
||
"authTokenView": "只读展示当前口令",
|
||
"tokenInput": "页面解锁框,不是设置项",
|
||
"newuid": "添加主播,走主播接口",
|
||
"pickHidden": "路径选择弹窗的内部状态",
|
||
"pickPath": "路径选择弹窗的内部状态",
|
||
"mrgVideoPick": "手动合并:视频列表,随合并请求提交",
|
||
"tcFilePick": "转码:待选文件列表,随转码请求提交",
|
||
"tcOutDir": "转码:输出目录,随转码请求提交",
|
||
}
|
||
for id := range ids {
|
||
if settings[id] {
|
||
continue
|
||
}
|
||
if _, ok := notSettings[id]; ok {
|
||
continue
|
||
}
|
||
t.Errorf("页面上的 %q 既不在 settingFields 里,也没说明为什么不算设置项:改了它不会被保存", id)
|
||
}
|
||
|
||
// numberFields 里的 id 必须都是设置项,否则 parseInt 的结果没人用
|
||
n := webNumberFieldsRe.FindStringSubmatch(webuiHTML)
|
||
if n == nil {
|
||
t.Fatal("没能在页面里找到 numberFields 列表")
|
||
}
|
||
for _, m := range regexp.MustCompile(`([A-Za-z0-9_]+)\s*:`).FindAllStringSubmatch(n[1], -1) {
|
||
if !settings[m[1]] {
|
||
t.Errorf("numberFields 里的 %q 不是设置项", m[1])
|
||
}
|
||
}
|
||
}
|
||
|
||
// 保存设置必须把新增字段真的落到 config 上:漏一个赋值就是「界面显示已保存、实际没生效」,
|
||
// 3 秒后刷新还被刷回原值,页面上完全看不出问题
|
||
func TestWebConfigHandlerSavesMergeFpsAndNotifyMergeFail(t *testing.T) {
|
||
dir := setupMergeTest(t)
|
||
savedConfig := config
|
||
savedLocation := configFileLocation
|
||
oldDir := configDir
|
||
if configDir == nil {
|
||
configDir = new(string)
|
||
}
|
||
defer func() {
|
||
config = savedConfig
|
||
configFileLocation = savedLocation
|
||
configDir = oldDir
|
||
}()
|
||
*configDir = dir
|
||
configFileLocation = filepath.Join(dir, configFile)
|
||
|
||
post := func(body map[string]any) string {
|
||
t.Helper()
|
||
data, err := json.Marshal(body)
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
req := httptest.NewRequest(http.MethodPost, "/api/config", bytes.NewReader(data))
|
||
rec := httptest.NewRecorder()
|
||
webConfigHandler(rec, req)
|
||
if rec.Code != http.StatusOK {
|
||
t.Fatalf("保存设置被拒(%d):%s", rec.Code, rec.Body.String())
|
||
}
|
||
return rec.Body.String()
|
||
}
|
||
base := func() map[string]any {
|
||
return map[string]any{
|
||
"directory": dir,
|
||
"ffmpegPath": testFFmpeg(t),
|
||
"recordSubdir": defaultRecordSubdir,
|
||
"mergeSubdir": defaultMergeSubdir,
|
||
}
|
||
}
|
||
|
||
config.MergeFps = 0
|
||
config.NotifyMergeFail = true
|
||
b := base()
|
||
b["mergeFps"] = 60
|
||
b["notifyMergeFail"] = false
|
||
post(b)
|
||
if config.MergeFps != 60 {
|
||
t.Errorf("mergeFps 没被保存下来:%d", config.MergeFps)
|
||
}
|
||
if config.NotifyMergeFail {
|
||
t.Error("notifyMergeFail=false 没被保存下来")
|
||
}
|
||
|
||
// 超范围(负数或大得离谱)必须挡在保存之前
|
||
for _, bad := range []int{-1, maxMergeFps + 1} {
|
||
config.MergeFps = 7
|
||
b = base()
|
||
b["mergeFps"] = bad
|
||
data, _ := json.Marshal(b)
|
||
req := httptest.NewRequest(http.MethodPost, "/api/config", bytes.NewReader(data))
|
||
rec := httptest.NewRecorder()
|
||
webConfigHandler(rec, req)
|
||
if rec.Code != http.StatusBadRequest {
|
||
t.Errorf("mergeFps=%d 应当被拒(400),实际 %d", bad, rec.Code)
|
||
}
|
||
if config.MergeFps != 7 {
|
||
t.Errorf("mergeFps=%d 被拒后不该改到配置里,实际 %d", bad, config.MergeFps)
|
||
}
|
||
}
|
||
}
|