345 lines
12 KiB
JavaScript
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"/);
|
|
});
|