Merge #167: A failure names itself on the page
This commit is contained in:
@@ -56,6 +56,10 @@ type laneRow struct {
|
||||
// offer Resume from the moment the owner presses Pause, with no pass
|
||||
// having run to record it (issue #147).
|
||||
Paused bool
|
||||
// FailingHref is the one navigation the row offers: the Site's name
|
||||
// links to that Site's failing Series. The chips beside it stay
|
||||
// unlinked; the withdrawn promise lives on outcomeChips (issue #167).
|
||||
FailingHref string
|
||||
}
|
||||
|
||||
// chip is one named outcome count over the owner's window.
|
||||
@@ -223,6 +227,7 @@ func buildLaneRow(p store.LanePass, o store.SiteOutcomes, now time.Time) laneRow
|
||||
if p.GapMS > 0 {
|
||||
row.Gap = (time.Duration(p.GapMS) * time.Millisecond).Truncate(time.Second).String()
|
||||
}
|
||||
row.FailingHref = seriesListHref(store.SeriesFilterFailing, p.Site, "", 0)
|
||||
row.Chips = outcomeChips(o)
|
||||
row.HasChips = len(row.Chips) > 0
|
||||
row.StatePhrase, row.StateGood, row.Attention = laneState(p, now)
|
||||
@@ -232,18 +237,24 @@ func buildLaneRow(p store.LanePass, o store.SiteOutcomes, now time.Time) laneRow
|
||||
|
||||
// outcomeChips lists a Site's nonzero window sums in the taxonomy's fixed
|
||||
// order, so the chips never reorder as the window changes. None observed is
|
||||
// written by the template, not drawn as a confident zero count.
|
||||
// written by the template, not drawn as a confident zero count. The names are
|
||||
// spelled through outcomeWord, the one vocabulary the failure surface shares
|
||||
// with the Series list's fact line (issue #167). The counts stay unlinked
|
||||
// permanently — a withdrawn promise: two of the six words write no per-Series
|
||||
// state, and the other four count attempts inside the owner window while the
|
||||
// failing filter lists Series failing now, so neither set contains the other
|
||||
// and no chip can be a door to its list.
|
||||
func outcomeChips(o store.SiteOutcomes) []chip {
|
||||
fixed := []struct {
|
||||
name string
|
||||
count int
|
||||
}{
|
||||
{"refused", o.Refused},
|
||||
{"unreachable", o.Unreachable},
|
||||
{"no chapter", o.NoChapter},
|
||||
{"unfetchable", o.Unfetchable},
|
||||
{"not found", o.NotFound},
|
||||
{"errors", o.Errors},
|
||||
{outcomeWord("refused"), o.Refused},
|
||||
{outcomeWord("unreachable"), o.Unreachable},
|
||||
{outcomeWord("no_chapter"), o.NoChapter},
|
||||
{outcomeWord("unfetchable"), o.Unfetchable},
|
||||
{outcomeWord("not_found"), o.NotFound},
|
||||
{outcomeWord("errors"), o.Errors},
|
||||
}
|
||||
var out []chip
|
||||
for _, f := range fixed {
|
||||
|
||||
@@ -123,6 +123,11 @@ type seriesRowView struct {
|
||||
// page, where a press that retires a Series from the Lane is on purpose
|
||||
// and confirm-gated (issue #158).
|
||||
Finished bool
|
||||
// Failure names the standing failure and how long it has stood — "not
|
||||
// found · 3d ago", "" while no failure row stands. Its own field, never a
|
||||
// Notes chip: the chips cap at two plus a tail, so the one fact that
|
||||
// names the failure would be the most likely to be truncated away.
|
||||
Failure string
|
||||
}
|
||||
|
||||
// adminSeries renders the filterable, bookmarkable Series list: filter, Site,
|
||||
@@ -683,6 +688,9 @@ func seriesRow(a store.AdminSeries, i int, now time.Time) seriesRowView {
|
||||
row.Ch = "—"
|
||||
}
|
||||
row.Age = checkedAge(now, a.LatestCheckedAt)
|
||||
if a.FailureOutcome != "" {
|
||||
row.Failure = outcomeWord(a.FailureOutcome) + " · " + checkedAge(now, a.FailingSince)
|
||||
}
|
||||
notes := seriesNotes(a, now)
|
||||
if n := len(notes); n > 2 {
|
||||
row.Notes, row.More = notes[:2], n-2
|
||||
@@ -693,6 +701,20 @@ func seriesRow(a store.AdminSeries, i int, now time.Time) seriesRowView {
|
||||
return row
|
||||
}
|
||||
|
||||
// outcomeWord spells the wire failure word as the Lanes chips spell it — a
|
||||
// space, not the underscore: not_found reads "not found", no_chapter "no
|
||||
// chapter". The remaining words are their own spelling, so an unknown word
|
||||
// degrades to itself rather than vanishing from the page.
|
||||
func outcomeWord(wire string) string {
|
||||
switch wire {
|
||||
case "not_found":
|
||||
return "not found"
|
||||
case "no_chapter":
|
||||
return "no chapter"
|
||||
}
|
||||
return wire
|
||||
}
|
||||
|
||||
// seriesNotes are a row's hygiene chips in the design's order: no URL, no
|
||||
// cover, orphan, stale, reader sighting.
|
||||
func seriesNotes(a store.AdminSeries, now time.Time) []string {
|
||||
|
||||
@@ -64,6 +64,11 @@ type seriesDetailView struct {
|
||||
// stands. It rides the meta fragment both presses swap, so the answer
|
||||
// itself shows how long the Series has been finished.
|
||||
FinishedSince string
|
||||
// Unverified is the sentence beside the Latest Chapter correction
|
||||
// control while a Reader's number stands behind a failure past the
|
||||
// owner window: the value is unconfirmed and the owner should not trust
|
||||
// it. It never names the Reader. "" otherwise.
|
||||
Unverified string
|
||||
}
|
||||
|
||||
// adminSeriesDetail renders one Series' page, keyed by the composite
|
||||
@@ -151,6 +156,14 @@ func (h *Handler) seriesDetailView(a store.AdminSeries) seriesDetailView {
|
||||
v.Corrected = correctedAge(time.Now(), a.LatestCorrectedAt)
|
||||
v.Finished = a.FinishedAt != 0
|
||||
v.FinishedSince = finishedAge(time.Now(), a.FinishedAt)
|
||||
// Unverified: a Reader's number standing behind a failure that has outlived
|
||||
// the owner window is a number nobody has re-checked since — say so next to
|
||||
// the correction control, without naming the Reader. A machine read or a
|
||||
// failure still inside the window carries no sentence; the failure itself
|
||||
// has not yet outlived the twelve hours' worth of trust.
|
||||
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."
|
||||
}
|
||||
|
||||
// Provenance: the actor class behind the current number, evaluated in the
|
||||
// order the classes outrank one another — the owner's stamp, which a
|
||||
|
||||
@@ -26,7 +26,7 @@
|
||||
</div>
|
||||
{{range .Rows}}
|
||||
<div class="trow{{if .Attention}} attention{{end}}">
|
||||
<span class="c-site">{{.Site}}</span>
|
||||
<a class="c-site" href="{{.FailingHref}}">{{.Site}}</a>
|
||||
<span>{{.Due}}</span>
|
||||
<span>{{.Checked}}</span>
|
||||
<span>{{.Gap}}</span>
|
||||
|
||||
@@ -19,6 +19,7 @@
|
||||
<input type="number" name="chapter" step="any" placeholder="{{.Chapter}}" required>
|
||||
<button type="submit" class="ghost">Set</button>
|
||||
</div>
|
||||
{{if .Unverified}}<p class="hint">{{.Unverified}}</p>{{end}}
|
||||
</form>
|
||||
<form class="dform" hx-post="/admin/series/{{.Key}}/series-url" hx-target="#detail-meta" hx-swap="outerHTML">
|
||||
<h3>Repair series URL</h3>
|
||||
|
||||
@@ -47,7 +47,7 @@
|
||||
<span class="c-ch">{{.Ch}}</span>
|
||||
<span>{{.Age}}</span>
|
||||
<span class="c-rd">{{.Readers}}</span>
|
||||
<span class="c-note">{{range .Notes}}<span class="mark">{{.}}</span>{{end}}{{if .More}}<span class="mark mark-faint">+{{.More}}</span>{{end}}{{if .Finished}}<span class="mark mark-faint">finished</span>{{end}}</span>
|
||||
<span class="c-note">{{if .Failure}}<span class="mark">{{.Failure}}</span>{{end}}{{range .Notes}}<span class="mark">{{.}}</span>{{end}}{{if .More}}<span class="mark mark-faint">+{{.More}}</span>{{end}}{{if .Finished}}<span class="mark mark-faint">finished</span>{{end}}</span>
|
||||
<span class="c-act">{{if .CanPoll}}<a class="ghost act" hx-post="/admin/series/{{.Key}}/poll" hx-target="closest .trow" hx-swap="outerHTML" hx-vals='{"band":{{if .Band}}1{{else}}0{{end}}}' href="#">Check now</a>{{end}}{{if .CanRemove}}<button class="ghost danger" hx-post="/admin/series/{{.Key}}/remove" hx-target="closest .trow" hx-swap="outerHTML" hx-include="#filterbar" hx-vals='{"band":{{if .Band}}1{{else}}0{{end}}}' hx-confirm="Removes this series and its stored cover. No Reader has it bookmarked; one re-bookmarking it recreates the row.">Remove</button>{{end}}</span>
|
||||
{{if .RemovalRefused}}<span class="row-msg">a Reader has bookmarked this Series again</span>{{end}}
|
||||
</div>
|
||||
|
||||
+115
-1
@@ -918,7 +918,7 @@ func TestLanesRenderFromSeededRowsAfterRestart(t *testing.T) {
|
||||
seedPass(t, st, store.LanePass{Site: "asura", RanAt: now.Add(-time.Minute).UnixMilli(), Due: 5, Checked: 5, GapMS: 20_000})
|
||||
|
||||
body := lanesBody(t, router, st)
|
||||
if !strings.Contains(body, `class="c-site">asura</span>`) {
|
||||
if !strings.Contains(body, `<a class="c-site" href="/admin/series?filter=failing&site=asura">asura</a>`) {
|
||||
t.Fatalf("asura row missing from a restart-read database:\n%s", body)
|
||||
}
|
||||
// The fragment carries its own single timer and answers the swap it asked
|
||||
@@ -989,6 +989,31 @@ func TestNamedOutcomeChips(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// Each Site row on the Lanes page carries one plain navigation link to that
|
||||
// Site's failing Series — a navigation, not a number: the outcome chips
|
||||
// beside it stay unlinked permanently, so the row offers one click and one
|
||||
// answer. The clean Site links too, into an empty list (issue #167).
|
||||
func TestLaneSiteLinksToFailingSeriesAndChipsStayUnlinked(t *testing.T) {
|
||||
router, st := newWebTestServer(t, lanesConfig())
|
||||
now := time.Now()
|
||||
seedPass(t, st, store.LanePass{Site: "asura", RanAt: now.UnixMilli(), NotFound: 6})
|
||||
seedPass(t, st, store.LanePass{Site: "demonic", RanAt: now.UnixMilli()})
|
||||
|
||||
body := lanesBody(t, router, st)
|
||||
if !strings.Contains(body, `<a class="c-site" href="/admin/series?filter=failing&site=asura">asura</a>`) {
|
||||
t.Errorf("the failing Site's name is not a link to its failing Series:\n%s", body)
|
||||
}
|
||||
if !strings.Contains(body, `<a class="c-site" href="/admin/series?filter=failing&site=demonic">demonic</a>`) {
|
||||
t.Errorf("the clean Site's name is not a link to its (empty) failing Series:\n%s", body)
|
||||
}
|
||||
if !strings.Contains(body, `<span class="mark">not found 6</span>`) {
|
||||
t.Errorf("the outcome chip no longer renders beside the link:\n%s", body)
|
||||
}
|
||||
if got := strings.Count(body, `<a class="c-site"`); got != 2 {
|
||||
t.Errorf("Site anchor count = %d, want exactly 2 (only the two Site names navigate):\n%s", got, body)
|
||||
}
|
||||
}
|
||||
|
||||
// Browser configuration is a deployment fact and reachability is derived from
|
||||
// the latest browser-Site passes inside the refusal backoff — no poller in any
|
||||
// of the three.
|
||||
@@ -2629,6 +2654,44 @@ func TestSeriesListRowShape(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// The row's fact line names the standing failure and how long it has stood,
|
||||
// through one format for every age: a one-hour-old failure and a
|
||||
// three-month-old one both render as "<word> · <checkedAge>", and a Series
|
||||
// with no failure row renders no marker at all. The marker is its own field,
|
||||
// never a Notes chip — the chips cap at two plus a tail, so the failure
|
||||
// would be the fact most likely to be truncated away (issue #167).
|
||||
func TestSeriesListFailureMarkerAges(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()
|
||||
seedSeriesRow(t, st, db, seriesRowSeed{key: "asura:hour", url: "u", cover: "c", checkedAt: now.UnixMilli(), latestNum: floatPtr(1), bookmarks: 1})
|
||||
seedSeriesRow(t, st, db, seriesRowSeed{key: "asura:months", url: "u", cover: "c", checkedAt: now.UnixMilli(), latestNum: floatPtr(1), bookmarks: 1})
|
||||
seedSeriesRow(t, st, db, seriesRowSeed{key: "asura:fine", url: "u", cover: "c", checkedAt: now.UnixMilli(), latestNum: floatPtr(1), bookmarks: 1})
|
||||
if err := st.RecordSeriesFailure("asura", "hour", "not_found", now.Add(-time.Hour).UnixMilli()); err != nil {
|
||||
t.Fatalf("seed failure row on hour: %v", err)
|
||||
}
|
||||
if err := st.RecordSeriesFailure("asura", "months", "no_chapter", now.Add(-90*24*time.Hour).UnixMilli()); err != nil {
|
||||
t.Fatalf("seed failure row on months: %v", err)
|
||||
}
|
||||
|
||||
body := adminSeriesPage(t, newRouter(st, testConfig()), st, "")
|
||||
if !strings.Contains(body, `<span class="mark">not found · 1h ago</span>`) {
|
||||
t.Errorf("the one-hour failure lacks its marker:\n%s", body)
|
||||
}
|
||||
if !strings.Contains(body, `<span class="mark">no chapter · 90d ago</span>`) {
|
||||
t.Errorf("the three-month failure lacks its marker:\n%s", body)
|
||||
}
|
||||
// The clean row carries none, and neither word leaks anywhere else on
|
||||
// the page: exactly one marker per failing row.
|
||||
if strings.Count(body, "not found") != 1 || strings.Count(body, "no chapter") != 1 {
|
||||
t.Errorf("failure words appear more than once, or on a clean row:\n%s", body)
|
||||
}
|
||||
}
|
||||
|
||||
// The per-Series page renders every Series-level fact the admin read model
|
||||
// holds for the key the list row already shows: title, the composite key with
|
||||
// Site and kind, the Latest Chapter, the check age and the anonymous Reader
|
||||
@@ -2768,6 +2831,57 @@ func TestAdminSeriesDetailRendersMarks(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// The detail page adds one sentence beside the Latest Chapter correction
|
||||
// control only while the number came from a Reader and the failure row has
|
||||
// stood past the owner window: a Reader-attributed failing Series carries it,
|
||||
// a machine-read failing Series does not, and neither does a
|
||||
// Reader-attributed Series whose failure is still inside the window. The
|
||||
// sentence names no Reader (issue #167).
|
||||
func TestAdminSeriesDetailUnverifiedSentence(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()
|
||||
seedSeriesRow(t, st, db, seriesRowSeed{key: "asura:reader-fail", url: "u", cover: "c", checkedAt: now.UnixMilli(), latestNum: floatPtr(2), bookmarks: 1, raisedBy: true})
|
||||
seedSeriesRow(t, st, db, seriesRowSeed{key: "asura:machine-fail", url: "u", cover: "c", checkedAt: now.UnixMilli(), latestNum: floatPtr(1), bookmarks: 1})
|
||||
seedSeriesRow(t, st, db, seriesRowSeed{key: "asura:reader-recent", url: "u", cover: "c", checkedAt: now.UnixMilli(), latestNum: floatPtr(3), bookmarks: 1, raisedBy: true})
|
||||
if err := st.RecordSeriesFailure("asura", "reader-fail", "not_found", now.Add(-24*time.Hour).UnixMilli()); err != nil {
|
||||
t.Fatalf("seed failure row on reader-fail: %v", err)
|
||||
}
|
||||
if err := st.RecordSeriesFailure("asura", "machine-fail", "not_found", now.Add(-24*time.Hour).UnixMilli()); err != nil {
|
||||
t.Fatalf("seed failure row on machine-fail: %v", err)
|
||||
}
|
||||
if err := st.RecordSeriesFailure("asura", "reader-recent", "not_found", now.Add(-time.Hour).UnixMilli()); err != nil {
|
||||
t.Fatalf("seed failure row on reader-recent: %v", err)
|
||||
}
|
||||
srv := newRouter(st, testConfig())
|
||||
|
||||
want := "Unverified Reader number: the page has been failing for over 12h, so this Reader-reported chapter is unconfirmed."
|
||||
fetch := func(key string) string {
|
||||
t.Helper()
|
||||
req := httptest.NewRequest(http.MethodGet, "/admin/series/"+key, nil)
|
||||
req.AddCookie(sessionCookie(t, st))
|
||||
rr := httptest.NewRecorder()
|
||||
srv.ServeHTTP(rr, req)
|
||||
if rr.Code != http.StatusOK {
|
||||
t.Fatalf("GET /admin/series/%s status = %d, want 200", key, rr.Code)
|
||||
}
|
||||
return rr.Body.String()
|
||||
}
|
||||
if body := fetch("asura:reader-fail"); !strings.Contains(body, want) {
|
||||
t.Errorf("a Reader-attributed failing Series lacks the unverified sentence:\n%s", body)
|
||||
}
|
||||
if body := fetch("asura:machine-fail"); strings.Contains(body, want) {
|
||||
t.Errorf("a machine-read failing Series carries the unverified sentence:\n%s", body)
|
||||
}
|
||||
if body := fetch("asura:reader-recent"); strings.Contains(body, want) {
|
||||
t.Errorf("a Reader-attributed Series failing inside the window carries the sentence:\n%s", body)
|
||||
}
|
||||
}
|
||||
|
||||
// The provenance line beside the chapter names the actor class behind the
|
||||
// value — "machine read" for a checked Series, "correction" for the owner's
|
||||
// stamp, "sighting" for a Reader-raised one — and appears nowhere in the
|
||||
|
||||
Reference in New Issue
Block a user