Add finished Series filter; clock-driven hygiene predicates exclude it (#159)

This commit is contained in:
2026-08-22 17:25:10 +07:00
parent f85641da61
commit 0029cff27c
5 changed files with 218 additions and 23 deletions
+98 -2
View File
@@ -2139,7 +2139,6 @@ func TestNovelPageOmitsUpdatedTab(t *testing.T) {
}
}
func TestMangaPageKeepsUpdatedTab(t *testing.T) {
cfg := testConfig()
srv, st := newWebTestServer(t, cfg)
@@ -2308,6 +2307,42 @@ func TestSeriesListFilterWiring(t *testing.T) {
}
}
// The finished filter is a first-class option: the select carries it with
// its label and library-wide count, and entering it lists exactly the
// retired rows — picking it up from order-plus-label like every other
// filter, with no per-filter branch in the handler.
func TestSeriesListFinishedFilterOption(t *testing.T) {
st, dsn := newTestStoreURL(t)
db, err := sql.Open("pgx", dsn)
if err != nil {
t.Fatalf("open %s: %v", dsn, err)
}
defer db.Close()
seedSeriesRow(t, st, db, seriesRowSeed{key: "asura:healthy", url: "u", cover: "c", checkedAt: time.Now().UnixMilli(), latestNum: floatPtr(10), bookmarks: 1})
seedSeriesRow(t, st, db, seriesRowSeed{key: "asura:retired", url: "u", cover: "c", checkedAt: time.Now().UnixMilli(), latestNum: floatPtr(1), bookmarks: 1})
if err := st.SetSeriesFinished("asura", "retired", time.Now().UnixMilli()); err != nil {
t.Fatalf("finish asura:retired: %v", err)
}
srv := newRouter(st, testConfig())
body := adminSeriesPage(t, srv, st, "?filter=finished")
if !strings.Contains(body, "Title of asura:retired") {
t.Errorf("finished list misses its row:\n%s", body)
}
if strings.Contains(body, "Title of asura:healthy") {
t.Errorf("finished list renders an unfinished row:\n%s", body)
}
if !strings.Contains(body, "1 series") || !strings.Contains(body, "Finished") {
t.Errorf("finished heading lacks the count and label:\n%s", body)
}
if !strings.Contains(body, "Finished (1)") {
t.Errorf("the finished option lacks its count:\n%s", body)
}
if !strings.Contains(body, `<option value="finished" selected>`) {
t.Errorf("the finished option is not selected:\n%s", body)
}
}
// ?filter=stale&site=kagane&kind=manga narrows on all three at once: only the
// kagane manga stale row renders, and every link the page emits carries the
// filter and Site so the narrowing survives in the URL.
@@ -2992,6 +3027,67 @@ func TestOverviewZeroIsAnUnlinkedDigit(t *testing.T) {
}
}
// The finished figure rides last in the Hygiene block: seriesFilterOrder
// appends it at the tail, the block renders order[1:], and the count comes
// from the same aggregate the select is numbered from. Asserting on the body
// order catches a render that merely shows the figure somewhere.
func TestOverviewFinishedFigureRidesLast(t *testing.T) {
st, dsn := newTestStoreURL(t)
db, err := sql.Open("pgx", dsn)
if err != nil {
t.Fatalf("open %s: %v", dsn, err)
}
defer db.Close()
seedSeriesRow(t, st, db, seriesRowSeed{key: "asura:healthy", url: "https://asurascans.com/comics/healthy", cover: "aaa", checkedAt: time.Now().UnixMilli(), latestNum: floatPtr(10), bookmarks: 1})
seedSeriesRow(t, st, db, seriesRowSeed{key: "asura:retired", url: "https://asurascans.com/comics/retired", cover: "bbb", checkedAt: time.Now().UnixMilli(), latestNum: floatPtr(1), bookmarks: 1})
if err := st.SetSeriesFinished("asura", "retired", time.Now().UnixMilli()); err != nil {
t.Fatalf("finish asura:retired: %v", err)
}
srv := newRouter(st, testConfig())
body := overviewBody(t, srv, st)
stat := regexp.MustCompile(`<div class="stat"><span class="lbl">(.*?)</span>(?:<a class="fig"[^>]*>(\d+)</a>|<span class="fig zero">(\d+)</span>)</div>`)
var labels []string
for _, m := range stat.FindAllStringSubmatch(body, -1) {
switch m[1] {
case "Series", "Manga", "Novels", "Readers":
continue // the Library block, not the hygiene figures
}
labels = append(labels, m[1])
}
if len(labels) != 8 {
t.Fatalf("hygiene block renders %d figures, want 8 (seven problems + finished):\n%s", len(labels), body)
}
if labels[len(labels)-1] != "Finished" {
t.Errorf("finished figure does not ride last; hygiene order = %v:\n%s", labels, body)
}
if !strings.Contains(body, `href="/admin/series?filter=finished">1</a>`) {
t.Errorf("the finished figure lacks its count and door:\n%s", body)
}
}
// A measured zero finished count stays on the page as a muted digit, never a
// link: following it would land on an empty list, exactly like every other
// zero figure.
func TestOverviewFinishedZeroIsAnUnlinkedDigit(t *testing.T) {
st, dsn := newTestStoreURL(t)
db, err := sql.Open("pgx", dsn)
if err != nil {
t.Fatalf("open %s: %v", dsn, err)
}
defer db.Close()
seedSeriesRow(t, st, db, seriesRowSeed{key: "asura:one", url: "https://asurascans.com/comics/one", cover: "aaa", checkedAt: time.Now().UnixMilli(), latestNum: floatPtr(1), bookmarks: 1})
srv := newRouter(st, testConfig())
body := overviewBody(t, srv, st)
if !strings.Contains(body, `Finished</span><span class="fig zero">0</span>`) {
t.Errorf("a zero finished figure does not render as a muted digit:\n%s", body)
}
if strings.Contains(body, `href="/admin/series?filter=finished"`) {
t.Errorf("a zero finished figure is still a link:\n%s", body)
}
}
// The waiting figure sums Due over the latest pass per Site — older passes
// for the same Site must not double-count, so the verdict reads the same
// latest-per-Site projection the Lanes page reads.
@@ -3495,7 +3591,7 @@ func TestSeriesFinishRoute(t *testing.T) {
cookie := sessionCookie(t, st)
for path, want := range map[string]int{
"/admin/series/solo/finish": http.StatusBadRequest,
"/admin/series/solo/finish": http.StatusBadRequest,
"/admin/series/ghost:x/finish": http.StatusNotFound,
} {
req := httptest.NewRequest(http.MethodPost, path, strings.NewReader(""))