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( /]*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( / 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(/ 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(/
([\s\S]*?)<\/section>/)[1]; assert.doesNotMatch(hero, //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(/
([\s\S]*?)<\/section>/)[1]; const links = Array.from( contact.matchAll(/ { const about = html.match(/
]*>([\s\S]*?)<\/section>/)[1]; assert.match(about, /class="about-intro"/); assert.doesNotMatch(about, /about-grid|about-block-tag/); const portrait = about.match(//)[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, /

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(/