Files
portfolio-next/test/site/feeds.test.ts
T
Lucas WintherandClaude Opus 5.5 974b3eb0df feat(work): CV-first work page, six proud projects, record and courses (spec 009)
- CV header with highlights and keeping-up summary replaces the intro and Now strip
- "Projects I'm proudest of": up to six featured panels; screenshots muted until hover
- "Also made" list; searching it also lists matching courses
- Record section: recognition & volunteering, courses by topic
- /work/learning/: searchable course list (plain script), in the sitemap
- Top bar: Projects, Record, Writing, CV; no Bluesky link in the work footer
- Highlights: only the placement takes the accent (owner, after the preview)

Owner looked at the rendered preview on 2026-11-12 before this commit.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-12 10:44:46 +01:00

121 lines
6.4 KiB
TypeScript

import { beforeAll, describe, expect, test } from 'bun:test';
import { mkdtempSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { loadSources } from '../../src/sources/index.ts';
import { loadSiteData, type SiteData } from '../../src/site/data.ts';
import { FEEDS, feedEntries, sitePaths, xmlFiles } from '../../src/site/feeds.ts';
import { child, childrenNamed, parseXml } from './xml-check.ts';
const SITE = 'https://example.invalid';
let data: SiteData;
let noAddress: SiteData;
beforeAll(async () => {
const cacheDir = mkdtempSync(join(tmpdir(), 'site-cache-'));
const sources = await loadSources({ cacheDir, env: { SOURCES_FIXTURES: 'fixtures/sources' } });
writeFileSync(join(cacheDir, 'sources.json'), JSON.stringify(sources));
data = await loadSiteData('fixtures/content', cacheDir, '2026-01-15', undefined, `${SITE}/`);
noAddress = await loadSiteData('fixtures/content', cacheDir, '2026-01-15', undefined, '');
});
describe('site address in site data', () => {
test('comes from the parameter (SITE_URL by default), normalised; blank means none', () => {
expect(data.siteUrl).toBe(SITE);
expect(noAddress.siteUrl).toBeUndefined();
});
});
describe('sitePaths', () => {
test('every page the site builds, except the 404, once each', () => {
const paths = sitePaths(data);
expect(new Set(paths).size).toBe(paths.length);
expect(paths.slice(0, 6)).toEqual(['/', '/work/', '/off-the-clock/', '/off-the-clock/issues/', '/everything/', '/simple/']);
for (const p of data.projects) expect(paths).toContain(`/work/${p.id}/`);
for (const p of data.workPosts) expect(paths).toContain(`/work/writing/${p.slug}/`);
for (const i of data.items) expect(paths).toContain(`/off-the-clock/${i.id}/`);
for (const p of data.sources.posts.filter((x) => x.side === 'off-the-clock')) expect(paths).toContain(`/off-the-clock/${p.slug}/`);
for (const i of data.issues) expect(paths).toContain(`/off-the-clock/issues/${i.id}/`);
expect(paths).toContain('/work/learning/');
expect(paths.length).toBe(7 + data.projects.length + data.workPosts.length + data.personal.length + data.issues.length);
expect(paths.some((p) => p.includes('404'))).toBe(false);
expect(paths.some((p) => p.includes('draft'))).toBe(false);
});
test('/work/learning/ only when there are courses', () => {
expect(sitePaths({ ...data, courses: [] })).not.toContain('/work/learning/');
});
});
describe('feedEntries', () => {
test('courses are never feed entries', () => {
for (const name of ['all', 'work'] as const) expect(feedEntries(data, name).some((e) => e.path.includes('learning'))).toBe(false);
});
test('work: projects and work writing, newest first, with their summaries and levels', () => {
const entries = feedEntries(data, 'work');
expect(entries.length).toBe(data.projects.length + data.workPosts.length);
const dates = entries.map((e) => e.date);
expect([...dates].sort().reverse()).toEqual(dates);
const project = data.projects[0]!;
expect(entries).toContainEqual({ title: project.title, path: `/work/${project.id}/`, date: project.date, summary: project.summary, ai: project.ai });
const post = data.workPosts[0]!;
expect(entries).toContainEqual({ title: post.title, path: `/work/writing/${post.slug}/`, date: post.date, summary: post.excerpt, ai: post.ai });
expect(entries.some((e) => e.path.startsWith('/off-the-clock/'))).toBe(false);
});
test("off the clock: the wall's items and off-the-clock posts; an item's line is its summary", () => {
const entries = feedEntries(data, 'off-the-clock');
expect(entries.length).toBe(data.personal.length);
expect(entries.every((e) => e.path.startsWith('/off-the-clock/'))).toBe(true);
const withLine = data.items.find((i) => i.line)!;
expect(entries.find((e) => e.path === `/off-the-clock/${withLine.id}/`)).toEqual({
title: withLine.title, path: `/off-the-clock/${withLine.id}/`, date: withLine.date, summary: withLine.line, ai: withLine.ai,
});
const noLine = data.items.find((i) => !i.line && i.ai === null)!;
expect(entries.find((e) => e.path === `/off-the-clock/${noLine.id}/`)).toEqual({
title: noLine.title, path: `/off-the-clock/${noLine.id}/`, date: noLine.date, ai: null,
});
// Live cards (Trakt, Hardcover, ListenBrainz) are not entries.
const live = [data.sources.trakt.lastFilm?.title, ...data.sources.hardcover.reading.map((r) => r.title)].filter(Boolean);
for (const t of live) expect(entries.some((e) => e.title === t)).toBe(false);
});
test('all: both, and the same things /everything/ lists; never drafts', () => {
const all = feedEntries(data, 'all');
expect(all.length).toBe(data.everything.length);
expect(new Set(all.map((e) => e.path))).toEqual(new Set(data.everything.map((e) => e.url)));
expect(all.some((e) => /draft/i.test(e.path) || /draft/i.test(e.title))).toBe(false);
// Ties on the date are ordered by path, so the order never depends on load order.
for (let i = 1; i < all.length; i++) {
const [a, b] = [all[i - 1]!, all[i]!];
expect(a.date > b.date || (a.date === b.date && a.path < b.path)).toBe(true);
}
});
});
describe('xmlFiles', () => {
test('without a site address there are none', () => {
expect(xmlFiles(noAddress)).toEqual({});
});
test('the sitemap and three feeds, at their paths', () => {
const files = xmlFiles(data);
expect(Object.keys(files).sort()).toEqual(['feed', 'off-the-clock/feed', 'sitemap', 'work/feed']);
expect(FEEDS.all.path).toBe('/feed.xml');
expect(FEEDS.work.path).toBe('/work/feed.xml');
expect(FEEDS['off-the-clock'].path).toBe('/off-the-clock/feed.xml');
const sitemap = parseXml(files.sitemap!());
expect(childrenNamed(sitemap, 'url').map((u) => child(u, 'loc')!.text)).toEqual(sitePaths(data).map((p) => `${SITE}${p}`));
const work = parseXml(files['work/feed']!());
expect(child(work, 'title')!.text).toBe(`Work · ${data.site.name}`);
expect(child(work, 'id')!.text).toBe(`${SITE}/work/feed.xml`);
const first = childrenNamed(work, 'entry')[0]!;
const newest = feedEntries(data, 'work')[0]!;
expect(child(first, 'id')!.text).toBe(`${SITE}${newest.path}`);
expect(child(first, 'category')!.attrs.term).toBe(newest.ai!);
expect(child(parseXml(files.feed!()), 'title')!.text).toBe(data.site.name);
expect(child(parseXml(files['off-the-clock/feed']!()), 'title')!.text).toBe(`Off the clock · ${data.site.name}`);
});
});