694d9e4e45
"что нового?" is a greeting, and the feed matcher claimed it: "нового" was a feed noun and "что" an ask. With no feeds block, which is what ships, the answer to hello was "я пока не читаю ленты — они не настроены". A newness word now needs a named topic or a real feed noun beside it. The topic prepositions lose "о" for the same class of reason: one rune of filler produced a category of whatever followed it, and then "по этой теме в лентах пока ничего". An undated feed was re-noted in full on every boot. Dated items are deduped against the durable mark, undated ones against a map that dies with the process, so five items became five more on the next start, stamped now, at the top of the recent-notes window. A crash loop made that a flood. The mark is now set for an undated feed too, and its existence marks the first poll after a restart as a resync: those items are recorded as seen rather than written. A burst larger than max_items lost its middle. The poll walked the feed newest-first, stopped at the cap, and marked the newest item written, which put everything below the cap behind the mark forever. The cap now applies to the oldest candidates and the mark follows what was written, so max_items paces instead of dropping. The category tag was read out loud: "Заголовок [технологии]" went through piper brackets and all, because the answer path took the whole first line. The tag is parsed off for reading and is now the only thing a topic is matched against. Matching the whole note meant "что нового про погоду" hit any tech headline whose link contained "pogod". Also: the charset comment on dec.Strict described something Strict does not do, and a skipped feed is named in the log. Found in review of #66.
115 lines
4.2 KiB
Go
115 lines
4.2 KiB
Go
package rss
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
// undatedFeed — a feed whose items carry no pubDate. Plenty of real ones do not.
|
|
func undatedFeed(titles ...string) string {
|
|
var b strings.Builder
|
|
b.WriteString(`<?xml version="1.0"?><rss version="2.0"><channel><title>u</title>`)
|
|
for _, t := range titles {
|
|
fmt.Fprintf(&b, `<item><title>%s</title><link>https://example.org/%s</link><guid>%s</guid></item>`, t, t, t)
|
|
}
|
|
b.WriteString(`</channel></rss>`)
|
|
return b.String()
|
|
}
|
|
|
|
// datedFeed — newest first, one hour apart, the standard shape.
|
|
func datedFeed(base time.Time, n int) string {
|
|
var b strings.Builder
|
|
b.WriteString(`<?xml version="1.0"?><rss version="2.0"><channel><title>d</title>`)
|
|
for i := 0; i < n; i++ {
|
|
ts := base.Add(-time.Duration(i) * time.Hour)
|
|
fmt.Fprintf(&b, `<item><title>item-%d</title><link>https://example.org/%d</link><guid>g%d</guid><pubDate>%s</pubDate></item>`,
|
|
i, i, i, ts.Format(time.RFC1123Z))
|
|
}
|
|
b.WriteString(`</channel></rss>`)
|
|
return b.String()
|
|
}
|
|
|
|
func TestPoll_UndatedFeedIsNotReNotedAfterARestart(t *testing.T) {
|
|
// The seen-IDs map dies with the process, so before the durable mark was
|
|
// consulted every boot re-noted the whole front page, stamped now, on top of
|
|
// the recent-notes window. A crash loop made that a flood.
|
|
feed := FeedConfig{Name: "u", URL: "https://example.org/rss"}
|
|
fetch := &fakeFetch{body: undatedFeed("a", "b", "c")}
|
|
marks := newMarks()
|
|
|
|
notes1 := &fakeNotes{}
|
|
p1 := NewPoller([]FeedConfig{feed}, fetch, notes1, marks, nil, nil, Config{})
|
|
p1.PollDue(context.Background(), now)
|
|
if len(notes1.notes) != 3 {
|
|
t.Fatalf("first boot wrote %d notes, want 3", len(notes1.notes))
|
|
}
|
|
if marks.m["u"].IsZero() {
|
|
t.Fatal("an undated feed left no mark, so the next process cannot tell it has been read")
|
|
}
|
|
|
|
// Restart. Same process-lifetime dedup map, gone.
|
|
notes2 := &fakeNotes{}
|
|
p2 := NewPoller([]FeedConfig{feed}, fetch, notes2, marks, nil, nil, Config{})
|
|
p2.PollDue(context.Background(), now.Add(time.Hour))
|
|
if len(notes2.notes) != 0 {
|
|
t.Fatalf("a restart re-noted %d undated items: %v", len(notes2.notes), notes2.notes)
|
|
}
|
|
// And the same process still notices something genuinely new.
|
|
fetch.body = undatedFeed("a", "b", "c", "d")
|
|
p2.PollDue(context.Background(), now.Add(2*time.Hour))
|
|
if len(notes2.notes) != 1 {
|
|
t.Fatalf("a new undated item after the resync wrote %d notes, want 1", len(notes2.notes))
|
|
}
|
|
}
|
|
|
|
func TestPoll_BurstLargerThanMaxItemsIsPacedNotDropped(t *testing.T) {
|
|
// max_items reads as pacing in the config doc. Marking the newest item
|
|
// written put everything below the cap behind the mark, permanently.
|
|
feed := FeedConfig{Name: "d", URL: "https://example.org/rss"}
|
|
fetch := &fakeFetch{body: datedFeed(now.Add(-time.Minute), 12)}
|
|
notes := &fakeNotes{}
|
|
marks := newMarks()
|
|
p := NewPoller([]FeedConfig{feed}, fetch, notes, marks, nil, nil, Config{MaxItems: 5, MaxAge: 48 * time.Hour})
|
|
|
|
at := now
|
|
for i := 0; i < 3; i++ {
|
|
if _, err := p.PollFeed(context.Background(), feed, at); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
at = at.Add(time.Hour)
|
|
}
|
|
seen := map[string]bool{}
|
|
for _, n := range notes.notes {
|
|
title := strings.SplitN(n.text, "\n", 2)[0]
|
|
if seen[title] {
|
|
t.Errorf("item %q was noted twice", title)
|
|
}
|
|
seen[title] = true
|
|
}
|
|
if len(seen) != 12 {
|
|
t.Errorf("after three polls of a 12-item burst she has %d of them; the rest were dropped for good", len(seen))
|
|
}
|
|
}
|
|
|
|
func TestNoteHeadlineAndCategory(t *testing.T) {
|
|
text := NoteText(FeedConfig{Category: "технологии"}, Item{
|
|
Title: "Новая уязвимость", Summary: "Патч вышел", Link: "https://example.org/a",
|
|
})
|
|
if got := NoteHeadline(text); got != "Новая уязвимость" {
|
|
t.Errorf("NoteHeadline = %q; she reads the brackets out loud", got)
|
|
}
|
|
if got := NoteCategory(text); got != "технологии" {
|
|
t.Errorf("NoteCategory = %q, want технологии", got)
|
|
}
|
|
// A feed's own leading tag stays part of the title.
|
|
if got := NoteHeadline("[перевод] Что-то"); got != "[перевод] Что-то" {
|
|
t.Errorf("NoteHeadline stripped the feed's own tag: %q", got)
|
|
}
|
|
if got := NoteCategory("Без категории"); got != "" {
|
|
t.Errorf("NoteCategory on an untagged note = %q, want empty", got)
|
|
}
|
|
}
|