// 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 & renders' })); expect(html.startsWith('')).toBe(true); expect(html).toContain('Game <b>&</b> renders'); 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 = /\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: [] }); }); });