375 lines
24 KiB
TypeScript
375 lines
24 KiB
TypeScript
// The teaser video (media/teaser, v9, the story cut tight): every CLI line it shows is real output of the command it
|
|
// names, the game screens and screenshot tiles are the demo game's own, every desktop frame is a real capture of the app
|
|
// (desktop.ts), every cut lands on a beat of the music, each shot is 2-4 s, each caption is at most six words and fully
|
|
// on screen for at least 2 s, the whole cut is 40-48 s, frame 0 already moves, the last 4 s hold the end card with
|
|
// `npx storebound init`, and it is 16:9 only.
|
|
import { afterAll, beforeAll, describe, expect, test } from 'bun:test';
|
|
import { generateKeyPairSync } from 'node:crypto';
|
|
import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
|
import { tmpdir } from 'node:os';
|
|
import { dirname, join } from 'node:path';
|
|
import { ALPHA3 } from '../cli/lib/money';
|
|
import { SIZES } from '../cli/shots/lib';
|
|
|
|
const ROOT = join(import.meta.dir, '..');
|
|
const CLI = join(ROOT, 'cli', 'index.ts');
|
|
const FX = join(import.meta.dir, 'fixtures', 'scan');
|
|
const MOCK = join(import.meta.dir, 'helpers', 'teaser-store-mock.ts');
|
|
|
|
interface Scene { id: string; start: number; end: number; x: number }
|
|
type Line = { at: number; text: string; cmd?: boolean; head?: boolean; final?: boolean };
|
|
interface Teaser {
|
|
FORMAT: { w: number; h: number }; DURATION: number; FPS: number; SCENES: Scene[]; MONTAGE: string[];
|
|
CAPTIONS: { id: string; pos: 'top' | 'edge'; text: string; at: number; until: number }[];
|
|
captionTimes(): { id: string; text: string; pos: string; at: number; from: number; to: number; gone: number; hold: number }[];
|
|
veil(t: number): number;
|
|
GAME_SCREENS: string[]; GAME_SPEED: number;
|
|
PRIVACY_ANSWERS: string[]; PRIVACY_STEPS: string[]; DATA_SAFETY_CSV: string[]; DECL_TOTAL: number; PRICE_ROWS: string[]; PRICE_CMD: string;
|
|
PILE: { at: number; kind: string }[]; PILE_TITLES: Record<string, string>;
|
|
PROMPT: string; REJECTS: { fixture: string; rule: string; title: string; head: string }[]; REJECT_STEP: number; SAFE: number;
|
|
STAMP: { at: number; d: number; jolt: number }; REJECT_LAND: { d: number }; REJECT_PUSH: [number, number]; REJECT_DARK: number;
|
|
ticks: () => { t: number; kind: string }[];
|
|
BUILD_LINES: Line[]; SHOT_SIZES: { id: string }[]; SHOT_LANGS: string[]; PRODUCT_LINES: Line[];
|
|
DECL_LINES: { what: string; value: string; where: string }[]; DECL_ASK: string;
|
|
CONSOLE_CMD: string; CONSOLE_LINES: Line[]; CONSOLES_SWAP: number[];
|
|
SCAN_CMD: string; RISKS_LINE: string; RESCAN_LINE: string; FINDINGS: string[]; FIX_AT: number[];
|
|
DESK: Record<string, { groups: { frame: string }[]; swap?: number[]; show?: number[]; rings: { at: number; until: number; target: string }[];
|
|
pointer?: { at: number; d: number; target: string; click?: number; until?: number } }>;
|
|
DESK_SCENES: string[]; DESK_FRAMES: string[]; CLICK_T: number;
|
|
END: { markAt: number; wordAt: number; cmdAt: number; cps: number; urlAt: number; d: number; full: number }; END_CMD: string; END_URL: string;
|
|
SHOT_NOTES: Record<string, string>;
|
|
}
|
|
type Box = { x: number; y: number; w: number; h: number };
|
|
const T = (await import(join(ROOT, 'media/teaser/script.js'))) as Teaser;
|
|
|
|
/** Output lines without the clock prefix and the "(0s)" timer, as the video shows them. */
|
|
const lines = (out: string): string[] => out.split('\n').map(l => l.replace(/^\d\d:\d\d:\d\d /, '').replace(/ \(\d+s\)$/, '').trimEnd());
|
|
const run = (argv: string[], cwd: string, env: Record<string, string> = {}): { code: number; out: string } => {
|
|
const r = Bun.spawnSync(['bun', CLI, ...argv], { cwd, env: { ...process.env, NO_COLOR: '1', ASC_KEY_ID: '', ASC_ISSUER_ID: '', ASC_P8: '', PLAY_SERVICE_ACCOUNT: '', ...env }, stdout: 'pipe', stderr: 'pipe' });
|
|
return { code: r.exitCode, out: r.stdout.toString() + r.stderr.toString() };
|
|
};
|
|
/** Every line is in the output, in the given order. */
|
|
function inOrder(out: string[], want: string[]): void {
|
|
for (const l of want) expect(out, l).toContain(l);
|
|
const idx = want.map(l => out.indexOf(l));
|
|
expect([...idx].sort((a, b) => a - b)).toEqual(idx);
|
|
}
|
|
|
|
// One throwaway project: `storebound init` in an empty folder plus two products, store answers from the teaser store
|
|
// double (tests/helpers/teaser-store-mock.ts, preloaded through bunfig.toml), placeholder keys.
|
|
let dir = '', keys: Record<string, string> = {};
|
|
beforeAll(() => {
|
|
dir = mkdtempSync(join(tmpdir(), 'sb-teaser-'));
|
|
expect(run(['init'], dir).code).toBe(0);
|
|
const cfg = join(dir, 'storebound.config.ts');
|
|
// the teaser's app has answered its declarations (a fresh init lists them as unanswered and nothing is sent)
|
|
writeFileSync(cfg, readFileSync(cfg, 'utf8').replace(/^ unanswered: \[[^\]]*\],$/m, ' unanswered: [],').replace(' dataSafety: {', ` products: [
|
|
{ id: 'com.example.app.no_ads', type: 'non_consumable', usd: 2.99, listings: { en: { title: 'No Ads', description: 'Removes every ad.' }, tr: { title: 'Reklamsız', description: 'Tüm reklamları kaldırır.' } } },
|
|
{ id: 'com.example.app.gems_100', type: 'consumable', usd: 0.99, listings: { en: { title: '100 Gems', description: 'A pouch of 100 gems.' }, tr: { title: '100 Mücevher', description: '100 mücevherlik bir kese.' } } },
|
|
],
|
|
dataSafety: {`));
|
|
writeFileSync(join(dir, 'bunfig.toml'), `preload = [${JSON.stringify(MOCK)}]\n`);
|
|
const ec = generateKeyPairSync('ec', { namedCurve: 'P-256' }).privateKey.export({ type: 'pkcs8', format: 'pem' }).toString();
|
|
const rsa = generateKeyPairSync('rsa', { modulusLength: 2048 }).privateKey.export({ type: 'pkcs8', format: 'pem' }).toString();
|
|
writeFileSync(join(dir, 'k.p8'), ec);
|
|
writeFileSync(join(dir, 'sa.json'), JSON.stringify({ client_email: 'sb@example.com', private_key: rsa, token_uri: 'https://oauth2.googleapis.com/token' }));
|
|
keys = { ASC_KEY_ID: 'ABCDE12345', ASC_ISSUER_ID: '00000000-0000-0000-0000-000000000000', ASC_P8: join(dir, 'k.p8'), PLAY_SERVICE_ACCOUNT: join(dir, 'sa.json') };
|
|
});
|
|
afterAll(() => rmSync(dir, { recursive: true, force: true }));
|
|
|
|
describe('teaser: every CLI line is real output', () => {
|
|
test('pile + consoles: `storebound console plan apple-app-privacy` (offline): answer sheet, steps, never-press, the owner\'s Publish', () => {
|
|
expect(T.CONSOLE_CMD).toBe('storebound console plan apple-app-privacy');
|
|
const r = run(T.CONSOLE_CMD.split(' ').slice(1), dir);
|
|
expect(r.code).toBe(0);
|
|
const out = lines(r.out);
|
|
inOrder(out, T.PRIVACY_ANSWERS);
|
|
inOrder(out, T.PRIVACY_STEPS);
|
|
inOrder(out, T.CONSOLE_LINES.map(l => l.text));
|
|
const fin = T.CONSOLE_LINES.find(l => l.final)!;
|
|
expect(fin.text).toBe("Final button (the owner's): Publish");
|
|
expect(T.CONSOLE_LINES.find(l => l.text.startsWith('Never press:'))!.text).toContain('"Publish"');
|
|
});
|
|
|
|
test('pile: the Data safety CSV of `storebound play data-safety --dry`, its row count from `storebound check`', () => {
|
|
const r = run(['play', 'data-safety', '--dry'], dir);
|
|
expect(r.code).toBe(0);
|
|
inOrder(lines(r.out), T.DATA_SAFETY_CSV);
|
|
expect(run(['check'], dir).out).toContain(`data safety: ${T.DECL_TOTAL} CSV rows`);
|
|
expect(T.PILE_TITLES.csv).toContain(`${T.DECL_TOTAL} rows`);
|
|
});
|
|
|
|
test('pile: the price sheet of `storebound prices ppp --offline --dry`', () => {
|
|
const r = run(T.PRICE_CMD.split(' '), dir);
|
|
expect(r.code).toBe(0);
|
|
inOrder(lines(r.out), T.PRICE_ROWS);
|
|
});
|
|
|
|
test('builds: `storebound apple build --dry` and `storebound play build --dry` in a Capacitor project', () => {
|
|
const d = mkdtempSync(join(tmpdir(), 'sb-teaser-build-'));
|
|
try {
|
|
const touch = (p: string, s = '') => { mkdirSync(dirname(join(d, p)), { recursive: true }); writeFileSync(join(d, p), s); };
|
|
touch('storebound.config.ts', `export default { app: { name: 'Example', privacyPolicyUrl: 'https://example.com/p', supportUrl: 'https://example.com', website: 'https://example.com', contactEmail: 'hi@example.com' },
|
|
languages: [{ id: 'en', apple: 'en-US', play: 'en-US' }], apple: { bundleId: 'com.example.game', signing: { teamId: 'TEAMID1234' } }, play: { packageName: 'com.example.game' } };\n`);
|
|
touch('capacitor.config.ts', 'export default {}');
|
|
touch('ios/App/App.xcodeproj/project.pbxproj');
|
|
touch('android/settings.gradle'); touch('android/gradlew', '#!/bin/sh');
|
|
touch('android/app/build.gradle', "android {\n defaultConfig {\n versionCode((project.findProperty('versionCode') ?: 1) as Integer)\n versionName project.findProperty('versionName') ?: '1.0'\n }\n}\n");
|
|
const cmds = T.BUILD_LINES.filter(l => l.cmd).map(l => l.text.replace(/^\$ storebound /, ''));
|
|
expect(cmds).toEqual(['apple build --dry --build-number 52 --version 1.0.0', 'play build --dry --version-code 52']);
|
|
const outs = cmds.map(c => run(c.split(' '), d, { HOME: join(d, 'home') }));
|
|
for (const o of outs) expect(o.code).toBe(0);
|
|
// each command's lines in its own output, in order
|
|
const groups: string[][] = [];
|
|
for (const l of T.BUILD_LINES) if (l.cmd) groups.push([]); else groups.at(-1)!.push(l.text);
|
|
groups.forEach((g, i) => {
|
|
const out = lines(outs[i]!.out);
|
|
for (const l of g) expect(out, l).toContain(l);
|
|
expect(out.indexOf(g[0]!)).toBeLessThan(out.lastIndexOf(g.at(-1)!));
|
|
});
|
|
} finally { rmSync(d, { recursive: true, force: true }); }
|
|
});
|
|
|
|
test('products: `play products --dry` before, `catalog diff` after, with the two products in the config', () => {
|
|
const cmds = T.PRODUCT_LINES.filter(l => l.cmd).map(l => l.text.replace(/^\$ storebound /, ''));
|
|
expect(cmds).toEqual(['play products --dry', 'catalog diff']);
|
|
const before = run(cmds[0]!.split(' '), dir, { ...keys, TEASER_STORE: 'new' });
|
|
const after = run(cmds[1]!.split(' '), dir, { ...keys, TEASER_STORE: 'catalog' });
|
|
expect(before.code).toBe(0);
|
|
expect(after.code).toBe(0);
|
|
const out = [...lines(before.out), ...lines(after.out)];
|
|
for (const l of T.PRODUCT_LINES.filter(x => !x.cmd)) expect(out, l.text).toContain(l.text);
|
|
// "175 countries": the App Store's storefronts, the region prices shown
|
|
expect(Object.keys(ALPHA3)).toHaveLength(175);
|
|
expect(T.PRODUCT_LINES.some(l => l.text.includes('175 region prices'))).toBe(true);
|
|
expect(T.CAPTIONS.find(c => c.id === 'products')!.text).toBe('Products, 175 countries.');
|
|
expect(out.join('\n')).toContain('0 mismatch(es), 0 warning(s), 0 price difference(s)');
|
|
});
|
|
});
|
|
|
|
describe('teaser: the scan text is real', () => {
|
|
const scanText = (fixture: string, demo: boolean): string => {
|
|
const env = { REVIEW_DEMO_USER: demo ? 'demo@example.com' : '', REVIEW_DEMO_PASSWORD: demo ? 'example' : '' };
|
|
const r = run(['scan', '--dir', join(FX, fixture)], ROOT, env);
|
|
expect(r.code).toBe(demo ? 0 : 1); // exit 1 on blockers (before the fixes)
|
|
return r.out;
|
|
};
|
|
let before = '', after = '';
|
|
beforeAll(() => { before = scanText('teaser', false); after = scanText('teaser-fixed', true); });
|
|
|
|
test('reject: each card is a real finding line of `storebound scan` (teaser, teaser-manifest), its rule number in it', () => {
|
|
const manifest = run(['scan', '--dir', join(FX, 'teaser-manifest')], ROOT, {}).out;
|
|
for (const f of T.REJECTS) {
|
|
const out = f.fixture === 'teaser' ? before : manifest;
|
|
expect(out, f.rule).toMatch(new RegExp(`^ \\d\\. ${f.head.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}$`, 'm'));
|
|
expect(f.head, f.rule).toContain(f.rule.replace('Guideline ', ''));
|
|
expect(f.title.split(' ').length, f.title).toBeLessThanOrEqual(6);
|
|
}
|
|
expect(T.REJECTS.map(f => f.rule)).toEqual(['Guideline 2.1(a)', 'Guideline 5.1.1(v)', 'Guideline 5.1.2(i)', 'ITMS-91064']);
|
|
});
|
|
test('scan: the risks line, all five findings in order, and the clean re-scan of teaser-fixed', () => {
|
|
expect(T.SCAN_CMD).toBe('storebound scan');
|
|
const out = lines(before);
|
|
expect(out).toContain(T.RISKS_LINE);
|
|
inOrder(out, T.FINDINGS.map((f, i) => ` ${i + 1}. ${f}`));
|
|
expect(T.FIX_AT).toHaveLength(T.FINDINGS.length);
|
|
expect(after).toContain(T.RESCAN_LINE);
|
|
});
|
|
test('forms: the declarations proposal lines and the ask-owner line', () => {
|
|
for (const d of T.DECL_LINES) expect(before).toContain(`detected ${d.what}: ${d.value}`);
|
|
for (const d of T.DECL_LINES) expect(before).toContain(`(${d.where})`);
|
|
expect(before).toContain(T.DECL_ASK);
|
|
});
|
|
});
|
|
|
|
describe('teaser: the game and its screenshots are the demo game\'s', () => {
|
|
test('game: the screens are the demo game\'s own captures (its config\'s capture shots)', async () => {
|
|
const cfg = (await import(join(ROOT, 'examples/demo-game/storebound.config.ts'))).default as { languages: { id: string }[]; shots: { capture: { shots: Record<string, unknown> } } };
|
|
expect(T.GAME_SCREENS).toEqual(Object.keys(cfg.shots.capture.shots));
|
|
expect(T.SHOT_LANGS).toEqual(cfg.languages.map(l => l.id));
|
|
const render = readFileSync(join(ROOT, 'media/teaser/render.ts'), 'utf8');
|
|
expect(render).toContain("'examples/demo-game/store/shots/frames/en/phone'");
|
|
});
|
|
test('shots: seven store sizes the demo game renders (iPhone, iPad, Play phone and tablets, feature graphic)', () => {
|
|
for (const s of T.SHOT_SIZES) expect(SIZES[s.id], s.id).toBeDefined();
|
|
expect(T.SHOT_SIZES).toHaveLength(7);
|
|
// the caption claims sizes, not a language count the demo does not have
|
|
expect(T.CAPTIONS.find(c => c.id === 'shots')!.text).toBe('Screenshots, every size.');
|
|
});
|
|
test('ask: the request is a plain sentence to the agent', () => {
|
|
expect(T.PROMPT).toBe('Get this game ready for review on both stores.');
|
|
});
|
|
});
|
|
|
|
describe('teaser: timing and format', () => {
|
|
test('16:9 only: the master is 1920x1080; the pipeline has no vertical output', () => {
|
|
expect(T.FORMAT).toEqual({ w: 1920, h: 1080 });
|
|
const render = readFileSync(join(ROOT, 'media/teaser/render.ts'), 'utf8');
|
|
expect(render).not.toMatch(/1080\s*x\s*1920|w: 1080, h: 1920|vertical/i);
|
|
expect(render).toContain('scale=1280:720');
|
|
});
|
|
test('the story: the shots in order, the montage in the middle, 40-48 s, hard cuts only', () => {
|
|
expect(T.SCENES.map(s => s.id)).toEqual(['game', 'pile', 'reject', 'ask', 'builds', 'shots', 'products', 'forms', 'consoles', 'scan', 'button', 'waiting', 'end']);
|
|
expect(T.MONTAGE).toEqual(['builds', 'shots', 'products', 'forms', 'consoles']);
|
|
expect(T.DURATION).toBe(T.SCENES.at(-1)!.end);
|
|
expect(T.DURATION).toBeGreaterThanOrEqual(40);
|
|
expect(T.DURATION).toBeLessThanOrEqual(48);
|
|
// the montage: 3 s per beat
|
|
for (const id of T.MONTAGE) { const s = T.SCENES.find(x => x.id === id)!; expect(s.end - s.start, id).toBe(3); }
|
|
for (const s of T.SCENES) expect(s.x, s.id).toBe(0);
|
|
// every shot says why it cuts (the animatic's shot list)
|
|
for (const s of T.SCENES) expect(T.SHOT_NOTES[s.id], s.id).toBeTruthy();
|
|
});
|
|
test('every cut lands on a beat of music.js (120 BPM: whole half seconds), shots 2-4 s, the end card 5 s', () => {
|
|
const music = readFileSync(join(ROOT, 'media/teaser/music.js'), 'utf8');
|
|
expect(music).toContain('export const STEP = 0.5;');
|
|
expect(T.SCENES[0]!.start).toBe(0);
|
|
for (let i = 1; i < T.SCENES.length; i++) expect(T.SCENES[i]!.start).toBe(T.SCENES[i - 1]!.end);
|
|
for (const s of T.SCENES) {
|
|
expect(Number.isInteger(s.start / 0.5), s.id).toBe(true);
|
|
expect(s.end - s.start, s.id).toBeGreaterThanOrEqual(2);
|
|
expect(s.end - s.start, s.id).toBeLessThanOrEqual(s.id === 'end' ? 5 : 4);
|
|
}
|
|
// the fixes chime on beats too, and the pile lands one card per beat
|
|
const scan = T.SCENES.find(s => s.id === 'scan')!;
|
|
for (const f of T.FIX_AT) expect(Number.isInteger((scan.start + f) / 0.5), String(f)).toBe(true);
|
|
T.PILE.forEach((c, i) => expect(c.at, c.kind).toBe(i * 0.5));
|
|
});
|
|
test('captions: at most six words, fully on screen for at least 2 s, never over the end card', () => {
|
|
const c = T.captionTimes();
|
|
expect(new Set(c.map(x => x.id)).size).toBe(c.length);
|
|
const end = T.SCENES.find(s => s.id === 'end')!;
|
|
for (const x of c) {
|
|
expect(x.hold, x.text).toBeGreaterThanOrEqual(2);
|
|
expect(x.at, x.text).toBeGreaterThanOrEqual(T.SCENES.find(y => y.id === x.id)!.start);
|
|
expect(x.gone, x.text).toBeLessThanOrEqual(end.start);
|
|
expect(x.text.split(' ').length, x.text).toBeLessThanOrEqual(6);
|
|
}
|
|
for (let i = 1; i < c.length; i++) expect(c[i]!.at, c[i]!.text).toBeGreaterThanOrEqual(c[i - 1]!.gone);
|
|
for (const x of c) expect(x.pos, x.id).toBe(T.MONTAGE.includes(x.id) ? 'edge' : 'top');
|
|
expect(c.map(x => x.text)).toEqual(['Your game is done.', 'Then come the stores.', 'Rejected. Again.', 'So my AI did it.',
|
|
'Builds.', 'Screenshots, every size.', 'Products, 175 countries.', 'Forms.', 'Consoles.', 'Nothing left to reject.', 'You press Submit.']);
|
|
});
|
|
test('the cold open moves on frame 0: no fade from black, the screens already rushing past', () => {
|
|
expect(T.CAPTIONS[0]!.at).toBeLessThanOrEqual(0.2);
|
|
expect(T.GAME_SPEED).toBeGreaterThan(200);
|
|
const js = readFileSync(join(ROOT, 'media/teaser/teaser.js'), 'utf8');
|
|
expect(js).toContain("set(el, 'opacity', String(i === 0 ? 1 :");
|
|
});
|
|
test('the end card: the logo, `npx storebound init` and storebound.dev, complete for at least the last 4 s', () => {
|
|
const e = T.SCENES.find(s => s.id === 'end')!;
|
|
expect(T.SCENES.at(-1)!.id).toBe('end');
|
|
expect(T.END_CMD).toBe('npx storebound init');
|
|
expect(T.END_URL).toBe('storebound.dev');
|
|
expect(T.DURATION - (e.start + T.END.full)).toBeGreaterThanOrEqual(4);
|
|
const html = readFileSync(join(ROOT, 'media/teaser/index.html'), 'utf8');
|
|
expect(html).toMatch(/#brand \.cmdl \{[^}]*font: 800 7\dpx/);
|
|
});
|
|
test('reject: 4 s, the cards land one per beat and all read >= 1 s before the REJECTED stamp hits on a beat, then it darkens', () => {
|
|
const r = T.SCENES.find(s => s.id === 'reject')!;
|
|
expect(r.end - r.start).toBe(4);
|
|
expect(T.REJECTS.length).toBeLessThanOrEqual(5);
|
|
T.REJECTS.forEach((_, i) => expect(Number.isInteger((r.start + i * T.REJECT_STEP) / 0.5)).toBe(true));
|
|
expect(T.STAMP.at - T.STAMP.d - ((T.REJECTS.length - 1) * T.REJECT_STEP + T.REJECT_LAND.d)).toBeGreaterThanOrEqual(0.7);
|
|
expect(Number.isInteger((r.start + T.STAMP.at) / 0.5)).toBe(true);
|
|
expect(T.ticks().some(x => x.kind === 'stamp' && x.t === r.start + T.STAMP.at)).toBe(true);
|
|
expect(T.REJECT_DARK).toBeGreaterThan(T.STAMP.at);
|
|
expect(T.STAMP.jolt).toBeLessThanOrEqual(6); // no shake: one tiny 1-frame jolt at most
|
|
expect(readFileSync(join(ROOT, 'media/teaser/teaser.js'), 'utf8')).not.toMatch(/shake/);
|
|
});
|
|
// The render's layout data: `render.ts --animatic` writes every reject card's box at every frame of the shot.
|
|
const CHECKS = join(ROOT, 'media/teaser/out/animatic-checks.json');
|
|
test.skipIf(!existsSync(CHECKS))('reject: every card and the stamp stay inside the 6% safe margins at every frame (animatic layout data)', () => {
|
|
const j = JSON.parse(readFileSync(CHECKS, 'utf8')) as { duration: number; rejectLayout?: { t: number; cards: Box[]; stamp: Box | null }[] };
|
|
expect(j.duration).toBe(T.DURATION);
|
|
const r = T.SCENES.find(s => s.id === 'reject')!;
|
|
expect(j.rejectLayout?.length).toBe(Math.round((r.end - r.start) * T.FPS));
|
|
const mx = T.SAFE * T.FORMAT.w, my = T.SAFE * T.FORMAT.h;
|
|
for (const f of j.rejectLayout!) for (const [k, b] of [...f.cards.map((c, i) => [`card ${i + 1}`, c] as const), ['stamp', f.stamp] as const]) {
|
|
if (!b) continue;
|
|
const at = `${k} at ${f.t.toFixed(3)} s`;
|
|
expect(b.x, at).toBeGreaterThanOrEqual(mx);
|
|
expect(b.y, at).toBeGreaterThanOrEqual(my);
|
|
expect(b.x + b.w, at).toBeLessThanOrEqual(T.FORMAT.w - mx);
|
|
expect(b.y + b.h, at).toBeLessThanOrEqual(T.FORMAT.h - my);
|
|
}
|
|
expect(j.rejectLayout!.at(-1)!.cards.length).toBe(T.REJECTS.length);
|
|
expect(j.rejectLayout!.some(f => f.stamp)).toBe(true);
|
|
});
|
|
test.skipIf(!existsSync(CHECKS))('no text overflows anywhere: every text node fits its element and its card, every card in frame, at every frame (animatic layout data)', () => {
|
|
const j = JSON.parse(readFileSync(CHECKS, 'utf8')) as { duration: number; textFrames?: number; textProblems?: unknown[] };
|
|
expect(j.duration).toBe(T.DURATION);
|
|
expect(j.textFrames).toBe(Math.round(T.DURATION * T.FPS));
|
|
expect(j.textProblems?.slice(0, 10)).toEqual([]);
|
|
});
|
|
test('the picture darkens with the rejection and lifts on the drop', () => {
|
|
const r = T.SCENES.find(s => s.id === 'reject')!, b = T.SCENES.find(s => s.id === 'builds')!;
|
|
expect(T.veil(r.start)).toBe(0);
|
|
expect(T.veil(r.end - 0.1)).toBeGreaterThan(0.5);
|
|
expect(T.veil(b.start)).toBe(0);
|
|
});
|
|
});
|
|
|
|
// The desktop frames: the real app (fixture mode) in the consoles beat, the button and the waiting scene, captured by
|
|
// media/teaser/desktop.ts. The frames are gitignored output; the file checks run wherever they were captured
|
|
// (render.ts captures them when they are missing).
|
|
const DESK_DIR = join(ROOT, 'media/teaser/out/desktop');
|
|
const captured = existsSync(join(DESK_DIR, 'boxes.json'));
|
|
/** Width and height of a JPEG (its first SOF marker). */
|
|
function jpegSize(buf: Buffer): { w: number; h: number } | null {
|
|
if (buf[0] !== 0xff || buf[1] !== 0xd8) return null;
|
|
for (let i = 2; i + 9 < buf.length;) {
|
|
if (buf[i] !== 0xff) { i++; continue; }
|
|
const m = buf[i + 1]!;
|
|
if (m >= 0xc0 && m <= 0xcf && m !== 0xc4 && m !== 0xc8 && m !== 0xcc) return { w: buf.readUInt16BE(i + 7), h: buf.readUInt16BE(i + 5) };
|
|
i += 2 + buf.readUInt16BE(i + 2);
|
|
}
|
|
return null;
|
|
}
|
|
|
|
describe('teaser: the desktop scenes are real captures of the app', () => {
|
|
test('consoles, the button and the click: the plan panel, Send for review, then Waiting for review', () => {
|
|
expect(T.DESK_SCENES).toEqual(['consoles', 'button', 'waiting']);
|
|
expect(T.DESK.consoles!.groups.map(g => g.frame)).toEqual(['console-plan']);
|
|
expect(T.DESK.consoles!.show).toEqual(T.CONSOLES_SWAP);
|
|
expect(T.DESK.button!.groups.map(g => g.frame)).toEqual(['submit-ready']);
|
|
expect(T.DESK.waiting!.groups.map(g => g.frame)).toEqual(['submit-ready', 'submit-waiting']);
|
|
// the pointer rests on Submit in the button scene and presses it once, at the start of the waiting scene
|
|
expect(T.DESK.button!.pointer!.target).toBe('submitBtn');
|
|
expect(T.DESK.button!.pointer!.click).toBeUndefined();
|
|
expect(T.DESK.waiting!.pointer!.target).toBe('submitBtn');
|
|
const w = T.SCENES.find(s => s.id === 'waiting')!;
|
|
expect(T.CLICK_T).toBe(w.start + T.DESK.waiting!.pointer!.click!);
|
|
expect(T.DESK.waiting!.swap![0]).toBeGreaterThan(T.DESK.waiting!.pointer!.click!);
|
|
// the waiting scene picks up exactly where the button scene's camera stopped (no jump on the cut)
|
|
const cam = (id: string) => (T.DESK[id] as unknown as { cam: { from: unknown; to: unknown } }).cam;
|
|
expect(cam('waiting').from).toEqual(cam('button').to);
|
|
});
|
|
test('the console plan panel shows the real plan; no drawn system dialogs or mails', () => {
|
|
const src = readFileSync(join(ROOT, 'media/teaser/desktop.ts'), 'utf8');
|
|
expect(src).toContain("realConsolePlan('apple-app-privacy')");
|
|
expect(src).toContain("['console', 'plan', form, '--json']");
|
|
const html = readFileSync(join(ROOT, 'media/teaser/index.html'), 'utf8');
|
|
expect(html).not.toMatch(/Submit for Review<span class="hov">|id="sbtn"|id="mail"/);
|
|
});
|
|
test('every frame a scene names is one desktop.ts captures from the app (no mock frames)', () => {
|
|
const src = readFileSync(join(ROOT, 'media/teaser/desktop.ts'), 'utf8');
|
|
expect(src).toContain("VITE_FIXTURES: '1'");
|
|
for (const f of T.DESK_FRAMES) expect(src, f).toMatch(new RegExp(`shot\\([a-z.]+, '${f}'\\)`));
|
|
const js = readFileSync(join(ROOT, 'media/teaser/teaser.js'), 'utf8');
|
|
expect(js).toContain('out/desktop/${g.frame}.jpg');
|
|
});
|
|
test.if(captured)('the captured frames are present: 1280x800 @2x JPEGs, every ring and pointer target captured', () => {
|
|
const boxes = JSON.parse(readFileSync(join(DESK_DIR, 'boxes.json'), 'utf8')) as Record<string, unknown> & { source: string };
|
|
expect(boxes.source).toContain('VITE_FIXTURES=1');
|
|
for (const f of T.DESK_FRAMES) {
|
|
const p = join(DESK_DIR, `${f}.jpg`);
|
|
expect(existsSync(p), p).toBe(true);
|
|
expect(jpegSize(readFileSync(p)), f).toEqual({ w: 2560, h: 1600 });
|
|
}
|
|
for (const id of T.DESK_SCENES) {
|
|
for (const c of T.DESK[id]!.rings) expect(boxes[c.target], c.target).toBeDefined();
|
|
if (T.DESK[id]!.pointer) expect(boxes[T.DESK[id]!.pointer!.target], id).toBeDefined();
|
|
}
|
|
});
|
|
});
|