Spec #135: owner data-correction actions — Latest Chapter, series_url, Cover, orphan removal #156
@@ -33,6 +33,12 @@ type seriesDetailView struct {
|
||||
// owner's, and it dies with the stamp (a machine write of the number).
|
||||
Corrected string
|
||||
|
||||
// Provenance is the actor class behind the current Chapter: "correction",
|
||||
// "sighting" or "machine read"; "" while the Series was never read, when
|
||||
// the line is not rendered. Derived from the same anonymous stamps the
|
||||
// marks above read — no Reader identity crosses here.
|
||||
Provenance string
|
||||
|
||||
// Marks, one per hygiene fact, rendered only while it holds.
|
||||
Unpollable bool // no SeriesURL to fetch
|
||||
NoCover bool
|
||||
@@ -130,6 +136,24 @@ func (h *Handler) seriesDetailView(a store.AdminSeries) seriesDetailView {
|
||||
v.Checked = since(time.Now(), time.UnixMilli(a.LatestCheckedAt))
|
||||
}
|
||||
v.Corrected = correctedAge(time.Now(), a.LatestCorrectedAt)
|
||||
|
||||
// Provenance: the actor class behind the current number, evaluated in the
|
||||
// order the classes outrank one another — the owner's stamp, which a
|
||||
// Correction leaves standing and a machine write clears (issue #149); a
|
||||
// raising Reader, which a Correction drops; then any check stamp at all.
|
||||
// An Acquisition reads as a machine read because it stamps
|
||||
// latest_checked_at exactly as a Poll does, so the two are
|
||||
// indistinguishable the moment it finishes; telling them apart would need
|
||||
// the column this project declines to add (spec #135), and the one
|
||||
// actionable case — acquired once, never read again — is already the
|
||||
// unchecked filter.
|
||||
if a.LatestCorrectedAt != 0 {
|
||||
v.Provenance = "correction"
|
||||
} else if a.RaisedByReader {
|
||||
v.Provenance = "sighting"
|
||||
} else if a.LatestCheckedAt != 0 {
|
||||
v.Provenance = "machine read"
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"bookmarkmanager/backend/internal/store"
|
||||
)
|
||||
|
||||
// seriesDetailView derives the provenance line from the three stamps the
|
||||
// admin projection already carries: the correction stamp outranks a raising
|
||||
// Reader, which outranks a check stamp, and a Series with none of the three
|
||||
// renders no line at all — it was never read, and no actor class is true of
|
||||
// it. Acquisition stamps latest_checked_at exactly as a Poll does, so an
|
||||
// acquired value lands in the same "machine read" class (#152).
|
||||
func TestSeriesDetailViewProvenance(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
a store.AdminSeries
|
||||
want string
|
||||
}{
|
||||
{"correction stamp", store.AdminSeries{LatestCorrectedAt: 1}, "correction"},
|
||||
{"raising reader only", store.AdminSeries{RaisedByReader: true}, "sighting"},
|
||||
{"check stamp only", store.AdminSeries{LatestCheckedAt: 1}, "machine read"},
|
||||
{"correction outranks sighting", store.AdminSeries{LatestCorrectedAt: 1, RaisedByReader: true}, "correction"},
|
||||
{"none of the three", store.AdminSeries{}, ""},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
if got := (&Handler{}).seriesDetailView(tc.a).Provenance; got != tc.want {
|
||||
t.Fatalf("Provenance = %q, want %q", got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -38,10 +38,13 @@
|
||||
|
||||
{{/* series-detail-meta is the meta line, and the answer a Check now or
|
||||
correction press on the detail page swaps into its place: the same marks,
|
||||
re-rendered after the stamp so the pending and corrected markers show. */}}
|
||||
re-rendered after the stamp so the pending and corrected markers — and
|
||||
the provenance line beside the number — describe the value they sit
|
||||
next to. */}}
|
||||
{{define "series-detail-meta"}}
|
||||
<div class="detail-meta" id="detail-meta">
|
||||
<span>ch {{.Chapter}}</span>
|
||||
{{if .Provenance}}<span>{{.Provenance}}</span>{{end}}
|
||||
<span>checked {{.Checked}}</span>
|
||||
<span>{{.Readers}} readers</span>
|
||||
{{if .Corrected}}<span class="mark">{{.Corrected}}</span>{{end}}
|
||||
|
||||
@@ -2722,6 +2722,60 @@ func TestAdminSeriesDetailRendersMarks(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// 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
|
||||
// rendered Series list: an actor class is context for the Series the owner is
|
||||
// already looking at, never a population to sweep (#152).
|
||||
func TestAdminSeriesDetailProvenanceLine(t *testing.T) {
|
||||
router, st := newWebTestServer(t, testConfig())
|
||||
|
||||
seed(t, st, store.Bookmark{
|
||||
Key: "asura:machine", Site: "asura", SeriesID: "machine",
|
||||
Title: "Machine", SeriesURL: "https://asurascans.com/series/machine",
|
||||
Kind: "manga", LatestChapter: "45", LatestChapterNum: floatPtr(45),
|
||||
})
|
||||
if err := st.MarkLatestChecked("asura", "machine", time.Now().Add(-time.Hour).UnixMilli()); err != nil {
|
||||
t.Fatalf("MarkLatestChecked: %v", err)
|
||||
}
|
||||
|
||||
seed(t, st, store.Bookmark{
|
||||
Key: "asura:hand", Site: "asura", SeriesID: "hand",
|
||||
Title: "Hand", SeriesURL: "https://asurascans.com/series/hand",
|
||||
Kind: "manga", LatestChapter: "12", LatestChapterNum: floatPtr(12),
|
||||
})
|
||||
if err := st.CorrectLatestChapter("asura", "hand", 13, time.Now().UnixMilli()); err != nil {
|
||||
t.Fatalf("CorrectLatestChapter: %v", err)
|
||||
}
|
||||
|
||||
seed(t, st, store.Bookmark{
|
||||
Key: "demonic:raised", Site: "demonic", SeriesID: "raised",
|
||||
Title: "Raised", SeriesURL: "https://demonicscans.org/series/raised",
|
||||
Kind: "manga",
|
||||
})
|
||||
if err := st.RecordSighting(st.OwnerID(), "demonic", "raised", floatPtr(7), time.Now().UnixMilli()); err != nil {
|
||||
t.Fatalf("RecordSighting: %v", err)
|
||||
}
|
||||
|
||||
for _, tc := range []struct{ key, want string }{
|
||||
{"asura:machine", "machine read"},
|
||||
{"asura:hand", "correction"},
|
||||
{"demonic:raised", "sighting"},
|
||||
} {
|
||||
body := seriesDetailPage(t, router, st, tc.key)
|
||||
if !strings.Contains(body, "<span>"+tc.want+"</span>") {
|
||||
t.Errorf("detail %s lacks the %q provenance line:\n%s", tc.key, tc.want, body)
|
||||
}
|
||||
}
|
||||
|
||||
listBody := adminSeriesPage(t, router, st, "")
|
||||
for _, word := range []string{"machine read", "correction", "sighting"} {
|
||||
if strings.Contains(listBody, "<span>"+word+"</span>") {
|
||||
t.Errorf("Series list carries a %q provenance line:\n%s", word, listBody)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A well-formed key naming no row is a 404, and so is a key with no ":",
|
||||
// an empty Site or an empty SeriesID — the detail page never answers 500 for
|
||||
// an address nobody can reach.
|
||||
|
||||
Reference in New Issue
Block a user