package evolve import ( "context" "errors" "sync/atomic" "testing" ) // newEvolver builds a DefaultParameterEvolver backed by a FileParameterStore // under t.TempDir(), plus a default proposer. Callers can override by // constructing their own if they need a different proposer. func newEvolver(t *testing.T, proposer ProposerFunc, opts ...EvolverOption) (*DefaultParameterEvolver, *FileParameterStore) { t.Helper() store := newStore(t) if proposer == nil { proposer = func(context.Context, string, []Feedback) (any, float64, error) { return "default", 0.5, nil } } e, err := NewDefaultParameterEvolver(store, proposer, opts...) if err != nil { t.Fatalf("NewDefaultParameterEvolver: %v", err) } return e, store } func TestEvolver_ProposeDelegatesToProposer(t *testing.T) { var got struct { key string evidence []Feedback } proposer := func(ctx context.Context, key string, ev []Feedback) (any, float64, error) { got.key = key got.evidence = ev return 42.0, 0.8, nil } e, _ := newEvolver(t, proposer) ev := []Feedback{{Entity: "A", Metric: "m", Value: 1}} v, c, err := e.Propose(context.Background(), "k", ev) if err != nil { t.Fatalf("Propose: %v", err) } if v != 42.0 || c != 0.8 { t.Errorf("Propose return: v=%v c=%v", v, c) } if got.key != "k" || len(got.evidence) != 1 || got.evidence[0].Entity != "A" { t.Errorf("proposer inputs: %+v", got) } } func TestEvolver_ProposeProposerErrorWrapped(t *testing.T) { root := errors.New("proposer boom") e, _ := newEvolver(t, func(context.Context, string, []Feedback) (any, float64, error) { return nil, 0, root }) _, _, err := e.Propose(context.Background(), "k", nil) if !errors.Is(err, root) { t.Errorf("proposer error: want wrap of root, got %v", err) } } func TestEvolver_ProposeCtxCanceledSkipsProposer(t *testing.T) { called := false e, _ := newEvolver(t, func(context.Context, string, []Feedback) (any, float64, error) { called = true return 1, 1, nil }) ctx, cancel := context.WithCancel(context.Background()) cancel() _, _, err := e.Propose(ctx, "k", nil) if !errors.Is(err, context.Canceled) { t.Errorf("canceled ctx: want context.Canceled, got %v", err) } if called { t.Errorf("proposer must not be called on canceled ctx") } } func TestEvolver_ApplyApprovedWritesStore(t *testing.T) { e, store := newEvolver(t, nil) ctx := context.Background() if err := e.Apply(ctx, "p1", 123.0, true, "initial rollout"); err != nil { t.Fatalf("Apply: %v", err) } v, ver, err := store.Get(ctx, "p1") if err != nil { t.Fatalf("Get after Apply: %v", err) } if ver != 1 { t.Errorf("version: want 1, got %d", ver) } // JSON roundtrip gives float64. if got, _ := v.(float64); got != 123.0 { t.Errorf("value: want 123, got %v", v) } } func TestEvolver_ApplyRejectedSkipsStore(t *testing.T) { e, store := newEvolver(t, nil) ctx := context.Background() if err := e.Apply(ctx, "p1", 123.0, false, "vetoed"); err != nil { t.Errorf("Apply rejected: want nil err, got %v", err) } if _, _, err := store.Get(ctx, "p1"); !errors.Is(err, ErrParameterNotFound) { t.Errorf("store must be untouched: err=%v", err) } } func TestEvolver_ApplyEmptyReasonApprovedSurfacesStoreError(t *testing.T) { e, _ := newEvolver(t, nil) err := e.Apply(context.Background(), "p1", 1, true, "") if !errors.Is(err, ErrReasonRequired) { t.Errorf("empty reason approved: want ErrReasonRequired, got %v", err) } } func TestEvolver_ApplyEmptyReasonRejectedOK(t *testing.T) { // approved=false is a governance no-op; reason is audit context, empty OK. e, _ := newEvolver(t, nil) if err := e.Apply(context.Background(), "p1", 1, false, ""); err != nil { t.Errorf("rejected with empty reason: want nil, got %v", err) } } func TestEvolver_ApplyAuditLoggerBothBranches(t *testing.T) { var accepted, rejected, failed atomic.Int32 logger := func(format string, args ...any) { switch { case len(format) > 0 && stringContains(format, "accepted"): accepted.Add(1) case stringContains(format, "rejected"): rejected.Add(1) case stringContains(format, "failed"): failed.Add(1) } } e, _ := newEvolver(t, nil, WithAuditLogger(logger)) ctx := context.Background() _ = e.Apply(ctx, "p1", 1, true, "go") _ = e.Apply(ctx, "p2", 2, false, "no") if accepted.Load() != 1 || rejected.Load() != 1 || failed.Load() != 0 { t.Errorf("audit counts: accepted=%d rejected=%d failed=%d", accepted.Load(), rejected.Load(), failed.Load()) } } func TestEvolver_ApplyStoreErrorWrappedAndLogged(t *testing.T) { var failed atomic.Int32 logger := func(format string, args ...any) { if stringContains(format, "failed") { failed.Add(1) } } e, store := newEvolver(t, nil, WithAuditLogger(logger)) ctx := context.Background() // Lock the key so Set returns ErrParameterLocked. if _, err := store.Set(ctx, "p1", 0, "seed"); err != nil { t.Fatal(err) } if err := store.Lock(ctx, "p1", "freeze"); err != nil { t.Fatal(err) } err := e.Apply(ctx, "p1", 99, true, "try update") if !errors.Is(err, ErrParameterLocked) { t.Errorf("want ErrParameterLocked, got %v", err) } if failed.Load() != 1 { t.Errorf("failed audit: want 1, got %d", failed.Load()) } } func TestEvolver_ApplyCtxCanceledSkipsStore(t *testing.T) { e, store := newEvolver(t, nil) ctx, cancel := context.WithCancel(context.Background()) cancel() err := e.Apply(ctx, "p1", 1, true, "r") if !errors.Is(err, context.Canceled) { t.Errorf("want context.Canceled, got %v", err) } if _, _, err := store.Get(context.Background(), "p1"); !errors.Is(err, ErrParameterNotFound) { t.Errorf("store must be untouched on canceled Apply: %v", err) } } func TestEvolver_NilStoreRejected(t *testing.T) { _, err := NewDefaultParameterEvolver(nil, func(context.Context, string, []Feedback) (any, float64, error) { return nil, 0, nil }) if err == nil { t.Error("nil store: want error") } } func TestEvolver_NilProposerRejected(t *testing.T) { store := newStore(t) _, err := NewDefaultParameterEvolver(store, nil) if err == nil { t.Error("nil proposer: want error") } } func TestEvolver_WithAuditLoggerNilKeepsDefault(t *testing.T) { // Construction must not panic; logger stays log.Printf. We cannot easily // assert the default is used, but we check that Apply still succeeds. e, _ := newEvolver(t, nil, WithAuditLogger(nil)) if err := e.Apply(context.Background(), "p1", 1, true, "r"); err != nil { t.Errorf("Apply with nil audit logger: %v", err) } } func TestEvolver_EvidenceNilEmptyPassThrough(t *testing.T) { var seen [][]Feedback proposer := func(_ context.Context, _ string, ev []Feedback) (any, float64, error) { seen = append(seen, ev) return 1, 1, nil } e, _ := newEvolver(t, proposer) _, _, _ = e.Propose(context.Background(), "k", nil) _, _, _ = e.Propose(context.Background(), "k", []Feedback{}) if len(seen) != 2 { t.Fatalf("proposer calls: want 2, got %d", len(seen)) } if seen[0] != nil { t.Errorf("nil evidence should pass through as nil, got %v", seen[0]) } if seen[1] == nil || len(seen[1]) != 0 { t.Errorf("empty evidence should pass through, got %v", seen[1]) } } func TestEvolver_ImplementsInterface(t *testing.T) { e, _ := newEvolver(t, nil) var _ ParameterEvolver = e } // stringContains is a tiny stdlib-avoidance helper so the test file does not // need to import strings; keeps the dep graph visibly minimal. func stringContains(hay, needle string) bool { if len(needle) == 0 { return true } for i := 0; i+len(needle) <= len(hay); i++ { if hay[i:i+len(needle)] == needle { return true } } return false }