Files
Maven/internal/rss/marks_test.go
kami 694d9e4e45 rss: stop claiming "что нового" and stop re-noting the same items
"что нового?" 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.
2026-08-01 14:21:36 +04:00

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)
}
}