// Release notes in player language: a player-facing source beats the git log, and internal jargon is flagged. import { afterEach, describe, expect, test } from 'bun:test'; import { jargonFindings, jargonMessage } from '../cli/lib/jargon'; import { changelogSection, checkLanguageNotes, cutNotes, findNotesSource, notesFromMarkdown } from '../cli/lib/whats-new'; import { run as whatsNew } from '../cli/whats-new'; import { BASE, capture, jsonOut, project, restore } from './helpers/store-mock'; afterEach(restore); const words = (t: string, allow?: string[]) => jargonFindings(t, allow).map(f => `${f.kind}:${f.word}`); describe('internal jargon', () => { test('vendors, dev and version-control words, paths, issue ids and code identifiers', () => { expect(words('Added Umami telemetry and a Keeper OS fix')).toEqual(['vendor:Umami', 'dev:telemetry', 'vendor:Keeper OS']); expect(words('Sentry, Firebase, Capacitor, Tauri, Jenkins and Forgejo')).toEqual(['vendor:Sentry', 'vendor:Firebase', 'vendor:Capacitor', 'vendor:Tauri', 'vendor:Jenkins', 'vendor:Forgejo']); expect(words('refactor the endpoint payload via the API in CI, PR merged, commit bump of deps, OTA fingerprint')) .toEqual(['dev:refactor', 'dev:endpoint', 'dev:payload', 'dev:API', 'dev:CI', 'dev:PR', 'vcs:merged', 'vcs:commit', 'dev:bump', 'dev:deps', 'dev:OTA', 'dev:fingerprint']); expect(words('Fixed src/game/save.ts and Player.gd')).toEqual(['path:src/game/save.ts', 'path:Player.gd']); expect(words('Fixes #123 and GAME-42')).toEqual(['issue:#123', 'issue:GAME-42']); expect(words('saveSlot now works, as does max_lives')).toEqual(['identifier:saveSlot', 'identifier:max_lives']); }); test('player language passes: product names, links, dates, plain words', () => { expect(words('New: 20 desert levels on iPhone and iPad! Faster loading. Help: https://example.com/help/api_v2 on 12/10.')).toEqual([]); expect(words('Capital letters, a pricey item, a picnic and the kick-off')).toEqual([]); // no "api" / "ci" inside words }); test('allowWords keeps a word, any case; messages name the word and a plain alternative', () => { expect(words('New Firebase level and saveSlot', ['firebase', 'SAVESLOT'])).toEqual([]); expect(jargonMessage(jargonFindings('endpoint')[0]!)).toBe('internal jargon (developer word): "endpoint"; instead: "server"'); expect(jargonMessage(jargonFindings('Umami')[0]!)).toBe('internal jargon (SDK / vendor name): "Umami"; instead: drop the name and say what changed for the player'); expect(jargonMessage(jargonFindings('merge')[0]!)).toBe('internal jargon (reads like a commit log): "merge"; drop it'); }); test('language check: jargon is a warning, never a blocking problem', () => { const c = checkLanguageNotes({ id: 'en', apple: 'en-US' }, { lang: 'en', source: 'en', status: 'source', text: 'Telemetry via Umami.' }); expect(c.problems).toEqual([]); expect(c.warnings).toHaveLength(2); expect(checkLanguageNotes({ id: 'en', apple: 'en-US' }, { lang: 'en', source: 'en', status: 'source', text: 'Telemetry via Umami.' }, undefined, ['telemetry', 'umami']).warnings).toEqual([]); }); }); describe('player-facing sources', () => { const CL = '# Changelog\n\n## Unreleased\n\n- **Night mode** for the map.\n\n## [1.2.0] - 2026-10-01\n\nBig update.\n\n- New `desert` world, see [the trailer](https://example.com).\n\n## 1.1.0\n\n- Old.\n'; test('changelog sections, markdown to text, cuts', () => { expect(changelogSection(CL, '1.2.0')).toContain('New `desert` world'); expect(changelogSection(CL, 'v1.1.0')).toBe('- Old.'); expect(changelogSection(CL)).toBe('- **Night mode** for the map.'); expect(changelogSection(CL, '9.9.9')).toBeUndefined(); expect(notesFromMarkdown(changelogSection(CL, '1.2.0')!)).toBe('Big update.\n\n• New desert world, see the trailer.'); expect(cutNotes('aaa\nbbb\nccc', 7)).toBe('aaa\nbbb'); }); test('order: whatsNew.source, docs/RELEASE-.md, CHANGELOG (version, then Unreleased), else none', () => { const files: Record = { 'CHANGELOG.md': CL, 'docs/RELEASE-1.2.0.md': '# 1.2.0\n\n- Desert world!\n', 'notes.md': 'Hand written.' }; const read = (rel: string) => files[rel]; expect(findNotesSource({ version: '1.2.0', source: 'notes.md', read })).toEqual({ kind: 'file', path: 'notes.md', text: 'Hand written.' }); expect(findNotesSource({ version: '1.2.0', read })).toEqual({ kind: 'release-doc', path: 'docs/RELEASE-1.2.0.md', text: '• Desert world!' }); expect(findNotesSource({ version: '1.1.0', read })?.text).toBe('• Old.'); expect(findNotesSource({ version: '2.0.0', read })?.text).toBe('• Night mode for the map.'); expect(findNotesSource({ read: () => undefined })).toBeUndefined(); expect(() => findNotesSource({ source: 'missing.md', read })).toThrow('whatsNew.source: missing.md does not exist'); }); test('command: the release doc wins over commit messages; --from-git and jargon warnings', async () => { const p = project({ ...BASE, whatsNew: { allowWords: ['sentry'] } }, { 'package.json': '{"version":"1.2.0"}', 'docs/RELEASE-1.2.0.md': '# 1.2.0\n\n- 20 desert levels.\n- Sentry the guard dog joins you.\n' }); const git = (...a: string[]) => Bun.spawnSync(['git', ...a], { cwd: p.root, env: { ...process.env, GIT_AUTHOR_NAME: 'T', GIT_AUTHOR_EMAIL: 't@example.com', GIT_COMMITTER_NAME: 'T', GIT_COMMITTER_EMAIL: 't@example.com' } }); git('init', '-q'); git('add', '.'); git('commit', '-qm', 'chore: init'); git('tag', 'v1.1.0'); git('commit', '-q', '--allow-empty', '-m', 'feat: Umami telemetry endpoint'); let r = await capture(() => whatsNew(p.ctx(['--dry', '--json']))); let j = jsonOut(r.out); expect(j.source).toEqual({ kind: 'release-doc', path: 'docs/RELEASE-1.2.0.md' }); expect(j.play).toBe('• 20 desert levels.\n• Sentry the guard dog joins you.'); expect(r.err).not.toContain('jargon'); // "Sentry" is allowed by whatsNew.allowWords r = await capture(() => whatsNew(p.ctx(['--dry', '--json', '--from-git']))); j = jsonOut(r.out); expect(j.source).toBeNull(); expect(j.play).toContain('Umami telemetry endpoint'); expect(r.err).toContain('internal jargon (SDK / vendor name): "Umami"'); expect(r.err).toContain('"endpoint"; instead: "server"'); }); });