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) } }