package logger import ( "os" "path/filepath" "strings" "testing" ) // TestStructuredLogging 验证 LogEvent→sink→MemoryLogStore 结构化链路: // LogEntry 的 category/level/account 应直接由调用方填入,而非字符串反解析。 func TestStructuredLogging(t *testing.T) { store := NewMemoryLogStore(100) cleanup := InitLogging(store, t.TempDir(), "测试实例", "/tmp/douyu-go-test") defer func() { cleanup() globalSink = nil }() LogEvent("抢购", "success", "兑换成功: %s", "商品A") LogAccountEvent("抢购", "warn", "testuser", "冷却: %ds", 5) entries := store.Since(0) // InitLogging 自身产生 1 条"日志文件已启用",加上上述 2 条,共 3 条 if len(entries) != 3 { t.Fatalf("Since(0) = %d entries, want 3", len(entries)) } if entries[0].Category != "日志" { t.Errorf("entry0 Category = %q, want '日志' (InitLogging 启动日志)", entries[0].Category) } e1 := entries[1] if e1.ID != 2 { t.Errorf("entry1 ID = %d, want 2", e1.ID) } if e1.Category != "抢购" { t.Errorf("entry1 Category = %q, want '抢购'", e1.Category) } if e1.Level != "success" { t.Errorf("entry1 Level = %q, want 'success'", e1.Level) } if e1.Account != "" { t.Errorf("entry1 Account = %q, want empty", e1.Account) } if e1.Time == "" { t.Error("entry1 Time is empty") } if !strings.Contains(e1.Message, "[抢购][success]") { t.Errorf("entry1 Message = %q, want contains '[抢购][success]'", e1.Message) } if !strings.Contains(e1.Message, "兑换成功: 商品A") { t.Errorf("entry1 Message = %q, want contains '兑换成功: 商品A'", e1.Message) } e2 := entries[2] if e2.ID != 3 { t.Errorf("entry2 ID = %d, want 3", e2.ID) } if e2.Account != "testuser" { t.Errorf("entry2 Account = %q, want 'testuser'", e2.Account) } if !strings.Contains(e2.Message, "[账号:testuser]") { t.Errorf("entry2 Message = %q, want contains '[账号:testuser]'", e2.Message) } if !strings.Contains(e2.Message, "冷却: 5s") { t.Errorf("entry2 Message = %q, want contains '冷却: 5s'", e2.Message) } // 验证 minLevel 过滤:设为 error 后 info 日志不应进入内存 SetMinLevel("error") LogEvent("测试", "info", "这条应被过滤") if got := store.Since(e2.ID); len(got) != 0 { t.Errorf("after SetMinLevel(error), Since(%d) = %d entries, want 0", e2.ID, len(got)) } SetMinLevel("info") // 恢复,避免影响后续测试 } // TestCreateUniqueLogFileAvoidsCollision 同一目录同秒启动的两个进程不能写同一个文件, // 否则日志会交错、分不清实例。 func TestCreateUniqueLogFileAvoidsCollision(t *testing.T) { dir := t.TempDir() base := filepath.Join(dir, "douyu_20261003_160856") first, firstPath, err := createUniqueLogFile(base) if err != nil { t.Fatal(err) } _ = first.Close() if got := filepath.Base(firstPath); got != "douyu_20261003_160856.log" { t.Fatalf("first = %q", got) } second, secondPath, err := createUniqueLogFile(base) if err != nil { t.Fatal(err) } _ = second.Close() if got := filepath.Base(secondPath); got != "douyu_20261003_160856-2.log" { t.Fatalf("second = %q, want 同目录 -2 后缀", got) } } // TestLogFileHeaderDocumentsFormat 日志文件开头必须说明双时间含义、实例名与数据目录, // 否则把日志复制或汇总后看不出两个时间分别是什么、来自哪台机器的哪个实例。 func TestLogFileHeaderDocumentsFormat(t *testing.T) { dir := t.TempDir() store := NewMemoryLogStore(20) cleanup := InitLogging(store, dir, "实例 2", `C:\Users\Administrator\AppData\Roaming\douyu-go\instances\instance-2`) LogEvent("系统", "success", "已启动实例 2") cleanup() globalSink = nil path := store.FilePath() if path == "" { t.Fatal("日志文件路径为空") } data, err := os.ReadFile(path) if err != nil { t.Fatal(err) } content := string(data) for _, want := range []string{ "实例: 实例 2", "数据目录: ", "时间格式: 本地时间(D斗鱼估算时间)", "校准完成前偏移为 0", "logLevel 改成 debug", "已启动实例 2", } { if !strings.Contains(content, want) { t.Errorf("日志文件缺少 %q\n实际内容:\n%s", want, content) } } if !strings.HasPrefix(content, "====") { t.Errorf("表头应在文件最开始:\n%s", content) } }