From f278df12148099891d0823a7d057eb43e7365061 Mon Sep 17 00:00:00 2001 From: bot-implementer Date: Tue, 25 Aug 2026 12:30:10 +0000 Subject: [PATCH] test: component + unit tests for the reading list page Covers: page wiring and nav reachability, data-file integrity, grouped rendering, valid hrefs, optional notes, HTML escaping, invalid-entry skipping, data-only extensibility, and a 500-entry fixture (counts, grouping, and a generous render-time bound). --- tests/reading-list.test.js | 320 +++++++++++++++++++++++++++++++++++++ 1 file changed, 320 insertions(+) create mode 100644 tests/reading-list.test.js diff --git a/tests/reading-list.test.js b/tests/reading-list.test.js new file mode 100644 index 0000000..760d685 --- /dev/null +++ b/tests/reading-list.test.js @@ -0,0 +1,320 @@ +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` + ); + } + + 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("