Files
acfunlive-remake/qualities_test.go
T
tamakyi 348e3bd675 档位改成「自动·第二高 + 实际档位」的下拉;手动合并也推送;头像缓存挪到程序目录
三件事:

1) 主播列表的录制档位
- 一律是下拉,不再有手填输入框。第一项「自动 · 第二高」(值 0)= 按这次读到的
  档位排序自上往下取第二高(只有一档就用它,读不到档位表才退回最高档);
  后面依次列出实际档位。选最高/最低/中间某一档直接点它,手动选过之后
  「自动」仍留在列表里,随时能改回去。
- 语义变化:0 不再被写死成某个具体码率。原来记录档位时会把「没选过」的主播
  按第二档定下来(写进 config.json),现在只在取流时解析,所以主播换了档位表
  也能跟着走;app 启动时的补齐逻辑(applyCachedDefaultQualities)随之去掉。
- 解析规则统一到 resolveQualityBitrate:0 = 第二高,>0 = 不超过它的最高档,
  都比它高时退回最低档;「自动」项会把解析到的档位名带出来
  (自动 · 第二高(当前 = 蓝光 4M))。/api/status 和档位接口都多了 qualityBitrate。
- 点「档位」按钮时先给一句「正在读取码率档位…」:主播没开播时服务端要重试三次
  才放弃,约 5 秒,之前这段时间界面没有任何反应。

2) 手动合并完成也推送(这就是「MeoW 只在测试时成功」的原因)
- 原来只有自动合并调 sendNotify,手动合并从头到尾没推过,所以事件开着、渠道也通
  却一直收不到。现在手动合并完成后同样按 mergeDone 事件推送两个渠道。
- 手动合并没有 uid,会拿成片名(再退到视频名)去对已有主播名,只有一个能对上时
  才在文案里带上主播名,对不上就用「手动合并完成」。

3) 头像缓存挪到程序所在目录
- 原来存在「录播目录/avatar_cache」,换一次保存目录(甚至换盘)就要把同一批观众
  的头像重新下一遍(一个头像可能 0.5MB)。现在统一放程序目录下的 avatar_cache。
- 旧位置作为只读回退:命中就搬过来(同盘改名,跨盘退回复制),不重复下载;
  没被用到的旧文件留在原地,用到哪个搬哪个。

验证:
- 新增 avatar_test.go 两个用例(缓存路径、旧位置搬家)和 notify_test.go 的
  TestManualMergeSendsMergeDoneNotify(真跑一次合并,断言 webhook 与 MeoW 各收到
  一条 mergeDone,且从文件名认出主播);qualities_test.go 改成新语义,新增
  resolveQualityBitrate(7 种档位/码率组合)和 guessUIDByFileName。
- 端到端:把旧位置的头像缓存放在录播目录、头像地址指向连不通的端口,合并仍然成功
  且画面里有那张头像(纯红像素 400 个),证明是搬来的而不是重新下载的;文件也从
  录播目录搬到了程序目录。
- 浏览器:verify_streamer_br.py 重写成档位下拉的 20 项断言,verify_qualities.py
  更新为新语义,两个都全过;verify_settings.py / verify_pick.py / verify_danmu_match.py
  无回归;go test ./...、gofmt、go vet 干净。
2026-09-25 20:03:24 +08:00

247 lines
7.6 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package main
import (
"encoding/json"
"os"
"path/filepath"
"strings"
"testing"
)
func tiersForTest() []qualityTier {
// 故意乱序,验证排序
return []qualityTier{
{Name: "高清", Bitrate: 2000},
{Name: "蓝光 8M", Bitrate: 8000},
{Name: "标清", Bitrate: 1000},
{Name: "蓝光 4M", Bitrate: 4000},
}
}
func TestSortQualitiesDesc(t *testing.T) {
got := sortQualitiesDesc(tiersForTest())
want := []string{"蓝光 8M", "蓝光 4M", "高清", "标清"}
for i, name := range want {
if got[i].Name != name {
t.Fatalf("第 %d 个应为 %s,实际 %s(%v)", i, name, got[i].Name, got)
}
}
// 不能改动传进来的切片
in := tiersForTest()
_ = sortQualitiesDesc(in)
if in[0].Name != "高清" {
t.Errorf("排序不该改动原切片:%v", in)
}
}
// 默认档位 = 自上往下第二档(不是最高档)
func TestDefaultQualityBitrate(t *testing.T) {
cases := []struct {
name string
tiers []qualityTier
want int
}{
{"四档取第二高", tiersForTest(), 4000},
{"三档取第二高(你给的例子)", []qualityTier{{"高清", 2000}, {"蓝光 8M", 8000}, {"蓝光 4M", 4000}}, 4000},
{"两档取低的那档", []qualityTier{{"蓝光 4M", 4000}, {"高清", 2000}}, 2000},
{"只有一档就用它", []qualityTier{{"高清", 2000}}, 2000},
{"没有档位返回 0", nil, 0},
}
for _, c := range cases {
if got := defaultQualityBitrate(c.tiers); got != c.want {
t.Errorf("%s:应为 %d,实际 %d", c.name, c.want, got)
}
}
}
func TestQualityNameFor(t *testing.T) {
if got := qualityNameFor(tiersForTest(), 4000); got != "蓝光 4M" {
t.Errorf("4000 应匹配「蓝光 4M」,实际 %q", got)
}
if got := qualityNameFor(tiersForTest(), 9999); got != "" {
t.Errorf("匹配不上应返回空,实际 %q", got)
}
}
func TestSameQualities(t *testing.T) {
a := tiersForTest()
b := []qualityTier{{"标清", 1000}, {"蓝光 4M", 4000}, {"高清", 2000}, {"蓝光 8M", 8000}}
if !sameQualities(a, b) {
t.Error("顺序不同但内容相同的档位列表应视为一样")
}
c := append([]qualityTier(nil), a[:3]...)
if sameQualities(a, c) {
t.Error("档位数量不同应视为不一样")
}
}
// 记录档位时只缓存档位表,不动用户选的码率:
// 0 表示「自动、按实际档位取第二高」,主播换了档位表要能跟着走
func TestSetStreamerQualities(t *testing.T) {
savedConfig := config
savedStreamers := streamers.crt
defer func() {
config = savedConfig
streamers.crt = savedStreamers
}()
config.UIDs = uidList{
{UID: 1, Danmu: true, Record: true, Bitrate: 0}, // 自动
{UID: 2, Danmu: true, Record: true, Bitrate: 1000}, // 手动指定过
}
streamers.crt = map[int]streamer{
1: {UID: 1, Bitrate: 0},
2: {UID: 2, Bitrate: 1000},
}
if !setStreamerQualities(1, tiersForTest()) {
t.Error("第一次记录档位应该算改动了配置")
}
if config.UIDs[0].Bitrate != 0 {
t.Errorf("自动的主播不该被写死成具体码率,实际 %d", config.UIDs[0].Bitrate)
}
if len(config.UIDs[0].Qualities) != 4 || len(streamers.crt[1].Qualities) != 4 {
t.Errorf("档位没缓存到配置或运行时数据:%+v / %+v", config.UIDs[0], streamers.crt[1])
}
if streamers.crt[1].Bitrate != 0 {
t.Errorf("运行时数据的码率不该被改动,实际 %d", streamers.crt[1].Bitrate)
}
// 手动设过的同样不动
setStreamerQualities(2, tiersForTest())
if config.UIDs[1].Bitrate != 1000 {
t.Errorf("uid 2 手动设过 1000,不该被改成 %d", config.UIDs[1].Bitrate)
}
// 同样的档位再来一次不算改动
if setStreamerQualities(1, tiersForTest()) {
t.Error("档位没变化时不该算改动")
}
// 档位变了才算改动
if !setStreamerQualities(1, []qualityTier{{"高清", 2000}, {"流畅", 500}}) {
t.Error("档位变化时应算改动")
}
}
// 实际用哪个档位:0 = 自动(第二高),填了具体码率 = 不超过它的最高档,
// 都比它高时退回最低档
func TestResolveQualityBitrate(t *testing.T) {
cases := []struct {
name string
bitrate int
want int
}{
{"自动取第二高", 0, 4000},
{"指定最高档", 8000, 8000},
{"指定第二高", 4000, 4000},
{"指定最低档", 1000, 1000},
{"填了中间值取不超过它的最高档", 3000, 2000},
{"比所有档位都高就取最高档", 99999, 8000},
{"比所有档位都低就退回最低档", 200, 1000},
}
for _, c := range cases {
if got := resolveQualityBitrate(tiersForTest(), c.bitrate); got != c.want {
t.Errorf("%s:应为 %d,实际 %d", c.name, c.want, got)
}
}
// 没有档位表时保持原样(取流逻辑会退回最高档)
if got := resolveQualityBitrate(nil, 0); got != 0 {
t.Errorf("没有档位表时应返回 0,实际 %d", got)
}
if got := resolveQualityBitrate(nil, 2000); got != 2000 {
t.Errorf("没有档位表时应原样返回,实际 %d", got)
}
// 只有一档时自动档就是它
one := []qualityTier{{"高清", 2000}}
if got := resolveQualityBitrate(one, 0); got != 2000 {
t.Errorf("只有一档时自动应为 2000,实际 %d", got)
}
}
// 手动合并的通知靠文件名猜主播:只有一个名字对得上才认
func TestGuessUIDByFileName(t *testing.T) {
savedConfig := config
savedStreamers := streamers.crt
defer func() {
config = savedConfig
streamers.crt = savedStreamers
}()
config.UIDs = uidList{{UID: 1}, {UID: 2}, {UID: 3}}
streamers.crt = map[int]streamer{
1: {UID: 1, Name: "AC娘本体"},
2: {UID: 2, Name: "随便唠会"},
3: {UID: 3, Name: "阿"},
}
cases := []struct {
file string
want int
}{
{"2024-10-14 00-33-32 AC娘本体 嘿嘿 醒了_弹幕合并.mp4", 1},
{"AC娘本体.mp4", 1},
{"2024-10-14 00-33-32 随便唠会 摸鱼.mp4", 2},
// 两个名字都在文件名里,猜不准就不猜
{"AC娘本体 和 随便唠会 联动.mp4", 0},
{"完全不认识的主播.mp4", 0},
// 名字只有一个字的不参与匹配,太容易误伤
{"阿 的录播.mp4", 0},
{"", 0},
}
for _, c := range cases {
if got := guessUIDByFileName(c.file); got != c.want {
t.Errorf("%q 应猜出 %d,实际 %d", c.file, c.want, got)
}
}
}
// 档位要能存进 config.json 再读回来
func TestQualitiesConfigRoundTrip(t *testing.T) {
savedConfig := config
savedLocation := configFileLocation
oldDir := configDir
if configDir == nil {
configDir = new(string)
}
defer func() {
config = savedConfig
configFileLocation = savedLocation
configDir = oldDir
}()
dir := t.TempDir()
*configDir = dir
configFileLocation = filepath.Join(dir, configFile)
body := `{"uids":[{"uid":23682490,"danmu":true,"record":true,"bitrate":4000,
"qualities":[{"name":"蓝光 8M","bitrate":8000},{"name":"蓝光 4M","bitrate":4000}]}]}`
if err := os.WriteFile(configFileLocation, []byte(body), 0644); err != nil {
t.Fatal(err)
}
config = configData{}
loadConfig()
if len(config.UIDs) != 1 {
t.Fatalf("应解析出 1 个主播,实际 %d", len(config.UIDs))
}
u := config.UIDs[0]
if u.Bitrate != 4000 || len(u.Qualities) != 2 || u.Qualities[0].Name != "蓝光 8M" {
t.Fatalf("档位没正确读出来:%+v", u)
}
if got := qualityNameFor(u.Qualities, u.Bitrate); got != "蓝光 4M" {
t.Errorf("当前档位名应为「蓝光 4M」,实际 %q", got)
}
// 再序列化一次,字段名要对得上(qualities / name / bitrate)
config.UIDs[0].Qualities = sortQualitiesDesc(config.UIDs[0].Qualities)
data, err := json.Marshal(config.UIDs[0])
if err != nil {
t.Fatal(err)
}
for _, key := range []string{`"qualities"`, `"name"`, `"bitrate"`} {
if !json.Valid(data) || !strings.Contains(string(data), key) {
t.Errorf("序列化结果里应包含 %s:%s", key, data)
}
}
}