@@ -18,6 +18,7 @@ package scheduling
1818
1919import (
2020 "context"
21+ "strings"
2122 "testing"
2223
2324 "github.com/google/go-cmp/cmp"
@@ -242,6 +243,188 @@ func (tp *testPlugin) reset() {
242243 tp .NumOfPickerCandidates = 0
243244}
244245
246+ func TestAddPlugins (t * testing.T ) {
247+ tests := []struct {
248+ name string
249+ plugins []fwkplugin.Plugin
250+ wantFilters int
251+ wantScorers int
252+ wantPicker bool
253+ wantErr bool
254+ errContains string
255+ }{
256+ {
257+ name : "add WeightedScorer that also implements Filter and Picker" ,
258+ plugins : []fwkplugin.Plugin {
259+ NewWeightedScorer (& testPlugin {TypeRes : "multi" }, 1.0 ),
260+ },
261+ wantFilters : 1 ,
262+ wantScorers : 1 ,
263+ wantPicker : true ,
264+ },
265+ {
266+ name : "add plugin that only implements Filter" ,
267+ plugins : []fwkplugin.Plugin {
268+ & filterOnlyPlugin {typedName : fwkplugin.TypedName {Name : "filter-only" }},
269+ },
270+ wantFilters : 1 ,
271+ wantScorers : 0 ,
272+ wantPicker : false ,
273+ },
274+ {
275+ name : "error when adding Scorer without weight" ,
276+ plugins : []fwkplugin.Plugin {
277+ & testPlugin {TypeRes : "bare-scorer" },
278+ },
279+ wantErr : true ,
280+ errContains : "without a weight" ,
281+ },
282+ {
283+ name : "error when adding duplicate picker" ,
284+ plugins : []fwkplugin.Plugin {
285+ NewWeightedScorer (& testPlugin {TypeRes : "picker1" }, 1.0 ),
286+ NewWeightedScorer (& testPlugin {TypeRes : "picker2" }, 1.0 ),
287+ },
288+ wantErr : true ,
289+ errContains : "already have a registered picker" ,
290+ },
291+ }
292+
293+ for _ , test := range tests {
294+ t .Run (test .name , func (t * testing.T ) {
295+ profile := NewSchedulerProfile ()
296+ err := profile .AddPlugins (test .plugins ... )
297+
298+ if test .wantErr {
299+ if err == nil {
300+ t .Fatalf ("expected error but got nil" )
301+ }
302+ if test .errContains != "" && ! strings .Contains (err .Error (), test .errContains ) {
303+ t .Errorf ("error %q does not contain %q" , err .Error (), test .errContains )
304+ }
305+ return
306+ }
307+ if err != nil {
308+ t .Fatalf ("unexpected error: %v" , err )
309+ }
310+ if len (profile .filters ) != test .wantFilters {
311+ t .Errorf ("got %d filters, want %d" , len (profile .filters ), test .wantFilters )
312+ }
313+ if len (profile .scorers ) != test .wantScorers {
314+ t .Errorf ("got %d scorers, want %d" , len (profile .scorers ), test .wantScorers )
315+ }
316+ if test .wantPicker && profile .picker == nil {
317+ t .Errorf ("expected picker to be set" )
318+ }
319+ if ! test .wantPicker && profile .picker != nil {
320+ t .Errorf ("expected picker to be nil" )
321+ }
322+ })
323+ }
324+ }
325+
326+ func TestSchedulerProfileString (t * testing.T ) {
327+ tp1 := & testPlugin {TypeRes : "test1" }
328+ tp2 := & testPlugin {TypeRes : "test2" }
329+ pickerPlugin := & testPlugin {TypeRes : "picker" }
330+
331+ profile := NewSchedulerProfile ().
332+ WithFilters (tp1 , tp2 ).
333+ WithScorers (NewWeightedScorer (tp1 , 1.5 ), NewWeightedScorer (tp2 , 2.0 )).
334+ WithPicker (pickerPlugin )
335+
336+ result := profile .String ()
337+
338+ // Verify the string contains filter, scorer, and picker info
339+ if ! strings .Contains (result , "Filters:" ) {
340+ t .Errorf ("String() missing Filters section: %s" , result )
341+ }
342+ if ! strings .Contains (result , "Scorers:" ) {
343+ t .Errorf ("String() missing Scorers section: %s" , result )
344+ }
345+ if ! strings .Contains (result , "Picker:" ) {
346+ t .Errorf ("String() missing Picker section: %s" , result )
347+ }
348+ }
349+
350+ func TestEnforceScoreRange (t * testing.T ) {
351+ tests := []struct {
352+ name string
353+ score float64
354+ want float64
355+ }{
356+ {name : "negative score clamped to 0" , score : - 0.5 , want : 0 },
357+ {name : "score above 1 clamped to 1" , score : 1.5 , want : 1 },
358+ {name : "score at 0 stays 0" , score : 0 , want : 0 },
359+ {name : "score at 1 stays 1" , score : 1 , want : 1 },
360+ {name : "score in range stays unchanged" , score : 0.5 , want : 0.5 },
361+ }
362+ for _ , test := range tests {
363+ t .Run (test .name , func (t * testing.T ) {
364+ got := enforceScoreRange (test .score )
365+ if got != test .want {
366+ t .Errorf ("enforceScoreRange(%v) = %v, want %v" , test .score , got , test .want )
367+ }
368+ })
369+ }
370+ }
371+
372+ func TestRunWithOutOfRangeScores (t * testing.T ) {
373+ // Scorer that returns negative score
374+ negativeScorer := & testPlugin {
375+ TypeRes : "negative" ,
376+ ScoreRes : - 0.5 ,
377+ FilterRes : []k8stypes.NamespacedName {{Name : "pod1" }},
378+ }
379+ // Scorer that returns score > 1
380+ overScorer := & testPlugin {
381+ TypeRes : "over" ,
382+ ScoreRes : 1.5 ,
383+ FilterRes : []k8stypes.NamespacedName {{Name : "pod1" }},
384+ }
385+ pickerPlugin := & testPlugin {
386+ TypeRes : "picker" ,
387+ PickRes : k8stypes.NamespacedName {Name : "pod1" },
388+ }
389+
390+ profile := NewSchedulerProfile ().
391+ WithFilters ().
392+ WithScorers (NewWeightedScorer (negativeScorer , 1 ), NewWeightedScorer (overScorer , 1 )).
393+ WithPicker (pickerPlugin )
394+
395+ input := []fwksched.Endpoint {
396+ fwksched .NewEndpoint (& fwkdl.EndpointMetadata {NamespacedName : k8stypes.NamespacedName {Name : "pod1" }}, nil , nil ),
397+ }
398+
399+ request := & fwksched.LLMRequest {
400+ TargetModel : "test-model" ,
401+ RequestId : uuid .NewString (),
402+ }
403+
404+ _ , err := profile .Run (context .Background (), request , fwksched .NewCycleState (), input )
405+ if err != nil {
406+ t .Fatalf ("unexpected error: %v" , err )
407+ }
408+
409+ // negative score clamped to 0, over score clamped to 1: total = 0*1 + 1*1 = 1.0
410+ if pickerPlugin .WinnerEndpointScore != 1.0 {
411+ t .Errorf ("expected winner score 1.0, got %v" , pickerPlugin .WinnerEndpointScore )
412+ }
413+ }
414+
415+ // filterOnlyPlugin implements only the Filter interface (not Scorer or Picker).
416+ type filterOnlyPlugin struct {
417+ typedName fwkplugin.TypedName
418+ }
419+
420+ func (p * filterOnlyPlugin ) TypedName () fwkplugin.TypedName {
421+ return p .typedName
422+ }
423+
424+ func (p * filterOnlyPlugin ) Filter (_ context.Context , _ * fwksched.CycleState , _ * fwksched.LLMRequest , endpoints []fwksched.Endpoint ) []fwksched.Endpoint {
425+ return endpoints
426+ }
427+
245428func findEndpoints (endpoints []fwksched.Endpoint , names ... k8stypes.NamespacedName ) []fwksched.Endpoint {
246429 res := []fwksched.Endpoint {}
247430 for _ , endpoint := range endpoints {
0 commit comments