package evolve import ( "context" "errors" "net/url" "os" "path/filepath" "sync" "testing" "time" ) func newFeedbackChannel(t *testing.T) *FileFeedbackChannel { t.Helper() c, err := NewFileFeedbackChannel(t.TempDir()) if err != nil { t.Fatalf("NewFileFeedbackChannel: %v", err) } return c } func TestFileFeedbackChannel_ReportThenQuery(t *testing.T) { c := newFeedbackChannel(t) ctx := context.Background() before := time.Now().UTC().Add(-time.Second) for i := 0; i < 3; i++ { if err := c.Report(ctx, "carrier-A", "on_time_rate", 0.9+float64(i)*0.01, 0.8, nil); err != nil { t.Fatalf("Report %d: %v", i, err) } } fbs, err := c.Query(ctx, "carrier-A", before, "") if err != nil { t.Fatalf("Query: %v", err) } if len(fbs) != 3 { t.Fatalf("want 3, got %d", len(fbs)) } for i := 1; i < len(fbs); i++ { if fbs[i].Timestamp.Before(fbs[i-1].Timestamp) { t.Errorf("results not sorted ascending at index %d", i) } } } func TestFileFeedbackChannel_QueryMetricFilter(t *testing.T) { c := newFeedbackChannel(t) ctx := context.Background() before := time.Now().UTC().Add(-time.Second) if err := c.Report(ctx, "carrier-A", "on_time", 0.9, 0.8, nil); err != nil { t.Fatal(err) } if err := c.Report(ctx, "carrier-A", "damage_rate", 0.01, 0.7, nil); err != nil { t.Fatal(err) } all, err := c.Query(ctx, "carrier-A", before, "") if err != nil { t.Fatal(err) } if len(all) != 2 { t.Errorf("empty metric (all): want 2, got %d", len(all)) } onTime, err := c.Query(ctx, "carrier-A", before, "on_time") if err != nil { t.Fatal(err) } if len(onTime) != 1 || onTime[0].Metric != "on_time" { t.Errorf("metric filter: got %+v", onTime) } } func TestFileFeedbackChannel_QueryUnknownEntity(t *testing.T) { c := newFeedbackChannel(t) ctx := context.Background() fbs, err := c.Query(ctx, "never-reported", time.Time{}, "") if err != nil { t.Errorf("Query unknown entity: want nil error, got %v", err) } if len(fbs) != 0 { t.Errorf("want empty slice, got %d", len(fbs)) } } func TestFileFeedbackChannel_ReportValidation(t *testing.T) { c := newFeedbackChannel(t) ctx := context.Background() if err := c.Report(ctx, "", "m", 1, 1, nil); !errors.Is(err, ErrEntityRequired) { t.Errorf("empty entity: want ErrEntityRequired, got %v", err) } if err := c.Report(ctx, "e", "", 1, 1, nil); !errors.Is(err, ErrMetricRequired) { t.Errorf("empty metric: want ErrMetricRequired, got %v", err) } } func TestFileFeedbackChannel_QueryValidation(t *testing.T) { c := newFeedbackChannel(t) ctx := context.Background() if _, err := c.Query(ctx, "", time.Time{}, ""); !errors.Is(err, ErrEntityRequired) { t.Errorf("Query empty entity: want ErrEntityRequired, got %v", err) } } func TestFileFeedbackChannel_SinceFilter(t *testing.T) { c := newFeedbackChannel(t) ctx := context.Background() if err := c.Report(ctx, "e", "m", 1, 1, nil); err != nil { t.Fatal(err) } // Choose since in the future to filter out everything. future := time.Now().UTC().Add(time.Hour) fbs, err := c.Query(ctx, "e", future, "") if err != nil { t.Fatal(err) } if len(fbs) != 0 { t.Errorf("future since: want 0, got %d", len(fbs)) } } func TestFileFeedbackChannel_EntityEscape(t *testing.T) { c := newFeedbackChannel(t) ctx := context.Background() // Entity with slashes, spaces, CJK: url.PathEscape makes a safe dir name. entity := "承运商 / Y-express" if err := c.Report(ctx, entity, "m", 0.5, 0.5, nil); err != nil { t.Fatalf("Report: %v", err) } expectedDir := filepath.Join(c.root, url.PathEscape(entity)) if _, err := os.Stat(expectedDir); err != nil { t.Errorf("expected dir %s: %v", expectedDir, err) } fbs, err := c.Query(ctx, entity, time.Time{}, "") if err != nil { t.Fatalf("Query: %v", err) } if len(fbs) != 1 || fbs[0].Entity != entity { t.Errorf("entity roundtrip: got %+v", fbs) } } func TestFileFeedbackChannel_MetaRoundtrip(t *testing.T) { c := newFeedbackChannel(t) ctx := context.Background() meta := map[string]any{"source": "dispatch", "confidence_note": "sample < 10"} if err := c.Report(ctx, "e", "m", 1, 0.5, meta); err != nil { t.Fatal(err) } fbs, err := c.Query(ctx, "e", time.Time{}, "") if err != nil { t.Fatal(err) } if len(fbs) != 1 { t.Fatalf("want 1, got %d", len(fbs)) } got := fbs[0].Meta if got["source"] != "dispatch" || got["confidence_note"] != "sample < 10" { t.Errorf("meta roundtrip: got %+v", got) } } func TestFileFeedbackChannel_ReportCtxCanceled(t *testing.T) { c := newFeedbackChannel(t) ctx, cancel := context.WithCancel(context.Background()) cancel() err := c.Report(ctx, "e", "m", 1, 1, nil) if !errors.Is(err, context.Canceled) { t.Errorf("Report ctx canceled: want context.Canceled, got %v", err) } } func TestFileFeedbackChannel_ReportConcurrent(t *testing.T) { c := newFeedbackChannel(t) ctx := context.Background() const workers = 8 const perWorker = 25 var wg sync.WaitGroup before := time.Now().UTC().Add(-time.Second) for w := 0; w < workers; w++ { wg.Add(1) go func(w int) { defer wg.Done() for i := 0; i < perWorker; i++ { if err := c.Report(ctx, "shared-entity", "m", float64(w*perWorker+i), 1, nil); err != nil { t.Errorf("Report: %v", err) return } } }(w) } wg.Wait() fbs, err := c.Query(ctx, "shared-entity", before, "") if err != nil { t.Fatalf("Query: %v", err) } if len(fbs) != workers*perWorker { t.Errorf("concurrent Report count: want %d, got %d", workers*perWorker, len(fbs)) } } func TestFileFeedbackChannel_IsolationBetweenEntities(t *testing.T) { c := newFeedbackChannel(t) ctx := context.Background() if err := c.Report(ctx, "A", "m", 1, 1, nil); err != nil { t.Fatal(err) } if err := c.Report(ctx, "B", "m", 2, 1, nil); err != nil { t.Fatal(err) } a, err := c.Query(ctx, "A", time.Time{}, "") if err != nil { t.Fatal(err) } if len(a) != 1 || a[0].Entity != "A" { t.Errorf("entity A: got %+v", a) } b, err := c.Query(ctx, "B", time.Time{}, "") if err != nil { t.Fatal(err) } if len(b) != 1 || b[0].Entity != "B" { t.Errorf("entity B: got %+v", b) } } func TestFileFeedbackChannel_NewCreatesRoot(t *testing.T) { dir := filepath.Join(t.TempDir(), "nested", "root") if _, err := NewFileFeedbackChannel(dir); err != nil { t.Fatalf("NewFileFeedbackChannel: %v", err) } info, err := os.Stat(dir) if err != nil || !info.IsDir() { t.Errorf("root not created: %v %v", info, err) } }