diff --git a/backend/internal/web/admin_series_detail.go b/backend/internal/web/admin_series_detail.go index 8178aba..4166f9a 100644 --- a/backend/internal/web/admin_series_detail.go +++ b/backend/internal/web/admin_series_detail.go @@ -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 } diff --git a/backend/internal/web/admin_series_detail_test.go b/backend/internal/web/admin_series_detail_test.go new file mode 100644 index 0000000..e244ead --- /dev/null +++ b/backend/internal/web/admin_series_detail_test.go @@ -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) + } + }) + } +} diff --git a/backend/internal/web/templates/series-detail.html b/backend/internal/web/templates/series-detail.html index c5a800c..71f63c8 100644 --- a/backend/internal/web/templates/series-detail.html +++ b/backend/internal/web/templates/series-detail.html @@ -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"}}
ch {{.Chapter}} + {{if .Provenance}}{{.Provenance}}{{end}} checked {{.Checked}} {{.Readers}} readers {{if .Corrected}}{{.Corrected}}{{end}} @@ -51,4 +54,4 @@ {{if .Orphan}}orphan{{end}} {{if .SightingRaised}}sighting-raised{{end}}
-{{end}} +{{end}} \ No newline at end of file diff --git a/backend/web_test.go b/backend/web_test.go index 5c048fe..2241186 100644 --- a/backend/web_test.go +++ b/backend/web_test.go @@ -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, ""+tc.want+"") { + 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, ""+word+"") { + 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.