import { test } from "node:test"; import assert from "node:assert/strict"; import { mkdtemp, writeFile, rm } from "node:fs/promises"; import { tmpdir } from "node:os"; import path from "node:path"; import JSZip from "jszip"; import { REPORT_SECTIONS, parseDonorSections, donorSections, presentSections, } from "../src/report.js"; /** * The report's structure is data, not a constant. * * The renderer never cared what a section was CALLED — `buildBody` emits * `content[name]` for any name, which is how the Image Credits appendix has * always worked without being in the graded list. What was fixed was the LIST, * and that belongs to whichever institution's template is installed. */ /** A donor body: numbered bold headings, plus the things that look like them * and are not — a numbered reference list, and a table of contents. */ function donorXml(headings, { refs = [], toc = [] } = {}) { const heading = (n, title) => `` + `${n}. ${title}`; const ref = (n, text) => `${n}. ${text}`; const tocTable = toc.length ? `${toc.map((t, i) => `${i + 1}. ${t}`, ).join("")}` : ""; return `${tocTable}` + headings.map((h, i) => heading(i + 1, h)).join("") + refs.map((r, i) => ref(i + 1, r)).join("") + ``; } async function donorFile(t, xml) { const dir = await mkdtemp(path.join(tmpdir(), "forge-structure-")); t.after(() => rm(dir, { recursive: true, force: true })); const zip = new JSZip(); zip.file("word/document.xml", xml); const file = path.join(dir, "donor.docx"); await writeFile(file, await zip.generateAsync({ type: "nodebuffer" })); return file; } /* ------------------------------------------------- reading the structure */ test("parseDonorSections reads the template's own numbered heading run", () => { assert.deepEqual( parseDonorSections(donorXml(["Abstract", "Introduction", "Methodology", "Results", "References"])), ["Abstract", "Introduction", "Methodology", "Results", "References"], ); }); test("a numbered reference list is not a structure — bold is the discriminator", () => { // References are the other consecutively numbered thing in a report, they // start at 1 like the headings do, and they are not bold. Numbering alone // would read the bibliography as the section list. const xml = donorXml(["Abstract", "Introduction", "Conclusion", "References"], { refs: ["Author, A. (2020). A paper.", "Author, B. (2021). Another.", "Author, C. (2022). A third."], }); assert.deepEqual(parseDonorSections(xml), ["Abstract", "Introduction", "Conclusion", "References"]); }); test("the table of contents is not mistaken for the structure", () => { // The TOC lists the same names in the same order inside a table. Reading it // as well would double every section. const names = ["Abstract", "Introduction", "Conclusion", "References"]; assert.deepEqual(parseDonorSections(donorXml(names, { toc: names })), names); }); test("a donor that declares nothing recognisable yields null, not a guess", () => { assert.equal(parseDonorSections("Hello."), null); // Two headings is not a structure — too short to be anything but a stray. assert.equal(parseDonorSections(donorXml(["Abstract", "References"])), null); }); test("a heading run must be consecutive from 1", () => { const xml = `4. Stray` + donorXml(["Abstract", "Introduction", "Conclusion", "References"]); assert.deepEqual(parseDonorSections(xml), ["Abstract", "Introduction", "Conclusion", "References"]); }); test("donorSections reads a real .docx, and answers null for one it cannot", async (t) => { const file = await donorFile(t, donorXml(["Abstract", "Design", "Evaluation", "References"])); assert.deepEqual(await donorSections(file), ["Abstract", "Design", "Evaluation", "References"]); assert.equal(await donorSections(null), null); assert.equal(await donorSections(path.join(path.dirname(file), "absent.docx")), null); }); test("donorSections re-reads a template that was replaced", async (t) => { // The cache is keyed on the file's mtime and size, not its path: an admin // uploading a new template must not keep getting the old structure. const file = await donorFile(t, donorXml(["Abstract", "Design", "Evaluation", "References"])); assert.deepEqual(await donorSections(file), ["Abstract", "Design", "Evaluation", "References"]); const zip = new JSZip(); zip.file("word/document.xml", donorXml(["Summary", "Method", "Findings", "Sources"])); await writeFile(file, await zip.generateAsync({ type: "nodebuffer" })); assert.deepEqual(await donorSections(file), ["Summary", "Method", "Findings", "Sources"]); }); /* --------------------------------------------- emitting in that structure */ const withContent = (names) => Object.fromEntries(names.map((n) => [n, { paragraphs: [`${n} prose.`] }])); test("presentSections follows the report's declared order", () => { const order = ["Abstract", "Introduction", "Depot Case Study", "Conclusion", "References"]; const report = { title: "T", order, content: withContent(order) }; assert.deepEqual(presentSections(report), order); }); test("presentSections falls back to the graded order for a report with none", () => { // Every report written before the structure became data. const report = { title: "T", content: withContent(["Conclusion", "Abstract", "References"]) }; assert.deepEqual(presentSections(report), ["Abstract", "Conclusion", "References"]); }); test("a section with content but missing from the order is still emitted", () => { // Silently dropping it would lose model-written prose to a bookkeeping slip, // which is the worst available outcome — the .docx renders and looks fine. const report = { title: "T", order: ["Abstract", "Conclusion"], content: withContent(["Abstract", "Conclusion", "References", "Late Addition"]), }; const out = presentSections(report); assert.ok(out.includes("References"), out.join(", ")); assert.ok(out.includes("Late Addition"), out.join(", ")); assert.equal(out[0], "Abstract"); }); test("an empty section is skipped even when the order names it", () => { const report = { title: "T", order: ["Abstract", "Methodology", "References"], content: { Abstract: { paragraphs: ["A."] }, Methodology: { paragraphs: [] }, References: { entries: ["1. S."] } }, }; assert.deepEqual(presentSections(report), ["Abstract", "References"]); }); test("a duplicated name in the order is emitted once", () => { const report = { title: "T", order: ["Abstract", "Abstract", "References"], content: withContent(["Abstract", "References"]), }; assert.deepEqual(presentSections(report), ["Abstract", "References"]); }); test("the graded eight remain the fallback structure", () => { assert.deepEqual(REPORT_SECTIONS.slice(0, 3), ["Abstract", "Acknowledgement", "Introduction"]); assert.equal(REPORT_SECTIONS.at(-1), "References"); });