import { renderBlockToHtml } from '../renderer/block-renderers' import type { TextBlock, MetricsBlock, DividerBlock, CTABlock, ImageBlock, SpacerBlock, SocialBlock, HeaderBlock, FooterBlock, GlobalStyles, Block, } from '../types' import { DEFAULT_GLOBAL_STYLES } from '../types' const styles: GlobalStyles = DEFAULT_GLOBAL_STYLES // --------------------------------------------------------------------------- // TextBlock // --------------------------------------------------------------------------- describe('renderBlockToHtml — text block', () => { const base: TextBlock = { id: 't1', type: 'text', content: '

Hello

', } it('includes the text content in output', () => { expect(renderBlockToHtml(base, styles)).toContain('

Hello

') }) it('applies default font-size 16px when not specified', () => { expect(renderBlockToHtml(base, styles)).toContain('font-size:16px') }) it('uses block fontSize when provided', () => { const html = renderBlockToHtml({ ...base, fontSize: 20 }, styles) expect(html).toContain('font-size:20px') }) it('applies default line-height 1.6 when not specified', () => { expect(renderBlockToHtml(base, styles)).toContain('line-height:1.6') }) it('uses block lineHeight when provided', () => { const html = renderBlockToHtml({ ...base, lineHeight: 2 }, styles) expect(html).toContain('line-height:2') }) it('applies default text color #1f2937 when not specified', () => { expect(renderBlockToHtml(base, styles)).toContain('color:#1f2937') }) it('uses block textColor when provided', () => { const html = renderBlockToHtml({ ...base, textColor: '#ff0000' }, styles) expect(html).toContain('color:#ff0000') }) it('uses block fontFamily when provided', () => { const html = renderBlockToHtml({ ...base, fontFamily: 'Georgia, serif' }, styles) expect(html).toContain('font-family:Georgia, serif') }) it('falls back to globalStyles fontFamily when block fontFamily not set', () => { const html = renderBlockToHtml(base, { ...styles, fontFamily: 'Arial, sans-serif' }) expect(html).toContain('font-family:Arial, sans-serif') }) it('includes block style padding when set', () => { const block: TextBlock = { ...base, style: { paddingTop: 10, paddingBottom: 12 } } const html = renderBlockToHtml(block, styles) expect(html).toContain('padding-top:10px') expect(html).toContain('padding-bottom:12px') }) it('renders a div element', () => { expect(renderBlockToHtml(base, styles)).toMatch(/^
{ const base: MetricsBlock = { id: 'met1', type: 'metrics', title: 'Key Numbers', metrics: [ { id: 'm1', label: 'Revenue', value: '$10k', change: '+12%', changeType: 'positive' }, { id: 'm2', label: 'Churn', value: '2%', change: '-1%', changeType: 'negative' }, ], columns: 2, } it('includes the section title in output', () => { expect(renderBlockToHtml(base, styles)).toContain('Key Numbers') }) it('omits title element when title is undefined', () => { const html = renderBlockToHtml({ ...base, title: undefined }, styles) expect(html).not.toContain('Key Numbers') }) it('includes metric value', () => { expect(renderBlockToHtml(base, styles)).toContain('$10k') }) it('includes metric label', () => { expect(renderBlockToHtml(base, styles)).toContain('Revenue') }) it('includes positive change in green (#16a34a)', () => { const html = renderBlockToHtml(base, styles) expect(html).toContain('+12%') expect(html).toContain('#16a34a') }) it('includes negative change in red (#dc2626)', () => { const html = renderBlockToHtml(base, styles) expect(html).toContain('-1%') expect(html).toContain('#dc2626') }) it('renders table element for metric cells', () => { expect(renderBlockToHtml(base, styles)).toContain(' { const html = renderBlockToHtml(base, styles) expect(html).toContain('Revenue') expect(html).toContain('Churn') }) it('does not render change element when metric has no change field', () => { const block: MetricsBlock = { ...base, metrics: [{ id: 'mx', label: 'Solo', value: '7' }], } const html = renderBlockToHtml(block, styles) expect(html).toContain('Solo') expect(html).not.toContain('undefined') }) }) // --------------------------------------------------------------------------- // DividerBlock // --------------------------------------------------------------------------- describe('renderBlockToHtml — divider block', () => { const base: DividerBlock = { id: 'd1', type: 'divider', dividerStyle: 'solid', color: '#aabbcc', thickness: 2, width: 80, } it('renders solid border-top style', () => { const html = renderBlockToHtml(base, styles) expect(html).toContain('solid') expect(html).toContain('#aabbcc') }) it('applies thickness to border-top', () => { const html = renderBlockToHtml(base, styles) expect(html).toContain('2px') }) it('applies width percentage', () => { const html = renderBlockToHtml(base, styles) expect(html).toContain('width:80%') }) it('renders dashed border for dashed style', () => { const html = renderBlockToHtml({ ...base, dividerStyle: 'dashed' }, styles) expect(html).toContain('dashed') }) it('renders dotted border for dotted style', () => { const html = renderBlockToHtml({ ...base, dividerStyle: 'dotted' }, styles) expect(html).toContain('dotted') }) it('renders height div (no border) for space style', () => { const html = renderBlockToHtml({ ...base, dividerStyle: 'space' }, styles) expect(html).toContain('height:32px') expect(html).not.toContain('border-top') }) it('defaults color to #d1d5db when not specified', () => { const html = renderBlockToHtml({ ...base, color: undefined }, styles) expect(html).toContain('#d1d5db') }) it('defaults thickness to 1 when not specified', () => { const html = renderBlockToHtml({ ...base, thickness: undefined }, styles) expect(html).toContain('1px') }) it('defaults width to 100 when not specified', () => { const html = renderBlockToHtml({ ...base, width: undefined }, styles) expect(html).toContain('width:100%') }) it('wraps in a table element', () => { const html = renderBlockToHtml(base, styles) expect(html).toContain(' { const base: CTABlock = { id: 'cta1', type: 'cta', text: 'Get Started', url: 'https://example.com/start', buttonColor: '#6d28d9', textColor: '#fafafa', borderRadius: 8, paddingH: 20, paddingV: 10, alignment: 'center', } it('includes button text', () => { expect(renderBlockToHtml(base, styles)).toContain('Get Started') }) it('includes the button URL in anchor href', () => { expect(renderBlockToHtml(base, styles)).toContain('https://example.com/start') }) it('applies button background color', () => { expect(renderBlockToHtml(base, styles)).toContain('#6d28d9') }) it('applies text color', () => { expect(renderBlockToHtml(base, styles)).toContain('#fafafa') }) it('applies border-radius', () => { expect(renderBlockToHtml(base, styles)).toContain('border-radius:8px') }) it('applies horizontal padding', () => { expect(renderBlockToHtml(base, styles)).toContain('padding:10px 20px') }) it('applies alignment for center', () => { expect(renderBlockToHtml(base, styles)).toContain('align="center"') }) it('applies alignment for left', () => { const html = renderBlockToHtml({ ...base, alignment: 'left' }, styles) expect(html).toContain('align="left"') }) it('applies alignment for right', () => { const html = renderBlockToHtml({ ...base, alignment: 'right' }, styles) expect(html).toContain('align="right"') }) it('defaults button color to #2563eb when not provided', () => { const html = renderBlockToHtml({ ...base, buttonColor: undefined }, styles) expect(html).toContain('#2563eb') }) it('defaults text color to #ffffff when not provided', () => { const html = renderBlockToHtml({ ...base, textColor: undefined }, styles) expect(html).toContain('#ffffff') }) it('falls back to # for URL when url is empty', () => { const html = renderBlockToHtml({ ...base, url: '' }, styles) expect(html).toContain('href="#"') }) it('includes MSO VML conditional comment', () => { expect(renderBlockToHtml(base, styles)).toContain('[if mso]') }) it('includes [if !mso] conditional for non-Outlook clients', () => { expect(renderBlockToHtml(base, styles)).toContain('[if !mso]') }) }) // --------------------------------------------------------------------------- // ImageBlock // --------------------------------------------------------------------------- describe('renderBlockToHtml — image block', () => { const base: ImageBlock = { id: 'img1', type: 'image', url: 'https://img.example.com/photo.jpg', alt: 'Photo description', alignment: 'center', width: 100, } it('returns empty string when url is empty', () => { expect(renderBlockToHtml({ ...base, url: '' }, styles)).toBe('') }) it('includes img src', () => { expect(renderBlockToHtml(base, styles)).toContain('src="https://img.example.com/photo.jpg"') }) it('includes alt text', () => { expect(renderBlockToHtml(base, styles)).toContain('alt="Photo description"') }) it('applies alignment via td align attribute', () => { expect(renderBlockToHtml(base, styles)).toContain('align="center"') }) it('applies left alignment', () => { const html = renderBlockToHtml({ ...base, alignment: 'left' }, styles) expect(html).toContain('align="left"') }) it('applies width as pixel calculation', () => { // 100% of 600 = 600 expect(renderBlockToHtml(base, styles)).toContain('width="600"') }) it('calculates partial width (50% of 600 = 300)', () => { const html = renderBlockToHtml({ ...base, width: 50 }, styles) expect(html).toContain('width="300"') }) it('wraps image in anchor when linkUrl is set', () => { const html = renderBlockToHtml({ ...base, linkUrl: 'https://linked.example.com' }, styles) expect(html).toContain(' { const html = renderBlockToHtml(base, styles) expect(html).not.toContain(' { const html = renderBlockToHtml({ ...base, caption: 'Beautiful scenery' }, styles) expect(html).toContain('Beautiful scenery') }) it('does not include caption when absent', () => { const html = renderBlockToHtml(base, styles) expect(html).not.toContain('Beautiful scenery') }) }) // --------------------------------------------------------------------------- // SpacerBlock // --------------------------------------------------------------------------- describe('renderBlockToHtml — spacer block', () => { const base: SpacerBlock = { id: 'sp1', type: 'spacer', height: 48, } it('renders a height div', () => { expect(renderBlockToHtml(base, styles)).toContain('height:48px') }) it('line-height matches height', () => { expect(renderBlockToHtml(base, styles)).toContain('line-height:48px') }) it('defaults height to 32 when not provided', () => { const html = renderBlockToHtml({ ...base, height: 0 }, styles) expect(html).toContain('height:32px') }) it('renders a non-breaking space for email client support', () => { expect(renderBlockToHtml(base, styles)).toContain(' ') }) }) // --------------------------------------------------------------------------- // SocialBlock // --------------------------------------------------------------------------- describe('renderBlockToHtml — social block', () => { const base: SocialBlock = { id: 'soc1', type: 'social', links: [ { id: 'sl1', platform: 'linkedin', url: 'https://linkedin.com/in/test' }, { id: 'sl2', platform: 'twitter', url: 'https://twitter.com/test' }, ], iconSize: 24, alignment: 'center', } it('includes LinkedIn label text', () => { expect(renderBlockToHtml(base, styles)).toContain('LinkedIn') }) it('includes Twitter label text', () => { expect(renderBlockToHtml(base, styles)).toContain('Twitter') }) it('includes linkedin URL in anchor href', () => { expect(renderBlockToHtml(base, styles)).toContain('https://linkedin.com/in/test') }) it('applies alignment via td align', () => { expect(renderBlockToHtml(base, styles)).toContain('align="center"') }) it('applies left alignment', () => { const html = renderBlockToHtml({ ...base, alignment: 'left' }, styles) expect(html).toContain('align="left"') }) it('applies LinkedIn brand color (#0A66C2)', () => { expect(renderBlockToHtml(base, styles)).toContain('#0A66C2') }) it('applies Twitter brand color (#1DA1F2)', () => { expect(renderBlockToHtml(base, styles)).toContain('#1DA1F2') }) it('returns empty string when all links have no URL', () => { const block: SocialBlock = { ...base, links: [ { id: 'sl1', platform: 'linkedin', url: '' }, { id: 'sl2', platform: 'twitter', url: '' }, ], } expect(renderBlockToHtml(block, styles)).toBe('') }) it('excludes links with empty URL from output', () => { const block: SocialBlock = { ...base, links: [ { id: 'sl1', platform: 'linkedin', url: '' }, { id: 'sl2', platform: 'twitter', url: 'https://twitter.com/test' }, ], } const html = renderBlockToHtml(block, styles) expect(html).not.toContain('LinkedIn') expect(html).toContain('Twitter') }) it('renders all supported platform labels', () => { const block: SocialBlock = { ...base, links: [ { id: '1', platform: 'facebook', url: 'https://fb.com' }, { id: '2', platform: 'instagram', url: 'https://ig.com' }, { id: '3', platform: 'youtube', url: 'https://yt.com' }, { id: '4', platform: 'github', url: 'https://gh.com' }, { id: '5', platform: 'website', url: 'https://site.com' }, ], } const html = renderBlockToHtml(block, styles) expect(html).toContain('Facebook') expect(html).toContain('Instagram') expect(html).toContain('YouTube') expect(html).toContain('GitHub') expect(html).toContain('Website') }) }) // --------------------------------------------------------------------------- // HeaderBlock // --------------------------------------------------------------------------- describe('renderBlockToHtml — header block', () => { const base: HeaderBlock = { id: 'hdr1', type: 'header', companyName: 'Render Corp', alignment: 'center', } it('includes company name', () => { expect(renderBlockToHtml(base, styles)).toContain('Render Corp') }) it('applies alignment via td align', () => { expect(renderBlockToHtml(base, styles)).toContain('align="center"') }) it('applies left alignment', () => { const html = renderBlockToHtml({ ...base, alignment: 'left' }, styles) expect(html).toContain('align="left"') }) it('includes logo img when logoUrl is provided', () => { const html = renderBlockToHtml({ ...base, logoUrl: 'https://cdn.example.com/logo.png' }, styles) expect(html).toContain('src="https://cdn.example.com/logo.png"') expect(html).toContain('alt="Render Corp"') }) it('does not include img tag when logoUrl is absent', () => { expect(renderBlockToHtml(base, styles)).not.toContain(' { expect(renderBlockToHtml(base, styles)).toContain(' { const base: FooterBlock = { id: 'ftr1', type: 'footer', companyName: 'Simpli Co', address: '100 Startup Lane', showUnsubscribe: true, unsubscribeUrl: 'https://simpli.co/unsub', alignment: 'center', } it('includes company name', () => { expect(renderBlockToHtml(base, styles)).toContain('Simpli Co') }) it('includes address when provided', () => { expect(renderBlockToHtml(base, styles)).toContain('100 Startup Lane') }) it('omits address when not provided', () => { const html = renderBlockToHtml({ ...base, address: undefined }, styles) expect(html).not.toContain('100 Startup Lane') }) it('includes unsubscribe link text when showUnsubscribe is true', () => { expect(renderBlockToHtml(base, styles)).toContain('Unsubscribe') }) it('includes unsubscribe URL', () => { expect(renderBlockToHtml(base, styles)).toContain('https://simpli.co/unsub') }) it('falls back to # for unsubscribe URL when not provided', () => { const html = renderBlockToHtml({ ...base, unsubscribeUrl: undefined }, styles) expect(html).toContain('href="#"') }) it('omits unsubscribe element when showUnsubscribe is false', () => { const html = renderBlockToHtml({ ...base, showUnsubscribe: false }, styles) expect(html).not.toContain('Unsubscribe') }) it('applies alignment via td align', () => { expect(renderBlockToHtml(base, styles)).toContain('align="center"') }) it('applies small font-size for footer text', () => { expect(renderBlockToHtml(base, styles)).toContain('font-size:12px') }) it('wraps in table element', () => { expect(renderBlockToHtml(base, styles)).toContain(' { it('applies paddingLeft and paddingRight from block.style', () => { const block: TextBlock = { id: 't1', type: 'text', content: 'X', style: { paddingLeft: 16, paddingRight: 16 }, } const html = renderBlockToHtml(block, styles) expect(html).toContain('padding-left:16px') expect(html).toContain('padding-right:16px') }) it('applies marginTop and marginBottom from block.style', () => { const block: TextBlock = { id: 't1', type: 'text', content: 'X', style: { marginTop: 8, marginBottom: 8 }, } const html = renderBlockToHtml(block, styles) expect(html).toContain('margin-top:8px') expect(html).toContain('margin-bottom:8px') }) it('applies backgroundColor from block.style', () => { const block: TextBlock = { id: 't1', type: 'text', content: 'X', style: { backgroundColor: '#fefefe' }, } const html = renderBlockToHtml(block, styles) expect(html).toContain('background-color:#fefefe') }) it('returns empty string for an unknown block type via default case', () => { // Cast to Block to simulate an unhandled type reaching the switch default const unknownBlock = { id: 'x', type: 'unknown' } as unknown as Block expect(renderBlockToHtml(unknownBlock, styles)).toBe('') }) })