1bf571736f
The new export is the seam the suite addresses behaviour through: for lightnovelworld no anchor set yields a Latest Chapter, comment-shaped anchors included, and for novelfull the scan still returns the maximum and ignores other series. The old lnw scan test asserted the deleted behaviour.
602 lines
24 KiB
JavaScript
602 lines
24 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,
|
|
computeLatestChapter,
|
|
kindOf,
|
|
maxChapter,
|
|
repairLnwStaleRow,
|
|
} = 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" };
|
|
const url = "https://lightnovelworld.net/my-longevity-simulation-chapter-1/";
|
|
// Same divergent page, both pointers from the real markup (research note
|
|
// §3): breadcrumb position 2 must say what the All Chapter anchor says.
|
|
attrEls = {
|
|
"a[aria-label='All Chapter']": {
|
|
getAttribute: () => "https://lightnovelworld.net/novel/immortality-simulator/",
|
|
},
|
|
};
|
|
const viaPointer = lightnovelworld.detect(loc(url));
|
|
attrEls = {
|
|
'[itemtype="http://schema.org/BreadcrumbList"] a[itemprop="item"][href*="/novel/"]': {
|
|
getAttribute: () => "https://lightnovelworld.net/novel/immortality-simulator/",
|
|
},
|
|
};
|
|
const viaBreadcrumb = lightnovelworld.detect(loc(url));
|
|
assert.equal(viaBreadcrumb.type, "chapter");
|
|
assert.equal(viaBreadcrumb.seriesId, viaPointer.seriesId);
|
|
assert.equal(viaBreadcrumb.seriesUrl, viaPointer.seriesUrl);
|
|
assert.equal(viaBreadcrumb.seriesId, "immortality-simulator");
|
|
assert.equal(viaBreadcrumb.chapterSlug, "my-longevity-simulation");
|
|
});
|
|
|
|
test("lightnovelworld.detect resolves a relative pointer against the page address", () => {
|
|
// Defensive: every measured page ships an absolute pointer href, but a
|
|
// theme change could go relative — the pointer is still this page's own
|
|
// link back to its Series.
|
|
reset();
|
|
elements = { "h1.entry-title": "A Will Eternal Chapter 1298" };
|
|
attrEls = {
|
|
"a[aria-label='All Chapter']": { getAttribute: () => "/novel/a-will-eternal/" },
|
|
};
|
|
const p = lightnovelworld.detect(loc("https://lightnovelworld.net/a-will-eternal-chapter-1298/"));
|
|
assert.equal(p.type, "chapter");
|
|
assert.equal(p.seriesId, "a-will-eternal");
|
|
assert.equal(p.seriesUrl, "https://lightnovelworld.net/novel/a-will-eternal/");
|
|
});
|
|
|
|
test("lightnovelworld.detect rejects a pointer that is not a /novel/ address on its own host", () => {
|
|
// Counterfactual pointers, exercising the criterion that a pointer "present
|
|
// but not parseable as /novel/<slug>/ on lightnovelworld.net" must not
|
|
// become a seriesUrl the backend is later asked to Poll: off-host, and
|
|
// on-host but the wrong path shape.
|
|
reset();
|
|
elements = { "h1.entry-title": "My Longevity Simulation Chapter 1" };
|
|
const url = "https://lightnovelworld.net/my-longevity-simulation-chapter-1/";
|
|
attrEls = {
|
|
"a[aria-label='All Chapter']": {
|
|
getAttribute: () => "https://evil.example/novel/immortality-simulator/",
|
|
},
|
|
};
|
|
assert.equal(lightnovelworld.detect(loc(url)).type, "other");
|
|
attrEls = {
|
|
"a[aria-label='All Chapter']": {
|
|
getAttribute: () => "https://lightnovelworld.net/fiction/immortality-simulator/",
|
|
},
|
|
};
|
|
assert.equal(lightnovelworld.detect(loc(url)).type, "other");
|
|
});
|
|
|
|
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("computeLatestChapter yields nothing for lightnovelworld, comment anchors included", () => {
|
|
// A realistic Series-page anchor set: current-slug chapters, a second
|
|
// Chapter Slug's chapters, and a wpdiscuz comment pasting a high-numbered
|
|
// chapter of another novel (the poisoning vector). The client never scans
|
|
// this Site — the Poll's cooldown dominates the client throttle and the
|
|
// comment thread is a public write surface — so this must fail the moment
|
|
// a scan is reintroduced, scoped or not.
|
|
const latest = computeLatestChapter(
|
|
"lightnovelworld",
|
|
[
|
|
{ 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" },
|
|
// a divergent novel's second Chapter Slug
|
|
{ href: "https://lightnovelworld.net/my-longevity-simulation-chapter-400/", text: "Chapter 400" },
|
|
// a wpdiscuz comment anchor
|
|
{ href: "https://lightnovelworld.net/overgeared-chapter-9999/", text: "Chapter 9999" },
|
|
],
|
|
"a-will-eternal"
|
|
);
|
|
assert.equal(latest, null);
|
|
});
|
|
|
|
test("computeLatestChapter still scans novelfull and ignores other series", () => {
|
|
const best = computeLatestChapter(
|
|
"novelfull",
|
|
[
|
|
{ href: "/reverend-insanity/chapter-2334-fang-yuan.html", text: "Chapter 2334" },
|
|
{ href: "/reverend-insanity/chapter-1.html", text: "Chapter 1" },
|
|
{ href: "/release-that-witch/chapter-9999.html", text: "Chapter 9999" },
|
|
],
|
|
"reverend-insanity"
|
|
);
|
|
assert.deepEqual(best, { num: 2334, label: "Chapter 2334" });
|
|
});
|
|
|
|
test("latestChapterFromAnchors returns null when nothing matches", () => {
|
|
assert.equal(novelfull.latestChapterFromAnchors([{ href: "/about", text: "About" }], "x"), null);
|
|
assert.equal(maxChapter([], /chapter-([0-9.]+)/), null);
|
|
});
|
|
|
|
// ============================================================
|
|
// repairLnwStaleRow — the row migration (spec seam 3)
|
|
// ============================================================
|
|
|
|
// A row as bookmarkCurrent builds it, keyed under the invented Chapter Slug
|
|
// identity the old adapter derived from the chapter address.
|
|
function staleRow(over) {
|
|
return Object.assign(
|
|
{
|
|
key: "lightnovelworld:my-longevity-simulation",
|
|
site: "lightnovelworld",
|
|
kind: "novel",
|
|
series_id: "my-longevity-simulation",
|
|
title: "My Longevity Simulation",
|
|
series_url: "https://lightnovelworld.net/novel/my-longevity-simulation/",
|
|
last_chapter: "Chapter 7",
|
|
last_chapter_num: 7,
|
|
last_chapter_url: "https://lightnovelworld.net/my-longevity-simulation-chapter-7/",
|
|
updated_at: 1000,
|
|
},
|
|
over
|
|
);
|
|
}
|
|
|
|
// The divergent novel from research §3: the address's slug is a Chapter Slug,
|
|
// the page pointer names the real Series.
|
|
function lnwPage(over) {
|
|
return Object.assign(
|
|
{
|
|
type: "chapter",
|
|
site: "lightnovelworld",
|
|
seriesId: "immortality-simulator",
|
|
chapterSlug: "my-longevity-simulation",
|
|
seriesUrl: "https://lightnovelworld.net/novel/immortality-simulator/",
|
|
},
|
|
over
|
|
);
|
|
}
|
|
|
|
test("repairLnwStaleRow rewrites the row, the queue entry and the last-checked map together", () => {
|
|
const list = [staleRow()];
|
|
const queue = [
|
|
{ key: "lightnovelworld:my-longevity-simulation", op: "put", sendStatus: false, attempts: 2 },
|
|
];
|
|
const lastChecked = { "lightnovelworld:my-longevity-simulation": 12345 };
|
|
const out = repairLnwStaleRow(list, queue, lastChecked, lnwPage());
|
|
assert.equal(out.list.length, 1);
|
|
assert.equal(out.list[0].key, "lightnovelworld:immortality-simulator");
|
|
assert.equal(out.list[0].series_id, "immortality-simulator");
|
|
assert.equal(out.list[0].series_url, "https://lightnovelworld.net/novel/immortality-simulator/");
|
|
assert.deepEqual(out.queue, [
|
|
{ key: "lightnovelworld:immortality-simulator", op: "put", sendStatus: false, attempts: 2 },
|
|
]);
|
|
assert.deepEqual(out.lastChecked, { "lightnovelworld:immortality-simulator": 12345 });
|
|
});
|
|
|
|
test("repairLnwStaleRow keeps Progress, Favourite and Lifecycle bucket on the rewritten row", () => {
|
|
const stale = staleRow({
|
|
last_chapter: "Chapter 42",
|
|
last_chapter_num: 42,
|
|
last_chapter_url: "https://lightnovelworld.net/my-longevity-simulation-chapter-42/",
|
|
favorite: true,
|
|
status: "archived",
|
|
updated_at: 777,
|
|
});
|
|
const out = repairLnwStaleRow([stale], [], {}, lnwPage());
|
|
const b = out.list[0];
|
|
assert.equal(b.last_chapter, "Chapter 42");
|
|
assert.equal(b.last_chapter_num, 42);
|
|
assert.equal(
|
|
b.last_chapter_url,
|
|
"https://lightnovelworld.net/my-longevity-simulation-chapter-42/"
|
|
);
|
|
assert.equal(b.favorite, true);
|
|
assert.equal(b.status, "archived");
|
|
assert.equal(b.updated_at, 777);
|
|
assert.equal(b.title, "My Longevity Simulation");
|
|
});
|
|
|
|
test("repairLnwStaleRow changes nothing when the Chapter Slug and Series slug agree", () => {
|
|
const list = [
|
|
staleRow({ key: "lightnovelworld:a-will-eternal", series_id: "a-will-eternal" }),
|
|
];
|
|
const queue = [{ key: "lightnovelworld:a-will-eternal", op: "put", sendStatus: true, attempts: 1 }];
|
|
const lastChecked = { "lightnovelworld:a-will-eternal": 99 };
|
|
const out = repairLnwStaleRow(
|
|
list,
|
|
queue,
|
|
lastChecked,
|
|
lnwPage({
|
|
chapterSlug: "a-will-eternal",
|
|
seriesId: "a-will-eternal",
|
|
seriesUrl: "https://lightnovelworld.net/novel/a-will-eternal/",
|
|
})
|
|
);
|
|
assert.equal(out.list, list); // same references, nothing rewritten
|
|
assert.equal(out.queue, queue);
|
|
assert.equal(out.lastChecked, lastChecked);
|
|
});
|
|
|
|
test("repairLnwStaleRow changes nothing when no row sits under the old key", () => {
|
|
const list = [
|
|
staleRow({
|
|
key: "lightnovelworld:immortality-simulator",
|
|
series_id: "immortality-simulator",
|
|
series_url: "https://lightnovelworld.net/novel/immortality-simulator/",
|
|
}),
|
|
];
|
|
const lastChecked = { "lightnovelworld:immortality-simulator": 99 };
|
|
const out = repairLnwStaleRow(list, [], lastChecked, lnwPage());
|
|
assert.equal(out.list, list);
|
|
assert.equal(out.queue.length, 0);
|
|
assert.equal(out.lastChecked, lastChecked);
|
|
});
|
|
|
|
test("repairLnwStaleRow ignores a page that carries no Chapter Slug", () => {
|
|
const list = [
|
|
staleRow({
|
|
key: "lightnovelworld:immortality-simulator",
|
|
series_id: "immortality-simulator",
|
|
}),
|
|
];
|
|
const lastChecked = { "lightnovelworld:immortality-simulator": 99 };
|
|
// A series page (chapterSlug null) and another site both stay untouched.
|
|
const series = repairLnwStaleRow(list, [], lastChecked, lnwPage({ chapterSlug: null }));
|
|
const other = repairLnwStaleRow(list, [], lastChecked, {
|
|
type: "chapter",
|
|
site: "novelfull",
|
|
seriesId: "x",
|
|
});
|
|
assert.equal(series.list, list);
|
|
assert.equal(other.list, list);
|
|
});
|
|
|
|
test("repairLnwStaleRow still moves the row and the last-checked map when the queue has no entry", () => {
|
|
const out = repairLnwStaleRow(
|
|
[staleRow()],
|
|
[],
|
|
{ "lightnovelworld:my-longevity-simulation": 5 },
|
|
lnwPage()
|
|
);
|
|
assert.equal(out.list[0].key, "lightnovelworld:immortality-simulator");
|
|
assert.deepEqual(out.queue, []);
|
|
assert.deepEqual(out.lastChecked, { "lightnovelworld:immortality-simulator": 5 });
|
|
});
|
|
|
|
test("repairLnwStaleRow carries a queued change to the repaired identity with its marker intact", () => {
|
|
const queue = [
|
|
{ key: "lightnovelworld:my-longevity-simulation", op: "put", sendStatus: true, attempts: 3 },
|
|
];
|
|
const out = repairLnwStaleRow([staleRow()], queue, {}, lnwPage());
|
|
assert.deepEqual(out.queue, [
|
|
{ key: "lightnovelworld:immortality-simulator", op: "put", sendStatus: true, attempts: 3 },
|
|
]);
|
|
});
|
|
|
|
test("repairLnwStaleRow merges a duplicate under the repaired key, keeping the farther-ahead progress", () => {
|
|
const canonical = staleRow({
|
|
key: "lightnovelworld:immortality-simulator",
|
|
series_id: "immortality-simulator",
|
|
series_url: "https://lightnovelworld.net/novel/immortality-simulator/",
|
|
last_chapter: "Chapter 5",
|
|
last_chapter_num: 5,
|
|
last_chapter_url: "https://lightnovelworld.net/immortality-simulator-chapter-5/",
|
|
favorite: false,
|
|
});
|
|
const stale = staleRow({
|
|
last_chapter: "Chapter 42",
|
|
last_chapter_num: 42,
|
|
last_chapter_url: "https://lightnovelworld.net/my-longevity-simulation-chapter-42/",
|
|
favorite: true,
|
|
status: "archived",
|
|
});
|
|
const queue = [
|
|
{ key: "lightnovelworld:my-longevity-simulation", op: "put", sendStatus: true, attempts: 3 },
|
|
{ key: "lightnovelworld:immortality-simulator", op: "put", sendStatus: false, attempts: 0 },
|
|
];
|
|
const out = repairLnwStaleRow([canonical, stale], queue, {}, lnwPage());
|
|
assert.equal(out.list.length, 1);
|
|
const merged = out.list[0];
|
|
assert.equal(merged.key, "lightnovelworld:immortality-simulator");
|
|
assert.equal(merged.last_chapter_num, 42); // the stale row is ahead — progress must not be lost
|
|
assert.equal(merged.favorite, true); // favourite survives from either row
|
|
assert.equal(merged.status, "archived"); // the stronger bucket survives
|
|
// One marker under the repaired key: sendStatus is sticky (the archive
|
|
// intent from the stale-key marker survives) and the worse attempts count
|
|
// wins — the queue's own coalescing rules.
|
|
assert.deepEqual(out.queue, [
|
|
{ key: "lightnovelworld:immortality-simulator", op: "put", sendStatus: true, attempts: 3 },
|
|
]);
|
|
});
|
|
|
|
test("repairLnwStaleRow does not regress progress when the repaired-key row is ahead", () => {
|
|
const canonical = staleRow({
|
|
key: "lightnovelworld:immortality-simulator",
|
|
series_id: "immortality-simulator",
|
|
series_url: "https://lightnovelworld.net/novel/immortality-simulator/",
|
|
last_chapter: "Chapter 100",
|
|
last_chapter_num: 100,
|
|
last_chapter_url: "https://lightnovelworld.net/immortality-simulator-chapter-100/",
|
|
});
|
|
const stale = staleRow({ last_chapter: "Chapter 42", last_chapter_num: 42 });
|
|
const out = repairLnwStaleRow([canonical, stale], [], {}, lnwPage());
|
|
assert.equal(out.list.length, 1);
|
|
assert.equal(out.list[0].last_chapter_num, 100);
|
|
});
|
|
|
|
// ============================================================
|
|
// 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`);
|
|
}
|
|
}
|
|
});
|