[Story] Reading list page #9
@@ -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("<script>"), "title is escaped");
|
||||
assert.ok(!html.includes("<img"), "raw img tag must not appear");
|
||||
assert.ok(html.includes("<img"), "category is escaped");
|
||||
assert.ok(html.includes('"quoted" & <b>bold</b>'));
|
||||
});
|
||||
|
||||
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(`&<>"'`), "&<>"'");
|
||||
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"]
|
||||
);
|
||||
});
|
||||
Reference in New Issue
Block a user