import { test } from "node:test"; import assert from "node:assert/strict"; import { mkdtemp, rm, mkdir, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import path from "node:path"; import { buildBody, presentSections, tocTable, locatePages, IMAGE_CREDITS, REPORT_SECTIONS } from "../src/report.js"; import { readCredits, isCitable, creditText, sourceLabel, providerOf } from "../src/credits.js"; const report = { title: "A Report", content: { Introduction: { paragraphs: ["It begins."] }, Conclusion: { paragraphs: ["It ends."] }, }, }; const identity = { institution: {}, team: { members: [] }, academic: {} }; const credit = (over = {}) => ({ file: "assets/auto/abc.png", query: "solar array", title: "Rooftop array", creator: "A. Photographer", licence: "CC BY 4.0", licence_code: "cc-by-4.0", licence_url: "https://creativecommons.org/licenses/by/4.0/", attribution_required: true, source: "wikimedia-commons", landing: "https://commons.wikimedia.org/wiki/File:X", slide: 3, ...over, }); /* ------------------------------------------------------------ classifying */ test("a report cites institutions, not stock platforms", () => { for (const s of ["wikimedia-commons", "openverse/nasa", "openverse/met", "openverse/smithsonian_gardens", "openverse/europeana"]) { assert.equal(isCitable({ source: s }), true, `${s} should be citable`); } for (const s of ["openverse/flickr", "openverse/stocksnap", "openverse/rawpixel", "openverse/sketchfab"]) { assert.equal(isCitable({ source: s }), false, `${s} should not be citable`); } assert.equal(isCitable({ source: "" }), false, "an unnamed source is not citable"); assert.equal(isCitable({}), false); }); test("the denylist fails open, so a provider added upstream still gets credited", () => { // The alternative — an allowlist of the 42 institutions Openverse carries — // would drop a legitimate museum silently the day it is added. assert.equal(isCitable({ source: "openverse/some_new_national_archive" }), true); }); test("a source reads as its name, not its slug", () => { assert.equal(sourceLabel("wikimedia-commons"), "Wikimedia Commons"); assert.equal(sourceLabel("openverse/nasa"), "NASA"); assert.equal(sourceLabel("openverse/clevelandmuseum"), "Cleveland Museum of Art"); assert.match(sourceLabel("openverse/smithsonian_postal_museum"), /^Smithsonian Institution — /); assert.equal(providerOf("openverse/flickr"), "flickr"); assert.equal(providerOf("wikimedia-commons"), "wikimedia-commons"); }); test("a credit reads as a sentence with what, who, licence and where", () => { const text = creditText(credit()); assert.match(text, /Rooftop array/); assert.match(text, /by A\. Photographer/); assert.match(text, /CC BY 4\.0/); assert.match(text, /Via Wikimedia Commons/); }); test("a credit with no title falls back to the query, never to nothing", () => { assert.match(creditText(credit({ title: null })), /solar array/); assert.match(creditText({}), /Untitled image/); }); /* ---------------------------------------------------------------- the body */ test("the credits appendix is not a section a model can write into", () => { // It must not be in REPORT_SECTIONS: that list is the graded structure the // planner picks from, and provenance is a record, not prose. assert.ok(!REPORT_SECTIONS.includes(IMAGE_CREDITS)); assert.ok(!presentSections(report).includes(IMAGE_CREDITS)); }); test("the appendix renders one numbered entry per picture, with its slide", () => { const present = [...presentSections(report), IMAGE_CREDITS]; const xml = buildBody(report, identity, present, {}, { includeToc: false, imageCredits: [credit(), credit({ title: "Cell diagram", slide: 7, source: "openverse/nasa" })], }); assert.match(xml, /3\. Image Credits/, "it is numbered after the authored sections"); assert.match(xml, /1\. Slide 3\. Rooftop array/); assert.match(xml, /2\. Slide 7\. Cell diagram/); assert.match(xml, /Via NASA/); }); test("without credits the appendix leaves no trace in the document", () => { const xml = buildBody(report, identity, presentSections(report), {}, { includeToc: false }); assert.ok(!xml.includes("Image Credits")); assert.match(xml, /1\. Introduction/); assert.match(xml, /2\. Conclusion/); }); test("the appendix takes a numbered TOC row like any other section", () => { const present = [...presentSections(report), IMAGE_CREDITS]; const xml = tocTable(present, { Introduction: 1, Conclusion: 4, [IMAGE_CREDITS]: 5 }); // Read the cells back rather than matching raw XML: the gap between a title // and its page cell is a few hundred bytes of table properties. const cells = [...xml.matchAll(/([^<]*)<\/w:t>/g)].map((m) => m[1]); const at = cells.indexOf("IMAGE CREDITS"); assert.ok(at > 0, `expected an IMAGE CREDITS row, got ${JSON.stringify(cells)}`); assert.equal(cells[at - 1], "3", "numbered after the two authored sections"); assert.equal(cells[at + 1], "5", "and carrying its page, which is the point of riding `present`"); }); test("the page locator finds the appendix by the same heading buildBody wrote", () => { const present = [...presentSections(report), IMAGE_CREDITS]; // Two pages; the appendix heading sits on the second. const pdfText = "1. Introduction\nIt begins.\n\f2. Conclusion\nIt ends.\n3. Image Credits\n"; const pages = locatePages(pdfText, present); assert.equal(pages[IMAGE_CREDITS], 2, "the heading text the locator hunts is the one the body emits"); }); /* ---------------------------------------------------------------- on disk */ test("reading credits from a deck with none is empty, never a throw", async () => { const dir = await mkdtemp(path.join(tmpdir(), "forge-credits-")); try { assert.deepEqual(await readCredits(dir), []); await mkdir(path.join(dir, "assets", "auto"), { recursive: true }); await writeFile(path.join(dir, "assets", "auto", "credits.json"), "{ not json", "utf8"); assert.deepEqual(await readCredits(dir), [], "a corrupt record is not a crash"); await writeFile(path.join(dir, "assets", "auto", "credits.json"), JSON.stringify([credit()]), "utf8"); const got = await readCredits(dir); assert.equal(got.length, 1); assert.equal(got[0].title, "Rooftop array"); } finally { await rm(dir, { recursive: true, force: true }); } }); /* ----------------------------------------------------------- the deck slide */ test("a deck whose pictures owe nothing gets no extra slide", async () => { const { creditsSlide } = await import("../src/credits.js"); assert.equal(creditsSlide([]), null); assert.equal(creditsSlide(null), null); // Public domain and CC0 owe nothing, and the licence ladder prefers them, so // this is the common case rather than the edge one. assert.equal(creditsSlide([credit({ attribution_required: false, licence: "CC0 1.0" })]), null); }); test("only the images that owe attribution reach the slide", async () => { const { creditsSlide } = await import("../src/credits.js"); const slide = creditsSlide([ credit({ title: "A", attribution_required: true }), credit({ title: "B", attribution_required: false, licence: "CC0 1.0" }), credit({ title: "C", attribution_required: true, slide: 7, creator: "B. Maker" }), ]); assert.equal(slide.type, "attribution"); assert.equal(slide.headline, "Image credits"); assert.equal(slide.items.length, 2, "the CC0 image is not credited — nothing is owed"); assert.equal(slide.items[1].name, "B. Maker"); assert.match(slide.items[1].contribution, /Slide 7/); assert.match(slide.items[1].contribution, /CC BY 4\.0/); }); test("a credit with no creator is credited to its source", async () => { const { creditsSlide } = await import("../src/credits.js"); const slide = creditsSlide([credit({ creator: null, source: "openverse/nasa" })]); assert.equal(slide.items[0].name, "NASA"); }); test("the slide stays inside the attribution type's caps", async () => { const { creditsSlide } = await import("../src/credits.js"); const { validateDeck } = await import("../src/validate.js"); const long = credit({ creator: "A Laboratory With An Extremely Long Institutional Name Indeed", title: "x".repeat(200), licence: "CC BY-NC-SA-Something-Very-Long 4.0", slide: 12, }); const slide = creditsSlide([long]); assert.ok(slide.items[0].name.length <= 30, slide.items[0].name); assert.ok(slide.items[0].contribution.length <= 60, slide.items[0].contribution); // A shortened credit still credits; a slide the schema refuses does not. const { ok, errors } = await validateDeck({ title: "T", slides: [{ type: "title", headline: "T" }, slide], }); assert.ok(ok, JSON.stringify(errors)); }); test("past twelve credits the slide says where the rest are", async () => { const { creditsSlide } = await import("../src/credits.js"); const many = Array.from({ length: 15 }, (_, i) => credit({ title: `Image ${i}`, slide: i + 1 })); const slide = creditsSlide(many); assert.equal(slide.items.length, 12, "the type caps at 12"); assert.match(slide.standfirst, /3 further credits in CREDITS\.md/); }); test("a creator too long for the name field is credited in full, not clipped", async () => { const { creditsSlide } = await import("../src/credits.js"); // `name` caps at 30 and `contribution` at 60. Clipping the attributee is the // one thing a credit cannot afford, so a long creator takes the roomier field. const slide = creditsSlide([credit({ creator: "National Renewable Energy Laboratory", source: "wikimedia-commons", slide: 5, })]); assert.equal(slide.items[0].name, "Wikimedia Commons"); assert.match(slide.items[0].contribution, /National Renewable Energy Laboratory/); assert.ok(!slide.items[0].contribution.includes("…"), "the creator is not truncated"); });