Latest Chapter correction: one numeric input, owner-gated (#149)
This commit is contained in:
@@ -41,7 +41,8 @@ type SeriesFilter struct {
|
||||
// must never leave the store package — so the projection does not select 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,
|
||||
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`
|
||||
|
||||
// raisedByReaderAnswer answers "did a Reader's report set this number" without
|
||||
// naming which Reader. Kept apart from adminSeriesColumns so the column list —
|
||||
@@ -71,9 +72,15 @@ type AdminSeries struct {
|
||||
// ForcePollAt is the owner's "check now" request stamp (issue #146), zero
|
||||
// meaning never asked. Pending is derived, never stored: a request is
|
||||
// pending while ForcePollAt is newer than LatestCheckedAt.
|
||||
ForcePollAt int64
|
||||
ReaderCount int
|
||||
RaisedByReader bool // a Reader's report set LatestChapterNum
|
||||
ForcePollAt int64
|
||||
// LatestCorrectedAt is the correction stamp (issue #149): non-zero means
|
||||
// the Latest Chapter is the owner's, zero means never corrected. The
|
||||
// provenance line (#152) derives from it, so the zero-means-never meaning
|
||||
// is load-bearing.
|
||||
LatestCorrectedAt int64
|
||||
ReaderCount int
|
||||
RaisedByReader bool // a Reader's report set LatestChapterNum
|
||||
|
||||
}
|
||||
|
||||
// SeriesPage is one page of the owner's filtered Series list plus the count
|
||||
@@ -189,7 +196,7 @@ func (s *Store) SeriesPage(f SeriesFilter) (SeriesPage, error) {
|
||||
`+where+`
|
||||
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.force_poll_at, s.latest_raised_by
|
||||
s.force_poll_at, s.latest_corrected_at, s.latest_raised_by
|
||||
`+having+`
|
||||
ORDER BY s.latest_checked_at, s.site, s.series_id
|
||||
LIMIT $`+strconv.Itoa(base+1)+` OFFSET $`+strconv.Itoa(base+2), args...)
|
||||
@@ -264,7 +271,7 @@ func scanAdminSeries(scan func(...any) error) (AdminSeries, int, error) {
|
||||
if err := scan(
|
||||
&a.Site, &a.SeriesID, &a.Title, &a.SeriesURL, &a.CoverAddress,
|
||||
&a.Kind, &a.LatestChapter, &latestChapterNum, &a.LatestCheckedAt,
|
||||
&a.ForcePollAt,
|
||||
&a.ForcePollAt, &a.LatestCorrectedAt,
|
||||
&a.RaisedByReader, &a.ReaderCount, &total,
|
||||
); err != nil {
|
||||
return AdminSeries{}, 0, err
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
-- latest_corrected_at is the "the current Latest Chapter is the owner's" stamp
|
||||
-- (#149). Written by the Correction; zeroed by every machine write of the
|
||||
-- value. Zero means never corrected.
|
||||
ALTER TABLE series ADD COLUMN latest_corrected_at bigint NOT NULL DEFAULT 0;
|
||||
@@ -904,6 +904,11 @@ func (s *Store) Upsert(readerID int64, b Bookmark) (Bookmark, error) {
|
||||
// xmax is zero only on a row this statement inserted, which is how a
|
||||
// Series nobody had bookmarked before is told apart from one that already
|
||||
// existed — DO UPDATE returns a row either way.
|
||||
// latest_corrected_at is the one clause conditional on the value moving
|
||||
// (#149): after a Correction a Reader's cached row holds the corrected
|
||||
// number and resends it on the next Progress PUT, so unconditional
|
||||
// zeroing would erase the fact while the value is still the owner's. The
|
||||
// stamp survives a same-number PUT and dies the moment the number moves.
|
||||
var created bool
|
||||
if err := tx.QueryRow(`
|
||||
INSERT INTO series (site, series_id, title, series_url, kind,
|
||||
@@ -914,7 +919,10 @@ func (s *Store) Upsert(readerID int64, b Bookmark) (Bookmark, error) {
|
||||
ON CONFLICT (site, series_id) DO UPDATE SET
|
||||
kind=excluded.kind,
|
||||
latest_chapter=excluded.latest_chapter,
|
||||
latest_chapter_num=excluded.latest_chapter_num
|
||||
latest_chapter_num=excluded.latest_chapter_num,
|
||||
latest_corrected_at = CASE
|
||||
WHEN series.latest_chapter_num IS DISTINCT FROM excluded.latest_chapter_num
|
||||
THEN 0 ELSE series.latest_corrected_at END
|
||||
RETURNING xmax = 0`,
|
||||
b.Site, b.SeriesID, b.Title, b.SeriesURL, b.Kind,
|
||||
b.LatestChapter, latestNum).Scan(&created); err != nil {
|
||||
@@ -1320,10 +1328,13 @@ func (s *Store) LatestCheckedAt(site, seriesID string) (int64, error) {
|
||||
// SetLatestChapter records the newest chapter the poll found on a series page.
|
||||
// The poller walks Series rather than Bookmarks, so this is a series-level
|
||||
// write: the row is shared, and updating it once refreshes every bookmark that
|
||||
// joins to it. Touching a missing series is not an error.
|
||||
// joins to it. Touching a missing series is not an error. The correction stamp
|
||||
// is zeroed unconditionally: checkOne only calls this when the number differs,
|
||||
// so a second copy of the condition would drift (#149).
|
||||
func (s *Store) SetLatestChapter(site, seriesID, label string, num float64) error {
|
||||
if _, err := s.db.Exec(
|
||||
`UPDATE series SET latest_chapter = $3, latest_chapter_num = $4
|
||||
`UPDATE series SET latest_chapter = $3, latest_chapter_num = $4,
|
||||
latest_corrected_at = 0
|
||||
WHERE site = $1 AND series_id = $2`,
|
||||
site, seriesID, label, num); err != nil {
|
||||
return fmt.Errorf("set latest chapter %s:%s: %w", site, seriesID, err)
|
||||
@@ -1331,8 +1342,27 @@ func (s *Store) SetLatestChapter(site, seriesID, label string, num float64) erro
|
||||
return nil
|
||||
}
|
||||
|
||||
// RecordSighting notes that a Reader's browser reported this Series' Latest
|
||||
// Chapter, which is the half of a Sighting the client body cannot express
|
||||
// CorrectLatestChapter makes the Latest Chapter the owner's: one UPDATE
|
||||
// carrying the number, the derived label and the correction stamp. The label
|
||||
// shape is the poller's and the userscript's ("Chapter " + the number as
|
||||
// printed), so chapterLeadIn strips it and the UI renders "Ch N" with no
|
||||
// special case. latest_checked_at is not touched: a Correction is not a check.
|
||||
// A raising Reader is cleared without judgement: the number is the owner's
|
||||
// now, and no Sighting counter moves (spec #135).
|
||||
func (s *Store) CorrectLatestChapter(site, seriesID string, num float64, at int64) error {
|
||||
if _, err := s.db.Exec(
|
||||
`UPDATE series SET
|
||||
latest_chapter = $3,
|
||||
latest_chapter_num = $4,
|
||||
latest_corrected_at = $5,
|
||||
latest_raised_by = NULL
|
||||
WHERE site = $1 AND series_id = $2`,
|
||||
site, seriesID, "Chapter "+strconv.FormatFloat(num, 'f', -1, 64), num, at); err != nil {
|
||||
return fmt.Errorf("correct latest chapter %s:%s: %w", site, seriesID, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// (issue #103). It must be called *before* the Upsert that stores the reported
|
||||
// value: the raise test compares against what is still on the row, and after
|
||||
// the Upsert there is nothing left to compare with. A Series that does not
|
||||
|
||||
@@ -1899,3 +1899,133 @@ func TestDueForLatestCheckForcedDoesNotOverrideURLOrJoin(t *testing.T) {
|
||||
t.Fatalf("due = %v, want neither the URL-less nor the orphan series", due)
|
||||
}
|
||||
}
|
||||
|
||||
// A Correction writes the number, the derived label and the stamp, clears the
|
||||
// raising Reader, and never touches either Sighting counter or the check
|
||||
// stamp — a Correction is not a check and never judges a Reader (#149).
|
||||
func TestCorrectLatestChapterStampsClearsAndDoesNotTouchCheckOrMarks(t *testing.T) {
|
||||
s := newTestStore(t)
|
||||
other := secondReader(t, s)
|
||||
seedForCheck(t, s, "asura:solo", "https://asurascans.com/comics/solo", 4321_000)
|
||||
// A Reader raised the number, and carries a mark for it.
|
||||
if err := s.RecordSighting(other, "asura", "solo", num2(3), 1000); err != nil {
|
||||
t.Fatalf("RecordSighting: %v", err)
|
||||
}
|
||||
if _, err := s.db.Exec(`
|
||||
UPDATE readers SET sighting_agreements = 5, sighting_disagreements = 2
|
||||
WHERE id = $1`, other); err != nil {
|
||||
t.Fatalf("mark reader: %v", err)
|
||||
}
|
||||
|
||||
if err := s.CorrectLatestChapter("asura", "solo", 12.5, 9000); err != nil {
|
||||
t.Fatalf("CorrectLatestChapter: %v", err)
|
||||
}
|
||||
|
||||
var chapter string
|
||||
var num float64
|
||||
var stamp, checkedAt int64
|
||||
var raisedBy any
|
||||
if err := s.db.QueryRow(`
|
||||
SELECT latest_chapter, latest_chapter_num, latest_corrected_at,
|
||||
latest_checked_at, latest_raised_by
|
||||
FROM series WHERE site = 'asura' AND series_id = 'solo'`).
|
||||
Scan(&chapter, &num, &stamp, &checkedAt, &raisedBy); err != nil {
|
||||
t.Fatalf("read back: %v", err)
|
||||
}
|
||||
if chapter != "Chapter 12.5" {
|
||||
t.Errorf("latest_chapter = %q, want the derived label %q", chapter, "Chapter 12.5")
|
||||
}
|
||||
if num != 12.5 {
|
||||
t.Errorf("latest_chapter_num = %v, want 12.5", num)
|
||||
}
|
||||
if stamp != 9000 {
|
||||
t.Errorf("latest_corrected_at = %d, want 9000", stamp)
|
||||
}
|
||||
if checkedAt != 4321_000 {
|
||||
t.Errorf("latest_checked_at = %d, want the untouched 4321000", checkedAt)
|
||||
}
|
||||
if raisedBy != nil {
|
||||
t.Errorf("latest_raised_by = %v, want the attribution cleared", raisedBy)
|
||||
}
|
||||
|
||||
readers, err := s.Readers()
|
||||
if err != nil {
|
||||
t.Fatalf("Readers: %v", err)
|
||||
}
|
||||
for _, r := range readers {
|
||||
if r.ID == other && (r.Agreements != 5 || r.Disagreements != 2) {
|
||||
t.Errorf("raising reader's marks = %+v, want agreements 5, disagreements 2 unchanged", r)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The stamp follows the number (spec #135): an Upsert resending the corrected
|
||||
// value — a Reader's cached row after a correction — keeps it, and an Upsert
|
||||
// that actually moves the number kills it. Unconditional zeroing would erase
|
||||
// the fact while the value is still the owner's; that is the whole point of
|
||||
// the clause.
|
||||
func TestUpsertCorrectionStampFollowsTheNumber(t *testing.T) {
|
||||
s := newTestStore(t)
|
||||
base := Bookmark{
|
||||
Key: "asura:solo", Site: "asura", SeriesID: "solo", Kind: KindManga,
|
||||
SeriesURL: "https://asurascans.com/comics/solo", UpdatedAt: 1000,
|
||||
}
|
||||
if _, err := s.Upsert(s.OwnerID(), base); err != nil {
|
||||
t.Fatalf("seed: %v", err)
|
||||
}
|
||||
if err := s.CorrectLatestChapter("asura", "solo", 5, 9000); err != nil {
|
||||
t.Fatalf("CorrectLatestChapter: %v", err)
|
||||
}
|
||||
|
||||
// Same number back: the value is still the owner's.
|
||||
same := base
|
||||
same.LatestChapterNum = num2(5)
|
||||
if _, err := s.Upsert(s.OwnerID(), same); err != nil {
|
||||
t.Fatalf("same-number upsert: %v", err)
|
||||
}
|
||||
if got := s.latestCorrectedAt(t, "asura", "solo"); got != 9000 {
|
||||
t.Fatalf("stamp after same-number Upsert = %d, want 9000 kept", got)
|
||||
}
|
||||
|
||||
// A different number: a machine (or a Reader) wrote the value.
|
||||
moved := base
|
||||
moved.LatestChapterNum = num2(7)
|
||||
if _, err := s.Upsert(s.OwnerID(), moved); err != nil {
|
||||
t.Fatalf("moved upsert: %v", err)
|
||||
}
|
||||
if got := s.latestCorrectedAt(t, "asura", "solo"); got != 0 {
|
||||
t.Fatalf("stamp after moved Upsert = %d, want zeroed", got)
|
||||
}
|
||||
}
|
||||
|
||||
// The poller's chapter setter zeroes the stamp unconditionally: checkOne only
|
||||
// calls it when the number differs, so the condition lives upstream and a
|
||||
// second copy here would drift (#149).
|
||||
func TestSetLatestChapterZeroesCorrectionStamp(t *testing.T) {
|
||||
s := newTestStore(t)
|
||||
seedForCheck(t, s, "asura:solo", "https://asurascans.com/comics/solo", 0)
|
||||
if err := s.CorrectLatestChapter("asura", "solo", 5, 9000); err != nil {
|
||||
t.Fatalf("CorrectLatestChapter: %v", err)
|
||||
}
|
||||
if err := s.SetLatestChapter("asura", "solo", "Chapter 6", 6); err != nil {
|
||||
t.Fatalf("SetLatestChapter: %v", err)
|
||||
}
|
||||
if got := s.latestCorrectedAt(t, "asura", "solo"); got != 0 {
|
||||
t.Fatalf("stamp after a machine write = %d, want zeroed", got)
|
||||
}
|
||||
}
|
||||
|
||||
// latestCorrectedAt reads the stamp column for the assertion above.
|
||||
func (s *Store) latestCorrectedAt(t *testing.T, site, seriesID string) int64 {
|
||||
t.Helper()
|
||||
var stamp int64
|
||||
if err := s.db.QueryRow(
|
||||
`SELECT latest_corrected_at FROM series WHERE site = $1 AND series_id = $2`,
|
||||
site, seriesID).Scan(&stamp); err != nil {
|
||||
t.Fatalf("read stamp: %v", err)
|
||||
}
|
||||
return stamp
|
||||
}
|
||||
|
||||
// num2 boxes a chapter number for the Bookmark fields that take a pointer.
|
||||
func num2(f float64) *float64 { return &f }
|
||||
|
||||
Reference in New Issue
Block a user