package rules import ( "testing" "time" "git.pengzhan.dev/haopengzhan/rsspipe/internal/models" ) func TestRuleEvaluator(t *testing.T) { eval := NewEvaluator() entry := models.FeedEntry{ ID: "entry-1", FeedID: "feed-hn", FeedTitle: "Hacker News", Title: "Announcing Go 1.24 Release", Author: "Go Team", Content: "

Go 1.24 brings new features...

", Variants: map[string]models.EntryVariant{ "keywords": { EntryID: "entry-1", VariantKey: "keywords", ContentType: "json", TextValue: `["go", "compiler", "release"]`, }, }, PublishedAt: time.Now().Add(-2 * time.Hour), CreatedAt: time.Now().Add(-2 * time.Hour), } // Test 1: Title contains Go AND FeedID in list rule1 := models.Rule{ ID: "r1", Name: "Tech Feeds in list", MatchMode: "all", Conditions: []models.RuleCondition{ {Field: "feed_id", Operator: "in", Value: []string{"feed-hn", "feed-36kr", "feed-ft"}}, {Field: "title", Operator: "contains", Value: "Go 1.24"}, }, } if !eval.Evaluate(rule1, entry) { t.Error("expected rule1 to match entry") } // Test 2: not_in operator rule2 := models.Rule{ ID: "r2", Name: "Feed not in noise", MatchMode: "all", Conditions: []models.RuleCondition{ {Field: "feed_id", Operator: "not_in", Value: "feed-spam,feed-ads"}, }, } if !eval.Evaluate(rule2, entry) { t.Error("expected rule2 to match entry") } // Test 3: contains_any operator rule3 := models.Rule{ ID: "r3", Name: "Title match any keyword", MatchMode: "all", Conditions: []models.RuleCondition{ {Field: "title", Operator: "contains_any", Value: "Rust, Python, Go 1.24"}, }, } if !eval.Evaluate(rule3, entry) { t.Error("expected rule3 to match entry with contains_any") } // Test 4: exists operator on variants rule4 := models.Rule{ ID: "r4", Name: "Keywords exist", MatchMode: "all", Conditions: []models.RuleCondition{ {Field: "variants.keywords", Operator: "exists"}, {Field: "variants.nonexistent", Operator: "not_exists"}, }, } if !eval.Evaluate(rule4, entry) { t.Error("expected rule4 to evaluate variant existence correctly") } // Test 5: Timestamp within_last_hours rule5 := models.Rule{ ID: "r5", Name: "Recent article", MatchMode: "all", Conditions: []models.RuleCondition{ {Field: "published_at", Operator: "within_last_hours", Value: 24}, }, } if !eval.Evaluate(rule5, entry) { t.Error("expected rule5 to match article within last 24h") } // Test 6: Safety validator unsafeRule := models.Rule{ Trigger: "variant_change", Conditions: []models.RuleCondition{ {Field: "variants.tags", Operator: "exists"}, }, Actions: []models.RuleAction{ { Type: "set_variant", Params: map[string]interface{}{ "variant_key": "tags", "value": "new-tag", }, }, }, } if err := ValidateRuleSafety(unsafeRule); err == nil { t.Error("expected ValidateRuleSafety to detect cyclic loop") } } func TestTransitionEvaluation(t *testing.T) { eval := NewEvaluator() oldEntry := models.FeedEntry{ ID: "entry-2", FeedID: "feed-1", FeedTitle: "Tech Blog", Title: "Deep Dive into Go Scheduler", Content: "Short intro...", Variants: map[string]models.EntryVariant{}, } newEntryWithFullText := models.FeedEntry{ ID: "entry-2", FeedID: "feed-1", FeedTitle: "Tech Blog", Title: "Deep Dive into Go Scheduler", Content: "Short intro...", Variants: map[string]models.EntryVariant{ "readability_full_text": { EntryID: "entry-2", VariantKey: "readability_full_text", TextValue: "Full article content here with extensive details...", }, }, } diff := &models.EntryDiff{ VariantsAdded: []string{"readability_full_text"}, } // Rule: trigger AI summary when readability_full_text became_present AND ai_summary is empty aiRule := models.Rule{ ID: "r-ai", Name: "AI summary on full text added", MatchMode: "all", Conditions: []models.RuleCondition{ {Field: "variants.readability_full_text", Operator: "became_present"}, {Field: "variants.ai_summary", Operator: "is_empty"}, }, } // Should match when full text was added if !eval.EvaluateTransition(aiRule, &oldEntry, &newEntryWithFullText, diff) { t.Error("expected aiRule to match when readability_full_text became_present") } // Should NOT match if tag was added instead of full text diffOnlyTags := &models.EntryDiff{ FieldsChanged: []string{"metadata.tags"}, } if eval.EvaluateTransition(aiRule, &oldEntry, &oldEntry, diffOnlyTags) { t.Error("expected aiRule to NOT match when only tags changed") } // Test word count condition for TTS: word_count >= 5 ttsRule := models.Rule{ ID: "r-tts", Name: "TTS on long summary", MatchMode: "all", Conditions: []models.RuleCondition{ {Field: "variants.readability_full_text.word_count", Operator: ">=", Value: 5}, }, } if !eval.EvaluateTransition(ttsRule, &oldEntry, &newEntryWithFullText, diff) { t.Error("expected ttsRule to match when word_count >= 5") } }