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('')
})
})