import test from "node:test"; import assert from "node:assert/strict"; import { readFileSync } from "node:fs"; import { fileURLToPath } from "node:url"; import { dirname, join } from "node:path"; import { READING_LIST } from "../data/reading-list.js"; import { escapeHtml, groupByCategory, isValidEntry, isValidUrl, renderReadingList, } from "../js/reading-list.js"; const root = join(dirname(fileURLToPath(import.meta.url)), ".."); const readingHtml = readFileSync(join(root, "reading.html"), "utf8"); const indexHtml = readFileSync(join(root, "index.html"), "utf8"); const contactHtml = readFileSync(join(root, "contact.html"), "utf8"); const rendererSource = readFileSync(join(root, "js/reading-list.js"), "utf8"); const ANCHOR_RE = /]*>([^<]*)<\/a>/g; const ENTRY_LI_RE = /
  • /g; const SECTION_RE = /
    { assert.match(readingHtml, /]*id="reading-list"/); assert.match( readingHtml, /]*type="module"[^>]*src="js\/reading-list\.js"/ ); }); test("home and contact pages link to the reading list page from the nav", () => { assert.match(indexHtml, /Reading<\/a>/); assert.match(contactHtml, /Reading<\/a>/); }); test("reading page nav marks the reading link as the current page", () => { assert.match( readingHtml, /]*aria-current="page"[^>]*>Reading<\/a>/ ); }); test("renderer module loads the reading list data file", () => { assert.match( rendererSource, /import\s*\{\s*READING_LIST\s*\}\s*from\s*"\.\.\/data\/reading-list\.js"/ ); }); // --------------------------------------------------------------------------- // Data file tests: every entry is renderable, note stays optional // --------------------------------------------------------------------------- test("every entry in the data file has a title, a category, and a valid http(s) url", () => { assert.ok(Array.isArray(READING_LIST), "data file exports an array"); assert.ok(READING_LIST.length > 0, "data file has at least one entry"); for (const entry of READING_LIST) { assert.ok( typeof entry.title === "string" && entry.title.trim().length > 0, `entry needs a title: ${JSON.stringify(entry)}` ); assert.ok( typeof entry.category === "string" && entry.category.trim().length > 0, `entry needs a category: ${JSON.stringify(entry)}` ); assert.ok( isValidUrl(entry.url), `entry url must be an absolute http(s) url: ${entry.url}` ); assert.ok( entry.note === undefined || (typeof entry.note === "string" && entry.note.length > 0), `note must be a non-empty string or absent: ${JSON.stringify(entry)}` ); } }); test("seed data includes an entry without a note so the optional path is exercised", () => { assert.ok( READING_LIST.some((entry) => !entry.note), "expected at least one entry with no note" ); }); // --------------------------------------------------------------------------- // Renderer tests: grouping, links, notes, and robustness // --------------------------------------------------------------------------- test("renders links grouped by category from the data file", () => { const html = renderReadingList(READING_LIST); const categories = [...new Set(READING_LIST.map((entry) => entry.category))]; assert.equal((html.match(SECTION_RE) || []).length, categories.length); for (const category of categories) { assert.ok( html.includes(`>${escapeHtml(category)}`), `expected a heading for category ${category}` ); } for (const entry of READING_LIST) { assert.ok( html.includes(escapeHtml(entry.title)), `expected title ${entry.title}` ); assert.ok(html.includes(entry.url), `expected url ${entry.url}`); } }); test("each rendered link has a valid href and shows its title, with the note shown only when present", () => { const fixture = [ { category: "Testing", title: "With a note", url: "https://example.com/with-note", note: "One line of context.", }, { category: "Testing", title: "Without a note", url: "https://example.com/without-note", }, ]; const html = renderReadingList(fixture); const anchors = [...html.matchAll(ANCHOR_RE)]; assert.equal(anchors.length, 2); for (const [, href, title] of anchors) { assert.ok(isValidUrl(href), `href must be a valid http(s) url: ${href}`); assert.ok( fixture.some((entry) => entry.title === title && entry.url === href), `rendered link ${title} -> ${href} must match a data entry` ); } // All reading links point at external sites, so each carries the // rel="noopener noreferrer" hardening attribute. assert.equal( (html.match(/rel="noopener noreferrer"/g) || []).length, anchors.length, "every rendered link carries rel=\"noopener noreferrer\"" ); assert.ok(html.includes("One line of context."), "note is rendered when present"); assert.equal( (html.match(/

    /g) || []).length, 1, "no note paragraph for the entry without a note" ); }); test("renders correctly against a 500+ entry fixture", () => { const fixture = makeFixture(500, ["A", "B", "C", "D", "E"]); const start = performance.now(); const html = renderReadingList(fixture); const elapsed = performance.now() - start; assert.ok( elapsed < 5000, `rendering 500 entries should stay fast, took ${elapsed.toFixed(1)}ms` ); // Every entry rendered exactly once. assert.equal((html.match(ENTRY_LI_RE) || []).length, fixture.length); assert.equal((html.match(ANCHOR_RE) || []).length, fixture.length); assert.ok(html.includes("https://example.com/articles/1")); assert.ok(html.includes("https://example.com/articles/500")); // Notes rendered only for entries that have them. assert.equal( (html.match(/

    /g) || []).length, fixture.filter((entry) => entry.note).length ); // Grouped by category: one section per category, containing exactly that // category's entries, in the first-seen category order. const expectedOrder = [...new Set(fixture.map((entry) => entry.category))]; const expectedCounts = new Map(); for (const entry of fixture) { expectedCounts.set(entry.category, (expectedCounts.get(entry.category) || 0) + 1); } const sections = html.split(/

    ]*>/).slice(1); assert.equal(sections.length, expectedOrder.length); sections.forEach((section, index) => { const category = expectedOrder[index]; const heading = section.match(/]*>([^<]*)<\/h2>/); assert.ok(heading, `section ${index} should have a heading`); assert.equal(heading[1], category); assert.equal( (section.match(ENTRY_LI_RE) || []).length, expectedCounts.get(category), `section for ${category} should contain exactly its own entries` ); }); }); test("escapes HTML in titles, notes, and categories", () => { const nasty = [ { category: 'Design ', title: '', url: "https://example.com/?a=1&b=2", note: 'note "quoted" & bold', }, ]; const html = renderReadingList(nasty); assert.ok(!html.includes("