Skip to content

Commit 5d82628

Browse files
TMHSDigitalclaude
andcommitted
Client behaviour tests: chips, shortlist storage, escaping, views, back link (#263)
The pure parts of the browser scripts move where tests reach them: - search.mjs activeFilterChips (app.js renders what it returns) - lib/client-state.mjs (served as client-state.js): parseShortlist, escapeHtml and backLinkHref; shortlist.js and entry.js import it, and entry.js is now a module - views.js readStoredView/storeView are tested with storage stubbed Checked in a browser: chips and Filters (n) on the homepage, the entry page's back link and shortlist toggle, no console errors. Closes #263 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LpkeXQPaUhxjgqdqnk6BrJ
1 parent e65ce4b commit 5d82628

8 files changed

Lines changed: 134 additions & 42 deletions

File tree

‎site/lib/client-state.mjs‎

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
/**
2+
* Pure helpers the browser scripts share, kept here so the tests can reach
3+
* them (#263): the stored shortlist, HTML escaping for pages that do not
4+
* load shared.mjs, and the entry page's "back to results" link. Served as
5+
* client-state.js.
6+
*/
7+
8+
/**
9+
* The shortlist ids stored as JSON. Anything but an array of strings reads
10+
* as empty or loses its bad items. With `byId` (the catalog), ids no longer
11+
* in it drop out; `byId` must have no prototype, so "constructor" is not an
12+
* entry.
13+
*/
14+
export function parseShortlist(raw, byId = null) {
15+
let ids;
16+
try {
17+
ids = JSON.parse(raw || "[]");
18+
} catch {
19+
return [];
20+
}
21+
if (!Array.isArray(ids)) return [];
22+
ids = ids.filter((id) => typeof id === "string");
23+
return byId ? ids.filter((id) => byId[id]) : ids;
24+
}
25+
26+
/** The same five replacements as esc() in shared.mjs (a test keeps them equal). */
27+
export function escapeHtml(str) {
28+
return String(str)
29+
.replaceAll("&", "&amp;")
30+
.replaceAll("<", "&lt;")
31+
.replaceAll(">", "&gt;")
32+
.replaceAll('"', "&quot;")
33+
.replaceAll("'", "&#39;");
34+
}
35+
36+
/**
37+
* Where an entry page's "Catalog" link goes: back to the search the
38+
* homepage stored (`qs`, a query string), or null to keep the built link.
39+
* Only URL-encoded key=value pairs are accepted.
40+
*/
41+
export function backLinkHref(qs) {
42+
if (!qs || !/^[\w.~%+=&-]+$/.test(qs)) return null;
43+
return `../../?${qs}#catalog`;
44+
}

‎site/lib/entry-page.mjs‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -184,7 +184,7 @@ export function entryPageHtml({ entry, leadHtml, restHtml, deprecatedReasonHtml,
184184
${pager}
185185
</main>
186186
${siteFooterHtml({ root: "../../", stamp, total })}
187-
<script src="../../entry.js" defer></script>
187+
<script type="module" src="../../entry.js"></script>
188188
<script type="module" src="../../shortlist.js"></script>
189189
</body>
190190
</html>

‎site/lib/lib.test.mjs‎

Lines changed: 54 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -157,8 +157,8 @@ has("description leads with licence, stance and credit", p, '<meta name="descrip
157157
has("canonical", p, '<link rel="canonical" href="https://x.test/s/entry/e1/" />');
158158
eq("one h1", (p.match(/<h1[\s>]/g) || []).length, 1);
159159
has("credit line escaped", p, "Art by &quot;X&quot; &lt;/script&gt;");
160-
has("entry script with a credit line", p, '<script src="../../entry.js" defer></script>');
161-
has("entry script without one, for the back link", page({ attribution_string: undefined }), '<script src="../../entry.js" defer></script>');
160+
has("entry script with a credit line", p, '<script type="module" src="../../entry.js"></script>');
161+
has("entry script without one, for the back link", page({ attribution_string: undefined }), '<script type="module" src="../../entry.js"></script>');
162162
lacks("no copy button without a credit line", page({ attribution_string: undefined }), "copy-attribution");
163163
has("breadcrumb catalog link can return to the results", p, '<a href="../../#catalog" data-back-to-results>Catalog</a>');
164164
has("note when credit is required but not canned", page({ attribution_string: undefined }), "no canned credit line");
@@ -2017,9 +2017,7 @@ eq("search: q from the URL is capped", readState(`?q=${"a".repeat(5000)}`, { q:
20172017
has("thumbs flags: a limit needs a value", errOf(() => thumbsOptions(["--limit"])), "argument missing");
20182018

20192019
const { esc } = await import("./shared.mjs");
2020-
const short = nodeFs.readFileSync(new URL("../public/shortlist.js", import.meta.url), "utf8");
2021-
const body = short.match(/function escapeHtml\(str\) \{([\s\S]*?)\n\}/)[1];
2022-
const escapeHtml = new Function("str", body);
2020+
const { escapeHtml } = await import("./client-state.mjs");
20232021
const sample = `<a href="x" title='y'>Tom & "Jerry"</a>`;
20242022
eq("shortlist's escapeHtml matches shared esc", escapeHtml(sample), esc(sample));
20252023
}
@@ -2263,6 +2261,57 @@ eq("search: q from the URL is capped", readState(`?q=${"a".repeat(5000)}`, { q:
22632261
lacks("footer without repo does not", siteFooterHtml({ root: "", stamp: "s", total: 1 }), "Contribute on GitHub");
22642262
}
22652263

2264+
/* client behaviour, pure parts (#263) -------------------------------------- */
2265+
{
2266+
const { activeFilterChips, FILTER_DEFAULTS: D } = await import("./search.mjs");
2267+
const labels = { license: { "cc-by": "CC BY" }, category: { "2d": "2D" } };
2268+
eq("chips: the default state has none", activeFilterChips({ ...D }, D, labels).length, 0);
2269+
const q = activeFilterChips({ ...D, q: " x " }, D, labels);
2270+
eq("chips: a search gives one chip", q.map((c) => `${c.key}=${c.label}`).join(), "q=search: x");
2271+
eq("chips: the search chip is not a filter", q.filter((c) => c.key !== "q").length, 0);
2272+
eq("chips: active off reads hiding active", activeFilterChips({ ...D, active: false }, D, labels)[0].label, "hiding active");
2273+
eq("chips: a known licence uses its label", activeFilterChips({ ...D, license: "cc-by" }, D, labels)[0].label, "licence: CC BY");
2274+
eq("chips: an unknown licence shows as written", activeFilterChips({ ...D, license: "odd" }, D, labels)[0].label, "licence: odd");
2275+
eq("chips: the category comes first", activeFilterChips({ ...D, category: "2d", noAttr: true }, D, labels).map((c) => c.key).join(), "category,noAttr");
2276+
2277+
const { parseShortlist, backLinkHref } = await import("./client-state.mjs");
2278+
for (const raw of [null, "{", '{"a":1}']) eq(`shortlist: ${raw} reads as empty`, parseShortlist(raw).length, 0);
2279+
eq("shortlist: only string ids are kept", parseShortlist('["a",1,null]').join(), "a");
2280+
const byId = Object.assign(Object.create(null), { a: {}, b: {} });
2281+
eq("shortlist: constructor is not an entry", parseShortlist('["constructor"]', byId).length, 0);
2282+
eq("shortlist: a removed id drops out", parseShortlist('["a","gone","b"]', byId).join(), "a,b");
2283+
2284+
eq("back link: a stored search returns to it", backLinkHref("q=a&sort=added"), "../../?q=a&sort=added#catalog");
2285+
eq("back link: nothing stored keeps the built link", backLinkHref(null), null);
2286+
eq("back link: a value that is not a query string is refused", backLinkHref('"><script>'), null);
2287+
2288+
// views.js: the remembered Cards / List choice, with storage stubbed.
2289+
const had = Object.getOwnPropertyDescriptor(globalThis, "localStorage");
2290+
const store = new Map();
2291+
const fake = { getItem: (k) => (store.has(k) ? store.get(k) : null), setItem: (k, v) => store.set(k, String(v)), removeItem: (k) => store.delete(k) };
2292+
Object.defineProperty(globalThis, "localStorage", { value: fake, configurable: true, writable: true });
2293+
try {
2294+
const { readStoredView, storeView } = await import("../public/views.js");
2295+
for (const v of ["cards", "list"]) {
2296+
store.set("fgda:view:cards-default", v);
2297+
eq(`views: ${v} is read back`, readStoredView("cards"), v);
2298+
}
2299+
for (const v of ["grid", ""]) {
2300+
store.set("fgda:view:cards-default", v);
2301+
eq(`views: "${v}" reads as nothing`, readStoredView("cards"), null);
2302+
}
2303+
storeView("cards", "list");
2304+
eq("views: a non-default view is stored", store.get("fgda:view:cards-default"), "list");
2305+
storeView("cards", "cards");
2306+
eq("views: the default view removes the key", store.has("fgda:view:cards-default"), false);
2307+
Object.defineProperty(globalThis, "localStorage", { get() { throw new Error("blocked"); }, configurable: true });
2308+
eq("views: blocked storage reads as nothing", readStoredView("cards"), null);
2309+
} finally {
2310+
if (had) Object.defineProperty(globalThis, "localStorage", had);
2311+
else delete globalThis.localStorage;
2312+
}
2313+
}
2314+
22662315
/* report (keep last) ------------------------------------------------------ */
22672316
if (failures.length) {
22682317
console.error(`lib.test failed (${failures.length}):`);

‎site/lib/search.mjs‎

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -284,6 +284,29 @@ export const FILTERS = Object.freeze([
284284
{ field: "noAttr", urlKey: "noattr", kind: "switch", default: false, control: "#filter-no-attr", chipOn: "no attribution only" },
285285
]);
286286

287+
/**
288+
* The chips for what is filtering the list (#263): the category, the search
289+
* words, then one per FILTERS row that differs from its default. `labels`
290+
* maps a choice field to { value: label } for choices that come from data;
291+
* an unknown value shows as written. The `q` chip is not a filter: callers
292+
* leave it out of the Filters (n) count.
293+
*/
294+
export function activeFilterChips(state, defaults, labels = {}) {
295+
const chips = [];
296+
const labelOf = (f, v) => (f.options || labels[f.field] || {})[v] || v;
297+
const category = FILTERS[0];
298+
if (state.category !== defaults.category) chips.push({ key: "category", label: `${category.chip}: ${labelOf(category, state.category)}` });
299+
if (String(state.q || "").trim()) chips.push({ key: "q", label: `search: ${state.q.trim()}` });
300+
for (const f of FILTERS.slice(1)) {
301+
const v = state[f.field];
302+
if (v === defaults[f.field]) continue;
303+
if (f.kind === "choice") chips.push({ key: f.field, label: `${f.chip}: ${labelOf(f, v)}` });
304+
else if (v && f.chipOn) chips.push({ key: f.field, label: f.chipOn });
305+
else if (!v && f.chipOff) chips.push({ key: f.field, label: f.chipOff });
306+
}
307+
return chips;
308+
}
309+
287310
/** Days since `verified` each `fresh` choice allows: the freshness page's bands. */
288311
export const FRESH_DAYS = Object.freeze({ "6m": VERIFIED_FRESH_DAYS, "12m": VERIFIED_AGING_DAYS });
289312

‎site/lib/shared.mjs‎

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -290,6 +290,7 @@ export const BROWSER_MODULES = {
290290
"search.js": "search.mjs", // the catalog search and the 404 page's guesses
291291
"cards.mjs": "cards.mjs", // the entry card template, for app.js and the category pages
292292
"shared.mjs": "shared.mjs",
293+
"client-state.js": "client-state.mjs", // shortlist parsing, escaping and the entry back link
293294
};
294295

295296
/**
@@ -299,9 +300,9 @@ export const BROWSER_MODULES = {
299300
* lib.test.mjs walks the real import graph and fails if a list drifts.
300301
*/
301302
export const PAGE_MODULES = {
302-
home: ["app.js", "search.js", "cards.mjs", "shared.mjs", "views.js", "shortlist.js", "owes.js"],
303-
entry: ["shortlist.js", "owes.js"],
304-
category: ["category.js", "views.js", "shortlist.js", "owes.js"],
303+
home: ["app.js", "search.js", "cards.mjs", "shared.mjs", "views.js", "shortlist.js", "owes.js", "client-state.js"],
304+
entry: ["shortlist.js", "owes.js", "client-state.js"],
305+
category: ["category.js", "views.js", "shortlist.js", "owes.js", "client-state.js"],
305306
notFound: ["not-found.js", "search.js", "shared.mjs"],
306307
};
307308

‎site/public/app.js‎

Lines changed: 2 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { FILTER_DEFAULTS, FILTERS, gapIssueUrl, closestEntries, computeResults as computeResultsFor, passesFilters as passes, placementKey, queryTests as compileQuery, readState, scoreFields, searchFields, shouldGroup as shouldGroupFor, slugOf, sorterFor, SORTS, stateQuery, ENGINES } from "./search.js";
1+
import { activeFilterChips, FILTER_DEFAULTS, FILTERS, gapIssueUrl, closestEntries, computeResults as computeResultsFor, passesFilters as passes, placementKey, queryTests as compileQuery, readState, scoreFields, searchFields, shouldGroup as shouldGroupFor, slugOf, sorterFor, SORTS, stateQuery, ENGINES } from "./search.js";
22
import { CARD_LIMIT, LIST_LIMIT, entryCardHtml, escapeHtml, groupHeadingHtml, groupMoreHtml, PERSPECTIVE_LABELS } from "./cards.mjs";
33
import { installViewToggles } from "./views.js";
44
import { isListed, PERSPECTIVES } from "./shared.mjs";
@@ -218,23 +218,7 @@ import { isListed, PERSPECTIVES } from "./shared.mjs";
218218
/** Chips describing what is currently filtering the list, each removable. */
219219
function renderActiveFilters() {
220220
const host = $("#active-filters");
221-
const chips = [];
222-
if (state.category !== DEFAULTS.category) {
223-
chips.push({
224-
key: "category",
225-
label: `category: ${categoryLabels[state.category]?.label || state.category}`,
226-
});
227-
}
228-
if (state.q.trim()) chips.push({ key: "q", label: `search: ${state.q.trim()}` });
229-
// One chip per filter that differs from its default, from the FILTERS table (#252).
230-
for (const f of FILTERS) {
231-
if (f.field === "category") continue;
232-
const v = state[f.field];
233-
if (f.kind === "choice") {
234-
if (v !== f.default) chips.push({ key: f.field, label: `${f.chip}: ${choicesOf(f)[v] || v}` });
235-
} else if (v && f.chipOn && v !== f.default) chips.push({ key: f.field, label: f.chipOn });
236-
else if (!v && f.chipOff && v !== f.default) chips.push({ key: f.field, label: f.chipOff });
237-
}
221+
const chips = activeFilterChips(state, DEFAULTS, CHOICE_LABELS);
238222

239223
// The filter keeps aggregators whose files differ, which a reader
240224
// filtering for "safe to ship" must see, not find in a tooltip.

‎site/public/entry.js‎

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
/** Entry page helpers. The page works without them. */
2+
import { backLinkHref } from "./client-state.js";
23

34
// "Catalog" in the breadcrumb returns to the search and filters the reader
45
// left, which the homepage keeps in sessionStorage. Without them it goes to
@@ -13,8 +14,8 @@
1314
return;
1415
}
1516
// Only a query string the homepage wrote: URL-encoded key=value pairs.
16-
if (!qs || !/^[\w.~%+=&-]+$/.test(qs)) return;
17-
link.setAttribute("href", `../../?${qs}#catalog`);
17+
const href = backLinkHref(qs);
18+
if (href) link.setAttribute("href", href);
1819
})();
1920

2021
/** Copy button for an entry page's credit line. */

‎site/public/shortlist.js‎

Lines changed: 3 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -4,14 +4,15 @@
44
* same code as the stack pages' "What this stack owes" (owes.js).
55
*/
66
import { creditsFile, owes } from "./owes.js";
7+
import { escapeHtml, parseShortlist } from "./client-state.js";
78

89
const KEY = "fgda:shortlist";
910

1011
function load() {
1112
try {
12-
const ids = JSON.parse(localStorage.getItem(KEY) || "[]");
13-
return Array.isArray(ids) ? ids.filter((id) => typeof id === "string") : [];
13+
return parseShortlist(localStorage.getItem(KEY));
1414
} catch {
15+
// Storage blocked: reading it throws.
1516
return [];
1617
}
1718
}
@@ -77,17 +78,6 @@ function nameOf(id) {
7778
return byId[id]?.name || document.querySelector(`[data-shortlist="${CSS.escape(id)}"]`)?.getAttribute("data-name") || document.querySelector("main h1")?.textContent.trim() || id;
7879
}
7980

80-
// The same five replacements as esc() in shared.mjs. Kept local so entry
81-
// pages, which load only this script and owes.js, do not fetch shared.mjs.
82-
function escapeHtml(str) {
83-
return String(str)
84-
.replaceAll("&", "&amp;")
85-
.replaceAll("<", "&lt;")
86-
.replaceAll(">", "&gt;")
87-
.replaceAll('"', "&quot;")
88-
.replaceAll("'", "&#39;");
89-
}
90-
9181
function download(name, text, type) {
9282
const url = URL.createObjectURL(new Blob([text], { type }));
9383
const a = document.createElement("a");

0 commit comments

Comments
 (0)