审查发现:录制时每写一条弹幕都调用 os.File.Write / fmt.Fprintf, 但返回值一个都没接。磁盘写满、外接盘或网络盘掉线时,写入会失败而程序 毫无察觉 —— 照样打印「录制完成」、照样自动合并,最后拿到的成片缺了一大截 弹幕,界面上看不出是「缺了」还是「本来就没弹幕」。 1. 写入器记住写盘错误 danmuWriter 增加一个 err 字段:每条弹幕的 ass / nick.ass / jsonl 写入, 以及文件头的写入,都检查返回值。只保留第一个错误 —— 磁盘满了之后 每写一条都会失败,全记下来只会把日志刷爆,而第一个就足以说明原因。 2. close() 负责把错误交出来 三个文件的 Close 错误也要记:os.File 在部分系统上(NFS、某些网络盘) 是到 Close 才报出「写不进去」,只盯每次 Write 的返回值会漏掉这种情况。 getDanmu 里拿到错误就明确告警,并把这条记录标记成「弹幕文件写坏了」。 3. 写坏的本场跳过自动合并 光打一条日志不够 —— 照样会合成一个缺弹幕的成片。liveInfo 增加 danmuWriteFailed,finishPart 据此跳过合并,录播和弹幕文件都留着, 用户检查磁盘后还能手动合并。 已验证: - 新增 4 个用例: TestDanmuWriteErrorSurfacedOnClose(写入失败后 failed() 为真、 close() 必须把错误交出来、错误里能看出是哪个文件)、 TestDanmuWriteKeepsOnlyFirstError(只记第一个)、 TestDanmuWriteNoFalseAlarm(正常录制不能误判,这是加检查最容易踩的反向坑)、 TestFinishPartSkipsMergeWhenDanmuBroken(先跑一个「条件都满足应当合并」 的对照组确认测试本身有效,再验证标了写坏之后不合并)。 - 反向验证:把 jsonl 的两处 Write 错误改回丢弃, TestDanmuWriteErrorSurfacedOnClose 立刻报「failed() 应为 true」。 - 测试踩到的两个坑(都记进注释):替换 w.jsonl 前要先关掉原句柄否则 Windows 上临时目录删不掉;测试里没跑 initialize(),lInfoMap.info 是 nil, 直接赋值会 panic。 - gofmt / go vet / go build / GOOS=linux go build / go test / go test -race 全过, 全量测试连跑两次稳定。
244 lines
7.8 KiB
Go
244 lines
7.8 KiB
Go
package main
|
||
|
||
import (
|
||
"bufio"
|
||
"context"
|
||
"encoding/json"
|
||
"errors"
|
||
"os"
|
||
"path/filepath"
|
||
"strings"
|
||
"testing"
|
||
"time"
|
||
|
||
"github.com/orzogc/acfundanmu"
|
||
)
|
||
|
||
// 用真正的录制写入器(danmuWriter)产生 jsonl,再按合并的流程重排一遍,
|
||
// 验证「本程序自己录下来的弹幕」合出来和以前一模一样(弹道、宽度都不变)。
|
||
// 这也是手动合并与自动合并的分水岭:录制侧写文件完全不经过 relayoutLanes,
|
||
// 只有合并渲染前会重排,所以磁盘上的 .ass/.nick.ass/.jsonl 一个字节都不变。
|
||
func TestLiveRecordingPathUnaffected(t *testing.T) {
|
||
dir := t.TempDir()
|
||
cfg := acfundanmu.SubConfig{Title: "测试直播", PlayResX: 1920, PlayResY: 1080,
|
||
FontSize: 48, StartTime: 1700000000000000000}
|
||
ass := filepath.Join(dir, "live.ass")
|
||
w := newDanmuWriter("liveid", "stream", cfg, ass)
|
||
|
||
texts := []string{"来啦", "这首歌好听", "主播加油啊", "打卡打卡", "哈哈哈哈哈哈哈哈哈",
|
||
"这条弹幕稍微长一点看看弹道到底怎么排"}
|
||
for i := 0; i < 500; i++ {
|
||
relMs := int64(i) * 220 // 平均 0.22 秒一条,够密集
|
||
c := &acfundanmu.Comment{Content: texts[i%len(texts)]}
|
||
c.SendTime = (cfg.StartTime + relMs*1e6) / 1e6
|
||
c.UserID = int64(2000 + i%9)
|
||
c.Nickname = "观众" + string(rune('A'+i%9))
|
||
c.Avatar = "http://example.invalid/a.png"
|
||
w.write(c)
|
||
}
|
||
w.close()
|
||
|
||
f, err := os.Open(filepath.Join(dir, "live.jsonl"))
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
defer f.Close()
|
||
sc := bufio.NewScanner(f)
|
||
sc.Buffer(make([]byte, 1<<20), 1<<20)
|
||
var meta danmuMeta
|
||
first := true
|
||
var recs []danmuRecord
|
||
for sc.Scan() {
|
||
if first {
|
||
json.Unmarshal(sc.Bytes(), &meta)
|
||
first = false
|
||
continue
|
||
}
|
||
var r danmuRecord
|
||
if json.Unmarshal(sc.Bytes(), &r) == nil {
|
||
recs = append(recs, r)
|
||
}
|
||
}
|
||
type snap struct{ lane, width int }
|
||
before := make([]snap, len(recs))
|
||
for i, r := range recs {
|
||
before[i] = snap{r.Lane, r.Width}
|
||
}
|
||
|
||
relayoutLanes(recs, meta)
|
||
|
||
laneDiff, widthDiff := 0, 0
|
||
for i, r := range recs {
|
||
if r.Lane != before[i].lane {
|
||
laneDiff++
|
||
if laneDiff <= 3 {
|
||
t.Logf("第 %d 条弹道变了:%d → %d", i, before[i].lane, r.Lane)
|
||
}
|
||
}
|
||
if r.Width != before[i].width {
|
||
widthDiff++
|
||
}
|
||
}
|
||
t.Logf("录制产生 %d 条弹幕(含空内容 %d 条),重排后弹道变化 %d 条、宽度变化 %d 条",
|
||
len(recs), countEmpty(recs), laneDiff, widthDiff)
|
||
if laneDiff != 0 || widthDiff != 0 {
|
||
t.Errorf("正常录制的弹幕重排后不应有任何变化")
|
||
}
|
||
}
|
||
|
||
func countEmpty(recs []danmuRecord) int {
|
||
n := 0
|
||
for _, r := range recs {
|
||
if r.Content == "" {
|
||
n++
|
||
}
|
||
}
|
||
return n
|
||
}
|
||
|
||
// ==================== 写盘出错必须能被发现 ====================
|
||
// 弹幕是边录边写的,写盘失败(磁盘满、外接盘/网络盘掉线)如果没人管,
|
||
// 程序会照常宣告「录制完成」并自动合并 —— 出来的成片缺一大截弹幕,
|
||
// 而日志里什么都看不出来。下面几条例外情况锁住「失败要浮出来」。
|
||
|
||
// 写入失败必须被记下来,并且在 close() 时交出来
|
||
func TestDanmuWriteErrorSurfacedOnClose(t *testing.T) {
|
||
dir := t.TempDir()
|
||
cfg := acfundanmu.SubConfig{Title: "测试直播", PlayResX: 1920, PlayResY: 1080,
|
||
FontSize: 48, StartTime: 1700000000000000000}
|
||
ass := filepath.Join(dir, "live.ass")
|
||
w := newDanmuWriter("liveid", "stream", cfg, ass)
|
||
if w.failed() {
|
||
t.Fatalf("刚建好的写入器不该有错误,实际:%v", w.err)
|
||
}
|
||
|
||
// 用「写到已关闭的句柄」来走同一条错误路径(和磁盘满、盘掉线一样,
|
||
// 都是 os.File.Write 返回 error)。换掉之前先把原来的句柄关掉,别泄漏
|
||
if err := w.jsonl.Close(); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
dead, err := os.Open(filepath.Join(dir, "live.jsonl"))
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if err := dead.Close(); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
w.jsonl = dead
|
||
|
||
c := &acfundanmu.Comment{Content: "这条应该写失败"}
|
||
c.SendTime = (cfg.StartTime + int64(500)*1e6) / 1e6
|
||
c.UserID = 7
|
||
c.Nickname = "观众"
|
||
w.write(c)
|
||
|
||
if !w.failed() {
|
||
t.Fatal("写入失败之后 failed() 应为 true(改之前错误是被丢掉的)")
|
||
}
|
||
err = w.close()
|
||
if err == nil {
|
||
t.Fatal("close() 必须把写盘错误交出来,实际返回 nil")
|
||
}
|
||
if !strings.Contains(err.Error(), "jsonl") {
|
||
t.Errorf("错误信息里要能看出是哪个文件出的问题,实际:%v", err)
|
||
}
|
||
}
|
||
|
||
// 只记第一个错:磁盘满了之后每写一条都会失败,全记下来会把日志刷爆
|
||
func TestDanmuWriteKeepsOnlyFirstError(t *testing.T) {
|
||
w := &danmuWriter{}
|
||
w.fail("第一次", errors.New("磁盘满了"))
|
||
first := w.err
|
||
w.fail("第二次", errors.New("还是写不进去"))
|
||
w.fail("第三次", errors.New("依旧写不进去"))
|
||
if w.err != first {
|
||
t.Fatalf("错误应保持在第一个,实际被后来的覆盖了:%v", w.err)
|
||
}
|
||
if !strings.Contains(w.err.Error(), "磁盘满了") {
|
||
t.Errorf("应保留第一个错误的原因,实际:%v", w.err)
|
||
}
|
||
}
|
||
|
||
// 正常录制不能被误判成失败(这是「加检查」最容易踩的反向坑)
|
||
func TestDanmuWriteNoFalseAlarm(t *testing.T) {
|
||
dir := t.TempDir()
|
||
cfg := acfundanmu.SubConfig{Title: "测试直播", PlayResX: 1920, PlayResY: 1080,
|
||
FontSize: 48, StartTime: 1700000000000000000}
|
||
w := newDanmuWriter("liveid", "stream", cfg, filepath.Join(dir, "ok.ass"))
|
||
for i := 0; i < 30; i++ {
|
||
c := &acfundanmu.Comment{Content: "正常弹幕"}
|
||
c.SendTime = (cfg.StartTime + int64(i)*220*1e6) / 1e6
|
||
c.UserID = int64(100 + i)
|
||
c.Nickname = "观众"
|
||
w.write(c)
|
||
}
|
||
if w.failed() {
|
||
t.Fatalf("正常写入不该判失败:%v", w.err)
|
||
}
|
||
if err := w.close(); err != nil {
|
||
t.Fatalf("正常写完后 close() 应返回 nil,实际:%v", err)
|
||
}
|
||
}
|
||
|
||
// 弹幕文件写坏之后必须跳过自动合并:只是打一条日志的话,
|
||
// 照样会合成一个「缺了一大截弹幕」的成片,而且是静默的
|
||
func TestFinishPartSkipsMergeWhenDanmuBroken(t *testing.T) {
|
||
oldAuto, oldCtx := config.AutoMerge, mainCtx
|
||
t.Cleanup(func() {
|
||
config.AutoMerge, mainCtx = oldAuto, oldCtx
|
||
// 等后台那两个(可能启动了的)合并跑完,别把任务状态留给后面的用例
|
||
takeMergeSlot()
|
||
releaseMergeSlot()
|
||
})
|
||
config.AutoMerge = true
|
||
mainCtx = context.Background()
|
||
|
||
dir := t.TempDir()
|
||
video := filepath.Join(dir, "v.mp4")
|
||
ass := filepath.Join(dir, "v.ass")
|
||
if err := os.WriteFile(video, []byte("fake"), 0644); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if err := os.WriteFile(ass, []byte("[Script Info]"), 0644); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
|
||
// 测试里没跑 initialize(),这张表还没建起来
|
||
lInfoMap.Lock()
|
||
if lInfoMap.info == nil {
|
||
lInfoMap.info = make(map[string]liveInfo)
|
||
}
|
||
lInfoMap.Unlock()
|
||
|
||
// 对照组:一切都正常时应该触发合并
|
||
good := "good-live-id"
|
||
lInfoMap.Lock()
|
||
lInfoMap.info[good] = liveInfo{LiveID: good, uid: 1, assFile: ass, firstRecordFile: video,
|
||
recordStartNs: time.Now().UnixNano(), recordSegments: []recordSegment{{file: video, startNs: time.Now().UnixNano()}}}
|
||
lInfoMap.Unlock()
|
||
finishPart(good, true)
|
||
finishPart(good, false)
|
||
lInfoMap.RLock()
|
||
started := lInfoMap.info[good].mergeStarted
|
||
lInfoMap.RUnlock()
|
||
if !started {
|
||
t.Fatal("对照组:条件都满足时应当触发自动合并(否则这条测试本身没意义)")
|
||
}
|
||
|
||
// 实验组:同样条件,但弹幕写盘出过错
|
||
bad := "bad-live-id"
|
||
lInfoMap.Lock()
|
||
lInfoMap.info[bad] = liveInfo{LiveID: bad, uid: 2, assFile: ass, firstRecordFile: video,
|
||
danmuWriteFailed: true,
|
||
recordStartNs: time.Now().UnixNano(), recordSegments: []recordSegment{{file: video, startNs: time.Now().UnixNano()}}}
|
||
lInfoMap.Unlock()
|
||
finishPart(bad, true)
|
||
finishPart(bad, false)
|
||
lInfoMap.RLock()
|
||
got := lInfoMap.info[bad].mergeStarted
|
||
lInfoMap.RUnlock()
|
||
if got {
|
||
t.Error("弹幕文件写坏过,不该触发自动合并(否则会合成一个缺弹幕的成片)")
|
||
}
|
||
}
|