package reflector_test import ( "context" "errors" "testing" "git.flytoex.net/yuanwei/flyto-agent/pkg/evolve" "git.flytoex.net/yuanwei/flyto-agent/pkg/reflector" "git.flytoex.net/yuanwei/flyto-agent/pkg/validator" ) // ---------- test doubles ---------- type fakeValidator struct { name string verdict validator.Verdict err error calls int lastIn validator.DiffInput } func (f *fakeValidator) Name() string { return f.name } func (f *fakeValidator) Validate(_ context.Context, d validator.DiffInput) (validator.Verdict, error) { f.calls++ f.lastIn = d return f.verdict, f.err } type fakeEvaluator struct { fitness float64 breakdown map[string]float64 err error calls int lastIn evolve.Candidate } func (f *fakeEvaluator) Score(_ context.Context, c evolve.Candidate) (float64, map[string]float64, error) { f.calls++ f.lastIn = c return f.fitness, f.breakdown, f.err } // identityCandidateToDiff threads a Candidate.ID through SourceTool // for happy-path tests that do not care about payload content. func identityCandidateToDiff(c evolve.Candidate) (validator.DiffInput, error) { return validator.DiffInput{SourceTool: c.ID, Raw: []byte("{}")}, nil } func identityDiffToCandidate(d validator.DiffInput) (evolve.Candidate, error) { return evolve.Candidate{ID: d.SourceTool}, nil } // ========== 1. ValidatorAsEvaluator ========== func TestValidatorAsEvaluator_ApprovedReturnsScore(t *testing.T) { fv := &fakeValidator{ name: "v", verdict: validator.Verdict{Approved: true, Score: 0.9, Details: map[string]any{"k": 0.5}}, } ev := reflector.ValidatorAsEvaluator(fv, identityCandidateToDiff) fit, bd, err := ev.Score(context.Background(), evolve.Candidate{ID: "c1"}) if err != nil { t.Fatalf("unexpected err: %v", err) } if fit != 0.9 { t.Fatalf("fitness=%v, want 0.9", fit) } if bd["k"] != 0.5 { t.Fatalf("breakdown=%v, want k=0.5", bd) } if fv.calls != 1 { t.Fatalf("validator calls=%d, want 1", fv.calls) } if fv.lastIn.SourceTool != "c1" { t.Fatalf("extract SourceTool=%q, want c1", fv.lastIn.SourceTool) } } func TestValidatorAsEvaluator_RejectedForcesZeroByDefault(t *testing.T) { fv := &fakeValidator{ name: "v", verdict: validator.Verdict{Approved: false, Score: 0.7}, } ev := reflector.ValidatorAsEvaluator(fv, identityCandidateToDiff) fit, _, err := ev.Score(context.Background(), evolve.Candidate{ID: "c1"}) if err != nil { t.Fatalf("unexpected err: %v", err) } if fit != 0 { t.Fatalf("fitness=%v, want 0 (default rejectFitness)", fit) } } func TestValidatorAsEvaluator_WithRejectFitness(t *testing.T) { fv := &fakeValidator{verdict: validator.Verdict{Approved: false, Score: 0.7}} ev := reflector.ValidatorAsEvaluator(fv, identityCandidateToDiff, reflector.WithRejectFitness(0.3)) fit, _, err := ev.Score(context.Background(), evolve.Candidate{ID: "c1"}) if err != nil { t.Fatalf("unexpected err: %v", err) } if fit != 0.3 { t.Fatalf("fitness=%v, want 0.3", fit) } } func TestValidatorAsEvaluator_ExtractErrorIsWrapped(t *testing.T) { fv := &fakeValidator{} ev := reflector.ValidatorAsEvaluator(fv, func(evolve.Candidate) (validator.DiffInput, error) { return validator.DiffInput{}, errors.New("boom") }) _, _, err := ev.Score(context.Background(), evolve.Candidate{ID: "c1"}) if !errors.Is(err, reflector.ErrExtractFailed) { t.Fatalf("err=%v, want ErrExtractFailed", err) } if fv.calls != 0 { t.Fatalf("validator should not be called after extract failure, calls=%d", fv.calls) } } func TestValidatorAsEvaluator_ValidateErrorPropagates(t *testing.T) { want := errors.New("backend down") fv := &fakeValidator{err: want} ev := reflector.ValidatorAsEvaluator(fv, identityCandidateToDiff) _, _, err := ev.Score(context.Background(), evolve.Candidate{ID: "c1"}) if !errors.Is(err, want) { t.Fatalf("err=%v, want %v", err, want) } } func TestValidatorAsEvaluator_BreakdownDropsNonNumeric(t *testing.T) { fv := &fakeValidator{ verdict: validator.Verdict{ Approved: true, Score: 1.0, Details: map[string]any{"num": 0.5, "str": "ignored", "i": 7}, }, } ev := reflector.ValidatorAsEvaluator(fv, identityCandidateToDiff) _, bd, _ := ev.Score(context.Background(), evolve.Candidate{}) if len(bd) != 2 || bd["num"] != 0.5 || bd["i"] != 7 { t.Fatalf("breakdown=%v, want only num=0.5 and i=7", bd) } } func TestValidatorAsEvaluator_NilInputsPanic(t *testing.T) { assertPanics(t, func() { reflector.ValidatorAsEvaluator(nil, identityCandidateToDiff) }) assertPanics(t, func() { reflector.ValidatorAsEvaluator(&fakeValidator{}, nil) }) } // ========== 2. EvaluatorAsValidator ========== func TestEvaluatorAsValidator_AboveThresholdApproves(t *testing.T) { fe := &fakeEvaluator{fitness: 0.8} v := reflector.EvaluatorAsValidator(fe, identityDiffToCandidate, 0.5) vd, err := v.Validate(context.Background(), validator.DiffInput{SourceTool: "t"}) if err != nil { t.Fatalf("unexpected err: %v", err) } if !vd.Approved { t.Fatalf("approved=false, want true") } if vd.Severity != validator.SeverityWarn { t.Fatalf("severity=%q, want warn", vd.Severity) } if vd.Score != 0.8 { t.Fatalf("score=%v, want 0.8", vd.Score) } } func TestEvaluatorAsValidator_BelowThresholdBlocksByDefault(t *testing.T) { fe := &fakeEvaluator{fitness: 0.2} v := reflector.EvaluatorAsValidator(fe, identityDiffToCandidate, 0.5) vd, err := v.Validate(context.Background(), validator.DiffInput{}) if err != nil { t.Fatalf("unexpected err: %v", err) } if vd.Approved { t.Fatal("approved=true, want false") } if vd.Severity != validator.SeverityBlock { t.Fatalf("severity=%q, want block", vd.Severity) } } func TestEvaluatorAsValidator_BelowThresholdWarnWhenConfigured(t *testing.T) { fe := &fakeEvaluator{fitness: 0.2} v := reflector.EvaluatorAsValidator(fe, identityDiffToCandidate, 0.5, reflector.WithBelowThresholdSeverity(validator.SeverityWarn)) vd, _ := v.Validate(context.Background(), validator.DiffInput{}) if vd.Severity != validator.SeverityWarn { t.Fatalf("severity=%q, want warn", vd.Severity) } } func TestEvaluatorAsValidator_BreakdownFlowsToDetails(t *testing.T) { fe := &fakeEvaluator{fitness: 0.8, breakdown: map[string]float64{"latency": 0.1}} v := reflector.EvaluatorAsValidator(fe, identityDiffToCandidate, 0.5) vd, _ := v.Validate(context.Background(), validator.DiffInput{}) got, ok := vd.Details["latency"].(float64) if !ok || got != 0.1 { t.Fatalf("details[latency]=%v, want 0.1 (float64)", vd.Details["latency"]) } } func TestEvaluatorAsValidator_PolicyVersionStamped(t *testing.T) { fe := &fakeEvaluator{fitness: 0.8} v := reflector.EvaluatorAsValidator(fe, identityDiffToCandidate, 0.5, reflector.WithPolicyVersion("2026-04")) vd, _ := v.Validate(context.Background(), validator.DiffInput{}) if vd.PolicyVersion != "2026-04" { t.Fatalf("policyVersion=%q, want 2026-04", vd.PolicyVersion) } } func TestEvaluatorAsValidator_ValidatorNameStamped(t *testing.T) { fe := &fakeEvaluator{fitness: 0.8} v := reflector.EvaluatorAsValidator(fe, identityDiffToCandidate, 0.5, reflector.WithName("custom")) if v.Name() != "custom" { t.Fatalf("Name=%q, want custom", v.Name()) } vd, _ := v.Validate(context.Background(), validator.DiffInput{}) if vd.ValidatorName != "custom" { t.Fatalf("verdict.ValidatorName=%q, want custom", vd.ValidatorName) } } func TestEvaluatorAsValidator_DefaultName(t *testing.T) { v := reflector.EvaluatorAsValidator(&fakeEvaluator{}, identityDiffToCandidate, 0.5) if v.Name() != "evaluator-as-validator" { t.Fatalf("Name=%q, want evaluator-as-validator", v.Name()) } } func TestEvaluatorAsValidator_ExtractErrorIsWrapped(t *testing.T) { v := reflector.EvaluatorAsValidator(&fakeEvaluator{}, func(validator.DiffInput) (evolve.Candidate, error) { return evolve.Candidate{}, errors.New("boom") }, 0.5) _, err := v.Validate(context.Background(), validator.DiffInput{}) if !errors.Is(err, reflector.ErrExtractFailed) { t.Fatalf("err=%v, want ErrExtractFailed", err) } } func TestEvaluatorAsValidator_ScoreErrorPropagates(t *testing.T) { want := errors.New("lookup failed") v := reflector.EvaluatorAsValidator(&fakeEvaluator{err: want}, identityDiffToCandidate, 0.5) _, err := v.Validate(context.Background(), validator.DiffInput{}) if !errors.Is(err, want) { t.Fatalf("err=%v, want %v", err, want) } } func TestEvaluatorAsValidator_NilInputsPanic(t *testing.T) { assertPanics(t, func() { reflector.EvaluatorAsValidator(nil, identityDiffToCandidate, 0.5) }) assertPanics(t, func() { reflector.EvaluatorAsValidator(&fakeEvaluator{}, nil, 0.5) }) } // ========== 3. ValidatorAsReflector ========== func TestValidatorAsReflector_HappyPathCallsSink(t *testing.T) { fv := &fakeValidator{verdict: validator.Verdict{Approved: true, Score: 0.9}} var gotV validator.Verdict var gotErr error sink := func(_ evolve.ReplayEvent, v validator.Verdict, err error) { gotV = v gotErr = err } r := reflector.ValidatorAsReflector(fv, func(evolve.ReplayEvent) (validator.DiffInput, error) { return validator.DiffInput{SourceTool: "ev"}, nil }, sink) if err := r.OnEvent(context.Background(), evolve.ReplayEvent{}); err != nil { t.Fatalf("OnEvent err=%v, want nil", err) } if !gotV.Approved || gotErr != nil { t.Fatalf("sink got v=%v err=%v, want approved+nil", gotV, gotErr) } } func TestValidatorAsReflector_ExtractErrorReachesSinkOnEventReturnsNil(t *testing.T) { var gotErr error sink := func(_ evolve.ReplayEvent, _ validator.Verdict, err error) { gotErr = err } r := reflector.ValidatorAsReflector(&fakeValidator{}, func(evolve.ReplayEvent) (validator.DiffInput, error) { return validator.DiffInput{}, errors.New("boom") }, sink) if err := r.OnEvent(context.Background(), evolve.ReplayEvent{}); err != nil { t.Fatalf("OnEvent should swallow err, got %v", err) } if !errors.Is(gotErr, reflector.ErrExtractFailed) { t.Fatalf("sink err=%v, want ErrExtractFailed", gotErr) } } func TestValidatorAsReflector_ValidateErrorReachesSinkOnEventReturnsNil(t *testing.T) { want := errors.New("backend") fv := &fakeValidator{err: want} var gotErr error sink := func(_ evolve.ReplayEvent, _ validator.Verdict, err error) { gotErr = err } r := reflector.ValidatorAsReflector(fv, func(evolve.ReplayEvent) (validator.DiffInput, error) { return validator.DiffInput{}, nil }, sink) if err := r.OnEvent(context.Background(), evolve.ReplayEvent{}); err != nil { t.Fatalf("OnEvent err=%v, want nil", err) } if !errors.Is(gotErr, want) { t.Fatalf("sink err=%v, want %v", gotErr, want) } } func TestValidatorAsReflector_NilInputsPanic(t *testing.T) { extract := func(evolve.ReplayEvent) (validator.DiffInput, error) { return validator.DiffInput{}, nil } sink := func(evolve.ReplayEvent, validator.Verdict, error) {} assertPanics(t, func() { reflector.ValidatorAsReflector(nil, extract, sink) }) assertPanics(t, func() { reflector.ValidatorAsReflector(&fakeValidator{}, nil, sink) }) assertPanics(t, func() { reflector.ValidatorAsReflector(&fakeValidator{}, extract, nil) }) } // ========== 4. EvaluatorAsReflector ========== func TestEvaluatorAsReflector_HappyPathCallsSink(t *testing.T) { fe := &fakeEvaluator{fitness: 0.77, breakdown: map[string]float64{"k": 1.0}} var gotF float64 var gotBd map[string]float64 sink := func(_ evolve.ReplayEvent, f float64, bd map[string]float64, _ error) { gotF = f gotBd = bd } r := reflector.EvaluatorAsReflector(fe, func(evolve.ReplayEvent) (evolve.Candidate, error) { return evolve.Candidate{ID: "c"}, nil }, sink) if err := r.OnEvent(context.Background(), evolve.ReplayEvent{}); err != nil { t.Fatalf("OnEvent err=%v, want nil", err) } if gotF != 0.77 || gotBd["k"] != 1.0 { t.Fatalf("sink got f=%v bd=%v", gotF, gotBd) } } func TestEvaluatorAsReflector_ExtractError(t *testing.T) { var gotErr error sink := func(_ evolve.ReplayEvent, _ float64, _ map[string]float64, err error) { gotErr = err } r := reflector.EvaluatorAsReflector(&fakeEvaluator{}, func(evolve.ReplayEvent) (evolve.Candidate, error) { return evolve.Candidate{}, errors.New("boom") }, sink) _ = r.OnEvent(context.Background(), evolve.ReplayEvent{}) if !errors.Is(gotErr, reflector.ErrExtractFailed) { t.Fatalf("sink err=%v, want ErrExtractFailed", gotErr) } } func TestEvaluatorAsReflector_ScoreError(t *testing.T) { want := errors.New("scorer down") var gotErr error sink := func(_ evolve.ReplayEvent, _ float64, _ map[string]float64, err error) { gotErr = err } r := reflector.EvaluatorAsReflector(&fakeEvaluator{err: want}, func(evolve.ReplayEvent) (evolve.Candidate, error) { return evolve.Candidate{}, nil }, sink) _ = r.OnEvent(context.Background(), evolve.ReplayEvent{}) if !errors.Is(gotErr, want) { t.Fatalf("sink err=%v, want %v", gotErr, want) } } func TestEvaluatorAsReflector_NilInputsPanic(t *testing.T) { extract := func(evolve.ReplayEvent) (evolve.Candidate, error) { return evolve.Candidate{}, nil } sink := func(evolve.ReplayEvent, float64, map[string]float64, error) {} assertPanics(t, func() { reflector.EvaluatorAsReflector(nil, extract, sink) }) assertPanics(t, func() { reflector.EvaluatorAsReflector(&fakeEvaluator{}, nil, sink) }) assertPanics(t, func() { reflector.EvaluatorAsReflector(&fakeEvaluator{}, extract, nil) }) } // ---------- helpers ---------- func assertPanics(t *testing.T, fn func()) { t.Helper() defer func() { if r := recover(); r == nil { t.Fatal("expected panic, got none") } }() fn() }