Folio/tests/run-fullbook-test.mjs

184 lines
6.3 KiB
JavaScript

// Runner for the native Full Book test. Builds a jsdom DOM, bundles the
// renderer assembly + Tiptap modules, and verifies that a book assembled from
// native chapters renders with correct chapter order, page-break anchors,
// reorder behavior, title injection, and Copy-All native fidelity.
import fs from "fs";
import path from "path";
import { JSDOM } from "jsdom";
import { bundleForTest } from "./helpers/bundle.mjs";
const dom = new JSDOM("<!DOCTYPE html><html><body></body></html>", {
pretendToBeVisual: true,
url: "http://localhost/",
});
const { window } = dom;
const g = globalThis;
g.window = window;
g.document = window.document;
g.HTMLElement = window.HTMLElement;
g.Element = window.Element;
g.Node = window.Node;
g.MutationObserver = window.MutationObserver;
g.getComputedStyle = window.getComputedStyle.bind(window);
g.DOMParser = window.DOMParser;
g.requestAnimationFrame = (cb) => setTimeout(cb, 0);
g.cancelAnimationFrame = (id) => clearTimeout(id);
const { mod, cleanupBundle } = await bundleForTest({
name: "folio-fullbook",
entryPoints: ["tests/.folio-fullbook-test-entry.ts"],
});
const {
buildFullBookDoc,
nativeNodeText,
createTiptapEditor,
setEditorDoc,
getEditorDoc,
ChapterBreak,
CURRENT_DOC_SCHEMA_VERSION,
} = mod;
let failures = 0;
function ok(cond, msg) {
if (cond) console.log(" ok -", msg);
else {
failures++;
console.error("FAIL -", msg);
}
}
function findNodes(node, type, acc = []) {
if (!node || typeof node !== "object") return acc;
if (node.type === type) acc.push(node);
for (const c of node.content ?? []) findNodes(c, type, acc);
return acc;
}
// A native chapter doc with a single paragraph of the given text.
function paraDoc(text) {
return {
type: "doc",
content: [{ type: "paragraph", content: [{ type: "text", text }] }],
};
}
function headingDoc(text) {
return {
type: "doc",
content: [{ type: "heading", attrs: { level: 1 }, content: [{ type: "text", text }] }],
};
}
function makeFullBookEditor() {
const host = window.document.createElement("div");
window.document.body.appendChild(host);
return createTiptapEditor(host, { onChange: () => undefined }, { editable: false, extensions: [ChapterBreak] });
}
// --- canonical chapter order / navigation anchors ---
const chapters = [
{ id: "c1", title: "One", markdown: "Body one", doc: paraDoc("Body one") },
{ id: "c2", title: "Two", markdown: "Body two", doc: paraDoc("Body two") },
{ id: "c3", title: "Three", markdown: "Body three", doc: paraDoc("Body three") },
];
const assembled = buildFullBookDoc(chapters);
ok(assembled.chapterEntries.length === 3, "three chapter entries produced");
ok(
assembled.chapterEntries.map((c) => c.id).join(",") === "c1,c2,c3",
"chapter order follows canonical chapterOrder"
);
ok(
findNodes(assembled.nativeDoc, "chapterBreak").length === 3,
"one chapterBreak anchor per chapter"
);
ok(
findNodes(assembled.nativeDoc, "chapterBreak")[0].attrs.first === true,
"first chapter break is marked first"
);
ok(
findNodes(assembled.nativeDoc, "chapterBreak").slice(1).every((n) => n.attrs.first === false),
"subsequent chapter breaks are not first (page breaks)"
);
// Render into the read-only editor and check DOM anchors in order.
const editor = makeFullBookEditor();
setEditorDoc(editor, assembled.nativeDoc);
const anchors = Array.from(editor.view.dom.querySelectorAll("[data-chapter-id]"));
ok(anchors.length === 3, "three DOM anchors rendered");
ok(
anchors.map((a) => a.getAttribute("data-chapter-id")).join(",") === "c1,c2,c3",
"DOM anchors preserve canonical order"
);
ok(anchors[0].classList.contains("first"), "first anchor carries first class");
ok(
anchors.slice(1).every((a) => !a.classList.contains("first")),
"subsequent anchors are plain chapter-break separators"
);
// --- reorder impact on Full Book output ---
const reordered = buildFullBookDoc([chapters[1], chapters[0], chapters[2]]);
const reAnchors = Array.from(
(() => {
const e = makeFullBookEditor();
setEditorDoc(e, reordered.nativeDoc);
return e.view.dom;
})().querySelectorAll("[data-chapter-id]")
);
ok(
reAnchors.map((a) => a.getAttribute("data-chapter-id")).join(",") === "c2,c1,c3",
"reordering chapterOrder reorders Full Book output"
);
// --- title injection fidelity ---
const withHeading = buildFullBookDoc([
{ id: "h1", title: "Title", markdown: "# Title\nrest", doc: headingDoc("Title") },
]);
ok(
findNodes(withHeading.nativeDoc, "heading").length === 1,
"chapter already starting with matching heading is not duplicated"
);
const withoutHeading = buildFullBookDoc([
{ id: "h2", title: "Title", markdown: "body", doc: paraDoc("body") },
]);
const heads = findNodes(withoutHeading.nativeDoc, "heading");
ok(heads.length === 1 && heads[0].attrs.level === 1, "missing title heading is injected as H1");
ok(
nativeNodeText(withoutHeading.nativeDoc).includes("Title"),
"injected title text appears in the assembled book"
);
// --- Copy All native fidelity (round trip) ---
const copyJson = JSON.stringify(getEditorDoc(editor));
const parsed = JSON.parse(copyJson);
const firstDoc = getEditorDoc(editor).doc;
ok(parsed.schemaVersion === CURRENT_DOC_SCHEMA_VERSION, "copied native doc has schema version");
ok(
findNodes(parsed.doc, "chapterBreak").length === 3,
"copied native doc preserves chapterBreak anchors"
);
const editor2 = makeFullBookEditor();
setEditorDoc(editor2, parsed.doc);
ok(
JSON.stringify(getEditorDoc(editor2).doc) === JSON.stringify(firstDoc),
"copied native doc reloads byte-identical to editor state"
);
ok(
nativeNodeText(parsed.doc).includes("Body one") &&
nativeNodeText(parsed.doc).includes("Body three"),
"copied native content preserves chapter text"
);
// --- word / char counts ---
const text = nativeNodeText(assembled.nativeDoc);
ok(text.includes("Body one") && text.includes("Body three"), "word/char source includes all chapters");
ok(text.split(/\s+/).filter(Boolean).length >= 3, "word count reflects chapter text");
// --- empty book ---
const empty = buildFullBookDoc([]);
ok(
empty.nativeDoc.type === "doc" && Array.isArray(empty.nativeDoc.content) && empty.nativeDoc.content.length === 0,
"empty book assembles to empty doc"
);
cleanupBundle();
console.log("\nFull Book tests: " + (failures === 0 ? "ALL PASSED" : failures + " FAILED"));
process.exit(failures === 0 ? 0 : 1);