shots: feature rotate, per-language frame fallback, layout-aware capture selectors

This commit is contained in:
CRTkafa 2026-10-24 13:00:48 +08:00
parent 2e1c7cdc3c
commit 61b5fa0623
15 changed files with 355 additions and 17 deletions

View file

@ -63,6 +63,8 @@ describe('shots.feature config', () => {
expect(errs({ frame: 'a.png' })).toEqual([]);
expect(errs({ frame: 'a.png', fit: 'bleed', scale: 1.2 })).toEqual([]);
expect(errs({ frame: 'a.png', fit: 'cover' })).toEqual(["shots.feature.fit: 'contain' | 'bleed'"]);
expect(errs({ frame: 'a.png', rotate: -90 })).toEqual([]);
expect(errs({ frame: 'a.png', rotate: 'left' })).toEqual(['shots.feature.rotate: a number of degrees from -360 to 360']);
expect(errs({ frame: 'a.png', scale: 3 })).toEqual(['shots.feature.scale: a number from 0.5 to 1.5']);
});
});
@ -106,3 +108,41 @@ describe.skipIf(!browser)('feature graphic templates at 1024 x 500 (DOM layout b
} finally { await page.close(); }
}, scaled(30_000));
});
describe.skipIf(!browser)('shots.feature.rotate', () => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const route = (page: any, html: string, name: string, job: unknown) => page.route('http://t.test/**', (r: { request(): { url(): string }; fulfill(o: unknown): Promise<void> }) => {
const u = new URL(r.request().url());
if (u.pathname === `/${name}`) return r.fulfill({ contentType: 'text/html', body: html });
if (u.pathname === '/job.json') return r.fulfill({ contentType: 'application/json', body: JSON.stringify(job) });
if (u.pathname.startsWith('/frames/')) return r.fulfill({ contentType: 'image/png', body: PNG });
return r.fulfill({ contentType: 'text/css', body: '' });
});
const pal = { bg: ['#123456', '#345678', '#567890'], glow: '#ffffff', ink: '#000000', shadow: 'rgba(0,0,0,.4)' };
test('game template: a -90 phone lies on its side and still fits', async () => {
const page = await browser.newPage({ viewport: { width: 1024, height: 500 } });
try {
const device = featureLayout('game', DEFAULT_PHONE, { rotate: -90 });
expect(device.angle).toBe(-83);
await route(page, readFileSync(join(import.meta.dir, '..', '..', 'cli', 'shots', 'feature.html'), 'utf8'), 'feature.html',
{ style: 'game', phone: DEFAULT_PHONE, logo: false, appName: 'Demo', fonts: { title: 'X' }, frame: 'a.png', device, palette: pal });
await page.goto('http://t.test/feature.html?lang=en');
await page.waitForFunction("document.body.dataset.ready === '1'", null, { timeout: 15000 });
const d = (JSON.parse(await page.evaluate('document.body.dataset.devices')) as { x: number; y: number; w: number; h: number }[])[0]!;
expect(d.w).toBeGreaterThan(d.h); // landscape outline
expect(boxOutside(d, 1024, 500)).toEqual([]);
} finally { await page.close(); }
}, scaled(30_000));
test('bleed template: the frame image is turned and still covers the canvas', async () => {
const page = await browser.newPage({ viewport: { width: 1024, height: 500 } });
try {
await route(page, readFileSync(join(import.meta.dir, '..', '..', 'cli', 'shots', 'feature-bleed.html'), 'utf8'), 'feature-bleed.html',
{ fonts: { title: 'X' }, frame: 'a.png', appName: 'Demo', rotate: -90 });
await page.goto('http://t.test/feature-bleed.html?lang=en');
await page.waitForFunction("document.body.dataset.ready === '1'", null, { timeout: 15000 });
const r = await page.evaluate("(() => { const b = document.querySelector('img.frame').getBoundingClientRect(); return { x: b.x, y: b.y, w: b.width, h: b.height }; })()") as { x: number; y: number; w: number; h: number };
expect(Math.round(r.w)).toBe(1024); expect(Math.round(r.h)).toBe(500); expect(Math.round(r.x)).toBe(0); expect(Math.round(r.y)).toBe(0);
expect(await page.evaluate("document.querySelector('img.frame').style.transform")).toBe('rotate(-90deg)');
} finally { await page.close(); }
}, scaled(30_000));
});

91
tests/shots-gaps.test.ts Normal file
View file

@ -0,0 +1,91 @@
// Real-use gaps in `shots`: feature rotate, check/render frame fallback per language, layout-aware capture selectors.
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 { DEFAULT_PHONE, featureLayout, frameLangs, langHasFrames } from '../cli/shots/lib';
import { applySelectors, candidates, missMessage, probe, validateSelector, type SelPage } from '../cli/shots/selectors';
import { validateCapture } from '../cli/shots/devices';
describe('feature rotate', () => {
test('adds to the template tilt and still fits', () => {
const a = featureLayout('game', DEFAULT_PHONE, {}), b = featureLayout('game', DEFAULT_PHONE, { rotate: -90 });
expect(a.angle).toBe(7); expect(b.angle).toBe(-83);
expect(b.box.w).toBeGreaterThan(b.box.h);
expect(b.box.x).toBeGreaterThanOrEqual(23.99); expect(b.box.x + b.box.w).toBeLessThanOrEqual(1024 - 23.99);
expect(b.box.y).toBeGreaterThanOrEqual(23.99); expect(b.box.y + b.box.h).toBeLessThanOrEqual(500 - 23.99);
});
});
describe('frame languages', () => {
const has = (set: string[]) => (l: string) => set.includes(l);
test('a language without frames borrows the default language', () => {
expect(frameLangs(['en', 'tr', 'de'], has(['en', 'de']), 'en')).toEqual({ map: { en: 'en', tr: 'en', de: 'de' }, borrowed: ['tr'] });
});
test('perLanguage requires its own', () => {
expect(frameLangs(['en', 'tr'], has(['en']), 'en', true)).toEqual({ map: { en: 'en', tr: 'tr' }, borrowed: [] });
});
test('no frames anywhere: nothing borrowed, the missing error names the language', () => {
expect(frameLangs(['en', 'tr'], has([]), 'en').borrowed).toEqual([]);
});
test('langHasFrames reads the folder', () => {
const d = mkdtempSync(join(tmpdir(), 'sb-fl-'));
try {
mkdirSync(join(d, 'en', 'phone'), { recursive: true }); writeFileSync(join(d, 'en', 'phone', 'a.png'), 'x');
mkdirSync(join(d, 'tr'), { recursive: true });
expect(langHasFrames(d, 'en')).toBe(true);
expect(langHasFrames(d, 'tr')).toBe(false);
expect(langHasFrames(d, 'de')).toBe(false);
} finally { rmSync(d, { recursive: true, force: true }); }
});
});
/** Fake page: a DOM of selector -> count; text= selectors are just keys. */
function fake(dom: Record<string, number>, clicks: string[] = []): SelPage {
return {
locator: (s: string) => ({ count: async () => { if (s.startsWith('!')) throw new Error('bad'); return dom[s] ?? 0; }, first: () => ({ click: async () => { clicks.push(s); } }) }),
waitForTimeout: async () => {},
};
}
describe('capture selectors', () => {
test('per-device list first, then shared, no repeats', () => {
const s = { phone: ['[data-shot="row"]'], tablet: ['[data-shot="tile"]', 'text=Shop'], any: ['text=Shop'] };
expect(candidates(s, 'tablet')).toEqual(['[data-shot="tile"]', 'text=Shop']);
expect(candidates(s, 'phone')).toEqual(['[data-shot="row"]', 'text=Shop']);
expect(candidates('a', 'phone')).toEqual(['a']);
expect(candidates(undefined, 'phone')).toEqual([]);
});
test('validation', () => {
expect(validateSelector('x', 'a')).toEqual([]);
expect(validateSelector('x', { tablet: ['a'] })).toEqual([]);
expect(validateSelector('x', { desktop: 'a' })[0]).toContain('unknown key desktop');
expect(validateSelector('x', '')).not.toEqual([]);
expect(validateSelector('x', {})).not.toEqual([]);
});
test('the click lands on the first candidate that matches', async () => {
const clicks: string[] = [];
await applySelectors(fake({ 'text=Shop': 1 }, clicks), { waitSelector: ['[data-shot="row"]', 'text=Shop'], click: { tablet: ['[data-shot="tile"]', 'text=Shop'] } }, { device: 'tablet', w: 1180, h: 820 }, 50);
expect(clicks).toEqual(['text=Shop']);
});
test('several clicks in order', async () => {
const clicks: string[] = [];
await applySelectors(fake({ a: 1, b: 1 }, clicks), { click: [{ any: 'a' }, 'b'] }, { device: 'phone', w: 440, h: 902 }, 50);
expect(clicks).toEqual(['a', 'b']);
});
test('a miss names the selector, the size and every match count', async () => {
const err = await applySelectors(fake({ '[data-shot="row"]': 3 }), { click: { tablet: ['[data-shot="tile"]', 'text=Shop'] } }, { device: 'tablet', w: 1180, h: 820 }, 30).then(() => '', e => (e as Error).message);
expect(err).toContain('click selector missed on tablet 1180x820');
expect(err).toContain('[data-shot="tile"] matched 0');
expect(err).toContain('text=Shop matched 0');
expect(missMessage('click', { device: 'phone', w: 1, h: 2 }, [{ selector: '!x', matches: -1 }])).toContain('is not a valid selector');
});
test('probe counts every candidate', async () => {
expect(await probe(fake({ a: 2 }), ['a', 'b'])).toEqual([{ selector: 'a', matches: 2 }, { selector: 'b', matches: 0 }]);
});
test('validateCapture accepts and rejects', () => {
const cap = (shot: object) => ({ source: 'web', baseUrl: 'http://127.0.0.1:1', shots: { shop: { path: '/', ...shot } } }) as never;
expect(validateCapture(cap({ waitSelector: { tablet: 'a' }, click: ['a', 'b'] }))).toEqual([]);
expect(validateCapture(cap({ click: { desktop: 'a' } }))[0]).toContain('unknown key desktop');
});
});