2026-07-12 14:18:49 -07:00
|
|
|
import { describe, test, expect } from 'vitest';
|
|
|
|
|
import { ContentSlider } from './ContentSlider';
|
|
|
|
|
|
|
|
|
|
const toHtml = (ContentSlider as any).toHtml;
|
|
|
|
|
|
|
|
|
|
const slides = [
|
|
|
|
|
{ type: 'image' as const, heading: 'One' },
|
|
|
|
|
{ type: 'image' as const, heading: 'Two' },
|
|
|
|
|
{ type: 'image' as const, heading: 'Three' },
|
|
|
|
|
];
|
|
|
|
|
|
|
|
|
|
describe('ContentSlider.toHtml accessibility (F1.1)', () => {
|
|
|
|
|
test('prev/next arrows get aria-labels and are real buttons', () => {
|
|
|
|
|
const { html } = toHtml({ slides, showArrows: true }, '');
|
|
|
|
|
expect(html).toMatch(/<button[^>]*aria-label="Previous slide"/);
|
|
|
|
|
expect(html).toMatch(/<button[^>]*aria-label="Next slide"/);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('dot buttons get "Go to slide N" aria-labels', () => {
|
|
|
|
|
const { html } = toHtml({ slides, showDots: true }, '');
|
|
|
|
|
expect(html).toMatch(/<button[^>]*aria-label="Go to slide 1"/);
|
|
|
|
|
expect(html).toMatch(/<button[^>]*aria-label="Go to slide 2"/);
|
|
|
|
|
expect(html).toMatch(/<button[^>]*aria-label="Go to slide 3"/);
|
|
|
|
|
});
|
|
|
|
|
|
2026-07-12 14:35:27 -07:00
|
|
|
test('slides are wrapped in an aria-live region', () => {
|
2026-07-12 14:18:49 -07:00
|
|
|
const { html } = toHtml({ slides }, '');
|
2026-07-12 14:35:27 -07:00
|
|
|
expect(html).toMatch(/aria-live="(polite|off)"/);
|
2026-07-12 14:18:49 -07:00
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('decorative chevron icons in arrows are aria-hidden', () => {
|
|
|
|
|
const { html } = toHtml({ slides, showArrows: true }, '');
|
|
|
|
|
expect(html).toMatch(/<i class="fa fa-chevron-left" aria-hidden="true"><\/i>/);
|
|
|
|
|
expect(html).toMatch(/<i class="fa fa-chevron-right" aria-hidden="true"><\/i>/);
|
|
|
|
|
});
|
|
|
|
|
});
|
2026-07-12 14:35:27 -07:00
|
|
|
|
|
|
|
|
describe('ContentSlider.toHtml deterministic + unique scope ids (thread node id)', () => {
|
|
|
|
|
test('same node id -> identical output across calls (deterministic, no Math.random)', () => {
|
|
|
|
|
const { html: html1 } = toHtml({ slides }, '', 'node-cs1');
|
|
|
|
|
const { html: html2 } = toHtml({ slides }, '', 'node-cs1');
|
|
|
|
|
expect(html1).toBe(html2);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('different node ids -> different, non-colliding scope ids (identical slides, no collision)', () => {
|
|
|
|
|
const { html: html1 } = toHtml({ slides }, '', 'node-cs1');
|
|
|
|
|
const { html: html2 } = toHtml({ slides }, '', 'node-cs2');
|
|
|
|
|
const id1 = html1.match(/<section id="([^"]+)"/)![1];
|
|
|
|
|
const id2 = html2.match(/<section id="([^"]+)"/)![1];
|
|
|
|
|
expect(id1).not.toBe(id2);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('no nodeId (legacy 2-arg call): still deterministic across repeated calls, not random', () => {
|
|
|
|
|
const { html: html1 } = toHtml({ slides }, '');
|
|
|
|
|
const { html: html2 } = toHtml({ slides }, '');
|
|
|
|
|
expect(html1).toBe(html2);
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
describe('ContentSlider.toHtml autoplay silences aria-live and is pausable (F-export review Minor)', () => {
|
|
|
|
|
test('aria-live is "off" while autoplay is running, to avoid announcing every auto-rotation', () => {
|
|
|
|
|
const { html } = toHtml({ slides, autoplay: true }, '');
|
|
|
|
|
expect(html).toContain('aria-live="off"');
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('aria-live stays "polite" when autoplay is disabled', () => {
|
|
|
|
|
const { html } = toHtml({ slides, autoplay: false }, '');
|
|
|
|
|
expect(html).toContain('aria-live="polite"');
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('manual navigation (next/prev/dot) marks the live region "polite"', () => {
|
|
|
|
|
const { html } = toHtml({ slides, autoplay: true }, '');
|
|
|
|
|
expect(html).toMatch(/setAttribute\(["']aria-live["'],\s*["']polite["']\)/);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('autoplay pauses on hover and resumes on mouse leave', () => {
|
|
|
|
|
const { html } = toHtml({ slides, autoplay: true }, '');
|
|
|
|
|
expect(html).toMatch(/addEventListener\(["']mouseenter["']/);
|
|
|
|
|
expect(html).toMatch(/addEventListener\(["']mouseleave["']/);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('autoplay pauses when the tab is hidden (visibilitychange) and the interval is clearable', () => {
|
|
|
|
|
const { html } = toHtml({ slides, autoplay: true }, '');
|
|
|
|
|
expect(html).toMatch(/visibilitychange/);
|
|
|
|
|
expect(html).toMatch(/clearInterval\(/);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('no autoplay: no setInterval/hover/visibility wiring at all', () => {
|
|
|
|
|
const { html } = toHtml({ slides, autoplay: false }, '');
|
|
|
|
|
expect(html).not.toMatch(/setInterval\(/);
|
|
|
|
|
expect(html).not.toMatch(/visibilitychange/);
|
|
|
|
|
});
|
|
|
|
|
});
|
2026-07-12 17:32:47 -07:00
|
|
|
|
|
|
|
|
describe('ContentSlider.toHtml renders slide.imageSrc as a background-image (INT)', () => {
|
|
|
|
|
test('a slide with imageSrc set exports a background-image referencing it', () => {
|
|
|
|
|
const slidesWithImage = [
|
|
|
|
|
{ type: 'image' as const, imageSrc: 'https://example.com/photo.jpg', heading: 'One' },
|
|
|
|
|
];
|
|
|
|
|
const { html } = toHtml({ slides: slidesWithImage }, '');
|
|
|
|
|
expect(html).toContain("background-image:url('https://example.com/photo.jpg')");
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('a slide with no imageSrc falls back to bgColor (no broken/empty background-image url)', () => {
|
|
|
|
|
const slidesNoImage = [
|
|
|
|
|
{ type: 'image' as const, imageSrc: '', heading: 'One', bgColor: '#123456' },
|
|
|
|
|
];
|
|
|
|
|
const { html } = toHtml({ slides: slidesNoImage }, '');
|
|
|
|
|
expect(html).not.toContain('background-image:url(');
|
|
|
|
|
expect(html).toContain('background-color:#123456');
|
|
|
|
|
});
|
|
|
|
|
});
|
2026-07-12 18:03:44 -07:00
|
|
|
|
|
|
|
|
describe('ContentSlider.toHtml interval is NOT runtime-type-checked -- must be coerced before it reaches the inline <script> numeric context', () => {
|
|
|
|
|
test('a malicious interval string cannot inject arbitrary JS into the autoplay setInterval call', () => {
|
|
|
|
|
const malicious = '5000);alert(document.domain);//';
|
|
|
|
|
const { html } = toHtml({ slides, autoplay: true, interval: malicious }, '');
|
|
|
|
|
expect(html).not.toContain('alert(document.domain)');
|
|
|
|
|
// the setInterval call must still be well-formed with a plain numeral delay
|
|
|
|
|
expect(html).toMatch(/setInterval\(function\(\)\{show\(current\+1\);\},\d+\);/);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('a non-numeric interval falls back to a safe default delay', () => {
|
|
|
|
|
const { html } = toHtml({ slides, autoplay: true, interval: 'not-a-number' }, '');
|
|
|
|
|
expect(html).toMatch(/setInterval\(function\(\)\{show\(current\+1\);\},5000\);/);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('a normal numeric interval still renders as the exact configured delay', () => {
|
|
|
|
|
const { html } = toHtml({ slides, autoplay: true, interval: 3000 }, '');
|
|
|
|
|
expect(html).toMatch(/setInterval\(function\(\)\{show\(current\+1\);\},3000\);/);
|
|
|
|
|
});
|
|
|
|
|
});
|