[Story] Reading list page #9

Merged
kpcto merged 6 commits from feature/8 into main 2026-08-25 12:33:22 +00:00
Showing only changes of commit f278df1214 - Show all commits
+320
View File
@@ -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 class="reading-link" href="([^"]*)">([^<]*)<\/a>/g;
const ENTRY_LI_RE = /<li class="reading-entry">/g;
const SECTION_RE = /<section class="reading-category"/g;
/** Build a deterministic fixture with `count` entries across `categories`. */
function makeFixture(count, categories) {
const entries = [];
for (let i = 1; i <= count; i++) {
entries.push({
category: categories[i % categories.length],
title: `Entry ${String(i).padStart(3, "0")}`,
url: `https://example.com/articles/${i}`,
...(i % 3 === 0 ? { note: `Note ${i}` } : {}),
});
}
return entries;
}
// ---------------------------------------------------------------------------
// Component tests: the reading list page is wired up and reachable
// ---------------------------------------------------------------------------
test("reading page renders a reading list container and loads the renderer module", () => {
assert.match(readingHtml, /<div[^>]*id="reading-list"/);
assert.match(
readingHtml,
/<script[^>]*type="module"[^>]*src="js\/reading-list\.js"/
);
});
test("home and contact pages link to the reading list page from the nav", () => {
assert.match(indexHtml, /<a href="reading\.html">Reading<\/a>/);
assert.match(contactHtml, /<a href="reading\.html">Reading<\/a>/);
});
test("reading page nav marks the reading link as the current page", () => {
assert.match(
readingHtml,
/<a href="reading\.html"[^>]*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)}</h2>`),
`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(/<p class="reading-note">/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(/<p class="reading-note">/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(/<section class="reading-category"[^>]*>/).slice(1);
assert.equal(sections.length, expectedOrder.length);
sections.forEach((section, index) => {
const category = expectedOrder[index];
const heading = section.match(/<h2[^>]*>([^<]*)<\/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 <img src=x onerror=alert(1)>',
title: '<script>alert("x")</script>',
url: "https://example.com/?a=1&b=2",
note: 'note "quoted" & <b>bold</b>',
},
];
const html = renderReadingList(nasty);
assert.ok(!html.includes("<script>"), "raw script tag must not appear");
assert.ok(html.includes("&lt;script&gt;"), "title is escaped");
assert.ok(!html.includes("<img"), "raw img tag must not appear");
assert.ok(html.includes("&lt;img"), "category is escaped");
assert.ok(html.includes('&quot;quoted&quot; &amp; &lt;b&gt;bold&lt;/b&gt;'));
});
test("skips invalid entries and shows an empty-state message when nothing renders", () => {
const mixed = [
{ category: "Good", title: "Fine", url: "https://example.com/fine" },
{ category: "Good", title: "Bad protocol", url: "javascript:alert(1)" },
{ category: "Good", title: "Empty url", url: "" },
{ category: "Good", title: "", url: "https://example.com/empty-title" },
{ category: "Good", title: "Ftp url", url: "ftp://example.com/x" },
null,
];
const html = renderReadingList(mixed);
assert.ok(html.includes("Fine"));
assert.ok(!html.includes("Bad protocol"));
assert.ok(!html.includes("Empty url"));
assert.ok(!html.includes("Ftp url"));
assert.ok(!html.includes("javascript:"));
assert.equal((html.match(ENTRY_LI_RE) || []).length, 1);
assert.equal(
renderReadingList([]),
'<p class="reading-empty">The reading list is empty.</p>'
);
assert.equal(
renderReadingList([{ title: "No url" }]),
'<p class="reading-empty">The reading list is empty.</p>'
);
});
test("a new link can be added by editing only the data file", () => {
const original = renderReadingList(READING_LIST);
assert.ok(!original.includes("https://example.com/brand-new"));
// Simulate an author adding a link to the data file — nothing else changes.
const extended = [
...READING_LIST,
{
category: "Engineering",
title: "A brand new link",
url: "https://example.com/brand-new",
note: "added later, no code changes",
},
];
const html = renderReadingList(extended);
assert.ok(html.includes("https://example.com/brand-new"));
assert.ok(html.includes("A brand new link"));
assert.ok(html.includes("added later, no code changes"));
});
// ---------------------------------------------------------------------------
// Unit tests: helpers
// ---------------------------------------------------------------------------
test("escapeHtml escapes the five HTML-sensitive characters", () => {
assert.equal(escapeHtml(`&<>"'`), "&amp;&lt;&gt;&quot;&#39;");
assert.equal(escapeHtml("plain text"), "plain text");
});
test("isValidUrl accepts http(s) and rejects everything else", () => {
assert.ok(isValidUrl("https://example.com"));
assert.ok(isValidUrl("http://example.com/path?q=1"));
assert.ok(!isValidUrl("javascript:alert(1)"));
assert.ok(!isValidUrl("ftp://example.com"));
assert.ok(!isValidUrl("/relative/path"));
assert.ok(!isValidUrl(""));
});
test("isValidEntry requires a non-empty title and a valid url", () => {
assert.ok(isValidEntry({ title: "T", url: "https://example.com" }));
assert.ok(!isValidEntry(null));
assert.ok(!isValidEntry({}));
assert.ok(!isValidEntry({ title: "T" }));
assert.ok(!isValidEntry({ url: "https://example.com" }));
assert.ok(!isValidEntry({ title: "", url: "https://example.com" }));
assert.ok(!isValidEntry({ title: "T", url: "javascript:alert(1)" }));
});
test("groupByCategory preserves first-seen order and falls back to Uncategorized", () => {
const groups = groupByCategory([
{ title: "A", url: "https://example.com/a", category: "Zebra" },
{ title: "B", url: "https://example.com/b", category: "Alpha" },
{ title: "C", url: "https://example.com/c" },
{ title: "D", url: "https://example.com/d", category: "Zebra" },
{ title: "Broken", url: "javascript:alert(1)", category: "Zebra" },
]);
assert.deepEqual([...groups.keys()], ["Zebra", "Alpha", "Uncategorized"]);
assert.deepEqual(
groups.get("Zebra").map((entry) => entry.title),
["A", "D"]
);
assert.deepEqual(
groups.get("Uncategorized").map((entry) => entry.title),
["C"]
);
});