- 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>
402 lines
22 KiB
TypeScript
402 lines
22 KiB
TypeScript
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('|'|’)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(/"/g, '"').replace(/&/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 & 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(/"/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');
|
||
});
|
||
});
|