storebound/tests/gallery.test.ts
CRT d942275920 shots: glyph coverage check per language, automatic Noto fallbacks, check errors on fallback / system-font glyphs
Lilita One lacks most of Latin Extended-A (Turkish s-cedilla, g-breve, dotted I ...), so Turkish titles were drawn
with a silent browser fallback while shots check passed. shots fonts now reads the cmap of every face (pure TS,
TTF/OTF/WOFF/WOFF2), adds a Noto fallback per missing character or fails with language + characters; render
measures which face drew each character (CDP probes) and records it in report.json; check errors on fallbacks the
config did not choose (shots.fonts.fallback) and on system-font glyphs. Default game title font: Paytone One.
2026-10-12 03:05:23 +08:00

120 lines
6.6 KiB
TypeScript

// Review gallery (data + HTML), A/B variant paths, contact sheet layout. Files are tiny generated headers; no browser.
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 { galleryData, galleryHtml, scriptJson } from '../cli/shots/gallery';
import { renderDir, sheetLayout, variantRoot } from '../cli/shots/lib';
import { scanRenders } from '../cli/shots/report';
import { jpeg, png } from './fixtures';
function fixture(): string {
const dir = mkdtempSync(join(tmpdir(), 'sb-gallery-'));
const put = (rel: string, b: Uint8Array) => { mkdirSync(join(dir, rel, '..'), { recursive: true }); writeFileSync(join(dir, rel), b); };
put('en/ios69/01.png', png(1320, 2868));
put('en/ios69/02.png', png(1320, 2868, { colorType: 6 }));
put('en/play/01.png', png(1080, 1920));
put('en/play/01.jpg', jpeg(1080, 1920, 8 * 1024 * 1024 + 1));
put('en/play/02.png', png(1080, 1920));
put('en/feature/1024x500.png', png(1024, 500));
put('tr/ios69/01.png', png(1320, 2868));
put('b/en/ios69/01.png', png(1320, 2868));
put('en/preview/iphone/01.mp4', new Uint8Array(10));
put('en/preview/iphone/01-poster.png', png(886, 1920));
writeFileSync(join(dir, 'report.json'), JSON.stringify({ 'tr/ios69/01.png': { overflow: ['title line 1 shrunk to 60 %: consider shorter words'], fonts: { used: { 'Paytone One': 20 }, fallback: [], missing: [] } } }));
return dir;
}
describe('gallery', () => {
const probe = () => ({ bytes: 1000, duration: 10, video: { codec: 'h264', w: 886, h: 1920, fps: 30 }, audio: { codec: 'aac', channels: 2, sampleRate: 48000 } });
test('data: variants, rows in size order, png/jpeg merged, issues on tiles and sets', () => {
const dir = fixture();
try {
const scan = scanRenders(dir, { langs: ['en', 'tr'], variants: ['b', 'missing'], required: { ios69: true, play: true, feature: true }, probe });
const d = galleryData(scan, { at: dir, now: new Date('2026-10-10T12:00:00Z'), exists: f => f.endsWith('-poster.png') });
expect(d.variants.map(v => v.label)).toEqual(['base', 'variant b']);
const en = d.variants[0]!.langs.find(l => l.lang === 'en')!;
expect(en.rows.map(r => r.size)).toEqual(['ios69', 'play', 'feature', 'preview/iphone']);
const play = en.rows.find(r => r.size === 'play')!;
expect(play.tiles.map(t => t.src)).toEqual(['en/play/01.png', 'en/play/02.png']);
expect(play.tiles[0]!.issues.map(i => i.msg)).toEqual([expect.stringMatching(/^JPEG copy: 8\.0 MB > 8 MB/)]);
expect(en.rows.find(r => r.size === 'ios69')!.tiles[1]!.issues[0]!.code).toBe('alpha');
const vid = en.rows.find(r => r.size === 'preview/iphone')!.tiles[0]!;
expect(vid).toMatchObject({ kind: 'video', poster: 'en/preview/iphone/01-poster.png', w: 886, h: 1920 });
expect(vid.issues.some(i => i.msg.includes('15-30 s'))).toBe(true);
const tr = d.variants[0]!.langs.find(l => l.lang === 'tr')!;
expect(tr.rows.find(r => r.size === 'ios69')!.tiles[0]!.issues[0]).toMatchObject({ level: 'warn', code: 'overflow' });
expect(tr.rows.find(r => r.size === 'play')!.issues[0]!.code).toBe('count');
expect(d.variants[1]!.langs.map(l => l.lang)).toEqual(['en', 'tr']);
expect(d.totals.images).toBe(8);
expect(d.totals.errors).toBeGreaterThanOrEqual(4);
expect(d.generated).toBe('2026-10-10 12:00');
} finally { rmSync(dir, { recursive: true, force: true }); }
});
test('html: self-contained, escaped, data embedded', () => {
const dir = fixture();
try {
const scan = scanRenders(dir, { langs: ['en'], probe });
const html = galleryHtml(galleryData(scan, { at: dir, title: 'Game <b>&</b> renders' }));
expect(html.startsWith('<!doctype html>')).toBe(true);
expect(html).toContain('<title>Game &lt;b&gt;&amp;&lt;/b&gt; renders</title>');
expect(html).not.toMatch(/(src|href)=["']?(https?:)?\/\//);
expect(html).not.toMatch(/@import|url\(\s*["']?https?:/);
expect(html).toContain('prefers-color-scheme: dark');
expect(html).toContain('id="only"');
const json = /<script id="data" type="application\/json">([\s\S]*?)<\/script>/.exec(html)![1]!;
expect(json).not.toContain('<');
expect(JSON.parse(json).variants[0].langs[0].rows[0].tiles[0].src).toBe('en/ios69/01.png');
} finally { rmSync(dir, { recursive: true, force: true }); }
});
test('scriptJson escapes closing tags and line separators', () => {
expect(scriptJson({ a: '</script><b>\u2028' })).toBe('{"a":"\\u003c/script>\\u003cb>\\u2028"}');
});
test('relative paths from a gallery placed elsewhere', () => {
const dir = fixture();
try {
const scan = scanRenders(dir, { langs: ['en'], probe });
const d = galleryData(scan, { at: join(dir, 'review') });
expect(d.variants[0]!.langs[0]!.rows[0]!.tiles[0]!.src).toBe('../en/ios69/01.png');
} finally { rmSync(dir, { recursive: true, force: true }); }
});
});
describe('variants', () => {
const variants = { b: { scenes: ['a'] }, short: {} };
test('variantRoot', () => {
expect(variantRoot('store/renders', undefined, variants)).toBe('store/renders');
expect(variantRoot('store/renders', 'base', variants)).toBe('store/renders');
expect(variantRoot('store/renders/', 'b', variants)).toBe('store/renders/b');
expect(() => variantRoot('r', 'c', variants)).toThrow('not in shots.variants (b, short)');
expect(() => variantRoot('r', 'c', undefined)).toThrow('none configured');
});
test('renderDir', () => {
expect(renderDir('out', 'en')).toBe('out/en');
expect(renderDir('out', 'en', 'b')).toBe('out/b/en');
});
});
describe('contact sheet layout', () => {
test('one scene per size packs into a grid, not a tall strip', () => {
const rows = ['ios69', 'ios65', 'play', 'tab7', 'tab10', 'feature'].map(size => ({ size, files: [`${size}/01.png`] }));
const l = sheetLayout(rows);
expect(l.tiles.length).toBe(6);
expect(l.h).toBeLessThan(2 * 900 + 4 * 24);
expect(l.w).toBeLessThanOrEqual(3600 + 24);
for (const t of l.tiles) expect(t.x + t.w).toBeLessThanOrEqual(l.w);
expect(l.tiles.find(t => t.file.startsWith('feature'))!.h).toBe(450);
});
test('a wide block keeps its own row and sets the width', () => {
const l = sheetLayout([{ size: 'ios69', files: Array.from({ length: 10 }, (_, i) => `${i}.png`) }, { size: 'play', files: ['1.png'] }]);
const ios = l.tiles.filter(t => t.y === 24);
expect(ios.length).toBe(10);
expect(l.w).toBe(ios.at(-1)!.x + ios.at(-1)!.w + 24);
expect(l.tiles.at(-1)!.y).toBeGreaterThan(24);
expect(sheetLayout([])).toEqual({ w: 0, h: 0, tiles: [] });
});
});