Merge #159: Finished joins the admin filter vocabulary
This commit is contained in:
@@ -7,11 +7,13 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Series filter names (issue #140), ordered permanent-then-fixable — the
|
// Series filter names (issue #140): the seven repair filters are ordered
|
||||||
// repairs nothing will ever undo first, the ones a Poll can make right after.
|
// permanent-then-fixable — the repairs nothing will ever undo first, the
|
||||||
// A name is the repair a row needs, not the SQL that finds it; the values are
|
// ones a Poll can make right after. SeriesFilterFinished is not part of
|
||||||
// the wire form the Series list URL carries (#142). "all" is the absent and
|
// that ordering: a finished Series is a deliberate state, not a repair, so
|
||||||
// unknown case: every Series.
|
// it sits last, informational. A name is the repair a row needs, not the
|
||||||
|
// SQL that finds it; the values are the wire form the Series list URL
|
||||||
|
// carries (#142). "all" is the absent and unknown case: every Series.
|
||||||
const (
|
const (
|
||||||
SeriesFilterAll = "all"
|
SeriesFilterAll = "all"
|
||||||
SeriesFilterNoURL = "no_series_url"
|
SeriesFilterNoURL = "no_series_url"
|
||||||
@@ -21,9 +23,10 @@ const (
|
|||||||
SeriesFilterStale = "stale"
|
SeriesFilterStale = "stale"
|
||||||
SeriesFilterNoCover = "no_cover"
|
SeriesFilterNoCover = "no_cover"
|
||||||
SeriesFilterReaderReport = "reader_report"
|
SeriesFilterReaderReport = "reader_report"
|
||||||
|
SeriesFilterFinished = "finished"
|
||||||
)
|
)
|
||||||
|
|
||||||
// SeriesFilter is one named hygiene predicate over the whole library. Site
|
// SeriesFilter is one named filter predicate over the whole library. Site
|
||||||
// and Kind narrow the row read; Name picks the predicate; Cutoff is the
|
// and Kind narrow the row read; Name picks the predicate; Cutoff is the
|
||||||
// staleness boundary the "stale" filter compares against, supplied by the
|
// staleness boundary the "stale" filter compares against, supplied by the
|
||||||
// caller's clock — the store has no clock; Page is 1-based.
|
// caller's clock — the store has no clock; Page is 1-based.
|
||||||
@@ -115,10 +118,19 @@ func (a AdminSeries) Key() string { return a.Site + ":" + a.SeriesID }
|
|||||||
// The WHERE set is: no URL (an empty URL only — the host-failing-the-fetch-
|
// The WHERE set is: no URL (an empty URL only — the host-failing-the-fetch-
|
||||||
// gate case is invisible to SQL, needs the Site registry in Go, and belongs to
|
// gate case is invisible to SQL, needs the Site registry in Go, and belongs to
|
||||||
// a later repair), never-read-a-chapter and never-checked as disjoint halves
|
// a later repair), never-read-a-chapter and never-checked as disjoint halves
|
||||||
// (non-zero versus zero check stamp), stale, no cover, and Reader-report.
|
// (non-zero versus zero check stamp), stale, no cover, finished (the
|
||||||
|
// retirement stamp, read directly), and Reader-report.
|
||||||
// no_readers is the one HAVING predicate: it is the orphan test, an aggregate
|
// no_readers is the one HAVING predicate: it is the orphan test, an aggregate
|
||||||
// over the LEFT JOIN, where a bare WHERE has no row to test.
|
// over the LEFT JOIN, where a bare WHERE has no row to test.
|
||||||
//
|
//
|
||||||
|
// The clock-versus-outcome split decides which predicates exclude finished
|
||||||
|
// Series (`s.finished_at = 0` in each of the four): the clock-driven one —
|
||||||
|
// never-checked, stale, no-chapter, no-cover — keep ticking after the last
|
||||||
|
// Poll, so they would report a retired row as a problem no Poll is coming to
|
||||||
|
// fix; the three outcome-driven ones — no-URL, no-readers, Reader-report —
|
||||||
|
// read stored facts that simply stop arriving, so a finished Series needing a
|
||||||
|
// genuine repair still shows up under them.
|
||||||
|
//
|
||||||
// stale is the checked-but-old half of the stamp partition — because the
|
// stale is the checked-but-old half of the stamp partition — because the
|
||||||
// verdict line wants "not checked in twelve hours" as one figure, and a never
|
// verdict line wants "not checked in twelve hours" as one figure, and a never
|
||||||
// checked Series is already counted on its own "waiting"/never-checked
|
// checked Series is already counted on its own "waiting"/never-checked
|
||||||
@@ -135,16 +147,18 @@ func adminFilter(f SeriesFilter) (where, having string, args []any, err error) {
|
|||||||
case SeriesFilterNoURL:
|
case SeriesFilterNoURL:
|
||||||
clauses = append(clauses, `s.series_url = ''`)
|
clauses = append(clauses, `s.series_url = ''`)
|
||||||
case SeriesFilterNoChapter:
|
case SeriesFilterNoChapter:
|
||||||
clauses = append(clauses, `s.latest_checked_at <> 0 AND s.latest_chapter_num IS NULL`)
|
clauses = append(clauses, `s.latest_checked_at <> 0 AND s.latest_chapter_num IS NULL AND s.finished_at = 0`)
|
||||||
case SeriesFilterNeverChecked:
|
case SeriesFilterNeverChecked:
|
||||||
clauses = append(clauses, `s.latest_checked_at = 0`)
|
clauses = append(clauses, `s.latest_checked_at = 0 AND s.finished_at = 0`)
|
||||||
case SeriesFilterStale:
|
case SeriesFilterStale:
|
||||||
clauses = append(clauses, `s.latest_checked_at > 0 AND s.latest_checked_at < $`+strconv.Itoa(len(args)+1))
|
clauses = append(clauses, `s.latest_checked_at > 0 AND s.latest_checked_at < $`+strconv.Itoa(len(args)+1)+` AND s.finished_at = 0`)
|
||||||
args = append(args, f.Cutoff)
|
args = append(args, f.Cutoff)
|
||||||
case SeriesFilterNoCover:
|
case SeriesFilterNoCover:
|
||||||
clauses = append(clauses, `s.cover_address = ''`)
|
clauses = append(clauses, `s.cover_address = '' AND s.finished_at = 0`)
|
||||||
case SeriesFilterReaderReport:
|
case SeriesFilterReaderReport:
|
||||||
clauses = append(clauses, `s.latest_raised_by IS NOT NULL`)
|
clauses = append(clauses, `s.latest_raised_by IS NOT NULL`)
|
||||||
|
case SeriesFilterFinished:
|
||||||
|
clauses = append(clauses, `s.finished_at > 0`)
|
||||||
case SeriesFilterNoReaders:
|
case SeriesFilterNoReaders:
|
||||||
having = `HAVING COUNT(b.reader_id) = 0`
|
having = `HAVING COUNT(b.reader_id) = 0`
|
||||||
default:
|
default:
|
||||||
|
|||||||
@@ -128,6 +128,85 @@ func TestAdminSeriesFilters(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// A finished Series is the owner's deliberate state, not a problem a Poll
|
||||||
|
// will fix: the four clock-driven hygiene predicates exclude it (their
|
||||||
|
// stamps stop advancing at the last Poll, so without the guard a retired row
|
||||||
|
// is reported forever), the three outcome-driven ones still include it, and
|
||||||
|
// the finished filter returns exactly the retired rows.
|
||||||
|
func TestAdminFinishedSeriesFilters(t *testing.T) {
|
||||||
|
s := newTestStore(t)
|
||||||
|
// Each fin-* row is shaped to trip exactly one predicate if its guard
|
||||||
|
// fails: checked-but-old for stale, a zero stamp for never-checked, a
|
||||||
|
// stamp with no chapter for no-chapter, an empty cover for no-cover, and
|
||||||
|
// the unguarded three shaped to trip their own. A healthy, unfinished
|
||||||
|
// neighbour keeps the exclusion checks honest: a filter that regressed to
|
||||||
|
// matching nothing would pass a bare "no finished rows" assertion.
|
||||||
|
seedAdminSeries(t, s, seriesSeed{key: "asura:fin-stale", url: "u", cover: "c", checkedAt: 2000, latestNum: new(4.0), bookmarks: 1})
|
||||||
|
seedAdminSeries(t, s, seriesSeed{key: "asura:fin-neverchecked", url: "u", cover: "c", checkedAt: 0, latestNum: new(4.0), bookmarks: 1})
|
||||||
|
seedAdminSeries(t, s, seriesSeed{key: "asura:fin-nochapter", url: "u", cover: "c", checkedAt: 9000, bookmarks: 1})
|
||||||
|
seedAdminSeries(t, s, seriesSeed{key: "asura:fin-nocover", url: "u", checkedAt: 9000, latestNum: new(4.0), bookmarks: 1})
|
||||||
|
seedAdminSeries(t, s, seriesSeed{key: "asura:fin-nourl", url: "", cover: "c", checkedAt: 9000, latestNum: new(4.0), bookmarks: 1})
|
||||||
|
seedAdminSeries(t, s, seriesSeed{key: "asura:fin-orphan", url: "u", cover: "c", checkedAt: 9000, latestNum: new(4.0), bookmarks: 0})
|
||||||
|
seedAdminSeries(t, s, seriesSeed{key: "asura:fin-report", url: "u", cover: "c", checkedAt: 9000, latestNum: new(4.0), bookmarks: 1, raisedBy: true})
|
||||||
|
seedAdminSeries(t, s, seriesSeed{key: "asura:healthy", url: "u", cover: "c", checkedAt: 9000, latestNum: new(4.0), bookmarks: 1})
|
||||||
|
finished := []string{
|
||||||
|
"asura:fin-stale", "asura:fin-neverchecked", "asura:fin-nochapter",
|
||||||
|
"asura:fin-nocover", "asura:fin-nourl", "asura:fin-orphan", "asura:fin-report",
|
||||||
|
}
|
||||||
|
for _, key := range finished {
|
||||||
|
site, id, _ := strings.Cut(key, ":")
|
||||||
|
if err := s.SetSeriesFinished(site, id, 1000); err != nil {
|
||||||
|
t.Fatalf("finish %s: %v", key, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
f SeriesFilter
|
||||||
|
want []string
|
||||||
|
}{
|
||||||
|
{"stale excludes finished", SeriesFilter{Name: SeriesFilterStale, Cutoff: 5000}, nil},
|
||||||
|
{"never checked excludes finished", SeriesFilter{Name: SeriesFilterNeverChecked}, nil},
|
||||||
|
{"no chapter excludes finished", SeriesFilter{Name: SeriesFilterNoChapter}, nil},
|
||||||
|
{"no cover excludes finished", SeriesFilter{Name: SeriesFilterNoCover}, nil},
|
||||||
|
{"no url includes finished", SeriesFilter{Name: SeriesFilterNoURL}, []string{"asura:fin-nourl"}},
|
||||||
|
{"no readers includes finished", SeriesFilter{Name: SeriesFilterNoReaders}, []string{"asura:fin-orphan"}},
|
||||||
|
{"reader report includes finished", SeriesFilter{Name: SeriesFilterReaderReport}, []string{"asura:fin-report"}},
|
||||||
|
{"finished returns the retired rows", SeriesFilter{Name: SeriesFilterFinished}, finished},
|
||||||
|
}
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// The aggregate's finished total counts every retired row — the same
|
||||||
|
// predicate the Overview's finished figure is summed from.
|
||||||
|
shapes, err := s.SeriesShapes(SeriesFilter{Name: SeriesFilterFinished})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("SeriesShapes(finished): %v", err)
|
||||||
|
}
|
||||||
|
sum := 0
|
||||||
|
for _, sh := range shapes {
|
||||||
|
sum += sh.Total
|
||||||
|
}
|
||||||
|
if sum != len(finished) {
|
||||||
|
t.Fatalf("finished aggregate = %d, want %d", sum, len(finished))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// "Never read a chapter" and "never checked" are disjoint by construction:
|
// "Never read a chapter" and "never checked" are disjoint by construction:
|
||||||
// the first requires a non-zero check stamp, the second a zero one. Over a
|
// the first requires a non-zero check stamp, the second a zero one. Over a
|
||||||
// mix that should satisfy both, no row may be counted twice.
|
// mix that should satisfy both, no row may be counted twice.
|
||||||
|
|||||||
@@ -24,8 +24,9 @@ type overviewView struct {
|
|||||||
// its own filter, and the verdict wants the inclusive number.
|
// its own filter, and the verdict wants the inclusive number.
|
||||||
Unchecked int
|
Unchecked int
|
||||||
// Hygiene is the seven problem filters in the Series list's own render
|
// 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
|
// order plus the finished figure riding last (informational); Library is
|
||||||
// door into the list that counts it, except a zero.
|
// the library split plus the roster. Every figure is a door into the list
|
||||||
|
// that counts it, except a zero.
|
||||||
Hygiene []fig
|
Hygiene []fig
|
||||||
Library []fig
|
Library []fig
|
||||||
// Sites is the per-Site library shape table, one row per Site with any
|
// Sites is the per-Site library shape table, one row per Site with any
|
||||||
@@ -59,7 +60,7 @@ type siteRow struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// overviewView assembles the landing page from the store's read model: one
|
// overviewView assembles the landing page from the store's read model: one
|
||||||
// SeriesShapes pass per filter summed in Go (the shipped surface offers eight
|
// SeriesShapes pass per filter summed in Go (the shipped surface offers nine
|
||||||
// grouped passes, not a stats query — #140), the pass log's latest pass per
|
// 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,
|
// Site, and the roster. A failure in any read is a 500 with a logged reason,
|
||||||
// never a page of silent zeroes.
|
// never a page of silent zeroes.
|
||||||
@@ -93,8 +94,10 @@ func (h *Handler) overviewView() (overviewView, error) {
|
|||||||
view.Unchecked = totals[store.SeriesFilterStale] + totals[store.SeriesFilterNeverChecked]
|
view.Unchecked = totals[store.SeriesFilterStale] + totals[store.SeriesFilterNeverChecked]
|
||||||
view.Verdict, view.HasCounts = overviewVerdict(passes, now)
|
view.Verdict, view.HasCounts = overviewVerdict(passes, now)
|
||||||
|
|
||||||
// The seven problem filters, in seriesFilterOrder's permanent-then-fixable
|
// The hygiene figures, in seriesFilterOrder's tail: the seven problem
|
||||||
// order; the All filter's count belongs to the Library block, not to a
|
// filters in permanent-then-fixable order, then the finished figure last —
|
||||||
|
// informational, not a problem, and last because seriesFilterOrder appends
|
||||||
|
// it there. The All filter's count belongs to the Library block, not to a
|
||||||
// "hygiene" figure.
|
// "hygiene" figure.
|
||||||
hygiene := make([]fig, 0, len(seriesFilterOrder)-1)
|
hygiene := make([]fig, 0, len(seriesFilterOrder)-1)
|
||||||
for _, name := range seriesFilterOrder[1:] {
|
for _, name := range seriesFilterOrder[1:] {
|
||||||
|
|||||||
@@ -20,10 +20,10 @@ import (
|
|||||||
// the wrong page. The store does not export it (#140).
|
// the wrong page. The store does not export it (#140).
|
||||||
const seriesPageSize = 50
|
const seriesPageSize = 50
|
||||||
|
|
||||||
// seriesFilterLabels names every hygiene filter for the Series list select,
|
// seriesFilterLabels names every Series filter for the list select, keyed by
|
||||||
// keyed by the wire constant the URL carries. The render order is
|
// the wire constant the URL carries. The render order is seriesFilterOrder;
|
||||||
// seriesFilterOrder; the labels are read by later admin tickets too, so the
|
// the labels are read by later admin tickets too, so the map and the
|
||||||
// map and the constants cannot drift apart.
|
// constants cannot drift apart.
|
||||||
var seriesFilterLabels = map[string]string{
|
var seriesFilterLabels = map[string]string{
|
||||||
store.SeriesFilterAll: "All series",
|
store.SeriesFilterAll: "All series",
|
||||||
store.SeriesFilterNoURL: "No series URL",
|
store.SeriesFilterNoURL: "No series URL",
|
||||||
@@ -33,10 +33,13 @@ var seriesFilterLabels = map[string]string{
|
|||||||
store.SeriesFilterStale: "Not checked in 12h",
|
store.SeriesFilterStale: "Not checked in 12h",
|
||||||
store.SeriesFilterNoCover: "No cover",
|
store.SeriesFilterNoCover: "No cover",
|
||||||
store.SeriesFilterReaderReport: "Latest from a Reader",
|
store.SeriesFilterReaderReport: "Latest from a Reader",
|
||||||
|
store.SeriesFilterFinished: "Finished",
|
||||||
}
|
}
|
||||||
|
|
||||||
// seriesFilterOrder is the select's render order: All first, then the
|
// seriesFilterOrder is the select's render order: All first, then the
|
||||||
// permanent repairs, then the fixable ones (issue #140).
|
// permanent repairs, then the fixable ones (issue #140). Finished rides the
|
||||||
|
// tail, last — deliberate, not a repair — and the Overview's stats block
|
||||||
|
// renders the same tail, which is what sits the finished figure last there.
|
||||||
var seriesFilterOrder = []string{
|
var seriesFilterOrder = []string{
|
||||||
store.SeriesFilterAll,
|
store.SeriesFilterAll,
|
||||||
store.SeriesFilterNoURL,
|
store.SeriesFilterNoURL,
|
||||||
@@ -46,6 +49,7 @@ var seriesFilterOrder = []string{
|
|||||||
store.SeriesFilterStale,
|
store.SeriesFilterStale,
|
||||||
store.SeriesFilterNoCover,
|
store.SeriesFilterNoCover,
|
||||||
store.SeriesFilterReaderReport,
|
store.SeriesFilterReaderReport,
|
||||||
|
store.SeriesFilterFinished,
|
||||||
}
|
}
|
||||||
|
|
||||||
// seriesListView is the Series list page's data. The template renders strings
|
// seriesListView is the Series list page's data. The template renders strings
|
||||||
@@ -611,9 +615,9 @@ func (h *Handler) seriesListView(r *http.Request) (seriesListView, error) {
|
|||||||
return view, nil
|
return view, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// seriesFilterOptions renders every hygiene filter with its library-wide
|
// seriesFilterOptions renders every filter with its library-wide count, one
|
||||||
// count, one SeriesShapes pass per filter summed in Go — the shipped surface
|
// SeriesShapes pass per filter summed in Go — the shipped surface offers nine
|
||||||
// offers eight grouped passes, not a single stats query (#140). The counts
|
// grouped passes, not a single stats query (#140). The counts
|
||||||
// are library-wide because the select sits next to the Site narrowing and
|
// are library-wide because the select sits next to the Site narrowing and
|
||||||
// must not shift as the owner narrows the list itself. Cutoff travels with
|
// must not shift as the owner narrows the list itself. Cutoff travels with
|
||||||
// the stale filter, or its count would always be zero.
|
// the stale filter, or its count would always be zero.
|
||||||
|
|||||||
+98
-2
@@ -2139,7 +2139,6 @@ func TestNovelPageOmitsUpdatedTab(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
func TestMangaPageKeepsUpdatedTab(t *testing.T) {
|
func TestMangaPageKeepsUpdatedTab(t *testing.T) {
|
||||||
cfg := testConfig()
|
cfg := testConfig()
|
||||||
srv, st := newWebTestServer(t, cfg)
|
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
|
// ?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
|
// kagane manga stale row renders, and every link the page emits carries the
|
||||||
// filter and Site so the narrowing survives in the URL.
|
// 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
|
// 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
|
// for the same Site must not double-count, so the verdict reads the same
|
||||||
// latest-per-Site projection the Lanes page reads.
|
// latest-per-Site projection the Lanes page reads.
|
||||||
@@ -3495,7 +3591,7 @@ func TestSeriesFinishRoute(t *testing.T) {
|
|||||||
cookie := sessionCookie(t, st)
|
cookie := sessionCookie(t, st)
|
||||||
|
|
||||||
for path, want := range map[string]int{
|
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,
|
"/admin/series/ghost:x/finish": http.StatusNotFound,
|
||||||
} {
|
} {
|
||||||
req := httptest.NewRequest(http.MethodPost, path, strings.NewReader(""))
|
req := httptest.NewRequest(http.MethodPost, path, strings.NewReader(""))
|
||||||
|
|||||||
Reference in New Issue
Block a user