package main import ( "errors" "math" "os" "path/filepath" "strings" "testing" "time" ) // 密集弹幕:每 0.02 秒一条,10 秒停留窗口里能塞进 500 条。 // 块尾扩张原来没有上限,会把窗口里的弹幕几乎全并进同一块 func denseRecords(n int) []danmuRecord { records := make([]danmuRecord, 0, n) for i := 0; i < n; i++ { records = append(records, danmuRecord{ Time: float64(i) * 0.02, UID: int64(i%5 + 1), Name: "密集测试", Content: "弹幕内容", Lane: i % 10, }) } return records } // 单块条数必须被硬上限挡住,否则 -filter_complex 会撑爆命令行长度 func TestSplitDanmuChunksCapsBlockSize(t *testing.T) { records := denseRecords(600) chunks := splitDanmuChunks(records, 0, 0) if len(chunks) < 2 { t.Fatalf("这么多弹幕应当分成多块,实际 %d 块", len(chunks)) } total := 0 for i, ch := range chunks { if n := len(ch.records); n > maxDanmuPerChunkHard { t.Fatalf("第 %d 块有 %d 条弹幕,超过硬上限 %d", i+1, n, maxDanmuPerChunkHard) } total += len(ch.records) } if total != len(records) { t.Fatalf("分块把弹幕弄丢或弄重了:%d -> %d", len(records), total) } } // 硬上限会把一部分弹幕挤到下一块,这些弹幕在下一块里仍要从自己的时间点开始显示: // 块尾不收紧的话,下一块的块头会落在它们后面,开头就被吃掉了 func TestSplitDanmuChunksLosesNoDanmu(t *testing.T) { records := denseRecords(600) chunks := splitDanmuChunks(records, 0, 0) for i := 1; i < len(chunks); i++ { if chunks[i].start != chunks[i-1].end { t.Fatalf("第 %d 块和第 %d 块之间有空隙:%.3f -> %.3f", i, i+1, chunks[i-1].end, chunks[i].start) } } // 按 writeChunkAss 的可见区间算法检查:每条弹幕在它所在的那一块里都要有完整的显示区间 for _, ch := range chunks { for _, r := range ch.records { if vs := math.Max(r.Time, ch.start); vs > r.Time+1e-9 { t.Fatalf("时间 %.3f 的弹幕在所在块里从 %.3f 才开始,开头被块头截掉了", r.Time, vs) } if ve := math.Min(r.Time+danmuShowSeconds, ch.end); ve <= r.Time+1e-9 { t.Fatalf("时间 %.3f 的弹幕在所有块里都没有显示区间,会被整条丢掉", r.Time) } } } } // 回退成「只合并第一个分段」时,used 必须只剩第一段: // 自动合并收尾按 used 删源视频,返回全部就等于把没进成片的后半场删掉 func TestPrepareForMergeOnlyMarksUsedSegments(t *testing.T) { dir := t.TempDir() a := filepath.Join(dir, "a.mp4") b := filepath.Join(dir, "b.mp4") for _, p := range []string{a, b} { if err := os.WriteFile(p, []byte("x"), 0644); err != nil { t.Fatal(err) } } jsonl := filepath.Join(dir, "a.jsonl") if err := os.WriteFile(jsonl, []byte("{}\n"), 0644); err != nil { t.Fatal(err) } // 用一个不存在的 ffmpeg:probeDuration 拿不到时长,触发「只合并第一个分段」的回退 segs := []recordSegment{{file: a, startNs: 0}, {file: b, startNs: int64(time.Second)}} video, _, _, used, cleanup, err := prepareForMerge(filepath.Join(dir, "没有这个-ffmpeg.exe"), segs, jsonl, 0) defer cleanup() if err != nil { t.Fatalf("回退不该报错:%v", err) } if video != a { t.Fatalf("回退后应当合并第一段,实际 %s", video) } if len(used) != 1 || used[0] != a { t.Fatalf("回退成只合并第一段时 used 应只含第一段,实际 %v", used) } } // 直接改全局任务状态:测试里只碰 stopped 这一个字段,用完恢复 func setJobStopped(t *testing.T, v bool) { t.Helper() job.Lock() prev := job.stopped job.stopped = v job.Unlock() t.Cleanup(func() { job.Lock() job.stopped = prev job.Unlock() }) } // 现场生成一个 1 秒的小视频分段 func makeTinyVideo(t *testing.T, ffmpegFile, path, color string) { t.Helper() if err := runFFmpegWith(ffmpegFile, []string{ "-y", "-f", "lavfi", "-i", "color=c=" + color + ":s=160x120:d=1:r=10", "-c:v", "libx264", "-pix_fmt", "yuv420p", path, }, filepath.Dir(path), nil); err != nil { t.Skipf("生成测试分段失败,跳过:%v", err) } } // 多段都拿到时长、成功拼接时,used 应当是全部分段(原来的删除行为不变) func TestPrepareForMergeMarksAllSegmentsWhenJoined(t *testing.T) { ffmpegFile := getFFmpeg() if ffmpegFile == "" { t.Skip("找不到 ffmpeg,跳过") } setJobStopped(t, false) dir := t.TempDir() a, b := filepath.Join(dir, "a.mp4"), filepath.Join(dir, "b.mp4") makeTinyVideo(t, ffmpegFile, a, "red") makeTinyVideo(t, ffmpegFile, b, "blue") jsonl := filepath.Join(dir, "a.jsonl") if err := os.WriteFile(jsonl, []byte(`{"playResX": 160, "playResY": 120, "fontSize": 20, "startTime": 0}`+"\n"+ `{"t": 0.2, "uid": 1, "name": "n", "avatar": "", "content": "c", "lane": 0, "width": 0}`+"\n"), 0644); err != nil { t.Fatal(err) } segs := []recordSegment{{file: a, startNs: 0}, {file: b, startNs: int64(time.Second)}} video, out, _, used, cleanup, err := prepareForMerge(ffmpegFile, segs, jsonl, 0) defer cleanup() if err != nil { t.Fatalf("拼接两个正常分段不该报错:%v", err) } if video != filepath.Join(dir, chunkTempPrefix+"joined.mp4") { t.Fatalf("应当用拼接后的文件合并,实际 %s", video) } if len(used) != 2 { t.Fatalf("拼接成功时 used 应包含全部分段,实际 %v", used) } if _, err := os.Stat(out); err != nil { t.Fatalf("重映射的弹幕文件应当存在:%v", err) } // cleanup 必须把中间文件收干净 cleanup() if _, err := os.Stat(video); err == nil { t.Fatalf("清理后拼接出来的中间视频还在:%s", video) } if _, err := os.Stat(out); err == nil { t.Fatalf("清理后重映射的弹幕文件还在:%s", out) } } // 拼接中途被停止:要报成 errJobStopped,并且把拼到一半的中间文件删掉 // (留在录播目录里既占几 GB 又带 chunkTempPrefix,界面文件列表里根本看不见) func TestPrepareForMergeDropsJoinedWhenStopped(t *testing.T) { ffmpegFile := getFFmpeg() if ffmpegFile == "" { t.Skip("找不到 ffmpeg,跳过") } setJobStopped(t, false) dir := t.TempDir() a, b := filepath.Join(dir, "a.mp4"), filepath.Join(dir, "b.mp4") makeTinyVideo(t, ffmpegFile, a, "red") makeTinyVideo(t, ffmpegFile, b, "blue") jsonl := filepath.Join(dir, "a.jsonl") if err := os.WriteFile(jsonl, []byte("{}\n"), 0644); err != nil { t.Fatal(err) } joined := filepath.Join(dir, chunkTempPrefix+"joined.mp4") if err := os.WriteFile(joined, []byte("拼到一半的内容"), 0644); err != nil { t.Fatal(err) } setJobStopped(t, true) segs := []recordSegment{{file: a, startNs: 0}, {file: b, startNs: int64(time.Second)}} _, _, _, used, cleanup, err := prepareForMerge(ffmpegFile, segs, jsonl, 0) defer cleanup() if !errors.Is(err, errJobStopped) { t.Fatalf("被停止时应当返回 errJobStopped,实际 %v", err) } if len(used) != 0 { t.Fatalf("被停止时不该把任何分段算作已合并,实际 %v", used) } if _, err := os.Stat(joined); err == nil { t.Fatalf("停止后拼到一半的中间文件应当被删掉:%s", joined) } } // 任务处于「已请求停止」时,拼接失败要报成 errJobStopped,而不是「拼接失败」 func TestConcatChunksReportsStopped(t *testing.T) { dir := t.TempDir() f := filepath.Join(dir, "in.mp4") if err := os.WriteFile(f, []byte("x"), 0644); err != nil { t.Fatal(err) } out := filepath.Join(dir, "out.mp4") setJobStopped(t, true) err := concatChunks(filepath.Join(dir, "没有这个-ffmpeg.exe"), []string{f}, out, dir, nil) if !errors.Is(err, errJobStopped) { t.Fatalf("停止状态下拼接失败应当返回 errJobStopped,实际 %v", err) } } // 没有停止请求时,拼接失败就是普通错误,别报成「已停止」 func TestConcatChunksReportsFailureWhenNotStopped(t *testing.T) { dir := t.TempDir() f := filepath.Join(dir, "in.mp4") if err := os.WriteFile(f, []byte("x"), 0644); err != nil { t.Fatal(err) } out := filepath.Join(dir, "out.mp4") setJobStopped(t, false) err := concatChunks(filepath.Join(dir, "没有这个-ffmpeg.exe"), []string{f}, out, dir, nil) if err == nil { t.Fatal("拼接应当失败") } if errors.Is(err, errJobStopped) { t.Fatalf("没有停止请求时不该报成已停止:%v", err) } if !strings.Contains(err.Error(), "拼接") { t.Fatalf("错误信息应当说明是拼接失败,实际 %v", err) } } // 确定性失败不该白白重试三次 func TestRunThriceSkipsNoRetry(t *testing.T) { calls := 0 err := runThrice(func() error { calls++ return noRetry(errors.New("确定性失败")) }) if calls != 1 { t.Fatalf("标记为不重试的错误应当只尝试一次,实际 %d 次", calls) } if err == nil || !strings.Contains(err.Error(), "确定性失败") { t.Fatalf("错误应当原样返回,实际 %v", err) } if strings.Contains(err.Error(), "运行三次") { t.Fatalf("不该被包装成「运行三次都出现错误」:%v", err) } } // 普通错误仍然照旧重试 func TestRunThriceStillRetriesNormalError(t *testing.T) { calls := 0 err := runThrice(func() error { calls++ if calls == 2 { return nil } return errors.New("暂时失败") }) if err != nil { t.Fatalf("第二次成功时不该报错:%v", err) } if calls != 2 { t.Fatalf("普通错误应当重试,实际只尝试了 %d 次", calls) } } // 交给 exec 的 ffmpeg 路径必须是绝对路径: // Windows 上 exec 会直接拒绝执行「相对当前目录」找到的可执行文件 func TestGetFFmpegReturnsAbsPath(t *testing.T) { p := getFFmpeg() if p == "" { t.Skip("本机找不到 ffmpeg,跳过") } if !filepath.IsAbs(p) { t.Fatalf("getFFmpeg 应当返回绝对路径,实际 %q", p) } }