package latest import ( "context" "fmt" "net/http" "os" "strings" "testing" "time" ) // lnwSeriesPageFloor is the smallest body that can still be a whole // lightnovelworld Series page. Whole pages measured 685 KB..1.18 MB on // 2026-08-11 and carry the marker at ~94% of the document, so a body under // 100 KB is a challenge, a notice, or a truncated read — asserting on it // would report the marker missing when it was never fetched. const lnwSeriesPageFloor = 100 << 10 // TestSmokeLnwCommentBoundary is the live proof that the comment-thread marker // the lightnovelworld chapter scan truncates at (lnwCommentMarker, // "wpd-threads") still holds on the Site. The scan depends on it: when the // marker vanishes every Series is skipped and logged — correct, but silent // until a Reader notices their Latest Chapter has stopped moving. It runs only // when SMOKE_LNW_SERIES_URL is set — the URL of the live Series page to check. // The immortality-simulator page measured 2026-08-11 // (docs/research/lightnovelworld-chapter-vs-series-slug.md) is the default to // point it at: // // SMOKE_LNW_SERIES_URL=https://lightnovelworld.net/novel/immortality-simulator/ go test -v -run TestSmokeLnwCommentBoundary ./internal/latest // // A red run means the Site's markup has moved — the marker is gone, occurs // more than once, or no longer follows the last chapter anchor — and the scan // in sites.go is now skipping this Site. Revisit sites.go before anything // else; the test is not flaky. A Cloudflare challenge or a non-200 is // distinguished from a marker failure by the "not a marker failure" messages // below, which carry the observed status and body length. func TestSmokeLnwCommentBoundary(t *testing.T) { seriesURL := os.Getenv("SMOKE_LNW_SERIES_URL") if seriesURL == "" { t.Skip("SMOKE_LNW_SERIES_URL unset") } if !FetchableSeriesURL("lightnovelworld", seriesURL) { t.Fatalf("%q is not a fetchable lightnovelworld series URL", seriesURL) } f, err := NewTLSFetcher() if err != nil { t.Fatalf("NewTLSFetcher: %v", err) } ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second) defer cancel() body, status, err := f.Get(ctx, seriesURL) if err != nil { t.Fatalf("Get: %v", err) } if status != http.StatusOK { t.Fatalf("status = %d, body %d bytes — not a marker failure; the Site did not answer this IP with a Series page", status, len(body)) } if len(body) < lnwSeriesPageFloor { t.Fatalf("body %d bytes — not a whole Series page (measured 685 KB..1.18 MB); not a marker failure, likely a Cloudflare challenge or a non-Series response", len(body)) } if !lnwChapterRe.MatchString(body) { t.Fatalf("no chapter anchor in %d bytes — not a lightnovelworld Series page; not a marker failure, likely a Cloudflare challenge or a non-Series response", len(body)) } markerIdx := strings.Index(body, lnwCommentMarker) if failures := checkLnwCommentBoundary(body); len(failures) > 0 { t.Fatalf("%s (body %d bytes)", strings.Join(failures, "; "), len(body)) } t.Logf("ok: %q once at byte %d, body %d bytes", lnwCommentMarker, markerIdx, len(body)) } // checkLnwCommentBoundary verifies the three marker assertions against a // fetched Series body: the marker occurs exactly once, every chapter anchor // precedes it, and at least one anchor precedes it at all. It returns one // human-readable failure per broken assertion — with observed offsets and body // length — and empty when the page is healthy. func checkLnwCommentBoundary(body string) []string { markerIdx := strings.Index(body, lnwCommentMarker) switch n := strings.Count(body, lnwCommentMarker); { case n == 0: return []string{fmt.Sprintf("%q occurs 0 times in %d bytes, want exactly 1", lnwCommentMarker, len(body))} case n != 1: return []string{fmt.Sprintf("%q occurs %d times in %d bytes (first at byte %d), want exactly 1", lnwCommentMarker, n, len(body), markerIdx)} } lastAnchor, anchorsBefore := -1, 0 for _, m := range lnwChapterRe.FindAllStringIndex(body, -1) { if m[0] < markerIdx { anchorsBefore++ } lastAnchor = m[0] } var failures []string if lastAnchor >= markerIdx { failures = append(failures, fmt.Sprintf("last chapter anchor at byte %d does not precede the marker at byte %d", lastAnchor, markerIdx)) } if anchorsBefore == 0 { failures = append(failures, fmt.Sprintf("no chapter anchor before the marker at byte %d — the truncated prefix the scan sees yields nothing", markerIdx)) } return failures }