290 lines
21 KiB
TypeScript
290 lines
21 KiB
TypeScript
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 { DATA_SAFETY_RULES, dataSafetyCsv, dataSafetyIssues, dataSafetyRows, formPairIssues, formRow, playForm, PLAY_FORM_EXPORTED } from '../cli/lib/data-safety';
|
|
import type { DataSafety } from '../cli/lib/config';
|
|
import { parseCsv } from '../cli/lib/csv';
|
|
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, partialDeletion: 'later' as never, 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', "dataSafety.partialDeletion: true | false | 'auto90'"]) 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<!-- play: es-ES, es-419 -->\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<!-- play: en-US -->\n### short\n```\na\n```\n', 'X')).toThrow('no "### ... full ..." block');
|
|
});
|
|
test('play: emoji count as one character', () => {
|
|
const md = `## A\n<!-- play: en-US -->\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, character limit (pitfall 43)', () => {
|
|
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(100)))['de-DE']!.keywords).toHaveLength(100);
|
|
expect(() => parseAppStoreListing(sec('ö'.repeat(101)))).toThrow('keywords is 101 > 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,
|
|
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)');
|
|
// Play removed this question from the form; writing it is a 400
|
|
expect(rows.some(r => r[0] === 'PSL_DATA_COLLECTION_USER_REQUEST_DELETE')).toBe(false);
|
|
expect(dataSafetyRows({ ...ds, deletionRequest: true }).some(r => r[0] === 'PSL_DATA_COLLECTION_USER_REQUEST_DELETE')).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('data safety: conditional required rows', () => {
|
|
const acc = { ...{ collectsData: true, encryptedInTransit: true }, accountCreation: ['PSL_ACM_USER_ID_OTHER_AUTH'],
|
|
data: { PSL_USER_ACCOUNT: { shared: false, collect: ['account' as const] } } };
|
|
const url = 'https://example.com/delete-account';
|
|
const get = (rows: string[][], q: string) => rows.find(r => r[0] === q);
|
|
test('accounts + username: the delete-account URL row carries accounts.deletionUrl, and the deletion question is yes', () => {
|
|
// PSL_SUPPORT_DATA_DELETION_BY_USER is the general "Do you provide a way for users to request that their data is
|
|
// deleted?": a delete-account page is such a way. Answering NO next to a delete-account URL contradicts it.
|
|
const rows = dataSafetyRows({ ...acc, partialDeletion: false }, { deletionUrl: url });
|
|
expect(get(rows, 'PSL_ACCOUNT_DELETION_URL')![2]).toBe(url);
|
|
// a SINGLE_CHOICE: one row per option, TRUE on the picked one (a value with no Response ID was Play's
|
|
// "Line 7: Response ID missing")
|
|
expect(rows.filter(r => r[0] === 'PSL_SUPPORT_DATA_DELETION_BY_USER').map(r => r.slice(0, 4).join(','))).toEqual([
|
|
'PSL_SUPPORT_DATA_DELETION_BY_USER,DATA_DELETION_YES,TRUE,SINGLE_CHOICE',
|
|
'PSL_SUPPORT_DATA_DELETION_BY_USER,DATA_DELETION_NO,,SINGLE_CHOICE',
|
|
'PSL_SUPPORT_DATA_DELETION_BY_USER,DATA_DELETION_NO_AUTO_DELETED,,SINGLE_CHOICE',
|
|
]);
|
|
// the "Delete data URL" a yes needs: the delete-account page (it deletes the data too) unless a data-only page is set
|
|
expect(get(rows, 'PSL_DATA_DELETION_URL')![2]).toBe(url);
|
|
expect(dataSafetyIssues(rows)).toEqual([]);
|
|
expect(get(dataSafetyRows({ ...acc, partialDeletion: true, partialDeletionUrl: 'https://example.com/data' }, { deletionUrl: url }), 'PSL_DATA_DELETION_URL')![2]).toBe('https://example.com/data');
|
|
// no delete-account URL: the explicit answer, deletionRequest or the old partialDeletion values decide
|
|
const picked = (ds: object) => dataSafetyRows({ ...acc, ...ds }).filter(r => r[0] === 'PSL_SUPPORT_DATA_DELETION_BY_USER' && r[2] === 'TRUE').map(r => r[1]);
|
|
expect(picked({ dataDeletion: 'auto90' })).toEqual(['DATA_DELETION_NO_AUTO_DELETED']);
|
|
expect(picked({ partialDeletion: 'auto90' })).toEqual(['DATA_DELETION_NO_AUTO_DELETED']);
|
|
expect(picked({ deletionRequest: true })).toEqual(['DATA_DELETION_YES']);
|
|
expect(picked({ deletionRequest: false })).toEqual(['DATA_DELETION_NO']);
|
|
expect(picked({})).toEqual([]);
|
|
// a delete-account URL with anything but yes is a problem before Play sees it
|
|
const no = dataSafetyRows({ ...acc, dataDeletion: 'no' }, { deletionUrl: url });
|
|
expect(dataSafetyIssues(no).map(i => i.msg).join('\n')).toContain('must be DATA_DELETION_YES (is DATA_DELETION_NO): PSL_SUPPORT_DATA_DELETION_BY_USER (PSL_ACCOUNT_DELETION_URL is set');
|
|
});
|
|
test('no accounts: no deletion rows, no issues', () => {
|
|
const rows = dataSafetyRows({ ...acc, accountCreation: ['PSL_ACM_NONE'] }, { deletionUrl: url });
|
|
for (const q of ['PSL_ACCOUNT_DELETION_URL', 'PSL_SUPPORT_DATA_DELETION_BY_USER', 'PSL_DATA_DELETION_URL']) expect(get(rows, q)).toBeUndefined();
|
|
expect(dataSafetyIssues(rows)).toEqual([]);
|
|
expect(get(dataSafetyRows({ ...acc, accountCreation: undefined }), 'PSL_ACCOUNT_DELETION_URL')).toBeUndefined();
|
|
});
|
|
test('missing deletionUrl / partialDeletion: named with the config field to set', () => {
|
|
const msgs = dataSafetyIssues(dataSafetyRows(acc)).map(i => i.msg);
|
|
expect(msgs[0]).toBe('missing: PSL_ACCOUNT_DELETION_URL (account creation is not PSL_ACM_NONE; set accounts.deletionUrl, a public https page to request account + data deletion (account-deletion skill))');
|
|
expect(msgs[1]).toContain('missing: PSL_SUPPORT_DATA_DELETION_BY_USER');
|
|
expect(msgs[1]).toContain('dataSafety.dataDeletion');
|
|
expect(dataSafetyIssues(dataSafetyRows({ ...acc, partialDeletion: true, partialDeletionUrl: undefined }, {})).map(i => i.question)).toEqual(['PSL_ACCOUNT_DELETION_URL', 'PSL_DATA_DELETION_URL']);
|
|
});
|
|
test('extra replaces a generated question; refused and per-type rows', () => {
|
|
const rows = dataSafetyRows({ ...acc, dataDeletion: 'yes', partialDeletionUrl: url, extra: [{ question: 'PSL_ACCOUNT_DELETION_URL', value: url }] });
|
|
expect(rows.filter(r => r[0] === 'PSL_ACCOUNT_DELETION_URL')).toHaveLength(1);
|
|
expect(dataSafetyIssues(rows)).toEqual([]);
|
|
const outside = dataSafetyRows({ ...acc, partialDeletion: false, extra: [{ question: 'PSL_HAS_OUTSIDE_APP_ACCOUNTS', value: 'FALSE' }] }, { deletionUrl: url });
|
|
expect(dataSafetyIssues(outside).map(i => i.msg.split(' (')[0])).toEqual(['not allowed: PSL_HAS_OUTSIDE_APP_ACCOUNTS']);
|
|
// a type selected but its purpose rows emptied (e.g. a hand-edited export)
|
|
const cut = dataSafetyRows({ ...acc, partialDeletion: false }, { deletionUrl: url })
|
|
.map(r => r[0] === 'PSL_DATA_USAGE_RESPONSES:PSL_USER_ACCOUNT:DATA_USAGE_COLLECTION_PURPOSE' ? [r[0], r[1]!, '', r[3]!, r[4]!] : r);
|
|
expect(dataSafetyIssues(cut).map(i => i.question)).toEqual(['PSL_DATA_USAGE_RESPONSES:PSL_USER_ACCOUNT:DATA_USAGE_COLLECTION_PURPOSE']);
|
|
});
|
|
test('the template passes the rules and only uses known question ids', () => {
|
|
const rows = parseCsv(readFileSync(join(import.meta.dir, '..', 'templates', 'data-safety.csv'), 'utf8'));
|
|
expect(dataSafetyIssues(rows)).toEqual([]);
|
|
// an example with answers, not the empty form, and every pair is in Play's current form
|
|
expect(rows.some(r => r[2] === 'TRUE')).toBe(true);
|
|
expect(formPairIssues(rows)).toEqual([]);
|
|
for (const r of rows.slice(1)) expect(r[3]).toBe(formRow(r[0]!, r[1]!)!.requirement);
|
|
const known = new Set(DATA_SAFETY_RULES.map(r => r.question));
|
|
for (const r of rows.slice(1)) expect(known.has(r[0]!) || /^PSL_DATA_TYPES_|^PSL_DATA_USAGE_RESPONSES:PSL_[A-Z_]+:/.test(r[0]!)).toBe(true);
|
|
});
|
|
});
|
|
|
|
describe("data safety: pairs from Play's exported form", () => {
|
|
const url = 'https://example.com/delete-account';
|
|
const all = (u: Partial<DataSafety['data'][string]> = {}) => Object.fromEntries(['PSL_NAME', 'PSL_EMAIL', 'PSL_PURCHASE_HISTORY', 'PSL_APPROX_LOCATION', 'PSL_PRECISE_LOCATION',
|
|
'PSL_CRASH_LOGS', 'PSL_USER_INTERACTION', 'PSL_DEVICE_ID', 'PSL_PHOTOS', 'PSL_HEALTH'].map(t => [t, { shared: true, collect: ['analytics', 'functionality'], share: ['advertising'], ...u }])) as DataSafety['data'];
|
|
const base: DataSafety = { collectsData: true, encryptedInTransit: true, data: {} };
|
|
const accounts = { ...base, accountCreation: ['PSL_ACM_USER_ID_PASSWORD', 'PSL_ACM_OAUTH'], data: { PSL_EMAIL: { shared: false, collect: ['account' as const] } } };
|
|
const cases: [string, DataSafety][] = [
|
|
['no data', { collectsData: false, encryptedInTransit: false, data: {} }],
|
|
['accounts, partialDeletion true', { ...accounts, partialDeletion: true }],
|
|
['accounts, partialDeletion false', { ...accounts, partialDeletion: false }],
|
|
["accounts, partialDeletion 'auto90'", { ...accounts, partialDeletion: 'auto90' }],
|
|
['many types shared and collected', { ...base, data: all() }],
|
|
['many types, optional and ephemeral', { ...base, data: all({ shared: false, optional: true, ephemeral: true }) }],
|
|
['extra rows', { ...base, data: all(), extra: [{ question: 'PSL_DATA_COLLECTION_COMPLIES_FAMILY_POLICY', value: 'TRUE' }, { question: 'PSL_INDEPENDENTLY_VALIDATED', value: 'FALSE' },
|
|
{ question: 'PSL_HAS_OUTSIDE_APP_ACCOUNTS', value: 'TRUE' }, { question: 'PSL_OUTSIDE_APP_ACCOUNT_TYPES', response: 'PSL_LOGIN_THROUGH_EMPLOYMENT_OR_ENTERPRISE_ACCOUNT', value: 'TRUE' }] }],
|
|
];
|
|
test(`the form (exported ${PLAY_FORM_EXPORTED}) is Google's empty export`, () => {
|
|
const f = [...playForm().values()];
|
|
expect(f.length).toBe(782);
|
|
expect(formRow('PSL_DATA_COLLECTION_USER_REQUEST_DELETE')).toBeUndefined();
|
|
expect(formRow('PSL_SUPPORT_DATA_DELETION_BY_USER', 'DATA_DELETION_NO')!.requirement).toBe('SINGLE_CHOICE');
|
|
expect(formRow('PSL_SUPPORT_DATA_DELETION_BY_USER')).toBeUndefined();
|
|
});
|
|
for (const [name, ds] of cases) test(`${name}: every pair is in the form with its Answer requirement`, () => {
|
|
const rows = dataSafetyRows(ds, { deletionUrl: url });
|
|
expect(formPairIssues(rows)).toEqual([]);
|
|
for (const r of rows.slice(1)) expect([r[0], r[1], r[3]]).toEqual([r[0], r[1], formRow(r[0]!, r[1]!)!.requirement]);
|
|
});
|
|
test('extra rows take the requirement from the form, not a guess', () => {
|
|
const rows = dataSafetyRows({ ...base, extra: [{ question: 'PSL_DATA_COLLECTION_COMPLIES_FAMILY_POLICY', value: 'TRUE' }, { question: 'PSL_OUTSIDE_APP_ACCOUNT_TYPES', response: 'PSL_OUTSIDE_APP_ACCOUNT_TYPE_OTHER', value: 'TRUE', label: 'mine' }] });
|
|
expect(rows.find(r => r[0] === 'PSL_DATA_COLLECTION_COMPLIES_FAMILY_POLICY')!.slice(2, 4)).toEqual(['TRUE', 'OPTIONAL']);
|
|
expect(rows.find(r => r[0] === 'PSL_OUTSIDE_APP_ACCOUNT_TYPES')!.slice(2)).toEqual(['TRUE', 'MULTIPLE_CHOICE', 'mine']);
|
|
});
|
|
test('an unknown pair is reported by dataSafetyIssues (check, declarations, play data-safety --dry)', () => {
|
|
const rows = dataSafetyRows({ ...base, extra: [{ question: 'PSL_DATA_COLLECTION_USER_REQUEST_DELETE', value: 'TRUE' }, { question: 'PSL_SUPPORT_DATA_DELETION_BY_USER', value: 'FALSE' }] });
|
|
const msgs = dataSafetyIssues(rows).map(i => i.msg);
|
|
expect(msgs).toContain(`PSL_DATA_COLLECTION_USER_REQUEST_DELETE, is not in Play's current Data safety form (cli/data/play-data-safety-form.csv, exported ${PLAY_FORM_EXPORTED}); Play answers 400`);
|
|
expect(msgs.some(m => m.startsWith("PSL_SUPPORT_DATA_DELETION_BY_USER, is not in Play's current"))).toBe(true);
|
|
expect(formPairIssues([['PSL_DATA_TYPES_PERSONAL', 'PSL_NOPE', 'TRUE', 'MULTIPLE_CHOICE', '']]).map(i => i.question)).toEqual(['PSL_DATA_TYPES_PERSONAL']);
|
|
});
|
|
});
|
|
|
|
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();
|
|
});
|
|
});
|