a4da2e2f00
The lightnovelworld chapter branch no longer derives the Series address by string-manipulating the chapter path: on ~7% of novels the Chapter Slug diverges from the Series slug and the derived address 404s on every Poll. The identity now comes from the page's own a[aria-label='All Chapter'] pointer (breadcrumb's second crumb as fallback), validated as a lightnovelworld.net /novel/<slug>/ address; no pointer resolves to type: other. The Chapter Slug rides on the detected page object only (chapterSlug; null on a series page) and is written to no store, for the stale-row repair in #90. Harness: document.querySelector now answers attribute selectors with an element exposing getAttribute (attrEls table, cleared by reset()). AGENTS.md records that the Series address is discovered, not derived.
326 lines
13 KiB
JavaScript
326 lines
13 KiB
JavaScript
"use strict";
|
|
|
|
const test = require("node:test");
|
|
const assert = require("node:assert");
|
|
|
|
// ============================================================
|
|
// Minimal browser stub. Same shape as logic.test.js.
|
|
// document.body stays UNDEFINED so the boot block waits for a DOMContentLoaded
|
|
// that never fires and no network call is ever made.
|
|
// ============================================================
|
|
|
|
const store = new Map();
|
|
globalThis.localStorage = {
|
|
getItem: (k) => (store.has(k) ? store.get(k) : null),
|
|
setItem: (k, v) => store.set(k, String(v)),
|
|
removeItem: (k) => store.delete(k),
|
|
};
|
|
|
|
globalThis.location = { href: "about:blank", hostname: "", pathname: "/", origin: "" };
|
|
|
|
let metaTags = {};
|
|
let elements = {};
|
|
// Attribute selectors (the lightnovelworld Series pointer) answer with an
|
|
// element exposing getAttribute, like the meta branch below.
|
|
let attrEls = {};
|
|
globalThis.document = {
|
|
querySelector(sel) {
|
|
const attr = attrEls[sel];
|
|
if (attr != null) return attr;
|
|
const m = sel.match(/^meta\[property="([^"]+)"\]$/);
|
|
if (m) {
|
|
const v = metaTags[m[1]];
|
|
return v == null ? null : { getAttribute: () => v };
|
|
}
|
|
const text = elements[sel];
|
|
return text == null ? null : { textContent: text };
|
|
},
|
|
querySelectorAll() {
|
|
return [];
|
|
},
|
|
addEventListener() {},
|
|
body: undefined,
|
|
};
|
|
|
|
const {
|
|
novelfull,
|
|
lightnovelworld,
|
|
kindOf,
|
|
maxChapter,
|
|
} = require("../novel-bookmark.user.js");
|
|
|
|
function loc(href) {
|
|
const u = new URL(href);
|
|
return { pathname: u.pathname, origin: u.origin, href: u.href, hostname: u.hostname };
|
|
}
|
|
|
|
function reset() {
|
|
metaTags = {};
|
|
elements = {};
|
|
attrEls = {};
|
|
}
|
|
|
|
// ============================================================
|
|
// novelfull adapter
|
|
// ============================================================
|
|
|
|
test("novelfull.detect reads a series page", () => {
|
|
reset();
|
|
elements = { "h3.title": "Reverend Insanity" };
|
|
const p = novelfull.detect(loc("https://novelfull.com/reverend-insanity.html"));
|
|
assert.equal(p.type, "series");
|
|
assert.equal(p.site, "novelfull");
|
|
assert.equal(p.seriesId, "reverend-insanity");
|
|
assert.equal(p.title, "Reverend Insanity");
|
|
assert.equal(p.seriesUrl, "https://novelfull.com/reverend-insanity.html");
|
|
assert.equal(p.chapterNum, null);
|
|
});
|
|
|
|
test("novelfull.detect reads a chapter page and points seriesUrl at the series", () => {
|
|
reset();
|
|
elements = { "a.truyen-title": "Reverend Insanity" };
|
|
const url = "https://novelfull.com/reverend-insanity/chapter-2334-fang-yuan.html";
|
|
const p = novelfull.detect(loc(url));
|
|
assert.equal(p.type, "chapter");
|
|
assert.equal(p.seriesId, "reverend-insanity");
|
|
assert.equal(p.chapterNum, 2334);
|
|
assert.equal(p.chapterLabel, "Chapter 2334");
|
|
assert.equal(p.chapterUrl, url);
|
|
assert.equal(p.seriesUrl, "https://novelfull.com/reverend-insanity.html");
|
|
assert.equal(p.title, "Reverend Insanity");
|
|
});
|
|
|
|
test("novelfull.detect returns other for non-series paths", () => {
|
|
reset();
|
|
assert.equal(novelfull.detect(loc("https://novelfull.com/")).type, "other");
|
|
assert.equal(novelfull.detect(loc("https://novelfull.com/genre/Fantasy")).type, "other");
|
|
});
|
|
|
|
test("novelfull.latestChapterFromAnchors takes the max and ignores other series", () => {
|
|
const best = novelfull.latestChapterFromAnchors(
|
|
[
|
|
{ href: "/reverend-insanity/chapter-2334-fang-yuan.html", text: "Chapter 2334" },
|
|
{ href: "/reverend-insanity/chapter-1.html", text: "Chapter 1" },
|
|
{ href: "/reverend-insanity/chapter-2.html", text: "Chapter 2" },
|
|
{ href: "/release-that-witch/chapter-9999.html", text: "Chapter 9999" },
|
|
],
|
|
"reverend-insanity"
|
|
);
|
|
assert.deepEqual(best, { num: 2334, label: "Chapter 2334" });
|
|
});
|
|
|
|
// ============================================================
|
|
// lightnovelworld adapter
|
|
// ============================================================
|
|
|
|
test("lightnovelworld.detect reads a series page", () => {
|
|
reset();
|
|
elements = { "h1.entry-title": "A Will Eternal" };
|
|
const p = lightnovelworld.detect(loc("https://lightnovelworld.net/novel/a-will-eternal/"));
|
|
assert.equal(p.type, "series");
|
|
assert.equal(p.site, "lightnovelworld");
|
|
assert.equal(p.seriesId, "a-will-eternal");
|
|
assert.equal(p.title, "A Will Eternal");
|
|
// A series page's address *is* its identity; there is no Chapter Slug.
|
|
assert.equal(p.chapterSlug, null);
|
|
});
|
|
|
|
test("lightnovelworld.detect strips the chapter suffix off the heading", () => {
|
|
reset();
|
|
elements = { "h1.entry-title": "A Will Eternal Chapter 1298" };
|
|
attrEls = {
|
|
"a[aria-label='All Chapter']": {
|
|
getAttribute: () => "https://lightnovelworld.net/novel/a-will-eternal/",
|
|
},
|
|
};
|
|
const url = "https://lightnovelworld.net/a-will-eternal-chapter-1298/";
|
|
const p = lightnovelworld.detect(loc(url));
|
|
assert.equal(p.type, "chapter");
|
|
assert.equal(p.seriesId, "a-will-eternal");
|
|
assert.equal(p.chapterNum, 1298);
|
|
assert.equal(p.chapterLabel, "Chapter 1298");
|
|
assert.equal(p.title, "A Will Eternal");
|
|
assert.equal(p.seriesUrl, "https://lightnovelworld.net/novel/a-will-eternal/");
|
|
});
|
|
|
|
test("lightnovelworld.detect reads the Series identity from the page pointer on a chapter page", () => {
|
|
reset();
|
|
elements = { "h1.entry-title": "A Will Eternal Chapter 1298" };
|
|
attrEls = {
|
|
// Verbatim from the real page (research note §3): the All Chapter anchor
|
|
// carries the absolute Series address.
|
|
"a[aria-label='All Chapter']": {
|
|
getAttribute: () => "https://lightnovelworld.net/novel/a-will-eternal/",
|
|
},
|
|
};
|
|
const url = "https://lightnovelworld.net/a-will-eternal-chapter-1298/";
|
|
const p = lightnovelworld.detect(loc(url));
|
|
assert.equal(p.type, "chapter");
|
|
assert.equal(p.seriesId, "a-will-eternal");
|
|
assert.equal(p.seriesUrl, "https://lightnovelworld.net/novel/a-will-eternal/");
|
|
assert.equal(p.chapterSlug, "a-will-eternal");
|
|
assert.equal(p.chapterNum, 1298);
|
|
});
|
|
|
|
test("lightnovelworld.detect falls back to the breadcrumb when the pointer is absent", () => {
|
|
reset();
|
|
elements = { "h1.entry-title": "My Longevity Simulation Chapter 1" };
|
|
attrEls = {
|
|
// Verbatim from the real page (research note §3): position 2 of the
|
|
// microdata BreadcrumbList is the Series.
|
|
'[itemtype="http://schema.org/BreadcrumbList"] a[itemprop="item"][href*="/novel/"]': {
|
|
getAttribute: () => "https://lightnovelworld.net/novel/immortality-simulator/",
|
|
},
|
|
};
|
|
const url = "https://lightnovelworld.net/my-longevity-simulation-chapter-1/";
|
|
const p = lightnovelworld.detect(loc(url));
|
|
assert.equal(p.type, "chapter");
|
|
assert.equal(p.seriesId, "immortality-simulator");
|
|
assert.equal(p.seriesUrl, "https://lightnovelworld.net/novel/immortality-simulator/");
|
|
assert.equal(p.chapterSlug, "my-longevity-simulation");
|
|
});
|
|
|
|
test("lightnovelworld.detect resolves to other when the page carries no pointer", () => {
|
|
reset();
|
|
elements = { "h1.entry-title": "My Longevity Simulation Chapter 1" };
|
|
const p = lightnovelworld.detect(
|
|
loc("https://lightnovelworld.net/my-longevity-simulation-chapter-1/")
|
|
);
|
|
assert.equal(p.type, "other");
|
|
});
|
|
|
|
test("lightnovelworld pins the divergent novel: the pointer's slug wins over the address's", () => {
|
|
// Regression pin for the derivation defect (research note §2/§3): the
|
|
// chapter address is built from "my-longevity-simulation" but the Series
|
|
// is published as "immortality-simulator". If the adapter ever derives the
|
|
// identity from the address again, this test goes red.
|
|
reset();
|
|
elements = { "h1.entry-title": "My Longevity Simulation Chapter 1" };
|
|
attrEls = {
|
|
"a[aria-label='All Chapter']": {
|
|
getAttribute: () => "https://lightnovelworld.net/novel/immortality-simulator/",
|
|
},
|
|
};
|
|
const url = "https://lightnovelworld.net/my-longevity-simulation-chapter-1/";
|
|
const p = lightnovelworld.detect(loc(url));
|
|
assert.equal(p.type, "chapter");
|
|
assert.equal(p.seriesId, "immortality-simulator");
|
|
assert.equal(p.seriesUrl, "https://lightnovelworld.net/novel/immortality-simulator/");
|
|
assert.equal(p.chapterSlug, "my-longevity-simulation");
|
|
assert.notEqual(p.chapterSlug, p.seriesId);
|
|
});
|
|
|
|
test("lightnovelworld.detect resolves a novel whose heading ends in a chapter number", () => {
|
|
// The split novel from research §4.4, chapter 200 (published under the
|
|
// current slug). Its heading ends "…Not Them All Chapter 200", which would
|
|
// false-match a selector that looks for the text "All Chapter".
|
|
reset();
|
|
elements = {
|
|
"h1.entry-title": "All Jobs and Classes I Just Wanted One Skill Not Them All Chapter 200",
|
|
};
|
|
attrEls = {
|
|
"a[aria-label='All Chapter']": {
|
|
getAttribute: () =>
|
|
"https://lightnovelworld.net/novel/all-jobs-and-classes-i-just-wanted-one-skill-not-them-all/",
|
|
},
|
|
};
|
|
const url =
|
|
"https://lightnovelworld.net/all-jobs-and-classes-i-just-wanted-one-skill-not-them-all-chapter-200/";
|
|
const p = lightnovelworld.detect(loc(url));
|
|
assert.equal(p.type, "chapter");
|
|
assert.equal(p.seriesId, "all-jobs-and-classes-i-just-wanted-one-skill-not-them-all");
|
|
assert.equal(p.title, "All Jobs and Classes I Just Wanted One Skill Not Them All");
|
|
assert.equal(p.chapterNum, 200);
|
|
assert.equal(p.chapterSlug, "all-jobs-and-classes-i-just-wanted-one-skill-not-them-all");
|
|
});
|
|
|
|
test("lightnovelworld.detect resolves both Chapter Slugs of the split novel to one Series", () => {
|
|
// Research note §4.4: this Series serves chapters 1-99 under one Chapter
|
|
// Slug and 100-423 under another; both chapter addresses are live and both
|
|
// point back at the same Series. An old deep link must not get a different
|
|
// identity than a current one.
|
|
reset();
|
|
elements = { "h1.entry-title": "All Jobs and Classes I Just Wanted One Skill Not Chapter 1" };
|
|
attrEls = {
|
|
"a[aria-label='All Chapter']": {
|
|
getAttribute: () =>
|
|
"https://lightnovelworld.net/novel/all-jobs-and-classes-i-just-wanted-one-skill-not-them-all/",
|
|
},
|
|
};
|
|
const oldSlug = lightnovelworld.detect(
|
|
loc("https://lightnovelworld.net/all-jobs-and-classes-i-just-wanted-one-skill-not-chapter-1/")
|
|
);
|
|
const currentSlug = lightnovelworld.detect(
|
|
loc(
|
|
"https://lightnovelworld.net/all-jobs-and-classes-i-just-wanted-one-skill-not-them-all-chapter-404/"
|
|
)
|
|
);
|
|
assert.equal(oldSlug.type, "chapter");
|
|
assert.equal(oldSlug.seriesId, "all-jobs-and-classes-i-just-wanted-one-skill-not-them-all");
|
|
assert.equal(oldSlug.chapterSlug, "all-jobs-and-classes-i-just-wanted-one-skill-not");
|
|
assert.equal(currentSlug.type, "chapter");
|
|
assert.equal(currentSlug.seriesId, "all-jobs-and-classes-i-just-wanted-one-skill-not-them-all");
|
|
assert.equal(currentSlug.chapterSlug, "all-jobs-and-classes-i-just-wanted-one-skill-not-them-all");
|
|
});
|
|
|
|
test("lightnovelworld.detect returns other for non-series paths", () => {
|
|
reset();
|
|
assert.equal(lightnovelworld.detect(loc("https://lightnovelworld.net/")).type, "other");
|
|
assert.equal(lightnovelworld.detect(loc("https://lightnovelworld.net/az-lists/")).type, "other");
|
|
});
|
|
|
|
test("lightnovelworld.latestChapterFromAnchors takes the max and ignores other series", () => {
|
|
const best = lightnovelworld.latestChapterFromAnchors(
|
|
[
|
|
{ href: "https://lightnovelworld.net/a-will-eternal-chapter-1/", text: "Chapter 1" },
|
|
{ href: "https://lightnovelworld.net/a-will-eternal-chapter-1317/", text: "Chapter 1317" },
|
|
{ href: "https://lightnovelworld.net/a-will-eternal-chapter-1298/", text: "Chapter 1298" },
|
|
{ href: "https://lightnovelworld.net/overgeared-chapter-9999/", text: "Chapter 9999" },
|
|
],
|
|
"a-will-eternal"
|
|
);
|
|
assert.deepEqual(best, { num: 1317, label: "Chapter 1317" });
|
|
});
|
|
|
|
test("latestChapterFromAnchors returns null when nothing matches", () => {
|
|
assert.equal(novelfull.latestChapterFromAnchors([{ href: "/about", text: "About" }], "x"), null);
|
|
assert.equal(maxChapter([], /chapter-([0-9.]+)/), null);
|
|
});
|
|
|
|
// ============================================================
|
|
// kindOf
|
|
// ============================================================
|
|
|
|
test("kindOf defaults a missing kind to manga", () => {
|
|
assert.equal(kindOf({}), "manga");
|
|
});
|
|
|
|
test("kindOf passes through novel", () => {
|
|
assert.equal(kindOf({ kind: "novel" }), "novel");
|
|
});
|
|
|
|
// ============================================================
|
|
// Source guard
|
|
//
|
|
// Issue #74: the novel script was split off the manga one and lost four
|
|
// module-scope constants. The reads sit inside try/catch or a fire-and-forget
|
|
// promise, so the ReferenceError never surfaced — nothing but a static check
|
|
// catches this class.
|
|
// ============================================================
|
|
|
|
test("every SCREAMING_CASE constant the script uses is declared in it", () => {
|
|
const fs = require("node:fs");
|
|
for (const f of ["novel-bookmark.user.js", "manga-bookmark.user.js"]) {
|
|
const src = fs.readFileSync(require.resolve("../" + f), "utf8")
|
|
// comments and strings carry prose and SVG path data in the same shape
|
|
.replace(/\/\/[^\n]*|\/\*[\s\S]*?\*\/|"[^"\n]*"|'[^'\n]*'|`[\s\S]*?`/g, " ");
|
|
const declared = new Set(
|
|
[...src.matchAll(/\b(?:const|let|var|function)\s+([A-Z][A-Z0-9_]{2,})\b/g)].map((m) => m[1]),
|
|
);
|
|
for (const name of new Set(src.match(/\b[A-Z][A-Z0-9_]{2,}\b/g) || [])) {
|
|
if (name.startsWith("GM_") || name in globalThis) continue;
|
|
assert.ok(declared.has(name), `${f} uses ${name} but never declares it`);
|
|
}
|
|
}
|
|
});
|