632 lines
26 KiB
JavaScript
632 lines
26 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,
|
|
finishedLabel,
|
|
} = 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);
|
|
});
|
|
|
|
// ============================================================
|
|
// Merge rank — exactly two values: archived beats reading, and nothing else
|
|
// can ever win (issue #157 retired the finished bucket).
|
|
// ============================================================
|
|
|
|
test("repairLnwStaleRow merge keeps archived over reading and lets no third value win", () => {
|
|
const canonical = (over) => staleRow(Object.assign({ key: "lightnovelworld:immortality-simulator" }, over));
|
|
// archived beats reading whether it arrives as the stale row or the repaired one
|
|
let out = repairLnwStaleRow([canonical({ status: "archived" }), staleRow({ status: "reading" })], [], {}, lnwPage());
|
|
assert.equal(out.list[0].status, "archived");
|
|
out = repairLnwStaleRow([staleRow({ status: "archived" }), canonical({ status: "reading" })], [], {}, lnwPage());
|
|
assert.equal(out.list[0].status, "archived");
|
|
// "finished" is not a bucket anymore: a row carrying it reads as the
|
|
// default, so it can never win (issue #157).
|
|
out = repairLnwStaleRow([staleRow({ status: "finished" }), canonical({ status: "reading" })], [], {}, lnwPage());
|
|
assert.equal(out.list[0].status, "reading");
|
|
});
|
|
// ============================================================
|
|
// kindOf
|
|
// ============================================================
|
|
|
|
test("kindOf defaults a missing kind to manga", () => {
|
|
assert.equal(kindOf({}), "manga");
|
|
});
|
|
|
|
test("kindOf passes through novel", () => {
|
|
assert.equal(kindOf({ kind: "novel" }), "novel");
|
|
});
|
|
|
|
// ============================================================
|
|
// finishedLabel — finished is a fact about the Series (issue #157), which is
|
|
// the server's business; the panel only names it. The label is text through
|
|
// the el() helper, never markup.
|
|
// ============================================================
|
|
|
|
test("finishedLabel names a finished series and nothing else", () => {
|
|
assert.equal(finishedLabel({ finished: true }), "Finished");
|
|
assert.equal(finishedLabel({ finished: false }), "");
|
|
assert.equal(finishedLabel({}), "");
|
|
});
|
|
|
|
// ============================================================
|
|
// 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`);
|
|
}
|
|
}
|
|
});
|