feat(web): overview page with verdict line and stats doors (#143)
This commit is contained in:
@@ -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&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&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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user