688c24d44c
novel-bookmark.user.js was split out of manga-bookmark.user.js and lost four constants: CACHE_KEY, LASTCHECKED_KEY, LATEST_CHECK_THROTTLE_MS and LATEST_CHECK_BATCH. Every use sits behind a try/catch or a fire-and-forget promise, so the ReferenceErrors were swallowed: backgroundRefreshLatest() threw before its first fetch, and the local cache silently read empty and never persisted. The issue named the two throttle constants; the cache keys are the same lost lines and are fixed here too. Add a static guard over both scripts, since no behavioural test can see a swallowed ReferenceError.
193 lines
7.1 KiB
JavaScript
193 lines
7.1 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 = {};
|
|
globalThis.document = {
|
|
querySelector(sel) {
|
|
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 = {};
|
|
}
|
|
|
|
// ============================================================
|
|
// 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");
|
|
});
|
|
|
|
test("lightnovelworld.detect strips the chapter suffix off the heading", () => {
|
|
reset();
|
|
elements = { "h1.entry-title": "A Will Eternal Chapter 1298" };
|
|
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 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`);
|
|
}
|
|
}
|
|
});
|