collections gains filter_tree JSONB (idempotent in-place; same qf DSL as saved filters — the scope key included). IsCollectionSmart is the ONE detection (tree OR legacy rules), and EvaluateSmartMembership is the ONE evaluator: tree-bearing collections run the composed engine via ComposedLiveMedia (full media rows, so the collection surfaces keep their episode-card stamping, feed positions for the replay chain, and the family lens rides the mandatory head), while legacy rule arrays keep per-item FieldMatches until rewritten — one call surface, two storage epochs. All smart surfaces (detail JSON, counts, detail page, reader chain) dispatch through it; the search service is injected into the collection subsystem (variadic — main.go builds one shared instance; the test harness mirrors it). Write path: CreateCollection/UpdateCollection accept the tree, validated against the engine grammar at the HANDLER (an invalid tree is a 400, never a stored render-time 500) and exposed in the envelope (pin widened in TestContractPin_CollectionsJSON + changelog). Update semantics: an edit form that doesn't send a tree keeps the stored one. RulesToFilterTree converts legacy rules + match mode at upgrade time: episode rules map mechanically (feed→in-list, played/downloaded→ booleans, published_within→published_date+within_days), tree root mode absorbs rule_match_mode, episode rules force the episodes scope, and legacy starts_with/ends_with fall back to contains (documented). Read-compat by design: nothing is migrated at boot — legacy arrays evaluate exactly as before until the collection is rewritten. Pinned by TestRulesToFilterTree (unit conversion) + TestEpisodeCollectionsFilterTree (a tree collection evaluates through the engine, the chain follows the tree's live membership, invalid trees 400 at write, the tree wins when legacy rules coexist, and an edit without a tree keeps it).
324 lines
8.4 KiB
Go
324 lines
8.4 KiB
Go
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)
|
|
})
|
|
}
|