Files
iptvnator/tools/release/highlight-cards.test.mjs

691 lines
23 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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,
estimateTextWidth,
HERO_BULLET_MAX_WIDTH,
isOwnedCardFile,
planHighlightCards,
SHOT_TOP,
TEXT_BOTTOM,
TEXT_MAX_WIDTH,
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', () => {
const budget = (maxWidth, fontSize, maxLines) => ({
maxWidth,
fontSize,
maxLines,
});
it('wraps on word boundaries within the width budget', () => {
// Asserted as a property, not an exact split: the break points move
// whenever the advance factors are tuned, and pinning them would make
// every future calibration look like a regression.
const text = 'one two three four';
const lines = wrapText(text, budget(60, 10, 4));
assert.ok(lines.length > 1, 'expected the text to wrap at all');
assert.equal(lines.join(' '), text, 'words were lost or reordered');
for (const line of lines) {
assert.ok(estimateTextWidth(line, 10) <= 60, line);
}
});
it('breaks a word wider than the budget instead of overflowing the card', () => {
const lines = wrapText('supercalifragilistic ok', budget(60, 10, 4));
assert.ok(lines.length > 1);
assert.equal(lines.join('').replace(/ /g, ''), 'supercalifragilisticok');
});
it('keeps every line inside the budget, wide glyphs included', () => {
// Counting characters is not a width budget: 34 `W` at font-size 52
// measures ~1948px where only 1072px are available.
for (const [text, maxWidth, fontSize, maxLines] of [
['W'.repeat(60), TEXT_MAX_WIDTH, 52, 2],
['W'.repeat(60), TEXT_MAX_WIDTH, 62, 2],
['W'.repeat(60), HERO_BULLET_MAX_WIDTH, 30, 1],
['M'.repeat(200), TEXT_MAX_WIDTH, 23, 3],
['ЖЮ'.repeat(40), TEXT_MAX_WIDTH, 52, 2],
['Advanced subtitles with external files', TEXT_MAX_WIDTH, 52, 2],
]) {
for (const line of wrapText(
text,
budget(maxWidth, fontSize, maxLines)
)) {
const width = estimateTextWidth(line, fontSize);
assert.ok(
width <= maxWidth,
`"${line}" estimates ${Math.round(width)}px > ${maxWidth}px`
);
}
}
});
it('treats uncalibrated scripts as full-width rather than narrow', () => {
// CJK, kana, hangul and emoji are ~1 em; falling through to the
// lowercase-Latin factor let a 60-glyph headline paint off-canvas.
for (const glyph of ['界', 'ひ', '한', '🎉', 'Ω'.repeat(1)]) {
const wide = estimateTextWidth(glyph.repeat(28), 52);
assert.ok(
wide > TEXT_MAX_WIDTH,
`28 × "${glyph}" estimates only ${Math.round(wide)}px`
);
}
});
it('keeps a full-width headline inside the canvas', () => {
for (const line of wrapText('界'.repeat(60), budget(TEXT_MAX_WIDTH, 52, 2))) {
assert.ok(estimateTextWidth(line, 52) <= TEXT_MAX_WIDTH, line);
}
});
it('estimates a wide glyph run near its measured rendered width', () => {
// Calibration anchor: 34 `W` at font-size 52 renders ~1948px.
const estimate = estimateTextWidth('W'.repeat(34), 52);
assert.ok(estimate > 1800, `estimate ${estimate} is too low`);
});
it('ellipsizes the last kept line on overflow', () => {
const lines = wrapText('aaa bbb ccc ddd eee', budget(30, 10, 2));
assert.equal(lines.length, 2);
assert.match(lines[1], /…$/);
assert.ok(estimateTextWidth(lines[1], 10) <= 30);
});
});
describe('escapeXml', () => {
it('escapes markup and quote characters', () => {
assert.equal(
escapeXml(`<a> & "b" 'c'`),
'&lt;a&gt; &amp; &quot;b&quot; &apos;c&apos;'
);
});
});
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 &lt;live&gt; &amp; &quot;vod&quot;/);
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']);
});
});
describe('estimateTextWidth against real rendering', () => {
/** Ink width of one rendered line, via sharp's trim. */
async function measureRenderedWidth(text, fontSize) {
const svg = [
'<svg xmlns="http://www.w3.org/2000/svg" width="6000" height="240">',
`<text x="0" y="150" font-family="DM Sans, Helvetica Neue, Helvetica, Arial, sans-serif" font-size="${fontSize}" font-weight="800" fill="#fff">`,
escapeXml(text),
'</text></svg>',
].join('');
const { info } = await sharp(Buffer.from(svg))
.trim({ threshold: 1 })
.toBuffer({ resolveWithObject: true });
return info.width;
}
it('never under-estimates a rendered line', async () => {
// The guard against the whole class of bug that produced this model:
// an under-estimate skips both the wrap and the textLength clamp, and
// the card is cropped with every unit test still passing.
const samples = [
['Advanced subtitles with external files', 52],
['Live TV recordings in the download manager', 52],
// A long run of a "narrow" glyph: the case that proved the
// narrow factors were themselves under-estimates.
['r'.repeat(54), 52],
['i'.repeat(54), 52],
['....------', 52],
['0123456789', 52],
['W'.repeat(34), 52],
['M'.repeat(34), 62],
['æ'.repeat(34), 52],
['œ'.repeat(30), 52],
['界'.repeat(28), 52],
['한'.repeat(28), 30],
['Дашборд с рекомендациями TMDB', 52],
['#@%&'.repeat(10), 52],
['Ünïcödé áccênts thrøughöut', 52],
];
for (const [text, fontSize] of samples) {
const measured = await measureRenderedWidth(text, fontSize);
const estimate = estimateTextWidth(text, fontSize);
// Fonts resolve differently per host — the SVG names DM Sans and
// nothing guarantees it is installed — so this asserts the model
// stays conservative in whatever environment runs it. A failure
// here means the factors in `advanceFactor` are too low for this
// host's fallback and must be raised, not that the test is wrong.
assert.ok(
estimate >= measured,
`"${text.slice(0, 30)}" at ${fontSize}px: estimate ${Math.round(estimate)}px < measured ${measured}px — raise the advanceFactor values`
);
}
});
});