451ea08414
web/handlers/standing.go: GET /standing/{reachID} renders standing score
+ bucket + 4-signal progress + Freeholder-eligible badge. Store extensions:
ListRatings/ListVouches/ListSlashes/ComputeStandingScore (simplified, from
locked x/standing/types constants PriorMean/PriorWeight/ComputeDiversityBonus/
GetVoucherWeight/GetStandingBucket — NOT hardcoded)/ComputeFreeholderSignals
(real FreeholderSignals struct + IsFreeholderEligible). Seed: holder-alia
(12 ratings, 4 cats, 1 Vouch, mature Stash -> eligible) vs holder-bryn
(3 ratings, 1 cat, no Vouch, immature -> not eligible). standing_test.go:
computed-from-locked-constants regression guard (PriorMean=4.0, PriorWeight=
10, DiversityBonus4Cats=0.10), Freeholder-eligible badge reflects
IsFreeholderEligible() (the real method), G-026 error lexicon check.
Coverage: store 98.0%, handlers 88.7%.
---ci---
project: oy
phase: 4
milestone: v0.6
status: execute
---/ci---
147 lines
5.6 KiB
Go
147 lines
5.6 KiB
Go
package handlers
|
|
|
|
import (
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"strings"
|
|
"testing"
|
|
|
|
standingtypes "github.com/oy/openyield/x/standing/types"
|
|
)
|
|
|
|
func TestStandingEligibleHolderRendersAllSignalsEarned(t *testing.T) {
|
|
srv := newTestServer(t)
|
|
mux := http.NewServeMux()
|
|
srv.Register(mux)
|
|
rec := httptest.NewRecorder()
|
|
req := httptest.NewRequest("GET", "/standing/holder-alia", nil)
|
|
mux.ServeHTTP(rec, req)
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("GET /standing/holder-alia: status %d, want 200", rec.Code)
|
|
}
|
|
body := rec.Body.String()
|
|
// holder-alia: 12 ratings in 4 categories, 1 Vouch, mature Stash, balance 920000.
|
|
// All 4 signals earned -> Freeholder-eligible.
|
|
if !strings.Contains(body, "Freeholder-eligible") {
|
|
t.Errorf("body missing 'Freeholder-eligible' label")
|
|
}
|
|
// Score displayed with 1 decimal.
|
|
if !strings.Contains(body, "4.") {
|
|
t.Errorf("body missing score (expected 4.x)")
|
|
}
|
|
// All 4 signals should show 'earned'.
|
|
earnedCount := strings.Count(body, "earned")
|
|
if earnedCount < 4 {
|
|
t.Errorf("body has %d 'earned' badges, want >=4 (all signals earned for holder-alia)", earnedCount)
|
|
}
|
|
assertNoBannedTerms(t, body)
|
|
}
|
|
|
|
func TestStandingNotEligibleHolderShowsNotYet(t *testing.T) {
|
|
srv := newTestServer(t)
|
|
mux := http.NewServeMux()
|
|
srv.Register(mux)
|
|
rec := httptest.NewRecorder()
|
|
req := httptest.NewRequest("GET", "/standing/holder-bryn", nil)
|
|
mux.ServeHTTP(rec, req)
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("GET /standing/holder-bryn: status %d, want 200", rec.Code)
|
|
}
|
|
body := rec.Body.String()
|
|
// holder-bryn: 3 ratings in 1 category, no Vouch, immature Stash.
|
|
// Not eligible.
|
|
if !strings.Contains(body, "not yet") {
|
|
t.Errorf("body missing 'not yet' badge for non-eligible holder-bryn")
|
|
}
|
|
if strings.Contains(body, "Freeholder-eligible\">yes") {
|
|
t.Errorf("body shows eligible=yes for holder-bryn (should not be eligible)")
|
|
}
|
|
assertNoBannedTerms(t, body)
|
|
}
|
|
|
|
func TestStandingMissingReturns404(t *testing.T) {
|
|
srv := newTestServer(t)
|
|
mux := http.NewServeMux()
|
|
srv.Register(mux)
|
|
rec := httptest.NewRecorder()
|
|
req := httptest.NewRequest("GET", "/standing/nobody", nil)
|
|
mux.ServeHTTP(rec, req)
|
|
if rec.Code != http.StatusNotFound {
|
|
t.Fatalf("GET /standing/nobody: status %d, want 404", rec.Code)
|
|
}
|
|
// G-026: rendered-HTML lexicon check on the ERROR response body too.
|
|
assertNoBannedTerms(t, rec.Body.String())
|
|
}
|
|
|
|
// TestStandingScoreComputedFromLockedConstants (P4 regression guard): asserts
|
|
// ComputeStandingScore uses the x/standing/types locked constants
|
|
// (PriorMean=4.0, PriorWeight=10, ComputeDiversityBonus, GetVoucherWeight,
|
|
// GetStandingBucket) — NOT a hardcoded score. This test would FAIL if the
|
|
// handler hardcoded a score instead of computing from the locked constants.
|
|
func TestStandingScoreComputedFromLockedConstants(t *testing.T) {
|
|
srv := newTestServer(t)
|
|
score, bucket := srv.Store.ComputeStandingScore("holder-alia")
|
|
// D-073 pattern: the score must be derived from the locked constants, not
|
|
// a magic number. Assert the prior mean is 4.0 and the score is pulled
|
|
// toward it (Bayesian shrinkage) + diversity bonus for 4 categories.
|
|
if standingtypes.PriorMean != 4.0 {
|
|
t.Fatalf("D-073: PriorMean = %v, want 4.0 (locked constant)", standingtypes.PriorMean)
|
|
}
|
|
if standingtypes.PriorWeight != 10 {
|
|
t.Fatalf("D-073: PriorWeight = %v, want 10 (locked constant)", standingtypes.PriorWeight)
|
|
}
|
|
// holder-alia has 4 categories -> diversity bonus 0.10 (DiversityBonus4Cats).
|
|
bonus := standingtypes.ComputeDiversityBonus(4)
|
|
if bonus != standingtypes.DiversityBonus4Cats {
|
|
t.Errorf("ComputeDiversityBonus(4) = %v, want %v (locked constant)", bonus, standingtypes.DiversityBonus4Cats)
|
|
}
|
|
// The score must be > 4.5 (ratings 4.6-4.9 + diversity bonus 0.10).
|
|
if score < 4.5 {
|
|
t.Errorf("score for holder-alia = %.2f, want >= 4.5 (12 ratings 4.6-4.9 + 4-cat bonus)", score)
|
|
}
|
|
// Bucket must be Preferred or Top (score >= 4.5, 12 ratings >= 10).
|
|
if bucket != standingtypes.BucketPreferred && bucket != standingtypes.BucketTop {
|
|
t.Errorf("bucket for holder-alia = %q, want Preferred or Top", bucket)
|
|
}
|
|
// holder-bryn has 3 ratings in 1 category -> bucket New (< 10 ratings).
|
|
_, brynBucket := srv.Store.ComputeStandingScore("holder-bryn")
|
|
if brynBucket != standingtypes.BucketNew {
|
|
t.Errorf("bucket for holder-bryn = %q, want New (< 10 ratings)", brynBucket)
|
|
}
|
|
}
|
|
|
|
// TestFreeholderEligibleBadgeReflectsMethod: asserts the rendered badge
|
|
// matches FreeholderSignals.IsFreeholderEligible() (the real method).
|
|
func TestFreeholderEligibleBadgeReflectsMethod(t *testing.T) {
|
|
srv := newTestServer(t)
|
|
mux := http.NewServeMux()
|
|
srv.Register(mux)
|
|
|
|
// holder-alia: eligible (all 4 signals true).
|
|
aliaSignals := srv.Store.ComputeFreeholderSignals("holder-alia")
|
|
if !aliaSignals.IsFreeholderEligible() {
|
|
t.Errorf("holder-alia IsFreeholderEligible = false, want true (signals=%+v)", aliaSignals)
|
|
}
|
|
rec := httptest.NewRecorder()
|
|
req := httptest.NewRequest("GET", "/standing/holder-alia", nil)
|
|
mux.ServeHTTP(rec, req)
|
|
if !strings.Contains(rec.Body.String(), "yes") {
|
|
t.Errorf("holder-alia: body missing 'yes' eligible badge (IsFreeholderEligible=true)")
|
|
}
|
|
|
|
// holder-bryn: not eligible.
|
|
brynSignals := srv.Store.ComputeFreeholderSignals("holder-bryn")
|
|
if brynSignals.IsFreeholderEligible() {
|
|
t.Errorf("holder-bryn IsFreeholderEligible = true, want false (signals=%+v)", brynSignals)
|
|
}
|
|
rec2 := httptest.NewRecorder()
|
|
req2 := httptest.NewRequest("GET", "/standing/holder-bryn", nil)
|
|
mux.ServeHTTP(rec2, req2)
|
|
if !strings.Contains(rec2.Body.String(), "not yet") {
|
|
t.Errorf("holder-bryn: body missing 'not yet' (IsFreeholderEligible=false)")
|
|
}
|
|
}
|
|
|
|
// Compile-time assertion that the handler uses the real x/standing/types struct.
|
|
var _ standingtypes.FreeholderSignals
|