From 6b1a9da5cc052d4dbec7c9a2ad57e50c200b98ee Mon Sep 17 00:00:00 2001 From: avi Date: Mon, 17 Aug 2026 19:33:41 -0500 Subject: [PATCH] Fix Full Book assembly to always render from canonical chapter metadata Full Book now assembles from a fresh read of the canonical book metadata (chapter order + chapter entries) instead of the renderer's in-memory chapter array, so it always contains every chapter in the current order and can never render from stale or partially-loaded state. The chapter sidebar and Full Book share the same single source of truth. Also refresh Full Book whenever book metadata loads, and extend the e2e scenario to cover reordering to a new order, order persistence across reopen, and delete/rename/new-empty chapters all being reflected. --- src/renderer/book.ts | 16 +++++-- tests/selftest-scenario.js | 92 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 104 insertions(+), 4 deletions(-) diff --git a/src/renderer/book.ts b/src/renderer/book.ts index c05b990..2aa1eb4 100644 --- a/src/renderer/book.ts +++ b/src/renderer/book.ts @@ -402,6 +402,7 @@ export function initBook(api: FolioAPI) { if (ordered.length) showPlaceholder(); else showPlaceholder(); } + await refreshFullBook(); } async function refreshMeta() { @@ -410,7 +411,6 @@ export function initBook(api: FolioAPI) { selectedId = null; showPlaceholder(); } - await refreshFullBook(); } function renderChapterList() { @@ -501,11 +501,19 @@ export function initBook(api: FolioAPI) { return false; } + // Assemble the whole book from the canonical book metadata (the same source + // the chapter sidebar uses) so Full Book always reflects the current chapter + // set and order, regardless of any in-memory renderer state. async function assembleFullBook(): Promise { + const res = await api.getBookMeta(); + if (!res || typeof res !== "object" || "error" in res) return ""; + const book = res as BookMeta; const parts: string[] = []; - for (const ch of ordered) { - const res = await api.getChapterContent(ch.id); - const content = typeof res === "string" ? res : ""; + for (const id of book.chapterOrder) { + const ch = book.chapters[id]; + if (!ch) continue; + const contentRes = await api.getChapterContent(ch.id); + const content = typeof contentRes === "string" ? contentRes : ""; const body = content.replace(/^\n+/, "").replace(/\s+$/, ""); const block: string[] = []; if (!chapterHasOwnHeading(content, ch.title)) { diff --git a/tests/selftest-scenario.js b/tests/selftest-scenario.js index 5f530ab..1a87ae5 100644 --- a/tests/selftest-scenario.js +++ b/tests/selftest-scenario.js @@ -269,6 +269,89 @@ const BOOK2_DIR = __BOOKDIR2_VALUE__; !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"); + + // 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; + + // 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"); + const okFlags = [ "dndMoved", "kbMoved", @@ -297,6 +380,15 @@ const BOOK2_DIR = __BOOKDIR2_VALUE__; "copyStatusShown", "fullBookSelectAllOk", "backToChapterOk", + "reorderOk", + "fbAfterReorderOk", + "fbHasAllContent", + "fbNoStale", + "reopenPersists", + "deleteOk", + "fbAfterDeleteOk", + "fbAfterRenameOk", + "fbEmptyOk", ]; out.all = okFlags.every((f) => out[f] === true); } catch (e) {