#172: three more owner-notice conditions: no-browser-route, sidecar-down, adapter-broken

FaultsFrom judges three more faults at the shared twelve-hour OwnerWindow:
a no-browser-route Site's refusing run (browser-backed Sites excluded), a
sidecar no Lane has reached (one site-wide fault), and a Site where more
than half of Series hold an old no-chapter failure row. recordPass fills
the inputs from three new store reads (RefusingSince, SidecarOK,
NoChapterShare), each failing independently and never failing a pass, and
the clear loop forgets the empty-Site row too so a lifted sidecar-down
fires again on return.
This commit is contained in:
2026-08-23 02:44:45 +07:00
parent 3d746092ab
commit 28fef689ec
5 changed files with 853 additions and 20 deletions
+159
View File
@@ -7,6 +7,7 @@ import (
"encoding/hex"
"errors"
"io/fs"
"fmt"
"os"
"path/filepath"
"strconv"
@@ -2738,3 +2739,161 @@ func TestOwnerNoticeRows(t *testing.T) {
t.Fatalf("ClearNotice on a missing row: %v", err)
}
}
// RefusingSince reads one Site's current unbroken run of refusing passes
// (issue #172). A refusing pass is one the Lane gate returned early from
// (skip 'refusing') or one whose loop counted refusals (refused > 0) — the
// two shapes a persistently-challenged Site alternates between, so the run
// must not break when the gate row follows the refusal row. A Site whose
// latest pass did not refuse is absent, nothing after the caller's clock
// counts, and a run older than the log answers with the oldest row present.
func TestRefusingSince(t *testing.T) {
s := newTestStore(t)
seed := func(site string, ranAt int64, skip string, refused int) {
t.Helper()
if err := s.RecordLanePass(LanePass{Site: site, RanAt: ranAt, Skip: skip, Refused: refused}, -1); err != nil {
t.Fatalf("RecordLanePass(%s/%d): %v", site, ranAt, err)
}
}
// asura: a refusing run broken by a healthy pass, then resumed; the
// current run began at the pass after the break.
seed("asura", 100, "", 2)
seed("asura", 200, "", 0)
seed("asura", 300, "refusing", 0)
seed("asura", 400, "", 2)
// demonic: the latest pass is healthy — no run, absent.
seed("demonic", 100, "", 2)
seed("demonic", 200, "", 0)
// novelfull: a healthy pass after now must not break the run — the run
// is judged as of the caller's clock.
seed("novelfull", 100, "", 2)
seed("novelfull", 600, "", 0)
// comix: an unbroken run reaches back to the oldest row present.
seed("comix", 100, "", 2)
seed("comix", 200, "refusing", 0)
got, err := s.RefusingSince(450)
if err != nil {
t.Fatalf("RefusingSince: %v", err)
}
want := map[string]int64{"asura": 300, "novelfull": 100, "comix": 100}
if len(got) != len(want) {
t.Fatalf("RefusingSince = %v, want %v", got, want)
}
for site, since := range want {
if got[site] != since {
t.Fatalf("RefusingSince[%s] = %d, want %d (got %v)", site, got[site], since, got)
}
}
if _, ok := got["demonic"]; ok {
t.Fatalf("RefusingSince names demonic, whose latest pass did not refuse: %v", got)
}
}
// SidecarOK reads, per Site, the most recent pass that actually reached the
// sidecar (issue #172): the pass ran its loop (skip '') and no read lost
// Chrome (unreachable 0). A challenge answer still reached the sidecar, a
// lost sidecar and every skip value did not, and a Site with no qualifying
// pass — an asleep Lane included — is absent.
func TestSidecarOK(t *testing.T) {
s := newTestStore(t)
seed := func(site string, ranAt int64, skip string, unreachable int) {
t.Helper()
if err := s.RecordLanePass(LanePass{Site: site, RanAt: ranAt, Skip: skip, Unreachable: unreachable}, -1); err != nil {
t.Fatalf("RecordLanePass(%s/%d): %v", site, ranAt, err)
}
}
// comix: only the skip='' unreachable=0 pass reached the sidecar; the
// mid-loop browser-loss row (skip '', unreachable > 0) and the skip
// values never did.
seed("comix", 100, "", 1)
seed("comix", 200, "sidecar-down", 0)
seed("comix", 300, "", 0)
seed("comix", 400, "refusing", 0)
// kagane: two passes reached the sidecar; the newest wins.
seed("kagane", 150, "", 0)
seed("kagane", 250, "", 0)
// novelfull: an asleep Lane never reached it.
seed("novelfull", 120, "asleep", 0)
got, err := s.SidecarOK([]string{"comix", "kagane", "novelfull", "lightnovelworld"})
if err != nil {
t.Fatalf("SidecarOK: %v", err)
}
want := map[string]int64{"comix": 300, "kagane": 250}
if len(got) != len(want) {
t.Fatalf("SidecarOK = %v, want %v", got, want)
}
for site, ranAt := range want {
if got[site] != ranAt {
t.Fatalf("SidecarOK[%s] = %d, want %d (got %v)", site, got[site], ranAt, got)
}
}
for _, site := range []string{"novelfull", "lightnovelworld"} {
if _, ok := got[site]; ok {
t.Fatalf("SidecarOK names %s, which never reached the sidecar: %v", site, got)
}
}
}
// NoChapterShare reads, per Site, the share of its Series holding a
// no-chapter failure row older than the cutoff (issue #172): old rows over
// the Site's whole Series count, so a four-Series Site and a 200-Series
// Site are judged on the same scale. A fresh no-chapter row and a row of
// any other outcome do not count.
func TestNoChapterShare(t *testing.T) {
s := newTestStore(t)
for i := 1; i <= 4; i++ {
seedForCheck(t, s, fmt.Sprintf("asura:a%d", i), fmt.Sprintf("https://asurascans.com/comics/a%d", i), 0)
}
seedForCheck(t, s, "comix:c1", "https://comix.to/title/c1", 0)
seedForCheck(t, s, "comix:c2", "https://comix.to/title/c2", 0)
seedForCheck(t, s, "demonic:d1", "https://demonicscans.org/series/d1", 0)
for i := 1; i <= 200; i++ {
seedForCheck(t, s, fmt.Sprintf("novelfull:n%d", i), fmt.Sprintf("https://novelfull.com/n%d.html", i), 0)
}
const cutoff = 1000
oldNoChapter := func(site, seriesID string) {
t.Helper()
if err := s.RecordSeriesFailure(site, seriesID, "no_chapter", 100); err != nil {
t.Fatalf("seed no-chapter failure %s:%s: %v", site, seriesID, err)
}
}
// asura: three of four hold an old no-chapter row; the fourth's
// no-chapter row is fresh — it is not old, so it does not count.
oldNoChapter("asura", "a1")
oldNoChapter("asura", "a2")
oldNoChapter("asura", "a3")
if err := s.RecordSeriesFailure("asura", "a4", "no_chapter", 2000); err != nil {
t.Fatalf("seed fresh no-chapter failure: %v", err)
}
// comix: an old no-chapter row and an old errors row — the errors row
// is not a no-chapter row, so the share is 1/2, the exact boundary.
oldNoChapter("comix", "c1")
if err := s.RecordSeriesFailure("comix", "c2", "errors", 100); err != nil {
t.Fatalf("seed errors failure: %v", err)
}
// demonic and novelfull: one old no-chapter row each, against sites of
// one and two hundred Series.
oldNoChapter("demonic", "d1")
oldNoChapter("novelfull", "n1")
got, err := s.NoChapterShare(cutoff)
if err != nil {
t.Fatalf("NoChapterShare: %v", err)
}
checks := []struct {
site string
want float64
}{
{"asura", 3.0 / 4.0},
{"comix", 1.0 / 2.0},
{"demonic", 1.0 / 1.0},
{"novelfull", 1.0 / 200.0},
}
for _, c := range checks {
if got[c.site] != c.want {
t.Fatalf("NoChapterShare[%s] = %v, want %v", c.site, got[c.site], c.want)
}
}
}