import { describe, expect, test } from 'bun:test'; import { readFileSync } from 'node:fs'; import { join } from 'node:path'; import { parseArgs } from '../cli/lib/args'; import { loadConfig, validateConfig } from '../cli/lib/config'; import { parseAppStoreListing, parsePlayListing } from '../cli/lib/copy'; import { dataSafetyCsv, dataSafetyRows } from '../cli/lib/data-safety'; import { ALPHA3, appleTerritory, fromMoney, nearestPrices, playRegionConfigs, samePrice, toMoney } from '../cli/lib/money'; import { maskExpr } from '../cli/icons'; import { ffmpegQ } from '../cli/shots/jpeg'; import { type CopyFile, copyText, palette, scriptFont, validateCopy } from '../cli/shots/lib'; import { numberedImages } from '../cli/play/images'; const T = join(import.meta.dir, '..', 'templates'); const tpl = (f: string) => readFileSync(join(T, f), 'utf8'); describe('config', () => { test('template config loads and validates', async () => { const l = await loadConfig(join(T, 'storebound.config.ts')); expect(l.config.play?.packageName).toBe('com.example.game'); expect(l.resolve('store/x.md')).toBe(join(T, 'store/x.md')); }); test('reports bad fields', () => { const e = validateConfig({ app: { name: 'X', privacyPolicyUrl: 'http://insecure', supportUrl: 'https://a.b', website: 'https://a.b', contactEmail: 'nope' }, languages: [{ id: 'en' }, { id: 'en' }], play: { packageName: 'notapackage' }, products: [{ id: 'bad id!', type: 'gift', usd: 0, listings: { de: { title: '', description: 'x' } }, overrides: [{ region: 'TUR', currency: 'tl', price: 1 }] }], dataSafety: { collectsData: true, encryptedInTransit: true, deletionRequest: true, data: { DEVICE: { shared: true, collect: [] } } }, }); const text = e.join('\n'); for (const s of ['privacyPolicyUrl', 'contactEmail', 'duplicate "en"', 'packageName', 'products[0].id', 'products[0].type', 'products[0].usd', 'listings.de: not in languages', 'region "TUR"', 'currency "tl"', 'dataSafety.data.DEVICE', 'sharing purposes']) expect(text).toContain(s); }); test('empty config', () => { expect(validateConfig(null)).toEqual(['config must be an object']); }); }); describe('copy parsing', () => { test('play template', () => { const c = parsePlayListing(tpl('listing.md'), 'Example Game'); expect(c.map(x => x.lang)).toEqual(['en-US', 'tr-TR']); expect(c[0]!.title).toBe('Example Game'); expect(c[1]!.whatsNew).toContain('İlk sürüm'); }); test('play: one section, several codes, explicit title, limits', () => { const md = '## Español\n\n### title\n```\nJuego\n```\n### short\n```\nCorto\n```\n### full\n```\nLargo\n```\n'; const c = parsePlayListing(md, 'X'); expect(c.map(x => [x.lang, x.title])).toEqual([['es-ES', 'Juego'], ['es-419', 'Juego']]); expect(() => parsePlayListing(md.replace('Corto', 'x'.repeat(81)), 'X')).toThrow('short is 81 > 80'); expect(() => parsePlayListing('## A\n\n### short\n```\na\n```\n', 'X')).toThrow('no "### ... full ..." block'); }); test('play: emoji count as one character', () => { const md = `## A\n\n### short\n\`\`\`\n${'é'.repeat(80)}\n\`\`\`\n### full\n\`\`\`\nx\n\`\`\`\n`; expect(parsePlayListing(md, 'X')[0]!.short.length).toBe(80); }); test('appstore template', () => { const c = parseAppStoreListing(tpl('appstore.md')); expect(Object.keys(c)).toEqual(['en-US', 'tr']); expect(c['en-US']!.subtitle).toBe('Short hook under the name'); }); test('appstore: missing field, keyword spaces, byte limit', () => { const sec = (kw: string) => `## de-DE\n### subtitle\n\`\`\`\nA\n\`\`\`\n### promotionalText\n\`\`\`\nB\n\`\`\`\n### keywords\n\`\`\`\n${kw}\n\`\`\`\n### description\n\`\`\`\nD\n\`\`\`\n`; expect(parseAppStoreListing(sec('a,b'))['de-DE']!.keywords).toBe('a,b'); expect(() => parseAppStoreListing(sec('a, b'))).toThrow('no spaces after commas'); expect(() => parseAppStoreListing(sec('ö'.repeat(60)))).toThrow('bytes > 100'); expect(() => parseAppStoreListing('## fr-FR\n### subtitle\n```\nx\n```\n')).toThrow('fr-FR: missing promotionalText'); }); }); describe('data safety csv', () => { const ds = { collectsData: true, encryptedInTransit: true, deletionRequest: false, data: { PSL_DEVICE_ID: { shared: true, collect: ['analytics' as const, 'advertising' as const], share: ['advertising' as const] }, PSL_OTHER_PERSONAL: { shared: false, optional: true, collect: ['functionality' as const] } } }; const rows = dataSafetyRows(ds); const find = (q: string, r: string) => rows.find(x => x[0] === q && x[1] === r); test('header + overview + account creation', () => { expect(rows[0]![0]).toBe('Question ID (machine readable)'); expect(find('PSL_DATA_COLLECTION_USER_REQUEST_DELETE', '')![2]).toBe('FALSE'); expect(find('PSL_SUPPORTED_ACCOUNT_CREATION_METHODS', 'PSL_ACM_NONE')![2]).toBe('TRUE'); }); test('types and purposes', () => { expect(find('PSL_DATA_TYPES_IDENTIFIERS', 'PSL_DEVICE_ID')![2]).toBe('TRUE'); expect(find('PSL_DATA_TYPES_LOCATION', 'PSL_PRECISE_LOCATION')![2]).toBe(''); const q = (t: string, s: string) => `PSL_DATA_USAGE_RESPONSES:${t}:${s}`; expect(find(q('PSL_DEVICE_ID', 'DATA_USAGE_COLLECTION_PURPOSE'), 'PSL_ANALYTICS')![2]).toBe('TRUE'); expect(find(q('PSL_DEVICE_ID', 'DATA_USAGE_SHARING_PURPOSE'), 'PSL_ANALYTICS')![2]).toBe(''); expect(find(q('PSL_DEVICE_ID', 'DATA_USAGE_SHARING_PURPOSE'), 'PSL_ADVERTISING')![2]).toBe('TRUE'); expect(find(q('PSL_DEVICE_ID', 'PSL_DATA_USAGE_EPHEMERAL'), '')![2]).toBe('FALSE'); expect(find(q('PSL_OTHER_PERSONAL', 'DATA_USAGE_USER_CONTROL'), 'PSL_DATA_USAGE_USER_CONTROL_OPTIONAL')![2]).toBe('TRUE'); expect(find(q('PSL_OTHER_PERSONAL', 'PSL_DATA_USAGE_COLLECTION_AND_SHARING'), 'PSL_DATA_USAGE_ONLY_SHARED')![2]).toBe(''); expect(find(q('PSL_CONTACTS', 'PSL_DATA_USAGE_EPHEMERAL'), '')![2]).toBe(''); }); test('every row has 5 cells; quoting; unknown type', () => { const csv = dataSafetyCsv(ds); expect(csv.endsWith('\n')).toBe(true); expect(csv).toContain('"Does your app collect or share any of the required user data types?"'.slice(1, -1)); for (const r of rows) expect(r.length).toBe(5); expect(() => dataSafetyRows({ ...ds, data: { PSL_NOPE: { shared: false, collect: ['analytics'] } } })).toThrow('unknown data type'); }); }); describe('prices', () => { test('toMoney without float noise', () => { expect(toMoney('USD', 2.99)).toEqual({ currencyCode: 'USD', units: '2', nanos: 990000000 }); expect(toMoney('TRY', 129.99)).toEqual({ currencyCode: 'TRY', units: '129', nanos: 990000000 }); expect(toMoney('JPY', 300)).toEqual({ currencyCode: 'JPY', units: '300', nanos: 0 }); expect(fromMoney(toMoney('EUR', 0.29))).toBeCloseTo(0.29); expect(() => toMoney('USD', -1)).toThrow(); }); test('play region configs with overrides', () => { const conv = { a: { regionCode: 'US', price: toMoney('USD', 2.99) }, b: { regionCode: 'TR', price: toMoney('TRY', 109.99) }, c: { regionCode: 'DE', price: toMoney('EUR', 2.99) } }; const r = playRegionConfigs(conv, [{ region: 'TR', currency: 'TRY', price: 99.99 }]); expect(r.map(x => x.regionCode)).toEqual(['DE', 'TR', 'US']); expect(r.find(x => x.regionCode === 'TR')!.price).toEqual(toMoney('TRY', 99.99)); expect(r.every(x => x.availability === 'AVAILABLE')).toBe(true); expect(() => playRegionConfigs(conv, [{ region: 'XX', currency: 'USD', price: 1 }])).toThrow('region XX'); }); test('apple price matching + territories', () => { expect(samePrice('2.99', 2.99)).toBe(true); expect(samePrice('30.0', 30)).toBe(true); expect(samePrice('2.98', 2.99)).toBe(false); expect(nearestPrices([1.99, 2.49, 2.99, 3.49], 2.89, 2)).toEqual([2.99, 2.49]); expect(appleTerritory({ region: 'TR', currency: 'TRY', price: 1 })).toBe('TUR'); expect(appleTerritory({ region: 'GB', currency: 'GBP', price: 1 })).toBe('GBR'); expect(appleTerritory({ region: 'ZZ', territory: 'ZZZ', currency: 'USD', price: 1 })).toBe('ZZZ'); expect(() => appleTerritory({ region: 'ZZ', currency: 'USD', price: 1 })).toThrow('set "territory"'); expect(Object.keys(ALPHA3).length).toBeGreaterThan(150); for (const [k, v] of Object.entries(ALPHA3)) { expect(k).toMatch(/^[A-Z]{2}$/); expect(v).toMatch(/^[A-Z]{3}$/); } }); }); describe('screenshots', () => { const copy = JSON.parse(tpl('shots-copy.json')) as CopyFile; test('script fonts', () => { expect(scriptFont('en', 'Hello')).toBeNull(); expect(scriptFont('tr', 'Dokun. Eşleştir.')).toBeNull(); expect(scriptFont('ja', copyText('ja', copy.ja))!.slug).toBe('jp'); expect(scriptFont('ar', copyText('ar', copy.ar))!.slug).toBe('arabic'); expect(scriptFont('ko', '안녕')!.slug).toBe('kr'); expect(scriptFont('zh-Hans', '你好')!.slug).toBe('sc'); expect(scriptFont('zh-Hant', '你好')!.slug).toBe('tc'); expect(scriptFont('ru', 'Привет')!.slug).toBe('cyrgreek'); expect(scriptFont('hi', 'नमस्ते')!.slug).toBe('devanagari'); }); test('template copy fits the template scenes', async () => { const { config } = await loadConfig(join(T, 'storebound.config.ts')); expect(validateCopy(copy, config.shots!, config.languages.map(l => l.id))).toEqual([]); const broken = { en: { ...copy.en, play: { ...copy.en!.play!, badges: [['a'], ['b'], ['c']] } } } as CopyFile; expect(validateCopy(broken, config.shots!, ['en', 'de']).join('\n')).toMatch(/3 badges but the scene has 2 slots[\s\S]*de: no copy/); }); test('palettes', () => { expect(palette('gold').ink).toBe('#6b2c00'); expect(() => palette('nope')).toThrow('unknown palette'); }); test('jpeg quality mapping', () => { expect(ffmpegQ(100)).toBe(2); expect(ffmpegQ(92)).toBe(4); expect(ffmpegQ(0)).toBe(31); }); test('numbered images prefer jpeg', () => { expect(numberedImages('/nonexistent')).toEqual([]); }); }); describe('icons + args', () => { test('mask expressions', () => { expect(maskExpr('circle', 192)).toBe('if(lte(hypot(X-96+0.5,Y-96+0.5),96),255,0)'); expect(maskExpr('squircle', 100)).toContain('22'); }); test('args', () => { const a = parseArgs(['file.aab', '--production', 'draft', '--dry', '--only=a,b', '--internal']); expect(a._).toEqual(['file.aab']); expect(a.get('production')).toBe('draft'); expect(a.has('dry')).toBe(true); expect(a.list('only')).toEqual(['a', 'b']); expect(a.get('internal')).toBe('true'); expect(() => parseArgs(['--jobs', 'x']).num('jobs', 1)).toThrow(); }); });