teaser v8: a 58 s story instead of a feature list (the game is done; the stores pile up; one missing line; hand it to your AI; a montage of builds, screenshots, products, forms, consoles; the scan turns green; one button is yours; Waiting for review; the end card), one short line per scene, cuts on the 120 BPM beat; music rewritten for the arc (calm, tension, near silence, a driving build, a stop, a resolve), mastered with one gain instead of dynamic loudnorm; the pile shows real console plan, Data safety CSV and price sheet lines, the builds beat real apple / play build dry runs; desktop.ts captures submit-ready (Send for review before the press); tests check every line, the beat-aligned cuts and the caption times; two unused teaser icons dropped

This commit is contained in:
CRTkafa 2026-10-13 04:48:27 +08:00
parent d5ffc159c0
commit 0b6c8cd7a0
11 changed files with 1117 additions and 1041 deletions

View file

@ -1,11 +1,12 @@
// The teaser video (media/teaser, v7): every CLI line it shows is real output of the command it names, every desktop
// frame is a real capture of the app (desktop.ts), each scene holds 4-5 s (a whole number of beats), each caption is
// fully on screen for at least 3 s, the whole cut is 60-65 s, and it is 16:9 only.
// The teaser video (media/teaser, v8, a story): 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 caption is short and fully on screen for at least 2.5 s, the whole cut is
// 56-62 s, and it is 16:9 only.
import { afterAll, beforeAll, describe, expect, test } from 'bun:test';
import { generateKeyPairSync } from 'node:crypto';
import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { dirname, join } from 'node:path';
import { ALPHA3 } from '../cli/lib/money';
import { SIZES } from '../cli/shots/lib';
@ -14,25 +15,25 @@ 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 }
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; SCENES: Scene[]; DISSOLVE: number;
CAPTIONS: { id: string; lines: string[] }[];
captionTimes(): { id: string; text: string; at: number; from: number; to: number; hold: number }[];
TODO: { text: string }[]; MAIL: { at: number; subject: string; guideline: string }; MAIL_IN: number;
INIT_CMD: string; INIT_LINES: string[];
PLAN_CMD: string; PLAN_LINES: string[]; PLAN_STEPS: number; PLAN_PILL: string;
COPY_FILE: string; COPY: { lang: string; title: string[] }[]; SHOT_SIZES: { id: string }[]; SHOT_LANGS: string[]; SHOTS_PILL: string;
PRODUCT_LINES: { text: string; cmd?: boolean }[]; PRODUCTS_PILL: { text: string };
DECL_LINES: { what: string; value: string; where: string }[]; DECL_ASK: string; DECL_TOTAL: number;
CONSOLE_CMD: string; CONSOLE_LINES: { text: string; head?: boolean; final?: boolean }[]; CONSOLE_STEPS: number; CONSOLE_ANSWERS: number;
CONSOLES_PILL: { text: string }; CONSOLES_SWAP: number[];
SCAN_CMD: string; RISKS_LINE: string; RESCAN_LINE: string;
FINDINGS: { sev: string; guideline: string; title: string; where: string; why: string }[];
DESK: Record<string, { groups: { frame: string; sweep?: { seq: string; at: number; d: number } }[]; swap?: number[]; show?: number[]; hide?: number[];
callouts: { at: number; until: number; target: string; text: string }[]; pointer?: { at: number; d: number; target: string; until?: number } }>;
SUBMIT: { logoAt: number };
DESK_SCENES: string[]; DESK_FRAMES: string[]; DESK_SWEEPS: string[];
FORMAT: { w: number; h: number }; DURATION: 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[];
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>;
REJECT: { head: string; at: string }; PROMPT: 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; lineAt: number; urlAt: number; d: number }; END_LINE: string; END_URL: string;
}
const T = (await import(join(ROOT, 'media/teaser/script.js'))) as Teaser;
@ -42,13 +43,25 @@ const run = (argv: string[], cwd: string, env: Record<string, string> = {}): { c
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 for the init, plan and products scenes: `storebound init` in an empty folder, store answers
// from the teaser store double (tests/helpers/teaser-store-mock.ts, preloaded through bunfig.toml), placeholder keys.
let dir = '', initOut = '', keys: Record<string, string> = {};
// 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-'));
initOut = run(['init'], dir).out;
expect(run(['init'], dir).code).toBe(0);
const cfg = join(dir, 'storebound.config.ts');
writeFileSync(cfg, readFileSync(cfg, 'utf8').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();
@ -59,74 +72,72 @@ beforeAll(() => {
afterAll(() => rmSync(dir, { recursive: true, force: true }));
describe('teaser: every CLI line is real output', () => {
test('init: `storebound init` in an empty folder', () => {
expect(T.INIT_CMD).toBe('bunx storebound init');
const out = lines(initOut);
for (const l of T.INIT_LINES) expect(out, l).toContain(l);
});
test('plan: `storebound release plan 1.0.0` against a brand-new app record on both stores', () => {
expect(T.PLAN_CMD).toBe('storebound release plan 1.0.0');
const r = run(T.PLAN_CMD.split(' ').slice(1), dir, { ...keys, TEASER_STORE: 'new' });
const out = lines(r.out);
for (const l of T.PLAN_LINES) expect(out, l).toContain(l);
// the lines keep the order of the real plan
const idx = T.PLAN_LINES.map(l => out.indexOf(l));
expect([...idx].sort((a, b) => a - b)).toEqual(idx);
const steps = out.filter(l => /^\s+\d+\. /.test(l)).length;
expect(steps).toBe(T.PLAN_STEPS);
expect(T.PLAN_PILL).toStartWith(`*${steps}* steps`);
expect(r.out).not.toContain('could not be read');
});
test('products: `play products --dry` before, `catalog diff` after, with two products in the config', () => {
const cfg = join(dir, 'storebound.config.ts');
const src = readFileSync(cfg, 'utf8');
writeFileSync(cfg, src.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: {`));
try {
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);
// the pill's numbers are the region counts above, and the App Store's 175 storefronts
expect(Object.keys(ALPHA3)).toHaveLength(175);
expect(T.PRODUCTS_PILL.text).toContain('*175* App Store regions');
expect(T.PRODUCTS_PILL.text).toContain('*174* Google Play regions');
expect(out.join('\n')).toContain('0 mismatch(es), 0 warning(s), 0 price difference(s)');
} finally { writeFileSync(cfg, src); }
});
test('consoles: `storebound console plan apple-app-privacy` (offline): steps, answer sheet, never-press, the owner\'s Publish', () => {
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);
for (const l of T.CONSOLE_LINES) expect(out, l.text).toContain(l.text);
// the lines keep the order of the real plan
const idx = T.CONSOLE_LINES.map(l => out.indexOf(l.text));
expect([...idx].sort((a, b) => a - b)).toEqual(idx);
// the pill's counts: the numbered steps and the answer-sheet rows of the whole plan
const steps = out.slice(out.indexOf('Steps:'), out.indexOf('Answer sheet:')).filter(l => /^\s+\d+\. /.test(l)).length;
const answers = out.filter(l => /^ {2}\[(ok|ask|check|agree)\] /.test(l)).length;
expect(steps).toBe(T.CONSOLE_STEPS);
expect(answers).toBe(T.CONSOLE_ANSWERS);
expect(T.CONSOLES_PILL.text).toBe(`*${steps}* steps · *${answers}* answers · stops before *Publish*`);
// the final button stays the owner's, and the never-press list holds it
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('forms: the Data safety answer count of `storebound check`', () => {
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)');
});
});
@ -140,19 +151,18 @@ describe('teaser: the scan text is real', () => {
let before = '', after = '';
beforeAll(() => { before = scanText('teaser', false); after = scanText('teaser-fixed', true); });
test('rejections: the risks line and three findings (severity, guideline, title, file:line)', () => {
expect(T.SCAN_CMD).toBe('storebound scan');
expect(before).toContain(T.RISKS_LINE);
const blocks = before.split(/\n\s+\d\. /).slice(1);
for (const f of T.FINDINGS) {
const b = blocks.find(x => x.startsWith(`${f.sev.padEnd(7)} ${f.guideline} · App Store · ${f.title}`));
expect(b, f.title).toBeDefined();
expect(b!).toContain(`at ${f.where}`);
// the plain reason is an annotation, short enough to read
expect(f.why.split(' ').length, f.why).toBeLessThanOrEqual(10);
}
test('reject: the account-deletion finding, its own header and evidence line', () => {
const out = lines(before);
expect(out).toContain(` 2. ${T.REJECT.head}`);
expect(out).toContain(` ${T.REJECT.at}`);
expect(T.REJECT.head).toContain('5.1.1(v)');
});
test('rejections: the re-scan after the fixes (tests/fixtures/scan/teaser-fixed) is clean', () => {
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', () => {
@ -162,18 +172,22 @@ describe('teaser: the scan text is real', () => {
});
});
describe('teaser: the store text and screenshots are the demo game\'s', () => {
test('copy: the hero title per language from examples/demo-game/store/shots/copy.json', () => {
const copy = JSON.parse(readFileSync(join(ROOT, 'examples/demo-game', T.COPY_FILE), 'utf8')) as Record<string, { hero: { title: string[] } }>;
for (const c of T.COPY) expect(c.title).toEqual(copy[c.lang]!.hero.title);
expect(T.SHOT_LANGS).toEqual(T.COPY.map(c => c.lang));
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('sizes: seven store sizes the demo game renders (iPhone, iPad, Play phone and tablets, feature graphic)', () => {
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);
expect(T.SHOTS_PILL).toBe(`*7* sizes × *${T.COPY.length}* languages`);
expect(T.TODO.map(x => x.text)).toContain('7 screenshot sizes × 14 languages');
expect(T.TODO.map(x => x.text)).toContain('prices in 175 countries');
// 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.');
});
});
@ -184,40 +198,70 @@ describe('teaser: timing and format', () => {
expect(render).not.toMatch(/1080\s*x\s*1920|w: 1080, h: 1920|vertical/i);
expect(render).toContain('scale=1280:720');
});
test('scenes are contiguous, 4-5 s each (whole half seconds: each cut on a beat of music.js), 60-65 s in total', () => {
test('the story: nine beats in order, the montage in the middle, 56-62 s', () => {
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(56);
expect(T.DURATION).toBeLessThanOrEqual(62);
// the montage spans 19-37 s and speeds up (no beat longer than the one before)
const m = T.MONTAGE.map(id => T.SCENES.find(s => s.id === id)!);
expect(m[0]!.start).toBe(19);
expect(m.at(-1)!.end).toBe(37);
for (let i = 1; i < m.length; i++) expect(m[i]!.end - m[i]!.start).toBeLessThanOrEqual(m[i - 1]!.end - m[i - 1]!.start);
for (const s of m) expect(s.x, s.id).toBeLessThanOrEqual(0.15); // cuts, not dissolves
});
test('every cut lands on a beat of music.js (120 BPM: whole half seconds), scenes 3-6 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(s.end - s.start, s.id).toBeGreaterThanOrEqual(4);
expect(s.end - s.start, s.id).toBeLessThanOrEqual(5);
expect(Number.isInteger(s.start * 2), s.id).toBe(true);
expect(Number.isInteger(s.start / 0.5), s.id).toBe(true);
expect(s.end - s.start, s.id).toBeGreaterThanOrEqual(3);
expect(s.end - s.start, s.id).toBeLessThanOrEqual(6);
}
expect(T.DURATION).toBe(T.SCENES.at(-1)!.end);
expect(T.DURATION).toBeGreaterThanOrEqual(60);
expect(T.DURATION).toBeLessThanOrEqual(65);
const music = readFileSync(join(ROOT, 'media/teaser/music.js'), 'utf8');
expect(music).toContain('export const STEP = 0.5;');
for (const s of T.SCENES) expect(music, s.id).toMatch(new RegExp(`SCENE_CHORD = \\{[^}]*\\b${s.id}: '`));
// the fixes chime on beats too
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);
});
test('one caption per scene, fully on screen for at least 3 s, inside its scene', () => {
test('captions: at most one short line per scene, fully on screen for at least 2.5 s, inside its scene', () => {
const c = T.captionTimes();
expect(c.map(x => x.id)).toEqual(T.SCENES.map(s => s.id));
expect(new Set(c.map(x => x.id)).size).toBe(c.length);
for (const x of c) {
const s = T.SCENES.find(y => y.id === x.id)!;
expect(x.hold, x.text).toBeGreaterThanOrEqual(3);
expect(x.at).toBeGreaterThanOrEqual(s.start);
expect(x.to).toBeLessThanOrEqual(s.end);
expect(x.hold, x.text).toBeGreaterThanOrEqual(2.5);
expect(x.at, x.text).toBeGreaterThanOrEqual(s.start);
expect(x.gone, x.text).toBeLessThanOrEqual(s.end);
expect(x.text.split(' ').length, x.text).toBeLessThanOrEqual(8);
}
// the montage words sit at the edge, the story lines at the top; the click and the end card carry no caption
for (const x of c) expect(x.pos, x.id).toBe(T.MONTAGE.includes(x.id) ? 'edge' : 'top');
expect(c.map(x => x.id)).not.toContain('waiting');
expect(c.map(x => x.id)).not.toContain('end');
expect(c.map(x => x.text)).toEqual(['The game is done.', 'Then come the stores.', 'One missing line, and you start over.', 'So I handed it all to my AI.',
'Builds.', 'Screenshots, every size.', 'Products, 175 countries.', 'Forms.', 'Consoles.', 'Nothing left for a reviewer to catch.', 'One button is yours.']);
});
test('the rejection mail holds at least 2 s before the first cut', () => {
expect(T.SCENES[0]!.end - T.MAIL.at - T.MAIL_IN).toBeGreaterThanOrEqual(2);
expect(T.MAIL.guideline).toStartWith('Guideline 2.1');
test('the end card: the two lines on screen for at least 2.5 s', () => {
const e = T.SCENES.find(s => s.id === 'end')!;
expect(T.END_LINE).toBe('From an empty folder to Submit.');
expect(T.END_URL).toBe('Open source · storebound.dev');
expect(e.end - e.start - T.END.urlAt - T.END.d).toBeGreaterThanOrEqual(2.5);
});
test('the picture darkens over 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);
});
test('the pile speeds up: each card lands sooner after the one before', () => {
for (let i = 2; i < T.PILE.length; i++) expect(T.PILE[i]!.at - T.PILE[i - 1]!.at).toBeLessThanOrEqual(T.PILE[i - 1]!.at - T.PILE[i - 2]!.at + 1e-9);
});
});
// The desktop sequence: the real app (fixture mode) in the consoles scene and the last five, captured by
// media/teaser/desktop.ts. The frames are
// gitignored output; the file checks run wherever they were captured (render.ts captures them before every render).
// 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). */
@ -233,78 +277,48 @@ function jpegSize(buf: Buffer): { w: number; h: number } | null {
}
describe('teaser: the desktop scenes are real captures of the app', () => {
test('the app in the consoles scene and the last five: the plan panel, portfolio, charts, prices, resubmit, submit', () => {
expect(T.DESK_SCENES).toEqual(['consoles', 'portfolio', 'charts', 'catalog', 'resubmit', 'submit']);
const ids = T.SCENES.map(s => s.id);
expect(ids).toEqual(['hook', 'init', 'plan', 'store', 'products', 'forms', 'consoles', 'rejections', 'portfolio', 'charts', 'catalog', 'resubmit', 'submit']);
const cap = (id: string) => T.CAPTIONS.find(c => c.id === id)!.lines.join(' ').replace(/\*/g, '');
expect(cap('consoles')).toBe('No API? Your AI fills the console. It stops at Publish.');
expect(cap('resubmit')).toBe('Rejected? Fix it, resubmit in one click.');
expect(cap('submit')).toBe('You press Submit. That\'s it.');
// the terminal gives way to the app's plan panel, which comes in as the terminal goes
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.resubmit!.groups.map(g => g.frame)).toEqual(['resubmit']);
// submit: the dry-run plan with Continue, then waiting for review; the window fades out as the end card comes in
expect(T.DESK.submit!.groups.map(g => g.frame)).toEqual(['submit-plan', 'submit-waiting']);
expect(T.DESK.submit!.hide![0]).toBe(T.SUBMIT.logoAt);
expect(T.DESK.submit!.pointer!.target).toBe('oaContinue');
expect(T.DESK.resubmit!.pointer!.target).toBe('resubmitBtn');
// the v6 desktop scene (timeline, readiness) is gone: v7 is 62 s with resubmit and submit
expect(ids).not.toContain('desktop');
for (const id of T.DESK_SCENES) {
const s = T.SCENES.find(x => x.id === id)!;
expect(T.DESK[id]!.callouts.length, id).toBeGreaterThanOrEqual(1);
expect(T.DESK[id]!.callouts.length, id).toBeLessThanOrEqual(2);
// callouts follow one another, never two at once, and each holds long enough to read
const c = T.DESK[id]!.callouts;
for (let i = 0; i < c.length; i++) {
expect(Math.min(c[i]!.until, s.end - s.start) - c[i]!.at, `${id} ${c[i]!.text}`).toBeGreaterThanOrEqual(0.9);
if (i) expect(c[i]!.at, id).toBeGreaterThanOrEqual(c[i - 1]!.until);
}
}
expect(T.DESK_SWEEPS).toEqual(['growth', 'health']);
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 (desktop.ts serves `console plan apple-app-privacy --json`)', () => {
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']");
// never a drawn macOS dialog: the submit scene shows the app's own plan panel before the system dialog
const html = readFileSync(join(ROOT, 'media/teaser/index.html'), 'utf8');
expect(html).not.toMatch(/Submit for Review<span class="hov">|id="sbtn"/);
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}'\\)|sweep\\('${f}'`));
for (const s of T.DESK_SWEEPS) expect(src, s).toContain(`sweep('${s}'`);
// the picture shows only files from out/desktop, never a drawn stand-in
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/${x.g.frame}.jpg');
expect(js).toContain('out/desktop/${f.file}.jpg');
expect(js).toContain('out/desktop/${g.frame}.jpg');
});
test.if(captured)('the captured frames are present: 1280x800 @2x JPEGs, the sweeps one frame per hovered day', () => {
const boxes = JSON.parse(readFileSync(join(DESK_DIR, 'boxes.json'), 'utf8')) as Record<string, unknown> & { source: string; sweeps: Record<string, { file: string; px: number; day: string }[]> };
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');
const files = [...T.DESK_FRAMES];
for (const s of T.DESK_SWEEPS) {
const f = boxes.sweeps[s]!;
expect(f.length, s).toBeGreaterThanOrEqual(8);
expect(new Set(f.map(x => x.day)).size, s).toBe(f.length);
for (let i = 1; i < f.length; i++) expect(f[i]!.px).toBeGreaterThan(f[i - 1]!.px);
files.push(...f.map(x => x.file));
}
const sizes = new Set<number>();
for (const f of files) {
for (const f of T.DESK_FRAMES) {
const p = join(DESK_DIR, `${f}.jpg`);
expect(existsSync(p), p).toBe(true);
const buf = readFileSync(p);
expect(jpegSize(buf), f).toEqual({ w: 2560, h: 1600 });
sizes.add(buf.length);
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();
}
// every sweep frame differs from the others (the tooltip moved): no copied frames
expect(sizes.size).toBeGreaterThan(files.length * 0.9);
for (const id of T.DESK_SCENES) for (const c of T.DESK[id]!.callouts) expect(boxes[c.target], c.target).toBeDefined();
for (const id of T.DESK_SCENES) if (T.DESK[id]!.pointer) expect(boxes[T.DESK[id]!.pointer!.target], id).toBeDefined();
});
});