feat(web): overview page with verdict line and stats doors (#143)

This commit is contained in:
2026-08-22 00:49:39 +07:00
parent 6f9109c885
commit c432a3be30
6 changed files with 489 additions and 5 deletions
+238
View File
@@ -12,6 +12,7 @@ import (
"os"
"path/filepath"
"reflect"
"regexp"
"strconv"
"strings"
"testing"
@@ -2599,3 +2600,240 @@ func TestAdminSeriesDetailEscapesStoredStrings(t *testing.T) {
t.Errorf("title rendered raw:\n%s", body)
}
}
// overviewBody fetches the Overview landing page as the owner and returns the
// rendered body, failing the test on anything but a 200.
func overviewBody(t *testing.T, srv http.Handler, st *store.Store) string {
t.Helper()
req := httptest.NewRequest(http.MethodGet, "/admin", nil)
req.AddCookie(sessionCookie(t, st))
rr := httptest.NewRecorder()
srv.ServeHTTP(rr, req)
if rr.Code != http.StatusOK {
t.Fatalf("GET /admin status = %d, want 200", rr.Code)
}
return rr.Body.String()
}
// The verdict line has three states, never two: healthy, the count of Lanes
// whose last pass needs the owner, and — on a pass log with no rows at all —
// "no Lane has reported yet". "Nothing has happened" must never render as
// "everything is fine": the virgin state draws no figures at all, least of
// all a confident zero waiting count.
func TestOverviewVerdictThreeStates(t *testing.T) {
router, st := newWebTestServer(t, testConfig())
now := time.Now()
t.Run("healthy", func(t *testing.T) {
seedPass(t, st, store.LanePass{Site: "asura", RanAt: now.UnixMilli(), Due: 2, Checked: 2})
seedPass(t, st, store.LanePass{Site: "kagane", RanAt: now.UnixMilli(), Skip: latest.SkipAsleep, Due: 1})
body := overviewBody(t, router, st)
if !strings.Contains(body, "all lanes healthy") {
t.Errorf("healthy verdict missing:\n%s", body)
}
if !strings.Contains(body, "<b>3</b> series waiting") {
t.Errorf("waiting figure missing from the verdict line:\n%s", body)
}
})
t.Run("lanes need a look", func(t *testing.T) {
seedPass(t, st, store.LanePass{Site: "demonic", RanAt: now.UnixMilli(), Skip: latest.SkipRefusing})
body := overviewBody(t, router, st)
if !strings.Contains(body, "1 lane needs a look") {
t.Errorf("attention verdict missing:\n%s", body)
}
})
t.Run("no lane has reported", func(t *testing.T) {
virgin, fresh := newWebTestServer(t, testConfig())
body := overviewBody(t, virgin, fresh)
if !strings.Contains(body, "no Lane has reported yet") {
t.Errorf("virgin verdict missing:\n%s", body)
}
if strings.Contains(body, "all lanes healthy") || strings.Contains(body, "series waiting") {
t.Errorf("virgin verdict draws confident zeroes:\n%s", body)
}
})
}
// The Overview never refreshes itself: the Lane rest is an hour, so a timer
// would re-run a cross-Series join to redraw identical rows. Only the Lanes
// block carries a refresh attribute.
func TestOverviewCarriesNoRefreshTimer(t *testing.T) {
router, st := newWebTestServer(t, testConfig())
seedPass(t, st, store.LanePass{Site: "asura", RanAt: time.Now().UnixMilli(), Due: 1, Checked: 1})
if body := overviewBody(t, router, st); strings.Contains(body, "hx-trigger") {
t.Errorf("overview carries a refresh timer:\n%s", body)
}
}
// A hygiene figure on the landing page and the heading count on the list it
// links to come from the same store predicate — the acceptance criterion most
// likely to rot. Both endpoints are exercised in one test: each figure's href
// is read off the overview and fetched, and its number must equal the count
// in the list's own heading.
func TestOverviewHygieneFigureAgreesWithListHeading(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()
now := time.Now().UnixMilli()
seedSeriesRow(t, st, db, seriesRowSeed{key: "asura:healthy", url: "https://asurascans.com/comics/healthy", cover: "aaa", checkedAt: now, latestNum: floatPtr(10), bookmarks: 1})
seedSeriesRow(t, st, db, seriesRowSeed{key: "asura:nocover", url: "https://asurascans.com/comics/nocover", checkedAt: now, latestNum: floatPtr(3), bookmarks: 1})
seedSeriesRow(t, st, db, seriesRowSeed{key: "asura:stale", url: "https://asurascans.com/comics/stale", cover: "bbb", checkedAt: now - 24*3600*1000, latestNum: floatPtr(4), bookmarks: 1})
seedSeriesRow(t, st, db, seriesRowSeed{key: "demonic:dead", checkedAt: 0, bookmarks: 0})
srv := newRouter(st, testConfig())
body := overviewBody(t, srv, st)
for _, filter := range []string{"no_cover", "no_series_url", "never_checked"} {
fig := regexp.MustCompile(`href="/admin/series\?filter=` + filter + `">(\d+)</a>`).FindStringSubmatch(body)
if fig == nil {
t.Fatalf("overview has no %s figure:\n%s", filter, body)
}
list := adminSeriesPage(t, srv, st, "?filter="+filter)
heading := regexp.MustCompile(`(\d+) series <span`).FindStringSubmatch(list)
if heading == nil {
t.Fatalf("%s list has no heading count:\n%s", filter, list)
}
if fig[1] != heading[1] {
t.Errorf("%s: the overview says %s, the list it links to says %s", filter, fig[1], heading[1])
}
}
}
// A measured zero is a real fact and stays on the page, but it is not a door:
// following it would land on an empty list. The zero renders as a muted
// digit, never an anchor.
func TestOverviewZeroIsAnUnlinkedDigit(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()
// Every row carries a cover and a URL, so the no-cover and no-URL figures
// are measured zeroes.
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, `<span class="fig zero">0</span>`) {
t.Errorf("no zero figure renders as a muted digit:\n%s", body)
}
if strings.Contains(body, `href="/admin/series?filter=no_cover"`) {
t.Errorf("a zero no-cover figure is still a link:\n%s", body)
}
if strings.Contains(body, `href="/admin/series?filter=no_series_url"`) {
t.Errorf("a zero no-URL 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.
func TestOverviewWaitingSumsLatestPassPerSite(t *testing.T) {
router, st := newWebTestServer(t, testConfig())
now := time.Now()
seedPass(t, st, store.LanePass{Site: "asura", RanAt: now.Add(-3 * time.Hour).UnixMilli(), Due: 1})
seedPass(t, st, store.LanePass{Site: "asura", RanAt: now.Add(-time.Minute).UnixMilli(), Due: 7})
seedPass(t, st, store.LanePass{Site: "demonic", RanAt: now.Add(-2 * time.Hour).UnixMilli(), Due: 4})
body := overviewBody(t, router, st)
if !strings.Contains(body, "<b>11</b> series waiting") {
t.Errorf("waiting figure is not the latest pass per Site summed (7+4=11):\n%s", body)
}
if strings.Contains(body, "<b>1</b> series waiting") {
t.Errorf("an older pass for the same Site counted into waiting:\n%s", body)
}
}
// The hygiene classes overlap — one orphaned, URL-less, cover-less Series is
// three counts and one row — so no figure on the page may equal the naive sum
// of the hygiene counts: a sum over-reports and a distinct count is a number
// nothing can be done about.
func TestOverviewRendersNoAggregateProblemCount(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()
// One Series with no URL, no cover, no bookmark and no check stamp: four
// hygiene filters each count it (NoURL, NoReaders, NeverChecked, NoCover).
seedSeriesRow(t, st, db, seriesRowSeed{key: "demonic:dead", checkedAt: 0, bookmarks: 0})
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>`)
hygieneSum := 0
for _, s := range stat.FindAllStringSubmatch(body, -1) {
switch s[1] {
case "Series", "Manga", "Novels", "Readers":
continue
}
n, _ := strconv.Atoi(s[2] + s[3])
hygieneSum += n
}
if hygieneSum != 4 {
t.Fatalf("seeded library's hygiene figures sum to %d, want 4 (one row in four overlapping filters):\n%s", hygieneSum, body)
}
figs := regexp.MustCompile(`class="fig[^"]*"[^>]*>(\d+)</`).FindAllStringSubmatch(body, -1)
if len(figs) == 0 {
t.Fatalf("no rendered figures found:\n%s", body)
}
for _, f := range figs {
if n, _ := strconv.Atoi(f[1]); n == hygieneSum {
t.Errorf("rendered figure %d equals the naive hygiene sum %d:\n%s", n, hygieneSum, body)
}
}
}
// The per-Site table is library shape only: exactly six columns, every figure
// a door to the list narrowed by both filter and Site, and the State cell
// carrying the Lane's own sentence. No Poll outcome column belongs here.
func TestOverviewPerSiteTableLinksToNarrowedLists(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()
now := time.Now().UnixMilli()
seedSeriesRow(t, st, db, seriesRowSeed{key: "asura:a", url: "https://asurascans.com/comics/a", cover: "aaa", checkedAt: now, latestNum: floatPtr(1), bookmarks: 1})
seedSeriesRow(t, st, db, seriesRowSeed{key: "asura:b", url: "https://asurascans.com/comics/b", checkedAt: now, latestNum: floatPtr(2), bookmarks: 1})
seedSeriesRow(t, st, db, seriesRowSeed{key: "demonic:dead", checkedAt: 0, bookmarks: 0})
seedPass(t, st, store.LanePass{Site: "asura", RanAt: now, Due: 2, Checked: 2})
srv := newRouter(st, testConfig())
body := overviewBody(t, srv, st)
// The landed grid pins six columns; the header states them in order.
if !strings.Contains(body, `<div class="thead"><span>Site</span><span>Series</span><span>No cover</span><span>Never chk</span><span>Stale</span><span>State</span></div>`) {
t.Errorf("per-Site table is not the six pinned columns:\n%s", body)
}
// The Site label is a door to the Site-narrowed list.
if !strings.Contains(body, `<a class="c-site site-asura" href="/admin/series?site=asura">asura</a>`) {
t.Errorf("per-Site label is not a door to its Site-narrowed list:\n%s", body)
}
// A per-Site hygiene figure carries both the filter and the Site.
if !strings.Contains(body, `href="/admin/series?filter=no_cover&amp;site=asura">1</a>`) {
t.Errorf("per-Site no-cover figure does not narrow by Site:\n%s", body)
}
if !strings.Contains(body, `href="/admin/series?filter=no_cover&amp;site=demonic">1</a>`) {
t.Errorf("per-Site no-cover figure missing for demonic:\n%s", body)
}
// The State cell carries the Lane's own sentence: a normal pass says
// nothing, a Site with no pass says so.
if !strings.Contains(body, `<span class="c-state"></span>`) {
t.Errorf("a Lane that read normally renders no state phrase:\n%s", body)
}
if !strings.Contains(body, `no pass yet`) {
t.Errorf("a Site with series but no pass row does not say so:\n%s", body)
}
// Library shape only: no Poll outcome column anywhere.
for _, col := range []string{"Due", "Checked", "Gap", "Refused", "Errors"} {
if strings.Contains(body, "<span>"+col+"</span>") {
t.Errorf("per-Site table carries an outcome column %q:\n%s", col, body)
}
}
}