package atomize import ( "context" "encoding/json" "errors" "math" "net/http" "net/http/httptest" "testing" "time" "bountyboard/internal/extsvc" ) func TestNormalizeCoefficients(t *testing.T) { tests := []struct { name string in []float64 wantErr bool wantSum float64 }{ {"exact sum kept", []float64{0.2, 0.5, 0.3}, false, 1}, {"tiny deviation accepted", []float64{0.2, 0.5, 0.3001}, false, 1}, {"small deviation renormalized", []float64{0.21, 0.52, 0.31}, false, 1}, // sum 1.04 {"under-sum renormalized", []float64{0.3, 0.3, 0.36}, false, 1}, // sum 0.96 {"large deviation rejected", []float64{0.5, 0.5, 0.5}, true, 0}, {"zero coefficient rejected", []float64{0, 0.5, 0.5}, true, 0}, {"negative rejected", []float64{-0.1, 0.6, 0.5}, true, 0}, {"above one rejected", []float64{1.2}, true, 0}, {"empty rejected", nil, true, 0}, {"single exact", []float64{1.0}, false, 1}, {"NaN rejected", []float64{math.NaN(), 0.5}, true, 0}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { out, err := NormalizeCoefficients(tt.in) if tt.wantErr { if !errors.Is(err, ErrBadCoefficients) { t.Fatalf("want ErrBadCoefficients, got %v", err) } return } if err != nil { t.Fatalf("unexpected error: %v", err) } sum := 0.0 for _, c := range out { if c <= 0 || c > 1 { t.Errorf("normalized coefficient %v out of range", c) } sum += c } if math.Abs(sum-tt.wantSum) > 1e-9 { t.Errorf("sum = %.6f, want exactly %v (coeffs %v)", sum, tt.wantSum, out) } }) } } func fakeAtomizer(t *testing.T, handler http.HandlerFunc) *Client { t.Helper() srv := httptest.NewServer(handler) t.Cleanup(srv.Close) return New(func() string { return srv.URL }, "test-token", 5*time.Second) } func TestAtomizeAppliesNormalization(t *testing.T) { client := fakeAtomizer(t, func(w http.ResponseWriter, r *http.Request) { if r.Header.Get("Authorization") != "Bearer test-token" { t.Errorf("missing bearer token") } var req AtomizeRequest json.NewDecoder(r.Body).Decode(&req) if req.TaskID != "01JTASK" { t.Errorf("taskId = %q", req.TaskID) } w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(AtomizeResponse{Tasks: []SubTask{ {Title: "A", EffortCoefficient: 0.31}, // sum 1.03 → renormalize {Title: "B", EffortCoefficient: 0.41}, {Title: "C", EffortCoefficient: 0.31}, }}) }) resp, err := client.Atomize(context.Background(), AtomizeRequest{TaskID: "01JTASK"}) if err != nil { t.Fatal(err) } sum := 0.0 for _, st := range resp.Tasks { sum += st.EffortCoefficient } if math.Abs(sum-1) > 1e-9 { t.Fatalf("normalized sum = %v", sum) } } func TestAtomizeRejectsBadSum(t *testing.T) { client := fakeAtomizer(t, func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(AtomizeResponse{Tasks: []SubTask{ {Title: "A", EffortCoefficient: 0.9}, {Title: "B", EffortCoefficient: 0.9}, }}) }) _, err := client.Atomize(context.Background(), AtomizeRequest{}) if !errors.Is(err, ErrBadCoefficients) { t.Fatalf("want ErrBadCoefficients, got %v", err) } } func TestExtendValidatesCoefficient(t *testing.T) { coeff := 2.5 client := fakeAtomizer(t, func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(ExtendResponse{Task: SubTask{Title: "X", EffortCoefficient: coeff}}) }) if _, err := client.Extend(context.Background(), ExtendRequest{}); !errors.Is(err, ErrBadCoefficients) { t.Fatalf("coefficient 2.5: want ErrBadCoefficients, got %v", err) } coeff = 1.4 // legal for extensions if _, err := client.Extend(context.Background(), ExtendRequest{}); err != nil { t.Fatalf("coefficient 1.4 should pass: %v", err) } } func TestBreakerOpensAfterRepeatedFailures(t *testing.T) { client := fakeAtomizer(t, func(w http.ResponseWriter, r *http.Request) { http.Error(w, "boom", http.StatusInternalServerError) }) for i := 0; i < 3; i++ { if _, err := client.Atomize(context.Background(), AtomizeRequest{}); err == nil { t.Fatal("expected failure") } } if client.BreakerState() != "open" { t.Fatalf("breaker = %s, want open", client.BreakerState()) } _, err := client.Atomize(context.Background(), AtomizeRequest{}) if !errors.Is(err, extsvc.ErrBreakerOpen) { t.Fatalf("want ErrBreakerOpen, got %v", err) } }