218 lines
13 KiB
TypeScript
218 lines
13 KiB
TypeScript
// Store image rules (cli/shots/spec.ts) on tiny generated PNG / JPEG buffers, the renders scan, the 13" iPad wiring
|
|
// (sizes, frame defaults, crop, ASC display types) and the local renders findings of `validate`. No browser, no store.
|
|
import { describe, expect, test } from 'bun:test';
|
|
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
|
|
import { tmpdir } from 'node:os';
|
|
import { join } from 'node:path';
|
|
import { DISPLAY, displaySets, IPAD_DISPLAY, requiredDisplayTypes } from '../cli/apple/screenshots';
|
|
import { type ShipConfig, validateConfig } from '../cli/lib/config';
|
|
import { type AppleSnapshot, appleRules, type PlaySnapshot, playRules, rendersFindings } from '../cli/lib/validate-rules';
|
|
import { rendersCheck } from '../cli/shots/check';
|
|
import { cropTopFor, validateCapture } from '../cli/shots/devices';
|
|
import { DEFAULT_IPAD, DEFAULT_PHONE, DEFAULT_TABLET, defaultSizes, frameDims, SIZES } from '../cli/shots/lib';
|
|
import { allIssues, requiredSets, scanRenders } from '../cli/shots/report';
|
|
import { checkCount, checkImage, type ImageInfo, IMAGE_SPECS, imageInfo } from '../cli/shots/spec';
|
|
import { jpeg, png } from './fixtures';
|
|
|
|
const codes = (i: { code: string }[]) => i.map(x => x.code);
|
|
|
|
describe('imageInfo', () => {
|
|
test('png rgb / rgba / tRNS / palette / 16 bit', () => {
|
|
expect(imageInfo(png(1320, 2868))).toMatchObject({ format: 'png', w: 1320, h: 2868, alpha: false, palette: false, depth: 8 });
|
|
expect(imageInfo(png(10, 20, { colorType: 6 })).alpha).toBe(true);
|
|
expect(imageInfo(png(10, 20, { colorType: 4 })).alpha).toBe(true);
|
|
expect(imageInfo(png(10, 20, { trns: true })).alpha).toBe(true);
|
|
expect(imageInfo(png(10, 20, { colorType: 3 })).palette).toBe(true);
|
|
expect(imageInfo(png(10, 20, { depth: 16 })).depth).toBe(16);
|
|
});
|
|
test('jpeg and unknown', () => {
|
|
expect(imageInfo(jpeg(1080, 1920))).toMatchObject({ format: 'jpeg', w: 1080, h: 1920, alpha: false, depth: 8 });
|
|
expect(imageInfo(new Uint8Array([1, 2, 3, 4, 5])).format).toBe('unknown');
|
|
expect(imageInfo(jpeg(1, 1, 1000)).bytes).toBe(1000);
|
|
});
|
|
});
|
|
|
|
describe('checkImage', () => {
|
|
const ok = (b: Uint8Array, k: string) => expect(checkImage(imageInfo(b), k)).toEqual([]);
|
|
test('apple sizes, portrait and landscape', () => {
|
|
ok(png(1320, 2868), 'ios69');
|
|
ok(png(1290, 2796), 'ios69');
|
|
ok(png(2868, 1320), 'ios69');
|
|
ok(jpeg(1284, 2778), 'ios65');
|
|
ok(png(2064, 2752), 'ipad13');
|
|
ok(png(2048, 2732), 'ipad13');
|
|
ok(png(2752, 2064), 'ipad13');
|
|
const e = checkImage(imageInfo(png(1242, 2208)), 'ios69');
|
|
expect(codes(e)).toEqual(['size']);
|
|
expect(e[0]!.msg).toContain('1320x2868');
|
|
expect(codes(checkImage(imageInfo(png(1600, 2480)), 'ipad13'))).toEqual(['size']);
|
|
});
|
|
test('alpha is an error on both stores', () => {
|
|
expect(codes(checkImage(imageInfo(png(1320, 2868, { colorType: 6 })), 'ios69'))).toEqual(['alpha']);
|
|
expect(codes(checkImage(imageInfo(png(1080, 1920, { trns: true })), 'play'))).toEqual(['alpha']);
|
|
});
|
|
test('play: any accepted screenshot size, palette / 16 bit PNG rejected', () => {
|
|
ok(png(1080, 1920), 'play');
|
|
ok(jpeg(1440, 2560), 'tab10');
|
|
ok(png(1242, 2208), 'play');
|
|
expect(codes(checkImage(imageInfo(png(1000, 2100)), 'play'))).toEqual(['size']); // longer than 2:1
|
|
expect(codes(checkImage(imageInfo(png(300, 500)), 'play'))).toEqual(['size']); // under 320
|
|
expect(codes(checkImage(imageInfo(png(2200, 4000)), 'tab10'))).toEqual(['size']); // over 3840
|
|
expect(codes(checkImage(imageInfo(png(1080, 1920, { colorType: 3 })), 'play'))).toEqual(['format']);
|
|
expect(codes(checkImage(imageInfo(png(1080, 1920, { depth: 16 })), 'play'))).toEqual(['format']);
|
|
ok(png(1320, 2868, { depth: 16 }), 'ios69');
|
|
});
|
|
test('play 8 MB per screenshot, feature graphic exact', () => {
|
|
const big = imageInfo(jpeg(1080, 1920, 8 * 1024 * 1024 + 1));
|
|
expect(codes(checkImage(big, 'play'))).toEqual(['bytes']);
|
|
expect(checkImage({ ...big, bytes: 8 * 1024 * 1024 }, 'play')).toEqual([]);
|
|
ok(jpeg(1024, 500), 'feature');
|
|
expect(codes(checkImage(imageInfo(png(1000, 500)), 'feature'))).toEqual(['size']);
|
|
expect(codes(checkImage(imageInfo(png(500, 1024)), 'feature'))).toEqual(['size']);
|
|
expect(codes(checkImage(imageInfo(png(1024, 500, { colorType: 6 })), 'feature'))).toEqual(['alpha']);
|
|
const info: ImageInfo = { format: 'png', w: 1024, h: 500, alpha: false, palette: false, bytes: 16 * 1024 * 1024, depth: 8 };
|
|
expect(codes(checkImage(info, 'feature'))).toEqual(['bytes']);
|
|
});
|
|
test('not an image, unknown size key', () => {
|
|
expect(codes(checkImage(imageInfo(new Uint8Array(40)), 'ios69'))).toEqual(['format']);
|
|
expect(checkImage(imageInfo(png(1, 1)), 'nope')[0]!.msg).toContain('unknown size key');
|
|
});
|
|
});
|
|
|
|
describe('checkCount', () => {
|
|
test('per store', () => {
|
|
expect(checkCount(0, 'ios69', true)[0]!.level).toBe('error');
|
|
expect(checkCount(0, 'ios65', false)).toEqual([]);
|
|
expect(checkCount(10, 'ipad13', true)).toEqual([]);
|
|
expect(checkCount(11, 'ipad13', true)[0]).toMatchObject({ level: 'error', code: 'count' });
|
|
expect(checkCount(1, 'play', true)[0]).toMatchObject({ level: 'error', code: 'count' });
|
|
expect(checkCount(9, 'play', true)[0]).toMatchObject({ level: 'warn', code: 'count' });
|
|
expect(checkCount(2, 'feature', true)[0]!.level).toBe('warn');
|
|
});
|
|
});
|
|
|
|
describe('13" iPad', () => {
|
|
test('size, spec and ASC display type agree', () => {
|
|
expect(SIZES.ipad13).toEqual({ w: 2064, h: 2752, device: 'tablet' });
|
|
expect(IMAGE_SPECS.ipad13!.sizes[0]).toEqual([2064, 2752]);
|
|
for (const [k, t] of Object.entries({ ...DISPLAY, ...IPAD_DISPLAY })) {
|
|
expect(IMAGE_SPECS[k]!.type).toBe(t);
|
|
expect(IMAGE_SPECS[k]!.sizes[0]).toEqual([SIZES[k]!.w, SIZES[k]!.h]);
|
|
}
|
|
expect(IPAD_DISPLAY.ipad13).toBe('APP_IPAD_PRO_3GEN_129');
|
|
});
|
|
test('only with apple.ipad', () => {
|
|
expect(displaySets({})).toEqual(DISPLAY);
|
|
expect(displaySets(undefined)).toEqual(DISPLAY);
|
|
expect(Object.keys(displaySets({ ipad: true }))).toEqual(['ios69', 'ios65', 'ipad13']);
|
|
expect(requiredDisplayTypes({})).toEqual(['APP_IPHONE_61']);
|
|
expect(requiredDisplayTypes({ ipad: true })).toEqual(['APP_IPHONE_61', 'APP_IPAD_PRO_3GEN_129']);
|
|
expect(requiredDisplayTypes({ ipad: true, screenshotTypes: ['APP_IPHONE_65'] })).toEqual(['APP_IPHONE_65']);
|
|
expect(defaultSizes({ apple: { bundleId: 'x' } })).not.toContain('ipad13');
|
|
expect(defaultSizes({ apple: { bundleId: 'x', ipad: true } })).toContain('ipad13');
|
|
expect(defaultSizes({ play: { packageName: 'x' } } as Pick<ShipConfig, 'apple' | 'play'>)).not.toContain('ipad13');
|
|
});
|
|
test('tablet frames follow the 13" iPad with apple.ipad', () => {
|
|
expect(frameDims({}).tablet).toEqual(DEFAULT_TABLET);
|
|
expect(frameDims({ apple: { bundleId: 'x', ipad: true } }).tablet).toEqual(DEFAULT_IPAD);
|
|
expect(DEFAULT_IPAD.w * DEFAULT_IPAD.dpr).toBe(2064);
|
|
expect((DEFAULT_IPAD.h + DEFAULT_IPAD.bar) * DEFAULT_IPAD.dpr).toBe(2752);
|
|
const custom = frameDims({ apple: { bundleId: 'x', ipad: true }, shots: { tablet: { w: 834, h: 1170 } } as ShipConfig['shots'] });
|
|
expect(custom.tablet).toEqual({ w: 834, h: 1170, dpr: 2, bar: 24 });
|
|
expect(frameDims({}).phone).toEqual(DEFAULT_PHONE);
|
|
});
|
|
test('crop per device class', () => {
|
|
expect(cropTopFor(undefined, 'phone', 2868, DEFAULT_PHONE)).toBe(162);
|
|
expect(cropTopFor(undefined, 'tablet', 2752, DEFAULT_IPAD)).toBe(48);
|
|
expect(cropTopFor(undefined, 'tablet', 2560, DEFAULT_TABLET)).toBe(80);
|
|
expect(cropTopFor(100, 'tablet', 2752, DEFAULT_IPAD)).toBe(100);
|
|
expect(cropTopFor({ tablet: 60 }, 'tablet', 2752, DEFAULT_IPAD)).toBe(60);
|
|
expect(cropTopFor({ tablet: 60 }, 'phone', 2868, DEFAULT_PHONE)).toBe(162);
|
|
const base = { source: 'folder' as const, shots: {} };
|
|
expect(validateCapture({ ...base, cropTop: { phone: 162, tablet: 48 } })).toEqual([]);
|
|
expect(validateCapture({ ...base, cropTop: { tablet: -1 } }).join()).toContain('cropTop.tablet');
|
|
expect(validateCapture({ ...base, cropTop: { watch: 1 } as never }).join()).toContain('cropTop.watch');
|
|
const ios = { source: 'ios-sim' as const, ios: { device: 'iPhone 17 Pro Max', bundleId: 'com.example.game' }, shots: { a: {} } };
|
|
expect(validateCapture({ ...ios, ios: { ...ios.ios, warmupMs: 0 } })).toEqual([]);
|
|
expect(validateCapture({ ...ios, ios: { ...ios.ios, warmupMs: -5 } }).join()).toContain('warmupMs');
|
|
});
|
|
});
|
|
|
|
describe('renders scan', () => {
|
|
const config = {
|
|
languages: [{ id: 'en', apple: 'en-US', play: 'en-US' }, { id: 'de', play: 'de-DE' }],
|
|
apple: { bundleId: 'com.example.game', ipad: true }, play: { packageName: 'com.example.game' },
|
|
} as unknown as ShipConfig;
|
|
|
|
test('required sets per language', () => {
|
|
expect(requiredSets(config)).toEqual({ ios69: true, ipad13: true, play: true, feature: true });
|
|
expect(requiredSets(config, 'de')).toEqual({ ios69: false, ipad13: false, play: true, feature: true });
|
|
expect(requiredSets({ ...config, apple: { bundleId: 'x' } } as ShipConfig, 'en').ipad13).toBe(false);
|
|
});
|
|
|
|
test('wrong size, alpha, too many, too large, overflow, missing sets', () => {
|
|
const dir = mkdtempSync(join(tmpdir(), 'sb-renders-'));
|
|
const put = (rel: string, b: Uint8Array) => { mkdirSync(join(dir, rel, '..'), { recursive: true }); writeFileSync(join(dir, rel), b); };
|
|
try {
|
|
for (let i = 1; i <= 11; i++) put(`en/ios69/${String(i).padStart(2, '0')}.png`, png(1320, 2868));
|
|
put('en/ios69/03.png', png(1320, 2868, { colorType: 6 }));
|
|
put('en/ipad13/01.png', png(1600, 2480));
|
|
put('en/play/01.png', png(1080, 1920));
|
|
put('en/play/01.jpg', jpeg(1080, 1920, 8 * 1024 * 1024 + 10));
|
|
put('en/feature/1024x500.png', png(1024, 500, { colorType: 3 }));
|
|
put('de/play/01.jpg', jpeg(1080, 1920));
|
|
put('de/play/02.jpg', jpeg(1080, 1920));
|
|
put('de/feature/1024x500.jpg', jpeg(1024, 500));
|
|
writeFileSync(join(dir, 'report.json'), JSON.stringify({ 'en/ios69/01.png': { overflow: ['title line 1 shrunk to 60 %'], fonts: { used: { Montserrat: 20 }, fallback: [], missing: [] } } }));
|
|
const scan = scanRenders(dir, { langs: ['en', 'de'], required: lang => requiredSets(config, lang), probe: () => null });
|
|
const got = allIssues(scan).map(x => `${x.where} ${x.issue.level} ${x.issue.code}`).sort();
|
|
expect(got).toEqual([
|
|
'en/feature/1024x500.png error format',
|
|
'en/ios69 error count',
|
|
'en/ios69/01.png warn overflow',
|
|
'en/ios69/03.png error alpha',
|
|
'en/ipad13/01.png error size',
|
|
'en/play error count',
|
|
'en/play/01.jpg error bytes',
|
|
].sort());
|
|
|
|
const local = rendersCheck(config, dir)!;
|
|
expect(local.apple.issues.map(i => i.where).sort()).toEqual(['en/ios69', 'en/ios69/01.png', 'en/ios69/03.png', 'en/ipad13/01.png']);
|
|
expect(local.play.issues.length).toBe(3);
|
|
expect(local.apple.images).toBe(12);
|
|
expect(rendersCheck(config, join(dir, 'nope'))).toBeNull();
|
|
|
|
const f = rendersFindings('apple', local.apple);
|
|
expect(f.filter(x => x.level === 'blocker').length).toBe(3);
|
|
expect(f.find(x => x.code === 'renders-overflow')!.level).toBe('warn');
|
|
expect(f.find(x => x.code === 'renders-alpha')!.fix).toContain('rgb24');
|
|
} finally { rmSync(dir, { recursive: true, force: true }); }
|
|
});
|
|
|
|
test('clean renders: one ok finding, nothing without a folder', () => {
|
|
expect(rendersFindings('play', { dir: 'r', images: 4, issues: [] })).toEqual([{ store: 'play', level: 'ok', code: 'renders', msg: expect.stringContaining('4 local store images') }]);
|
|
expect(rendersFindings('play', undefined)).toEqual([]);
|
|
expect(rendersFindings('play', { dir: 'r', images: 0, issues: [] })).toEqual([]);
|
|
});
|
|
|
|
test('appleRules / playRules carry render blockers', () => {
|
|
const apple = { version: { versionString: '1.0', state: 'PREPARE_FOR_SUBMISSION' }, build: null, expectedLocales: [], locales: [],
|
|
requiredDisplayTypes: [], reviewDetail: null, ageRating: null, priceSchedule: true, availability: true, iaps: [], expectedProducts: [],
|
|
privacyUrl: { url: 'https://example.com/privacy', status: 200 },
|
|
renders: { dir: 'r', images: 1, issues: [{ where: 'en/ipad13/01.png', level: 'error', code: 'size', msg: '1600x2480' }] } } as AppleSnapshot;
|
|
const a = appleRules(apple).find(x => x.code === 'renders-size')!;
|
|
expect(a).toMatchObject({ store: 'apple', level: 'blocker' });
|
|
expect(a.msg).toContain('en/ipad13/01.png');
|
|
const play = { defaultLanguage: 'en-US', expectedLanguages: [], listings: [], privacyUrl: { url: 'https://example.com/privacy', status: 200 },
|
|
website: null, appAds: null, appAdsLines: [], adsLikely: false, tracks: [], products: [], expectedProducts: [],
|
|
renders: { dir: 'r', images: 1, issues: [{ where: 'en/play/01.jpg', level: 'error', code: 'bytes', msg: '8.1 MB > 8 MB' }] } } as PlaySnapshot;
|
|
expect(playRules(play).find(x => x.code === 'renders-bytes')).toMatchObject({ store: 'play', level: 'blocker' });
|
|
});
|
|
|
|
test('config accepts per-device cropTop', () => {
|
|
const c = { app: { name: 'G', privacyPolicyUrl: 'https://example.com/p' }, languages: [{ id: 'en' }],
|
|
shots: { copy: 'c.json', frames: 'f', out: 'o', scenes: [{ id: 'a', palette: 'fire', frames: [{ src: 'a.png' }] }],
|
|
capture: { source: 'folder', cropTop: { tablet: 48 }, shots: {} } } } as unknown as ShipConfig;
|
|
expect(validateConfig(c).filter(e => e.includes('cropTop'))).toEqual([]);
|
|
});
|
|
});
|