Files
havox/tests/script.test.js
T
2026-10-08 11:59:24 +01:00

345 lines
12 KiB
JavaScript

const assert = require('node:assert/strict');
const { readFileSync, existsSync } = require('node:fs');
const { resolve } = require('node:path');
const { test } = require('node:test');
const { runInNewContext } = require('node:vm');
const root = resolve(__dirname, '..');
const script = readFileSync(resolve(root, 'script.js'), 'utf8');
const html = readFileSync(resolve(root, 'index.html'), 'utf8');
function element(dataset = {}, textContent = '') {
const classes = new Set();
const attributes = new Map();
const listeners = new Map();
return {
dataset,
textContent,
hidden: true,
focused: false,
classList: {
add: (name) => classes.add(name),
contains: (name) => classes.has(name),
toggle: (name, force) => {
if (force) classes.add(name);
else classes.delete(name);
},
},
setAttribute: (name, value) => attributes.set(name, value),
getAttribute: (name) => attributes.get(name),
removeAttribute: (name) => attributes.delete(name),
addEventListener: (name, listener) => listeners.set(name, listener),
click: () => listeners.get('click')(),
dispatch: (name, event) => listeners.get(name)(event),
focus() {
this.focused = true;
},
scrollIntoView() {
this.scrolledIntoView = true;
},
};
}
function fixture({ missingFilter = false, reducedMotion = false } = {}) {
const chips = Array.from(
html.matchAll(
/<button\b[^>]*data-filter="([^"]+)"[^>]*>([\s\S]*?)<\/button>/g
),
(match) => element({ filter: match[1] }, match[2].trim())
);
if (missingFilter) chips.push(element({ filter: 'missing' }, 'Missing'));
const cards = Array.from(
html.matchAll(
/<article\s+class="card project-card"\s+data-tech="([^"]+)"/g
),
(match) => element({ tech: match[1] })
);
const controls = new Map(
[
'#projects-see-more',
'#project-count',
'#projects-empty',
'#project-grid',
'.project-filters',
'#projects-reset',
].map((selector) => [selector, element()])
);
const reveal = element();
const observers = [];
class IntersectionObserver {
constructor(callback, options) {
this.callback = callback;
this.options = options;
this.targets = [];
observers.push(this);
}
observe(target) {
this.targets.push(target);
}
unobserve(target) {
this.targets = this.targets.filter((item) => item !== target);
}
disconnect() {
this.targets = [];
this.disconnected = true;
}
}
const context = {
document: {
querySelectorAll: (selector) => {
if (selector === '.filter-chip') return chips;
if (selector === '#project-grid .project-card') return cards;
if (selector === '.reveal') return [reveal];
return [];
},
querySelector: (selector) => controls.get(selector) || null,
},
window: {
IntersectionObserver,
matchMedia: () => ({ matches: reducedMotion }),
},
IntersectionObserver,
};
return {
chips,
cards,
controls,
reveal,
observers,
context,
start: () => runInNewContext(script, context),
visible: () => cards.filter((card) => !card.hidden),
};
}
test('projects start collapsed with an accurate count and remaining label', () => {
const f = fixture();
f.start();
assert.equal(f.visible().length, 9);
assert.equal(
f.controls.get('#project-count').textContent,
`Showing 9 of ${f.cards.length} projects`
);
assert.equal(
f.controls.get('#projects-see-more').textContent,
`Show ${f.cards.length - 9} more projects`
);
assert.equal(f.controls.get('.project-filters').hidden, false);
assert.equal(f.controls.get('#projects-empty').hidden, true);
assert.equal(f.chips[0].getAttribute('aria-pressed'), 'true');
});
test('expanding and collapsing updates the count and keeps the control in view', () => {
const f = fixture();
f.start();
const button = f.controls.get('#projects-see-more');
button.click();
assert.equal(f.visible().length, f.cards.length);
assert.equal(button.getAttribute('aria-expanded'), 'true');
assert.equal(button.textContent, 'Show fewer projects');
button.click();
assert.equal(f.visible().length, 9);
assert.equal(button.getAttribute('aria-expanded'), 'false');
assert.equal(button.scrolledIntoView, true);
});
test('every technology filter selects matching cards and resets expansion', () => {
const f = fixture();
f.start();
for (const chip of f.chips) {
f.controls.get('#projects-see-more').click();
chip.click();
const expected = f.cards.filter(
(card) =>
chip.dataset.filter === 'all' ||
card.dataset.tech.split(/\s+/).includes(chip.dataset.filter)
);
assert.deepEqual(f.visible(), expected.slice(0, 9));
assert.equal(chip.getAttribute('aria-pressed'), 'true');
assert.equal(
f.chips.filter((item) => item.getAttribute('aria-pressed') === 'true')
.length,
1
);
assert.equal(
f.controls.get('#projects-see-more').hidden,
expected.length <= 9
);
assert.equal(
f.controls.get('#projects-see-more').getAttribute('aria-expanded'),
'false'
);
assert.equal(
f.controls.get('#project-count').textContent,
`Showing ${Math.min(9, expected.length)} of ${expected.length} project${expected.length === 1 ? '' : 's'}` +
(chip.dataset.filter === 'all' ? '' : ` (${chip.textContent})`)
);
}
});
test('empty results show a reset which restores all projects and focus', () => {
const f = fixture({ missingFilter: true });
f.start();
f.chips.at(-1).click();
assert.equal(f.visible().length, 0);
assert.equal(f.controls.get('#projects-empty').hidden, false);
assert.equal(f.controls.get('#project-grid').hidden, true);
assert.equal(f.controls.get('#projects-see-more').hidden, true);
f.controls.get('#projects-reset').click();
assert.equal(f.visible().length, 9);
assert.equal(f.controls.get('#projects-empty').hidden, true);
assert.equal(f.controls.get('#project-grid').hidden, false);
assert.equal(f.chips[0].focused, true);
});
test('reveals only become pending when an observer can reveal them', () => {
const f = fixture();
f.start();
assert.equal(f.reveal.classList.contains('reveal-pending'), true);
const observer = f.observers[0];
observer.callback([{ target: f.reveal, isIntersecting: true }], observer);
assert.equal(f.reveal.classList.contains('visible'), true);
assert.equal(observer.targets.length, 0);
const reduced = fixture({ reducedMotion: true });
reduced.start();
assert.equal(reduced.reveal.classList.contains('reveal-pending'), false);
const unsupported = fixture();
delete unsupported.context.window.IntersectionObserver;
unsupported.start();
assert.equal(unsupported.reveal.classList.contains('reveal-pending'), false);
assert.equal(unsupported.controls.get('.project-filters').hidden, false);
});
test('active navigation uses a viewport-sized observer and updates on resize', () => {
const f = fixture();
const sections = ['hero', 'about', 'skills', 'projects', 'contact'].map(
(id) => ({ ...element(), id })
);
const links = sections.slice(1).map((section) => {
const link = element();
link.setAttribute('href', `#${section.id}`);
return link;
});
const query = f.context.document.querySelectorAll;
f.context.document.querySelectorAll = (selector) => {
if (selector === 'section[id]') return sections;
if (selector === '.nav-links a, .mobile-nav-links a') return links;
return query(selector);
};
const listeners = new Map();
f.context.window.innerHeight = 900;
f.context.window.addEventListener = (name, listener) =>
listeners.set(name, listener);
f.start();
const observer = f.observers[0];
assert.equal(observer.options.rootMargin, '-56px 0px -740px 0px');
observer.callback([{ target: sections[1], isIntersecting: true }]);
assert.equal(links[0].getAttribute('aria-current'), 'location');
observer.callback([
{ target: sections[1], isIntersecting: false },
{ target: sections[2], isIntersecting: true },
]);
assert.equal(links[0].getAttribute('aria-current'), undefined);
assert.equal(links[1].getAttribute('aria-current'), 'location');
f.context.window.innerHeight = 600;
listeners.get('resize')();
assert.equal(observer.disconnected, true);
assert.equal(f.observers.at(-1).options.rootMargin, '-56px 0px -440px 0px');
assert.deepEqual(f.observers.at(-1).targets, sections);
});
test('mobile navigation closes after selection and Escape restores menu focus', () => {
const f = fixture();
const menu = element();
const summary = element();
const link = element();
menu.querySelectorAll = () => [link];
menu.querySelector = () => summary;
f.controls.set('.nav-menu', menu);
f.start();
menu.open = true;
link.click();
assert.equal(menu.open, false);
menu.open = true;
menu.dispatch('keydown', { key: 'Escape' });
assert.equal(menu.open, false);
assert.equal(summary.focused, true);
});
test('all project destinations are native links with existing local files', () => {
assert.doesNotMatch(html, /data-href=|class="[^"]*card-link/);
const links = Array.from(
html.matchAll(/<a class="card-title" href="([^"]+)"/g),
(match) => match[1]
);
assert.equal(links.length, 17);
for (const href of links) {
if (!href.startsWith('https://')) {
assert.ok(existsSync(resolve(root, href)), `Missing project: ${href}`);
}
}
});
test('the hero is text-only and featured projects have local screenshots', () => {
const hero = html.match(/<section id="hero">([\s\S]*?)<\/section>/)[1];
assert.doesNotMatch(hero, /<img|<figure|flocking/i);
const previews = Array.from(
html.matchAll(/<img\s+class="project-preview"[\s\S]*?\/>/g),
(match) => match[0]
);
assert.equal(previews.length, 2);
for (const preview of previews) {
const src = preview.match(/src="([^"]+)"/)[1];
assert.ok(existsSync(resolve(root, src)));
assert.match(preview, /loading="lazy"/);
assert.match(preview, /width="1200"/);
assert.match(preview, /height="800"/);
}
});
test('LinkedIn is the first and primary contact action, with GitHub secondary', () => {
const contact = html.match(/<section id="contact">([\s\S]*?)<\/section>/)[1];
const links = Array.from(
contact.matchAll(/<a\s+href="([^"]+)"\s+class="([^"]+)"/g)
);
assert.equal(links.length, 2);
assert.equal(links[0][1], 'https://www.linkedin.com/in/jaygatward');
assert.match(links[0][2], /btn-primary/);
assert.equal(links[1][1], 'https://github.com/jayo60013');
assert.doesNotMatch(links[1][2], /btn-primary/);
assert.match(contact, /Connect on LinkedIn/);
assert.match(contact, /Explore my code/);
});
test('About has a genuine local portrait and an unboxed opening paragraph', () => {
const about = html.match(/<section id="about"[^>]*>([\s\S]*?)<\/section>/)[1];
assert.match(about, /class="about-intro"/);
assert.doesNotMatch(about, /about-grid|about-block-tag/);
const portrait = about.match(/<img\s+class="about-portrait"[\s\S]*?\/>/)[0];
assert.match(portrait, /alt="John Gatward"/);
assert.match(portrait, /width="800"[\s\S]*height="800"/);
assert.ok(existsSync(resolve(root, portrait.match(/src="([^"]+)"/)[1])));
assert.match(about, /<h3 class="about-subheading">Version history<\/h3>/);
assert.equal(
(about.match(/href="archive\/v[123]\/index.html"/g) || []).length,
3
);
});
test('featured projects put title and purpose ahead of supporting metadata', () => {
const featured = Array.from(
html.matchAll(/<article class="card featured-card">([\s\S]*?)<\/article>/g),
(match) => match[1]
);
assert.equal(featured.length, 2);
for (const card of featured) {
assert.ok(card.indexOf('project-preview') < card.indexOf('card-title'));
assert.ok(card.indexOf('card-title') < card.indexOf('card-desc'));
assert.ok(card.indexOf('card-desc') < card.indexOf('project-meta'));
assert.ok(card.indexOf('project-meta') < card.indexOf('tag-row'));
}
assert.doesNotMatch(html, /class="section-label|class="divider"/);
});