refactor(core): overhaul state transition architecture, persistent job queue, and API layer
- 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.
This commit is contained in:
@@ -116,3 +116,73 @@ func TestRuleEvaluator(t *testing.T) {
|
||||
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")
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user