120 lines
9.2 KiB
TypeScript
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' });
|
|
});
|
|
|