Merge pull request #493 from PR0M3TH3AN/codex/add-unit-tests-for-blog-carousel

Add blog carousel unit tests with DOM style guard
This commit is contained in:
thePR0M3TH3AN authored and GitHub committed 2025-10-13 16:05:24 -04:00
commit d5c0847dd7
2 files changed
+414

No files matched your search

+236
View File
@@ -0,0 +1,236 @@
import assert from 'node:assert/strict';
import { beforeEach, afterEach, describe, test } from 'node:test';
import { advanceSlide, createCarousel, destroyCarousel } from '../js/blog/carousel.js';
import { mountProgressBar } from '../js/blog/progressBar.js';
import {
createBlogDomEnvironment,
createSpy,
readDynamicStyleRules,
} from './test-helpers/blog-dom.mjs';
const CAROUSEL_HTML = `
<div data-carousel>
<div data-carousel-track>
<article data-slide="0">Slide 1</article>
<article data-slide="1">Slide 2</article>
<article data-slide="2">Slide 3</article>
</div>
<button type="button" data-carousel-prev>Prev</button>
<button type="button" data-carousel-next>Next</button>
</div>
`;
const PROGRESS_HTML = '<div id="progress-root"></div>';
describe('blog carousel', () => {
let env;
let container;
let track;
let slides;
let prevButton;
let nextButton;
beforeEach(() => {
env = createBlogDomEnvironment(CAROUSEL_HTML);
const doc = env.document;
container = doc.querySelector('[data-carousel]');
track = container.querySelector('[data-carousel-track]');
slides = Array.from(track.children);
prevButton = container.querySelector('[data-carousel-prev]');
nextButton = container.querySelector('[data-carousel-next]');
env.inlineStyleGuard.guard(container);
env.inlineStyleGuard.guard(track);
env.inlineStyleGuard.guard(prevButton);
env.inlineStyleGuard.guard(nextButton);
slides.forEach((slide) => env.inlineStyleGuard.guard(slide));
});
afterEach(() => {
env.cleanup();
});
test('initializes slides with dynamic styling and accessibility state', () => {
const state = createCarousel(container, {
perPage: 1,
loop: true,
controls: { prev: prevButton, next: nextButton },
});
assert.ok(state, 'state should be created when container markup is valid');
assert.strictEqual(state.perPage, 1);
assert.strictEqual(state.slides.length, 3);
assert.ok(container.classList.contains('splide'));
assert.ok(container.classList.contains('is-initialized'));
assert.ok(container.classList.contains('is-active'));
const trackDynamicClasses = Array.from(track.classList).filter((className) => className.startsWith('bvds-'));
assert.ok(trackDynamicClasses.length >= 1, 'track should receive dynamic style classes');
for (const slide of slides) {
assert.ok(slide.classList.contains('splide__slide'));
const dynamicClasses = Array.from(slide.classList).filter((className) => className.startsWith('bvds-'));
assert.ok(dynamicClasses.length >= 1, 'slide should receive dynamic sizing styles');
}
assert.strictEqual(slides[0].getAttribute('aria-hidden'), 'false');
assert.strictEqual(slides[1].getAttribute('aria-hidden'), 'true');
assert.strictEqual(slides[2].getAttribute('aria-hidden'), 'true');
state.goTo(2);
assert.strictEqual(state.index, 2);
assert.strictEqual(slides[2].getAttribute('aria-hidden'), 'false');
assert.strictEqual(slides[0].getAttribute('aria-hidden'), 'true');
destroyCarousel(state);
});
test('control buttons advance slides and respect looping', () => {
const state = createCarousel(container, {
perPage: 1,
loop: true,
controls: { prev: prevButton, next: nextButton },
});
nextButton.dispatchEvent(new env.window.MouseEvent('click', { bubbles: true }));
assert.strictEqual(state.index, 1);
nextButton.dispatchEvent(new env.window.MouseEvent('click', { bubbles: true }));
assert.strictEqual(state.index, 2);
nextButton.dispatchEvent(new env.window.MouseEvent('click', { bubbles: true }));
assert.strictEqual(state.index, 0, 'looping should wrap forward navigation to the first slide');
prevButton.dispatchEvent(new env.window.MouseEvent('click', { bubbles: true }));
assert.strictEqual(state.index, 2, 'looping should wrap backward navigation to the last slide');
destroyCarousel(state);
});
test('auto play progress resets when navigation occurs', () => {
const state = createCarousel(container, {
perPage: 1,
controls: { prev: prevButton, next: nextButton },
});
const events = [];
const progress = {
reset: () => events.push('reset'),
start: (duration) => events.push(['start', duration]),
destroy: () => events.push('destroy'),
};
state.setProgress(progress);
state.autoPlayInterval = 4200;
state.goTo(1);
assert.deepStrictEqual(events, ['reset', ['start', 4200]]);
events.length = 0;
state.goTo(2, { restartProgress: false });
assert.deepStrictEqual(events, [], 'progress should not restart when restartProgress is false');
destroyCarousel(state);
});
test('destroyCarousel tears down listeners and progress state', () => {
const state = createCarousel(container, {
perPage: 1,
controls: { prev: prevButton, next: nextButton },
});
const progressEvents = [];
const progress = {
reset: () => progressEvents.push('reset'),
start: () => progressEvents.push('start'),
destroy: () => progressEvents.push('destroy'),
};
state.setProgress(progress);
state.autoPlayInterval = 2000;
state.goTo(1);
destroyCarousel(state);
assert.ok(progressEvents.includes('destroy'), 'progress.destroy should be called during teardown');
assert.ok(!container.classList.contains('is-initialized'));
assert.ok(!container.classList.contains('is-active'));
assert.strictEqual(state.cleanup.length, 0);
const previousIndex = state.index;
nextButton.dispatchEvent(new env.window.MouseEvent('click', { bubbles: true }));
assert.strictEqual(state.index, previousIndex, 'clicks after destroy should not change the index');
});
test('advanceSlide delegates to goTo with relative offsets', () => {
const state = createCarousel(container, { perPage: 1 });
const goToSpy = createSpy();
state.goTo = goToSpy;
state.index = 2;
advanceSlide(state, -1, { restartProgress: false });
assert.strictEqual(goToSpy.callCount, 1);
assert.deepStrictEqual(goToSpy.calls[0], [1, { restartProgress: false }]);
});
});
describe('progress bar', () => {
let env;
let container;
beforeEach(() => {
env = createBlogDomEnvironment(PROGRESS_HTML);
container = env.document.getElementById('progress-root');
env.inlineStyleGuard.guard(container);
});
afterEach(() => {
env.cleanup();
});
test('mountProgressBar renders structure with dynamic width styles', () => {
const progress = mountProgressBar(container, { initialDuration: 500, autoStart: false });
assert.ok(progress, 'progress instance should be returned');
const wrapper = container.querySelector('.splide__progress');
assert.ok(wrapper, 'wrapper element should be appended');
const fill = wrapper.querySelector('.splide__progress__bar');
assert.ok(fill, 'fill element should be created');
progress.reset();
const cssRules = readDynamicStyleRules(env.document);
assert.match(cssRules, /width:\s*0%/);
});
test('autoStart runs animation and triggers completion callback', () => {
const onComplete = createSpy();
const progress = mountProgressBar(container, { initialDuration: 600, autoStart: true, onComplete });
const wrapper = progress.element;
const fill = wrapper.querySelector('.splide__progress__bar');
assert.ok(Array.from(fill.classList).some((className) => className.startsWith('bvds-')));
env.raf.step(0);
env.raf.step(300);
assert.strictEqual(onComplete.callCount, 0);
env.raf.step(300);
assert.strictEqual(onComplete.callCount, 1);
const cssRules = readDynamicStyleRules(env.document);
assert.match(cssRules, /width:\s*100%/);
});
test('manual controls support stop, restart, and cleanup', () => {
const onComplete = createSpy();
const progress = mountProgressBar(container, { initialDuration: 800, onComplete });
progress.start(800);
env.raf.step(0);
env.raf.step(400);
progress.stop();
env.raf.step(600);
assert.strictEqual(onComplete.callCount, 0, 'stop should cancel pending completion');
progress.setOnComplete(onComplete);
progress.start(0);
assert.strictEqual(onComplete.callCount, 1, 'zero duration should fire completion immediately');
progress.destroy();
assert.strictEqual(container.children.length, 0, 'destroy should remove wrapper from DOM');
});
});
+178
View File
@@ -0,0 +1,178 @@
import { JSDOM } from 'jsdom';
const DOM_GLOBALS = [
'window',
'document',
'HTMLElement',
'HTMLStyleElement',
'Element',
'Node',
'Event',
'MouseEvent',
'CustomEvent',
'KeyboardEvent',
'MutationObserver',
'getComputedStyle',
];
export function createSpy(implementation = () => {}) {
function spy(...args) {
spy.calls.push(args);
spy.callCount += 1;
return implementation(...args);
}
spy.calls = [];
spy.callCount = 0;
return spy;
}
export function createAnimationFrameStub() {
let now = 0;
let handle = 0;
const queue = new Map();
function requestAnimationFrame(callback) {
handle += 1;
const id = handle;
queue.set(id, callback);
return id;
}
function cancelAnimationFrame(id) {
queue.delete(id);
}
function step(elapsed = 0) {
now += elapsed;
const callbacks = Array.from(queue.entries());
queue.clear();
for (const [, callback] of callbacks) {
callback(now);
}
}
return {
requestAnimationFrame,
cancelAnimationFrame,
step,
get now() {
return now;
},
get pending() {
return queue.size;
},
};
}
export function installInlineStyleGuard(document) {
const originalCreateElement = document.createElement.bind(document);
const guardedElements = new WeakSet();
function guard(element) {
if (!element || typeof element !== 'object') {
return element;
}
if (guardedElements.has(element)) {
return element;
}
Object.defineProperty(element, 'style', {
get() {
throw new Error(
'Inline style access is disabled in blog widget tests. Use applyDynamicStyles or CSS class toggles instead.',
);
},
set() {
throw new Error(
'Inline style assignment is disabled in blog widget tests. Use applyDynamicStyles or CSS class toggles instead.',
);
},
configurable: true,
});
guardedElements.add(element);
if (element.children?.length) {
Array.from(element.children).forEach((child) => guard(child));
}
return element;
}
document.createElement = function createElementGuarded(...args) {
const element = originalCreateElement(...args);
guard(element);
return element;
};
return {
guard,
restore() {
document.createElement = originalCreateElement;
},
};
}
export function readDynamicStyleRules(document) {
const styleElement = document.getElementById('bitvid-style-system');
const sheet = styleElement?.sheet;
if (!sheet) {
return '';
}
try {
return Array.from(sheet.cssRules)
.map((rule) => rule.cssText)
.join('\n');
} catch (error) {
return '';
}
}
export function createBlogDomEnvironment(html) {
const dom = new JSDOM(html, { url: 'https://example.com' });
const { window } = dom;
const previousGlobals = new Map();
for (const key of DOM_GLOBALS) {
previousGlobals.set(key, globalThis[key]);
const nextValue = window[key];
if (typeof nextValue !== 'undefined') {
globalThis[key] = nextValue;
} else {
delete globalThis[key];
}
}
const inlineStyleGuard = installInlineStyleGuard(window.document);
const raf = createAnimationFrameStub();
previousGlobals.set('requestAnimationFrame', globalThis.requestAnimationFrame);
previousGlobals.set('cancelAnimationFrame', globalThis.cancelAnimationFrame);
globalThis.requestAnimationFrame = raf.requestAnimationFrame;
globalThis.cancelAnimationFrame = raf.cancelAnimationFrame;
window.requestAnimationFrame = raf.requestAnimationFrame;
window.cancelAnimationFrame = raf.cancelAnimationFrame;
return {
window,
document: window.document,
dom,
raf,
inlineStyleGuard,
cleanup() {
inlineStyleGuard.restore();
dom.window.close();
for (const [key, value] of previousGlobals) {
if (typeof value === 'undefined') {
delete globalThis[key];
} else {
globalThis[key] = value;
}
}
},
};
}