Spec #137: per-Series poll failure state, completion hint, and outbound owner notification #174
@@ -9,23 +9,26 @@ import (
|
|||||||
|
|
||||||
// Series filter names (issue #140): the seven repair filters are ordered
|
// Series filter names (issue #140): the seven repair filters are ordered
|
||||||
// permanent-then-fixable — the repairs nothing will ever undo first, the
|
// permanent-then-fixable — the repairs nothing will ever undo first, the
|
||||||
// ones a Poll can make right after. SeriesFilterFinished is not part of
|
// ones a Poll can make right after. SeriesFilterFinished and
|
||||||
// that ordering: a finished Series is a deliberate state, not a repair, so
|
// SeriesFilterSiteCompleted are not part of that ordering: a finished
|
||||||
// it sits last, informational. A name is the repair a row needs, not the
|
// Series is a deliberate state and a Site-completed one is the Site's own
|
||||||
// SQL that finds it; the values are the wire form the Series list URL
|
// marker, not repairs, so the pair rides the tail, informational. A name is
|
||||||
// carries (#142). "all" is the absent and unknown case: every Series.
|
// 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"
|
||||||
SeriesFilterNoChapter = "never_read_a_chapter"
|
SeriesFilterNoChapter = "never_read_a_chapter"
|
||||||
SeriesFilterNoReaders = "no_readers"
|
SeriesFilterNoReaders = "no_readers"
|
||||||
SeriesFilterNeverChecked = "never_checked"
|
SeriesFilterNeverChecked = "never_checked"
|
||||||
SeriesFilterStale = "stale"
|
SeriesFilterStale = "stale"
|
||||||
SeriesFilterNoCover = "no_cover"
|
SeriesFilterNoCover = "no_cover"
|
||||||
SeriesFilterReaderReport = "reader_report"
|
SeriesFilterReaderReport = "reader_report"
|
||||||
SeriesFilterFinished = "finished"
|
SeriesFilterFinished = "finished"
|
||||||
SeriesFilterFailing = "failing"
|
SeriesFilterSiteCompleted = "site_completed"
|
||||||
SeriesFilterUnverified = "unverified"
|
SeriesFilterFailing = "failing"
|
||||||
|
SeriesFilterUnverified = "unverified"
|
||||||
)
|
)
|
||||||
|
|
||||||
// SeriesFilter is one named filter predicate over the whole library. Site
|
// SeriesFilter is one named filter predicate over the whole library. Site
|
||||||
@@ -48,7 +51,7 @@ type SeriesFilter struct {
|
|||||||
// and only the anonymous boolean in raisedByReaderAnswer crosses it.
|
// and only the anonymous boolean in raisedByReaderAnswer crosses it.
|
||||||
const adminSeriesColumns = `s.site, s.series_id, s.title, s.series_url, s.cover_address,
|
const adminSeriesColumns = `s.site, s.series_id, s.title, s.series_url, s.cover_address,
|
||||||
s.kind, s.latest_chapter, s.latest_chapter_num, s.latest_checked_at, s.force_poll_at,
|
s.kind, s.latest_chapter, s.latest_chapter_num, s.latest_checked_at, s.force_poll_at,
|
||||||
s.latest_corrected_at, s.finished_at`
|
s.latest_corrected_at, s.finished_at, s.site_completed_at`
|
||||||
|
|
||||||
// raisedByReaderAnswer answers "did a Reader's report set this number" without
|
// raisedByReaderAnswer answers "did a Reader's report set this number" without
|
||||||
// naming which Reader. Kept apart from adminSeriesColumns so the column list —
|
// naming which Reader. Kept apart from adminSeriesColumns so the column list —
|
||||||
@@ -101,6 +104,10 @@ type AdminSeries struct {
|
|||||||
// FinishedAt is the owner's finish stamp: unix ms, zero while the Series is
|
// FinishedAt is the owner's finish stamp: unix ms, zero while the Series is
|
||||||
// not finished — the same shape as the Correction stamp, and its own undo.
|
// not finished — the same shape as the Correction stamp, and its own undo.
|
||||||
FinishedAt int64
|
FinishedAt int64
|
||||||
|
// SiteCompletedAt is when the last successful Poll read saw the Site's own
|
||||||
|
// completed value, zero meaning it did not (issue #168). The Site's marker,
|
||||||
|
// never a Lifecycle decision: the owner's Finish is the only retirement.
|
||||||
|
SiteCompletedAt int64
|
||||||
}
|
}
|
||||||
|
|
||||||
// SeriesPage is one page of the owner's filtered Series list plus the count
|
// SeriesPage is one page of the owner's filtered Series list plus the count
|
||||||
@@ -134,23 +141,26 @@ func (a AdminSeries) Key() string { return a.Site + ":" + a.SeriesID }
|
|||||||
// 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, finished (the
|
// (non-zero versus zero check stamp), stale, no cover, finished (the
|
||||||
// retirement stamp, read directly), Reader-report, and the failing pair —
|
// retirement stamp, read directly), site completed (the Site's marker, read
|
||||||
// failing (the failure row exists, a chapter exists, and failing_since is
|
// directly), Reader-report, and the failing pair — failing (the failure row
|
||||||
// past the cutoff) and unverified (the Reader-attributed subset of failing).
|
// exists, a chapter exists, and failing_since is past the cutoff) and
|
||||||
|
// unverified (the Reader-attributed subset of failing).
|
||||||
// failing's chapter IS NOT NULL is the exact complement of never-read-a-
|
// failing's chapter IS NOT NULL is the exact complement of never-read-a-
|
||||||
// chapter's IS NULL half, so the two are disjoint by construction.
|
// chapter's IS NULL half, so the two are disjoint by construction.
|
||||||
// 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
|
// The clock-versus-outcome split decides which predicates exclude finished
|
||||||
// Series (`s.finished_at = 0` in each of the four): the clock-driven one —
|
// Series (`s.finished_at = 0` in each of the five): the clock-driven ones —
|
||||||
// never-checked, stale, no-chapter, no-cover — keep ticking after the last
|
// 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
|
// Poll, so they would report a retired row as a problem no Poll is coming to
|
||||||
// fix; the outcome-driven ones — no-URL, no-readers, Reader-report, failing,
|
// fix; site-completed's stamp is the Site's, and it keeps standing after the
|
||||||
// unverified — read stored facts that simply stop arriving, so a finished
|
// owner retires the row, so a finished Series would be reported as work
|
||||||
// Series needing a genuine repair still shows up under them. The failing pair
|
// nobody is going to do. The outcome-driven ones — no-URL, no-readers,
|
||||||
// carries no finished guard for exactly that contrast: a failure row is a
|
// Reader-report, failing, unverified — read stored facts that simply stop
|
||||||
// stored outcome, not a ticking clock.
|
// arriving, so a finished Series needing a genuine repair still shows up
|
||||||
|
// under them. The failing pair carries no finished guard for exactly that
|
||||||
|
// contrast: a failure row is a stored outcome, not a ticking clock.
|
||||||
//
|
//
|
||||||
// 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
|
||||||
@@ -186,6 +196,8 @@ func adminFilter(f SeriesFilter) (where, having string, args []any, err error) {
|
|||||||
args = append(args, f.Cutoff)
|
args = append(args, f.Cutoff)
|
||||||
case SeriesFilterFinished:
|
case SeriesFilterFinished:
|
||||||
clauses = append(clauses, `s.finished_at > 0`)
|
clauses = append(clauses, `s.finished_at > 0`)
|
||||||
|
case SeriesFilterSiteCompleted:
|
||||||
|
clauses = append(clauses, `s.site_completed_at > 0 AND s.finished_at = 0`)
|
||||||
case SeriesFilterNoReaders:
|
case SeriesFilterNoReaders:
|
||||||
having = `HAVING COUNT(b.reader_id) = 0`
|
having = `HAVING COUNT(b.reader_id) = 0`
|
||||||
default:
|
default:
|
||||||
@@ -240,7 +252,8 @@ func (s *Store) SeriesPage(f SeriesFilter) (SeriesPage, error) {
|
|||||||
`+where+`
|
`+where+`
|
||||||
GROUP BY s.site, s.series_id, s.title, s.series_url, s.cover_address,
|
GROUP BY s.site, s.series_id, s.title, s.series_url, s.cover_address,
|
||||||
s.kind, s.latest_chapter, s.latest_chapter_num, s.latest_checked_at,
|
s.kind, s.latest_chapter, s.latest_chapter_num, s.latest_checked_at,
|
||||||
s.force_poll_at, s.latest_corrected_at, s.finished_at, s.latest_raised_by,
|
s.force_poll_at, s.latest_corrected_at, s.finished_at, s.site_completed_at,
|
||||||
|
s.latest_raised_by,
|
||||||
f.outcome, f.failing_since
|
f.outcome, f.failing_since
|
||||||
`+having+`
|
`+having+`
|
||||||
ORDER BY s.latest_checked_at, s.site, s.series_id
|
ORDER BY s.latest_checked_at, s.site, s.series_id
|
||||||
@@ -317,7 +330,7 @@ func scanAdminSeries(scan func(...any) error) (AdminSeries, int, error) {
|
|||||||
if err := scan(
|
if err := scan(
|
||||||
&a.Site, &a.SeriesID, &a.Title, &a.SeriesURL, &a.CoverAddress,
|
&a.Site, &a.SeriesID, &a.Title, &a.SeriesURL, &a.CoverAddress,
|
||||||
&a.Kind, &a.LatestChapter, &latestChapterNum, &a.LatestCheckedAt,
|
&a.Kind, &a.LatestChapter, &latestChapterNum, &a.LatestCheckedAt,
|
||||||
&a.ForcePollAt, &a.LatestCorrectedAt, &a.FinishedAt,
|
&a.ForcePollAt, &a.LatestCorrectedAt, &a.FinishedAt, &a.SiteCompletedAt,
|
||||||
&a.RaisedByReader, &a.FailureOutcome, &a.FailingSince, &a.ReaderCount, &total,
|
&a.RaisedByReader, &a.FailureOutcome, &a.FailingSince, &a.ReaderCount, &total,
|
||||||
); err != nil {
|
); err != nil {
|
||||||
return AdminSeries{}, 0, err
|
return AdminSeries{}, 0, err
|
||||||
|
|||||||
@@ -12,14 +12,15 @@ import (
|
|||||||
// Upsert path could not produce together: an orphan has no bookmark, and a
|
// Upsert path could not produce together: an orphan has no bookmark, and a
|
||||||
// Reader-raised Latest Chapter needs a Sighting the store does not create.
|
// Reader-raised Latest Chapter needs a Sighting the store does not create.
|
||||||
type seriesSeed struct {
|
type seriesSeed struct {
|
||||||
key string
|
key string
|
||||||
kind string
|
kind string
|
||||||
url string
|
url string
|
||||||
cover string // cover_address
|
cover string // cover_address
|
||||||
checkedAt int64
|
checkedAt int64
|
||||||
latestNum *float64
|
latestNum *float64
|
||||||
bookmarks int // readers that hold it; 0 = orphan
|
bookmarks int // readers that hold it; 0 = orphan
|
||||||
raisedBy bool // a Reader's report is attributed as the raiser
|
raisedBy bool // a Reader's report is attributed as the raiser
|
||||||
|
siteCompletedAt int64 // the Site's own marker (issue #168), 0 = not set
|
||||||
}
|
}
|
||||||
|
|
||||||
// seedAdminSeries inserts one series row and its bookmarks (owner first, then
|
// seedAdminSeries inserts one series row and its bookmarks (owner first, then
|
||||||
@@ -39,10 +40,10 @@ func seedAdminSeries(t *testing.T, s *Store, seed seriesSeed) {
|
|||||||
}
|
}
|
||||||
if _, err := s.db.Exec(`
|
if _, err := s.db.Exec(`
|
||||||
INSERT INTO series (site, series_id, title, kind, series_url, cover_address,
|
INSERT INTO series (site, series_id, title, kind, series_url, cover_address,
|
||||||
latest_checked_at, latest_chapter, latest_chapter_num)
|
latest_checked_at, latest_chapter, latest_chapter_num, site_completed_at)
|
||||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)`,
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)`,
|
||||||
site, seriesID, "Title of "+seed.key, seed.kind, seed.url, seed.cover,
|
site, seriesID, "Title of "+seed.key, seed.kind, seed.url, seed.cover,
|
||||||
seed.checkedAt, latestChapter, seed.latestNum); err != nil {
|
seed.checkedAt, latestChapter, seed.latestNum, seed.siteCompletedAt); err != nil {
|
||||||
t.Fatalf("seed series %q: %v", seed.key, err)
|
t.Fatalf("seed series %q: %v", seed.key, err)
|
||||||
}
|
}
|
||||||
for i := range seed.bookmarks {
|
for i := range seed.bookmarks {
|
||||||
@@ -658,3 +659,78 @@ func TestAdminFailureProjection(t *testing.T) {
|
|||||||
t.Fatalf("fine row = %+v, want empty outcome and zero stamp", byKey["asura:fine"])
|
t.Fatalf("fine row = %+v, want empty outcome and zero stamp", byKey["asura:fine"])
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The site-completed filter (issue #170) lists the Site's own marker as work
|
||||||
|
// to work through, carrying the same finished guard the clock-driven
|
||||||
|
// predicates gained: the Site's stamp keeps standing after the owner retires
|
||||||
|
// the row, so a finished Series would be reported as work nobody is going to
|
||||||
|
// do. A zero stamp never appears. Un-finishing puts the Series back in the
|
||||||
|
// Poll query — the finished_at gate is #157's own, asserted through
|
||||||
|
// DueForLatestCheck rather than re-derived here.
|
||||||
|
func TestAdminSiteCompletedFilter(t *testing.T) {
|
||||||
|
s := newTestStore(t)
|
||||||
|
seedAdminSeries(t, s, seriesSeed{key: "asura:done", url: "u", cover: "c", checkedAt: 9000, latestNum: new(4.0), bookmarks: 1, siteCompletedAt: 5000})
|
||||||
|
seedAdminSeries(t, s, seriesSeed{key: "asura:never", url: "u", cover: "c", checkedAt: 9000, latestNum: new(4.0), bookmarks: 1})
|
||||||
|
seedAdminSeries(t, s, seriesSeed{key: "asura:retired", url: "u", cover: "c", checkedAt: 0, latestNum: new(4.0), bookmarks: 1, siteCompletedAt: 5000})
|
||||||
|
seedAdminSeries(t, s, seriesSeed{key: "asura:healthy", url: "u", cover: "c", checkedAt: 9000, latestNum: new(4.0), bookmarks: 1})
|
||||||
|
if err := s.SetSeriesFinished("asura", "retired", 1000); err != nil {
|
||||||
|
t.Fatalf("finish asura:retired: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
got := pageKeys(t, s, SeriesFilter{Name: SeriesFilterSiteCompleted})
|
||||||
|
if len(got) != 1 || !got["asura:done"] {
|
||||||
|
t.Fatalf("site-completed returned %v, want only asura:done", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
// The projection carries the stamp so the detail page can age it.
|
||||||
|
page, err := s.SeriesPage(SeriesFilter{Name: SeriesFilterSiteCompleted})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("SeriesPage(site_completed): %v", err)
|
||||||
|
}
|
||||||
|
if len(page.Rows) != 1 || page.Rows[0].SiteCompletedAt != 5000 {
|
||||||
|
t.Fatalf("row = %+v, want SiteCompletedAt 5000", page.Rows)
|
||||||
|
}
|
||||||
|
|
||||||
|
// The aggregate counts the same row: the landing figure and the select's
|
||||||
|
// option come from this pass, so they cannot disagree with the list.
|
||||||
|
shapes, err := s.SeriesShapes(SeriesFilter{Name: SeriesFilterSiteCompleted})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("SeriesShapes(site_completed): %v", err)
|
||||||
|
}
|
||||||
|
sum := 0
|
||||||
|
for _, sh := range shapes {
|
||||||
|
sum += sh.Total
|
||||||
|
}
|
||||||
|
if sum != 1 {
|
||||||
|
t.Fatalf("site-completed aggregate = %d, want 1", sum)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Un-finishing puts the Series back in the Poll query: the due read's
|
||||||
|
// finished_at gate (issue #157) admits it again — asserted through the
|
||||||
|
// Lane's own read, not re-derived here.
|
||||||
|
due, err := s.DueForLatestCheck("asura", 1000, noCeiling)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("DueForLatestCheck: %v", err)
|
||||||
|
}
|
||||||
|
for _, sr := range due {
|
||||||
|
if sr.Key() == "asura:retired" {
|
||||||
|
t.Fatalf("a finished Series is still due for a Poll:\n%+v", due)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := s.SetSeriesFinished("asura", "retired", 0); err != nil {
|
||||||
|
t.Fatalf("un-finish asura:retired: %v", err)
|
||||||
|
}
|
||||||
|
due, err = s.DueForLatestCheck("asura", 1000, noCeiling)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("DueForLatestCheck after un-finish: %v", err)
|
||||||
|
}
|
||||||
|
found := false
|
||||||
|
for _, sr := range due {
|
||||||
|
if sr.Key() == "asura:retired" {
|
||||||
|
found = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !found {
|
||||||
|
t.Fatalf("un-finished Series is not due for a Poll:\n%+v", due)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -23,10 +23,10 @@ type overviewView struct {
|
|||||||
// as stale + never_checked: a never-checked Series is already counted on
|
// as stale + never_checked: a never-checked Series is already counted on
|
||||||
// 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 problem filters in the Series list's own render order
|
||||||
// order plus the finished figure riding last (informational); Library is
|
// plus the informational tail — finished, then site completed — riding
|
||||||
// the library split plus the roster. Every figure is a door into the list
|
// last; Library is the library split plus the roster. Every figure is a
|
||||||
// that counts it, except a zero.
|
// 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
|
||||||
@@ -60,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 nine
|
// SeriesShapes pass per filter summed in Go (the shipped surface offers ten
|
||||||
// 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.
|
||||||
@@ -94,10 +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 hygiene figures, in seriesFilterOrder's tail: the seven problem
|
// The hygiene figures, in seriesFilterOrder's tail: the problem filters
|
||||||
// filters in permanent-then-fixable order, then the finished figure last —
|
// in permanent-then-fixable order, then the informational tail — finished
|
||||||
// informational, not a problem, and last because seriesFilterOrder appends
|
// and site completed — riding last because seriesFilterOrder appends them
|
||||||
// it there. The All filter's count belongs to the Library block, not to a
|
// 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:] {
|
||||||
|
|||||||
@@ -25,23 +25,25 @@ const seriesPageSize = 50
|
|||||||
// the labels are read by later admin tickets too, so the map and the
|
// the labels are read by later admin tickets too, so 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",
|
||||||
store.SeriesFilterNoChapter: "Never read a chapter",
|
store.SeriesFilterNoChapter: "Never read a chapter",
|
||||||
store.SeriesFilterNoReaders: "No Readers",
|
store.SeriesFilterNoReaders: "No Readers",
|
||||||
store.SeriesFilterNeverChecked: "Never checked",
|
store.SeriesFilterNeverChecked: "Never checked",
|
||||||
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.SeriesFilterFailing: "Failing over 12h",
|
store.SeriesFilterFailing: "Failing over 12h",
|
||||||
store.SeriesFilterUnverified: "Unverified Reader number",
|
store.SeriesFilterUnverified: "Unverified Reader number",
|
||||||
store.SeriesFilterFinished: "Finished",
|
store.SeriesFilterFinished: "Finished",
|
||||||
|
store.SeriesFilterSiteCompleted: "Site says completed",
|
||||||
}
|
}
|
||||||
|
|
||||||
// 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). Finished rides the
|
// permanent repairs, then the fixable ones (issue #140). Finished and the
|
||||||
// tail, last — deliberate, not a repair — and the Overview's stats block
|
// site-completed hint ride the tail — deliberate, not repairs — and the
|
||||||
// renders the same tail, which is what sits the finished figure last there.
|
// Overview's stats block renders the same tail, which is what sits the
|
||||||
|
// finished and site-completed figures last there.
|
||||||
var seriesFilterOrder = []string{
|
var seriesFilterOrder = []string{
|
||||||
store.SeriesFilterAll,
|
store.SeriesFilterAll,
|
||||||
store.SeriesFilterNoURL,
|
store.SeriesFilterNoURL,
|
||||||
@@ -54,6 +56,7 @@ var seriesFilterOrder = []string{
|
|||||||
store.SeriesFilterFailing,
|
store.SeriesFilterFailing,
|
||||||
store.SeriesFilterUnverified,
|
store.SeriesFilterUnverified,
|
||||||
store.SeriesFilterFinished,
|
store.SeriesFilterFinished,
|
||||||
|
store.SeriesFilterSiteCompleted,
|
||||||
}
|
}
|
||||||
|
|
||||||
// seriesListView is the Series list page's data. The template renders strings
|
// seriesListView is the Series list page's data. The template renders strings
|
||||||
|
|||||||
@@ -69,6 +69,10 @@ type seriesDetailView struct {
|
|||||||
// owner window: the value is unconfirmed and the owner should not trust
|
// owner window: the value is unconfirmed and the owner should not trust
|
||||||
// it. It never names the Reader. "" otherwise.
|
// it. It never names the Reader. "" otherwise.
|
||||||
Unverified string
|
Unverified string
|
||||||
|
// SiteCompleted is the hint's line, "" while the Site has said nothing or
|
||||||
|
// the owner has finished the Series: "the site says this work is
|
||||||
|
// completed (since 3d ago)". A hint, never a control (issue #170).
|
||||||
|
SiteCompleted string
|
||||||
}
|
}
|
||||||
|
|
||||||
// adminSeriesDetail renders one Series' page, keyed by the composite
|
// adminSeriesDetail renders one Series' page, keyed by the composite
|
||||||
@@ -164,6 +168,9 @@ func (h *Handler) seriesDetailView(a store.AdminSeries) seriesDetailView {
|
|||||||
if a.RaisedByReader && a.FailureOutcome != "" && a.FailingSince < time.Now().Add(-ownerWindow).UnixMilli() {
|
if a.RaisedByReader && a.FailureOutcome != "" && a.FailingSince < time.Now().Add(-ownerWindow).UnixMilli() {
|
||||||
v.Unverified = "Unverified Reader number: the page has been failing for over 12h, so this Reader-reported chapter is unconfirmed."
|
v.Unverified = "Unverified Reader number: the page has been failing for over 12h, so this Reader-reported chapter is unconfirmed."
|
||||||
}
|
}
|
||||||
|
if a.SiteCompletedAt != 0 && a.FinishedAt == 0 {
|
||||||
|
v.SiteCompleted = "the site says this work is completed (since " + checkedAge(time.Now(), a.SiteCompletedAt) + ")"
|
||||||
|
}
|
||||||
|
|
||||||
// Provenance: the actor class behind the current number, evaluated in the
|
// Provenance: the actor class behind the current number, evaluated in the
|
||||||
// order the classes outrank one another — the owner's stamp, which a
|
// order the classes outrank one another — the owner's stamp, which a
|
||||||
|
|||||||
@@ -47,6 +47,7 @@
|
|||||||
{{else}}
|
{{else}}
|
||||||
<div class="dform">
|
<div class="dform">
|
||||||
<div class="field"><button type="button" class="ghost" onclick="document.getElementById('confirm-finish').hidden = false">Finish</button></div>
|
<div class="field"><button type="button" class="ghost" onclick="document.getElementById('confirm-finish').hidden = false">Finish</button></div>
|
||||||
|
{{if .SiteCompleted}}<p class="hint">{{.SiteCompleted}}</p>{{end}}
|
||||||
<div class="confirm-row calm" id="confirm-finish" role="group" aria-live="polite" hidden>
|
<div class="confirm-row calm" id="confirm-finish" role="group" aria-live="polite" hidden>
|
||||||
<span>Mark this Series finished?</span>
|
<span>Mark this Series finished?</span>
|
||||||
<div>
|
<div>
|
||||||
|
|||||||
+201
-19
@@ -2212,14 +2212,15 @@ func TestNovelNewTabFallsBackToAll(t *testing.T) {
|
|||||||
// cannot produce an orphan series or a Reader-raised Latest Chapter — the
|
// cannot produce an orphan series or a Reader-raised Latest Chapter — the
|
||||||
// same reason the store's admin tests seed this way.
|
// same reason the store's admin tests seed this way.
|
||||||
type seriesRowSeed struct {
|
type seriesRowSeed struct {
|
||||||
key string
|
key string
|
||||||
kind string
|
kind string
|
||||||
url string
|
url string
|
||||||
cover string // cover_address
|
cover string // cover_address
|
||||||
checkedAt int64
|
checkedAt int64
|
||||||
latestNum *float64
|
latestNum *float64
|
||||||
bookmarks int // readers that hold it; 0 = orphan
|
bookmarks int // readers that hold it; 0 = orphan
|
||||||
raisedBy bool // a Reader's report is attributed as the raiser
|
raisedBy bool // a Reader's report is attributed as the raiser
|
||||||
|
siteCompletedAt int64 // the Site's own marker (issue #168), 0 = not set
|
||||||
}
|
}
|
||||||
|
|
||||||
// seedSeriesRow inserts one series row and its bookmarks (owner first, then
|
// seedSeriesRow inserts one series row and its bookmarks (owner first, then
|
||||||
@@ -2239,10 +2240,10 @@ func seedSeriesRow(t *testing.T, st *store.Store, db *sql.DB, seed seriesRowSeed
|
|||||||
}
|
}
|
||||||
if _, err := db.Exec(`
|
if _, err := db.Exec(`
|
||||||
INSERT INTO series (site, series_id, title, kind, series_url, cover_address,
|
INSERT INTO series (site, series_id, title, kind, series_url, cover_address,
|
||||||
latest_checked_at, latest_chapter, latest_chapter_num)
|
latest_checked_at, latest_chapter, latest_chapter_num, site_completed_at)
|
||||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)`,
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)`,
|
||||||
site, seriesID, "Title of "+seed.key, seed.kind, seed.url, seed.cover,
|
site, seriesID, "Title of "+seed.key, seed.kind, seed.url, seed.cover,
|
||||||
seed.checkedAt, latestChapter, seed.latestNum); err != nil {
|
seed.checkedAt, latestChapter, seed.latestNum, seed.siteCompletedAt); err != nil {
|
||||||
t.Fatalf("seed series %q: %v", seed.key, err)
|
t.Fatalf("seed series %q: %v", seed.key, err)
|
||||||
}
|
}
|
||||||
for i := range seed.bookmarks {
|
for i := range seed.bookmarks {
|
||||||
@@ -2378,6 +2379,46 @@ func TestSeriesListFinishedFilterOption(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The site-completed filter is in the select's vocabulary like any other,
|
||||||
|
// and the list it opens shows exactly the hinted Series — the Site's marker
|
||||||
|
// with no owner's Finish, because a retired Series is not work a Poll is
|
||||||
|
// coming to do (issue #170).
|
||||||
|
func TestSeriesListSiteCompletedFilterOption(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()
|
||||||
|
hinted := time.Now().Add(-72 * time.Hour).UnixMilli()
|
||||||
|
seedSeriesRow(t, st, db, seriesRowSeed{key: "asura:hinted", url: "u", cover: "c", checkedAt: time.Now().UnixMilli(), latestNum: floatPtr(10), bookmarks: 1, siteCompletedAt: hinted})
|
||||||
|
seedSeriesRow(t, st, db, seriesRowSeed{key: "asura:plain", url: "u", cover: "c", checkedAt: time.Now().UnixMilli(), latestNum: floatPtr(1), bookmarks: 1})
|
||||||
|
seedSeriesRow(t, st, db, seriesRowSeed{key: "asura:retired", url: "u", cover: "c", checkedAt: time.Now().UnixMilli(), latestNum: floatPtr(2), bookmarks: 1, siteCompletedAt: hinted})
|
||||||
|
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=site_completed")
|
||||||
|
if !strings.Contains(body, "Title of asura:hinted") {
|
||||||
|
t.Errorf("site-completed list misses its row:\n%s", body)
|
||||||
|
}
|
||||||
|
for _, banned := range []string{"Title of asura:plain", "Title of asura:retired"} {
|
||||||
|
if strings.Contains(body, banned) {
|
||||||
|
t.Errorf("site-completed list renders %q:\n%s", banned, body)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !strings.Contains(body, "1 series") {
|
||||||
|
t.Errorf("site-completed heading lacks the count:\n%s", body)
|
||||||
|
}
|
||||||
|
if !strings.Contains(body, `Site says completed (1)`) {
|
||||||
|
t.Errorf("the site-completed option lacks its count:\n%s", body)
|
||||||
|
}
|
||||||
|
if !strings.Contains(body, `<option value="site_completed" selected>`) {
|
||||||
|
t.Errorf("the site-completed 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.
|
||||||
@@ -3151,10 +3192,11 @@ func TestOverviewZeroIsAnUnlinkedDigit(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// The finished figure rides last in the Hygiene block: seriesFilterOrder
|
// The finished figure and the site-completed hint ride last in the Hygiene
|
||||||
// appends it at the tail, the block renders order[1:], and the count comes
|
// block: seriesFilterOrder appends the pair at the tail, the block renders
|
||||||
// from the same aggregate the select is numbered from. Asserting on the body
|
// order[1:], and each count comes from the same aggregate the select is
|
||||||
// order catches a render that merely shows the figure somewhere.
|
// numbered from. Asserting on the body order catches a render that merely
|
||||||
|
// shows a figure somewhere.
|
||||||
func TestOverviewFinishedFigureRidesLast(t *testing.T) {
|
func TestOverviewFinishedFigureRidesLast(t *testing.T) {
|
||||||
st, dsn := newTestStoreURL(t)
|
st, dsn := newTestStoreURL(t)
|
||||||
db, err := sql.Open("pgx", dsn)
|
db, err := sql.Open("pgx", dsn)
|
||||||
@@ -3179,17 +3221,69 @@ func TestOverviewFinishedFigureRidesLast(t *testing.T) {
|
|||||||
}
|
}
|
||||||
labels = append(labels, m[1])
|
labels = append(labels, m[1])
|
||||||
}
|
}
|
||||||
if len(labels) != 10 {
|
if len(labels) != 11 {
|
||||||
t.Fatalf("hygiene block renders %d figures, want 10 (nine problems + finished):\n%s", len(labels), body)
|
t.Fatalf("hygiene block renders %d figures, want 11 (nine problems + finished + site completed):\n%s", len(labels), body)
|
||||||
}
|
}
|
||||||
if labels[len(labels)-1] != "Finished" {
|
if labels[len(labels)-2] != "Finished" || labels[len(labels)-1] != "Site says completed" {
|
||||||
t.Errorf("finished figure does not ride last; hygiene order = %v:\n%s", labels, body)
|
t.Errorf("the informational tail does not ride last; hygiene order = %v:\n%s", labels, body)
|
||||||
}
|
}
|
||||||
if !strings.Contains(body, `href="/admin/series?filter=finished">1</a>`) {
|
if !strings.Contains(body, `href="/admin/series?filter=finished">1</a>`) {
|
||||||
t.Errorf("the finished figure lacks its count and door:\n%s", body)
|
t.Errorf("the finished figure lacks its count and door:\n%s", body)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The site-completed figure is a door into the list that counts it, and the
|
||||||
|
// figure agrees with the list's own heading: both come from the same
|
||||||
|
// aggregate, so they cannot drift apart.
|
||||||
|
func TestOverviewSiteCompletedFigureAgreesWithListHeading(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()
|
||||||
|
hinted := time.Now().Add(-72 * time.Hour).UnixMilli()
|
||||||
|
seedSeriesRow(t, st, db, seriesRowSeed{key: "asura:hinted", url: "https://asurascans.com/comics/hinted", cover: "aaa", checkedAt: time.Now().UnixMilli(), latestNum: floatPtr(10), bookmarks: 1, siteCompletedAt: hinted})
|
||||||
|
seedSeriesRow(t, st, db, seriesRowSeed{key: "asura:plain", url: "https://asurascans.com/comics/plain", cover: "bbb", checkedAt: time.Now().UnixMilli(), latestNum: floatPtr(1), bookmarks: 1})
|
||||||
|
srv := newRouter(st, testConfig())
|
||||||
|
body := overviewBody(t, srv, st)
|
||||||
|
|
||||||
|
fig := regexp.MustCompile(`href="/admin/series\?filter=site_completed">(\d+)</a>`).FindStringSubmatch(body)
|
||||||
|
if fig == nil {
|
||||||
|
t.Fatalf("overview has no site-completed figure:\n%s", body)
|
||||||
|
}
|
||||||
|
list := adminSeriesPage(t, srv, st, "?filter=site_completed")
|
||||||
|
heading := regexp.MustCompile(`(\d+) series <span`).FindStringSubmatch(list)
|
||||||
|
if heading == nil {
|
||||||
|
t.Fatalf("site-completed list has no heading count:\n%s", list)
|
||||||
|
}
|
||||||
|
if fig[1] != heading[1] {
|
||||||
|
t.Errorf("the overview says %s, the list it links to says %s", fig[1], heading[1])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A measured zero site-completed 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 TestOverviewSiteCompletedZeroIsAnUnlinkedDigit(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, `Site says completed</span><span class="fig zero">0</span>`) {
|
||||||
|
t.Errorf("a zero site-completed figure does not render as a muted digit:\n%s", body)
|
||||||
|
}
|
||||||
|
if strings.Contains(body, `href="/admin/series?filter=site_completed"`) {
|
||||||
|
t.Errorf("a zero site-completed figure is still a link:\n%s", body)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// A measured zero finished count stays on the page as a muted digit, never a
|
// 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
|
// link: following it would land on an empty list, exactly like every other
|
||||||
// zero figure.
|
// zero figure.
|
||||||
@@ -3698,6 +3792,94 @@ func TestAdminSeriesDetailFinishControl(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The hint line rides the Finish control: present for a Series the Site
|
||||||
|
// marked completed, with its age, absent while the Site has said nothing
|
||||||
|
// (zero stamp) and gone once the owner finishes — the Site's stamp keeps
|
||||||
|
// standing after retirement, so the hint is suppressed by the same
|
||||||
|
// finished_at guard the clock-driven filters carry (issue #170).
|
||||||
|
func TestAdminSeriesDetailSiteCompletedHint(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()
|
||||||
|
hinted := time.Now().Add(-72 * time.Hour).UnixMilli()
|
||||||
|
seedSeriesRow(t, st, db, seriesRowSeed{key: "asura:hinted", url: "u", cover: "c", checkedAt: time.Now().UnixMilli(), latestNum: floatPtr(1), bookmarks: 1, siteCompletedAt: hinted})
|
||||||
|
seedSeriesRow(t, st, db, seriesRowSeed{key: "asura:plain", url: "u", cover: "c", checkedAt: time.Now().UnixMilli(), latestNum: floatPtr(1), bookmarks: 1})
|
||||||
|
seedSeriesRow(t, st, db, seriesRowSeed{key: "asura:retired", url: "u", cover: "c", checkedAt: time.Now().UnixMilli(), latestNum: floatPtr(1), bookmarks: 1, siteCompletedAt: hinted})
|
||||||
|
if err := st.SetSeriesFinished("asura", "retired", time.Now().UnixMilli()); err != nil {
|
||||||
|
t.Fatalf("finish asura:retired: %v", err)
|
||||||
|
}
|
||||||
|
srv := newRouter(st, testConfig())
|
||||||
|
|
||||||
|
body := seriesDetailPage(t, srv, st, "asura:hinted")
|
||||||
|
if !strings.Contains(body, "the site says this work is completed (since 3d ago)") {
|
||||||
|
t.Errorf("hinted Series lacks the hint with its age:\n%s", body)
|
||||||
|
}
|
||||||
|
// The hint sits between the Finish opener and the confirm row — a line
|
||||||
|
// beside the control, not the control itself.
|
||||||
|
opener := strings.Index(body, "document.getElementById('confirm-finish').hidden = false")
|
||||||
|
hintAt := strings.Index(body, "the site says this work is completed")
|
||||||
|
confirmAt := strings.Index(body, `id="confirm-finish"`)
|
||||||
|
if opener < 0 || hintAt < 0 || confirmAt < 0 || hintAt < opener || confirmAt < hintAt {
|
||||||
|
t.Errorf("the hint is not a line beside the Finish control (opener %d, hint %d, row %d):\n%s", opener, hintAt, confirmAt, body)
|
||||||
|
}
|
||||||
|
if strings.Contains(seriesDetailPage(t, srv, st, "asura:plain"), "the site says this work is completed") {
|
||||||
|
t.Errorf("an unmarked Series renders the hint:\n%s", body)
|
||||||
|
}
|
||||||
|
if strings.Contains(seriesDetailPage(t, srv, st, "asura:retired"), "the site says this work is completed") {
|
||||||
|
t.Errorf("a finished Series renders the hint:\n%s", body)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The hint is a line of text, never a control: with and without a hint, the
|
||||||
|
// Finish control's markup is byte-identical (AC4). Two stores seed the same
|
||||||
|
// key so the hx-post URLs match byte for byte; only the hint line differs,
|
||||||
|
// and it sits outside the confirm row.
|
||||||
|
func TestAdminSeriesDetailFinishControlUnchangedWithHint(t *testing.T) {
|
||||||
|
hinted := time.Now().Add(-72 * time.Hour).UnixMilli()
|
||||||
|
var bodies [2]string
|
||||||
|
for i, sc := range []int64{hinted, 0} {
|
||||||
|
st, dsn := newTestStoreURL(t)
|
||||||
|
db, err := sql.Open("pgx", dsn)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("open %s: %v", dsn, err)
|
||||||
|
}
|
||||||
|
seedSeriesRow(t, st, db, seriesRowSeed{key: "asura:solo", url: "u", cover: "c", checkedAt: time.Now().UnixMilli(), latestNum: floatPtr(1), bookmarks: 1, siteCompletedAt: sc})
|
||||||
|
db.Close()
|
||||||
|
srv := newRouter(st, testConfig())
|
||||||
|
bodies[i] = seriesDetailPage(t, srv, st, "asura:solo")
|
||||||
|
}
|
||||||
|
withHint, without := bodies[0], bodies[1]
|
||||||
|
if !strings.Contains(withHint, "the site says this work is completed") {
|
||||||
|
t.Fatalf("the hinted store did not render a hint:\n%s", withHint)
|
||||||
|
}
|
||||||
|
if strings.Contains(without, "the site says this work is completed") {
|
||||||
|
t.Fatalf("the plain store rendered a hint:\n%s", without)
|
||||||
|
}
|
||||||
|
if got, want := confirmRowMarkup(t, withHint), confirmRowMarkup(t, without); got != want {
|
||||||
|
t.Errorf("the Finish control's markup changed with a hint present:\nwith hint: %s\nwithout: %s", got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// confirmRowMarkup returns the confirm-finish row's HTML, byte for byte —
|
||||||
|
// the Finish control AC4 says must not change with a hint present. The row
|
||||||
|
// is the outer div (the span plus the inner button pair), with no nested
|
||||||
|
// divs beyond that, so the first two </div> closes end it.
|
||||||
|
func confirmRowMarkup(t *testing.T, body string) string {
|
||||||
|
t.Helper()
|
||||||
|
start := strings.Index(body, `<div class="confirm-row calm" id="confirm-finish"`)
|
||||||
|
if start < 0 {
|
||||||
|
t.Fatalf("no confirm-finish row in:\n%s", body)
|
||||||
|
}
|
||||||
|
open := strings.Index(body[start:], ">") + start + 1
|
||||||
|
inner := strings.Index(body[open:], "</div>")
|
||||||
|
outer := strings.Index(body[open+inner+len("</div>"):], "</div>")
|
||||||
|
end := open + inner + len("</div>") + outer + len("</div>")
|
||||||
|
return body[start:end]
|
||||||
|
}
|
||||||
|
|
||||||
// The finish route is the owner's one writer: a finish press stamps
|
// The finish route is the owner's one writer: a finish press stamps
|
||||||
// finished_at and answers with the swapped detail-meta fragment carrying the
|
// finished_at and answers with the swapped detail-meta fragment carrying the
|
||||||
// "finished <age> ago" mark, and an un-finish press writes zero and answers
|
// "finished <age> ago" mark, and an un-finish press writes zero and answers
|
||||||
|
|||||||
Reference in New Issue
Block a user