mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-14 21:26:15 -08:00
- wrapText left a word longer than the line budget intact, so a valid 60-character unbroken highlight rendered past the edge of the 1200px card and was cropped. Overlong words are now broken at the budget, and a test asserts no returned line can exceed it. The previous test asserting the old behavior encoded the bug. - The announcement commands are documented as `pnpm --silent run`: reproduced that plain `pnpm run release:notes:telegram > post` writes pnpm's lifecycle banner into the file ahead of the post, so the advertised paste-ready output was not paste-ready. 186 tests passing. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
587 lines
18 KiB
JavaScript
587 lines
18 KiB
JavaScript
import assert from 'node:assert/strict';
|
||
import {
|
||
existsSync,
|
||
mkdirSync,
|
||
mkdtempSync,
|
||
readdirSync,
|
||
rmSync,
|
||
writeFileSync,
|
||
} from 'node:fs';
|
||
import { tmpdir } from 'node:os';
|
||
import path from 'node:path';
|
||
import { spawnSync } from 'node:child_process';
|
||
import { fileURLToPath } from 'node:url';
|
||
import { after, describe, it } from 'node:test';
|
||
import sharp from 'sharp';
|
||
|
||
import {
|
||
buildFeatureCardSvg,
|
||
buildHeroCardSvg,
|
||
CARD_HEIGHT,
|
||
CARD_WIDTH,
|
||
escapeXml,
|
||
isOwnedCardFile,
|
||
planHighlightCards,
|
||
SHOT_TOP,
|
||
TEXT_BOTTOM,
|
||
wrapText,
|
||
} from './highlight-cards.mjs';
|
||
import {
|
||
parseCardArguments,
|
||
removeStaleCards,
|
||
renderCards,
|
||
} from './generate-highlight-cards.mjs';
|
||
|
||
const tempDirs = [];
|
||
|
||
function makeTempDir() {
|
||
const directory = mkdtempSync(path.join(tmpdir(), 'highlight-cards-'));
|
||
|
||
tempDirs.push(directory);
|
||
|
||
return directory;
|
||
}
|
||
|
||
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,
|
||
};
|
||
}
|
||
|
||
const planOptions = {
|
||
version: '0.24.0',
|
||
releaseSlug: 'v0-24',
|
||
screenshotsDir: '/blog/v0-24/screenshots',
|
||
theme: 'dark',
|
||
};
|
||
|
||
after(() => {
|
||
for (const directory of tempDirs) {
|
||
rmSync(directory, { recursive: true, force: true });
|
||
}
|
||
});
|
||
|
||
describe('wrapText', () => {
|
||
it('wraps on word boundaries within the budget', () => {
|
||
assert.deepEqual(wrapText('one two three four', 9, 3), [
|
||
'one two',
|
||
'three',
|
||
'four',
|
||
]);
|
||
});
|
||
|
||
it('breaks a word longer than the budget instead of overflowing the card', () => {
|
||
assert.deepEqual(wrapText('supercalifragilistic ok', 10, 3), [
|
||
'supercalif',
|
||
'ragilistic',
|
||
'ok',
|
||
]);
|
||
});
|
||
|
||
it('never returns a line wider than the budget', () => {
|
||
// A 60-character unbroken highlight is valid input; on a 34-character
|
||
// feature-card budget it must still fit inside the canvas.
|
||
for (const [text, maxChars, maxLines] of [
|
||
['W'.repeat(60), 34, 2],
|
||
['W'.repeat(60), 30, 2],
|
||
['W'.repeat(60), 60, 1],
|
||
['a'.repeat(200), 34, 2],
|
||
['short words only here', 34, 2],
|
||
]) {
|
||
for (const line of wrapText(text, maxChars, maxLines)) {
|
||
assert.ok(
|
||
line.length <= maxChars,
|
||
`"${line}" is ${line.length} > ${maxChars}`
|
||
);
|
||
}
|
||
}
|
||
});
|
||
|
||
it('ellipsizes the last kept line on overflow', () => {
|
||
const lines = wrapText('aaa bbb ccc ddd eee', 3, 2);
|
||
|
||
assert.equal(lines.length, 2);
|
||
assert.match(lines[1], /…$/);
|
||
});
|
||
});
|
||
|
||
describe('escapeXml', () => {
|
||
it('escapes markup and quote characters', () => {
|
||
assert.equal(
|
||
escapeXml(`<a> & "b" 'c'`),
|
||
'<a> & "b" 'c''
|
||
);
|
||
});
|
||
});
|
||
|
||
describe('planHighlightCards', () => {
|
||
it('plans one card per highlight in group order plus a hero', () => {
|
||
const plan = planHighlightCards(
|
||
[
|
||
note({ type: 'fix', body: 'Not highlighted.' }),
|
||
note({
|
||
highlight: 'Up Next rail',
|
||
screenshot: 'up-next-rail',
|
||
}),
|
||
note({
|
||
type: 'breaking',
|
||
highlight: 'New settings',
|
||
sourcePath: '.changes/settings-rework.md',
|
||
}),
|
||
],
|
||
planOptions
|
||
);
|
||
|
||
assert.deepEqual(
|
||
plan.feature.map((job) => job.fileName),
|
||
['card-settings-rework.png', 'card-playback-up-next.png']
|
||
);
|
||
assert.equal(
|
||
plan.feature[1].screenshotPath,
|
||
path.join('/blog/v0-24/screenshots', 'up-next-rail-dark.png')
|
||
);
|
||
assert.equal(plan.feature[0].screenshotPath, null);
|
||
assert.deepEqual(plan.hero.headlines, [
|
||
'New settings',
|
||
'Up Next rail',
|
||
]);
|
||
assert.equal(plan.hero.counts, '1 breaking change · 1 feature · 1 fix');
|
||
});
|
||
|
||
it('keeps card filenames unique when two highlights share a screenshot', () => {
|
||
const plan = planHighlightCards(
|
||
[
|
||
note({
|
||
highlight: 'Rail on wide windows',
|
||
screenshot: 'up-next-rail',
|
||
sourcePath: '.changes/playback-up-next.md',
|
||
}),
|
||
note({
|
||
highlight: 'Rail progress bars',
|
||
screenshot: 'up-next-rail',
|
||
sourcePath: '.changes/playback-rail-progress.md',
|
||
}),
|
||
],
|
||
planOptions
|
||
);
|
||
|
||
assert.deepEqual(
|
||
plan.feature.map((job) => job.fileName),
|
||
['card-playback-up-next.png', 'card-playback-rail-progress.png']
|
||
);
|
||
// Both still read the same shared screenshot.
|
||
assert.equal(
|
||
plan.feature[0].screenshotPath,
|
||
plan.feature[1].screenshotPath
|
||
);
|
||
});
|
||
|
||
it('reports the public note count so an internal-only release is detectable', () => {
|
||
assert.equal(
|
||
planHighlightCards(
|
||
[note({ type: 'internal', body: 'Churn.' })],
|
||
planOptions
|
||
).publicNoteCount,
|
||
0
|
||
);
|
||
assert.equal(
|
||
planHighlightCards(
|
||
[note(), note({ type: 'internal', body: 'Churn.' })],
|
||
planOptions
|
||
).publicNoteCount,
|
||
1
|
||
);
|
||
});
|
||
|
||
it('emits only filenames the ownership predicate can reclaim', () => {
|
||
// Nothing enforces the lowercase-slug filename convention, and a card
|
||
// the cleanup pass cannot recognize is a card that lives forever.
|
||
const plan = planHighlightCards(
|
||
[
|
||
note({
|
||
highlight: 'New UI',
|
||
sourcePath: '.changes/Player_New-UI.md',
|
||
}),
|
||
note({
|
||
highlight: 'Other',
|
||
sourcePath: '.changes/player.new.ui.md',
|
||
}),
|
||
],
|
||
planOptions
|
||
);
|
||
|
||
assert.deepEqual(
|
||
plan.feature.map((job) => job.fileName),
|
||
['card-player-new-ui.png', 'card-player-new-ui-2.png']
|
||
);
|
||
for (const job of plan.feature) {
|
||
assert.equal(isOwnedCardFile(job.fileName), true, job.fileName);
|
||
}
|
||
});
|
||
|
||
it('claims only the files it writes', () => {
|
||
for (const owned of ['card-playback-up-next.png', 'hero.png', 'hero.jpg']) {
|
||
assert.equal(isOwnedCardFile(owned), true, owned);
|
||
}
|
||
|
||
for (const foreign of [
|
||
'notes.txt',
|
||
'card-Upper.png',
|
||
'screenshot-dashboard-dark.png',
|
||
'hero.webp',
|
||
]) {
|
||
assert.equal(isOwnedCardFile(foreign), false, foreign);
|
||
}
|
||
});
|
||
|
||
it('never plans a card for internal notes', () => {
|
||
const plan = planHighlightCards(
|
||
[note({ type: 'internal', highlight: 'Nope' })],
|
||
planOptions
|
||
);
|
||
|
||
assert.deepEqual(plan.feature, []);
|
||
assert.deepEqual(plan.hero.headlines, []);
|
||
});
|
||
|
||
it('respects the requested screenshot theme', () => {
|
||
const plan = planHighlightCards(
|
||
[note({ highlight: 'Up Next rail', screenshot: 'up-next-rail' })],
|
||
{ ...planOptions, theme: 'light' }
|
||
);
|
||
|
||
assert.match(plan.feature[0].screenshotPath, /-light\.png$/);
|
||
});
|
||
});
|
||
|
||
describe('card SVGs', () => {
|
||
it('escapes user text and carries the version chip', () => {
|
||
const svg = buildFeatureCardSvg(
|
||
{
|
||
slug: 'x',
|
||
fileName: 'card-x.png',
|
||
headline: 'Support <live> & "vod"',
|
||
body: "The app's import.",
|
||
screenshotPath: null,
|
||
},
|
||
'0.24.0'
|
||
);
|
||
|
||
assert.match(svg, /Support <live> & "vod"/);
|
||
assert.match(svg, /v0\.24\.0/);
|
||
assert.doesNotMatch(svg, /<live>/);
|
||
});
|
||
|
||
it('draws the screenshot frame only when a screenshot exists', () => {
|
||
const withShot = buildFeatureCardSvg(
|
||
{
|
||
headline: 'H',
|
||
body: 'B',
|
||
screenshotPath: '/shots/x-dark.png',
|
||
},
|
||
'0.24.0'
|
||
);
|
||
const without = buildFeatureCardSvg(
|
||
{ headline: 'H', body: 'B', screenshotPath: null },
|
||
'0.24.0'
|
||
);
|
||
|
||
// The frame rect is the only element using the surface frame color.
|
||
assert.match(withShot, /fill="#2e2e28"/);
|
||
assert.doesNotMatch(without, /fill="#2e2e28"/);
|
||
});
|
||
|
||
it('keeps every body line clear of the opaque screenshot frame', () => {
|
||
// A two-line headline plus a long body used to push the last body
|
||
// line under the frame, which is painted after the text.
|
||
const svg = buildFeatureCardSvg(
|
||
{
|
||
headline: 'Advanced subtitles with external files and styling',
|
||
body: 'External subtitle files load from disk, timing shifts in half-second steps, and you can set caption size and colour — the settings stick between episodes.',
|
||
screenshotPath: '/shots/x-dark.png',
|
||
},
|
||
'0.24.0'
|
||
);
|
||
const baselines = [...svg.matchAll(/<text[^>]*\sy="(\d+)"/g)].map(
|
||
(match) => Number(match[1])
|
||
);
|
||
|
||
assert.ok(baselines.length > 0);
|
||
for (const baseline of baselines) {
|
||
assert.ok(
|
||
baseline <= TEXT_BOTTOM,
|
||
`baseline ${baseline} overlaps the frame at ${SHOT_TOP - 2}`
|
||
);
|
||
}
|
||
});
|
||
|
||
it('survives a whitespace-only headline instead of dying half-written', () => {
|
||
assert.doesNotThrow(() =>
|
||
buildHeroCardSvg({
|
||
fileName: 'hero.png',
|
||
version: '0.24.0',
|
||
releaseSlug: 'v0-24',
|
||
headlines: [' '],
|
||
counts: '1 feature',
|
||
})
|
||
);
|
||
});
|
||
|
||
it('lists at most four highlights on the hero and counts the rest', () => {
|
||
const svg = buildHeroCardSvg({
|
||
fileName: 'hero.png',
|
||
version: '0.24.0',
|
||
releaseSlug: 'v0-24',
|
||
headlines: ['One', 'Two', 'Three', 'Four', 'Five'],
|
||
counts: '5 features',
|
||
});
|
||
|
||
assert.match(svg, />Four</);
|
||
assert.doesNotMatch(svg, />Five</);
|
||
assert.match(svg, /…and 1 more/);
|
||
assert.match(svg, /5 features/);
|
||
});
|
||
});
|
||
|
||
describe('parseCardArguments', () => {
|
||
it('requires a mode and defaults the theme', () => {
|
||
assert.equal(parseCardArguments([]), null);
|
||
assert.deepEqual(parseCardArguments(['--dry-run']), {
|
||
mode: 'dry-run',
|
||
theme: 'dark',
|
||
version: null,
|
||
dir: '.changes',
|
||
out: null,
|
||
});
|
||
});
|
||
|
||
it('parses the full flag set', () => {
|
||
assert.deepEqual(
|
||
parseCardArguments([
|
||
'--generate',
|
||
'--theme',
|
||
'light',
|
||
'--version',
|
||
'0.24.0',
|
||
'--dir',
|
||
'notes',
|
||
'--out',
|
||
'cards',
|
||
]),
|
||
{
|
||
mode: 'generate',
|
||
theme: 'light',
|
||
version: '0.24.0',
|
||
dir: 'notes',
|
||
out: 'cards',
|
||
}
|
||
);
|
||
});
|
||
|
||
it('rejects bad usage', () => {
|
||
for (const args of [
|
||
['--dry-run', '--generate'],
|
||
['--generate', '--theme', 'sepia'],
|
||
['--generate', '--version', '0.24'],
|
||
['--generate', '--nope'],
|
||
['--generate', '--out'],
|
||
]) {
|
||
assert.equal(parseCardArguments(args), null, args.join(' '));
|
||
}
|
||
});
|
||
});
|
||
|
||
describe('renderCards', () => {
|
||
it('renders 1200×630 PNGs plus the hero pair from a real screenshot', async () => {
|
||
const shotsDir = makeTempDir();
|
||
const outputDir = path.join(makeTempDir(), 'cards');
|
||
const screenshotPath = path.join(shotsDir, 'up-next-rail-dark.png');
|
||
|
||
await sharp({
|
||
create: {
|
||
width: 1280,
|
||
height: 720,
|
||
channels: 3,
|
||
background: { r: 20, g: 20, b: 24 },
|
||
},
|
||
})
|
||
.png()
|
||
.toFile(screenshotPath);
|
||
|
||
const plan = planHighlightCards(
|
||
[
|
||
note({ highlight: 'Up Next rail', screenshot: 'up-next-rail' }),
|
||
note({
|
||
type: 'perf',
|
||
highlight: 'Faster imports',
|
||
body: 'Playlists import faster.',
|
||
sourcePath: '.changes/m3u-faster-imports.md',
|
||
}),
|
||
],
|
||
{ ...planOptions, screenshotsDir: shotsDir }
|
||
);
|
||
const written = await renderCards(plan, '0.24.0', outputDir);
|
||
|
||
assert.deepEqual(
|
||
written.map((file) => path.basename(file)),
|
||
[
|
||
'card-playback-up-next.png',
|
||
'card-m3u-faster-imports.png',
|
||
'hero.png',
|
||
'hero.jpg',
|
||
]
|
||
);
|
||
|
||
for (const file of written) {
|
||
assert.ok(existsSync(file), file);
|
||
const metadata = await sharp(file).metadata();
|
||
|
||
assert.equal(metadata.width, CARD_WIDTH, file);
|
||
assert.equal(metadata.height, CARD_HEIGHT, file);
|
||
}
|
||
});
|
||
|
||
it('removeStaleCards tolerates a missing directory and reports what it took', () => {
|
||
const outputDir = path.join(makeTempDir(), 'never-created');
|
||
|
||
assert.deepEqual(removeStaleCards(outputDir), []);
|
||
|
||
mkdirSync(outputDir, { recursive: true });
|
||
writeFileSync(path.join(outputDir, 'card-gone.png'), 'x');
|
||
writeFileSync(path.join(outputDir, 'hero.jpg'), 'x');
|
||
writeFileSync(path.join(outputDir, 'keep.txt'), 'x');
|
||
|
||
assert.deepEqual(removeStaleCards(outputDir).sort(), [
|
||
'card-gone.png',
|
||
'hero.jpg',
|
||
]);
|
||
assert.deepEqual(readdirSync(outputDir), ['keep.txt']);
|
||
});
|
||
|
||
it('removes its own stale cards but leaves other files alone', async () => {
|
||
const outputDir = path.join(makeTempDir(), 'cards');
|
||
|
||
mkdirSync(outputDir, { recursive: true });
|
||
writeFileSync(path.join(outputDir, 'card-removed-feature.png'), 'old');
|
||
writeFileSync(path.join(outputDir, 'notes.txt'), 'keep me');
|
||
|
||
const plan = planHighlightCards(
|
||
[note({ highlight: 'Faster imports' })],
|
||
planOptions
|
||
);
|
||
|
||
await renderCards(plan, '0.24.0', outputDir);
|
||
|
||
const remaining = readdirSync(outputDir).sort();
|
||
|
||
assert.deepEqual(remaining, [
|
||
'card-playback-up-next.png',
|
||
'hero.jpg',
|
||
'hero.png',
|
||
'notes.txt',
|
||
]);
|
||
});
|
||
});
|
||
|
||
describe('generate-highlight-cards CLI', () => {
|
||
const CLI = path.resolve(
|
||
path.dirname(fileURLToPath(import.meta.url)),
|
||
'generate-highlight-cards.mjs'
|
||
);
|
||
|
||
function runCli(args) {
|
||
const result = spawnSync(process.execPath, [CLI, ...args], {
|
||
encoding: 'utf8',
|
||
stdio: ['ignore', 'pipe', 'pipe'],
|
||
});
|
||
|
||
return {
|
||
status: result.status ?? 1,
|
||
stdout: result.stdout ?? '',
|
||
stderr: result.stderr ?? '',
|
||
};
|
||
}
|
||
|
||
it('fails on an empty notes directory instead of calling it internal-only', () => {
|
||
// The realistic cause is running this step after `--consume`, which
|
||
// deleted the notes; silently reporting "internal-only" would hide it.
|
||
const emptyDir = makeTempDir();
|
||
const result = runCli([
|
||
'--generate',
|
||
'--version',
|
||
'0.24.0',
|
||
'--dir',
|
||
emptyDir,
|
||
'--out',
|
||
path.join(makeTempDir(), 'cards'),
|
||
]);
|
||
|
||
assert.equal(result.status, 1);
|
||
assert.match(result.stderr, /No notes found/);
|
||
assert.match(result.stderr, /Render cards before consuming/);
|
||
assert.doesNotMatch(result.stderr, /Internal-only/);
|
||
});
|
||
|
||
it('clears stale cards when a release becomes internal-only', () => {
|
||
const notesDir = makeTempDir();
|
||
const outputDir = path.join(makeTempDir(), 'cards');
|
||
|
||
writeFileSync(
|
||
path.join(notesDir, 'deps-bump.md'),
|
||
'---\ntype: internal\narea: deps\n---\n\nBumped a parser.\n',
|
||
'utf8'
|
||
);
|
||
mkdirSync(outputDir, { recursive: true });
|
||
writeFileSync(path.join(outputDir, 'card-old-feature.png'), 'stale');
|
||
writeFileSync(path.join(outputDir, 'keep.txt'), 'mine');
|
||
|
||
const result = runCli([
|
||
'--generate',
|
||
'--version',
|
||
'0.24.0',
|
||
'--dir',
|
||
notesDir,
|
||
'--out',
|
||
outputDir,
|
||
]);
|
||
|
||
assert.equal(result.status, 0);
|
||
assert.match(result.stderr, /Internal-only release/);
|
||
assert.deepEqual(readdirSync(outputDir), ['keep.txt']);
|
||
});
|
||
|
||
it('leaves stale cards untouched in dry-run mode', () => {
|
||
const notesDir = makeTempDir();
|
||
const outputDir = path.join(makeTempDir(), 'cards');
|
||
|
||
writeFileSync(
|
||
path.join(notesDir, 'deps-bump.md'),
|
||
'---\ntype: internal\narea: deps\n---\n\nBumped a parser.\n',
|
||
'utf8'
|
||
);
|
||
mkdirSync(outputDir, { recursive: true });
|
||
writeFileSync(path.join(outputDir, 'card-old-feature.png'), 'stale');
|
||
|
||
const result = runCli([
|
||
'--dry-run',
|
||
'--version',
|
||
'0.24.0',
|
||
'--dir',
|
||
notesDir,
|
||
'--out',
|
||
outputDir,
|
||
]);
|
||
|
||
assert.equal(result.status, 0);
|
||
assert.deepEqual(readdirSync(outputDir), ['card-old-feature.png']);
|
||
});
|
||
});
|