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(` & "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 & "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, //); }); 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(/]*\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, />FourFive { 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 = [ '', ``, escapeXml(text), '', ].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` ); } }); });