Files
bookhoard/internal/services/collection_service_test.go
T
John O'Keefe 18727fef93 refactor(collections): rule evaluators take the real row; both 45-field converter twins deleted
checkRulesAgainstBook/evaluateRule and the service's EvaluateRules
existed in two parallel universes: one over database.MediaItems (what
every query actually returns) and one over ListMediaItemsRow (what two
hand-written 45-field mediaItemsToListMediaItemsRow converters
produced — verbatim twins in the handler and the dashboard service,
with a URL-resolution side effect baked into a "converter"). The rule
fields (genre/series/author/language/publisher/copyright_year/tags)
all exist on MediaItems, so the evaluators now take the real row and
the converters, their three call-site loops, and the dead
ApplyAutoAssignRules entry point (zero callers) are gone.

The rules surface is wire-covered by the collections rules scenarios;
handlers+services suites and the full pin/H1/H3 battery green.
2026-10-17 16:48:39 -04:00

279 lines
6.1 KiB
Go

package services
import (
"bookhoard/internal/database"
"testing"
"github.com/jackc/pgx/v5/pgtype"
)
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, 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, 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, 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, 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, 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, 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, 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, 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)
}
}