Files
acfunlive-remake/p2_fixes_test.go
T
tamakyi a24425e16d 修第三批 P2:除零、停止/暂停的响应与状态一致性、临时文件、带 BOM 的 ASS
一、弹幕滚动时间不再整数除零
   write() 里拿 (PlayResX+length) 做除数,PlayResX 被配成 0 又收到一条空内容弹幕时
   分母正好是 0,直接 panic 终止整场录制。抽成 scrollMarks 并给分母为 0 的兜底
   (退回半个停留时长,至少让它正常显示出来)。

二、点停止不再要等整个块渲完
   jobAttach 原来只看 paused:停止请求若落在 jobGate 之后、ffmpeg 启动过程中,
   job.proc 还没登记,jobStop 拿不到进程可杀,用户得等整个块渲完(大块要几分钟)。
   现在 attach 时发现任务已请求停止就地把它杀掉,上层拿到 errJobStopped,
   卡片显示的是「已停止」而不是「失败」。

三、暂停没挂住就回滚状态
   jobPause 先把 job.paused 置 true 再调 suspendProcess,挂起失败时状态已经改了:
   界面显示「已暂停」而 ffmpeg 还在跑。失败时回滚(只在 proc 仍是同一个进程时回滚)。
   jobResume 同理:恢复不成放回「已暂停」,免得显示在跑却停着。

四、.ass 还原的临时 jsonl 用完就删
   还原结果写不进录播目录时会退到系统临时目录的固定名文件,原来从不清理,
   每失败一次留一份。新增 danmuSource.tempJsonl 标记 + dropTempDanmu 收尾删除;
   只删标记过的临时文件 —— 正常情况下那个路径是用户录播目录里的同名 jsonl,
   删了就是丢数据。

五、带 BOM 的 ASS 能整份解析
   带 UTF-8 BOM 时首行是 BOM+[Script Info],一个节都进不去,最后只报
   「可能不是弹幕文件」,跟真实原因完全对不上。解析时剥掉 BOM。

六、头像下载期间暂停/停止不用再干等
   单次超时 30 秒 × 3 次重试,最坏 96 秒才响应暂停/停止。单次超时收到 10 秒
   (头像只有几百 KB),并且每次重试前过闸门:暂停在这里等、停止直接放弃,
   不再把剩下的重试跑完;停止也不再打「头像不可用」的告警。

验证:
- gofmt -l 空;go vet ./... 空;go build 与 GOOS=linux go build 通过
- go test ./... 全绿(10.9s);go test -race ./... 全绿(14.5s)
- 新增 p2_fixes_test.go 8 个用例;本轮 6 项加上一批 6 项共 12 项反向验证全部通过
  (每项临时退回源码 → 对应测试确实失败 → 恢复)
- 51897 隔离实例上完整复测:e2e.py 37 项全过、smoke_extra.py 33 项全过
2026-09-26 10:07:30 +08:00

209 lines
6.7 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 (
"os"
"os/exec"
"path/filepath"
"runtime"
"strings"
"testing"
"time"
"github.com/orzogc/acfundanmu"
)
// 直接摆布全局任务状态:测试里只碰这几个标志位,用完恢复
func withJobFlags(t *testing.T, active, paused, stopped bool) {
t.Helper()
job.Lock()
prevActive, prevPaused, prevStopped, prevProc := job.active, job.paused, job.stopped, job.proc
job.active, job.paused, job.stopped = active, paused, stopped
job.Unlock()
t.Cleanup(func() {
job.Lock()
job.active, job.paused, job.stopped, job.proc = prevActive, prevPaused, prevStopped, prevProc
job.Unlock()
})
}
// resX + length 为 0(PlayResX 配置异常 + 空内容)时不能整数除零
func TestScrollMarksFallsBackWhenTotalIsZero(t *testing.T) {
emerge, left := scrollMarks(1000, 0, 0, danmuShowDur)
if emerge != 1000+danmuShowDur/2 {
t.Fatalf("行程为 0 时 emerge 应当退到半个停留时长,实际 %d", emerge-1000)
}
if left != 1000+danmuShowDur {
t.Fatalf("行程为 0 时 left 应当退到一个停留时长,实际 %d", left-1000)
}
}
// 端到端:配置里 PlayResX 为 0 又收到一条空弹幕时,写入器不能 panic
func TestDanmuWriterSurvivesZeroPlayRes(t *testing.T) {
dir := t.TempDir()
cfg := acfundanmu.SubConfig{Title: "测试", PlayResX: 0, PlayResY: 0,
FontSize: 40, StartTime: 1700000000000000000}
w := newDanmuWriter("liveid", "stream", cfg, filepath.Join(dir, "zero.ass"))
c := &acfundanmu.Comment{Content: ""} // 空内容 → 宽度 0,和 PlayResX=0 一起把分母变成 0
c.SendTime = (cfg.StartTime + int64(time.Second)) / 1e6
c.UserID = 1
c.Nickname = "甲"
w.write(c) // 原来这里整数除零 panic,会终止整场录制
if err := w.close(); err != nil {
t.Fatalf("关文件不该报错:%v", err)
}
if w.failed() {
t.Fatalf("写盘不该出错:%v", w.err)
}
// 这条弹幕仍要正常出现在 jsonl 里
b, err := os.ReadFile(filepath.Join(dir, "zero.jsonl"))
if err != nil {
t.Fatal(err)
}
if lines := strings.Split(strings.TrimSpace(string(b)), "\n"); len(lines) != 2 {
t.Fatalf("应当写出 1 条弹幕记录(加首行元数据共 2 行),实际 %d 行", len(lines))
}
}
// 任务已请求停止时,jobAttach 要就地结束刚启动的进程,
// 否则用户点了停止还得等整个块渲完
func TestJobAttachKillsProcessWhenStopped(t *testing.T) {
var cmd *exec.Cmd
if runtime.GOOS == "windows" {
cmd = exec.Command("cmd", "/c", "ping", "-n", "30", "127.0.0.1")
} else {
cmd = exec.Command("sleep", "30")
}
if err := cmd.Start(); err != nil {
t.Skipf("起不了测试子进程,跳过:%v", err)
}
defer func() { _ = cmd.Process.Kill() }()
withJobFlags(t, true, false, true) // 任务已经处于「已请求停止」
jobAttach(cmd)
defer jobDetach(cmd)
done := make(chan error, 1)
go func() { done <- cmd.Wait() }()
select {
case <-done:
case <-time.After(10 * time.Second):
t.Fatal("任务已请求停止时,jobAttach 应当就地结束刚启动的进程,不能等它自己跑完")
}
}
// 挂起失败(进程已经不在)时不能留着「已暂停」的状态
func TestJobPauseRollsBackStateWhenSuspendFails(t *testing.T) {
withJobFlags(t, true, false, false)
fake := &exec.Cmd{Process: &os.Process{Pid: 999999}} // 不存在的进程号,挂起必然失败
jobAttach(fake)
defer jobDetach(fake)
if err := jobPause(); err == nil {
t.Fatal("挂起一个不存在的进程应当报错")
}
job.Lock()
paused := job.paused
job.Unlock()
if paused {
t.Fatal("挂起失败后不该留着「已暂停」:界面会显示已暂停,而 ffmpeg 其实还在跑")
}
}
// 只有标记成临时的 jsonl 才能删,正常同名 jsonl 一个字节都不能动
func TestDropTempDanmuOnlyRemovesTempFile(t *testing.T) {
dir := t.TempDir()
keep := filepath.Join(dir, "keep.jsonl")
if err := os.WriteFile(keep, []byte("{}\n"), 0644); err != nil {
t.Fatal(err)
}
dropTempDanmu(danmuSource{jsonlPath: keep})
if _, err := os.Stat(keep); err != nil {
t.Fatalf("正常的同名 jsonl 不能被删掉:%v", err)
}
tmp := filepath.Join(dir, "tmp.jsonl")
if err := os.WriteFile(tmp, []byte("{}\n"), 0644); err != nil {
t.Fatal(err)
}
dropTempDanmu(danmuSource{jsonlPath: tmp, tempJsonl: true})
if _, err := os.Stat(tmp); err == nil {
t.Fatal("标记为临时的还原结果应当在收尾时删掉")
}
}
// 还原结果写不进原目录时退回系统临时目录,并且要打上临时标记
func TestPrepareDanmuSourceMarksTempFile(t *testing.T) {
dir := t.TempDir()
assPath := filepath.Join(dir, "bom.ass")
if err := os.WriteFile(assPath, []byte(plainAssFixture), 0644); err != nil {
t.Fatal(err)
}
// 同名 jsonl 占成只读:写不进去,只能退到临时目录
jsonlPath := filepath.Join(dir, "bom.jsonl")
if err := os.WriteFile(jsonlPath, []byte("{}\n"), 0444); err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = os.Chmod(jsonlPath, 0644) })
src, err := prepareDanmuSource(assPath, true)
if err != nil {
t.Skipf("这台机器上没能造出「写不进去」的条件,跳过:%v", err)
}
if !src.tempJsonl {
t.Skip("写进原目录成功了(文件系统不认只读属性),跳过")
}
if src.jsonlPath == jsonlPath {
t.Fatal("退回临时文件时 jsonlPath 不该还指着原路径")
}
if _, err := os.Stat(src.jsonlPath); err != nil {
t.Fatalf("临时文件应当已经写好:%v", err)
}
dropTempDanmu(src)
if _, err := os.Stat(src.jsonlPath); err == nil {
t.Fatal("临时文件应当在收尾时被删掉")
}
}
// 带 UTF-8 BOM 的 ASS 也要能整份解析出来
func TestParseAssFileAcceptsBOM(t *testing.T) {
dir := t.TempDir()
p := filepath.Join(dir, "bom.ass")
if err := os.WriteFile(p, []byte("\ufeff"+plainAssFixture), 0644); err != nil {
t.Fatal(err)
}
meta, records, err := parseAssFile(p)
if err != nil {
t.Fatalf("带 BOM 的 ASS 应当能正常解析,实际报错:%v", err)
}
if meta.PlayResX != 1920 || meta.PlayResY != 1080 {
t.Fatalf("分辨率应当解析出来,实际 %dx%d", meta.PlayResX, meta.PlayResY)
}
if meta.FontSize != 48 {
t.Fatalf("字号应当解析出来,实际 %d", meta.FontSize)
}
if len(records) != 2 {
t.Fatalf("应当读到 2 条弹幕(Comment 那行不算),实际 %d", len(records))
}
}
// 停止后下载头像不该把三次重试耗完(原来最坏要等 96 秒才响应停止)
func TestEnsureAvatarGivesUpAtOnceWhenStopped(t *testing.T) {
withJobFlags(t, true, false, true)
dir := t.TempDir()
t0 := time.Now()
got := ensureAvatar(424242, "http://127.0.0.1:1/none.png", dir)
elapsed := time.Since(t0)
if got != "" {
t.Fatalf("停止状态下不该返回头像路径,实际 %q", got)
}
if elapsed > 3*time.Second {
t.Fatalf("停止后应当立刻放弃这个头像,实际等了 %v", elapsed)
}
}