Files
site-builder/craft/src/components/sections/ContentSlider.toHtml.test.ts
T
shadowdao 0419291259 feat(builder): media package -- image crop/perf, video size+picker, gallery cols/lightbox, box-model+anim/vis rollout
- ImageBlock/ImageStylePanel: SizeControl width(absorbs old maxWidth
  presets)/height, AspectRatioControl + OBJECT_FIT grid + FocalPointGrid for
  CSS framing crop (aspect-ratio/object-fit/object-position on the <img>).
  Exported <img> always gets loading="lazy" decoding="async", plus width/
  height attrs when the style has a plain px length (pxAttr helper).
- VideoBlock/MediaStylePanel: Video URL text input replaced with
  AssetPicker(mediaType="video") writing videoUrl (paste-URL still handles
  YouTube/Vimeo, upload/browse handle files). Added SizeControl(width) +
  AspectRatioControl so the video frame honors real size/aspect instead of
  a hardcoded 16:9 (padding-bottom hack replaced with CSS aspect-ratio).
  New optional `poster` prop (image AssetPicker) + preload="metadata" on
  file-type <video>.
- Gallery: surfaced the existing-but-unexposed `columns` and `lightbox`
  props with panel controls.
- All 5 owned components (ImageBlock, VideoBlock, Gallery, ContentSlider,
  MapEmbed): added margin/padding (per-side)/border/box-shadow/opacity style
  defaults + AnimationControl/VisibilityControl-backed animation/
  animationDelay/hideOnDesktop/hideOnTablet/hideOnMobile props. New shared
  mediaBoxModel.tsx (package-local, not shared.tsx) DRYs the box-model +
  border/effects + animation/visibility panel sections across
  ImageStylePanel and MediaStylePanel.
- Tests: extended *.toHtml.test.ts for all 5 components (crop/perf attrs,
  video size/aspect/poster/preload, gallery columns/lightbox, box-model
  style emission, craft.props presence) + new MediaStylePanel.video.test.tsx
  verifying the AssetPicker wiring writes videoUrl/poster and the video-only
  size controls are gated on videoUrl. 694 tests green, tsc + vite build
  clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-14 06:30:50 -07:00

185 lines
7.8 KiB
TypeScript

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"/);
});
test('slides are wrapped in an aria-live region', () => {
const { html } = toHtml({ slides }, '');
expect(html).toMatch(/aria-live="(polite|off)"/);
});
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>/);
});
});
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/);
});
});
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');
});
test('a slide with a data:image/svg+xml imageSrc exports a non-empty background-image url (safeImageUrl, not safeUrl)', () => {
const svgDataUri = 'data:image/svg+xml,%3Csvg%2F%3E';
const slidesWithSvg = [
{ type: 'image' as const, imageSrc: svgDataUri, heading: 'One' },
];
const { html } = toHtml({ slides: slidesWithSvg }, '');
expect(html).toContain(`background-image:url('${svgDataUri}')`);
});
});
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\);/);
});
});
describe('ContentSlider.toHtml box-model styles (margin/padding/border/shadow/opacity)', () => {
test('margin/padding/border/box-shadow/opacity all flow into the section style attribute', () => {
const { html } = toHtml(
{
slides,
style: {
marginBottom: '24px',
paddingTop: '8px',
border: '3px dashed #00ff00',
boxShadow: '0 10px 24px rgba(0,0,0,0.18)',
opacity: '0.75',
},
},
''
);
expect(html).toContain('margin-bottom:24px');
expect(html).toContain('padding-top:8px');
expect(html).toContain('border:3px dashed #00ff00');
expect(html).toContain('box-shadow:0 10px 24px rgba(0,0,0,0.18)');
expect(html).toContain('opacity:0.75');
});
});
describe('ContentSlider.craft.props exposes the animation/visibility rollout', () => {
test('animation, animationDelay, hideOnDesktop/Tablet/Mobile are present with blank/false defaults', () => {
const props = (ContentSlider as any).craft.props;
expect(props.animation).toBe('');
expect(props.animationDelay).toBe('0');
expect(props.hideOnDesktop).toBe(false);
expect(props.hideOnTablet).toBe(false);
expect(props.hideOnMobile).toBe(false);
});
test('style carries blank/default box-model keys', () => {
const style = (ContentSlider as any).craft.props.style;
expect(style).toHaveProperty('marginTop');
expect(style).toHaveProperty('paddingTop');
expect(style.border).toBe('none');
expect(style.boxShadow).toBe('none');
expect(style.opacity).toBe('1');
});
});