Files
portfolio-next/test/site/build.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

402 lines
22 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { beforeAll, describe, expect, test } from 'bun:test';
import { existsSync, readFileSync, readdirSync } from 'node:fs';
import { join } from 'node:path';
import { loadContent } from '../../src/library/load.ts';
import type { Project } from '../../src/library/types.ts';
import { buildFixtureSites, page } from './fixture-sites.ts';
// Builds the fixture sites once and checks the rendered HTML of the two spaces.
let out: string;
let sparseOut: string;
let projects: Project[];
beforeAll(async () => {
({ out, sparseOut } = buildFixtureSites());
const { content } = await loadContent('fixtures/content');
projects = content!.projects;
}, 300_000);
describe('work space', () => {
test('work page lists every visible project', () => {
const html = page(out, 'work/index.html');
expect(projects.length).toBe(9);
for (const p of projects) {
expect(html).toContain(`href="/work/${p.id}/"`);
expect(html).toContain(`<template id="p-${p.id}">`);
}
expect(html).not.toContain('draft-unfinished-thing');
});
test('every project has its own page', () => {
for (const p of projects) {
const file = join(out, 'work', p.id, 'index.html');
expect(existsSync(file)).toBe(true);
const html = readFileSync(file, 'utf8');
expect(html).toMatch(new RegExp(`<h1[^>]*>${p.title}</h1>`));
expect(html).toContain('data-theme="work"');
}
expect(existsSync(join(out, 'work', 'draft-unfinished-thing', 'index.html'))).toBe(false);
});
test('featured panels carry data-ai', () => {
const html = page(out, 'work/index.html');
const panels = [...html.matchAll(/<article\b[^>]*\bclass="feat\b[^"]*"[^>]*>/g)].map((m) => m[0]);
const featured = projects.filter((p) => p.featured !== undefined);
expect(panels.length).toBe(featured.length);
for (const p of featured) {
const tag = panels.find((t) => t.includes(`data-id="${p.id}"`));
expect(tag).toBeDefined();
expect(tag).toContain(`data-ai="${p.ai}"`);
}
});
test('six featured panels lay out wide, half, half, wide reversed, half, half under "Projects I\'m proudest of"', () => {
const html = page(out, 'work/index.html');
const classes = [...html.matchAll(/<article\b[^>]*\bclass="(feat\b[^"]*)"[^>]*>/g)].map((m) => m[1]!.replace(/\s*astro-\w+/g, '').trim());
expect(classes).toEqual(['feat wide', 'feat', 'feat', 'feat wide rev', 'feat', 'feat']);
const feat = html.slice(html.indexOf('id="featured"'));
expect(feat).toMatch(/<h2[^>]*>Projects I(&#39;|'|’)m proudest of<\/h2>/);
expect(feat.slice(0, 1500)).toContain('6 projects');
expect(feat.slice(0, 1500)).toContain('hover the drawings · open a panel for the full story');
// The AI explanation sits with the heading, with the four legend marks.
const head = feat.slice(0, feat.indexOf('class="feats"'));
for (const level of ['ai-made', 'some-ai', 'by-hand', 'unknown']) expect(head).toContain(`data-level="${level}"`);
});
test('screenshots in panels and list thumbnails are muted until hovered; drawings are not', () => {
const html = page(out, 'work/index.html');
const css = html + readdirSync(join(out, '_astro')).filter((f) => f.endsWith('.css')).map((f) => page(out, `_astro/${f}`)).join('');
expect(css).toMatch(/\.feat \.s-image img[^{]*\{[^}]*filter:\s*saturate\(\.5\)\s*brightness\(\.8\)/);
expect(css).toMatch(/\.row \.th-image img[^{]*\{[^}]*filter:\s*saturate\(\.5\)\s*brightness\(\.8\)/);
expect(css).toMatch(/\.feat:hover \.s-image img/);
expect(css).toMatch(/\.feat:focus-within \.s-image img/);
expect(css).toMatch(/\.row:focus-visible \.th-image img|\.row:focus \.th-image img/);
expect(css).not.toMatch(/\.stage\s*\{[^}]*saturate/);
});
test('a hidden featured panel is followed by a note with a working "show anyway" button', () => {
const html = page(out, 'work/index.html');
const featured = projects.filter((p) => p.featured !== undefined);
// The note is the panel's next sibling, so base.css can reveal it when the panel is filtered out.
const pairs = [...html.matchAll(/<\/article>\s*<div\b[^>]*\bclass="filtered-note\b[^"]*"[^>]*>([\s\S]*?)<\/div>/g)];
expect(pairs.length).toBe(featured.length);
for (const m of pairs) expect(m[1]).toMatch(/<button\b[^>]*\bdata-show-anyway\b[^>]*>show anyway<\/button>/);
// The script that sets data-show on the panel ships with the page.
expect(html).toMatch(/data-show-anyway[\s\S]*setAttribute\(['"]data-show['"]/);
});
test('a project without a showcase gets the generic frame', () => {
expect(page(out, 'work/placeholder-notes/index.html')).toContain('data-showcase="generic"');
// A known showcase id renders its drawing (unknown ids are a validation error).
const html = page(out, 'work/index.html');
const dash = html.slice(html.indexOf('data-id="fixture-dashboard"'));
expect(dash.slice(0, dash.indexOf('</article>'))).toContain('data-showcase="config-editor"');
expect(html).toContain('data-showcase="compute-queue"');
});
test('a project without years shows no dash or empty separator', () => {
const list = page(out, 'work/index.html');
const row = list.slice(list.indexOf('data-open-sheet="sample-garden-bot"'));
const rowHtml = row.slice(0, row.indexOf('</a>'));
expect(rowHtml).toContain('class="yr"></span>');
expect(rowHtml).not.toMatch(/–|—/);
const proj = page(out, 'work/sample-garden-bot/index.html');
const meta = proj.slice(proj.indexOf('class="meta"'));
const metaHtml = meta.slice(0, meta.indexOf('</div>'));
expect(metaHtml).not.toContain('<span></span>');
expect(metaHtml).not.toMatch(/undefined|–/);
// A project that has years still shows them.
expect(page(out, 'work/demo-course-planner/index.html')).toContain('<span>2021</span>');
});
test('a project image replaces the generic frame where there is no showcase', () => {
const alt = 'Screenshot of Sample Garden Bot';
const proj = page(out, 'work/sample-garden-bot/index.html');
expect(proj).toMatch(/<img[^>]*src="\/media\/fixture-pixel\.png"[^>]*alt="Screenshot of Sample Garden Bot"[^>]*loading="lazy"|<img[^>]*alt="Screenshot of Sample Garden Bot"[^>]*src="\/media\/fixture-pixel\.png"/);
expect(proj).not.toContain('data-showcase="generic"');
const list = page(out, 'work/index.html');
const row = list.slice(list.indexOf('data-open-sheet="sample-garden-bot"'));
expect(row.slice(0, row.indexOf('</a>'))).toContain(`alt="${alt}"`);
// The sheet template carries it too.
const tpl = list.slice(list.indexOf('<template id="p-sample-garden-bot">'));
expect(tpl.slice(0, tpl.indexOf('</template>'))).toContain(`alt="${alt}"`);
// A project with neither showcase nor image keeps the generic frame.
expect(page(out, 'work/placeholder-notes/index.html')).toContain('data-showcase="generic"');
});
test('the Writing card is left out when there are no work posts', () => {
expect(page(out, 'work/index.html')).toContain('id="writing"');
const sparse = page(sparseOut, 'work/index.html');
expect(sparse).not.toContain('id="writing"');
expect(sparse).not.toContain('undefined');
});
test('writing boxes collapse when the filter hides every post in them', () => {
const html = page(out, 'work/index.html');
// The Writing card is a filter box: base.css hides a box with no visible [data-ai] link under the
// active preference.
expect(html).toMatch(/<section\b[^>]*\bclass="card filter-box"[^>]*\bid="writing"/);
// Counts never claim hidden posts: one count per preference, CSS shows the active one.
// The fixture's only work post has no AI level, so "by hand only" counts 0.
expect(html).toContain('<span class="pc" data-for="any">1 post</span>');
expect(html).toContain('<span class="pc" data-for="not-fully-ai">1 post</span>');
expect(html).toContain('<span class="pc" data-for="by-hand-only">0 posts</span>');
expect(html).not.toMatch(/all \d+ ↓/);
// Astro inlines small stylesheets, so look in the page and in the emitted CSS files.
const css = html + readdirSync(join(out, '_astro')).filter((f) => f.endsWith('.css')).map((f) => page(out, `_astro/${f}`)).join('');
expect(css).toMatch(/\[data-pref=["']?by-hand-only["']?\] \.filter-box:not\(:has\(/);
expect(css).toMatch(/\[data-pref=["']?not-fully-ai["']?\] \.filter-box:not\(:has\(/);
});
test('a work post has its own page with a link to the original', () => {
const html = page(out, 'work/writing/synthetic-work-post/index.html');
expect(html).toMatch(/<h1[^>]*>A synthetic work post<\/h1>/);
expect(html).toContain('<p>Body of a work post.</p>');
expect(html).toContain('href="https://blog.example.test/synthetic-work-post/"');
expect(existsSync(join(out, 'work', 'writing', 'synthetic-essay'))).toBe(false);
});
});
describe('intros', () => {
const ledeOfPersonalGate = (html: string) =>
html.slice(html.indexOf('Off the clock</h2>')).match(/<p class="lede">([^<]*)<\/p>/)?.[1];
test('the gate and the wall use personal_intro, never the work intro', () => {
const gate = page(out, 'index.html');
expect(ledeOfPersonalGate(gate)).toBe('Fixture personal intro, also invented.');
expect(gate).not.toContain('This is synthetic fixture content for tests.');
expect(page(out, 'off-the-clock/index.html')).toContain('Fixture personal intro, also invented.');
});
test('without personal_intro there is no lede; /work/ is headed by the name (spec 009)', () => {
expect(ledeOfPersonalGate(page(sparseOut, 'index.html'))).toBeUndefined();
const work = page(sparseOut, 'work/index.html');
expect(work).toMatch(/<h1>Example Person<\/h1>/);
expect(work.match(/<h1\b/g)).toHaveLength(1);
expect(page(sparseOut, 'off-the-clock/index.html')).not.toContain('Fixture personal intro');
});
});
describe('personal space', () => {
// Opening tags of the wall's cards, keyed by entry id.
function cardTags(html: string): Map<string, string> {
const tags = [...html.matchAll(/<article\b[^>]*\bclass="card\b[^"]*"[^>]*>/g)].map((m) => m[0]);
return new Map(tags.flatMap((t) => {
const id = t.match(/\bdata-id="([^"]+)"/)?.[1];
return id ? [[id, t] as const] : [];
}));
}
const ITEM_IDS = [
'fixture-comic', 'example-song', 'sample-game', 'kickstarted-game', 'demo-film', 'fixture-book', 'own-drawing',
'commissioned-art', 'edge-long-title', 'fixture-life', 'example-set', 'fixture-soundtrack-film', 'fixture-cover-comic', 'fixture-band-photo',
];
// A whole wall card (opening tag to its closing tag) by entry id.
function card(html: string, id: string): string {
const m = html.match(new RegExp(`<article\\b[^>]*\\bdata-id="${id}"[\\s\\S]*?</article>`));
if (!m) throw new Error(`no card ${id}`);
return m[0];
}
// The counts the server renders into the chips island's props, keyed by interest id.
function chipCounts(html: string): Record<string, number> {
const props = html.match(/component-url="[^"]*Chips[^"]*"[^>]*props="([^"]*)"/)![1]!.replace(/&quot;/g, '"').replace(/&amp;/g, '&');
const out: Record<string, number> = {};
for (const m of props.matchAll(/"id":\[0,"([^"]+)"\],"name":\[0,"[^"]*"\],"count":\[0,(\d+)\]/g)) out[m[1]!] = Number(m[2]);
return out;
}
const POST_IDS = ['synthetic-essay', 'synthetic-life-note'];
test('wall renders one card per personal entry with its kind class', async () => {
const { content } = await loadContent('fixtures/content');
const html = page(out, 'off-the-clock/index.html');
const cards = cardTags(html);
expect([...cards.keys()].sort()).toEqual([...ITEM_IDS, ...POST_IDS].sort());
for (const it of content!.items) {
expect(cards.get(it.id)).toMatch(new RegExp(`class="card\\b[^"]*\\b${it.kind}\\b`));
expect(cards.get(it.id)).toContain(`data-interests="${it.interests.join(' ')}"`);
expect(existsSync(join(out, 'off-the-clock', it.id, 'index.html'))).toBe(true);
}
expect(cards.get('synthetic-essay')).toMatch(/class="card\b[^"]*\bessay\b/);
expect(cards.get('synthetic-life-note')).toMatch(/class="card\b[^"]*\blife\b/);
expect(html).not.toContain('draft-item');
expect(html).not.toContain('data-interest=');
// Ghost posts get their own page too, with the post body made safe.
const post = page(out, 'off-the-clock/synthetic-essay/index.html');
expect(post).toContain('<p>Body of an essay.</p>');
expect(post).toContain('href="https://blog.example.test/synthetic-essay/"');
expect(page(out, 'off-the-clock/fixture-comic/index.html')).toMatch(/<h1[^>]*>Fixture Comic<\/h1>/);
});
test('a two-interest item counts under both chips and names both in its meta', () => {
const html = page(out, 'off-the-clock/index.html');
const counts = chipCounts(html);
const cards = [...html.matchAll(/<article\b[^>]*\bdata-wall-card\b[^>]*>/g)].map((m) => m[0]); // live cards have no data-id
const under = (id: string) => cards.filter((t) => new RegExp(`data-interests="[^"]*\\b${id}\\b`).test(t)).length;
expect(counts['music']).toBe(under('music'));
expect(counts['film-tv']).toBe(under('film-tv'));
expect(cardTags(html).get('fixture-soundtrack-film')).toContain('data-interests="film-tv music"');
expect(card(html, 'fixture-soundtrack-film')).toMatch(/<span>film &amp; tv<\/span><span aria-hidden="true">·<\/span><span>music<\/span>/);
expect(card(html, 'demo-film')).not.toContain('<span>music</span>');
});
test('a comic with one image shows the whole cover at span 3, with its credit', () => {
const html = page(out, 'off-the-clock/index.html');
expect(cardTags(html).get('fixture-cover-comic')).toContain('style="--span:3"');
// Filtered walls widen cards (--fspan); a single cover stays narrow there too.
expect(cardTags(html).get('fixture-cover-comic')).toMatch(/class="card comic cover-comic\b/);
const css = readdirSync(join(out, '_astro')).filter((f) => f.endsWith('.css')).map((f) => readFileSync(join(out, '_astro', f), 'utf8')).join('');
expect(css).toMatch(/\.wall\[data-filtered\]>\.card\.cover-comic[^{]*\{--fspan:3\}/);
const c = card(html, 'fixture-cover-comic');
expect(c).toContain('class="panels cover"');
expect(c).toContain('by Fixture Artist');
expect(card(html, 'fixture-comic')).toContain('class="panels n2"');
expect(page(out, 'off-the-clock/fixture-cover-comic/index.html')).toContain('by Fixture Artist');
});
test('a film that is also music gets a soundtrack tray that loads nothing until pressed', () => {
const html = page(out, 'off-the-clock/index.html');
const c = card(html, 'fixture-soundtrack-film');
expect(c).toContain('class="tray"');
expect(c).toContain('Fixture Soundtrack');
// The art repeats the title, so it is decorative: the play link's name is the title alone.
expect(c).toMatch(/<img class="tray-art" src="\/media\/fixture-3.svg" alt=""/);
const props = c.replace(/&quot;/g, '"');
expect(props).toContain('https://open.spotify.com/embed/album/fixture0000');
expect(props).toContain('"height":[0,152]');
expect(c).not.toContain('<iframe');
expect(card(html, 'demo-film')).not.toContain('tray');
expect(page(out, 'off-the-clock/fixture-soundtrack-film/index.html')).toContain('class="tray"');
});
test('a music item with a photo shows it in place of the stripes', () => {
const html = page(out, 'off-the-clock/index.html');
const c = card(html, 'fixture-band-photo');
expect(c).toContain('class="video photo"');
expect(c).toContain('src="/media/fixture-5.svg"');
expect(c).toContain('href="https://example.invalid/band"');
expect(c).toContain('example.invalid ↗');
expect(c).toMatch(/<span>music<\/span><span aria-hidden="true">·<\/span><span>made by me<\/span>/);
expect(page(out, 'off-the-clock/example-set/index.html')).not.toContain('video photo');
// 4:3, so a group photo keeps every face (spec 003)
const css = readdirSync(join(out, '_astro')).filter((f) => f.endsWith('.css')).map((f) => readFileSync(join(out, '_astro', f), 'utf8')).join('');
expect(css).toMatch(/\.card\.music \.video\.photo\{aspect-ratio:4\/3\}/);
});
test('the now strip shows the last film from Trakt with its stars', () => {
const html = page(out, 'off-the-clock/index.html');
const strip = html.slice(html.indexOf('<h2><span>Last film</span>'), html.indexOf('data-wall-title'));
expect(strip).toContain('Synthetic Movie');
expect(strip).toContain('★★★★½');
expect(strip).toContain('watched <time datetime="2025-06-30">30 Jun 2025</time>');
expect(strip).toContain('href="https://trakt.tv/movies/synthetic-movie"');
expect(html).not.toContain('<span>Watching</span>');
});
test('film cards alternate the poster side on the wall', () => {
const html = page(out, 'off-the-clock/index.html');
expect(card(html, 'fixture-soundtrack-film')).toContain('class="film-grid"'); // newest film: poster left
expect(card(html, 'demo-film')).toContain('class="film-grid flip"'); // next film: poster right
expect(page(out, 'off-the-clock/demo-film/index.html')).not.toContain('film-grid flip');
});
test('the now strip shows the week\'s top track from ListenBrainz', () => {
const html = page(out, 'off-the-clock/index.html');
const strip = html.slice(html.indexOf('<h2><span>Listening</span>'), html.indexOf('<h2><span>Reading</span>'));
expect(strip).toContain('href="https://listenbrainz.org/user/fixture-user/"');
expect(strip).toContain('ListenBrainz ↗');
expect(strip).toContain('<b>Synthetic Track A</b>');
expect(strip).toContain('Fixture Band');
expect(strip).toContain('3 plays this week');
expect(html).not.toContain('on the wall since');
});
test('shares carry no data-ai', async () => {
const { content } = await loadContent('fixtures/content');
const cards = cardTags(page(out, 'off-the-clock/index.html'));
for (const it of content!.items) {
if (it.ai === null) expect(cards.get(it.id)).not.toContain('data-ai');
else expect(cards.get(it.id)).toContain(`data-ai="${it.ai}"`);
}
expect(cards.get('commissioned-art')).not.toContain('data-ai');
expect(cards.get('own-drawing')).toContain('data-ai="by-hand"');
expect(cards.get('synthetic-essay')).toContain('data-ai="by-hand"');
});
test('older years are collapsed', () => {
const html = page(out, 'off-the-clock/index.html');
const m = html.match(/<details\b[^>]*\bclass="older-year\b[^"]*"[^>]*>([\s\S]*?)<\/details>/);
expect(m).not.toBeNull();
expect(m![0]).not.toMatch(/<details\b[^>]*\bopen\b/);
expect(m![1]).toMatch(/<summary\b[^>]*>[\s\S]*2024[\s\S]*<\/summary>/);
expect(m![1]).toContain('data-id="fixture-life"');
expect(m![1]).toContain('data-id="example-set"');
// 2025 is the lead year: its cards are on the wall, not folded away.
expect(m![1]).not.toContain('data-id="fixture-comic"');
});
test('item without media keeps its card', () => {
const html = page(out, 'off-the-clock/index.html');
const start = html.indexOf('data-id="edge-long-title"');
const card = html.slice(start, html.indexOf('</article>', start));
expect(card).toContain('Edge case with a very long title');
expect(card).toContain('An essay with no media');
expect(card).not.toContain('undefined');
expect(card).not.toMatch(/<img\b/);
const own = page(out, 'off-the-clock/edge-long-title/index.html');
expect(own).toContain('An essay with no media and an overlong title.');
expect(own).not.toContain('undefined');
});
test('embeds are not in the html until clicked', () => {
const html = page(out, 'off-the-clock/index.html');
expect(html).not.toContain('<iframe');
// The song's poster is a click-to-load embed that links out without JavaScript.
expect(html).toMatch(/data-embed="youtube"[\s\S]*?href="https:\/\/www\.youtube\.com\/watch\?v=fixture0000"/);
expect(page(out, 'off-the-clock/example-song/index.html')).not.toContain('<iframe');
});
test('comic reader controls are hidden without javascript, pages stay reachable', () => {
const html = page(out, 'off-the-clock/fixture-comic/index.html');
const css = html + readdirSync(join(out, '_astro')).filter((f) => f.endsWith('.css')).map((f) => page(out, `_astro/${f}`)).join('');
expect(css).toMatch(/html:not\(\.js\) \.reader \.ctl\s*\{\s*display:\s*none/);
// Every page of a multi-image comic is linked for no-JS visitors.
const ns = html.match(/<noscript>([\s\S]*?)<\/noscript>/)?.[1] ?? '';
expect(ns).toContain('href="/media/fixture-1.svg"');
expect(ns).toContain('href="/media/fixture-2.svg"');
});
test('personal pages carry a hidden "hidden by your AI filter" note; gate own work carries data-ai', () => {
for (const path of ['index.html', 'off-the-clock/index.html', 'off-the-clock/issues/2025-h2/index.html']) {
expect(page(out, path)).toMatch(/<p class="ai-hidden-note[^"]*" data-filter-note [^>]*hidden>/);
}
const gate = page(out, 'index.html');
expect(gate).toContain('class="scrap note" href="/off-the-clock/synthetic-life-note/" data-ai="some-ai"');
// Shares carry no level, so they are never counted or hidden.
expect(gate).toMatch(/class="scrap polaroid" href="\/off-the-clock\/fixture-comic\/" data-entry="[^"]+">/);
});
test('the gate cassette shows a strip of the song cover under a paper tab with title and artist', () => {
const gate = page(out, 'index.html');
const tape = gate.slice(gate.indexOf('class="scrap tape cover"'), gate.indexOf('class="scrap polaroid"'));
expect(tape).toContain('<span class="strip" aria-hidden="true" style="background-image:url(/media/fixture-3.svg)"></span>');
expect(tape).toMatch(/<a class="lbl tab" href="\/off-the-clock\/example-song\/">Example Song<small>Fixture Singer<\/small><\/a>/);
});
test('the gate grain is a pre-rendered tile used as a mask, not a live noise filter', () => {
const gate = page(out, 'index.html');
expect(gate).not.toContain('feTurbulence');
expect(existsSync(join(out, 'grain.webp'))).toBe(true);
const css = readdirSync(join(out, '_astro')).filter((f) => f.endsWith('.css')).map((f) => readFileSync(join(out, '_astro', f), 'utf8')).join('');
expect(css).toMatch(/mask:url\(\/grain\.webp\)/);
});
test('empty personal side shows the designed empty state', () => {
const html = page(sparseOut, 'off-the-clock/index.html');
expect(html).toContain('class="wall-empty"');
expect(html).not.toMatch(/<article\b[^>]*\bclass="card\b/);
expect(html).not.toContain('class="chips"');
expect(html).not.toContain('class="now"');
expect(html).not.toContain('older-year');
expect(html).not.toContain('undefined');
});
});