package main import ( "bytes" "errors" "fmt" "image" "image/png" "net/http" "net/http/httptest" "os" "path/filepath" "strings" "sync/atomic" "testing" ) // ==================== ffmpeg 报错的可见性 ==================== // 尾部 writer 只留最近若干行(ffmpeg 的进度行会不停刷) func TestTailWriterKeepsLastLines(t *testing.T) { w := newTailWriter(3) fmt.Fprint(w, "第1行\n第2行\n第3行\r第4行\n第5行\n") got := w.String() if !strings.Contains(got, "第5行") { t.Fatalf("应当保留最后一行:%q", got) } if strings.Contains(got, "第1行") || strings.Contains(got, "第2行") { t.Fatalf("超出保留行数的旧内容应当被丢掉:%q", got) } if lines := strings.Split(got, "\n"); len(lines) != 3 { t.Fatalf("应当只留 3 行,实际 %d 行:%q", len(lines), got) } } // 失败时要把 ffmpeg 自己的报错带上,不然只剩一句 exit status func TestFFmpegResultCarriesStderr(t *testing.T) { err := ffmpegResult(errors.New("exit status 0xffffffea"), "Error binding filtergraph inputs/outputs") if err == nil { t.Fatal("应当返回错误") } msg := err.Error() if !strings.Contains(msg, "exit status 0xffffffea") || !strings.Contains(msg, "Error binding filtergraph") { t.Fatalf("错误里应当同时有退出状态和 ffmpeg 的说明,实际:%s", msg) } // 成功时不带任何东西 if got := ffmpegResult(nil, "随便什么输出"); got != nil { t.Fatalf("成功时应当返回 nil,实际 %v", got) } // 没有 stderr 时保持原样 plain := errors.New("exit status 1") if got := ffmpegResult(plain, " "); got != plain { t.Fatalf("没有 ffmpeg 输出时应当原样返回,实际 %v", got) } } // 跟编码器无关的失败不该挨个回退重试 func TestEncoderMightHelp(t *testing.T) { unrelated := []string{ "Error binding filtergraph inputs/outputs: Invalid argument", "Error initializing filtergraph", "Error opening input: No such file or directory", } for _, msg := range unrelated { if encoderMightHelp(errors.New(msg)) { t.Fatalf("这类失败跟编码器无关,不该回退重试:%s", msg) } } related := []string{ "Error initializing output stream 0:0 -- Error while opening encoder", "hevc_qsv: Function not implemented", } for _, msg := range related { if !encoderMightHelp(errors.New(msg)) { t.Fatalf("编码器自身的失败应当允许回退:%s", msg) } } } // ==================== 头像缓存完整性 ==================== func tinyPNG(t *testing.T) []byte { t.Helper() var buf bytes.Buffer if err := png.Encode(&buf, image.NewRGBA(image.Rect(0, 0, 8, 8))); err != nil { t.Fatal(err) } return buf.Bytes() } // 半截的 PNG 不能被当成可用缓存 func TestValidCachedAvatar(t *testing.T) { dir := t.TempDir() good := tinyPNG(t) full := filepath.Join(dir, "full.png") if err := os.WriteFile(full, good, 0644); err != nil { t.Fatal(err) } if !validCachedAvatar(full) { t.Fatal("完整的 PNG 应当算有效") } // 砍掉尾巴(模拟下载中断) cut := filepath.Join(dir, "cut.png") if err := os.WriteFile(cut, good[:len(good)-8], 0644); err != nil { t.Fatal(err) } if validCachedAvatar(cut) { t.Fatal("半截 PNG 不该算有效") } empty := filepath.Join(dir, "empty.png") if err := os.WriteFile(empty, nil, 0644); err != nil { t.Fatal(err) } if validCachedAvatar(empty) { t.Fatal("空文件不该算有效") } if validCachedAvatar(filepath.Join(dir, "不存在.png")) { t.Fatal("不存在的文件不该算有效") } } // 命中的是半截缓存时要重新下载,下载完必须是完整 PNG func TestEnsureAvatarRedownloadsBrokenCache(t *testing.T) { withJobFlags(t, true, false, false) dir := t.TempDir() var hits int64 body := tinyPNG(t) srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { atomic.AddInt64(&hits, 1) w.Header().Set("Content-Type", "image/png") _, _ = w.Write(body) })) defer srv.Close() url := srv.URL + "/a.png" path := filepath.Join(dir, avatarCacheName(42, url)) // 先摆一个「写了一半」的缓存 if err := os.WriteFile(path, body[:len(body)-8], 0644); err != nil { t.Fatal(err) } if validCachedAvatar(path) { t.Fatal("前置条件不成立:半截文件应当被判为无效") } got := ensureAvatar(42, url, dir) if got != path { t.Fatalf("应当返回缓存路径 %q,实际 %q", path, got) } if atomic.LoadInt64(&hits) == 0 { t.Fatal("半截缓存应当触发重新下载") } if !validCachedAvatar(path) { t.Fatal("重新下载之后应当是完整的 PNG") } } // 完好的缓存不该再走网络 func TestEnsureAvatarUsesGoodCache(t *testing.T) { withJobFlags(t, true, false, false) dir := t.TempDir() var hits int64 body := tinyPNG(t) srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { atomic.AddInt64(&hits, 1) _, _ = w.Write(body) })) defer srv.Close() url := srv.URL + "/b.png" path := filepath.Join(dir, avatarCacheName(43, url)) if err := os.WriteFile(path, body, 0644); err != nil { t.Fatal(err) } if got := ensureAvatar(43, url, dir); got != path { t.Fatalf("应当直接命中缓存,实际 %q", got) } if n := atomic.LoadInt64(&hits); n != 0 { t.Fatalf("完好的缓存不该再请求网络,实际 %d 次", n) } } // 下载过程中不会留下能被误用的半截文件 func TestDownloadAvatarWritesAtomically(t *testing.T) { withJobFlags(t, true, false, false) dir := t.TempDir() srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { _, _ = w.Write(tinyPNG(t)) })) defer srv.Close() url := srv.URL + "/c.png" path := filepath.Join(dir, avatarCacheName(44, url)) if err := downloadAvatar(44, url, path); err != nil { t.Fatalf("下载不该失败:%v", err) } if !validCachedAvatar(path) { t.Fatal("下载完的缓存应当是完整 PNG") } // 临时文件不该留下 if entries, err := os.ReadDir(dir); err == nil { for _, e := range entries { if strings.HasSuffix(e.Name(), ".part") { t.Fatalf("不该留下临时文件:%s", e.Name()) } } } } // 记录当前日志长度,返回一个能读到之后新增内容的函数 func logCursor() func() string { logString.Lock() start := logString.str.Len() logString.Unlock() return func() string { logString.Lock() defer logString.Unlock() data := logString.str.String() if start > len(data) { return data // 中间被 Reset 过,那就给全部 } return data[start:] } } // 头像输入打不开属于「跟编码器无关」的失败:不该挨个编码器回退重试, // 而且 ffmpeg 自己的报错必须留在日志里(否则只剩一句 exit status 0xffffffea) func TestRenderChunkDoesNotFallBackOnUnrelatedFailure(t *testing.T) { ffmpegFile := getFFmpeg() if ffmpegFile == "" { t.Skip("找不到 ffmpeg,跳过") } withJobFlags(t, true, false, false) dir := t.TempDir() video := filepath.Join(dir, "in.mp4") makeTinyVideo(t, ffmpegFile, video, "red") uid := int64(1) chunk := danmuChunk{start: 0, end: 1, records: []danmuRecord{ {Time: 0.2, UID: uid, Name: "甲", Content: "正常一条", Lane: 0, Width: 100}, }} ctx := &burnContext{ ffmpegFile: ffmpegFile, videoFile: video, outFile: filepath.Join(dir, "out.mp4"), meta: danmuMeta{PlayResX: 160, PlayResY: 120, FontSize: 20}, // 头像文件根本不存在:ffmpeg 打开输入就会失败 avatars: map[int64]string{uid: filepath.Join(dir, "根本没有这个头像.png")}, dir: dir, // 故意不指定 encoder:从候选列表第一个开始,才能看出有没有挨个回退 } ad, gap := avatarSize(ctx.meta.FontSize), avatarGap(ctx.meta.FontSize) if err := writeChunkAss(filepath.Join(dir, "chunk.ass"), ctx.meta, chunk, 0, ad, gap); err != nil { t.Fatal(err) } read := logCursor() err := ctx.renderChunk(chunk, "chunk.ass", filepath.Join(dir, "out.mp4"), false, nil) if err == nil { t.Fatal("头像打不开时应当渲染失败") } msg := err.Error() if !strings.Contains(msg, "ffmpeg 最后的输出") { t.Fatalf("错误里应当带上 ffmpeg 自己的输出:%s", msg) } if log := read(); strings.Contains(log, "回退到") { t.Fatalf("输入打不开跟编码器无关,不该挨个回退重试:\n%s", log) } } // 启动时那段版本/编译配置要过滤掉,别把真正的报错挤出日志 func TestTailWriterSkipsStartupNoise(t *testing.T) { w := newTailWriter(10) fmt.Fprint(w, "ffmpeg version n9.0.2-3\n"+ " built with gcc 16.2.0\n"+ " configuration: --prefix=/ffbuild --enable-gpl --enable-libass 等等很长的一行\n"+ " libavutil 61. 1.102 / 61. 1.102\n"+ " libswscale 10. 1.102 / 10. 1.102\n"+ "[in#0 @ 0x1] moov atom not found\n"+ "Error opening input files: Invalid data found when processing input\n") got := w.String() for _, noise := range []string{"ffmpeg version", "configuration:", "libavutil", "libswscale", "built with"} { if strings.Contains(got, noise) { t.Fatalf("启动信息应当被过滤掉(%s),实际:%q", noise, got) } } if !strings.Contains(got, "moov atom not found") || !strings.Contains(got, "Invalid data found") { t.Fatalf("真正的报错必须保留:%q", got) } } // 头像直径再小也要 >= 1,否则 scale=0:0 会让 ffmpeg 直接 EINVAL func TestAvatarSizeNeverZero(t *testing.T) { for _, fs := range []int{0, 1, 2, 5, 42, 60} { if got := avatarSize(fs); got < 1 { t.Fatalf("字号 %d 算出的头像直径是 %d,应当至少为 1", fs, got) } } if got := avatarSize(60); got != 48 { t.Fatalf("正常字号的口径不该变:60 -> %d(应为 48)", got) } }