mirror of
https://github.com/PR0M3TH3AN/bitvid.git
synced 2026-10-27 16:02:26 +00:00
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:
2 files changed
+414
No files matched your search
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
Reference in new issue
Block a user