package atomize import ( "encoding/json" "math" "net/http" "net/http/httptest" "strings" "testing" "time" ) func ptr(f float64) *float64 { return &f } func sum(xs []float64) float64 { t := 0.0 for _, x := range xs { t += x } return t } func TestFeatureCoefficientsUsesEstimations(t *testing.T) { // The shape the live API returns: estimations already summing to 1. got, estimated, err := featureCoefficients([]apFeature{ {Estimation: ptr(0.3636363636)}, {Estimation: ptr(0.6363636364)}, }) if err != nil { t.Fatalf("unexpected error: %v", err) } if !estimated { t.Fatal("expected estimations to be used") } if math.Abs(sum(got)-1) > 1e-9 { t.Fatalf("coefficients must sum to exactly 1, got %v (sum %v)", got, sum(got)) } if math.Abs(got[0]-0.3636) > 1e-9 { t.Fatalf("estimation not preserved: %v", got) } } func TestFeatureCoefficientsRescalesUnnormalized(t *testing.T) { // Nothing in the spec promises a normalized feature set. got, estimated, err := featureCoefficients([]apFeature{ {Estimation: ptr(0.2)}, {Estimation: ptr(0.2)}, {Estimation: ptr(0.4)}, }) if err != nil { t.Fatalf("unexpected error: %v", err) } if !estimated { t.Fatal("expected estimations to be used") } if math.Abs(sum(got)-1) > 1e-9 { t.Fatalf("want sum 1, got %v (sum %v)", got, sum(got)) } if math.Abs(got[2]-0.5) > 1e-9 { t.Fatalf("want proportional rescale (0.25/0.25/0.5), got %v", got) } } func TestFeatureCoefficientsFallsBackToEvenSplit(t *testing.T) { // estimation is optional and nullable — a missing one must not fail the // job, and must be reported so the consultant knows the split is not real. got, estimated, err := featureCoefficients([]apFeature{ {Estimation: ptr(0.5)}, {Estimation: nil}, {Estimation: ptr(0.2)}, }) if err != nil { t.Fatalf("unexpected error: %v", err) } if estimated { t.Fatal("expected fallback to even split when an estimation is missing") } if math.Abs(sum(got)-1) > 1e-9 { t.Fatalf("want sum 1, got %v (sum %v)", got, sum(got)) } for _, c := range got { if math.Abs(c-1.0/3.0) > 1e-3 { t.Fatalf("want even split, got %v", got) } } } func TestNormalizeToOneRejectsNonPositive(t *testing.T) { for _, bad := range [][]float64{{0.5, 0}, {0.5, -0.1}, {math.NaN()}, {}} { if _, err := normalizeToOne(bad); err == nil { t.Fatalf("want error for %v", bad) } } } func TestIssueTextCarriesFieldsTheAPILacks(t *testing.T) { // The API takes a single `issue` string: criteria, links and attachments // have no fields of their own and must survive in the text. got := issueText(AtomizeRequest{ Title: "Add CSV export", Description: "Customers cannot export.", AcceptanceCriteria: []string{"Downloads a .csv"}, Links: []string{"https://tracker/1"}, Attachments: []Attachment{{Name: "mock.png", MimeType: "image/png", URL: "https://app/f/1"}}, }) for _, want := range []string{"Add CSV export", "Customers cannot export.", "Downloads a .csv", "https://tracker/1", "mock.png", "https://app/f/1"} { if !strings.Contains(got, want) { t.Errorf("issue text lost %q:\n%s", want, got) } } } func TestGuidanceNoteFoldsInConstraints(t *testing.T) { got := guidanceNote(AtomizeRequest{ SubdivisionNote: "Keep it small.", Constraints: &Constraints{MinTasks: 2, MaxTasks: 5}, }) if !strings.Contains(got, "Keep it small.") || !strings.Contains(got, "between 2 and 5") { t.Fatalf("want note + constraints, got %q", got) } } func TestTruncateDoesNotSplitRunes(t *testing.T) { if got := truncate("héllo", 2); got != "h" { t.Fatalf("want %q, got %q", "h", got) } } // Exercises the full remote path against a stub speaking the Anypreta wire // format, asserting both the request we send and the children we build. func TestRemoteAtomizeMapsFeaturesToChildren(t *testing.T) { var got apAtomizeIssueRequest srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.URL.Path != "/v1/atomize-issue" { t.Errorf("wrong path: %s", r.URL.Path) } if auth := r.Header.Get("Authorization"); auth != "Bearer k" { t.Errorf("wrong auth header: %q", auth) } if err := json.NewDecoder(r.Body).Decode(&got); err != nil { t.Errorf("decode: %v", err) } w.Header().Set("Content-Type", "application/json") w.Write([]byte(`{"feature_set":{"features":[ {"id":"f1","title":"UI","stakeholder_summary":"s","description":"d1", "acceptance_criteria":[{"description":"c1"},{"description":"c2"}], "priority":1,"related_components":["web"],"estimation":0.25}, {"id":"f2","title":"API","stakeholder_summary":"s","description":"d2", "acceptance_criteria":[{"description":"c3"}], "priority":2,"related_components":["api"],"estimation":0.75}]}}`)) })) defer srv.Close() resp, err := newRemote(srv.URL, "k", 5*time.Second).Atomize(t.Context(), AtomizeRequest{ TaskID: "t1", Title: "Export", Description: "desc", CustomerID: "cust1", CustomerName: "Acme", }) if err != nil { t.Fatalf("Atomize: %v", err) } if got.System.ID != "cust1" || !strings.Contains(got.System.Description, "Acme") { t.Errorf("system not derived from customer: %+v", got.System) } // An empty component tree makes the live service return zero features // (422), so never let the tree go missing. if len(got.System.Components) == 0 { t.Error("system.components must not be empty: the service only emits features for in-scope components") } if len(resp.Tasks) != 2 { t.Fatalf("want 2 children, got %d", len(resp.Tasks)) } if resp.Tasks[0].Title != "UI" || resp.Tasks[0].Description != "d1" { t.Errorf("bad child mapping: %+v", resp.Tasks[0]) } if len(resp.Tasks[0].AcceptanceCriteria) != 2 || resp.Tasks[0].AcceptanceCriteria[0] != "c1" { t.Errorf("acceptance criteria not mapped: %+v", resp.Tasks[0].AcceptanceCriteria) } if resp.Tasks[0].EffortCoefficient != 0.25 || resp.Tasks[1].EffortCoefficient != 0.75 { t.Errorf("estimation not mapped to effort coefficient: %v / %v", resp.Tasks[0].EffortCoefficient, resp.Tasks[1].EffortCoefficient) } if resp.Notes != "" { t.Errorf("no even-split note expected when estimations are present: %q", resp.Notes) } } // A remote-backed client must still serve Extend and Summarize from the §5.1 // service — the Anypreta API has no counterpart for either. func TestRemoteDoesNotHijackExtend(t *testing.T) { var hit string mock := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { hit = r.URL.Path w.Header().Set("Content-Type", "application/json") w.Write([]byte(`{"task":{"title":"t","description":"d","acceptanceCriteria":[],"effortCoefficient":0.5}}`)) })) defer mock.Close() c := New(func() string { return mock.URL }, "tok", 5*time.Second) c.UseRemote("http://remote.invalid", "k", 5*time.Second) if _, err := c.Extend(t.Context(), ExtendRequest{TaskID: "t1", ExtensionNote: "more"}); err != nil { t.Fatalf("Extend: %v", err) } if hit != "/v1/extend" { t.Fatalf("Extend must go to the §5.1 service, hit %q", hit) } }