// apple.name: the App Store name when the plain app.name is taken on the App Store. Fallback order (### name block > // apple.name > app.name), validation, the console answer, check's length error and same-name hint, listing pull. import { afterEach, describe, expect, test } from 'bun:test'; import { run as check } from '../cli/check'; import { appleName, type ShipConfig, validateConfig } from '../cli/lib/config'; import { consoleAnswers } from '../cli/lib/console-answers'; import { navPlan } from '../cli/lib/console-forms'; import { appleNameIssues } from '../cli/lib/copy'; import { mergeAppleListing } from '../cli/lib/listing-pull'; import { listingCompleteness } from '../cli/lib/project-files'; import { BASE, capture, jsonOut, project, restore } from './helpers/store-mock'; afterEach(restore); const NAMED: ShipConfig = { ...BASE, app: { ...BASE.app, name: 'STAR DASH' }, apple: { ...BASE.apple!, name: 'STAR DASH: Stone Age to 2100', primaryLocale: 'en-US' } }; const PLAIN: ShipConfig = { ...BASE, app: { ...BASE.app, name: 'STAR DASH', copyright: '2026 Example Brand', privacyPolicyUrl: 'https://rush.example.com/privacy', supportUrl: 'https://rush.example.com/support', website: 'https://rush.example.com', contactEmail: 'support@rush.example.com' }, apple: { ...BASE.apple!, primaryLocale: 'en-US' } }; const sec = (loc: string, name?: string) => `## ${loc}\n\n${name ? `### name\n\`\`\`\n${name}\n\`\`\`\n\n` : ''}### subtitle\n\`\`\`\nSub\n\`\`\`\n\n### promotionalText\n\`\`\`\nPromo\n\`\`\`\n\n### keywords\n\`\`\`\nrun,time\n\`\`\`\n\n### description\n\`\`\`\nText.\n\`\`\`\n\n`; describe('apple.name', () => { test('fallback order: apple.name, else app.name', () => { expect(appleName(NAMED)).toBe('STAR DASH: Stone Age to 2100'); expect(appleName(PLAIN)).toBe('STAR DASH'); expect(appleName({ ...PLAIN, apple: { ...PLAIN.apple!, name: ' ' } })).toBe('STAR DASH'); }); test('validated: at most 30 characters, text', () => { expect(validateConfig(NAMED)).toEqual([]); expect(validateConfig({ ...NAMED, apple: { ...NAMED.apple!, name: 'STAR DASH: Stone Age to the Year 2100' } }).join('\n')).toContain('apple.name: 37 characters > 30'); expect(validateConfig({ ...NAMED, apple: { ...NAMED.apple!, name: '' } }).join('\n')).toContain('apple.name: the App Store name'); }); test('console fill apple-app-record answers Name from apple.name, else app.name', () => { const a = new Map(consoleAnswers('apple-app-record', NAMED).map(x => [x.id, x])); expect(a.get('name')).toMatchObject({ value: 'STAR DASH: Stone Age to 2100', source: 'apple.name', certain: true }); const b = new Map(consoleAnswers('apple-app-record', PLAIN).map(x => [x.id, x])); expect(b.get('name')).toMatchObject({ value: 'STAR DASH', source: 'app.name' }); expect(b.get('name')!.note).toContain('apple.name'); const pick = navPlan('apple-app-privacy', { ...NAMED, apple: { ...NAMED.apple!, appId: undefined } }).find(s => s.kind === 'pickApp') as { name?: string } | undefined; expect(pick?.name).toBe('STAR DASH: Stone Age to 2100'); }); test('name issues: length error per locale, same-name hint only without apple.name or own names', () => { const long = 'A Name That Is Way Too Long For Apple'; expect(appleNameIssues({}, ['en-US'], long, long)).toEqual([expect.objectContaining({ level: 'error', msg: expect.stringContaining('app.name; set apple.name') }), expect.objectContaining({ level: 'info' })]); expect(appleNameIssues({ 'de-DE': { name: long } }, ['en-US'], 'Short', 'Short').map(i => `${i.level} ${i.msg.slice(0, 31)}`)).toEqual(['error App Store name of de-DE is 37 >']); const hint = appleNameIssues({ 'en-US': {} , 'de-DE': {} }, ['en-US', 'de-DE'], 'STAR DASH', 'STAR DASH'); expect(hint).toHaveLength(1); expect(hint[0]!.msg).toContain('already being used'); expect(appleNameIssues({}, ['en-US', 'de-DE'], 'STAR DASH: Stone Age to 2100', 'STAR DASH')).toEqual([]); expect(appleNameIssues({ 'de-DE': { name: 'STAR DASH: Steinzeit bis 2100' } }, ['en-US', 'de-DE'], 'STAR DASH', 'STAR DASH')).toEqual([]); }); test('check: hint with the plain name, error when the fallback is too long', async () => { const files = { 'store/appstore.md': `# App Store\n\n${sec('en-US')}${sec('de-DE')}${sec('tr')}`, '.gitignore': '.storebound/\n' }; let p = project({ ...PLAIN, play: undefined }, files); let j = jsonOut((await capture(() => check(p.ctx(['--json'], ['json'])))).out); expect(j.ok).toBe(true); expect((j.hints as string[]).join('\n')).toContain('already being used'); p = project({ ...NAMED, play: undefined }, files); j = jsonOut((await capture(() => check(p.ctx(['--json'], ['json'])))).out); // only the name hint is under test (the Duo and Header and Search Results notes are hints of their own) expect((j.hints as string[]).filter(h => /App Store name|already being used/.test(h))).toEqual([]); p = project({ ...PLAIN, app: { ...PLAIN.app, name: 'STAR DASH Stone Age to the Year 2100' }, play: undefined }, files); const r = await capture(() => check(p.ctx(['--json'], ['json']))); j = jsonOut(r.out); expect(j.ok).toBe(false); expect((j.problems as string[]).filter(x => x.includes('App Store name of'))).toHaveLength(3); }); test('listing completeness and listing pull use apple.name as the default name', () => { const p = project({ ...NAMED, play: undefined }, { 'store/appstore.md': `# App Store\n\n${sec('en-US')}${sec('de-DE', 'STAR DASH: Steinzeit bis 2100')}` }); const l = listingCompleteness(p.loaded.config, p.root); const name = (code: string) => l.locales.find(x => x.apple?.code === code)!.apple!.fields.find(f => f.field === 'name'); expect(name('en-US')).toMatchObject({ length: 28, state: 'ok' }); expect(name('de-DE')).toMatchObject({ length: 29, state: 'ok' }); const md = `# App Store\n\n${sec('en-US')}`; const r = mergeAppleListing(md, { 'en-US': { name: 'STAR DASH: Stone Age to 2100' }, 'fr-FR': { name: 'STAR DASH: Stone Age to 2100', subtitle: 'Sous-titre' } }, 'STAR DASH: Stone Age to 2100'); expect(r.changes.map(c => `${c.section} ${c.field}`)).toEqual(['fr-FR subtitle']); expect(r.md).not.toContain('### name'); const own = mergeAppleListing(`# App Store\n\n${sec('en-US', 'Old')}`, { 'en-US': { name: 'STAR DASH: Stone Age to 2100' } }, 'STAR DASH: Stone Age to 2100'); expect(own.changes.map(c => c.field)).toEqual(['name']); }); });