- Implement atomic state transitions: add EntryService.PatchEntry() with EntryDiff computation and EventEntryTransition event emission. - Add persistent SQLite task queue (JobEngine): add jobs table with crash recovery, exponential backoff retries, concurrency control, and idempotency key deduplication. - Decompose monolithic HTTP handlers: split handlers.go into domain handlers (auth, feeds, entries, jobs, settings, rules, sse). - Implement real-time Server-Sent Events (/api/events/stream) for multi-tab/device state synchronization. - Fix SQLite concurrency: apply busy_timeout(10000) and journal_mode(WAL) via DSN query parameters to resolve SQLITE_BUSY (5). - Fix AI settings & prompt loading: unmarshal JSON from ai_settings, apply configured prompt templates, and enforce strict enabled/API key validations. - Fix undo share state: remove omitempty from ShareUUID to serialize null for state resets. - Fix middle list counter: compute true unread/total stats from DB and clean switcher button labels. - Adapt mobile UI for iOS: add viewport-fit=cover in index.html and calibrate safe-area insets for the bottom navigation and article reader toolbar. - Add architectural documentation and worklog in docs/009_worklog.md.
189 lines
5.0 KiB
Go
189 lines
5.0 KiB
Go
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: "<p>Go 1.24 brings new features...</p>",
|
|
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")
|
|
}
|
|
}
|