184 lines
6.3 KiB
JavaScript
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);
|