129 lines
5.1 KiB
JavaScript
129 lines
5.1 KiB
JavaScript
import assert from "node:assert/strict";
|
|||
|
|
import { test, after } from "node:test";
|
||
|
|
import { stripTypeScriptTypes } from "node:module";
|
||
|
|
import { readFile, mkdtemp, writeFile, rm } from "node:fs/promises";
|
||
|
|
import { tmpdir } from "node:os";
|
||
|
|
import { join } from "node:path";
|
||
|
|
import { pathToFileURL } from "node:url";
|
||
|
|
const directory = await mkdtemp(join(tmpdir(), "gutenberg-test-"));
|
||
|
|
for (const name of ["catalog", "download"]) {
|
||
|
|
const source = await readFile(new URL("../src/" + name + ".ts", import.meta.url), "utf8");
|
||
|
|
await writeFile(
|
||
|
|
join(directory, name + ".mjs"),
|
||
|
|
stripTypeScriptTypes(source).replace("./catalog.js", "./catalog.mjs"),
|
||
|
|
);
|
||
|
|
}
|
||
|
|
after(() => rm(directory, { recursive: true, force: true }));
|
||
|
|
const { parseIndex, searchIndex, openingExcerpt } = await import(pathToFileURL(join(directory, "catalog.mjs")));
|
||
|
|
const { createBookLoader, bookURL, fetchBook } = await import(pathToFileURL(join(directory, "download.mjs")));
|
||
|
|
test("CSV preserves quoted/multiline titles and selects text editions", () => {
|
||
|
|
const books = parseIndex(
|
||
|
|
'Text#,Type,Title,Language,Authors\r\n1342,Text,"Pride, and\r\nPrejudice",en,"Austen, Jane"\r\n11,Text,"Alice and ""friends""",en,Carroll\r\n12,Sound,Audio,en,Someone\r\n',
|
||
|
|
);
|
||
|
|
assert.deepEqual(books, [
|
||
|
|
{ ebook: 1342, title: "Pride, and Prejudice", language: "en", author: "Austen, Jane" },
|
||
|
|
{ ebook: 11, title: 'Alice and "friends"', language: "en", author: "Carroll" },
|
||
|
|
]);
|
||
|
|
assert.throws(() => parseIndex('Text#,Type,Title,Language,Authors\n1,Text,"broken'), /Unclosed/);
|
||
|
|
});
|
||
|
|
test("fuzzy titles and diacritics work, with a bounded result list", () => {
|
||
|
|
const books = [
|
||
|
|
{ ebook: 1342, title: "Pride and Prejudice", author: "Austen, Jane", language: "en" },
|
||
|
|
{ ebook: 11, title: "Alice in Wonderland", author: "Carroll", language: "en" },
|
||
|
|
...Array.from({ length: 40 }, (_, i) => ({
|
||
|
|
ebook: 100 + i,
|
||
|
|
title: "Émile " + i,
|
||
|
|
author: "Rousseau",
|
||
|
|
language: "fr",
|
||
|
|
})),
|
||
|
|
];
|
||
|
|
const search = searchIndex(books);
|
||
|
|
assert.equal(search("prde and prejudi").at(0).ebook, 1342);
|
||
|
|
assert.equal(search("austen").at(0).ebook, 1342);
|
||
|
|
assert.equal(search("emile").length, 20);
|
||
|
|
assert.deepEqual(search("a"), []);
|
||
|
|
});
|
||
|
|
test("opening excerpt keeps paragraph boundaries and excludes Gutenberg framing", () => {
|
||
|
|
const body =
|
||
|
|
"license\n*** START OF THE PROJECT GUTENBERG EBOOK TEST ***\n\nFirst\nline\n\nSecond\n\n*** END OF THE PROJECT GUTENBERG EBOOK TEST ***\nlicense";
|
||
|
|
assert.deepEqual(openingExcerpt(body), { text: "First\nline\n\nSecond", words: 3 });
|
||
|
|
const first = "word ".repeat(600).trim(),
|
||
|
|
second = "more ".repeat(450).trim();
|
||
|
|
const text = "*** START OF THE PROJECT GUTENBERG EBOOK TEST ***\n\n" + first + "\n\n" + second;
|
||
|
|
assert.equal(openingExcerpt(text), null, "Wait for the whole final paragraph");
|
||
|
|
assert.deepEqual(openingExcerpt(text + "\n\nignored"), { text: first + "\n\n" + second, words: 1050 });
|
||
|
|
assert.deepEqual(openingExcerpt("short book", true), { text: "short book", words: 2 });
|
||
|
|
const long = "😀".repeat(30000);
|
||
|
|
const bounded = openingExcerpt(long, true);
|
||
|
|
assert(bounded.text.length <= 56000);
|
||
|
|
assert(!/[\uD800-\uDBFF]$/.test(bounded.text));
|
||
|
|
});
|
||
|
|
test("streaming fetch stops at the excerpt and releases the remaining download", async () => {
|
||
|
|
const original = globalThis.fetch;
|
||
|
|
let cancelled = false,
|
||
|
|
reads = 0;
|
||
|
|
const chunks = [
|
||
|
|
"licence\n*** START OF THE PROJECT GUTEN",
|
||
|
|
"BERG EBOOK TEST ***\n\n",
|
||
|
|
"word ".repeat(600) + "\n\n",
|
||
|
|
"more ".repeat(450) + "\n\n",
|
||
|
|
"unused rest of the book",
|
||
|
|
];
|
||
|
|
globalThis.fetch = async () =>
|
||
|
|
new Response(
|
||
|
|
new ReadableStream(
|
||
|
|
{
|
||
|
|
pull(controller) {
|
||
|
|
reads++;
|
||
|
|
const chunk = chunks.shift();
|
||
|
|
if (chunk) controller.enqueue(new TextEncoder().encode(chunk));
|
||
|
|
else controller.close();
|
||
|
|
},
|
||
|
|
cancel() {
|
||
|
|
cancelled = true;
|
||
|
|
},
|
||
|
|
},
|
||
|
|
{ highWaterMark: 0 },
|
||
|
|
),
|
||
|
|
);
|
||
|
|
try {
|
||
|
|
const text = await fetchBook(1342);
|
||
|
|
assert.equal(reads, 4);
|
||
|
|
assert.equal(cancelled, true);
|
||
|
|
assert.equal(openingExcerpt(text, true).words, 1050);
|
||
|
|
assert(!text.includes("unused"));
|
||
|
|
} finally {
|
||
|
|
globalThis.fetch = original;
|
||
|
|
}
|
||
|
|
});
|
||
|
|
test("downloads coalesce, cache, serialize, and permit retries after failure", async () => {
|
||
|
|
let calls = 0,
|
||
|
|
active = 0,
|
||
|
|
maximum = 0,
|
||
|
|
fail = true;
|
||
|
|
const load = createBookLoader(async (ebook) => {
|
||
|
|
calls++;
|
||
|
|
active++;
|
||
|
|
maximum = Math.max(maximum, active);
|
||
|
|
await new Promise((r) => setTimeout(r, 2));
|
||
|
|
active--;
|
||
|
|
if (ebook === 3 && fail) {
|
||
|
|
fail = false;
|
||
|
|
throw Error("offline");
|
||
|
|
}
|
||
|
|
return "Book " + ebook;
|
||
|
|
}, 1);
|
||
|
|
assert.deepEqual(await Promise.all([load(1), load(1), load(2)]), [
|
||
|
|
{ text: "Book 1", words: 2 },
|
||
|
|
{ text: "Book 1", words: 2 },
|
||
|
|
{ text: "Book 2", words: 2 },
|
||
|
|
]);
|
||
|
|
assert.equal(calls, 2);
|
||
|
|
assert.equal(maximum, 1);
|
||
|
|
await load(1);
|
||
|
|
assert.equal(calls, 2);
|
||
|
|
await assert.rejects(load(3), /offline/);
|
||
|
|
assert.deepEqual(await load(3), { text: "Book 3", words: 2 });
|
||
|
|
assert.equal(bookURL(1342), "https://gutenberg.pglaf.org/cache/epub/1342/pg1342.txt");
|
||
|
|
});
|