Two new Series filters: failing, and the unverified Reader subset (#166)

This commit is contained in:
2026-08-22 23:43:47 +07:00
parent 5a32943528
commit 8a56907558
4 changed files with 224 additions and 14 deletions
+131
View File
@@ -527,3 +527,134 @@ func TestAdminSeriesCarriesFinishedAt(t *testing.T) {
t.Fatalf("row = %+v, want FinishedAt 5000", page.Rows)
}
}
// The failing pair reads the failure row's age, not the Series row (issue
// #165): failing requires the joined row, a Latest Chapter, and failing_since
// past the cutoff — the same constant stale reads; unverified is the
// Reader-attributed subset of the same test. A failure inside the window is
// in neither — one bad fetch is not a fault to correct — and a Series that
// never captured a chapter is never failing, the exact complement of
// never-read-a-chapter's IS NULL half, so the two filters are disjoint by
// construction. A finished failing Series still appears: this pair reads
// stored outcomes, which simply stop arriving, and carries no finished guard.
func TestAdminFailingAndUnverifiedFilters(t *testing.T) {
s := newTestStore(t)
const cutoff = 5000
seedAdminSeries(t, s, seriesSeed{key: "asura:failing", url: "u", cover: "c", checkedAt: 9000, latestNum: new(10.0), bookmarks: 1})
seedAdminSeries(t, s, seriesSeed{key: "asura:recent", url: "u", cover: "c", checkedAt: 9000, latestNum: new(9.0), bookmarks: 1})
seedAdminSeries(t, s, seriesSeed{key: "asura:nochapter", url: "u", cover: "c", checkedAt: 9000, bookmarks: 1})
seedAdminSeries(t, s, seriesSeed{key: "asura:reader", url: "u", cover: "c", checkedAt: 9000, latestNum: new(8.0), bookmarks: 1, raisedBy: true})
seedAdminSeries(t, s, seriesSeed{key: "asura:polled", url: "u", cover: "c", checkedAt: 9000, latestNum: new(7.0), bookmarks: 1})
seedAdminSeries(t, s, seriesSeed{key: "asura:fin-failing", url: "u", cover: "c", checkedAt: 9000, latestNum: new(6.0), bookmarks: 1})
seedAdminSeries(t, s, seriesSeed{key: "asura:healthy", url: "u", cover: "c", checkedAt: 9000, latestNum: new(5.0), bookmarks: 1})
// Failure rows seed the run's start stamp; the cutoff decides the age.
for _, f := range []struct {
key, word string
since int64
}{
{"asura:failing", "not_found", 2000},
{"asura:recent", "not_found", 9000},
{"asura:nochapter", "not_found", 2000},
{"asura:reader", "not_found", 2000},
{"asura:polled", "errors", 2000},
{"asura:fin-failing", "not_found", 2000},
} {
site, id, _ := strings.Cut(f.key, ":")
if err := s.RecordSeriesFailure(site, id, f.word, f.since); err != nil {
t.Fatalf("seed failure %s: %v", f.key, err)
}
}
if err := s.SetSeriesFinished("asura", "fin-failing", 1000); err != nil {
t.Fatalf("finish asura:fin-failing: %v", err)
}
cases := []struct {
name string
f SeriesFilter
want []string
}{
{"failing", SeriesFilter{Name: SeriesFilterFailing, Cutoff: cutoff}, []string{"asura:failing", "asura:reader", "asura:polled", "asura:fin-failing"}},
{"unverified", SeriesFilter{Name: SeriesFilterUnverified, Cutoff: cutoff}, []string{"asura:reader"}},
{"never read a chapter", SeriesFilter{Name: SeriesFilterNoChapter}, []string{"asura:nochapter"}},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
got := pageKeys(t, s, tc.f)
want := map[string]bool{}
for _, k := range tc.want {
want[k] = true
}
if len(got) != len(want) {
t.Fatalf("%+v returned %v, want exactly %v", tc.f, got, want)
}
for k := range want {
if !got[k] {
t.Fatalf("%+v dropped %q (got %v)", tc.f, k, got)
}
}
})
}
// Disjointness: the failing pair and never-read-a-chapter are disjoint by
// the chapter column — IS NOT NULL here, IS NULL there — so no row may be
// counted under both.
failing := pageKeys(t, s, SeriesFilter{Name: SeriesFilterFailing, Cutoff: cutoff})
noChapter := pageKeys(t, s, SeriesFilter{Name: SeriesFilterNoChapter})
for k := range failing {
if noChapter[k] {
t.Fatalf("row %q matches both failing and never-read-a-chapter", k)
}
}
if failing["asura:nochapter"] {
t.Fatal("a Series with no chapter ever captured appears in failing")
}
if !noChapter["asura:nochapter"] {
t.Fatal("the no-chapter Series vanished from never-read-a-chapter")
}
// The aggregates count the same rows the lists do: the landing page's
// figures and the select's options come from these two passes.
for _, name := range []string{SeriesFilterFailing, SeriesFilterUnverified} {
shapes, err := s.SeriesShapes(SeriesFilter{Name: name, Cutoff: cutoff})
if err != nil {
t.Fatalf("SeriesShapes(%s): %v", name, err)
}
sum := 0
for _, sh := range shapes {
sum += sh.Total
}
want := len(pageKeys(t, s, SeriesFilter{Name: name, Cutoff: cutoff}))
if sum != want {
t.Fatalf("%s aggregate sum = %d, want %d (aggregate disagrees with row query)", name, sum, want)
}
}
}
// The projection carries the failure facts from the LEFT JOIN, not the
// Series row: a failing Series reads back its outcome word and the stamp its
// run began at; a Series with no failure row reads empty and zero. Nothing
// new is projected from the Series row itself.
func TestAdminFailureProjection(t *testing.T) {
s := newTestStore(t)
seedAdminSeries(t, s, seriesSeed{key: "asura:broken", url: "u", cover: "c", checkedAt: 9000, latestNum: new(9.0), bookmarks: 1})
seedAdminSeries(t, s, seriesSeed{key: "asura:fine", url: "u", cover: "c", checkedAt: 9000, latestNum: new(8.0), bookmarks: 1})
if err := s.RecordSeriesFailure("asura", "broken", "not_found", 2000); err != nil {
t.Fatalf("seed failure row: %v", err)
}
page, err := s.SeriesPage(SeriesFilter{})
if err != nil {
t.Fatalf("SeriesPage: %v", err)
}
byKey := map[string]AdminSeries{}
for _, a := range page.Rows {
byKey[a.Key()] = a
}
if byKey["asura:broken"].FailureOutcome != "not_found" || byKey["asura:broken"].FailingSince != 2000 {
t.Fatalf("broken row = %+v, want FailureOutcome not_found, FailingSince 2000", byKey["asura:broken"])
}
if byKey["asura:fine"].FailureOutcome != "" || byKey["asura:fine"].FailingSince != 0 {
t.Fatalf("fine row = %+v, want empty outcome and zero stamp", byKey["asura:fine"])
}
}