package services import ( "bookhoard/internal/database" "encoding/json" "testing" "github.com/jackc/pgx/v5/pgtype" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) func TestEvaluateRules_Equality(t *testing.T) { service := &CollectionService{} mediaItem := database.MediaItems{ Genre: pgtype.Text{String: "Science Fiction", Valid: true}, } rules := []Rule{ { ID: "rule-1", Field: "genre", Operator: "equals", Value: "Science Fiction", Priority: 5, }, } evaluations := service.EvaluateRules(mediaItem, 0, rules) if len(evaluations) != 1 { t.Fatalf("expected 1 evaluation, got %d", len(evaluations)) } if !evaluations[0].Matches { t.Errorf("expected rule to match, but it didn't") } if evaluations[0].Confidence <= 0.85 { t.Errorf("expected confidence > 0.85, got %f", evaluations[0].Confidence) } } func TestEvaluateRules_NoMatch(t *testing.T) { service := &CollectionService{} mediaItem := database.MediaItems{ Genre: pgtype.Text{String: "Fantasy", Valid: true}, } rules := []Rule{ { ID: "rule-1", Field: "genre", Operator: "equals", Value: "Science Fiction", Priority: 5, }, } evaluations := service.EvaluateRules(mediaItem, 0, rules) if len(evaluations) != 1 { t.Fatalf("expected 1 evaluation, got %d", len(evaluations)) } if evaluations[0].Matches { t.Errorf("expected rule not to match, but it did") } if evaluations[0].Confidence != 0 { t.Errorf("expected confidence 0, got %f", evaluations[0].Confidence) } } func TestEvaluateRules_Contains(t *testing.T) { service := &CollectionService{} mediaItem := database.MediaItems{ Series: pgtype.Text{String: "The Expanse", Valid: true}, } rules := []Rule{ { ID: "rule-1", Field: "series", Operator: "contains", Value: "Expanse", Priority: 7, }, } evaluations := service.EvaluateRules(mediaItem, 0, rules) if len(evaluations) != 1 { t.Fatalf("expected 1 evaluation, got %d", len(evaluations)) } if !evaluations[0].Matches { t.Errorf("expected rule to match, but it didn't") } if evaluations[0].Confidence <= 0.9 { t.Errorf("expected confidence > 0.9 with priority boost, got %f", evaluations[0].Confidence) } } func TestEvaluateRules_PriorityOrder(t *testing.T) { service := &CollectionService{} mediaItem := database.MediaItems{ Genre: pgtype.Text{String: "Science Fiction", Valid: true}, Series: pgtype.Text{String: "Foundation", Valid: true}, } rules := []Rule{ { ID: "rule-1", Field: "genre", Operator: "equals", Value: "Science Fiction", Priority: 5, }, { ID: "rule-2", Field: "series", Operator: "equals", Value: "Foundation", Priority: 9, }, } evaluations := service.EvaluateRules(mediaItem, 0, rules) if len(evaluations) != 2 { t.Fatalf("expected 2 evaluations, got %d", len(evaluations)) } if evaluations[0].RuleID != "rule-2" { t.Errorf("expected rule-2 (higher priority) to be first, got %s", evaluations[0].RuleID) } if evaluations[1].RuleID != "rule-1" { t.Errorf("expected rule-1 (lower priority) to be second, got %s", evaluations[1].RuleID) } if evaluations[0].Confidence <= evaluations[1].Confidence { t.Errorf("expected higher priority rule to have higher confidence") } } func TestEvaluateRules_CopyrightYear(t *testing.T) { service := &CollectionService{} mediaItem := database.MediaItems{ CopyrightYear: pgtype.Int4{Int32: 2020, Valid: true}, } rules := []Rule{ { ID: "rule-1", Field: "copyright_year", Operator: "greater_than", Value: "2019", Priority: 5, }, } evaluations := service.EvaluateRules(mediaItem, 0, rules) if len(evaluations) != 1 { t.Fatalf("expected 1 evaluation, got %d", len(evaluations)) } if !evaluations[0].Matches { t.Errorf("expected rule to match (2020 > 2019), but it didn't") } } func TestEvaluateRules_MissingField(t *testing.T) { service := &CollectionService{} mediaItem := database.MediaItems{} rules := []Rule{ { ID: "rule-1", Field: "genre", Operator: "equals", Value: "Science Fiction", Priority: 5, }, } evaluations := service.EvaluateRules(mediaItem, 0, rules) if len(evaluations) != 1 { t.Fatalf("expected 1 evaluation, got %d", len(evaluations)) } if evaluations[0].Matches { t.Errorf("expected rule not to match when field is missing, but it did") } } func TestEvaluateRules_CaseInsensitive(t *testing.T) { service := &CollectionService{} mediaItem := database.MediaItems{ Genre: pgtype.Text{String: "science fiction", Valid: true}, } rules := []Rule{ { ID: "rule-1", Field: "genre", Operator: "equals", Value: "SCIENCE FICTION", Priority: 5, }, } evaluations := service.EvaluateRules(mediaItem, 0, rules) if len(evaluations) != 1 { t.Fatalf("expected 1 evaluation, got %d", len(evaluations)) } if !evaluations[0].Matches { t.Errorf("expected case-insensitive match, but it didn't match") } } func TestEvaluateRules_PriorityBoost(t *testing.T) { service := &CollectionService{} mediaItem := database.MediaItems{ Genre: pgtype.Text{String: "Science Fiction", Valid: true}, } rules := []Rule{ { ID: "rule-1", Field: "genre", Operator: "equals", Value: "Science Fiction", Priority: 1, }, { ID: "rule-2", Field: "genre", Operator: "equals", Value: "Science Fiction", Priority: 10, }, } evaluations := service.EvaluateRules(mediaItem, 0, rules) if len(evaluations) != 2 { t.Fatalf("expected 2 evaluations, got %d", len(evaluations)) } // Higher priority (10) should give higher confidence than lower priority (1) // Genre base confidence is 0.9 // Priority 1: 0.9 + 0.01 = 0.91 // Priority 10: 0.9 + 0.1 = 1.0 (capped) if evaluations[0].Confidence != 1.0 { t.Errorf("expected higher priority confidence to be 1.0, got %f", evaluations[0].Confidence) } if evaluations[1].Confidence != 0.91 { t.Errorf("expected lower priority confidence to be 0.91, got %f", evaluations[1].Confidence) } // The higher priority rule should come first (sorted by priority descending) if evaluations[0].RuleID != "rule-2" { t.Errorf("expected higher priority rule to be first, got %s", evaluations[0].RuleID) } } func TestRulesToFilterTree(t *testing.T) { t.Run("episode_rules_map_mechanically", func(t *testing.T) { rules := []Rule{ {ID: "1", Field: "feed", Operator: "in", Value: "feed-a, feed-b"}, {ID: "2", Field: "played", Operator: "equals", Value: "false"}, {ID: "3", Field: "downloaded", Operator: "equals", Value: "true"}, {ID: "4", Field: "published_within", Operator: "greater_than", Value: "7"}, } tree, err := RulesToFilterTree(rules, RuleMatchAll) require.NoError(t, err) assert.Equal(t, RuleMatchAll, tree.Mode, "the collection's match mode becomes the tree mode") assert.Equal(t, ScopeEpisodes, tree.Scope, "episode rules force the episodes scope") require.Len(t, tree.Children, 4) assert.Equal(t, FilterChild{Field: "feed", Op: OpIn, Values: []string{"feed-a", "feed-b"}}, tree.Children[0]) assert.Equal(t, FilterChild{Field: "played", Op: OpIsFalse}, tree.Children[1]) assert.Equal(t, FilterChild{Field: "downloaded", Op: OpIsTrue}, tree.Children[2]) assert.Equal(t, FilterChild{Field: "published_date", Op: OpWithinDays, Values: []string{"7"}}, tree.Children[3]) // The converted tree parses under the strict engine. b, _ := json.Marshal(tree) _, err = ParseFilterTree(string(b)) require.NoError(t, err, "converted tree must satisfy the engine grammar") }) t.Run("book_rules_map_and_stay_books_scope", func(t *testing.T) { rules := []Rule{ {Field: "genre", Operator: "equals", Value: "fantasy"}, {Field: "author", Operator: "contains", Value: "Voss"}, {Field: "copyright_year", Operator: "greater_than", Value: "1990"}, {Field: "tags", Operator: "starts_with", Value: "dyst"}, } tree, err := RulesToFilterTree(rules, RuleMatchAny) require.NoError(t, err) assert.Equal(t, "", tree.Scope, "no episode rules → the books scope (legacy default)") assert.Equal(t, RuleMatchAny, tree.Mode) assert.Equal(t, FilterChild{Field: "genre", Op: OpIs, Values: []string{"fantasy"}}, tree.Children[0]) assert.Equal(t, FilterChild{Field: "copyright_year", Op: OpAtLeast, Values: []string{"1990"}}, tree.Children[2]) // Documented fallback: rules ops without an engine equivalent // (starts_with/ends_with) become contains. assert.Equal(t, OpContains, tree.Children[3].Op) }) }