test: vitest unit + playwright e2e suites; fix a11y, contrast, no-JS reveal
- vitest: dictionary parity, slug-map bijection, content-schema validation, JSON-LD (23) - playwright: both locales, language-switch mapping (incl. no-JS), CV downloads, form happy-path + honeypot, axe a11y on 5 templates x 2 themes (23) - fix: raise ink-faint + light accent to meet WCAG AA 4.5:1 (axe-verified) - fix: gate reveal animations behind html.js so content is visible without JS (progressive enhancement - was hidden at opacity 0); scan reduced-motion path - fix: validate contact form before the anti-bot time-trap (no false success) - chore: ESLint node globals, typed diagram props, resvg fontFiles, prettier pass Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,68 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import {
|
||||
getProfile,
|
||||
getSkills,
|
||||
getExperience,
|
||||
getProjectCards,
|
||||
getProject,
|
||||
getProjectIds,
|
||||
getMeta,
|
||||
} from '@lib/content';
|
||||
import { PAGE_IDS } from '@i18n/slugMap';
|
||||
import { LOCALES } from '@i18n/locales';
|
||||
|
||||
// Importing @lib/content runs every zod schema parse at module load; a schema
|
||||
// violation would throw here and fail the suite (DATA_MODEL §8).
|
||||
|
||||
describe('content loads and validates', () => {
|
||||
it('profile resolves in both locales', () => {
|
||||
for (const l of LOCALES) {
|
||||
const p = getProfile(l);
|
||||
expect(p.name).toBe('Connor Babbington');
|
||||
expect(p.chips.length).toBe(4);
|
||||
expect(p.aboutParagraphs.length).toBe(3);
|
||||
}
|
||||
});
|
||||
|
||||
it('exactly three projects, correctly ordered and typed', () => {
|
||||
const cards = getProjectCards('en');
|
||||
expect(cards.map((c) => c.id)).toEqual(['jobtrack', 'inboxintel', 'homelab']);
|
||||
expect(cards.find((c) => c.id === 'inboxintel')?.status).toBe('in-development');
|
||||
});
|
||||
|
||||
it('every case-study project has decisions and a security section (promise to P2)', () => {
|
||||
for (const id of getProjectIds()) {
|
||||
const p = getProject(id, 'en');
|
||||
if (p.template !== 'case-study') continue;
|
||||
const kinds = p.sections.map((s) => s.kind);
|
||||
expect(kinds, id).toContain('security');
|
||||
const decisions = p.sections.find((s) => s.kind === 'decisions');
|
||||
expect(decisions?.decisions?.length ?? 0, id).toBeGreaterThanOrEqual(3);
|
||||
}
|
||||
});
|
||||
|
||||
it('skills expose three groups', () => {
|
||||
expect(getSkills('no').map((g) => g.id)).toEqual([
|
||||
'development',
|
||||
'devops-infrastructure',
|
||||
'practices',
|
||||
]);
|
||||
});
|
||||
|
||||
it('experience features the council role with progression', () => {
|
||||
const w = getExperience('en').find((i) => i.id === 'warwickshire');
|
||||
expect(w?.emphasis).toBe('featured');
|
||||
expect(w?.progression?.length).toBe(2);
|
||||
});
|
||||
|
||||
it('page meta exists for every page in both locales, within length limits', () => {
|
||||
for (const id of PAGE_IDS) {
|
||||
for (const l of LOCALES) {
|
||||
const m = getMeta(id, l);
|
||||
expect(m.title.length, `${id}.${l} title`).toBeLessThanOrEqual(70);
|
||||
expect(m.description.length, `${id}.${l} desc`).toBeLessThanOrEqual(160);
|
||||
expect(m.description.length).toBeGreaterThan(0);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,45 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { en } from '@i18n/dictionary.en';
|
||||
import { no } from '@i18n/dictionary.no';
|
||||
import { interpolate } from '@i18n/t';
|
||||
|
||||
/** Collect all leaf key paths of a nested object. */
|
||||
function keyPaths(obj: unknown, prefix = ''): string[] {
|
||||
if (obj === null || typeof obj !== 'object') return [prefix];
|
||||
return Object.entries(obj as Record<string, unknown>).flatMap(([k, v]) =>
|
||||
keyPaths(v, prefix ? `${prefix}.${k}` : k),
|
||||
);
|
||||
}
|
||||
|
||||
describe('dictionary parity (I18N_SPEC §4.1)', () => {
|
||||
it('EN and NO expose exactly the same key paths', () => {
|
||||
const enKeys = keyPaths(en).sort();
|
||||
const noKeys = keyPaths(no).sort();
|
||||
expect(noKeys).toEqual(enKeys);
|
||||
});
|
||||
|
||||
it('no dictionary value is empty', () => {
|
||||
for (const [dict, name] of [
|
||||
[en, 'en'],
|
||||
[no, 'no'],
|
||||
] as const) {
|
||||
for (const path of keyPaths(dict)) {
|
||||
const value = path
|
||||
.split('.')
|
||||
.reduce<unknown>((acc, k) => (acc as Record<string, unknown>)[k], dict);
|
||||
expect(typeof value, `${name}.${path}`).toBe('string');
|
||||
expect((value as string).length, `${name}.${path}`).toBeGreaterThan(0);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('interpolate', () => {
|
||||
it('replaces named tokens', () => {
|
||||
expect(interpolate('© {year} X', { year: 2026 })).toBe('© 2026 X');
|
||||
expect(interpolate('pdf · {size} kB', { size: 132 })).toBe('pdf · 132 kB');
|
||||
});
|
||||
it('leaves unknown tokens untouched', () => {
|
||||
expect(interpolate('{a} {b}', { a: '1' })).toBe('1 {b}');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,62 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { alternates, buildJsonLd, absUrl, ogImagePath } from '@lib/seo';
|
||||
import { PAGE_IDS } from '@i18n/slugMap';
|
||||
|
||||
const ORIGIN = 'https://cesnimda.co.uk/';
|
||||
|
||||
describe('SEO builders (SEO_SPEC §2–4)', () => {
|
||||
it('alternates include en, nb and x-default for every page', () => {
|
||||
for (const id of PAGE_IDS) {
|
||||
const alts = alternates(id, ORIGIN);
|
||||
const langs = alts.map((a) => a.hreflang);
|
||||
expect(langs).toContain('en');
|
||||
expect(langs).toContain('nb');
|
||||
expect(langs).toContain('x-default');
|
||||
for (const a of alts) expect(a.href.startsWith('https://')).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it('x-default points at the English URL', () => {
|
||||
const alts = alternates('projects', ORIGIN);
|
||||
const xd = alts.find((a) => a.hreflang === 'x-default');
|
||||
const en = alts.find((a) => a.hreflang === 'en');
|
||||
expect(xd?.href).toBe(en?.href);
|
||||
});
|
||||
|
||||
it('home JSON-LD is a valid Person graph', () => {
|
||||
const graph = buildJsonLd('home', 'en', ORIGIN);
|
||||
const person = graph.find((g) => (g as { '@type': string })['@type'] === 'Person') as Record<
|
||||
string,
|
||||
unknown
|
||||
>;
|
||||
expect(person).toBeTruthy();
|
||||
expect(person.name).toBe('Connor Babbington');
|
||||
expect(person.jobTitle).toBe('Systems Developer');
|
||||
expect(() => JSON.stringify(graph)).not.toThrow();
|
||||
});
|
||||
|
||||
it('Norwegian home uses the localised job title', () => {
|
||||
const graph = buildJsonLd('home', 'no', ORIGIN);
|
||||
const person = graph.find((g) => (g as { '@type': string })['@type'] === 'Person') as Record<
|
||||
string,
|
||||
unknown
|
||||
>;
|
||||
expect(person.jobTitle).toBe('Systemutvikler');
|
||||
});
|
||||
|
||||
it('case-study JSON-LD carries the project name and author', () => {
|
||||
const graph = buildJsonLd('jobtrack', 'en', ORIGIN, {
|
||||
projectName: 'JobTrack',
|
||||
projectType: 'SoftwareApplication',
|
||||
});
|
||||
const work = graph[0] as Record<string, unknown>;
|
||||
expect(work['@type']).toBe('SoftwareApplication');
|
||||
expect(work.name).toBe('JobTrack');
|
||||
expect((work.author as Record<string, unknown>)['@type']).toBe('Person');
|
||||
});
|
||||
|
||||
it('helpers build absolute URLs and OG paths', () => {
|
||||
expect(absUrl('/projects/', ORIGIN)).toBe('https://cesnimda.co.uk/projects/');
|
||||
expect(ogImagePath('home', 'no')).toBe('/og/home-no.png');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,65 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import {
|
||||
PAGE_IDS,
|
||||
ROUTES,
|
||||
pathFor,
|
||||
pageIdFromPath,
|
||||
localeFromPath,
|
||||
twinPath,
|
||||
allRoutes,
|
||||
} from '@i18n/slugMap';
|
||||
import { LOCALES } from '@i18n/locales';
|
||||
|
||||
describe('slug map contract (ROUTING_SPEC §1, ARCHITECTURE A5)', () => {
|
||||
it('every pageId has a path in both locales', () => {
|
||||
for (const id of PAGE_IDS) {
|
||||
for (const locale of LOCALES) {
|
||||
const p = ROUTES[id][locale];
|
||||
expect(p, `${id}.${locale}`).toMatch(/^\/.*\/$|^\/$/); // trailing slash canonical
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it('all paths are unique (bijection — no two routes collide)', () => {
|
||||
const paths = allRoutes().map((r) => r.path);
|
||||
expect(new Set(paths).size).toBe(paths.length);
|
||||
});
|
||||
|
||||
it('NO paths are prefixed with /no and use localised slugs', () => {
|
||||
for (const id of PAGE_IDS) {
|
||||
expect(ROUTES[id].no.startsWith('/no/') || ROUTES[id].no === '/no/').toBe(true);
|
||||
}
|
||||
expect(ROUTES.projects.no).toBe('/no/prosjekter/');
|
||||
expect(ROUTES.homelab.no).toBe('/no/prosjekter/hjemmelab/');
|
||||
expect(ROUTES.about.no).toBe('/no/om-meg/');
|
||||
});
|
||||
|
||||
it('pageIdFromPath round-trips for every route', () => {
|
||||
for (const id of PAGE_IDS) {
|
||||
for (const locale of LOCALES) {
|
||||
expect(pageIdFromPath(pathFor(id, locale))).toBe(id);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it('localeFromPath detects the locale', () => {
|
||||
expect(localeFromPath('/projects/jobtrack/')).toBe('en');
|
||||
expect(localeFromPath('/no/prosjekter/jobtrack/')).toBe('no');
|
||||
expect(localeFromPath('/')).toBe('en');
|
||||
expect(localeFromPath('/no/')).toBe('no');
|
||||
});
|
||||
|
||||
it('twinPath maps a page to its twin locale and back', () => {
|
||||
for (const id of PAGE_IDS) {
|
||||
const en = pathFor(id, 'en');
|
||||
const no = pathFor(id, 'no');
|
||||
expect(twinPath(en, 'no')).toBe(no);
|
||||
expect(twinPath(no, 'en')).toBe(en);
|
||||
}
|
||||
});
|
||||
|
||||
it('twinPath falls back to the target home for unmapped paths', () => {
|
||||
expect(twinPath('/nonsense/', 'no')).toBe('/no/');
|
||||
expect(twinPath('/no/tull/', 'en')).toBe('/');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user