package logsys import ( "context" "errors" "strings" "sync" "testing" "time" ) type memSink struct { mu sync.Mutex entries []Entry } func (m *memSink) Write(e Entry) error { m.mu.Lock() defer m.mu.Unlock() m.entries = append(m.entries, e) return nil } func (m *memSink) Sync() error { return nil } func (m *memSink) Close() error { return nil } func (m *memSink) list() []Entry { m.mu.Lock() defer m.mu.Unlock() out := make([]Entry, len(m.entries)) copy(out, m.entries) return out } type stubErr struct{} func (stubErr) Error() string { return "dial failed" } func (stubErr) ErrorCode() string { return "agent.provider_unavailable" } func (stubErr) ErrorFields() map[string]any { return map[string]any{"model": "glm-4", "reason": "模型不可达"} } func TestLevelFilterModuleAndTrace(t *testing.T) { mem := &memSink{} fixed := time.Date(2026, 9, 29, 12, 0, 0, 0, time.UTC) log := New(Options{ Service: "agent-squad", Level: LevelInfo, ModuleLevels: map[string]Level{ "engine": LevelDebug, "server": LevelWarn, }, Sinks: []Sink{mem}, Now: func() time.Time { return fixed }, }) log.Debug("hidden") log.Info("visible") log.Module("engine").Debug("engine-debug") log.Module("server").Info("server-info") log.Module("server").Warn("server-warn") ctx := WithTrace(context.Background(), "trace-1") log.WithContext(ctx).With("step", "plan").Info("traced") got := mem.list() if len(got) != 4 { t.Fatalf("len %d %+v", len(got), got) } if got[0].Message != "visible" || got[0].Time != fixed || got[0].Service != "agent-squad" { t.Fatalf("first %+v", got[0]) } if got[1].Module != "engine" || got[1].Message != "engine-debug" { t.Fatalf("engine %+v", got[1]) } if got[2].Message != "server-warn" { t.Fatalf("server %+v", got[2]) } if got[3].TraceID != "trace-1" || got[3].Fields["step"] != "plan" { t.Fatalf("trace %+v", got[3]) } } func TestErrorDuckTypingAndRedact(t *testing.T) { mem := &memSink{} log := New(Options{ Level: LevelInfo, Sinks: []Sink{mem}, Redact: func(s string) string { return strings.ReplaceAll(s, "glm-4", "***") }, }) log.Error("provider down", stubErr{}, "attempt", 2) got := mem.list() if len(got) != 1 { t.Fatal(len(got)) } e := got[0] if e.Code != "agent.provider_unavailable" || e.Level != "error" { t.Fatalf("%+v", e) } if e.Fields["model"] != "***" || e.Fields["reason"] != "模型不可达" || e.Fields["attempt"] != 2 { t.Fatalf("fields %+v", e.Fields) } if !strings.Contains(e.Error, "dial failed") { t.Fatal(e.Error) } plain := errors.New("boom") log.Error("plain", plain) if mem.list()[1].Code != "" || mem.list()[1].Error != "boom" { t.Fatalf("%+v", mem.list()[1]) } } func TestDefaultLogger(t *testing.T) { prev := Default() t.Cleanup(func() { SetDefault(prev) }) mem := &memSink{} SetDefault(New(Options{Level: LevelInfo, Sinks: []Sink{mem}})) Default().Info("from-default") if err := Sync(); err != nil { t.Fatal(err) } if len(mem.list()) != 1 { t.Fatal(mem.list()) } } func TestHumanAndJSON(t *testing.T) { e := Entry{Time: time.Date(2026, 1, 2, 3, 4, 5, 0, time.UTC), Level: "info", Service: "svc", Message: "hello", Fields: map[string]any{"b": 1, "a": "x"}} line := FormatHuman(e) if !strings.Contains(line, "INFO svc hello") || !strings.Contains(line, "a=x") { t.Fatal(line) } raw, err := FormatJSON(e) if err != nil || !strings.Contains(string(raw), `"msg":"hello"`) { t.Fatalf("%s %v", raw, err) } }