Replace markdown editor with native Tiptap editor and add Home navigation
- Migrate renderer from md-editor/md-format to a Tiptap-based native editor (native-doc pipeline: native-doc, markdown-to-native, native-to-markdown, native-assembly, migration, backup) - Use pointer-event-based chapter reordering with grab/grabbing cursors to avoid the Wayland prohibited-drop cursor while preserving reordering - Add a persistent Home button that closes the open book and returns to the Folio library/welcome screen - Update app icons, package deps, theme styles, and tests
This commit is contained in:
parent
0df26d4ce6
commit
786a65ae2a
48 changed files with 5762 additions and 3999 deletions
9
tests/.folio-editor-test-entry.ts
Normal file
9
tests/.folio-editor-test-entry.ts
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
// Headless automated test for the Tiptap editor integration (Stage 3).
|
||||
// Verifies: editor command -> ProseMirror JSON state -> native persistence ->
|
||||
// reload round-trip, plus loading a Markdown-migrated document into the editor.
|
||||
//
|
||||
// Run with: node tests/run-editor-test.mjs
|
||||
export { createTiptapEditor, runCommand, getEditorDoc, setEditorDoc } from "../src/renderer/tiptap-editor.ts";
|
||||
export { setChapterDoc, getChapterDoc, parseChapter, CURRENT_DOC_SCHEMA_VERSION } from "../src/main/native-doc.ts";
|
||||
export { createBook, loadBook, saveMeta } from "../src/main/project.ts";
|
||||
export { markdownToNative } from "../src/main/markdown-to-native.ts";
|
||||
14
tests/.folio-fullbook-test-entry.ts
Normal file
14
tests/.folio-fullbook-test-entry.ts
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
// Headless test entry for the native Full Book assembly + rendering.
|
||||
// Run with: node tests/run-fullbook-test.mjs
|
||||
export {
|
||||
buildFullBookDoc,
|
||||
nativeNodeText,
|
||||
chapterHasOwnHeading,
|
||||
} from "../src/renderer/native-assembly.ts";
|
||||
export {
|
||||
createTiptapEditor,
|
||||
setEditorDoc,
|
||||
getEditorDoc,
|
||||
ChapterBreak,
|
||||
} from "../src/renderer/tiptap-editor.ts";
|
||||
export { CURRENT_DOC_SCHEMA_VERSION } from "../src/main/native-doc.ts";
|
||||
|
|
@ -1,18 +0,0 @@
|
|||
#!/usr/bin/env sh
|
||||
# End-to-end self-test runner (Linux, requires xvfb-run for headless).
|
||||
# Creates a throwaway book, opens it via folio:openBookPath (no dialogs),
|
||||
# runs the full chapter CRUD scenario through the real renderer -> IPC -> chapters
|
||||
# chain, prints a JSON summary, and quits. Inert unless run explicitly.
|
||||
set -e
|
||||
BOOK=$(node -e 'const fs=require("fs"),path=require("path"),os=require("os");const d=fs.mkdtempSync(path.join(os.tmpdir(),"folio-e2e-"));fs.mkdirSync(path.join(d,"chapters"),{recursive:true});fs.writeFileSync(path.join(d,"folio.json"),JSON.stringify({app:"folio",version:1,title:"E2E Book",chapterOrder:[],chapters:{}}));process.stdout.write(d);')
|
||||
BOOK2=$(node -e 'const fs=require("fs"),path=require("path"),os=require("os");const d=fs.mkdtempSync(path.join(os.tmpdir(),"folio-e2e2-"));fs.mkdirSync(path.join(d,"chapters"),{recursive:true});fs.writeFileSync(path.join(d,"folio.json"),JSON.stringify({app:"folio",version:1,title:"E2E Book 2",chapterOrder:[],chapters:{}}));process.stdout.write(d);')
|
||||
BOOK3=$(node -e 'const fs=require("fs"),path=require("path"),os=require("os");const d=fs.mkdtempSync(path.join(os.tmpdir(),"folio-e2e3-"));fs.mkdirSync(path.join(d,"chapters"),{recursive:true});const titles=["One","Two","Three","Four"];const chapters={};const order=[];titles.forEach((t,i)=>{const id="ch"+(i+1);const file=path.posix.join("chapters",id+".md");order.push(id);chapters[id]={id,title:t,file};const body=Array.from({length:40},(_,p)=>`Paragraph ${p} of chapter ${t}: the quick brown fox jumps over the lazy dog while the whole manuscript stays deterministic and long enough to scroll.`).join("\n\n");fs.writeFileSync(path.join(d,file),`# ${t}\n\n${body}`);});fs.writeFileSync(path.join(d,"folio.json"),JSON.stringify({app:"folio",version:1,title:"Nav Book",chapterOrder:order,chapters}));process.stdout.write(d);')
|
||||
export FOLIO_SELF_TEST=1
|
||||
export FOLIO_TEST_BOOK="$BOOK"
|
||||
export FOLIO_TEST_BOOK2="$BOOK2"
|
||||
export FOLIO_TEST_BOOK3="$BOOK3"
|
||||
export FOLIO_TEST_PDF="$BOOK/export-test.pdf"
|
||||
export FOLIO_TEST_DOCX="$BOOK/export-test.docx"
|
||||
export FOLIO_TEST_ODT="$BOOK/export-test.odt"
|
||||
trap 'rm -rf "$BOOK" "$BOOK2" "$BOOK3"' EXIT
|
||||
xvfb-run -a electron .
|
||||
223
tests/run-editor-test.mjs
Normal file
223
tests/run-editor-test.mjs
Normal file
|
|
@ -0,0 +1,223 @@
|
|||
// Runner for the Tiptap editor test. Sets up a jsdom DOM, then dynamically
|
||||
// imports the bundled editor module so Tiptap sees a browser-like environment.
|
||||
import esbuild from "esbuild";
|
||||
import fs from "fs";
|
||||
import os from "os";
|
||||
import path from "path";
|
||||
import url from "url";
|
||||
import { JSDOM } from "jsdom";
|
||||
|
||||
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 out = path.join(os.tmpdir(), `folio-editor-${process.pid}.mjs`);
|
||||
const entry = path.resolve("tests/.folio-editor-test-entry.ts");
|
||||
await esbuild.build({
|
||||
entryPoints: [entry],
|
||||
bundle: true,
|
||||
platform: "node",
|
||||
format: "esm",
|
||||
outfile: out,
|
||||
write: true,
|
||||
logLevel: "silent",
|
||||
});
|
||||
|
||||
const mod = await import(url.pathToFileURL(out).href);
|
||||
const {
|
||||
createTiptapEditor,
|
||||
runCommand,
|
||||
getEditorDoc,
|
||||
setEditorDoc,
|
||||
setChapterDoc,
|
||||
getChapterDoc,
|
||||
parseChapter,
|
||||
createBook,
|
||||
saveMeta,
|
||||
markdownToNative,
|
||||
} = 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;
|
||||
}
|
||||
function findMarks(node, type, acc = []) {
|
||||
for (const m of node.marks ?? []) if (m.type === type) acc.push(m);
|
||||
for (const c of node.content ?? []) findMarks(c, type, acc);
|
||||
return acc;
|
||||
}
|
||||
function allText(node, acc = "") {
|
||||
if (node.text) acc += node.text;
|
||||
for (const c of node.content ?? []) acc = allText(c, acc);
|
||||
return acc;
|
||||
}
|
||||
|
||||
function makeEditor() {
|
||||
const host = window.document.createElement("div");
|
||||
window.document.body.appendChild(host);
|
||||
return createTiptapEditor(host, {});
|
||||
}
|
||||
|
||||
const book = fs.mkdtempSync(path.join(os.tmpdir(), "folio-ed-"));
|
||||
const meta = createBook(book, "Editor Book");
|
||||
const id = "chap-1";
|
||||
meta.chapters[id] = { id, title: "Chapter One", file: `chapters/${id}.json` };
|
||||
meta.chapterOrder = [id];
|
||||
saveMeta(book, meta);
|
||||
|
||||
// --- editor command -> state (each feature on a fresh editor) ---
|
||||
function editorDocAfter(commands) {
|
||||
const ed = makeEditor();
|
||||
for (const c of commands) {
|
||||
if (typeof c === "function") c(ed);
|
||||
else runCommand(ed, c.cmd, c.payload);
|
||||
}
|
||||
const d = getEditorDoc(ed);
|
||||
return { ed, doc: d.doc };
|
||||
}
|
||||
|
||||
let r;
|
||||
r = editorDocAfter([{ cmd: "h2" }, (ed) => ed.commands.insertContent("Title")]);
|
||||
ok(r.doc.type === "doc", "editor state is a ProseMirror doc");
|
||||
ok(findNodes(r.doc, "heading").some((h) => h.attrs?.level === 2), "heading level 2 present");
|
||||
ok(allText(r.doc).includes("Title"), "inserted heading text present");
|
||||
|
||||
r = editorDocAfter([{ cmd: "bullet" }, (ed) => ed.commands.insertContent("item one")]);
|
||||
ok(findNodes(r.doc, "bulletList").length === 1, "bullet list present");
|
||||
ok(allText(r.doc).includes("item one"), "bullet list text present");
|
||||
|
||||
r = editorDocAfter([
|
||||
{ cmd: "bold" },
|
||||
(ed) => ed.commands.insertContent("bolded"),
|
||||
{ cmd: "color", payload: { color: "#ff0000" } },
|
||||
(ed) => ed.commands.insertContent("red"),
|
||||
]);
|
||||
ok(findMarks(r.doc, "bold").length >= 1, "bold mark present");
|
||||
ok(findMarks(r.doc, "textColor").some((m) => m.attrs?.color === "#ff0000"), "text color mark present");
|
||||
|
||||
r = editorDocAfter([
|
||||
{ cmd: "table", payload: { rows: 2, cols: 2 } },
|
||||
{ cmd: "image", payload: { src: "assets/x.png", alt: "x" } },
|
||||
]);
|
||||
ok(findNodes(r.doc, "table").length === 1, "table present");
|
||||
ok(findNodes(r.doc, "image").length === 1, "image present");
|
||||
ok(findNodes(r.doc, "image")[0]?.attrs?.src === "assets/x.png", "image src preserved");
|
||||
|
||||
// A richer document combining several features in one editor. Each block-level
|
||||
// feature is placed in its own block so toggling a list does not consume the
|
||||
// heading (which is the correct ProseMirror behavior).
|
||||
const editor = makeEditor();
|
||||
runCommand(editor, "h2");
|
||||
editor.commands.insertContent("Hello world");
|
||||
editor.commands.insertContent({ type: "paragraph" });
|
||||
runCommand(editor, "bold");
|
||||
editor.commands.insertContent("bolded");
|
||||
runCommand(editor, "color", { color: "#ff0000" });
|
||||
editor.commands.insertContent(" red");
|
||||
editor.commands.insertContent({ type: "paragraph" });
|
||||
runCommand(editor, "bullet");
|
||||
editor.commands.insertContent("list item");
|
||||
runCommand(editor, "table", { rows: 2, cols: 2 });
|
||||
runCommand(editor, "image", { src: "assets/x.png", alt: "x" });
|
||||
|
||||
const doc = getEditorDoc(editor);
|
||||
ok(allText(doc.doc).includes("Hello world"), "combined document has text");
|
||||
ok(findNodes(doc.doc, "heading").some((h) => h.attrs?.level === 2), "combined: heading present");
|
||||
ok(findMarks(doc.doc, "bold").length >= 1, "combined: bold present");
|
||||
ok(findNodes(doc.doc, "bulletList").length >= 1, "combined: bullet list present");
|
||||
ok(findMarks(doc.doc, "textColor").some((m) => m.attrs?.color === "#ff0000"), "combined: color present");
|
||||
ok(findNodes(doc.doc, "table").length === 1, "combined: table present");
|
||||
ok(findNodes(doc.doc, "image").length === 1, "combined: image present");
|
||||
|
||||
// --- state -> native persistence ---
|
||||
setChapterDoc(book, id, doc);
|
||||
const reloaded = getChapterDoc(book, id);
|
||||
ok(reloaded.doc.type === "doc", "persisted chapter reads back as doc");
|
||||
ok(
|
||||
JSON.stringify(reloaded.doc) === JSON.stringify(doc.doc),
|
||||
"reload is byte-identical to editor state"
|
||||
);
|
||||
|
||||
// --- reload -> editor (round trip through a fresh editor) ---
|
||||
const editor2 = makeEditor();
|
||||
setEditorDoc(editor2, reloaded.doc);
|
||||
const doc2 = getEditorDoc(editor2);
|
||||
ok(
|
||||
JSON.stringify(doc2.doc) === JSON.stringify(doc.doc),
|
||||
"second editor reproduces same state"
|
||||
);
|
||||
|
||||
// --- Markdown-migrated document loads into the editor ---
|
||||
const migrated = markdownToNative("# Title\n\nSome **strong** text and a [link](https://e.com).");
|
||||
const editor3 = makeEditor();
|
||||
setEditorDoc(editor3, migrated.doc);
|
||||
const doc3 = getEditorDoc(editor3);
|
||||
ok(findNodes(doc3.doc, "heading").some((h) => h.attrs?.level === 1), "migrated heading loads");
|
||||
ok(findMarks(doc3.doc, "bold").length === 1, "migrated bold loads");
|
||||
ok(findMarks(doc3.doc, "link").some((m) => m.attrs?.href === "https://e.com"), "migrated link loads");
|
||||
ok(allText(doc3.doc).includes("Title"), "migrated text loads");
|
||||
|
||||
// --- document containing a hard break loads (snake_case hard_break node) ---
|
||||
// Regression: StarterKit registers HardBreak as `hardBreak`; a doc with a
|
||||
// `hard_break` node (as produced by markdown-to-native and the export pipeline)
|
||||
// must load and render its text rather than throwing and leaving the editor empty.
|
||||
const withBreak = markdownToNative("Line one \nline two\n\nPara two");
|
||||
const editor4 = makeEditor();
|
||||
setEditorDoc(editor4, withBreak.doc);
|
||||
const doc4 = getEditorDoc(editor4);
|
||||
ok(editor4.getText().includes("Line one") && editor4.getText().includes("line two"),
|
||||
"hard_break document renders its text (not empty)");
|
||||
ok(findNodes(doc4.doc, "hard_break").length === 1, "hard_break node preserved on load");
|
||||
|
||||
// --- legacy camelCase hardBreak (from older editor saves) normalizes on read ---
|
||||
const camel = {
|
||||
schemaVersion: 1,
|
||||
doc: {
|
||||
type: "doc",
|
||||
content: [
|
||||
{ type: "paragraph", content: [
|
||||
{ type: "text", text: "alpha" },
|
||||
{ type: "hardBreak" },
|
||||
{ type: "text", text: "beta" },
|
||||
] },
|
||||
],
|
||||
},
|
||||
};
|
||||
const normalized = parseChapter(JSON.stringify(camel));
|
||||
ok(findNodes(normalized.doc, "hard_break").length === 1, "parseChapter renames hardBreak -> hard_break");
|
||||
const editor5 = makeEditor();
|
||||
setEditorDoc(editor5, normalized.doc);
|
||||
ok(editor5.getText().includes("alpha") && editor5.getText().includes("beta"),
|
||||
"legacy camelCase hardBreak chapter loads (not empty)");
|
||||
|
||||
fs.rmSync(out, { force: true });
|
||||
fs.rmSync(book, { recursive: true, force: true });
|
||||
|
||||
console.log(
|
||||
"\nEditor tests: " + (failures === 0 ? "ALL PASSED" : failures + " FAILED")
|
||||
);
|
||||
process.exit(failures === 0 ? 0 : 1);
|
||||
|
|
@ -1,471 +0,0 @@
|
|||
// Standalone test for the Markdown formatting commands (md-format.ts).
|
||||
// Bundles with esbuild and drives the commands through a plain
|
||||
// { state, dispatch } target, so no DOM is required.
|
||||
// Run with: node tests/run-format-test.mjs
|
||||
import esbuild from "esbuild";
|
||||
import fs from "fs";
|
||||
import os from "os";
|
||||
import path from "path";
|
||||
import url from "url";
|
||||
|
||||
// Write the bundle inside the repo so node can resolve the external
|
||||
// @codemirror/state peer dependency (same instance as the one imported below).
|
||||
const out = path.resolve(`tests/.tmp-folio-format-${process.pid}.mjs`);
|
||||
await esbuild.build({
|
||||
entryPoints: [path.resolve("src/renderer/md-format.ts")],
|
||||
bundle: true,
|
||||
platform: "node",
|
||||
format: "esm",
|
||||
external: ["@codemirror/state"],
|
||||
outfile: out,
|
||||
write: true,
|
||||
logLevel: "silent",
|
||||
});
|
||||
|
||||
const mod = await import(url.pathToFileURL(out).href);
|
||||
const {
|
||||
toggleBold,
|
||||
toggleItalic,
|
||||
toggleStrikethrough,
|
||||
toggleInlineCode,
|
||||
toggleCode,
|
||||
toggleLink,
|
||||
setHeading,
|
||||
toggleBulletList,
|
||||
toggleOrderedList,
|
||||
toggleBlockquote,
|
||||
insertHorizontalRule,
|
||||
clearFormatting,
|
||||
headingLevelAt,
|
||||
toggleUnderline,
|
||||
toggleHighlight,
|
||||
toggleTextColor,
|
||||
toggleHighlightColor,
|
||||
removeColor,
|
||||
removeHighlight,
|
||||
toggleChecklist,
|
||||
insertWikilink,
|
||||
insertImage,
|
||||
insertTable,
|
||||
insertCallout,
|
||||
toggleAlign,
|
||||
toggleFencedCode,
|
||||
detectFormatting,
|
||||
applyFormatPainter,
|
||||
} = mod;
|
||||
|
||||
// Build a minimal { state, dispatch } target around an EditorState.
|
||||
import { EditorState } from "@codemirror/state";
|
||||
|
||||
function target(doc, anchor, head = anchor) {
|
||||
let state = EditorState.create({
|
||||
doc,
|
||||
selection: { anchor, head },
|
||||
});
|
||||
const t = {
|
||||
get state() {
|
||||
return state;
|
||||
},
|
||||
dispatch(spec) {
|
||||
state = state.update(spec).state;
|
||||
return true;
|
||||
},
|
||||
};
|
||||
return {
|
||||
t,
|
||||
text: () => state.doc.toString(),
|
||||
sel: () => state.selection.main,
|
||||
select: (a, h = a) => (state = state.update({ selection: { anchor: a, head: h } }).state),
|
||||
};
|
||||
}
|
||||
|
||||
let failures = 0;
|
||||
function ok(cond, msg) {
|
||||
if (cond) console.log(" ok -", msg);
|
||||
else {
|
||||
failures++;
|
||||
console.error("FAIL -", msg);
|
||||
}
|
||||
}
|
||||
function eq(actual, expected, msg) {
|
||||
ok(actual === expected, `${msg} (got ${JSON.stringify(actual)}, want ${JSON.stringify(expected)})`);
|
||||
}
|
||||
|
||||
// ---- bold ----
|
||||
{
|
||||
const { t, text, sel } = target("say hello world", 4, 9); // "hello"
|
||||
toggleBold(t);
|
||||
eq(text(), "say **hello** world", "bold wraps selection");
|
||||
eq(sel().from, 6, "bold keeps selection on inner text (from)");
|
||||
eq(sel().to, 11, "bold keeps selection on inner text (to)");
|
||||
toggleBold(t); // cursor/selection still over inner text -> unwrap
|
||||
eq(text(), "say hello world", "bold toggles back off");
|
||||
}
|
||||
|
||||
// ---- bold with empty cursor ----
|
||||
{
|
||||
const { t, text, sel } = target("abc", 1);
|
||||
toggleBold(t);
|
||||
eq(text(), "a****bc", "bold on empty cursor inserts marker pair");
|
||||
eq(sel().from, 3, "bold cursor placed between markers");
|
||||
toggleBold(t);
|
||||
eq(text(), "abc", "bold on cursor toggles back off");
|
||||
}
|
||||
|
||||
// ---- bold unwrap when whole span selected ----
|
||||
{
|
||||
const { t, text, sel } = target("x **bold** y", 2, 10);
|
||||
toggleBold(t);
|
||||
eq(text(), "x bold y", "bold unwraps when selection includes markers");
|
||||
eq(sel().from, 2, "unwrapped selection anchor");
|
||||
eq(sel().to, 6, "unwrapped selection head");
|
||||
}
|
||||
|
||||
// ---- italic must not unwrap bold markers ----
|
||||
{
|
||||
const { t, text } = target("**bold**", 2, 6); // inside bold
|
||||
toggleItalic(t);
|
||||
eq(text(), "***bold***", "italic inside bold produces bold+italic, not a broken span");
|
||||
const { t: t2, text: text2 } = target("*em*", 1, 3); // inside italic
|
||||
toggleItalic(t2);
|
||||
eq(text2(), "em", "italic toggles off when already inside italic markers");
|
||||
const { t: t3, text: text3 } = target("*em*", 0, 4); // whole italic span
|
||||
toggleItalic(t3);
|
||||
eq(text3(), "em", "italic unwraps whole span");
|
||||
}
|
||||
|
||||
// ---- strikethrough ----
|
||||
{
|
||||
const { t, text, sel } = target("strike this text", 0, 6);
|
||||
toggleStrikethrough(t);
|
||||
eq(text(), "~~strike~~ this text", "strikethrough wraps selection");
|
||||
eq(sel().from, 2, "strikethrough selection from");
|
||||
toggleStrikethrough(t);
|
||||
eq(text(), "strike this text", "strikethrough toggles back off");
|
||||
}
|
||||
|
||||
// ---- inline code ----
|
||||
{
|
||||
const { t, text } = target("run `npm` and code here", 5, 8);
|
||||
toggleInlineCode(t);
|
||||
eq(text(), "run npm and code here", "inline code toggles off when already in a code span");
|
||||
const { t: t2, text: text2 } = target("var x = 1;", 0, 9);
|
||||
toggleInlineCode(t2);
|
||||
eq(text2(), "`var x = 1`;", "inline code wraps normal text");
|
||||
}
|
||||
|
||||
// ---- links ----
|
||||
{
|
||||
const { t, text, sel } = target("visit example", 6, 13);
|
||||
toggleLink(t);
|
||||
eq(text(), "visit [example](url)", "link wraps selection with url placeholder");
|
||||
eq(sel().from, 16, "link selects url placeholder (from)");
|
||||
eq(sel().to, 19, "link selects url placeholder (to)");
|
||||
}
|
||||
{
|
||||
const { t, text, sel } = target("ab", 1);
|
||||
toggleLink(t);
|
||||
eq(text(), "a[](url)b", "link on empty cursor inserts empty label");
|
||||
eq(sel().from, 2, "link cursor inside label brackets");
|
||||
}
|
||||
{
|
||||
const { t, text, sel } = target("see [docs](old-url) here", 4, 19);
|
||||
toggleLink(t);
|
||||
eq(text(), "see [docs](old-url) here", "link edit does not change text");
|
||||
eq(sel().from, 11, "link edit selects url (from)");
|
||||
eq(sel().to, 18, "link edit selects url (to)");
|
||||
}
|
||||
|
||||
// ---- code: inline vs fenced ----
|
||||
{
|
||||
const { t, text } = target("var x = 1;", 0, 9);
|
||||
toggleCode(t);
|
||||
eq(text(), "`var x = 1`;", "code on single line uses inline markers");
|
||||
}
|
||||
{
|
||||
const { t, text, select } = target("line one\nline two", 0, 16);
|
||||
toggleCode(t);
|
||||
eq(text(), "```\nline one\nline two\n```", "code on multiple lines uses a fenced block");
|
||||
select(0, 25);
|
||||
toggleCode(t);
|
||||
eq(text(), "line one\nline two", "fenced code toggles back off");
|
||||
}
|
||||
{
|
||||
const { t, text } = target("before\n```\ncode a\ncode b\n```\nafter", 7, 24);
|
||||
toggleCode(t);
|
||||
eq(text(), "before\ncode a\ncode b\nafter", "fenced code unwraps when selection starts at the fence");
|
||||
}
|
||||
{
|
||||
const { t, text } = target("abc", 1);
|
||||
toggleCode(t);
|
||||
eq(text(), "a``bc", "code on empty cursor inserts inline markers");
|
||||
}
|
||||
|
||||
// ---- headings ----
|
||||
{
|
||||
const { t, text } = target("Chapter one", 0);
|
||||
setHeading(2)(t);
|
||||
eq(text(), "## Chapter one", "setHeading adds level 2 marker");
|
||||
setHeading(3)(t);
|
||||
eq(text(), "### Chapter one", "setHeading changes existing level");
|
||||
setHeading(3)(t);
|
||||
eq(text(), "### Chapter one", "setHeading same level is a no-op");
|
||||
setHeading(0)(t);
|
||||
eq(text(), "Chapter one", "setHeading paragraph clears heading");
|
||||
}
|
||||
{
|
||||
const { t } = target("# Title", 3);
|
||||
ok(headingLevelAt(t.state, 3) === 1, "headingLevelAt detects level 1");
|
||||
const { t: t2 } = target("plain", 2);
|
||||
ok(headingLevelAt(t2.state, 2) === 0, "headingLevelAt returns 0 for plain line");
|
||||
}
|
||||
|
||||
// ---- bullet list ----
|
||||
{
|
||||
const { t, text } = target("one\ntwo\nthree", 0, 13);
|
||||
toggleBulletList(t);
|
||||
eq(text(), "- one\n- two\n- three", "bullet list prefixes each line");
|
||||
toggleBulletList(t);
|
||||
eq(text(), "one\ntwo\nthree", "bullet list toggles back off");
|
||||
}
|
||||
{
|
||||
const { t, text } = target("already listed", 0);
|
||||
toggleBulletList(t);
|
||||
eq(text(), "- already listed", "bullet list works with cursor only");
|
||||
}
|
||||
|
||||
// ---- numbered list ----
|
||||
{
|
||||
const { t, text } = target("one\ntwo", 0, 7);
|
||||
toggleOrderedList(t);
|
||||
eq(text(), "1. one\n2. two", "numbered list numbers each line");
|
||||
toggleOrderedList(t);
|
||||
eq(text(), "one\ntwo", "numbered list toggles back off");
|
||||
}
|
||||
|
||||
// ---- blockquote ----
|
||||
{
|
||||
const { t, text } = target("quoted words", 0);
|
||||
toggleBlockquote(t);
|
||||
eq(text(), "> quoted words", "blockquote prefixes line");
|
||||
toggleBlockquote(t);
|
||||
eq(text(), "quoted words", "blockquote toggles back off");
|
||||
}
|
||||
|
||||
// ---- horizontal rule ----
|
||||
{
|
||||
const { t, text, sel } = target("", 0);
|
||||
insertHorizontalRule(t);
|
||||
eq(text(), "---\n\n", "hr inserts at doc start");
|
||||
eq(sel().from, 5, "hr cursor after rule");
|
||||
}
|
||||
{
|
||||
const { t, text } = target("para\n\nnext", 5); // start of the empty line
|
||||
insertHorizontalRule(t);
|
||||
eq(text(), "para\n\n---\n\nnext", "hr inserts on empty line");
|
||||
}
|
||||
{
|
||||
const { t, text } = target("mid | line", 4);
|
||||
insertHorizontalRule(t);
|
||||
eq(text(), "mid \n\n---\n\n| line", "hr splits a line in the middle");
|
||||
}
|
||||
|
||||
// ---- clear formatting ----
|
||||
{
|
||||
const { t, text } = target("## **Bold** and ~~strike~~", 0, 26);
|
||||
clearFormatting(t);
|
||||
eq(text(), "Bold and strike", "clear formatting strips heading, bold, strike");
|
||||
}
|
||||
{
|
||||
const { t, text } = target("- item\n> quote\n1. thing\n`code` and [label](https://x.com)", 0, 57);
|
||||
clearFormatting(t);
|
||||
eq(text(), "item\nquote\nthing\ncode and label", "clear formatting handles lists, quote, code, links");
|
||||
}
|
||||
{
|
||||
const { t, text } = target("==hi== and <u>under</u> and <span style=\"color:#e11d48\">red</span>", 0, 64);
|
||||
clearFormatting(t);
|
||||
eq(text(), "hi and under and red", "clear formatting strips highlight, underline, color");
|
||||
}
|
||||
|
||||
// ---- underline ----
|
||||
{
|
||||
const { t, text, sel } = target("some text", 5, 9);
|
||||
toggleUnderline(t);
|
||||
eq(text(), "some <u>text</u>", "underline wraps selection");
|
||||
eq(sel().from, 8, "underline selection from");
|
||||
toggleUnderline(t);
|
||||
eq(text(), "some text", "underline toggles back off");
|
||||
}
|
||||
{
|
||||
const { t, text } = target("a <u>mid</u> b", 6); // cursor inside <u>…</u>
|
||||
toggleUnderline(t);
|
||||
eq(text(), "a mid b", "underline unwraps when cursor is inside");
|
||||
}
|
||||
{
|
||||
const { t, text, sel } = target("ab", 1);
|
||||
toggleUnderline(t);
|
||||
eq(text(), "a<u></u>b", "underline on empty cursor inserts tag pair");
|
||||
eq(sel().from, 4, "underline cursor between tags");
|
||||
}
|
||||
|
||||
// ---- highlight (Obsidian ==) ----
|
||||
{
|
||||
const { t, text } = target("mark this", 0, 4);
|
||||
toggleHighlight(t);
|
||||
eq(text(), "==mark== this", "highlight wraps selection");
|
||||
toggleHighlight(t);
|
||||
eq(text(), "mark this", "highlight toggles back off");
|
||||
}
|
||||
|
||||
// ---- text color ----
|
||||
{
|
||||
const { t, text, sel } = target("red text", 0, 3);
|
||||
toggleTextColor("#e11d48")(t);
|
||||
eq(text(), '<span style="color:#e11d48">red</span> text', "text color wraps selection");
|
||||
eq(sel().from, 28, "text color keeps selection on text");
|
||||
toggleTextColor("#e11d48")(t);
|
||||
eq(text(), "red text", "text color toggles off with same color");
|
||||
}
|
||||
{
|
||||
const { t, text } = target("change color", 0, 6);
|
||||
toggleTextColor("#e11d48")(t);
|
||||
toggleTextColor("#3b82f6")(t);
|
||||
eq(text(), '<span style="color:#3b82f6">change</span> color', "text color replaces existing color");
|
||||
}
|
||||
|
||||
// ---- highlight color ----
|
||||
{
|
||||
const { t, text } = target("mark this", 0, 4);
|
||||
toggleHighlightColor("#fde047")(t);
|
||||
eq(text(), '<span style="background-color:#fde047">mark</span> this', "highlight color wraps selection");
|
||||
removeHighlight(t);
|
||||
eq(text(), "mark this", "removeHighlight strips background color");
|
||||
}
|
||||
{
|
||||
const { t, text } = target("==plain==", 0, 8);
|
||||
removeHighlight(t);
|
||||
eq(text(), "plain", "removeHighlight strips Obsidian markers");
|
||||
}
|
||||
{
|
||||
const { t, text } = target("colored", 0, 7);
|
||||
toggleTextColor("#e11d48")(t);
|
||||
removeColor(t);
|
||||
eq(text(), "colored", "removeColor strips text color");
|
||||
}
|
||||
|
||||
// ---- checklist ----
|
||||
{
|
||||
const { t, text } = target("buy milk\nwrite note", 0, 19);
|
||||
toggleChecklist(t);
|
||||
eq(text(), "- [ ] buy milk\n- [ ] write note", "checklist prefixes lines");
|
||||
toggleChecklist(t);
|
||||
eq(text(), "- [x] buy milk\n- [x] write note", "checklist toggles to checked");
|
||||
toggleChecklist(t);
|
||||
eq(text(), "- [ ] buy milk\n- [ ] write note", "checklist toggles back unchecked");
|
||||
}
|
||||
{
|
||||
const { t, text } = target("1. numbered item", 0, 16);
|
||||
toggleChecklist(t);
|
||||
eq(text(), "- [ ] numbered item", "checklist converts numbered item");
|
||||
}
|
||||
|
||||
// ---- wikilink ----
|
||||
{
|
||||
const { t, text, sel } = target("see intro", 4, 9);
|
||||
insertWikilink("Introduction")(t);
|
||||
eq(text(), "see [[Introduction]]", "wikilink inserts target");
|
||||
eq(sel().from, 20, "wikilink cursor after insertion");
|
||||
}
|
||||
{
|
||||
const { t, text } = target("see intro", 4, 9);
|
||||
insertWikilink("Introduction", "intro")(t);
|
||||
eq(text(), "see [[Introduction|intro]]", "wikilink with alias label");
|
||||
}
|
||||
|
||||
// ---- image ----
|
||||
{
|
||||
const { t, text, sel } = target("before", 6);
|
||||
insertImage("alt", "https://x.com/a.png")(t);
|
||||
eq(text(), "before", "image inserts markdown");
|
||||
eq(sel().from, 13, "image selects url start");
|
||||
eq(sel().to, 32, "image selects url end");
|
||||
}
|
||||
|
||||
// ---- table ----
|
||||
{
|
||||
const { t, text } = target("para", 4);
|
||||
insertTable(3, 2)(t);
|
||||
eq(
|
||||
text(),
|
||||
"para\n| Col 1 | Col 2 |\n| --- | --- |\n| | |\n| | |\n",
|
||||
"table inserts header, separator, body rows"
|
||||
);
|
||||
}
|
||||
|
||||
// ---- callout ----
|
||||
{
|
||||
const { t, text } = target("body here", 0);
|
||||
insertCallout("note", "Tip")(t);
|
||||
eq(text(), "> [!note] Tip\n> body here", "callout inserts Obsidian blockquote syntax");
|
||||
}
|
||||
|
||||
// ---- alignment ----
|
||||
{
|
||||
const { t, text } = target("centered line", 0);
|
||||
toggleAlign("center")(t);
|
||||
eq(text(), '<div align="center">centered line</div>', "align wraps paragraph in HTML div");
|
||||
toggleAlign("center")(t);
|
||||
eq(text(), "centered line", "align toggles back off");
|
||||
toggleAlign("center")(t);
|
||||
toggleAlign("right")(t);
|
||||
eq(text(), '<div align="right">centered line</div>', "align replaces alignment");
|
||||
toggleAlign("left")(t);
|
||||
eq(text(), "centered line", "align left clears alignment");
|
||||
}
|
||||
|
||||
// ---- fenced code block ----
|
||||
{
|
||||
const { t, text } = target("single line", 0);
|
||||
toggleFencedCode(t);
|
||||
eq(text(), "```\nsingle line\n```", "code block wraps single line");
|
||||
toggleFencedCode(t);
|
||||
eq(text(), "single line", "code block toggles off");
|
||||
}
|
||||
|
||||
// ---- detectFormatting ----
|
||||
{
|
||||
const { t } = target("**bold** and *italic* and <u>under</u> and ==hl==", 6);
|
||||
const f = detectFormatting(t.state, 6);
|
||||
ok(f.bold === true, "detectFormatting finds bold");
|
||||
ok(f.italic === false, "detectFormatting bold not italic");
|
||||
}
|
||||
{
|
||||
const { t } = target("# Heading\nplain", 3);
|
||||
ok(detectFormatting(t.state, 3).heading === 1, "detectFormatting finds heading");
|
||||
}
|
||||
{
|
||||
const { t } = target("- [ ] task", 3);
|
||||
ok(detectFormatting(t.state, 3).list === "checklist", "detectFormatting finds checklist");
|
||||
}
|
||||
{
|
||||
const { t } = target("- item", 3);
|
||||
ok(detectFormatting(t.state, 3).list === "bullet", "detectFormatting finds bullet list");
|
||||
}
|
||||
{
|
||||
const { t } = target("1. item", 3);
|
||||
ok(detectFormatting(t.state, 3).list === "ordered", "detectFormatting finds ordered list");
|
||||
}
|
||||
|
||||
// ---- format painter ----
|
||||
{
|
||||
const { t, text } = target("**bold source** plain target", 0, 16);
|
||||
const fmt = { ...detectFormatting(t.state, 8), bold: true };
|
||||
const { t: t2, text: text2 } = target("**bold source** plain target", 16, 28);
|
||||
applyFormatPainter(t2, fmt);
|
||||
eq(text2(), "**bold source** **plain target**", "format painter applies bold");
|
||||
}
|
||||
|
||||
fs.rmSync(out, { force: true });
|
||||
|
||||
console.log("\nFormat tests: " + (failures === 0 ? "ALL PASSED" : failures + " FAILED"));
|
||||
process.exit(failures === 0 ? 0 : 1);
|
||||
195
tests/run-fullbook-test.mjs
Normal file
195
tests/run-fullbook-test.mjs
Normal file
|
|
@ -0,0 +1,195 @@
|
|||
// 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 esbuild from "esbuild";
|
||||
import fs from "fs";
|
||||
import os from "os";
|
||||
import path from "path";
|
||||
import url from "url";
|
||||
import { JSDOM } from "jsdom";
|
||||
|
||||
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 out = path.join(os.tmpdir(), `folio-fullbook-${process.pid}.mjs`);
|
||||
const entry = path.resolve("tests/.folio-fullbook-test-entry.ts");
|
||||
await esbuild.build({
|
||||
entryPoints: [entry],
|
||||
bundle: true,
|
||||
platform: "node",
|
||||
format: "esm",
|
||||
outfile: out,
|
||||
write: true,
|
||||
logLevel: "silent",
|
||||
});
|
||||
|
||||
const mod = await import(url.pathToFileURL(out).href);
|
||||
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"
|
||||
);
|
||||
|
||||
fs.rmSync(out, { force: true });
|
||||
console.log("\nFull Book tests: " + (failures === 0 ? "ALL PASSED" : failures + " FAILED"));
|
||||
process.exit(failures === 0 ? 0 : 1);
|
||||
303
tests/run-migration-test.mjs
Normal file
303
tests/run-migration-test.mjs
Normal file
|
|
@ -0,0 +1,303 @@
|
|||
// Standalone test for Stage 2 of the rich-text migration:
|
||||
// - native document model (Tiptap/ProseMirror JSON)
|
||||
// - Markdown -> native conversion (headings, bold, italic, strike, code,
|
||||
// lists, checklists, tables, images, links, wikilinks, callouts,
|
||||
// highlight, blockquote, hr)
|
||||
// - native persistence + schema versioning
|
||||
// - migration + external recovery backup
|
||||
// - idempotency (original .md untouched)
|
||||
// Builds src/main/migration.ts with esbuild (no Electron needed).
|
||||
// Run with: node tests/run-migration-test.mjs
|
||||
import esbuild from "esbuild";
|
||||
import fs from "fs";
|
||||
import os from "os";
|
||||
import path from "path";
|
||||
import url from "url";
|
||||
|
||||
const out = path.join(os.tmpdir(), `folio-migration-${process.pid}.mjs`);
|
||||
const entry = path.resolve(".folio-mig-test-entry.ts");
|
||||
fs.writeFileSync(
|
||||
entry,
|
||||
`
|
||||
export { migrateChapterToNative, migrateBookToNative, isNativeChapter } from "./src/main/migration.ts";
|
||||
export { createBook, loadBook, saveMeta } from "./src/main/project.ts";
|
||||
export { setChapterContent, getChapterContent } from "./src/main/chapters.ts";
|
||||
export { markdownToNative } from "./src/main/markdown-to-native.ts";
|
||||
export { parseChapter, serializeChapter, migrateDoc, getChapterDoc, nativeChapterFile, CURRENT_DOC_SCHEMA_VERSION, isValidDoc } from "./src/main/native-doc.ts";
|
||||
export { RECOVERY_ROOT, _resetSessionBackupGuard } from "./src/main/backup.ts";
|
||||
`
|
||||
);
|
||||
await esbuild.build({
|
||||
entryPoints: [entry],
|
||||
bundle: true,
|
||||
platform: "node",
|
||||
format: "esm",
|
||||
outfile: out,
|
||||
write: true,
|
||||
logLevel: "silent",
|
||||
});
|
||||
fs.rmSync(entry, { force: true });
|
||||
|
||||
const mod = await import(url.pathToFileURL(out).href);
|
||||
const {
|
||||
createBook,
|
||||
setChapterContent,
|
||||
getChapterContent,
|
||||
loadBook,
|
||||
saveMeta,
|
||||
migrateChapterToNative,
|
||||
migrateBookToNative,
|
||||
isNativeChapter,
|
||||
getChapterDoc,
|
||||
markdownToNative,
|
||||
parseChapter,
|
||||
serializeChapter,
|
||||
migrateDoc,
|
||||
CURRENT_DOC_SCHEMA_VERSION,
|
||||
RECOVERY_ROOT,
|
||||
_resetSessionBackupGuard,
|
||||
} = 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;
|
||||
}
|
||||
function findMark(node, type, acc = []) {
|
||||
for (const m of node.marks ?? []) if (m.type === type) acc.push(m);
|
||||
for (const c of node.content ?? []) findMark(c, type, acc);
|
||||
return acc;
|
||||
}
|
||||
function allText(node, acc = "") {
|
||||
if (node.text) acc += node.text;
|
||||
for (const c of node.content ?? []) acc = allText(c, acc);
|
||||
return acc;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 1. Direct Markdown -> native conversion
|
||||
// ---------------------------------------------------------------------------
|
||||
const md = `# Title
|
||||
|
||||
A paragraph with **bold**, *italic*, ~~strike~~, \`code\` and ==highlight==.
|
||||
|
||||
<u>underline</u> and <span style="color:red">red</span> and <span style="background-color:#ff0">yellow bg</span>.
|
||||
|
||||
> [!note] Callout title
|
||||
> Callout body text.
|
||||
|
||||
> A plain blockquote.
|
||||
|
||||
- bullet one
|
||||
- bullet two
|
||||
|
||||
1. first
|
||||
2. second
|
||||
|
||||
- [ ] todo
|
||||
- [x] done
|
||||
|
||||
| Name | Age |
|
||||
| ---- | --- |
|
||||
| Ada | 36 |
|
||||
|
||||

|
||||
|
||||
A [link](https://example.com) and a [[Other Chapter]] wikilink.
|
||||
|
||||
\`\`\`js
|
||||
const x = 1;
|
||||
\`\`\`
|
||||
|
||||
---
|
||||
|
||||
Final paragraph.
|
||||
`;
|
||||
|
||||
const doc = markdownToNative(md, {
|
||||
targets: [{ id: "other-chapter-1", title: "Other Chapter" }],
|
||||
});
|
||||
|
||||
ok(doc.schemaVersion === CURRENT_DOC_SCHEMA_VERSION, "converted doc has current schema version");
|
||||
ok(doc.doc.type === "doc", "doc root is type 'doc'");
|
||||
ok(findNodes(doc.doc, "heading").length === 1, "one heading converted");
|
||||
ok(findNodes(doc.doc, "heading")[0]?.attrs?.level === 1, "heading is level 1");
|
||||
ok(findNodes(doc.doc, "paragraph").length >= 5, "paragraphs converted");
|
||||
ok(findMark(doc.doc, "bold").length === 1, "bold mark present");
|
||||
ok(findMark(doc.doc, "italic").length === 1, "italic mark present");
|
||||
ok(findMark(doc.doc, "strike").length === 1, "strike mark present");
|
||||
ok(findMark(doc.doc, "code").length === 1, "inline code mark present");
|
||||
ok(findMark(doc.doc, "highlight").length >= 2, "highlight marks present (== and bg span)");
|
||||
ok(findMark(doc.doc, "textColor").length === 1, "textColor mark present");
|
||||
ok(findMark(doc.doc, "underline").length === 1, "underline mark present");
|
||||
ok(findNodes(doc.doc, "blockquote").length === 2, "two blockquotes (callout + quote)");
|
||||
ok(
|
||||
findNodes(doc.doc, "blockquote").some((b) => b.attrs?.callout === "note"),
|
||||
"callout preserved as blockquote with callout type"
|
||||
);
|
||||
ok(allText(doc.doc).includes("Callout title"), "callout title preserved");
|
||||
ok(findNodes(doc.doc, "bulletList").length === 1, "bullet list converted");
|
||||
ok(findNodes(doc.doc, "orderedList").length === 1, "ordered list converted");
|
||||
ok(findNodes(doc.doc, "taskList").length === 1, "checklist converted to taskList");
|
||||
const checked = findNodes(doc.doc, "taskItem").filter((n) => n.attrs?.checked === true);
|
||||
ok(checked.length === 1, "exactly one checklist item marked checked");
|
||||
ok(findNodes(doc.doc, "table").length === 1, "table converted");
|
||||
ok(findNodes(doc.doc, "tableRow").length === 2, "table has two rows");
|
||||
ok(findNodes(doc.doc, "tableHeader").length === 2, "table header cells converted");
|
||||
ok(findNodes(doc.doc, "image").length === 1, "image converted");
|
||||
ok(
|
||||
findNodes(doc.doc, "image")[0]?.attrs?.src === "https://example.com/cat.png",
|
||||
"external image src kept as-is"
|
||||
);
|
||||
const links = findMark(doc.doc, "link");
|
||||
ok(links.length === 2, "two links (standard + wikilink)");
|
||||
ok(
|
||||
links.some((l) => l.attrs?.href === "https://example.com"),
|
||||
"standard link href preserved"
|
||||
);
|
||||
ok(
|
||||
links.some((l) => l.attrs?.href === "folio:chapter/other-chapter-1"),
|
||||
"wikilink resolved to folio:chapter/<id>"
|
||||
);
|
||||
ok(findNodes(doc.doc, "codeBlock").length === 1, "fenced code block converted");
|
||||
ok(
|
||||
findNodes(doc.doc, "codeBlock")[0]?.attrs?.language === "js",
|
||||
"code block language preserved"
|
||||
);
|
||||
ok(findNodes(doc.doc, "horizontalRule").length === 1, "horizontal rule converted");
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 2. Round-trip serialize / parse / migrate
|
||||
// ---------------------------------------------------------------------------
|
||||
const reparsed = parseChapter(serializeChapter(doc));
|
||||
ok(reparsed.schemaVersion === CURRENT_DOC_SCHEMA_VERSION, "reparsed doc keeps schema version");
|
||||
ok(reparsed.doc.type === "doc", "reparsed doc root intact");
|
||||
ok(
|
||||
JSON.stringify(reparsed.doc) === JSON.stringify(doc.doc),
|
||||
"serialize -> parse is lossless"
|
||||
);
|
||||
|
||||
const oldDoc = { schemaVersion: 0, doc: { type: "doc", content: [] } };
|
||||
const migrated = migrateDoc(oldDoc);
|
||||
ok(migrated.schemaVersion === CURRENT_DOC_SCHEMA_VERSION, "migrateDoc bumps old schema version");
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 3. End-to-end migration of a real book (backup + persistence + idempotency)
|
||||
// ---------------------------------------------------------------------------
|
||||
const book = fs.mkdtempSync(path.join(os.tmpdir(), "folio-test-"));
|
||||
_resetSessionBackupGuard();
|
||||
const meta = createBook(book, "Migration Book");
|
||||
const c1 = "chapter-one-1111";
|
||||
const c2 = "chapter-two-2222";
|
||||
meta.chapters[c1] = { id: c1, title: "Chapter One", file: `chapters/${c1}.md` };
|
||||
meta.chapters[c2] = { id: c2, title: "Chapter Two", file: `chapters/${c2}.md` };
|
||||
meta.chapterOrder = [c1, c2];
|
||||
saveMeta(book, meta);
|
||||
fs.mkdirSync(path.join(book, "chapters"), { recursive: true });
|
||||
const md1 = "# One\n\nHello **world**.";
|
||||
const md2 = "## Two\n\n- a\n- b\n\n";
|
||||
fs.writeFileSync(path.join(book, `chapters/${c1}.md`), md1, "utf-8");
|
||||
fs.writeFileSync(path.join(book, `chapters/${c2}.md`), md2, "utf-8");
|
||||
|
||||
const before1 = fs.readFileSync(path.join(book, `chapters/${c1}.md`), "utf-8");
|
||||
const before2 = fs.readFileSync(path.join(book, `chapters/${c2}.md`), "utf-8");
|
||||
|
||||
const results = migrateBookToNative(book);
|
||||
ok(
|
||||
results.every((r) => r.status === "migrated"),
|
||||
"both chapters migrated"
|
||||
);
|
||||
ok(fs.existsSync(path.join(book, `chapters/${c1}.json`)), "chapter json written");
|
||||
ok(fs.existsSync(path.join(book, `chapters/${c2}.json`)), "chapter json written (2)");
|
||||
ok(
|
||||
fs.readFileSync(path.join(book, `chapters/${c1}.md`), "utf-8") === before1,
|
||||
"ORIGINAL .md left untouched (1)"
|
||||
);
|
||||
ok(
|
||||
fs.readFileSync(path.join(book, `chapters/${c2}.md`), "utf-8") === before2,
|
||||
"ORIGINAL .md left untouched (2)"
|
||||
);
|
||||
|
||||
const metaAfter = loadBook(book);
|
||||
ok(metaAfter.docFormat === "folio-doc", "book marked as folio-doc");
|
||||
ok(metaAfter.docSchemaVersion === CURRENT_DOC_SCHEMA_VERSION, "book has doc schema version");
|
||||
ok(metaAfter.chapters[c1].file.endsWith(".json"), "chapter entry repointed to .json");
|
||||
|
||||
ok(isNativeChapter(book, c1), "isNativeChapter true after migration");
|
||||
const readBack = getChapterDoc(book, c1);
|
||||
ok(readBack.doc.type === "doc", "migrated chapter reads back as native doc");
|
||||
ok(
|
||||
findMark(readBack.doc, "bold").length === 1,
|
||||
"migrated chapter preserved bold"
|
||||
);
|
||||
|
||||
// Recovery backup exists under ~/Folio-Recovery and contains folio.json
|
||||
const bookName = path.basename(book);
|
||||
const backups = fs
|
||||
.readdirSync(RECOVERY_ROOT)
|
||||
.filter((d) => d.startsWith(bookName) && fs.statSync(path.join(RECOVERY_ROOT, d)).isDirectory());
|
||||
ok(backups.length >= 1, "recovery backup created under ~/Folio-Recovery");
|
||||
const backupDir = path.join(RECOVERY_ROOT, backups[0]);
|
||||
ok(fs.existsSync(path.join(backupDir, "folio.json")), "backup contains folio.json");
|
||||
ok(
|
||||
fs.existsSync(path.join(backupDir, "chapters", `${c1}.md`)),
|
||||
"backup preserves original .md"
|
||||
);
|
||||
|
||||
// Idempotency: re-migrating does not rewrite .md and reports already-native
|
||||
const results2 = migrateBookToNative(book);
|
||||
ok(
|
||||
results2.every((r) => r.status === "already-native"),
|
||||
"re-migration is idempotent (already-native)"
|
||||
);
|
||||
ok(
|
||||
fs.readFileSync(path.join(book, `chapters/${c1}.md`), "utf-8") === before1,
|
||||
"ORIGINAL .md still untouched after re-migration"
|
||||
);
|
||||
|
||||
// Image asset copy: a local image is copied into assets/ and rewritten.
|
||||
const c3 = "chapter-three-3333";
|
||||
const meta3 = loadBook(book);
|
||||
meta3.chapters[c3] = { id: c3, title: "Chapter Three", file: `chapters/${c3}.md` };
|
||||
meta3.chapterOrder.push(c3);
|
||||
saveMeta(book, meta3);
|
||||
fs.writeFileSync(path.join(book, `chapters/${c3}.md`), "", "utf-8");
|
||||
fs.mkdirSync(path.join(book, "img"), { recursive: true });
|
||||
fs.writeFileSync(path.join(book, "img", "local.png"), "PNGDATA", "utf-8");
|
||||
_resetSessionBackupGuard();
|
||||
const r3 = migrateChapterToNative(book, c3);
|
||||
ok(r3.status === "migrated", "third chapter (local image) migrated");
|
||||
const doc3 = getChapterDoc(book, c3);
|
||||
const img3 = findNodes(doc3.doc, "image")[0];
|
||||
ok(
|
||||
img3?.attrs?.src?.startsWith("assets/"),
|
||||
"local image reference rewritten to assets/"
|
||||
);
|
||||
ok(
|
||||
fs.existsSync(path.join(book, img3.attrs.src)),
|
||||
"local image copied into assets/"
|
||||
);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Cleanup
|
||||
// ---------------------------------------------------------------------------
|
||||
fs.rmSync(out, { force: true });
|
||||
fs.rmSync(book, { recursive: true, force: true });
|
||||
for (const d of backups) {
|
||||
fs.rmSync(path.join(RECOVERY_ROOT, d), { recursive: true, force: true });
|
||||
}
|
||||
|
||||
console.log(
|
||||
"\nMigration tests: " + (failures === 0 ? "ALL PASSED" : failures + " FAILED")
|
||||
);
|
||||
process.exit(failures === 0 ? 0 : 1);
|
||||
|
|
@ -1,7 +1,8 @@
|
|||
// Standalone test for the DOCX/ODT Full Book export (office-export.ts).
|
||||
// Bundles the main-process module with esbuild and drives buildDocx/buildOdt
|
||||
// against a throwaway book, then inspects the produced ZIP contents to check
|
||||
// that the manuscript, its chapter order, and its formatting survive.
|
||||
// against a throwaway book whose chapters are stored as native Folio document
|
||||
// JSON, then inspects the produced ZIP contents to check that the manuscript,
|
||||
// its chapter order, and its formatting survive the native -> DOCX path.
|
||||
// Run with: node tests/run-office-export-test.mjs
|
||||
import esbuild from "esbuild";
|
||||
import fs from "fs";
|
||||
|
|
@ -30,94 +31,161 @@ const check = (name, ok) => {
|
|||
if (!ok) failures += 1;
|
||||
};
|
||||
|
||||
// --- fixture book -----------------------------------------------------------
|
||||
// --- fixture book (native JSON chapters) ------------------------------------
|
||||
const bookDir = fs.mkdtempSync(path.join(os.tmpdir(), "folio-office-test-"));
|
||||
fs.mkdirSync(path.join(bookDir, "chapters"), { recursive: true });
|
||||
const chapters = {};
|
||||
const order = [];
|
||||
const addChapter = (id, title, body) => {
|
||||
const file = path.posix.join("chapters", `${id}.md`);
|
||||
|
||||
// Build a native chapter file ({ schemaVersion, doc }).
|
||||
const addChapter = (id, title, content) => {
|
||||
const file = path.posix.join("chapters", `${id}.json`);
|
||||
order.push(id);
|
||||
chapters[id] = { id, title, file };
|
||||
fs.writeFileSync(path.join(bookDir, file), body);
|
||||
fs.writeFileSync(
|
||||
path.join(bookDir, file),
|
||||
JSON.stringify({ schemaVersion: 1, doc: { type: "doc", content } }, null, 2)
|
||||
);
|
||||
};
|
||||
|
||||
addChapter(
|
||||
"ch1",
|
||||
"Alpha",
|
||||
[
|
||||
"# Alpha",
|
||||
"",
|
||||
"**bold** *italic* ~~strike~~ ==highlight== <u>under</u>",
|
||||
"",
|
||||
'<span style="color:#e11d48">red</span> <span style="background-color:#fde047">marked</span>',
|
||||
"",
|
||||
"- bullet one",
|
||||
"- bullet two",
|
||||
" - nested bullet",
|
||||
"",
|
||||
"1. numbered one",
|
||||
"2. numbered two",
|
||||
"",
|
||||
"> [!note] Callout title",
|
||||
"> callout body",
|
||||
"",
|
||||
"> plain quote",
|
||||
"",
|
||||
"```js",
|
||||
"const answer = 42;",
|
||||
"```",
|
||||
"",
|
||||
"---",
|
||||
"",
|
||||
"| Head A | Head B |",
|
||||
"| --- | --- |",
|
||||
"| cell 1 | cell 2 |",
|
||||
"",
|
||||
"[Folio](https://example.com)",
|
||||
"",
|
||||
"[[Beta|Go to Beta]]",
|
||||
"",
|
||||
'<div align="center">centered text</div>',
|
||||
].join("\n")
|
||||
// Inline helpers --------------------------------------------------------------
|
||||
const text = (t, marks = []) => ({ type: "text", text: t, marks });
|
||||
const para = (content, attrs = {}) => ({ type: "paragraph", attrs, content });
|
||||
const heading = (level, t) => ({ type: "heading", attrs: { level }, content: [text(t)] });
|
||||
const listItem = (content) => ({ type: "listItem", content });
|
||||
|
||||
addChapter("ch1", "Alpha", [
|
||||
heading(1, "Alpha"),
|
||||
para([
|
||||
text("bold", [{ type: "bold" }]),
|
||||
text(" "),
|
||||
text("italic", [{ type: "italic" }]),
|
||||
text(" "),
|
||||
text("strike", [{ type: "strike" }]),
|
||||
text(" "),
|
||||
text("highlight", [{ type: "highlight" }]),
|
||||
text(" "),
|
||||
text("under", [{ type: "underline" }]),
|
||||
]),
|
||||
para([
|
||||
text("red", [{ type: "textColor", attrs: { color: "#e11d48" } }]),
|
||||
text(" "),
|
||||
text("marked", [{ type: "highlight", attrs: { color: "#fde047" } }]),
|
||||
]),
|
||||
{
|
||||
type: "bulletList",
|
||||
content: [
|
||||
listItem([para([text("bullet one")])]),
|
||||
listItem([
|
||||
para([text("bullet two")]),
|
||||
{
|
||||
type: "bulletList",
|
||||
content: [listItem([para([text("nested bullet")])])],
|
||||
},
|
||||
]),
|
||||
],
|
||||
},
|
||||
{
|
||||
type: "orderedList",
|
||||
content: [
|
||||
listItem([para([text("numbered one")])]),
|
||||
listItem([para([text("numbered two")])]),
|
||||
],
|
||||
},
|
||||
{
|
||||
type: "blockquote",
|
||||
attrs: { callout: "note" },
|
||||
content: [
|
||||
para([text("Callout title")], { class: "callout-title" }),
|
||||
para([text("callout body")]),
|
||||
],
|
||||
},
|
||||
{
|
||||
type: "blockquote",
|
||||
content: [para([text("plain quote")])],
|
||||
},
|
||||
{ type: "codeBlock", attrs: { language: "js" }, content: [text("const answer = 42;")] },
|
||||
{ type: "horizontalRule" },
|
||||
{
|
||||
type: "table",
|
||||
content: [
|
||||
{
|
||||
type: "tableRow",
|
||||
content: [
|
||||
{ type: "tableHeader", content: [para([text("Head A")])] },
|
||||
{ type: "tableHeader", content: [para([text("Head B")])] },
|
||||
],
|
||||
},
|
||||
{
|
||||
type: "tableRow",
|
||||
content: [
|
||||
{ type: "tableCell", content: [para([text("cell 1")])] },
|
||||
{ type: "tableCell", content: [para([text("cell 2")])] },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
para([text("Folio", [{ type: "link", attrs: { href: "https://example.com" } }])]),
|
||||
para([text("Go to Beta", [{ type: "link", attrs: { href: "folio:chapter/Beta" } }])]),
|
||||
para([text("centered text")], { textAlign: "center" }),
|
||||
]);
|
||||
|
||||
addChapter("ch2", "Beta", [para([text("This chapter has no heading of its own.")])]);
|
||||
addChapter("ch3", "Gamma", [
|
||||
heading(1, "Gamma"),
|
||||
heading(2, "Subsection"),
|
||||
para([text("trailing paragraph")]),
|
||||
]);
|
||||
addChapter("ch4", "Empty", []);
|
||||
addChapter("ch5", "Unicode", [para([text("Café — naïve naïve 中文 📖")])]);
|
||||
|
||||
// Real image file so the DOCX embedding path (ImageRun) is exercised: a
|
||||
// relative src must resolve against the book directory and the bytes survive.
|
||||
fs.mkdirSync(path.join(bookDir, "assets"), { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(bookDir, "assets", "pic.png"),
|
||||
Buffer.from("iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==", "base64")
|
||||
);
|
||||
addChapter("ch2", "Beta", "This chapter has no heading of its own.");
|
||||
addChapter("ch3", "Gamma", `# Gamma\n\n## Subsection\n\ntrailing paragraph`);
|
||||
addChapter("ch4", "Empty", "");
|
||||
addChapter("ch5", "Unicode", "Café — naïve naïve 中文 📖");
|
||||
addChapter("ch6", "Pics", [{ type: "image", attrs: { src: "assets/pic.png", alt: "a picture" } }]);
|
||||
|
||||
fs.writeFileSync(
|
||||
path.join(bookDir, "folio.json"),
|
||||
JSON.stringify({ app: "folio", version: 1, title: "Office Test Book", chapterOrder: order, chapters })
|
||||
);
|
||||
|
||||
const origFiles = order.map((id) => chapters[id].file).map((f) => [f, fs.readFileSync(path.join(bookDir, f))]);
|
||||
const origFiles = order
|
||||
.map((id) => chapters[id].file)
|
||||
.map((f) => [f, fs.readFileSync(path.join(bookDir, f))]);
|
||||
const origManifest = fs.readFileSync(path.join(bookDir, "folio.json"));
|
||||
|
||||
const docxBuf = await mod.buildDocx(bookDir);
|
||||
const docxZip = await JSZip.loadAsync(docxBuf);
|
||||
const docxXml = await docxZip.file("word/document.xml").async("string");
|
||||
const docxStyles = await docxZip.file("word/styles.xml").async("string");
|
||||
const docxXml = await docxZip.file("word/document.xml").async("string");
|
||||
const docxStyles = await docxZip.file("word/styles.xml").async("string");
|
||||
|
||||
check("docx is a zip (PK magic)", docxBuf.subarray(0, 2).toString("utf-8") === "PK");
|
||||
check("docx has [Content_Types].xml", !!docxZip.file("[Content_Types].xml"));
|
||||
check("docx has word/document.xml", !!docxZip.file("word/document.xml"));
|
||||
check("docx is a zip (PK magic)", docxBuf.subarray(0, 2).toString("utf-8") === "PK");
|
||||
check("docx has [Content_Types].xml", !!docxZip.file("[Content_Types].xml"));
|
||||
check("docx has word/document.xml", !!docxZip.file("word/document.xml"));
|
||||
|
||||
const stripTags = (xml) => xml.replace(/<[^>]+>/g, " ").replace(/\s+/g, " ").trim();
|
||||
const stripTags = (xml) => xml.replace(/<[^>]+>/g, " ").replace(/\s+/g, " ").trim();
|
||||
|
||||
const docxText = stripTags(docxXml);
|
||||
check("docx contains book title", docxText.includes("Office Test Book"));
|
||||
check("docx contains byline", docxText.includes("A book compiled with Folio."));
|
||||
const docxText = stripTags(docxXml);
|
||||
check("docx contains book title", docxText.includes("Office Test Book"));
|
||||
check("docx contains byline", docxText.includes("A book compiled with Folio."));
|
||||
|
||||
const idxAlpha = docxText.indexOf("Alpha");
|
||||
const idxBeta = docxText.indexOf("Beta");
|
||||
const idxGamma = docxText.indexOf("Gamma");
|
||||
const idxUnicode = docxText.indexOf("Unicode");
|
||||
const idxPics = docxText.indexOf("Pics");
|
||||
check(
|
||||
"docx keeps chapter order",
|
||||
idxAlpha >= 0 && idxAlpha < idxBeta && idxBeta < idxGamma && idxGamma < idxUnicode
|
||||
idxAlpha >= 0 && idxAlpha < idxBeta && idxBeta < idxGamma && idxGamma < idxUnicode && idxUnicode < idxPics
|
||||
);
|
||||
|
||||
const mediaFiles = Object.keys(docxZip.files).filter((f) => f.startsWith("word/media/"));
|
||||
check("docx embeds the image (no data loss)", mediaFiles.length >= 1);
|
||||
|
||||
check("docx has bold", /<w:b\/>/.test(docxXml));
|
||||
check("docx has italic", /<w:i\/>/.test(docxXml));
|
||||
check("docx has strike", /<w:strike\/>/.test(docxXml));
|
||||
|
|
@ -140,8 +208,8 @@ check("docx has 1.7 line spacing", /<w:spacing[^>]*w:line="408"[^>]*w:lineRule="
|
|||
check("docx has blank-line paragraph spacing", /<w:spacing[^>]*w:after="408"/.test(docxXml));
|
||||
check("docx has chapter break before later chapters", (docxXml.match(/w:before="320"/g) || []).length >= 4);
|
||||
check("docx has callout shading", /w:fill="F5F2ED"/.test(docxXml));
|
||||
check("docx renders ==highlight== (no raw markup)", !docxText.includes("==highlight=="));
|
||||
check("docx renders **bold** (no raw markup)", !docxText.includes("**bold**"));
|
||||
check("docx renders highlight (no raw markup)", !docxText.includes("==highlight=="));
|
||||
check("docx renders bold (no raw markup)", !docxText.includes("**bold**"));
|
||||
check("docx resolves wikilink label", docxText.includes("Go to Beta"));
|
||||
check("docx has no raw wikilink brackets", !docxText.includes("[[Beta"));
|
||||
check("docx headingless chapter gets title", docxText.includes("Beta") && docxText.includes("no heading"));
|
||||
|
|
@ -190,4 +258,4 @@ if (failures > 0) {
|
|||
console.error(`\n${failures} check(s) failed.`);
|
||||
process.exit(1);
|
||||
}
|
||||
console.log("\nAll office-export checks passed.");
|
||||
console.log("\nAll office-export checks passed.");
|
||||
|
|
|
|||
145
tests/run-print-test.mjs
Normal file
145
tests/run-print-test.mjs
Normal file
|
|
@ -0,0 +1,145 @@
|
|||
// Standalone test for the native -> print HTML renderer (print-format.ts).
|
||||
// This is the path the PDF export uses (#printBook innerHTML). It bundles the
|
||||
// renderer module and verifies that chapter breaks become print sections and
|
||||
// that every rich-text feature survives the native -> HTML conversion.
|
||||
// Run with: node tests/run-print-test.mjs
|
||||
import esbuild from "esbuild";
|
||||
import fs from "fs";
|
||||
import os from "os";
|
||||
import path from "path";
|
||||
import url from "url";
|
||||
|
||||
const out = path.resolve(`tests/.tmp-folio-print-${process.pid}.mjs`);
|
||||
await esbuild.build({
|
||||
entryPoints: [path.resolve("src/renderer/print-format.ts")],
|
||||
bundle: true,
|
||||
platform: "node",
|
||||
format: "esm",
|
||||
external: ["electron"],
|
||||
outfile: out,
|
||||
write: true,
|
||||
logLevel: "silent",
|
||||
});
|
||||
|
||||
const mod = await import(url.pathToFileURL(out).href);
|
||||
const renderPrintChaptersNative = mod.renderPrintChaptersNative;
|
||||
|
||||
let failures = 0;
|
||||
const check = (name, ok) => {
|
||||
console.log(`${ok ? "PASS" : "FAIL"} ${name}`);
|
||||
if (!ok) failures += 1;
|
||||
};
|
||||
|
||||
// --- native document with chapter breaks + every formatting feature ---------
|
||||
const text = (t, marks = []) => ({ type: "text", text: t, marks });
|
||||
const para = (content, attrs = {}) => ({ type: "paragraph", attrs, content });
|
||||
const heading = (level, t) => ({ type: "heading", attrs: { level }, content: [text(t)] });
|
||||
const listItem = (content) => ({ type: "listItem", content });
|
||||
|
||||
const doc = {
|
||||
type: "doc",
|
||||
content: [
|
||||
{ type: "chapterBreak", attrs: { chapterId: "c1", title: "One", first: true } },
|
||||
heading(1, "One"),
|
||||
para([
|
||||
text("bold", [{ type: "bold" }]),
|
||||
text(" "),
|
||||
text("italic", [{ type: "italic" }]),
|
||||
text(" "),
|
||||
text("underline", [{ type: "underline" }]),
|
||||
text(" "),
|
||||
text("hl", [{ type: "highlight" }]),
|
||||
text(" "),
|
||||
text("red", [{ type: "textColor", attrs: { color: "#e11d48" } }]),
|
||||
text(" "),
|
||||
text("mk", [{ type: "highlight", attrs: { color: "#fde047" } }]),
|
||||
]),
|
||||
para([text("center")], { textAlign: "center" }),
|
||||
{ type: "bulletList", content: [listItem([para([text("bullet one")])])] },
|
||||
{
|
||||
type: "orderedList",
|
||||
content: [
|
||||
listItem([para([text("numbered one")])]),
|
||||
listItem([para([text("numbered two")])]),
|
||||
],
|
||||
},
|
||||
{
|
||||
type: "taskList",
|
||||
content: [
|
||||
{ type: "taskItem", attrs: { checked: true }, content: [para([text("done task")])] },
|
||||
{ type: "taskItem", attrs: { checked: false }, content: [para([text("todo task")])] },
|
||||
],
|
||||
},
|
||||
{
|
||||
type: "blockquote",
|
||||
attrs: { callout: "note" },
|
||||
content: [
|
||||
para([text("Callout title")], { class: "callout-title" }),
|
||||
para([text("callout body")]),
|
||||
],
|
||||
},
|
||||
{ type: "codeBlock", attrs: { language: "js" }, content: [text("const answer = 42;")] },
|
||||
{ type: "horizontalRule" },
|
||||
{
|
||||
type: "table",
|
||||
content: [
|
||||
{
|
||||
type: "tableRow",
|
||||
content: [
|
||||
{ type: "tableHeader", content: [para([text("Head A")])] },
|
||||
{ type: "tableHeader", content: [para([text("Head B")])] },
|
||||
],
|
||||
},
|
||||
{
|
||||
type: "tableRow",
|
||||
content: [
|
||||
{ type: "tableCell", content: [para([text("cell 1")])] },
|
||||
{ type: "tableCell", content: [para([text("cell 2")])] },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{ type: "image", attrs: { src: "assets/pic.png", alt: "a picture" } },
|
||||
para([text("Site", [{ type: "link", attrs: { href: "https://example.com" } }])]),
|
||||
para([text("Go", [{ type: "link", attrs: { href: "folio:chapter/Two" } }])]),
|
||||
{ type: "chapterBreak", attrs: { chapterId: "c2", title: "Two", first: false } },
|
||||
heading(1, "Two"),
|
||||
para([text("second chapter body")]),
|
||||
],
|
||||
};
|
||||
|
||||
const html = renderPrintChaptersNative(doc);
|
||||
|
||||
check("has two print-chapter sections", (html.match(/<section class="print-chapter">/g) || []).length === 2);
|
||||
const iOne = html.indexOf("One");
|
||||
const iTwo = html.indexOf("Two");
|
||||
check("chapters appear in order", iOne >= 0 && iOne < iTwo);
|
||||
check("first section contains chapter one", html.indexOf("<section") < iOne && html.indexOf("second chapter body") > iOne);
|
||||
|
||||
check("renders h1", /<h1>One<\/h1>/.test(html));
|
||||
check("renders bold", /<strong>bold<\/strong>/.test(html));
|
||||
check("renders italic", /<em>italic<\/em>/.test(html));
|
||||
check("renders underline", /<u>underline<\/u>/.test(html));
|
||||
check("renders highlight as mark", /<mark>hl<\/mark>/.test(html));
|
||||
check("renders text color span", /<span style="color:#e11d48">red<\/span>/.test(html));
|
||||
check("renders highlight color span", /<span style="background-color:#fde047">mk<\/span>/.test(html));
|
||||
check("renders centered paragraph", /<p style="text-align:center">center<\/p>/.test(html));
|
||||
check("renders bullet list", /<ul>[\s\S]*bullet one[\s\S]*<\/ul>/.test(html));
|
||||
check("renders ordered list", /<ol>[\s\S]*numbered one[\s\S]*numbered two[\s\S]*<\/ol>/.test(html));
|
||||
check("renders task list with checkboxes", html.includes("☒") && html.includes("done task") && html.includes("☐") && html.includes("todo task"));
|
||||
check("renders callout", /<div class="callout callout-note">.*<p class="callout-title">Callout title<\/p>/s.test(html));
|
||||
check("renders code block", /<pre><code>const answer = 42;<\/code><\/pre>/.test(html));
|
||||
check("renders horizontal rule", /<hr>/.test(html));
|
||||
check("renders table with header + cells", /<table class="folio-table">.*<th>Head A<\/th>.*<td>cell 1<\/td>.*<\/table>/s.test(html));
|
||||
check("renders image", /<img src="assets\/pic.png" alt="a picture">/.test(html));
|
||||
check("renders external link", /<a href="https:\/\/example.com">Site<\/a>/.test(html));
|
||||
check("renders wikilink as internal anchor", /<a href="folio:chapter\/Two" class="wikilink internal">Go<\/a>/.test(html));
|
||||
check("no raw wikilink brackets", !html.includes("[[Two"));
|
||||
|
||||
fs.rmSync(out, { force: true });
|
||||
|
||||
if (failures > 0) {
|
||||
console.error(`\n${failures} check(s) failed.`);
|
||||
process.exit(1);
|
||||
}
|
||||
console.log("\nAll print-format checks passed.");
|
||||
|
|
@ -1,966 +0,0 @@
|
|||
/* Selftest scenario - runs inside the renderer via executeJavaScript.
|
||||
Injected only when FOLIO_SELF_TEST=1. Exercises the full
|
||||
renderer -> preload -> IPC -> chapters chain. */
|
||||
const BOOK_DIR = __BOOKDIR_VALUE__;
|
||||
const BOOK2_DIR = __BOOKDIR2_VALUE__;
|
||||
const BOOK3_DIR = __BOOKDIR3_VALUE__;
|
||||
(async () => {
|
||||
const out = {};
|
||||
try {
|
||||
out.folioType = typeof window.folio;
|
||||
out.recentsCount = (await window.folio.getRecents()).length;
|
||||
out.openBook = await window.folio.openBookPath(BOOK_DIR);
|
||||
out.metaTitle = (await window.folio.getBookMeta()).title;
|
||||
|
||||
const created = await window.folio.createChapter("Introduction");
|
||||
out.created = created && !created.error ? { id: created.id, title: created.title } : created;
|
||||
const cid = created.id;
|
||||
out.contentEmpty = await window.folio.getChapterContent(cid);
|
||||
out.listLen1 = (await window.folio.getChapterList()).length;
|
||||
|
||||
out.renamed = await window.folio.renameChapter(cid, "Intro");
|
||||
const dup = await window.folio.duplicateChapter(cid);
|
||||
out.dup = dup && !dup.error ? { id: dup.id, title: dup.title } : dup;
|
||||
const did = dup.id;
|
||||
out.listLen2 = (await window.folio.getChapterList()).length;
|
||||
|
||||
out.deleted = await window.folio.deleteChapter(cid);
|
||||
const remaining = (await window.folio.getChapterList())[0];
|
||||
out.listLen3 = (await window.folio.getChapterList()).length;
|
||||
|
||||
out.reorderOk = await window.folio.reorderChapters([remaining.id]);
|
||||
out.reorderErr = await window.folio.reorderChapters(["bogus-id"]);
|
||||
|
||||
out.dupContent = await window.folio.getChapterContent(did);
|
||||
|
||||
// --- drag & drop reorder test ---
|
||||
const a = (await window.folio.createChapter("Alpha")).id;
|
||||
await new Promise((r) => setTimeout(r, 80));
|
||||
const b = (await window.folio.createChapter("Bravo")).id;
|
||||
await new Promise((r) => setTimeout(r, 80));
|
||||
const c = (await window.folio.createChapter("Charlie")).id;
|
||||
await new Promise((r) => setTimeout(r, 200));
|
||||
const orderBefore = (await window.folio.getBookMeta()).chapterOrder;
|
||||
out.orderBeforeDnD = orderBefore;
|
||||
const rows = document.getElementById("chapterList").querySelectorAll(".chapter-row");
|
||||
out.dndRowCount = rows.length;
|
||||
const draggedId = rows[0].dataset.id;
|
||||
const targetId = rows[2].dataset.id;
|
||||
const dt = new DataTransfer();
|
||||
rows[0].dispatchEvent(new DragEvent("dragstart", { dataTransfer: dt, bubbles: true }));
|
||||
rows[2].dispatchEvent(new DragEvent("dragover", { dataTransfer: dt, bubbles: true }));
|
||||
rows[2].dispatchEvent(new DragEvent("drop", { dataTransfer: dt, bubbles: true }));
|
||||
rows[0].dispatchEvent(new DragEvent("dragend", { dataTransfer: dt, bubbles: true }));
|
||||
await new Promise((r) => setTimeout(r, 250));
|
||||
const orderAfter = (await window.folio.getBookMeta()).chapterOrder;
|
||||
out.orderAfterDnD = orderAfter;
|
||||
out.dndMoved =
|
||||
orderAfter[0] === orderBefore[1] &&
|
||||
orderAfter[2] === orderBefore[0] &&
|
||||
orderAfter[3] === orderBefore[3];
|
||||
|
||||
// keyboard reorder: select first visible row, Alt+ArrowDown moves it down
|
||||
const fresh1 = document.getElementById("chapterList").querySelectorAll(".chapter-row");
|
||||
fresh1[0].dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
await new Promise((r) => setTimeout(r, 120));
|
||||
const fresh2 = document.getElementById("chapterList").querySelectorAll(".chapter-row");
|
||||
fresh2[0].focus();
|
||||
fresh2[0].dispatchEvent(new KeyboardEvent("keydown", { key: "ArrowDown", altKey: true, bubbles: true }));
|
||||
await new Promise((r) => setTimeout(r, 250));
|
||||
const orderKb = (await window.folio.getBookMeta()).chapterOrder;
|
||||
out.orderAfterKb = orderKb;
|
||||
out.kbMoved = orderKb[0] === orderAfter[1] && orderKb[1] === orderAfter[0];
|
||||
|
||||
// UI-driven chapter creation via the in-app modal (window.prompt is unsupported)
|
||||
const countBefore = (await window.folio.getBookMeta()).chapterOrder.length;
|
||||
document.getElementById("addChapterBtn").click();
|
||||
await new Promise((r) => setTimeout(r, 80));
|
||||
const modalInput = document.getElementById("modalInput");
|
||||
out.modalShown = !!modalInput && !document.getElementById("modalBackdrop").classList.contains("hidden");
|
||||
modalInput.value = "Via Modal";
|
||||
document.getElementById("modalOk").click();
|
||||
await new Promise((r) => setTimeout(r, 300));
|
||||
const metaAfterUi = await window.folio.getBookMeta();
|
||||
out.uiCreated = metaAfterUi.chapterOrder.length === countBefore + 1 &&
|
||||
Object.values(metaAfterUi.chapters).some((c) => c.title === "Via Modal");
|
||||
|
||||
// CodeMirror editor: typing, cursor/selection, autosave persistence
|
||||
const cm = window.__folioEditor;
|
||||
out.hasEditor = !!cm;
|
||||
out.editorLoaded = cm ? cm.state.doc.toString() : null;
|
||||
if (cm) {
|
||||
cm.dispatch({
|
||||
changes: {
|
||||
from: 0,
|
||||
to: cm.state.doc.length,
|
||||
insert: "# Heading One\n\n**bold** and *italic* text\n\n- bullet one\n- bullet two\n\n> quoted line\n\n[link](https://example.com) and `inline code`\n\n---",
|
||||
},
|
||||
});
|
||||
cm.dispatch({ selection: { anchor: cm.state.doc.length } });
|
||||
}
|
||||
await new Promise((r) => setTimeout(r, 1200));
|
||||
out.editorDocAfterType = cm ? cm.state.doc.toString() : null;
|
||||
const typedId = metaAfterUi.chapterOrder[metaAfterUi.chapterOrder.length - 1];
|
||||
const typedContent = await window.folio.getChapterContent(typedId);
|
||||
out.editorAutosaved =
|
||||
!!cm &&
|
||||
cm.state.doc.toString().includes("**bold** and *italic*") &&
|
||||
typedContent.includes("# Heading One") &&
|
||||
typedContent.includes("- bullet two") &&
|
||||
typedContent.includes("> quoted line") &&
|
||||
typedContent.includes("[link](https://example.com)") &&
|
||||
typedContent.includes("`inline code`");
|
||||
|
||||
// --- formatting toolbar + keyboard shortcuts ---
|
||||
if (cm) {
|
||||
cm.dispatch({ changes: { from: 0, to: cm.state.doc.length, insert: "" }, selection: { anchor: 0 } });
|
||||
cm.focus();
|
||||
cm.dispatch({ changes: { from: 0, to: 0, insert: "Hello World" }, selection: { anchor: 0, head: 5 } });
|
||||
|
||||
document.querySelector('[data-cmd="bold"]').click();
|
||||
await new Promise((r) => setTimeout(r, 50));
|
||||
out.toolbarBoldOk = cm.state.doc.toString() === "**Hello** World";
|
||||
out.focusAfterBold = document.activeElement === cm.contentDOM;
|
||||
|
||||
cm.dispatch({ selection: { anchor: 10, head: 15 } }); // "World"
|
||||
cm.contentDOM.dispatchEvent(
|
||||
new KeyboardEvent("keydown", { key: "b", ctrlKey: true, bubbles: true, cancelable: true })
|
||||
);
|
||||
await new Promise((r) => setTimeout(r, 50));
|
||||
out.shortcutBoldOk = cm.state.doc.toString() === "**Hello** **World**";
|
||||
|
||||
const headingSel = document.getElementById("headingSelect");
|
||||
headingSel.value = "2";
|
||||
headingSel.dispatchEvent(new Event("change", { bubbles: true }));
|
||||
await new Promise((r) => setTimeout(r, 50));
|
||||
out.headingOk = cm.state.doc.toString().startsWith("## ");
|
||||
|
||||
document.querySelector('[data-cmd="undo"]').click();
|
||||
await new Promise((r) => setTimeout(r, 50));
|
||||
out.undoOk = cm.state.doc.toString() === "**Hello** **World**";
|
||||
|
||||
document.querySelector('[data-cmd="redo"]').click();
|
||||
await new Promise((r) => setTimeout(r, 50));
|
||||
out.redoOk = cm.state.doc.toString().startsWith("## ");
|
||||
|
||||
cm.dispatch({ selection: { anchor: 15, head: 20 } }); // "World"
|
||||
cm.contentDOM.dispatchEvent(
|
||||
new KeyboardEvent("keydown", {
|
||||
key: "s",
|
||||
ctrlKey: true,
|
||||
shiftKey: true,
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
})
|
||||
);
|
||||
await new Promise((r) => setTimeout(r, 50));
|
||||
out.strikeShortcutOk = cm.state.doc.toString().includes("~~World~~");
|
||||
|
||||
cm.dispatch({
|
||||
changes: { from: 0, to: 0, insert: "line one\nline two\n" },
|
||||
selection: { anchor: 0, head: 16 },
|
||||
});
|
||||
document.querySelector('[data-cmd="codeblock"]').click();
|
||||
await new Promise((r) => setTimeout(r, 50));
|
||||
out.fencedCodeOk = cm.state.doc.toString().startsWith("```\nline one\nline two\n```");
|
||||
|
||||
// underline shortcut (Ctrl+U)
|
||||
cm.dispatch({ changes: { from: 0, to: cm.state.doc.length, insert: "" } });
|
||||
cm.dispatch({ changes: { from: 0, to: 0, insert: "Under me" }, selection: { anchor: 0, head: 5 } });
|
||||
cm.contentDOM.dispatchEvent(
|
||||
new KeyboardEvent("keydown", { key: "u", ctrlKey: true, bubbles: true, cancelable: true })
|
||||
);
|
||||
await new Promise((r) => setTimeout(r, 50));
|
||||
out.underlineOk = cm.state.doc.toString().startsWith("<u>Under</u>");
|
||||
|
||||
// task list via toolbar
|
||||
cm.dispatch({ changes: { from: 0, to: cm.state.doc.length, insert: "" } });
|
||||
cm.dispatch({ changes: { from: 0, to: 0, insert: "task one\ntask two" }, selection: { anchor: 0, head: 8 } });
|
||||
document.querySelector('[data-cmd="checklist"]').click();
|
||||
await new Promise((r) => setTimeout(r, 50));
|
||||
out.checklistOk = cm.state.doc.toString().startsWith("- [ ] task one");
|
||||
|
||||
// highlight via toolbar
|
||||
cm.dispatch({ changes: { from: 0, to: cm.state.doc.length, insert: "" } });
|
||||
cm.dispatch({ changes: { from: 0, to: 0, insert: "cell text" }, selection: { anchor: 0, head: 4 } });
|
||||
document.querySelector('[data-cmd="highlight"]').click();
|
||||
await new Promise((r) => setTimeout(r, 50));
|
||||
out.highlightOk = cm.state.doc.toString().startsWith("==cell==");
|
||||
|
||||
// table via toolbar dropdown config
|
||||
cm.dispatch({ changes: { from: 0, to: cm.state.doc.length, insert: "" } });
|
||||
document.getElementById("tableRows").value = "3";
|
||||
document.getElementById("tableCols").value = "2";
|
||||
document.querySelector('[data-action="table"]').click();
|
||||
await new Promise((r) => setTimeout(r, 50));
|
||||
out.tableOk = cm.state.doc.toString().startsWith("| Col 1 | Col 2 |\n| --- | --- |");
|
||||
|
||||
// format painter
|
||||
cm.dispatch({ changes: { from: 0, to: cm.state.doc.length, insert: "" } });
|
||||
cm.dispatch({ changes: { from: 0, to: 0, insert: "**src** dst" }, selection: { anchor: 3, head: 3 } });
|
||||
document.querySelector('[data-cmd="painter"]').click();
|
||||
cm.dispatch({ selection: { anchor: 8, head: 11 } });
|
||||
document.querySelector('[data-cmd="painter"]').click();
|
||||
await new Promise((r) => setTimeout(r, 50));
|
||||
out.painterOk = cm.state.doc.toString() === "**src** **dst**";
|
||||
|
||||
// text color via the toolbar dropdown.
|
||||
cm.dispatch({ changes: { from: 0, to: cm.state.doc.length, insert: "" } });
|
||||
cm.dispatch({ changes: { from: 0, to: 0, insert: "color me" }, selection: { anchor: 0, head: 5 } });
|
||||
cm.focus();
|
||||
const colorToggle = document.querySelector('[data-dropdown="colorMenu"]');
|
||||
colorToggle?.click();
|
||||
await new Promise((r) => setTimeout(r, 50));
|
||||
const colorMenuEl = document.getElementById("colorMenu");
|
||||
const colorRect = colorMenuEl?.getBoundingClientRect();
|
||||
out.colorMenuOpens =
|
||||
!!colorRect && !colorMenuEl?.classList.contains("hidden") && colorRect.bottom <= window.innerHeight + 1;
|
||||
// the dropdown is pinned to the viewport, so it must stay on screen even
|
||||
// though the toolbar itself is a horizontal scroll container
|
||||
out.colorMenuOnScreen = !!colorRect && colorRect.top >= 0 && colorRect.bottom <= window.innerHeight;
|
||||
document.querySelector('[data-cmd="color:#e11d48"]').click();
|
||||
await new Promise((r) => setTimeout(r, 50));
|
||||
out.colorApplied = cm.state.doc.toString() === '<span style="color:#e11d48">color</span> me';
|
||||
|
||||
// custom color picker works
|
||||
cm.dispatch({ changes: { from: 0, to: cm.state.doc.length, insert: "" } });
|
||||
cm.dispatch({ changes: { from: 0, to: 0, insert: "pick me" }, selection: { anchor: 0, head: 4 } });
|
||||
cm.focus();
|
||||
colorToggle?.click();
|
||||
await new Promise((r) => setTimeout(r, 50));
|
||||
const customInput = document.getElementById("customTextColor");
|
||||
customInput.value = "#0066cc";
|
||||
customInput.dispatchEvent(new Event("change", { bubbles: true }));
|
||||
await new Promise((r) => setTimeout(r, 50));
|
||||
out.customColorApplied = cm.state.doc.toString() === '<span style="color:#0066cc">pick</span> me';
|
||||
|
||||
// leave some formatted content on disk for the autosave check
|
||||
cm.dispatch({
|
||||
changes: { from: 0, to: cm.state.doc.length, insert: "**Hello** World" },
|
||||
selection: { anchor: 0 },
|
||||
});
|
||||
await new Promise((r) => setTimeout(r, 1200)); // wait for autosave debounce
|
||||
out.formattingAutosaved =
|
||||
(await window.folio.getChapterContent(typedId)).includes("**Hello**");
|
||||
}
|
||||
|
||||
// --- full book view (fresh second book with known chapter contents) ---
|
||||
await window.folio.openBookPath(BOOK2_DIR);
|
||||
await new Promise((r) => setTimeout(r, 400));
|
||||
const fb = window.__folioFullBookEditor;
|
||||
const alphaId = (await window.folio.createChapter("Alpha")).id;
|
||||
const betaId = (await window.folio.createChapter("Beta")).id;
|
||||
const gammaId = (await window.folio.createChapter("Gamma")).id;
|
||||
await new Promise((r) => setTimeout(r, 300));
|
||||
await window.folio.saveChapter(alphaId, "# Alpha\n\nAlpha body text");
|
||||
await window.folio.saveChapter(betaId, "Beta body without heading");
|
||||
await window.folio.saveChapter(gammaId, "# Gamma's Own Title\n\nGamma text");
|
||||
await new Promise((r) => setTimeout(r, 300));
|
||||
|
||||
document.getElementById("fullBookBtn").click();
|
||||
await new Promise((r) => setTimeout(r, 300));
|
||||
|
||||
const expectedAssembled =
|
||||
"# Alpha\n\nAlpha body text\n\n# Beta\n\nBeta body without heading\n\n# Gamma\n\n# Gamma's Own Title\n\nGamma text";
|
||||
out.fullBookShown =
|
||||
!document.getElementById("fullBookView").classList.contains("hidden") &&
|
||||
document.getElementById("chapterView").classList.contains("hidden") &&
|
||||
document.getElementById("fullBookBtn").classList.contains("active") &&
|
||||
!document.getElementById("fullBookNav").classList.contains("hidden");
|
||||
const assembled = fb.state.doc.toString();
|
||||
out.assembled = assembled;
|
||||
out.assembledOk = assembled === expectedAssembled;
|
||||
const words = expectedAssembled.trim().split(/\s+/).length;
|
||||
const statsEl = document.getElementById("fullBookStats");
|
||||
out.fullBookStatsText = statsEl.textContent;
|
||||
out.fullBookStatsOk =
|
||||
statsEl.textContent === `words: ${words} · chars: ${expectedAssembled.length}`;
|
||||
|
||||
// --- chapter page breaks in Full Book ---
|
||||
// Each chapter after the first gets a "chapter-break" marker on its first
|
||||
// rendered line (a CSS border on screen, break-before: page when printed).
|
||||
// The first chapter must never be marked, so no blank page precedes it.
|
||||
const chapterBreakLines = () =>
|
||||
Array.from(document.querySelectorAll("#fullBookHost .cm-line.chapter-break"));
|
||||
const allContentLines = () =>
|
||||
Array.from(document.querySelectorAll("#fullBookHost .cm-content .cm-line"));
|
||||
const fbBreaks = chapterBreakLines();
|
||||
const firstLine = allContentLines()[0];
|
||||
out.chapterBreakCount = fbBreaks.length;
|
||||
out.chapterBreakCountOk = fbBreaks.length === 2; // 3 chapters -> 2 breaks
|
||||
out.firstChapterUnmarked = !!firstLine && !firstLine.classList.contains("chapter-break");
|
||||
out.breaksAtChapterStarts =
|
||||
fbBreaks.length === 2 &&
|
||||
fbBreaks.every((el) => {
|
||||
const t = el.textContent;
|
||||
return t.startsWith("# Beta") || t.startsWith("# Gamma");
|
||||
});
|
||||
|
||||
// print rules must be present in the stylesheet (fetch the raw CSS because
|
||||
// Chromium's CSSOM normalizes the page-break-before alias into break-before)
|
||||
const rawCss = await (await fetch("./index.css")).text();
|
||||
out.printBreakRule = /break-before\s*:\s*page/.test(rawCss);
|
||||
out.printBreakFallback = /page-break-before\s*:\s*always/.test(rawCss);
|
||||
out.printBreakScoped = /\.print-chapter\s*{[^}]*break-before\s*:\s*page/.test(rawCss);
|
||||
|
||||
document.getElementById("copyAllBtn").click();
|
||||
await new Promise((r) => setTimeout(r, 200));
|
||||
const clip = await window.folio.getClipboardText();
|
||||
out.clipboardLen = clip.length;
|
||||
out.copyAllOk = clip === expectedAssembled;
|
||||
out.copyStatusShown = document.getElementById("fullBookStatus").textContent === "Full book copied";
|
||||
|
||||
// Ctrl+A (routed through the renderer listener) selects the whole read-only doc
|
||||
fb.focus();
|
||||
fb.contentDOM.dispatchEvent(
|
||||
new KeyboardEvent("keydown", { key: "a", ctrlKey: true, bubbles: true, cancelable: true })
|
||||
);
|
||||
const sel = fb.state.selection.main;
|
||||
out.fullBookSelectAllOk =
|
||||
fb.state.doc.length > 0 && sel.from === 0 && sel.to === fb.state.doc.length;
|
||||
|
||||
// back to normal chapter editing still works
|
||||
document.querySelector("#chapterList .chapter-row").click();
|
||||
await new Promise((r) => setTimeout(r, 250));
|
||||
out.backToChapterOk =
|
||||
document.getElementById("fullBookView").classList.contains("hidden") &&
|
||||
!document.getElementById("chapterView").classList.contains("hidden") &&
|
||||
!document.getElementById("fullBookBtn").classList.contains("active");
|
||||
|
||||
// --- requirement matrix: reorder, persistence, edit ops reflect in Full Book ---
|
||||
const rowFor = (id) =>
|
||||
Array.from(document.querySelectorAll("#chapterList .chapter-row")).find((r) => r.dataset.id === id);
|
||||
const moveUp = async (id) => {
|
||||
rowFor(id).dispatchEvent(
|
||||
new KeyboardEvent("keydown", { key: "ArrowUp", altKey: true, bubbles: true })
|
||||
);
|
||||
await new Promise((r) => setTimeout(r, 250));
|
||||
};
|
||||
|
||||
// current order: [Alpha, Beta, Gamma]; move Gamma to front -> [Gamma, Alpha, Beta]
|
||||
rowFor(gammaId).click();
|
||||
await new Promise((r) => setTimeout(r, 200));
|
||||
await moveUp(gammaId);
|
||||
await moveUp(gammaId);
|
||||
const orderReordered = (await window.folio.getBookMeta()).chapterOrder;
|
||||
out.orderReordered = orderReordered;
|
||||
out.reorderOk =
|
||||
orderReordered[0] === gammaId && orderReordered[1] === alphaId && orderReordered[2] === betaId;
|
||||
|
||||
// Full Book follows the new chapter order and keeps every chapter's content
|
||||
document.getElementById("fullBookBtn").click();
|
||||
await new Promise((r) => setTimeout(r, 300));
|
||||
const assembledGAB = fb.state.doc.toString();
|
||||
out.fbAfterReorder = assembledGAB;
|
||||
out.fbAfterReorderOk =
|
||||
assembledGAB.startsWith("# Gamma\n\n# Gamma's Own Title") &&
|
||||
assembledGAB.indexOf("# Alpha") > assembledGAB.indexOf("# Gamma") &&
|
||||
assembledGAB.indexOf("# Beta") > assembledGAB.indexOf("# Alpha");
|
||||
out.fbHasAllContent =
|
||||
assembledGAB.includes("Gamma text") &&
|
||||
assembledGAB.includes("Alpha body text") &&
|
||||
assembledGAB.includes("Beta body without heading");
|
||||
|
||||
// chapter breaks follow the reordered manuscript: still exactly 2 breaks,
|
||||
// now marking Alpha and Beta (Gamma is first, so it stays unmarked)
|
||||
const fbBreaksReordered = chapterBreakLines();
|
||||
out.fbBreaksAfterReorder = fbBreaksReordered.map((el) => el.textContent);
|
||||
out.fbBreakAfterReorderOk =
|
||||
fbBreaksReordered.length === 2 &&
|
||||
fbBreaksReordered.every((el) => {
|
||||
const t = el.textContent;
|
||||
return t.startsWith("# Alpha") || t.startsWith("# Beta");
|
||||
}) &&
|
||||
!allContentLines()[0].classList.contains("chapter-break");
|
||||
|
||||
// switching between an individual chapter and Full Book never shows stale data
|
||||
document.querySelector("#chapterList .chapter-row").click();
|
||||
await new Promise((r) => setTimeout(r, 200));
|
||||
document.getElementById("fullBookBtn").click();
|
||||
await new Promise((r) => setTimeout(r, 300));
|
||||
out.fbNoStale = fb.state.doc.toString() === assembledGAB;
|
||||
|
||||
// the Full Book pane must be a bounded flex column whose editor can be
|
||||
// scrolled, so every chapter is actually reachable on screen (a block
|
||||
// layout here clips the manuscript to the first screenful)
|
||||
const fbViewEl = document.getElementById("fullBookView");
|
||||
const fbHost = document.getElementById("fullBookHost");
|
||||
const fbEditor = fbHost.querySelector(".cm-editor");
|
||||
const vs = getComputedStyle(fbViewEl);
|
||||
out.fbViewDisplay = vs.display;
|
||||
out.fbViewDir = vs.flexDirection;
|
||||
out.fbEditorHeight = Math.round(fbEditor.getBoundingClientRect().height);
|
||||
out.fbViewportHeight = window.innerHeight;
|
||||
out.fbLayoutOk =
|
||||
vs.display === "flex" &&
|
||||
vs.flexDirection === "column" &&
|
||||
fbEditor.getBoundingClientRect().height <= window.innerHeight;
|
||||
|
||||
// closing and reopening the book preserves the chapter order
|
||||
await window.folio.openBookPath(BOOK2_DIR);
|
||||
await new Promise((r) => setTimeout(r, 400));
|
||||
const orderReopened = (await window.folio.getBookMeta()).chapterOrder;
|
||||
out.orderAfterReopen = orderReopened;
|
||||
out.reopenPersists = JSON.stringify(orderReopened) === JSON.stringify(orderReordered);
|
||||
|
||||
// deleting a chapter updates the sidebar and Full Book
|
||||
rowFor(alphaId).querySelector('[data-act="del"]').click();
|
||||
await new Promise((r) => setTimeout(r, 150));
|
||||
document.getElementById("modalOk").click();
|
||||
await new Promise((r) => setTimeout(r, 300));
|
||||
out.orderAfterDelete = (await window.folio.getBookMeta()).chapterOrder;
|
||||
out.deleteOk = !(await window.folio.getBookMeta()).chapterOrder.includes(alphaId);
|
||||
document.getElementById("fullBookBtn").click();
|
||||
await new Promise((r) => setTimeout(r, 300));
|
||||
const assembledAfterDelete = fb.state.doc.toString();
|
||||
out.fbAfterDelete = assembledAfterDelete;
|
||||
out.fbAfterDeleteOk =
|
||||
!assembledAfterDelete.includes("Alpha body text") && assembledAfterDelete.includes("Gamma text");
|
||||
|
||||
// renaming a chapter updates its Full Book heading
|
||||
rowFor(betaId).querySelector('[data-act="rename"]').click();
|
||||
await new Promise((r) => setTimeout(r, 150));
|
||||
document.getElementById("modalInput").value = "Beta Renamed";
|
||||
document.getElementById("modalOk").click();
|
||||
await new Promise((r) => setTimeout(r, 300));
|
||||
const assembledAfterRename = fb.state.doc.toString();
|
||||
out.fbAfterRename = assembledAfterRename;
|
||||
out.fbAfterRenameOk =
|
||||
assembledAfterRename.includes("# Beta Renamed") &&
|
||||
!assembledAfterRename.includes("# Beta\n\nBeta body without heading");
|
||||
|
||||
// a newly created empty chapter appears in Full Book with just its heading
|
||||
const emptyId = (await window.folio.createChapter("Empty")).id;
|
||||
await new Promise((r) => setTimeout(r, 300));
|
||||
document.getElementById("fullBookBtn").click();
|
||||
await new Promise((r) => setTimeout(r, 300));
|
||||
const assembledWithEmpty = fb.state.doc.toString();
|
||||
out.fbWithEmpty = assembledWithEmpty;
|
||||
out.fbEmptyOk = assembledWithEmpty.includes("# Empty") && assembledWithEmpty.includes("Beta Renamed");
|
||||
|
||||
// --- Export of the Full Book (PDF / DOCX / ODT) ---
|
||||
// The e2e harness sets FOLIO_TEST_PDF/DOCX/ODT, so the main process skips
|
||||
// the native save dialog and writes to those paths, returning the byte
|
||||
// count and whether the buffer starts with the right magic number. The
|
||||
// Full Book view is on screen here, so the PDF is the assembled manuscript
|
||||
// (the print stylesheet hides all app chrome).
|
||||
const exportMenuToggle = document.getElementById("exportMenuToggle");
|
||||
const exportMenu = document.getElementById("exportMenu");
|
||||
out.exportMenuPresent = !!exportMenuToggle && !!exportMenu;
|
||||
out.pdfExportPdfVisible = !document.getElementById("fullBookView").classList.contains("hidden");
|
||||
exportMenuToggle.click();
|
||||
await new Promise((r) => setTimeout(r, 50));
|
||||
out.exportMenuOpened = !exportMenu.classList.contains("hidden");
|
||||
const pdfItem = exportMenu.querySelector('[data-export="pdf"]');
|
||||
out.pdfItemPresent = !!pdfItem;
|
||||
pdfItem.click();
|
||||
await new Promise((r) => setTimeout(r, 2500));
|
||||
out.pdfStatusShown = document
|
||||
.getElementById("fullBookStatus")
|
||||
.textContent.startsWith("PDF saved to");
|
||||
const pdfRes = await window.folio.exportPdf();
|
||||
out.pdfExport =
|
||||
pdfRes && "filePath" in pdfRes
|
||||
? { filePath: pdfRes.filePath, bytes: pdfRes.bytes, pdfOk: pdfRes.pdfOk }
|
||||
: pdfRes;
|
||||
out.pdfExportOk =
|
||||
!!pdfRes &&
|
||||
!("error" in pdfRes) &&
|
||||
"filePath" in pdfRes &&
|
||||
pdfRes.filePath.endsWith(".pdf") &&
|
||||
typeof pdfRes.bytes === "number" &&
|
||||
pdfRes.bytes > 500 &&
|
||||
pdfRes.pdfOk === true;
|
||||
|
||||
// The printed book comes from the plain-HTML #printBook container (the
|
||||
// print stylesheet hides the whole app and shows #printBook instead), so
|
||||
// the PDF can never be limited to the CodeMirror viewport. Verify the
|
||||
// container is hidden on screen and carries every chapter.
|
||||
const printBookEl = document.getElementById("printBook");
|
||||
out.printBookHiddenOnScreen = getComputedStyle(printBookEl).display === "none";
|
||||
const printChapters = () =>
|
||||
Array.from(document.querySelectorAll("#printBook .print-chapter"));
|
||||
const pcsAtSmallBook = printChapters();
|
||||
out.printBookChapterCount = pcsAtSmallBook.length;
|
||||
out.printBookHasAllChapters =
|
||||
pcsAtSmallBook.length === 3 &&
|
||||
pcsAtSmallBook.some((s) => s.textContent.includes("Gamma text")) &&
|
||||
pcsAtSmallBook.some((s) => s.textContent.includes("Beta body without heading")) &&
|
||||
pcsAtSmallBook.some((s) => s.querySelector("h1")?.textContent === "Empty");
|
||||
|
||||
// Long book: every line must reach the print DOM — the guard against the
|
||||
// "PDF does not show the full book" bug where a long manuscript was cut
|
||||
// off after the first screenful.
|
||||
const longLines = Array.from(
|
||||
{ length: 1500 },
|
||||
(_, i) => `Line ${i} of a very long chapter that must all reach the printed book.`
|
||||
);
|
||||
const veryLongId = (await window.folio.createChapter("Very Long")).id;
|
||||
await window.folio.saveChapter(veryLongId, longLines.join("\n"));
|
||||
await new Promise((r) => setTimeout(r, 500));
|
||||
document.getElementById("fullBookBtn").click();
|
||||
await new Promise((r) => setTimeout(r, 400));
|
||||
const printBookText = printBookEl.textContent;
|
||||
out.printBookHasFirstLongLine = printBookText.includes("Line 0 of a very long chapter");
|
||||
out.printBookHasLastLongLine = printBookText.includes("Line 1499 of a very long chapter");
|
||||
out.printBookLongChapterCount = printChapters().length;
|
||||
out.printBookLongFull =
|
||||
out.printBookHasFirstLongLine &&
|
||||
out.printBookHasLastLongLine &&
|
||||
printChapters().length === 4;
|
||||
|
||||
const pdfLongRes = await window.folio.exportPdf();
|
||||
out.pdfLongExport =
|
||||
pdfLongRes && "filePath" in pdfLongRes
|
||||
? { filePath: pdfLongRes.filePath, bytes: pdfLongRes.bytes, pdfOk: pdfLongRes.pdfOk }
|
||||
: pdfLongRes;
|
||||
out.pdfLongExportOk =
|
||||
!!pdfLongRes &&
|
||||
!("error" in pdfLongRes) &&
|
||||
"filePath" in pdfLongRes &&
|
||||
pdfLongRes.bytes > pdfRes.bytes * 2 &&
|
||||
pdfLongRes.pdfOk === true;
|
||||
|
||||
// DOCX export: main process assembles the manuscript from the chapter
|
||||
// files (all four chapters, including the very long one), writes a ZIP
|
||||
// (PK magic) to FOLIO_TEST_DOCX, and reports its size. The deep content
|
||||
// checks live in tests/run-office-export-test.mjs; here we verify the
|
||||
// end-to-end IPC path, extension, magic number, and the status notice.
|
||||
const docxRes = await window.folio.exportDocx();
|
||||
out.docxExport =
|
||||
docxRes && "filePath" in docxRes
|
||||
? { filePath: docxRes.filePath, bytes: docxRes.bytes, docxOk: docxRes.docxOk }
|
||||
: docxRes;
|
||||
out.docxExportOk =
|
||||
!!docxRes &&
|
||||
!("error" in docxRes) &&
|
||||
"filePath" in docxRes &&
|
||||
docxRes.filePath.endsWith(".docx") &&
|
||||
typeof docxRes.bytes === "number" &&
|
||||
docxRes.bytes > 1000 &&
|
||||
docxRes.docxOk === true;
|
||||
|
||||
const odtRes = await window.folio.exportOdt();
|
||||
out.odtExport =
|
||||
odtRes && "filePath" in odtRes
|
||||
? { filePath: odtRes.filePath, bytes: odtRes.bytes, odtOk: odtRes.odtOk }
|
||||
: odtRes;
|
||||
out.odtExportOk =
|
||||
!!odtRes &&
|
||||
!("error" in odtRes) &&
|
||||
"filePath" in odtRes &&
|
||||
odtRes.filePath.endsWith(".odt") &&
|
||||
typeof odtRes.bytes === "number" &&
|
||||
odtRes.bytes > 1000 &&
|
||||
odtRes.odtOk === true;
|
||||
|
||||
// Formatting must survive into the printed book: markdown-it is configured
|
||||
// to pass through the toolbar's HTML (<u>, <span style=...>, <div>),
|
||||
// ==highlight== becomes <mark>, callouts become styled divs, and
|
||||
// wikilinks resolve to links — none of them may show up as raw source.
|
||||
const fmtId = (await window.folio.createChapter("Formatting")).id;
|
||||
await window.folio.saveChapter(
|
||||
fmtId,
|
||||
[
|
||||
"**bold** *italic* ~~strike~~ ==highlight== <u>under</u>",
|
||||
'<span style="color:#e11d48">red</span> <span style="background-color:#fde047">marked</span>',
|
||||
"",
|
||||
"> [!note] Callout title",
|
||||
"> callout body",
|
||||
"",
|
||||
"[[Beta Renamed]]",
|
||||
].join("\n")
|
||||
);
|
||||
await new Promise((r) => setTimeout(r, 500));
|
||||
document.getElementById("fullBookBtn").click();
|
||||
await new Promise((r) => setTimeout(r, 400));
|
||||
const pbInner = document.getElementById("printBook").innerHTML;
|
||||
const pbText = document.getElementById("printBook").textContent;
|
||||
out.printFmtBold = /<strong>bold<\/strong>/.test(pbInner);
|
||||
out.printFmtMark = /<mark>highlight<\/mark>/.test(pbInner);
|
||||
out.printFmtUnderline = /<u>under<\/u>/.test(pbInner);
|
||||
out.printFmtColor = /<span style="color:#e11d48">red<\/span>/.test(pbInner);
|
||||
out.printFmtNoRawEq = !pbText.includes("==highlight==");
|
||||
out.printFmtCallout = /class="callout callout-note"/.test(pbInner);
|
||||
out.printFmtNoRawCallout = !pbText.includes("[!note]");
|
||||
out.printFmtWikilink = /class="wikilink internal"/.test(pbInner);
|
||||
out.printFmtNoRawWikilink = !pbText.includes("[[Beta Renamed]]");
|
||||
out.printFmtOk =
|
||||
out.printFmtBold &&
|
||||
out.printFmtMark &&
|
||||
out.printFmtUnderline &&
|
||||
out.printFmtColor &&
|
||||
out.printFmtNoRawEq &&
|
||||
out.printFmtCallout &&
|
||||
out.printFmtNoRawCallout &&
|
||||
out.printFmtWikilink &&
|
||||
out.printFmtNoRawWikilink;
|
||||
|
||||
// --- chapter navigation inside Full Book (third book: One/Two/Three/Four) ---
|
||||
// Four chapters, each taller than the viewport, so every jump target lands
|
||||
// its own heading near the top of the Full Book viewport.
|
||||
await window.folio.openBookPath(BOOK3_DIR);
|
||||
await new Promise((r) => setTimeout(r, 400));
|
||||
const navBook = window.__folioFullBookEditor;
|
||||
const navViewEl = document.getElementById("fullBookView");
|
||||
const navSel = document.getElementById("fbChapterSelect");
|
||||
const navPrev = document.getElementById("fbPrevBtn");
|
||||
const navNext = document.getElementById("fbNextBtn");
|
||||
document.getElementById("fullBookBtn").click();
|
||||
await new Promise((r) => setTimeout(r, 300));
|
||||
|
||||
const navOptions = () => Array.from(navSel.options).map((o) => o.textContent);
|
||||
const navDocStarts = () => {
|
||||
const doc = navBook.state.doc.toString();
|
||||
return ["One", "Two", "Three", "Four"].map((t) => doc.indexOf("# " + t));
|
||||
};
|
||||
// How far (px) the given chapter's heading sits below the top of the Full
|
||||
// Book viewport. The jump is considered correct when it is near the top.
|
||||
const chapterTopY = (title) => {
|
||||
const doc = navBook.state.doc.toString();
|
||||
const start = doc.indexOf("# " + title);
|
||||
if (start < 0) return -1;
|
||||
const rect = navBook.scrollDOM.getBoundingClientRect();
|
||||
const c = navBook.coordsAtPos(start);
|
||||
if (!c) return -1;
|
||||
return c.top - rect.top;
|
||||
};
|
||||
const navTopChapter = () => {
|
||||
const scroller = navBook.scrollDOM;
|
||||
const rect = scroller.getBoundingClientRect();
|
||||
const pos = navBook.posAtCoords({ x: rect.left + 4, y: rect.top + 30 });
|
||||
if (pos === null || pos === undefined) return -1;
|
||||
const starts = navDocStarts();
|
||||
let idx = 0;
|
||||
for (let i = 0; i < starts.length; i++) {
|
||||
if (starts[i] >= 0 && starts[i] <= pos) idx = i;
|
||||
else break;
|
||||
}
|
||||
return idx;
|
||||
};
|
||||
const navTopLine = () => {
|
||||
const scroller = navBook.scrollDOM;
|
||||
const rect = scroller.getBoundingClientRect();
|
||||
const pos = navBook.posAtCoords({ x: rect.left + 4, y: rect.top + 30 });
|
||||
if (pos === null || pos === undefined) return "";
|
||||
return navBook.state.doc.lineAt(pos).text;
|
||||
};
|
||||
const navSnap = () => ({
|
||||
value: navSel.value,
|
||||
prevDisabled: navPrev.disabled,
|
||||
nextDisabled: navNext.disabled,
|
||||
topChapter: navTopChapter(),
|
||||
topLine: navTopLine(),
|
||||
fullBookVisible:
|
||||
!navViewEl.classList.contains("hidden") &&
|
||||
document.getElementById("chapterView").classList.contains("hidden"),
|
||||
});
|
||||
const navWait = (ms) => new Promise((r) => setTimeout(r, ms));
|
||||
|
||||
// 1. Full Book opens at Chapter One; Previous disabled.
|
||||
const navStart = navSnap();
|
||||
out.navOptions = navOptions();
|
||||
out.navOptionsOk =
|
||||
JSON.stringify(out.navOptions) === JSON.stringify(["One", "Two", "Three", "Four"]);
|
||||
out.navOpenAtOne = navStart;
|
||||
out.navOpenAtOneOk =
|
||||
navStart.value === "ch1" &&
|
||||
navStart.prevDisabled &&
|
||||
!navStart.nextDisabled &&
|
||||
navStart.fullBookVisible &&
|
||||
navStart.topChapter === 0;
|
||||
|
||||
// 2. Next -> Chapter Two.
|
||||
navNext.click();
|
||||
await navWait(300);
|
||||
const navTwo = navSnap();
|
||||
out.navNextToTwo = navTwo;
|
||||
out.navNextToTwoOk =
|
||||
navTwo.value === "ch2" &&
|
||||
!navTwo.prevDisabled &&
|
||||
!navTwo.nextDisabled &&
|
||||
navTwo.fullBookVisible &&
|
||||
navTwo.topChapter === 1 &&
|
||||
chapterTopY("Two") >= 0 &&
|
||||
chapterTopY("Two") < 200;
|
||||
|
||||
// 3. Next -> Chapter Three.
|
||||
navNext.click();
|
||||
await navWait(300);
|
||||
const navThree = navSnap();
|
||||
out.navNextToThree = navThree;
|
||||
out.navNextToThreeOk =
|
||||
navThree.value === "ch3" &&
|
||||
navThree.fullBookVisible &&
|
||||
navThree.topChapter === 2 &&
|
||||
chapterTopY("Three") >= 0 &&
|
||||
chapterTopY("Three") < 200;
|
||||
|
||||
// 4. Previous -> back to Chapter Two.
|
||||
navPrev.click();
|
||||
await navWait(300);
|
||||
const navBackTwo = navSnap();
|
||||
out.navPrevToTwo = navBackTwo;
|
||||
out.navPrevToTwoOk =
|
||||
navBackTwo.value === "ch2" &&
|
||||
navBackTwo.topChapter === 1 &&
|
||||
chapterTopY("Two") >= 0 &&
|
||||
chapterTopY("Two") < 200;
|
||||
|
||||
// 5. Dropdown -> Chapter Four (Next disabled at the last chapter).
|
||||
navSel.value = "ch4";
|
||||
navSel.dispatchEvent(new Event("change", { bubbles: true }));
|
||||
await navWait(300);
|
||||
const navFour = navSnap();
|
||||
out.navDropToFour = navFour;
|
||||
out.navDropToFourOk =
|
||||
navFour.value === "ch4" &&
|
||||
navFour.nextDisabled &&
|
||||
navFour.fullBookVisible &&
|
||||
chapterTopY("Four") >= 0 &&
|
||||
chapterTopY("Four") < 200;
|
||||
|
||||
// 6. Manual scrolling updates the selector: scroll to the very bottom
|
||||
// (last chapter) and to the very top (first chapter).
|
||||
navBook.scrollDOM.scrollTop = navBook.scrollDOM.scrollHeight;
|
||||
navBook.scrollDOM.dispatchEvent(new Event("scroll"));
|
||||
await navWait(250);
|
||||
const navScrolledBottom = navSnap();
|
||||
out.navScrollBottomTracks =
|
||||
navScrolledBottom.value === "ch4" && navScrolledBottom.nextDisabled;
|
||||
navBook.scrollDOM.scrollTop = 0;
|
||||
navBook.scrollDOM.dispatchEvent(new Event("scroll"));
|
||||
await navWait(250);
|
||||
const navScrolledTop = navSnap();
|
||||
out.navScrollTopTracks = navScrolledTop.value === "ch1" && navScrolledTop.prevDisabled;
|
||||
|
||||
// 7. Reordering chapters in the sidebar must be reflected immediately, even
|
||||
// while Full Book stays open (no restart). Drag the last row onto the first.
|
||||
const navRows = document.getElementById("chapterList").querySelectorAll(".chapter-row");
|
||||
const navDt = new DataTransfer();
|
||||
navRows[3].dispatchEvent(new DragEvent("dragstart", { dataTransfer: navDt, bubbles: true }));
|
||||
navRows[0].dispatchEvent(new DragEvent("dragover", { dataTransfer: navDt, bubbles: true }));
|
||||
navRows[0].dispatchEvent(new DragEvent("drop", { dataTransfer: navDt, bubbles: true }));
|
||||
navRows[3].dispatchEvent(new DragEvent("dragend", { dataTransfer: navDt, bubbles: true }));
|
||||
await navWait(400);
|
||||
const navOrderReordered = (await window.folio.getBookMeta()).chapterOrder;
|
||||
out.navOrderReordered = navOrderReordered;
|
||||
out.navReorderChanged =
|
||||
JSON.stringify(navOrderReordered) === JSON.stringify(["ch4", "ch1", "ch2", "ch3"]);
|
||||
const navOptionsAfter = navOptions();
|
||||
out.navOptionsAfterReorder = navOptionsAfter;
|
||||
out.navDropdownFollowsReorder =
|
||||
JSON.stringify(navOptionsAfter) === JSON.stringify(["Four", "One", "Two", "Three"]);
|
||||
|
||||
// Full Book is re-rendered at the top: the new first chapter (Four) is
|
||||
// current, Previous is disabled, and Next now moves to One.
|
||||
const navAfterReorder = navSnap();
|
||||
out.navAfterReorder = navAfterReorder;
|
||||
out.navFollowsNewOrder =
|
||||
navAfterReorder.value === "ch4" &&
|
||||
navAfterReorder.prevDisabled &&
|
||||
navAfterReorder.fullBookVisible &&
|
||||
chapterTopY("Four") >= 0 &&
|
||||
chapterTopY("Four") < 200;
|
||||
navNext.click();
|
||||
await navWait(300);
|
||||
const navNextAfterReorder = navSnap();
|
||||
out.navNextAfterReorder = navNextAfterReorder;
|
||||
out.navNextAfterReorderOk =
|
||||
navNextAfterReorder.value === "ch1" &&
|
||||
navNextAfterReorder.fullBookVisible &&
|
||||
chapterTopY("One") >= 0 &&
|
||||
chapterTopY("One") < 200;
|
||||
|
||||
// 8. Page breaks still render after navigation. Break markers are
|
||||
// decorations on the first line of every chapter after the first, and
|
||||
// CodeMirror only puts rendered lines in the DOM, so navigate to a middle
|
||||
// chapter and confirm its marked first line is present.
|
||||
navSel.value = "ch2";
|
||||
navSel.dispatchEvent(new Event("change", { bubbles: true }));
|
||||
await navWait(300);
|
||||
const navBreaks = chapterBreakLines();
|
||||
out.navBreakCount = navBreaks.length;
|
||||
out.navBreaksOk = navBreaks.length >= 1 && navBreaks.some((el) => el.textContent.startsWith("# Two"));
|
||||
|
||||
// --- theme system: selector, immediate switching, persistence ---
|
||||
const themeSelect = document.getElementById("themeSelect");
|
||||
const themeVar = (name) =>
|
||||
getComputedStyle(document.documentElement).getPropertyValue(name).trim();
|
||||
const docBeforeTheme = navBook.state.doc.toString();
|
||||
out.themeSelectPresent = !!themeSelect;
|
||||
out.themeSelectOptions = themeSelect
|
||||
? Array.from(themeSelect.options).map((o) => o.value)
|
||||
: [];
|
||||
out.themeHasLight = out.themeSelectOptions.includes("light");
|
||||
out.themeHasNeonflux = out.themeSelectOptions.includes("neonflux");
|
||||
out.themeInitialStored = await window.folio.getTheme();
|
||||
out.themeInitialAttr = document.documentElement.getAttribute("data-theme");
|
||||
// Old saves may hold "default", which must resolve to the Light theme
|
||||
// (migration). Valid saved themes must be restored exactly on startup.
|
||||
out.themeInitialConsistent =
|
||||
out.themeInitialStored === "default" || out.themeInitialStored === "light"
|
||||
? out.themeInitialAttr === "light"
|
||||
: out.themeInitialAttr === out.themeInitialStored;
|
||||
|
||||
// Switch to Neonflux: applies immediately and persists via IPC.
|
||||
themeSelect.value = "neonflux";
|
||||
themeSelect.dispatchEvent(new Event("change", { bubbles: true }));
|
||||
await navWait(200);
|
||||
out.themeNeonfluxAttr = document.documentElement.getAttribute("data-theme");
|
||||
out.themeNeonfluxStored = await window.folio.getTheme();
|
||||
const neonBg = themeVar("--color-bg");
|
||||
const neonAccent = themeVar("--color-accent");
|
||||
out.themeNeonfluxBg = neonBg;
|
||||
out.themeNeonfluxAccent = neonAccent;
|
||||
out.themeNeonfluxApplied =
|
||||
out.themeNeonfluxAttr === "neonflux" &&
|
||||
out.themeNeonfluxStored === "neonflux" &&
|
||||
neonBg.toLowerCase() === "#0a0d14" &&
|
||||
neonAccent.toLowerCase() === "#22d3ee";
|
||||
|
||||
// Neonflux is dark: computed background of the app should be near-black.
|
||||
const neonBodyBg = getComputedStyle(document.body).backgroundColor;
|
||||
out.themeNeonfluxDark = /rgb\((10|11|12|9|8),/.test(neonBodyBg);
|
||||
|
||||
// Switching themes must not touch the manuscript content.
|
||||
out.themeContentUnchanged = navBook.state.doc.toString() === docBeforeTheme;
|
||||
|
||||
// Switch to Light: applies immediately and persists.
|
||||
themeSelect.value = "light";
|
||||
themeSelect.dispatchEvent(new Event("change", { bubbles: true }));
|
||||
await navWait(200);
|
||||
out.themeLightAttr = document.documentElement.getAttribute("data-theme");
|
||||
out.themeLightStored = await window.folio.getTheme();
|
||||
const lightBg = themeVar("--color-bg");
|
||||
const lightAccent = themeVar("--color-accent");
|
||||
out.themeLightBg = lightBg;
|
||||
out.themeLightAccent = lightAccent;
|
||||
out.themeLightApplied =
|
||||
out.themeLightAttr === "light" &&
|
||||
out.themeLightStored === "light" &&
|
||||
lightBg.toLowerCase() === "#fffdf9" &&
|
||||
lightAccent.toLowerCase() === "#2f6b5f";
|
||||
|
||||
// Light is light: computed background must be a warm off-white, never the
|
||||
// near-black used by Neonflux.
|
||||
const lightBodyBg = getComputedStyle(document.body).backgroundColor;
|
||||
out.themeLightNotDark = !/rgb\((10|11|12|9|8),/.test(lightBodyBg);
|
||||
out.themeContentUnchanged2 = navBook.state.doc.toString() === docBeforeTheme;
|
||||
|
||||
// Round-trip: back to Neonflux restores it exactly.
|
||||
themeSelect.value = "neonflux";
|
||||
themeSelect.dispatchEvent(new Event("change", { bubbles: true }));
|
||||
await navWait(200);
|
||||
out.themeRoundTripAttr = document.documentElement.getAttribute("data-theme");
|
||||
out.themeRoundTripStored = await window.folio.getTheme();
|
||||
out.themeRoundTripOk =
|
||||
out.themeRoundTripAttr === "neonflux" &&
|
||||
out.themeRoundTripStored === "neonflux" &&
|
||||
themeVar("--color-bg").toLowerCase() === "#0a0d14" &&
|
||||
themeVar("--color-accent").toLowerCase() === "#22d3ee";
|
||||
|
||||
// Leave the app on Light so subsequent runs start deterministically.
|
||||
themeSelect.value = "light";
|
||||
themeSelect.dispatchEvent(new Event("change", { bubbles: true }));
|
||||
await navWait(200);
|
||||
out.themeFinalAttr = document.documentElement.getAttribute("data-theme");
|
||||
out.themeFinalStored = await window.folio.getTheme();
|
||||
out.themeFinalLightOk = out.themeFinalAttr === "light" && out.themeFinalStored === "light";
|
||||
out.themeContentUnchanged3 = navBook.state.doc.toString() === docBeforeTheme;
|
||||
|
||||
const okFlags = [
|
||||
"dndMoved",
|
||||
"kbMoved",
|
||||
"modalShown",
|
||||
"uiCreated",
|
||||
"hasEditor",
|
||||
"editorAutosaved",
|
||||
"toolbarBoldOk",
|
||||
"focusAfterBold",
|
||||
"shortcutBoldOk",
|
||||
"headingOk",
|
||||
"undoOk",
|
||||
"redoOk",
|
||||
"strikeShortcutOk",
|
||||
"fencedCodeOk",
|
||||
"underlineOk",
|
||||
"checklistOk",
|
||||
"highlightOk",
|
||||
"tableOk",
|
||||
"painterOk",
|
||||
"colorMenuOpens",
|
||||
"colorMenuOnScreen",
|
||||
"colorApplied",
|
||||
"customColorApplied",
|
||||
"formattingAutosaved",
|
||||
"fullBookShown",
|
||||
"assembledOk",
|
||||
"fullBookStatsOk",
|
||||
"chapterBreakCountOk",
|
||||
"firstChapterUnmarked",
|
||||
"breaksAtChapterStarts",
|
||||
"printBreakRule",
|
||||
"printBreakFallback",
|
||||
"printBreakScoped",
|
||||
"copyAllOk",
|
||||
"copyStatusShown",
|
||||
"fullBookSelectAllOk",
|
||||
"backToChapterOk",
|
||||
"reorderOk",
|
||||
"fbAfterReorderOk",
|
||||
"fbHasAllContent",
|
||||
"fbBreakAfterReorderOk",
|
||||
"fbNoStale",
|
||||
"fbLayoutOk",
|
||||
"reopenPersists",
|
||||
"deleteOk",
|
||||
"fbAfterDeleteOk",
|
||||
"fbAfterRenameOk",
|
||||
"fbEmptyOk",
|
||||
"exportMenuPresent",
|
||||
"exportMenuOpened",
|
||||
"pdfItemPresent",
|
||||
"pdfExportPdfVisible",
|
||||
"pdfStatusShown",
|
||||
"pdfExportOk",
|
||||
"printBookHiddenOnScreen",
|
||||
"printBookHasAllChapters",
|
||||
"printBookLongFull",
|
||||
"pdfLongExportOk",
|
||||
"docxExportOk",
|
||||
"odtExportOk",
|
||||
"printFmtOk",
|
||||
"navOptionsOk",
|
||||
"navOpenAtOneOk",
|
||||
"navNextToTwoOk",
|
||||
"navNextToThreeOk",
|
||||
"navPrevToTwoOk",
|
||||
"navDropToFourOk",
|
||||
"navScrollBottomTracks",
|
||||
"navScrollTopTracks",
|
||||
"navReorderChanged",
|
||||
"navDropdownFollowsReorder",
|
||||
"navFollowsNewOrder",
|
||||
"navNextAfterReorderOk",
|
||||
"navBreaksOk",
|
||||
"themeSelectPresent",
|
||||
"themeHasLight",
|
||||
"themeHasNeonflux",
|
||||
"themeInitialConsistent",
|
||||
"themeNeonfluxApplied",
|
||||
"themeNeonfluxDark",
|
||||
"themeContentUnchanged",
|
||||
"themeLightApplied",
|
||||
"themeLightNotDark",
|
||||
"themeContentUnchanged2",
|
||||
"themeRoundTripOk",
|
||||
"themeFinalLightOk",
|
||||
"themeContentUnchanged3",
|
||||
];
|
||||
out.all = okFlags.every((f) => out[f] === true);
|
||||
} catch (e) {
|
||||
out.error = String((e && e.message) || e);
|
||||
}
|
||||
return out;
|
||||
})();
|
||||
Loading…
Add table
Add a link
Reference in a new issue