import assert from 'node:assert/strict'; import { describe, it } from 'node:test'; import { buildRedditTitle, REDDIT_POST_LIMIT, REDDIT_TITLE_LIMIT, renderRedditPost, renderTelegramPost, splitHighlights, TELEGRAM_MESSAGE_LIMIT, } from './release-announcements.mjs'; function note(overrides = {}) { return { type: 'feature', area: 'playback', issues: [], screenshot: null, highlight: null, unknownKeys: [], body: 'Series now show an Up Next rail beside the player.', sourcePath: '.changes/playback-up-next.md', ...overrides, }; } describe('splitHighlights', () => { it('separates highlight notes from the rest in group order', () => { const { highlights, rest } = splitHighlights([ note({ type: 'fix', body: 'Fixed resume.' }), note({ highlight: 'Up Next rail' }), note({ type: 'breaking', highlight: 'New settings layout', body: 'Settings moved.', }), ]); // breaking sorts before feature regardless of input order assert.deepEqual( highlights.map((entry) => entry.highlight), ['New settings layout', 'Up Next rail'] ); assert.deepEqual( rest.map((entry) => entry.body), ['Fixed resume.'] ); }); it('drops internal notes entirely', () => { const { highlights, rest } = splitHighlights([ note({ type: 'internal', body: 'Split the store.' }), ]); assert.deepEqual(highlights, []); assert.deepEqual(rest, []); }); }); describe('renderTelegramPost', () => { it('leads with highlights and compresses the rest into a counter', () => { const post = renderTelegramPost( [ note({ highlight: 'Up Next rail' }), note({ type: 'fix', body: 'Stalker resume works again.' }), note({ type: 'fix', body: 'EPG no longer flickers.' }), ], { version: '0.24.0' } ); assert.match(post, /^🎉 IPTVnator v0\.24\.0 is out!/); assert.match( post, /✨ Up Next rail — Series now show an Up Next rail beside the player\./ ); assert.match(post, /…plus 2 more fixes and improvements\./); assert.doesNotMatch(post, /Stalker resume|EPG no longer/); assert.match( post, /⬇️ Download: https:\/\/github\.com\/4gray\/iptvnator\/releases\/tag\/v0\.24\.0/ ); assert.match( post, /📝 Full notes: https:\/\/4gray\.github\.io\/iptvnator\/blog\/v0-24-release-notes\// ); }); it('uses the singular counter line for one hidden change', () => { const post = renderTelegramPost( [ note({ highlight: 'Up Next rail' }), note({ type: 'fix', body: 'Stalker resume works again.' }), ], { version: '0.24.0' } ); assert.match(post, /…plus 1 more fix or improvement\./); }); it('lists all changes when no note is highlighted', () => { const post = renderTelegramPost( [ note({ type: 'fix', body: 'Stalker resume works again.' }), note({ type: 'perf', body: 'Playlists import faster.' }), ], { version: '0.24.1' } ); assert.match(post, /🔧 Stalker resume works again\./); assert.match(post, /⚡ Playlists import faster\./); assert.doesNotMatch(post, /…plus/); }); it('omits internal notes and stays inside the Telegram limit', () => { const notes = Array.from({ length: 120 }, (_, index) => note({ type: 'fix', body: `Fix number ${index}: ${'detail '.repeat(10)}.`, sourcePath: `.changes/fix-${index}.md`, }) ); notes.push(note({ type: 'internal', body: 'Internal churn.' })); const post = renderTelegramPost(notes, { version: '0.24.0' }); assert.ok(post.length <= TELEGRAM_MESSAGE_LIMIT); assert.doesNotMatch(post, /Internal churn/); assert.match(post, /…plus \d+ more fixes and improvements\./); }); it('never folds a breaking change into the counter', () => { const post = renderTelegramPost( [ note({ highlight: 'Up Next rail' }), note({ type: 'breaking', body: 'Legacy playlist storage is removed.', }), note({ type: 'fix', body: 'Stalker resume works again.' }), ], { version: '0.24.0' } ); assert.match(post, /⚠️ Legacy playlist storage is removed\./); assert.match(post, /…plus 1 more fix or improvement\./); assert.doesNotMatch(post, /Stalker resume/); }); it('refuses to fold a breaking change into the counter to make room', () => { // No highlights, so the fitting loop is what truncates. A breaking // change reported as "fixes and improvements" is not an option. const notes = Array.from({ length: 14 }, (_, index) => note({ type: 'breaking', body: `Breaking change ${index}: ${'detail '.repeat(50)}.`, sourcePath: `.changes/breaking-${index}.md`, }) ); assert.throws( () => renderTelegramPost(notes, { version: '0.24.0' }), /breaking change\(s\) do not fit Telegram's 4096-character limit/ ); }); it('returns null for an internal-only release instead of throwing', () => { assert.equal( renderTelegramPost([note({ type: 'internal' })], { version: '0.24.0', }), null ); }); it('refuses to silently drop a hand-picked highlight', () => { const notes = Array.from({ length: 30 }, (_, index) => note({ highlight: `Feature ${index} with quite a long headline text`, body: `${'Very long body copy. '.repeat(15)}`, sourcePath: `.changes/feature-${index}.md`, }) ); assert.throws( () => renderTelegramPost(notes, { version: '0.24.0' }), /do not fit Telegram's 4096-character limit/ ); }); }); describe('renderRedditPost', () => { it('suggests a title, sections the highlights and collapses the rest', () => { const post = renderRedditPost( [ note({ highlight: 'Up Next rail' }), note({ type: 'fix', area: 'stalker', body: 'Resume works.' }), ], { version: '0.24.0' } ); assert.match(post, /^Suggested title: IPTVnator v0\.24\.0 — Up Next rail\n/); assert.match(post, /## Highlights/); assert.match( post, /### Up Next rail\n\nSeries now show an Up Next rail beside the player\./ ); assert.match(post, /## Also in this release/); assert.match(post, /\*\*Fixes\*\*\n\n- \*\*stalker\*\* — Resume works\./); assert.match( post, /\[Download\]\(https:\/\/github\.com\/4gray\/iptvnator\/releases\/tag\/v0\.24\.0\)/ ); assert.match( post, /\[Full release notes\]\(https:\/\/4gray\.github\.io\/iptvnator\/blog\/v0-24-release-notes\/\)/ ); }); it('falls back to a plain title and full list without highlights', () => { const post = renderRedditPost( [note({ type: 'fix', body: 'Resume works.' })], { version: '0.24.1' } ); assert.match(post, /^Suggested title: IPTVnator v0\.24\.1 released\n/); assert.match(post, /## What's changed/); assert.doesNotMatch(post, /## Highlights/); }); it('omits internal notes', () => { const post = renderRedditPost( [note(), note({ type: 'internal', body: 'Internal churn.' })], { version: '0.24.0' } ); assert.doesNotMatch(post, /Internal churn/); }); it('keeps the suggested title inside the Reddit title limit', () => { // Five highlights at the validated 60-character maximum overshoot the // 300-character title cap while the body stays far below its own. const notes = Array.from({ length: 5 }, (_, index) => note({ highlight: `H${index}`.padEnd(60, 'x'), sourcePath: `.changes/feature-${index}.md`, }) ); const post = renderRedditPost(notes, { version: '0.24.0' }); const title = post.split('\n')[0].replace('Suggested title: ', ''); assert.ok( title.length <= REDDIT_TITLE_LIMIT, `title is ${title.length} characters` ); assert.match(title, /, and \d+ more$/); }); it('names every highlight when they fit', () => { assert.equal( buildRedditTitle('0.24.0', [ { highlight: 'Up Next rail' }, { highlight: 'Faster imports' }, ]), 'IPTVnator v0.24.0 — Up Next rail, Faster imports' ); assert.equal(buildRedditTitle('0.24.0', []), 'IPTVnator v0.24.0 released'); }); it('stays inside the Reddit post limit and says what it dropped', () => { const notes = [ note({ highlight: 'Up Next rail' }), ...Array.from({ length: 400 }, (_, index) => note({ type: 'fix', body: `Fix ${index}: ${'x'.repeat(360)}`, sourcePath: `.changes/fix-${index}.md`, }) ), ]; const post = renderRedditPost(notes, { version: '0.24.0' }); assert.ok( post.length <= REDDIT_POST_LIMIT, `post is ${post.length} characters` ); assert.match(post, /### Up Next rail/); assert.match(post, /…and \d+ more changes — see the full release notes/); }); it('refuses to drop a breaking change to fit', () => { const notes = Array.from({ length: 200 }, (_, index) => note({ type: 'breaking', body: `Breaking ${index}: ${'x'.repeat(360)}`, sourcePath: `.changes/breaking-${index}.md`, }) ); assert.throws( () => renderRedditPost(notes, { version: '0.24.0' }), /breaking change\(s\) do not fit Reddit's 40000-character limit/ ); }); it('leaves a normal release untouched', () => { const post = renderRedditPost( [note({ highlight: 'Up Next rail' }), note({ type: 'fix' })], { version: '0.24.0' } ); assert.doesNotMatch(post, /…and \d+ more/); }); it('returns null for an internal-only release', () => { assert.equal( renderRedditPost([note({ type: 'internal' })], { version: '0.24.0', }), null ); }); it('links the patch blog post to its minor release page', () => { const post = renderRedditPost([note()], { version: '0.24.2' }); assert.match(post, /blog\/v0-24-release-notes\//); }); });