Merge ticket/143-overview
This commit is contained in:
@@ -25,6 +25,8 @@ type adminView struct {
|
|||||||
SeriesList seriesListView
|
SeriesList seriesListView
|
||||||
// Detail is the per-Series page data; zero on every other page.
|
// Detail is the per-Series page data; zero on every other page.
|
||||||
Detail seriesDetailView
|
Detail seriesDetailView
|
||||||
|
// Overview is the landing page data; zero on every other page.
|
||||||
|
Overview overviewView
|
||||||
}
|
}
|
||||||
|
|
||||||
// adminRoute pairs a route pattern with its handler so the route list and the
|
// adminRoute pairs a route pattern with its handler so the route list and the
|
||||||
@@ -76,9 +78,17 @@ func (h *Handler) requireOwner(next http.HandlerFunc) http.HandlerFunc {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// admin renders the Overview shell. Its data arrives in later admin tickets.
|
// admin renders the Overview landing page: a verdict line, a stats block
|
||||||
|
// where every figure is a door into the list it counts, and the per-Site
|
||||||
|
// library shape table — all read from the database, never from a poller.
|
||||||
func (h *Handler) admin(w http.ResponseWriter, r *http.Request) {
|
func (h *Handler) admin(w http.ResponseWriter, r *http.Request) {
|
||||||
h.renderAdmin(w, adminView{Page: "overview"})
|
view, err := h.overviewView()
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("admin overview: %v", err)
|
||||||
|
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
h.renderAdmin(w, adminView{Page: "overview", Overview: view})
|
||||||
}
|
}
|
||||||
|
|
||||||
// adminReaders renders the Reader roster on its own bookmarkable page.
|
// adminReaders renders the Reader roster on its own bookmarkable page.
|
||||||
|
|||||||
@@ -0,0 +1,197 @@
|
|||||||
|
package web
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"bookmarkmanager/backend/internal/store"
|
||||||
|
)
|
||||||
|
|
||||||
|
// overviewView is the Overview landing page's data: one verdict line, the
|
||||||
|
// hygiene and library stats blocks, and the per-Site library shape table.
|
||||||
|
// Every judgement — the verdict state, which figures link, what a Lane's
|
||||||
|
// state means — is made here; the template only prints.
|
||||||
|
type overviewView struct {
|
||||||
|
// Verdict is the attention phrase that leads the page.
|
||||||
|
Verdict string
|
||||||
|
// HasCounts is false on a virgin pass log: the waiting figure would be a
|
||||||
|
// confident zero, and "nothing has happened" must not render as health.
|
||||||
|
HasCounts bool
|
||||||
|
// Waiting is the sum of Due over the latest pass per Site.
|
||||||
|
Waiting int
|
||||||
|
// Unchecked is the number of Series not checked in the window, computed
|
||||||
|
// as stale + never_checked: a never-checked Series is already counted on
|
||||||
|
// its own filter, and the verdict wants the inclusive number.
|
||||||
|
Unchecked int
|
||||||
|
// Hygiene is the seven problem filters in the Series list's own render
|
||||||
|
// order; Library is the library split plus the roster. Every figure is a
|
||||||
|
// door into the list that counts it, except a zero.
|
||||||
|
Hygiene []fig
|
||||||
|
Library []fig
|
||||||
|
// Sites is the per-Site library shape table, one row per Site with any
|
||||||
|
// Series, in the store's Site order.
|
||||||
|
Sites []siteRow
|
||||||
|
}
|
||||||
|
|
||||||
|
// fig is one stats figure: its label, the list it counts, and the count
|
||||||
|
// itself. Href empty means the count is zero: a measured zero is a real
|
||||||
|
// figure that stays on the page, but it is not a door, because following it
|
||||||
|
// lands on an empty list.
|
||||||
|
type fig struct {
|
||||||
|
Label string
|
||||||
|
Href string
|
||||||
|
Count int
|
||||||
|
}
|
||||||
|
|
||||||
|
// siteRow is one Site's share of the library: the Series total and the three
|
||||||
|
// hygiene counts the per-Site table carries, each a door to the list narrowed
|
||||||
|
// to that Site, plus the Lane state phrase derived from its latest pass. The
|
||||||
|
// table is library shape only — the Poll outcome sums live on the Lanes page.
|
||||||
|
type siteRow struct {
|
||||||
|
Site string
|
||||||
|
SiteHref string
|
||||||
|
Figs []fig
|
||||||
|
// State is the Lane's own sentence; "" means the last pass read normally.
|
||||||
|
// StateGood / StateBad pick the ok / bad second class.
|
||||||
|
State string
|
||||||
|
StateGood bool
|
||||||
|
StateBad bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// overviewView assembles the landing page from the store's read model: one
|
||||||
|
// SeriesShapes pass per filter summed in Go (the shipped surface offers eight
|
||||||
|
// grouped passes, not a stats query — #140), the pass log's latest pass per
|
||||||
|
// Site, and the roster. A failure in any read is a 500 with a logged reason,
|
||||||
|
// never a page of silent zeroes.
|
||||||
|
func (h *Handler) overviewView() (overviewView, error) {
|
||||||
|
now := time.Now()
|
||||||
|
cutoff := now.Add(-ownerWindow).UnixMilli()
|
||||||
|
|
||||||
|
shapes := make(map[string][]store.SiteSeriesShape, len(seriesFilterOrder))
|
||||||
|
totals := make(map[string]int, len(seriesFilterOrder))
|
||||||
|
for _, name := range seriesFilterOrder {
|
||||||
|
rows, err := h.store.SeriesShapes(store.SeriesFilter{Name: name, Cutoff: cutoff})
|
||||||
|
if err != nil {
|
||||||
|
return overviewView{}, err
|
||||||
|
}
|
||||||
|
shapes[name] = rows
|
||||||
|
for _, sh := range rows {
|
||||||
|
totals[name] += sh.Total
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
passes, err := h.store.LatestLanePasses()
|
||||||
|
if err != nil {
|
||||||
|
return overviewView{}, err
|
||||||
|
}
|
||||||
|
readers, err := h.store.Readers()
|
||||||
|
if err != nil {
|
||||||
|
return overviewView{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
view := overviewView{Waiting: waiting(passes)}
|
||||||
|
view.Unchecked = totals[store.SeriesFilterStale] + totals[store.SeriesFilterNeverChecked]
|
||||||
|
view.Verdict, view.HasCounts = overviewVerdict(passes, now)
|
||||||
|
|
||||||
|
// The seven problem filters, in seriesFilterOrder's permanent-then-fixable
|
||||||
|
// order; the All filter's count belongs to the Library block, not to a
|
||||||
|
// "hygiene" figure.
|
||||||
|
hygiene := make([]fig, 0, len(seriesFilterOrder)-1)
|
||||||
|
for _, name := range seriesFilterOrder[1:] {
|
||||||
|
hygiene = append(hygiene, door(seriesFilterLabels[name], totals[name], seriesListHref(name, "", "", 0)))
|
||||||
|
}
|
||||||
|
view.Hygiene = hygiene
|
||||||
|
|
||||||
|
var manga, novel int
|
||||||
|
for _, sh := range shapes[store.SeriesFilterAll] {
|
||||||
|
manga += sh.Manga
|
||||||
|
novel += sh.Novel
|
||||||
|
}
|
||||||
|
view.Library = []fig{
|
||||||
|
door("Series", totals[store.SeriesFilterAll], seriesListHref("", "", "", 0)),
|
||||||
|
door("Manga", manga, seriesListHref("", "", store.KindManga, 0)),
|
||||||
|
door("Novels", novel, seriesListHref("", "", store.KindNovel, 0)),
|
||||||
|
door("Readers", len(readers), "/admin/readers"),
|
||||||
|
}
|
||||||
|
|
||||||
|
// One row per Site with any Series, from the All shapes; the hygiene
|
||||||
|
// counts come from the same per-Site projection so the table cannot
|
||||||
|
// disagree with the library-wide figures above it.
|
||||||
|
siteCounts := make(map[string]map[string]int, len(shapes))
|
||||||
|
for name, rows := range shapes {
|
||||||
|
m := make(map[string]int, len(rows))
|
||||||
|
for _, sh := range rows {
|
||||||
|
m[sh.Site] = sh.Total
|
||||||
|
}
|
||||||
|
siteCounts[name] = m
|
||||||
|
}
|
||||||
|
passBySite := make(map[string]store.LanePass, len(passes))
|
||||||
|
for _, p := range passes {
|
||||||
|
passBySite[p.Site] = p
|
||||||
|
}
|
||||||
|
view.Sites = make([]siteRow, 0, len(shapes[store.SeriesFilterAll]))
|
||||||
|
for _, sh := range shapes[store.SeriesFilterAll] {
|
||||||
|
row := siteRow{
|
||||||
|
Site: sh.Site,
|
||||||
|
SiteHref: seriesListHref("", sh.Site, "", 0),
|
||||||
|
Figs: []fig{
|
||||||
|
door("", sh.Total, seriesListHref("", sh.Site, "", 0)),
|
||||||
|
door("", siteCounts[store.SeriesFilterNoCover][sh.Site], seriesListHref(store.SeriesFilterNoCover, sh.Site, "", 0)),
|
||||||
|
door("", siteCounts[store.SeriesFilterNeverChecked][sh.Site], seriesListHref(store.SeriesFilterNeverChecked, sh.Site, "", 0)),
|
||||||
|
door("", siteCounts[store.SeriesFilterStale][sh.Site], seriesListHref(store.SeriesFilterStale, sh.Site, "", 0)),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
if p, ok := passBySite[sh.Site]; ok {
|
||||||
|
row.State, row.StateGood, row.StateBad = laneState(p, now)
|
||||||
|
} else {
|
||||||
|
row.State = "no pass yet"
|
||||||
|
}
|
||||||
|
view.Sites = append(view.Sites, row)
|
||||||
|
}
|
||||||
|
return view, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// door is one figure with its door: the list that counts it. A measured zero
|
||||||
|
// is still a real figure, but the door closes — following it would land on an
|
||||||
|
// empty list. The count is written once so the figure and what it links to
|
||||||
|
// cannot drift apart.
|
||||||
|
func door(label string, count int, href string) fig {
|
||||||
|
if count == 0 {
|
||||||
|
href = ""
|
||||||
|
}
|
||||||
|
return fig{Label: label, Href: href, Count: count}
|
||||||
|
}
|
||||||
|
|
||||||
|
// overviewVerdict decides the landing page's one line from the latest pass
|
||||||
|
// per Site: no passes at all is "no Lane has reported yet" — never confident
|
||||||
|
// zeroes; otherwise the count of Lanes whose last pass needs the owner, or
|
||||||
|
// "all lanes healthy". The count comes from the same laneState judgement the
|
||||||
|
// Lanes page colours on, so the two pages cannot disagree on what a fault is.
|
||||||
|
func overviewVerdict(passes []store.LanePass, now time.Time) (phrase string, counts bool) {
|
||||||
|
if len(passes) == 0 {
|
||||||
|
return "no Lane has reported yet", false
|
||||||
|
}
|
||||||
|
attention := 0
|
||||||
|
for _, p := range passes {
|
||||||
|
if _, _, attn := laneState(p, now); attn {
|
||||||
|
attention++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if attention == 0 {
|
||||||
|
return "all lanes healthy", true
|
||||||
|
}
|
||||||
|
if attention == 1 {
|
||||||
|
return "1 lane needs a look", true
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("%d lanes need a look", attention), true
|
||||||
|
}
|
||||||
|
|
||||||
|
// waiting sums Due over the latest pass per Site: how many Series the Lanes
|
||||||
|
// found waiting, from the durable log rather than a running poller.
|
||||||
|
func waiting(passes []store.LanePass) int {
|
||||||
|
n := 0
|
||||||
|
for _, p := range passes {
|
||||||
|
n += p.Due
|
||||||
|
}
|
||||||
|
return n
|
||||||
|
}
|
||||||
@@ -214,6 +214,25 @@
|
|||||||
color: var(--mute-2);
|
color: var(--mute-2);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* The verdict line is set in the data face, not the display face: it is
|
||||||
|
three counts, not a page title. The judgement is the only bright thing. */
|
||||||
|
.admin-sheet .verdict {
|
||||||
|
padding: 16px 0 12px;
|
||||||
|
border-bottom: 1px solid var(--rule);
|
||||||
|
font: 500 15px/1.6 var(--font-mono);
|
||||||
|
letter-spacing: .04em;
|
||||||
|
color: var(--mute);
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-sheet .verdict .attn {
|
||||||
|
color: var(--patina);
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-sheet .verdict .counts b {
|
||||||
|
color: var(--paper);
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
.admin-sheet .tbl {
|
.admin-sheet .tbl {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: minmax(240px, 1fr) 156px 92px 110px 76px minmax(150px, 220px) 140px;
|
grid-template-columns: minmax(240px, 1fr) 156px 92px 110px 76px minmax(150px, 220px) 140px;
|
||||||
@@ -319,6 +338,12 @@
|
|||||||
color: var(--mute);
|
color: var(--mute);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.admin-sheet .stat a.fig {
|
||||||
|
/* The hover rule below sets the border on :hover only, which shifts the
|
||||||
|
row a pixel on hover; the transparent baseline holds the layout. */
|
||||||
|
border-bottom: 1px solid transparent;
|
||||||
|
}
|
||||||
|
|
||||||
.admin-sheet .stat a.fig:hover {
|
.admin-sheet .stat a.fig:hover {
|
||||||
color: var(--patina);
|
color: var(--patina);
|
||||||
border-bottom: 1px solid var(--patina);
|
border-bottom: 1px solid var(--patina);
|
||||||
|
|||||||
@@ -42,9 +42,7 @@
|
|||||||
{{template "series-list" .SeriesList}}
|
{{template "series-list" .SeriesList}}
|
||||||
{{else if eq .Page "series-detail"}}
|
{{else if eq .Page "series-detail"}}
|
||||||
{{template "series-detail" .Detail}}
|
{{template "series-detail" .Detail}}
|
||||||
{{else}}
|
{{else}}{{template "overview" .Overview}}{{end}}
|
||||||
<h2 class="sec">Overview</h2>
|
|
||||||
{{end}}
|
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
</body>
|
</body>
|
||||||
|
|||||||
@@ -0,0 +1,14 @@
|
|||||||
|
{{/* The Overview landing page: one verdict line leading, then a stats block
|
||||||
|
where every figure is a door into the list that counts it, and the
|
||||||
|
per-Site library shape table. Every judgement — the verdict state, which
|
||||||
|
figures link, what a Lane's state means — is made in Go; this template
|
||||||
|
only prints. */}}
|
||||||
|
{{define "overview"}}
|
||||||
|
<p class="verdict"><span class="attn">{{.Verdict}}</span> {{if .HasCounts}}<span class="counts">· <b>{{.Waiting}}</b> series waiting · <b>{{.Unchecked}}</b> unchecked over 12h</span>{{end}}</p>
|
||||||
|
<h2 class="sec">Hygiene</h2>
|
||||||
|
<div class="stats">{{range .Hygiene}}<div class="stat"><span class="lbl">{{.Label}}</span>{{if .Href}}<a class="fig" href="{{.Href}}">{{.Count}}</a>{{else}}<span class="fig zero">{{.Count}}</span>{{end}}</div>{{end}}</div>
|
||||||
|
<h2 class="sec">Library</h2>
|
||||||
|
<div class="stats">{{range .Library}}<div class="stat"><span class="lbl">{{.Label}}</span>{{if .Href}}<a class="fig" href="{{.Href}}">{{.Count}}</a>{{else}}<span class="fig zero">{{.Count}}</span>{{end}}</div>{{end}}</div>
|
||||||
|
<h2 class="sec">Sites · last 12h</h2>
|
||||||
|
<div class="tbl sites"><div class="thead"><span>Site</span><span>Series</span><span>No cover</span><span>Never chk</span><span>Stale</span><span>State</span></div>{{range .Sites}}<div class="trow"><a class="c-site site-{{.Site}}" href="{{.SiteHref}}">{{.Site}}</a>{{range .Figs}}{{if .Href}}<a class="fig" href="{{.Href}}">{{.Count}}</a>{{else}}<span class="fig zero">{{.Count}}</span>{{end}}{{end}}<span class="c-state{{if .StateGood}} ok{{end}}{{if .StateBad}} bad{{end}}">{{.State}}</span></div>{{end}}</div>
|
||||||
|
{{end}}
|
||||||
@@ -12,6 +12,7 @@ import (
|
|||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"reflect"
|
"reflect"
|
||||||
|
"regexp"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
@@ -2599,3 +2600,240 @@ func TestAdminSeriesDetailEscapesStoredStrings(t *testing.T) {
|
|||||||
t.Errorf("title rendered raw:\n%s", body)
|
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