storebound/tests/listing-translate.test.ts

120 lines
9.2 KiB
TypeScript

// storebound listing translate / check / reviewed: per-language listing drafts in both listing files, store limits,
// keyword rules, the --translate hook and the review marker.
import { afterAll, describe, expect, test } from 'bun:test';
import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { parseAppStoreListing, parsePlayListing } from '../cli/lib/copy';
import { appleFields, checkFields, findAppleSection, findPlaySection, markerOf, normalizeKeywords, playFields, putSection, unmark } from '../cli/lib/listing-translate';
const ROOT = join(import.meta.dir, '..');
const CLI = join(ROOT, 'cli', 'index.ts');
const tmp = mkdtempSync(join(tmpdir(), 'storebound-listing-'));
afterAll(() => rmSync(tmp, { recursive: true, force: true }));
const sb = (cwd: string, ...args: string[]) => Bun.spawnSync([process.execPath, CLI, ...args], { cwd, stdout: 'pipe', stderr: 'pipe', stdin: 'ignore' });
describe('rules', () => {
test('keywords: commas, duplicates, name / subtitle words, whole keywords cut at 100', () => {
const r = normalizeKeywords('Liste , notizen,liste、 planer,Aufgaben,,familie', { name: 'Listen App', subtitle: 'Notizen und mehr' });
expect(r.text).toBe('Liste,planer,Aufgaben,familie');
expect(r.dropped).toEqual([{ kw: 'notizen', why: 'already in the name or subtitle' }, { kw: 'liste', why: 'duplicate' }]);
const long = normalizeKeywords(Array.from({ length: 30 }, (_, i) => `keyword${i}`).join(','));
expect(long.text.length).toBeLessThanOrEqual(100);
expect(long.text.endsWith(',')).toBe(false);
expect(long.dropped.at(-1)!.why).toBe('over 100');
// multi-byte: 100 characters, not bytes (App Store Connect counts characters; pitfall 43)
const ja = normalizeKeywords(Array.from({ length: 30 }, (_, i) => `日本語${i}`).join(',')).text;
expect([...ja].length).toBeLessThanOrEqual(100);
expect(Buffer.byteLength(ja)).toBeGreaterThan(100);
});
test('limits per store field, keyword rules, untranslated and identical text', () => {
const c = checkFields('apple', { subtitle: 'x'.repeat(31), keywords: 'a, b,a', description: 'Same long description text' }, { description: 'Same long description text' }, 'draft');
expect(c.find(x => x.field === 'subtitle')!.problems).toEqual(['31 > 30 characters']);
expect(c.find(x => x.field === 'keywords')!.problems.join()).toContain('spaces around commas');
expect(c.find(x => x.field === 'keywords')!.problems.join()).toContain('duplicates: a');
expect(c.find(x => x.field === 'description')!.problems).toEqual(['identical to the source language']);
expect(checkFields('play', { short: 's'.repeat(81), full: 'ok' }).map(x => x.problems)).toEqual([['81 > 80 characters'], []]);
expect(checkFields('play', { short: 'short' }, {}, 'translate')[0]!.problems).toEqual(['not translated yet (status=translate)']);
});
test('sections: replace in place keeps the rest, markers come off after review', () => {
const md = '# X\n\n## en-US · English\n\n### subtitle\n```\nA\n```\n\n---\n\n## tr · Türkçe\n\n### subtitle\n```\nB\n```\n';
const out = putSection(md, findAppleSection(md, 'en-US'), '## en-US · English\n<!-- storebound-translation from=tr status=draft -->\n\n### subtitle\n```\nC\n```\n');
expect(out).toContain('```\nC\n```\n\n---\n\n## tr');
expect(markerOf(findAppleSection(out, 'en-US')!)).toEqual({ from: 'tr', status: 'draft' });
expect(unmark(findAppleSection(out, 'en-US')!)).toBe('## en-US · English\n\n### subtitle\n```\nC\n```\n\n---\n\n');
});
});
describe('listing commands', () => {
const dir = join(tmp, 'app');
mkdirSync(dir);
sb(dir, 'init', '--profile', 'app');
const cfg = join(dir, 'storebound.config.ts');
writeFileSync(cfg, readFileSync(cfg, 'utf8').replace("{ id: 'tr', play: 'tr-TR', apple: 'tr' },", "{ id: 'tr', play: 'tr-TR', apple: 'tr' },\n { id: 'de', play: 'de-DE', apple: 'de-DE' },\n { id: 'ja', play: 'ja-JP', apple: 'ja' },"));
const apple = join(dir, 'store/appstore.md'), play = join(dir, 'store/listing.md');
test('without a hook: source text copied in as status=translate, check fails until translated', () => {
const r = sb(dir, 'listing', 'translate', '--to', 'de-DE,ja', '--json');
expect(r.exitCode).toBe(0);
const j = JSON.parse(r.stdout.toString());
expect(j.source).toBe('en');
expect(j.results.map((x: { lang: string; store: string; action: string }) => `${x.lang}/${x.store}/${x.action}`)).toEqual(
['de/apple/added (status=translate)', 'de/play/added (status=translate)', 'ja/apple/added (status=translate)', 'ja/play/added (status=translate)']);
const md = readFileSync(apple, 'utf8');
expect(markerOf(findAppleSection(md, 'ja')!)).toEqual({ from: 'en-US', status: 'translate' });
expect(appleFields(findAppleSection(md, 'ja')!).keywords).toBe(appleFields(findAppleSection(md, 'en-US')!).keywords);
expect(Object.keys(parseAppStoreListing(md))).toEqual(['en-US', 'tr', 'de-DE', 'ja']);
expect(parsePlayListing(readFileSync(play, 'utf8'), 'Example App').map(c => c.lang)).toEqual(['en-US', 'tr-TR', 'de-DE', 'ja-JP']);
const c = sb(dir, 'listing', 'check');
expect(c.exitCode).toBe(1);
expect(c.stdout.toString()).toContain('TO TRANSLATE');
expect(sb(dir, 'listing', 'reviewed', '--lang', 'ja').stderr.toString()).toContain('still status=translate');
const check = sb(dir, 'check');
expect(check.stderr.toString()).toContain('not translated yet (status=translate): de-DE, ja');
});
test('with --translate: one call per field with the env, keywords cleaned up, drafts until reviewed; reviewed sections kept', () => {
const hook = join(tmp, 'hook.sh');
writeFileSync(hook, '#!/bin/sh\nif [ "$STOREBOUND_FIELD" = keywords ]; then echo "Liste, liste,notizen,$STOREBOUND_TARGET_LANG"; else printf "[%s %s %s %s] " "$STOREBOUND_STORE" "$STOREBOUND_FIELD" "$STOREBOUND_TARGET_LANG" "$STOREBOUND_LIMIT"; tr a-z A-Z; fi\n');
const r = sb(dir, 'listing', 'translate', '--to', 'de', '--translate', `sh ${hook}`);
expect(r.exitCode).toBe(0);
const sec = findAppleSection(readFileSync(apple, 'utf8'), 'de-DE')!;
expect(markerOf(sec)!.status).toBe('draft');
const f = appleFields(sec);
expect(f.subtitle).toBe('[apple subtitle de-DE 30] LISTS AND NOTES, IN SYNC');
expect(f.keywords).toBe('Liste,notizen,de-DE');
expect(r.stdout.toString()).toContain('dropped "liste" (duplicate)');
const p = playFields(findPlaySection(readFileSync(play, 'utf8'), ['de-DE'])!);
expect(p.short).toStartWith('[play short de-DE 80] ');
expect(p.whatsNew).toBeUndefined();
const c = sb(dir, 'listing', 'check', '--json');
const j = JSON.parse(c.stdout.toString());
expect(j.drafts).toEqual(['apple:de-DE', 'play:de-DE']);
expect(sb(dir, 'listing', 'reviewed', '--lang', 'de').stdout.toString()).toContain('2 section(s) marked reviewed');
expect(markerOf(findAppleSection(readFileSync(apple, 'utf8'), 'de-DE')!)).toBeNull();
// a reviewed section is kept unless --force
const again = JSON.parse(sb(dir, 'listing', 'translate', '--to', 'de', '--translate', `sh ${hook}`, '--json').stdout.toString());
expect(again.results.map((x: { action: string }) => x.action)).toEqual(['kept (reviewed; --force replaces it)', 'kept (reviewed; --force replaces it)']);
expect(sb(dir, 'listing', 'translate', '--to', 'en').stderr.toString()).toContain('source language');
expect(sb(dir, 'listing', 'translate', '--to', 'xx').stderr.toString()).toContain('not a language of the config');
});
});
test('listing check counts App Store keywords in characters, like App Store Connect (pitfall 43)', () => {
const ru = 'игра,бег,прыжки,монеты,уровни,рекорд,аркада,быстро'; // 50 characters, 93 bytes
const kw = checkFields('apple', { keywords: ru, subtitle: 'Быстрая аркада' }).find(c => c.field === 'keywords')!;
expect(kw).toMatchObject({ chars: 50, unit: 'chars', problems: [] });
// live shape: a Thai field of 95 characters / 265 bytes, READY_FOR_SALE on the App Store
const th = [...'ปลา,ตกปลา,เกมปลา,ทะเล,มหาสมุทร,ตู้ปลา,สัตว์น้ำ,ผ่อนคลาย,สะสม,ฉลาม,ปลาวาฬ,ปะการัง,ดำน้ำ,ฟาร์มปลา,เลี้ยงปลา'].slice(0, 95).join('');
expect([...th].length).toBe(95);
expect(Buffer.byteLength(th)).toBeGreaterThan(100);
expect(checkFields('apple', { keywords: th }).find(c => c.field === 'keywords')!.problems).toEqual([]);
// Turkish 100 characters / 105 bytes (live) passes; 101 characters does not
const tr = 'balık,akvaryum,okyanus,deniz,köpekbalığı,mercan,dalış,sakin,rahatlatıcı,koleksiyon,çiftlik,yüzme,su'.slice(0, 100).padEnd(100, 'a');
expect([...tr].length).toBe(100);
expect(Buffer.byteLength(tr)).toBeGreaterThan(100);
expect(checkFields('apple', { keywords: tr }).find(c => c.field === 'keywords')!.problems).toEqual([]);
expect(checkFields('apple', { keywords: tr + 'b' }).find(c => c.field === 'keywords')!.problems).toContain('101 > 100 characters');
expect(checkFields('apple', { subtitle: 'Быстрая аркада' })[0]).toMatchObject({ chars: 14, unit: 'chars' });
});