e7e22a12a5
Implements spec #80 / ADR-0008 — Gitea issues #86, #87, #88, #89, #90, all closed. A Reader bookmarks a novel on lightnovelworld and it never shows a New Chapter, because the Series identity was derived from the chapter address instead of read from the page. One Series can publish under several Chapter Slugs, so the derived key points at a slug that 404s. - **#89** — the userscript's lnw adapter stops deriving `seriesUrl`/`seriesId` from the path. It reads the page's own pointer (`a[aria-label='All Chapter']`), falling back to the microdata breadcrumb's second crumb, and carries `chapterSlug` on the page object, stored nowhere. - **#87** — the Poll's lnw chapter scan is unscoped (no stored-slug pattern can cover a Series' whole list) and truncated at the `wpd-threads` comment thread, the one region a visitor can write to. Marker absent means skip and log with the body length, never scan whole. Corrects the `maxBodyBytes` headroom comment to the measured 3.5x. - **#86** — the scan fixture is now text trimmed from a real, wholly-fetched Series page instead of a hand-written cross-series anchor that no live page carries. - **#90** — stale stored rows repair themselves on the next chapter visit: a pure transform over cache, queue and last-checked map, silent to the Reader, with progress, favourite and lifecycle bucket preserved when two rows merge. - **#88** — an env-gated live canary (`SMOKE_LNW_SERIES_URL`) proving the marker still occurs exactly once and still follows the last chapter anchor, asserted against the production symbols themselves. Verified on the merged branch: `go test ./...` green, `gofmt -l internal/latest/` silent, both userscripts `node --check` clean, 35/35 + 29/29 logic tests. Live canary green (marker once at byte 612,182 of 651,795). #90 verified on device with Playwright. Open follow-up: **#91** — the userscript's client-side latest-chapter scan is still scoped to the derived slug. Reviewed-on: #92 Co-authored-by: Sulthan Zaki <sultankiki05@gmail.com> Co-committed-by: Sulthan Zaki <sultankiki05@gmail.com>
469 lines
21 KiB
Go
469 lines
21 KiB
Go
package latest
|
||
|
||
import (
|
||
"strings"
|
||
"testing"
|
||
)
|
||
|
||
// Trimmed from https://asurascans.com/comics/chronicles-of-the-demon-faction-f886a8af
|
||
// fetched 2026-07-26. The first anchor is the "First Chapter" shortcut: it is a
|
||
// real chapter link with no "Chapter N" text, and it must not be mistaken for
|
||
// the latest just because it parses.
|
||
const asuraSeriesFixture = `
|
||
<a href="/comics/chronicles-of-the-demon-faction-f886a8af/chapter/1" class="py-3 rounded-md bg-[#E8E8E8]"><svg class="w-4 h-4"></svg>First Chapter</a>
|
||
<a href="/comics/chronicles-of-the-demon-faction-f886a8af/chapter/179" data-astro-prefetch="hover" class="group flex"><span class="font-medium">Chapter 179</span></a>
|
||
<a href="/comics/chronicles-of-the-demon-faction-f886a8af/chapter/181" data-astro-prefetch="hover" class="group flex"><span class="font-medium">Chapter 181</span></a>
|
||
<a href="/comics/chronicles-of-the-demon-faction-f886a8af/chapter/180" data-astro-prefetch="hover" class="group flex"><span class="font-medium">Chapter 180</span></a>
|
||
`
|
||
|
||
// A chapter link belonging to a different series, of the kind a "you might also
|
||
// like" strip would introduce. Slug scoping must exclude it.
|
||
const asuraCrossSeriesFixture = asuraSeriesFixture + `
|
||
<a href="/comics/some-other-series-aabbccdd/chapter/999" class="group flex"><span>Chapter 999</span></a>
|
||
`
|
||
|
||
// Trimmed from https://demonicscans.org/manga/Catastrophic-Necromancer fetched
|
||
// 2026-07-26. Note the raw "&", the doubled space after <a, and the decimal
|
||
// chapters, all as they appear live.
|
||
const demonicSeriesFixture = `
|
||
<a href="/chaptered.php?manga=11799&chapter=0.5" class="chplinks" title="Catastrophic Necromancer 0.5">Chapter 0.5</a>
|
||
<a href="/chaptered.php?manga=11799&chapter=294" class="chplinks" title="Catastrophic Necromancer 294">Chapter 294</a>
|
||
<a href="/chaptered.php?manga=11799&chapter=296" class="chplinks" title="Catastrophic Necromancer 296">Chapter 296</a>
|
||
<a href="/chaptered.php?manga=11799&chapter=295" class="chplinks" title="Catastrophic Necromancer 295">Chapter 295</a>
|
||
`
|
||
|
||
// What Cloudflare serves instead of the page when an IP's bot score flips.
|
||
const challengeFixture = `<!DOCTYPE html><html><head><title>Just a moment...</title>
|
||
<script src="/cdn-cgi/challenge-platform/h/b/orchestrate/chl_page/v1"></script></head>
|
||
<body><div id="challenge-running">Checking your browser</div></body></html>`
|
||
|
||
// Trimmed from the server-rendered HTML of
|
||
// https://comix.to/title/n8we-dungeons-and-crayons fetched 2026-08-03. comix is
|
||
// an SPA: the page ships a JSON state blob rather than a list of chapter
|
||
// anchors, and latestChapterUrl is where the newest chapter actually lives.
|
||
const comixSeriesFixture = `
|
||
{"firstChapterUrl":"/title/n8we-dungeons-and-crayons/5038739-chapter-1","latestChapterUrl":"/title/n8we-dungeons-and-crayons/11139891-chapter-80"},
|
||
{""manga","recommended","n8we",1]":{"items":[{"latestChapterUrl":"/title/qqwrm-full-time-awakening/99999999-chapter-999"}]}
|
||
`
|
||
|
||
// The kagane branch is fed by the browser fetcher, so the body is API JSON, not
|
||
// HTML. Trimmed from GET /api/v2/series/<uuid> on 2026-08-03.
|
||
const kaganeAPIFixture = `
|
||
{"series_id":"019f84bc-9ba0-7ed9-86f5-8b905ec7c28b","title":"Infinite Decryption",
|
||
"series_books":[{"book_id":"a","title":"Episode 1","chapter_no":"1","sort_no":1},
|
||
{"book_id":"b","title":"Episode 41","chapter_no":"41","sort_no":41},
|
||
{"book_id":"c","title":"Episode 40.5","chapter_no":"40.5","sort_no":40}]}
|
||
`
|
||
|
||
// Trimmed from https://novelfull.com/reverend-insanity.html fetched 2026-08-05.
|
||
// The page carries a newest-first "latest chapters" widget above an
|
||
// oldest-first paginated list, so the newest anchor is deliberately NOT last —
|
||
// only a maximum finds it. The final anchor belongs to another series and must
|
||
// be excluded by slug scoping.
|
||
const novelfullSeriesFixture = `
|
||
<div class="l-chapters">
|
||
<a href="/reverend-insanity/chapter-2334-fang-yuan-and-giant-sun.html">Chapter 2334</a>
|
||
<a href="/reverend-insanity/chapter-2333-three-venerables.html">Chapter 2333</a>
|
||
</div>
|
||
<ul class="list-chapter">
|
||
<li><a href="/reverend-insanity/chapter-1.html">Chapter 1</a></li>
|
||
<li><a href="/reverend-insanity/chapter-2.html">Chapter 2</a></li>
|
||
</ul>
|
||
<a href="/release-that-witch/chapter-9999.html">Chapter 9999</a>
|
||
`
|
||
|
||
// Trimmed from
|
||
// https://lightnovelworld.net/novel/all-jobs-and-classes-i-just-wanted-one-skill-not-them-all/
|
||
// fetched 2026-08-11, whole document (~307 KB decoded; the wire body is ~32 KB
|
||
// zstd-compressed). This
|
||
// novel publishes its chapters under two Chapter Slugs: 1–99 at
|
||
// …-not-chapter-<n>/ and 100–423 at …-not-them-all-chapter-<n>/, and the page
|
||
// lists them newest-first, so the …-not-them-all anchors precede the …-not
|
||
// anchors. Every anchor through the comment-thread marker is verbatim page
|
||
// text (the site renders this novel's chapter titles as "[ ... words ]"). The
|
||
// comment block after the marker is the real wpdiscuz comment #wpd-comm-358_0
|
||
// from https://lightnovelworld.net/novel/the-sword-illuminates-the-great-wilderness/
|
||
// — the pinned page serves zero comments — with its share/link/vote/reply
|
||
// boilerplate trimmed. The comment's body carried no link, so the bare <a
|
||
// href> to https://lightnovelworld.net/overgeared-chapter-2059/ inside
|
||
// wpd-comment-text is the one composed element; that URL is a real chapter of
|
||
// a real different novel (overgeared; fetched, HTTP 200).
|
||
const lnwSeriesFixture = `
|
||
<li data-ID="102741">
|
||
<a href="https://lightnovelworld.net/all-jobs-and-classes-i-just-wanted-one-skill-not-them-all-chapter-404/">
|
||
<div class="epl-num">Vol. 1 Ch. 404</div>
|
||
<div class="epl-title">[ ... words ]</div>
|
||
<div class="epl-date">April 12, 2026</div>
|
||
</a>
|
||
</li>
|
||
<li data-ID="102780">
|
||
<a href="https://lightnovelworld.net/all-jobs-and-classes-i-just-wanted-one-skill-not-them-all-chapter-423/">
|
||
<div class="epl-num">Vol. 1 Ch. 423</div>
|
||
<div class="epl-title">[ ... words ]</div>
|
||
<div class="epl-date">April 7, 2026</div>
|
||
</a>
|
||
</li>
|
||
<li data-ID="102527">
|
||
<a href="https://lightnovelworld.net/all-jobs-and-classes-i-just-wanted-one-skill-not-them-all-chapter-300/">
|
||
<div class="epl-num">Vol. 1 Ch. 300</div>
|
||
<div class="epl-title">[ ... words ]</div>
|
||
<div class="epl-date">April 4, 2026</div>
|
||
</a>
|
||
</li>
|
||
<li data-ID="102325">
|
||
<a href="https://lightnovelworld.net/all-jobs-and-classes-i-just-wanted-one-skill-not-them-all-chapter-200/">
|
||
<div class="epl-num">Vol. 1 Ch. 200</div>
|
||
<div class="epl-title">[ ... words ]</div>
|
||
<div class="epl-date">March 29, 2026</div>
|
||
</a>
|
||
</li>
|
||
<li data-ID="102121">
|
||
<a href="https://lightnovelworld.net/all-jobs-and-classes-i-just-wanted-one-skill-not-them-all-chapter-100/">
|
||
<div class="epl-num">Vol. 1 Ch. 100</div>
|
||
<div class="epl-title">[ ... words ]</div>
|
||
<div class="epl-date">March 22, 2026</div>
|
||
</a>
|
||
</li>
|
||
<li data-ID="26014">
|
||
<a href="https://lightnovelworld.net/all-jobs-and-classes-i-just-wanted-one-skill-not-chapter-99/">
|
||
<div class="epl-num">Vol. 1 Ch. 99</div>
|
||
<div class="epl-title">Chapter 99</div>
|
||
<div class="epl-date">November 5, 2025</div>
|
||
</a>
|
||
</li>
|
||
<li data-ID="25916">
|
||
<a href="https://lightnovelworld.net/all-jobs-and-classes-i-just-wanted-one-skill-not-chapter-50/">
|
||
<div class="epl-num">Vol. 1 Ch. 50</div>
|
||
<div class="epl-title">Chapter 50</div>
|
||
<div class="epl-date">October 29, 2025</div>
|
||
</a>
|
||
</li>
|
||
<li class='tseplsfrst' data-ID="25818">
|
||
<a href="https://lightnovelworld.net/all-jobs-and-classes-i-just-wanted-one-skill-not-chapter-1/">
|
||
<div class="epl-num">Vol. 1 Ch. 1</div>
|
||
<div class="epl-title">Chapter 01</div>
|
||
<div class="epl-date">October 11, 2025</div>
|
||
</a>
|
||
</li>
|
||
<div id="wpd-threads" class="wpd-thread-wrapper">
|
||
<div class="wpd-thread-list">
|
||
<div id='wpd-comm-358_0' class='comment byuser comment-author-jimbear even thread-even depth-1 wpd-comment wpd_comment_level-1'><div class="wpd-comment-wrap wpd-blog-user wpd-blog-subscriber">
|
||
<div class="wpd-comment-left ">
|
||
<div class="wpd-avatar ">
|
||
<img alt='hasbi asy' src='https://secure.gravatar.com/avatar/3c1792cb31cab842f90e7c463f0948e98536cf938aebbd2f678f937ca60fb799?s=64&d=mm&r=g' srcset='https://secure.gravatar.com/avatar/3c1792cb31cab842f90e7c463f0948e98536cf938aebbd2f678f937ca60fb799?s=128&d=mm&r=g 2x' class='avatar avatar-64 photo' height='64' width='64' decoding='async'/>
|
||
</div>
|
||
<div class="wpd-comment-label" wpd-tooltip="Member" wpd-tooltip-position="right">
|
||
<span>Member</span>
|
||
</div>
|
||
|
||
</div>
|
||
<div id="comment-358" class="wpd-comment-right">
|
||
<div class="wpd-comment-header">
|
||
<div class="wpd-comment-author ">
|
||
hasbi asy
|
||
</div>
|
||
<div class="wpd-comment-date" title="July 9, 2026 1:44 am">
|
||
<i class='far fa-clock' aria-hidden='true'></i>
|
||
1 month ago
|
||
</div>
|
||
|
||
</div>
|
||
|
||
<div class="wpd-comment-text">
|
||
<p>where’s everyone</p>
|
||
|
||
<a href="https://lightnovelworld.net/overgeared-chapter-2059/">https://lightnovelworld.net/overgeared-chapter-2059/</a>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div id='wpdiscuz_form_anchor-358_0'></div>
|
||
</div>
|
||
</div>
|
||
`
|
||
|
||
// Trimmed from https://asurascans.com/comics/chronicles-of-the-demon-faction-f886a8af
|
||
// (redirected to ...-00dcbf97) on 2026-08-10.
|
||
const asuraCoverFixture = `<meta property="og:image" content="https://cdn.asurascans.com/asura-images/covers/chronicles-of-the-demon-faction.d4dcb8.webp">`
|
||
|
||
// Trimmed from https://demonicscans.org/manga/Catastrophic-Necromancer on 2026-08-10.
|
||
// The source publishes the raw space in this URL.
|
||
const demonicCoverFixture = `<meta property="og:image" content="https://readermc.org/images/thumbnails/Catastrophic Necromancer.webp">`
|
||
|
||
// Trimmed from https://comix.to/title/n8we-dungeons-and-crayons on 2026-08-10.
|
||
// The state includes a recommended poster before the target detail object and
|
||
// nested IDs inside that object; no og:image is present.
|
||
const comixCoverFixture = `<script type="application/json" id="initial-data">{"queries":{"[\"manga\",\"recommended\",\"n8we\",1]":{"poster":{"medium":"https://static.comix.to/recommended@280.jpg","large":"https://static.comix.to/recommended.jpg"}},"[\"manga\",\"detail\",\"n8we\"]":{"chapters":[{"hid":"nested"}],"poster":{"medium":"https://static.comix.to/039d/i/1/34/6a6742bf15736@280.jpg","large":"https://static.comix.to/039d/i/1/34/6a6742bf15736.jpg"}}}}</script>`
|
||
|
||
// Trimmed from GET https://kagane.to/api/v2/series/019fe11a-8670-7cf3-8343-0b02057d3787 on 2026-08-10.
|
||
const kaganeCoverFixture = `{"series_covers":[{"cover_id":"019fe11a-84d1-714b-9cf4-2827f277f3c0","language":"en","volume_number":"1","chapter_number":null,"note":null,"image_id":"019fe11a-84c3-7fc3-a84b-88787374b617"}]}`
|
||
|
||
// Trimmed from https://novelfull.com/reverend-insanity.html on 2026-08-10.
|
||
const novelfullCoverFixture = `<meta name="image" content="https://novelfull.com/uploads/webp/novel/reverend-insanity-82661d911a.webp">`
|
||
|
||
// Trimmed from
|
||
// https://lightnovelworld.net/novel/all-jobs-and-classes-i-just-wanted-one-skill-not-them-all/
|
||
// on 2026-08-11.
|
||
const lnwCoverFixture = `<meta property="og:image" content="https://i1.wp.com/lightnovelworld.net/wp-content/uploads/2025/10/all-jobs-and-classes-i-just-wanted-one-skill-not-them-all.jpg" />`
|
||
|
||
func TestCoverFrom(t *testing.T) {
|
||
const comixURL = "https://comix.to/title/n8we-dungeons-and-crayons"
|
||
tests := []struct {
|
||
name string
|
||
site string
|
||
seriesURL string
|
||
body string
|
||
wantOK bool
|
||
wantCover string
|
||
}{
|
||
{
|
||
name: "asura uses published metadata URL",
|
||
site: "asura", body: asuraCoverFixture, wantOK: true,
|
||
wantCover: "https://cdn.asurascans.com/asura-images/covers/chronicles-of-the-demon-faction.d4dcb8.webp",
|
||
},
|
||
{
|
||
name: "demonic escapes raw spaces",
|
||
site: "demonic", body: demonicCoverFixture, wantOK: true,
|
||
wantCover: "https://readermc.org/images/thumbnails/Catastrophic%20Necromancer.webp",
|
||
},
|
||
{
|
||
name: "comix takes target medium poster",
|
||
site: "comix", seriesURL: comixURL, body: comixCoverFixture, wantOK: true,
|
||
wantCover: "https://static.comix.to/039d/i/1/34/6a6742bf15736@280.jpg",
|
||
},
|
||
{
|
||
name: "kagane reads API cover image ID",
|
||
site: "kagane", body: kaganeCoverFixture, wantOK: true,
|
||
wantCover: "https://kagane.to/api/v2/image/019fe11a-84c3-7fc3-a84b-88787374b617/compressed",
|
||
},
|
||
{
|
||
name: "novelfull reads image metadata",
|
||
site: "novelfull", body: novelfullCoverFixture, wantOK: true,
|
||
wantCover: "https://novelfull.com/uploads/webp/novel/reverend-insanity-82661d911a.webp",
|
||
},
|
||
{
|
||
name: "lightnovelworld reads og image",
|
||
site: "lightnovelworld", body: lnwCoverFixture, wantOK: true,
|
||
wantCover: "https://i1.wp.com/lightnovelworld.net/wp-content/uploads/2025/10/all-jobs-and-classes-i-just-wanted-one-skill-not-them-all.jpg",
|
||
},
|
||
{
|
||
name: "later metadata cover survives empty match",
|
||
site: "asura",
|
||
body: `<meta property="og:image" content="">` + asuraCoverFixture,
|
||
wantOK: true,
|
||
wantCover: "https://cdn.asurascans.com/asura-images/covers/chronicles-of-the-demon-faction.d4dcb8.webp",
|
||
},
|
||
{
|
||
name: "page without cover is empty",
|
||
site: "asura", body: `<meta property="og:title" content="No Cover">`,
|
||
},
|
||
{
|
||
name: "unknown site is empty",
|
||
site: "unknown", body: asuraCoverFixture,
|
||
},
|
||
}
|
||
|
||
for _, tt := range tests {
|
||
t.Run(tt.name, func(t *testing.T) {
|
||
got, ok := coverFrom(tt.site, tt.seriesURL, tt.body)
|
||
if ok != tt.wantOK {
|
||
t.Fatalf("ok = %v, want %v (got %q)", ok, tt.wantOK, got)
|
||
}
|
||
if got != tt.wantCover {
|
||
t.Errorf("cover = %q, want %q", got, tt.wantCover)
|
||
}
|
||
})
|
||
}
|
||
}
|
||
|
||
func TestCoverFromChallenge(t *testing.T) {
|
||
tests := []struct {
|
||
site string
|
||
seriesURL string
|
||
}{
|
||
{"asura", "https://asurascans.com/comics/chronicles-of-the-demon-faction-f886a8af"},
|
||
{"demonic", "https://demonicscans.org/manga/Catastrophic-Necromancer"},
|
||
{"comix", "https://comix.to/title/n8we-dungeons-and-crayons"},
|
||
{"kagane", "https://kagane.to/series/019f84bc-9ba0-7ed9-86f5-8b905ec7c28b"},
|
||
{"novelfull", "https://novelfull.com/reverend-insanity.html"},
|
||
{"lightnovelworld", "https://lightnovelworld.net/novel/a-will-eternal/"},
|
||
}
|
||
for _, tt := range tests {
|
||
t.Run(tt.site, func(t *testing.T) {
|
||
if got, ok := coverFrom(tt.site, tt.seriesURL, challengeFixture); ok || got != "" {
|
||
t.Fatalf("cover = %q, ok = %v, want empty", got, ok)
|
||
}
|
||
})
|
||
}
|
||
}
|
||
|
||
func TestLatestChapterFrom(t *testing.T) {
|
||
const asuraURL = "https://asurascans.com/comics/chronicles-of-the-demon-faction-f886a8af"
|
||
const demonicURL = "https://demonicscans.org/manga/Catastrophic-Necromancer"
|
||
|
||
tests := []struct {
|
||
name string
|
||
site string
|
||
seriesURL string
|
||
body string
|
||
wantOK bool
|
||
wantNum float64
|
||
wantLabel string
|
||
}{
|
||
{
|
||
name: "asura takes the max, not the last listed",
|
||
site: "asura", seriesURL: asuraURL, body: asuraSeriesFixture,
|
||
wantOK: true, wantNum: 181, wantLabel: "Chapter 181",
|
||
},
|
||
{
|
||
name: "asura ignores another series' chapter links",
|
||
site: "asura", seriesURL: asuraURL, body: asuraCrossSeriesFixture,
|
||
wantOK: true, wantNum: 181, wantLabel: "Chapter 181",
|
||
},
|
||
{
|
||
name: "asura scoping survives a build-hash rotation",
|
||
site: "asura",
|
||
seriesURL: "https://asurascans.com/comics/chronicles-of-the-demon-faction-059befe1",
|
||
body: asuraCrossSeriesFixture,
|
||
wantOK: true, wantNum: 181, wantLabel: "Chapter 181",
|
||
},
|
||
{
|
||
name: "asura with an unparseable series url",
|
||
site: "asura", seriesURL: "https://asurascans.com/", body: asuraSeriesFixture,
|
||
wantOK: false,
|
||
},
|
||
{
|
||
name: "demonic takes the max across raw and escaped ampersands",
|
||
site: "demonic", seriesURL: demonicURL, body: demonicSeriesFixture,
|
||
wantOK: true, wantNum: 296, wantLabel: "Chapter 296",
|
||
},
|
||
{
|
||
name: "demonic keeps decimal chapters parseable",
|
||
site: "demonic", seriesURL: demonicURL,
|
||
body: `<a href="/chaptered.php?manga=11799&chapter=0.5">Chapter 0.5</a>`,
|
||
wantOK: true, wantNum: 0.5, wantLabel: "Chapter 0.5",
|
||
},
|
||
{
|
||
name: "empty body",
|
||
site: "asura", seriesURL: asuraURL, body: "",
|
||
wantOK: false,
|
||
},
|
||
{
|
||
name: "cloudflare challenge page",
|
||
site: "asura", seriesURL: asuraURL, body: challengeFixture,
|
||
wantOK: false,
|
||
},
|
||
{
|
||
name: "demonic markup handed to the asura rule",
|
||
site: "asura", seriesURL: asuraURL, body: demonicSeriesFixture,
|
||
wantOK: false,
|
||
},
|
||
{
|
||
name: "unknown site",
|
||
site: "mangadex", seriesURL: "https://example.com/x", body: asuraSeriesFixture,
|
||
wantOK: false,
|
||
},
|
||
{
|
||
name: "comix reads latestChapterUrl, scoped to this series",
|
||
site: "comix",
|
||
seriesURL: "https://comix.to/title/n8we-dungeons-and-crayons",
|
||
body: comixSeriesFixture,
|
||
wantOK: true, wantNum: 80, wantLabel: "Chapter 80",
|
||
},
|
||
{
|
||
name: "comix yields nothing on a challenge page",
|
||
site: "comix",
|
||
seriesURL: "https://comix.to/title/n8we-dungeons-and-crayons",
|
||
body: challengeFixture,
|
||
wantOK: false,
|
||
},
|
||
{
|
||
name: "kagane takes the max chapter_no from API json",
|
||
site: "kagane",
|
||
seriesURL: "https://kagane.to/series/019f84bc-9ba0-7ed9-86f5-8b905ec7c28b",
|
||
body: kaganeAPIFixture,
|
||
wantOK: true, wantNum: 41, wantLabel: "Chapter 41",
|
||
},
|
||
{
|
||
name: "kagane yields nothing on a challenge page",
|
||
site: "kagane",
|
||
seriesURL: "https://kagane.to/series/019f84bc-9ba0-7ed9-86f5-8b905ec7c28b",
|
||
body: challengeFixture,
|
||
wantOK: false,
|
||
},
|
||
{
|
||
name: "novelfull takes the max and ignores another series",
|
||
site: "novelfull",
|
||
seriesURL: "https://novelfull.com/reverend-insanity.html",
|
||
body: novelfullSeriesFixture,
|
||
wantOK: true, wantNum: 2334, wantLabel: "Chapter 2334",
|
||
},
|
||
{
|
||
name: "novelfull yields nothing on a challenge page",
|
||
site: "novelfull",
|
||
seriesURL: "https://novelfull.com/reverend-insanity.html",
|
||
body: challengeFixture,
|
||
wantOK: false,
|
||
},
|
||
{
|
||
name: "novelfull with an unparseable series url",
|
||
site: "novelfull",
|
||
seriesURL: "https://novelfull.com/genre/Fantasy",
|
||
body: novelfullSeriesFixture,
|
||
wantOK: false,
|
||
},
|
||
// Stored before the slug split, so the address carries the ...-not
|
||
// Chapter Slug; the 100-423 block under the other slug must still win.
|
||
// The body is the chapter-list portion of lnwSeriesFixture with the
|
||
// comment block omitted; the marker is kept, because a body without it
|
||
// is skipped, not scanned.
|
||
{
|
||
name: "lightnovelworld max spans both chapter slugs",
|
||
site: "lightnovelworld",
|
||
seriesURL: "https://lightnovelworld.net/novel/all-jobs-and-classes-i-just-wanted-one-skill-not/",
|
||
body: strings.SplitN(lnwSeriesFixture, lnwCommentMarker, 2)[0] + lnwCommentMarker,
|
||
wantOK: true, wantNum: 423, wantLabel: "Chapter 423",
|
||
},
|
||
{
|
||
name: "lightnovelworld comment anchor cannot set the latest chapter",
|
||
site: "lightnovelworld",
|
||
seriesURL: "https://lightnovelworld.net/novel/all-jobs-and-classes-i-just-wanted-one-skill-not-them-all/",
|
||
body: lnwSeriesFixture,
|
||
wantOK: true, wantNum: 423, wantLabel: "Chapter 423",
|
||
},
|
||
// Marker removed from the fixture, comment block still present: a
|
||
// redesign must degrade into a skip, never into the comment's number.
|
||
{
|
||
name: "lightnovelworld body without the comment marker is skipped",
|
||
site: "lightnovelworld",
|
||
seriesURL: "https://lightnovelworld.net/novel/all-jobs-and-classes-i-just-wanted-one-skill-not-them-all/",
|
||
body: strings.ReplaceAll(lnwSeriesFixture, lnwCommentMarker, ""),
|
||
wantOK: false,
|
||
},
|
||
{
|
||
name: "lightnovelworld yields nothing on a challenge page",
|
||
site: "lightnovelworld",
|
||
seriesURL: "https://lightnovelworld.net/novel/all-jobs-and-classes-i-just-wanted-one-skill-not-them-all/",
|
||
body: challengeFixture,
|
||
wantOK: false,
|
||
},
|
||
}
|
||
|
||
for _, tt := range tests {
|
||
t.Run(tt.name, func(t *testing.T) {
|
||
got, ok := latestChapterFrom(tt.site, tt.seriesURL, tt.body)
|
||
if ok != tt.wantOK {
|
||
t.Fatalf("ok = %v, want %v (got %+v)", ok, tt.wantOK, got)
|
||
}
|
||
if !tt.wantOK {
|
||
return
|
||
}
|
||
if got.Num != tt.wantNum {
|
||
t.Errorf("Num = %v, want %v", got.Num, tt.wantNum)
|
||
}
|
||
if got.Label != tt.wantLabel {
|
||
t.Errorf("Label = %q, want %q", got.Label, tt.wantLabel)
|
||
}
|
||
})
|
||
}
|
||
}
|