Files
iptvnator/tools/release/release-announcements.test.mjs

330 lines
11 KiB
JavaScript

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\//);
});
});