Leaner userscript: edge tab, card-click continue, test harness (#7)
Drops the Continue and Edit buttons, makes the card body the continue target, adds a confirm to Remove, replaces the view-blocking circular FAB with a 7x44 edge tab plus a two-finger long-press, and adds a zero-dependency node:test harness for the adapter logic. Fixes a latent offsetWidth/offsetHeight bug in the FAB placement math. No backend or API change. Verified live against real asurascans.com and demonicscans.org series and chapter pages: edge tab does not cover artwork, card-body click navigates, action buttons do not, Remove confirms and cancel preserves the row, the 25s dwell fill records progress, and the tab drags/snaps/clamps correctly. Known and accepted: a deliberately slow two-finger pinch that stays inside the 15px slop for the full 500ms opens the panel. A normal-speed pinch (240px in ~240ms) cancels well before the timer. An absolute per-finger drift threshold cannot separate a slow pinch from two fingers resting, which is the gesture being detected. Reviewed-on: #7 Co-authored-by: Sulthan Zaki <sultankiki05@gmail.com> Co-committed-by: Sulthan Zaki <sultankiki05@gmail.com>
This commit was merged in pull request #7.
This commit is contained in:
@@ -0,0 +1,186 @@
|
||||
"use strict";
|
||||
|
||||
const test = require("node:test");
|
||||
const assert = require("node:assert");
|
||||
|
||||
// ============================================================
|
||||
// Minimal browser stub
|
||||
//
|
||||
// manga-bookmark.user.js is a browser IIFE. At module scope it touches
|
||||
// localStorage (loadCache, loadQueue, the key-migration IIFE) and, on its very
|
||||
// last lines, document. Nothing else runs under test: document.body is left
|
||||
// UNDEFINED, so the boot block takes its `else` branch and waits for a
|
||||
// DOMContentLoaded that never fires. buildUI, init, and every network call stay
|
||||
// dormant with no further stubbing.
|
||||
// ============================================================
|
||||
|
||||
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),
|
||||
};
|
||||
|
||||
// location stub. The IIFE reads location.href during boot; set it to a dummy value.
|
||||
globalThis.location = {
|
||||
href: "about:blank",
|
||||
hostname: "",
|
||||
pathname: "/",
|
||||
origin: "",
|
||||
};
|
||||
|
||||
// og: meta tags the adapters read through meta(). Reassigned per test.
|
||||
let metaTags = {};
|
||||
globalThis.document = {
|
||||
querySelector(sel) {
|
||||
const m = sel.match(/^meta\[property="([^"]+)"\]$/);
|
||||
if (!m) return null;
|
||||
const v = metaTags[m[1]];
|
||||
return v == null ? null : { getAttribute: () => v };
|
||||
},
|
||||
addEventListener() {},
|
||||
body: undefined,
|
||||
};
|
||||
|
||||
const {
|
||||
stripBuildHash,
|
||||
asura,
|
||||
demonic,
|
||||
anchorsFromHTML,
|
||||
statusOf,
|
||||
} = require("../manga-bookmark.user.js");
|
||||
|
||||
// detect() reads only these four properties off location.
|
||||
function loc(href) {
|
||||
const u = new URL(href);
|
||||
return { pathname: u.pathname, origin: u.origin, href: u.href, hostname: u.hostname };
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// stripBuildHash — Asura's slug suffix rotates on every redeploy, so it must
|
||||
// not be part of the series identity. Must stay in sync with asuraBuildHash in
|
||||
// backend/store.go.
|
||||
// ============================================================
|
||||
|
||||
test("stripBuildHash removes a trailing 8-hex suffix", () => {
|
||||
assert.equal(stripBuildHash("solo-leveling-059befe1"), "solo-leveling");
|
||||
});
|
||||
|
||||
test("stripBuildHash leaves a slug with no suffix alone", () => {
|
||||
assert.equal(stripBuildHash("solo-leveling"), "solo-leveling");
|
||||
});
|
||||
|
||||
test("stripBuildHash ignores suffixes that are not exactly 8 hex chars", () => {
|
||||
assert.equal(stripBuildHash("solo-leveling-abc1234"), "solo-leveling-abc1234");
|
||||
assert.equal(stripBuildHash("solo-leveling-059befe1a"), "solo-leveling-059befe1a");
|
||||
assert.equal(stripBuildHash("solo-leveling-059befeg1"), "solo-leveling-059befeg1");
|
||||
});
|
||||
|
||||
// ============================================================
|
||||
// Asura adapter
|
||||
// ============================================================
|
||||
|
||||
test("asura.detect reads a series page, stripping the hash from the id only", () => {
|
||||
metaTags = { "og:title": "Solo Leveling | Asura Scans", "og:image": "https://cdn.example/x.jpg" };
|
||||
const p = asura.detect(loc("https://asurascans.com/comics/solo-leveling-059befe1"));
|
||||
assert.equal(p.type, "series");
|
||||
assert.equal(p.site, "asura");
|
||||
assert.equal(p.seriesId, "solo-leveling");
|
||||
// seriesUrl keeps the hash: navigation needs the current one (stale ones 302).
|
||||
assert.equal(p.seriesUrl, "https://asurascans.com/comics/solo-leveling-059befe1");
|
||||
assert.equal(p.title, "Solo Leveling");
|
||||
assert.equal(p.cover, "https://cdn.example/x.jpg");
|
||||
assert.equal(p.chapterNum, null);
|
||||
});
|
||||
|
||||
test("asura.detect reads a chapter page including a decimal number", () => {
|
||||
metaTags = { "og:title": "Solo Leveling Chapter 12.5 - Read Online | Asura Scans", "og:image": "" };
|
||||
const url = "https://asurascans.com/comics/solo-leveling-059befe1/chapter/12.5";
|
||||
const p = asura.detect(loc(url));
|
||||
assert.equal(p.type, "chapter");
|
||||
assert.equal(p.seriesId, "solo-leveling");
|
||||
assert.equal(p.chapterNum, 12.5);
|
||||
assert.equal(p.chapterLabel, "Chapter 12.5");
|
||||
assert.equal(p.chapterUrl, url);
|
||||
assert.equal(p.title, "Solo Leveling");
|
||||
});
|
||||
|
||||
test("asura.detect returns other for non-series paths", () => {
|
||||
metaTags = {};
|
||||
assert.equal(asura.detect(loc("https://asurascans.com/")).type, "other");
|
||||
assert.equal(asura.detect(loc("https://asurascans.com/bookmarks")).type, "other");
|
||||
});
|
||||
|
||||
test("asura.latestChapterFromAnchors takes the highest and skips the First Chapter shortcut", () => {
|
||||
const best = asura.latestChapterFromAnchors([
|
||||
{ href: "/comics/solo-leveling-059befe1/chapter/1", text: "Chapter 1" },
|
||||
{ href: "/comics/solo-leveling-059befe1/chapter/10", text: "Chapter 10" },
|
||||
{ href: "/comics/solo-leveling-059befe1/chapter/2", text: "Chapter 2" },
|
||||
// Points at a chapter but is not a list entry — the text check excludes it.
|
||||
{ href: "/comics/solo-leveling-059befe1/chapter/1", text: "First Chapter" },
|
||||
]);
|
||||
assert.deepEqual(best, { num: 10, label: "Chapter 10" });
|
||||
});
|
||||
|
||||
test("asura.latestChapterFromAnchors returns null when nothing matches", () => {
|
||||
assert.equal(asura.latestChapterFromAnchors([{ href: "/about", text: "About" }]), null);
|
||||
});
|
||||
|
||||
// ============================================================
|
||||
// Demonic adapter
|
||||
// ============================================================
|
||||
|
||||
test("demonic.detect reads a series page", () => {
|
||||
metaTags = { "og:title": "The World After The Fall", "og:image": "https://cdn.example/y.jpg" };
|
||||
const p = demonic.detect(loc("https://demonicscans.org/manga/the-world-after-the-fall"));
|
||||
assert.equal(p.type, "series");
|
||||
assert.equal(p.site, "demonic");
|
||||
assert.equal(p.seriesId, "the-world-after-the-fall");
|
||||
assert.equal(p.title, "The World After The Fall");
|
||||
});
|
||||
|
||||
test("demonic.detect reads a chapter page and strips the suffix from the title", () => {
|
||||
metaTags = { "og:title": "The World After The Fall Chapter 3", "og:image": "" };
|
||||
const p = demonic.detect(loc("https://demonicscans.org/title/the-world-after-the-fall/chapter/3/1"));
|
||||
assert.equal(p.type, "chapter");
|
||||
assert.equal(p.chapterNum, 3);
|
||||
assert.equal(p.chapterLabel, "Chapter 3");
|
||||
assert.equal(p.seriesUrl, "https://demonicscans.org/manga/the-world-after-the-fall");
|
||||
assert.equal(p.title, "The World After The Fall");
|
||||
});
|
||||
|
||||
test("demonic percent-encoded slugs decode to the same id on /manga/ and /title/", () => {
|
||||
metaTags = {};
|
||||
const series = demonic.detect(loc("https://demonicscans.org/manga/i%2527m-the-max-level-newbie"));
|
||||
const chapter = demonic.detect(loc("https://demonicscans.org/title/i%2527m-the-max-level-newbie/chapter/3/1"));
|
||||
assert.equal(series.seriesId, chapter.seriesId);
|
||||
assert.equal(series.seriesId, "i%27m-the-max-level-newbie");
|
||||
});
|
||||
|
||||
test("demonic.latestChapterFromAnchors parses chaptered.php links, including &", () => {
|
||||
const best = demonic.latestChapterFromAnchors([
|
||||
{ href: "chaptered.php?manga=5&chapter=3", text: "Chapter 3" },
|
||||
{ href: "chaptered.php?manga=5&chapter=12", text: "Chapter 12" },
|
||||
{ href: "/manga/something", text: "Series" },
|
||||
]);
|
||||
assert.deepEqual(best, { num: 12, label: "Chapter 12" });
|
||||
});
|
||||
|
||||
// ============================================================
|
||||
// Shared helpers
|
||||
// ============================================================
|
||||
|
||||
test("anchorsFromHTML pulls href/text pairs and flattens nested tags", () => {
|
||||
const out = anchorsFromHTML('<a href="/a">One</a><a class="x" href="/b"><span>Two</span> more</a>');
|
||||
assert.equal(out.length, 2);
|
||||
assert.deepEqual(out[0], { href: "/a", text: "One" });
|
||||
assert.equal(out[1].href, "/b");
|
||||
assert.equal(out[1].text.replace(/\s+/g, " ").trim(), "Two more");
|
||||
});
|
||||
|
||||
test("statusOf defaults a missing status to reading", () => {
|
||||
assert.equal(statusOf({}), "reading");
|
||||
assert.equal(statusOf({ status: "" }), "reading");
|
||||
assert.equal(statusOf({ status: "archived" }), "archived");
|
||||
assert.equal(statusOf({ status: "finished" }), "finished");
|
||||
});
|
||||
Reference in New Issue
Block a user