web: site-completed hint reaches the owner, decides nothing (#170)
The Site's own completed marker (issue #168) joins the owner's surface as an informational hint, never a decision: - store: SeriesFilterSiteCompleted ('site_completed') sits beside Finished in the filter vocabulary; its predicate carries the same finished_at guard as the clock-driven filters, because the Site's stamp keeps standing after the owner retires the row. The projection and SeriesPage GROUP BY carry site_completed_at so the detail page can age it. - web: the filter joins the Series list select as 'Site says completed' and rides the tail of seriesFilterOrder, so the landing stats figure comes for free and sits after the finished figure. - detail: one hint line beside the Finish control - 'the site says this work is completed (since 3d ago)' - rendered only while the stamp stands and no Finish stands; the Finish control itself is untouched. - tests: filter's finished exclusion and un-finish return to the Poll query (store); figure at zero and nonzero, hint present/absent, and the Finish control's markup byte-identical with a hint present (web).
This commit is contained in:
+201
-19
@@ -2187,14 +2187,15 @@ func TestNovelNewTabFallsBackToAll(t *testing.T) {
|
||||
// cannot produce an orphan series or a Reader-raised Latest Chapter — the
|
||||
// same reason the store's admin tests seed this way.
|
||||
type seriesRowSeed struct {
|
||||
key string
|
||||
kind string
|
||||
url string
|
||||
cover string // cover_address
|
||||
checkedAt int64
|
||||
latestNum *float64
|
||||
bookmarks int // readers that hold it; 0 = orphan
|
||||
raisedBy bool // a Reader's report is attributed as the raiser
|
||||
key string
|
||||
kind string
|
||||
url string
|
||||
cover string // cover_address
|
||||
checkedAt int64
|
||||
latestNum *float64
|
||||
bookmarks int // readers that hold it; 0 = orphan
|
||||
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
|
||||
@@ -2214,10 +2215,10 @@ func seedSeriesRow(t *testing.T, st *store.Store, db *sql.DB, seed seriesRowSeed
|
||||
}
|
||||
if _, err := db.Exec(`
|
||||
INSERT INTO series (site, series_id, title, kind, series_url, cover_address,
|
||||
latest_checked_at, latest_chapter, latest_chapter_num)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)`,
|
||||
latest_checked_at, latest_chapter, latest_chapter_num, site_completed_at)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)`,
|
||||
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)
|
||||
}
|
||||
for i := range seed.bookmarks {
|
||||
@@ -2353,6 +2354,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
|
||||
// kagane manga stale row renders, and every link the page emits carries the
|
||||
// filter and Site so the narrowing survives in the URL.
|
||||
@@ -3037,10 +3078,11 @@ 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.
|
||||
// The finished figure and the site-completed hint ride last in the Hygiene
|
||||
// block: seriesFilterOrder appends the pair at the tail, the block renders
|
||||
// order[1:], and each count comes from the same aggregate the select is
|
||||
// numbered from. Asserting on the body order catches a render that merely
|
||||
// shows a figure somewhere.
|
||||
func TestOverviewFinishedFigureRidesLast(t *testing.T) {
|
||||
st, dsn := newTestStoreURL(t)
|
||||
db, err := sql.Open("pgx", dsn)
|
||||
@@ -3065,17 +3107,69 @@ func TestOverviewFinishedFigureRidesLast(t *testing.T) {
|
||||
}
|
||||
labels = append(labels, m[1])
|
||||
}
|
||||
if len(labels) != 10 {
|
||||
t.Fatalf("hygiene block renders %d figures, want 10 (nine problems + finished):\n%s", len(labels), body)
|
||||
if len(labels) != 11 {
|
||||
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" {
|
||||
t.Errorf("finished figure does not ride last; hygiene order = %v:\n%s", labels, body)
|
||||
if labels[len(labels)-2] != "Finished" || labels[len(labels)-1] != "Site says completed" {
|
||||
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>`) {
|
||||
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
|
||||
// link: following it would land on an empty list, exactly like every other
|
||||
// zero figure.
|
||||
@@ -3584,6 +3678,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
|
||||
// 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
|
||||
|
||||
Reference in New Issue
Block a user