shots: app titles get the weights the templates use (variable axis, used range or static weights; font-weight warning), palette.bg as one colour or a CSS gradient string (validated; contrast guard in shots check), photo backgrounds (palette.image, shots.feature.background) with overlay and cover crop
This commit is contained in:
parent
2ba8278ab7
commit
3a11682a37
21 changed files with 713 additions and 44 deletions
141
tests/shots-look.test.ts
Normal file
141
tests/shots-look.test.ts
Normal file
|
|
@ -0,0 +1,141 @@
|
|||
// Real use (app style): a variable Google title family was downloaded as its 400 instance (thin headlines under
|
||||
// font-synthesis: none), a string palette.bg rendered white (white ink vanished), and photo backgrounds were missing.
|
||||
import { describe, expect, test } from 'bun:test';
|
||||
import { validateConfig } from '../cli/lib/config';
|
||||
import { palette } from '../cli/shots/lib';
|
||||
import { BG_ERROR, bgCss, bgIssue, contrastIssues, contrastRatio, coverCrop, featureBackgroundIssues, paletteIssues, parseColour, photoBackground, photoCss, photoIssues, worstContrast } from '../cli/shots/palette';
|
||||
import { axisCandidates, fetchFaces, localWeightGap, parseFaces, serves, templateWeights, weightIssues } from '../cli/shots/weights';
|
||||
import { weightsFor } from '../cli/shots/fonts';
|
||||
import { fontsCss, planFonts } from '../cli/shots/glyphs';
|
||||
|
||||
/** Google css2 mock: `ranges` = the wght range a variable family has, `statics` = the weights a static family has. */
|
||||
function google(o: { ranges?: [number, number]; statics?: number[] }) {
|
||||
const asked: string[] = [];
|
||||
const fn = async (url: string) => {
|
||||
asked.push(url);
|
||||
if (url.startsWith('https://fonts.gstatic.com/')) return { ok: true, status: 200, text: async () => '', arrayBuffer: async () => new TextEncoder().encode(url).buffer as ArrayBuffer };
|
||||
const axis = /family=[^:&]+(?::wght@([^&]+))?/.exec(decodeURIComponent(url))![1];
|
||||
const fail = { ok: false, status: 400, text: async () => 'bad', arrayBuffer: async () => new ArrayBuffer(0) };
|
||||
const face = (w: string) => `@font-face { font-family: 'X'; font-weight: ${w}; src: url(https://fonts.gstatic.com/x/${w.replace(' ', '-')}.woff2) format('woff2'); }\n`;
|
||||
const ok = (css: string) => ({ ok: true, status: 200, text: async () => css, arrayBuffer: async () => new ArrayBuffer(0) });
|
||||
if (!axis) return ok(face(String(o.statics?.[0] ?? 400)));
|
||||
const r = /^(\d+)\.\.(\d+)$/.exec(axis);
|
||||
if (r) return o.ranges && +r[1]! >= o.ranges[0] && +r[2]! <= o.ranges[1] ? ok(face(`${r[1]} ${r[2]}`)) : fail;
|
||||
const ws = axis.split(';').map(Number);
|
||||
const has = (w: number) => (o.ranges ? w >= o.ranges[0] && w <= o.ranges[1] : !!o.statics?.includes(w));
|
||||
return ws.every(has) ? ok(ws.map(w => face(String(w))).join('')) : fail;
|
||||
};
|
||||
return { fn, asked };
|
||||
}
|
||||
|
||||
describe('font weights', () => {
|
||||
test('the app templates ask the title for 700 / 750 and the body for 400-700', () => {
|
||||
const w = weightsFor('app');
|
||||
expect(w.title).toEqual([700, 750]);
|
||||
expect(w.body).toEqual(expect.arrayContaining([400, 500, 600, 700]));
|
||||
expect(templateWeights('<style>h1 { font-family: var(--tf); font-weight: var(--tw); }</style><script>R.setProperty(\'--tw\', 800);</script>').title).toEqual([800]);
|
||||
});
|
||||
test('candidates: full axis, the used range, then rounded statics', () => {
|
||||
expect(axisCandidates([700, 750])).toEqual(['wght@100..900', 'wght@700..750', 'wght@700;800']);
|
||||
expect(axisCandidates([400])).toEqual(['wght@100..900', 'wght@400']);
|
||||
});
|
||||
test('variable family with a 200..800 axis (Plus Jakarta Sans): gets the 700..750 range, not the 400 default', async () => {
|
||||
const g = google({ ranges: [200, 800] });
|
||||
const faces = await fetchFaces('Plus Jakarta Sans', 'Ab', { weights: [700, 750] }, g.fn);
|
||||
expect(faces.map(f => f.weight)).toEqual(['700 750']);
|
||||
expect(g.asked.filter(u => u.includes('googleapis')).map(u => decodeURIComponent(u).match(/:wght@([^&]+)/)?.[1])).toEqual(['100..900', '700..750']);
|
||||
expect(serves('700 750', 750)).toBe(true);
|
||||
});
|
||||
test('variable family with the full axis: one request', async () => {
|
||||
const g = google({ ranges: [100, 900] });
|
||||
expect((await fetchFaces('Inter', 'Ab', { weights: [700, 750] }, g.fn)).map(f => f.weight)).toEqual(['100 900']);
|
||||
});
|
||||
test('static family: the weights it has, one by one', async () => {
|
||||
const g = google({ statics: [400, 700] });
|
||||
const faces = await fetchFaces('Static Sans', 'Ab', { weights: [700, 750] }, g.fn);
|
||||
expect(faces.map(f => f.weight)).toEqual(['700']);
|
||||
const one = await fetchFaces('Paytone One', 'Ab', { weights: [900] }, google({ statics: [400] }).fn);
|
||||
expect(one.map(f => f.weight)).toEqual(['400']); // only the default instance exists
|
||||
});
|
||||
test('parseFaces and fontsCss declare each file with its own weight', async () => {
|
||||
expect(parseFaces("@font-face { font-weight: 200 800; src: url(https://a/b.woff2) }")).toEqual([{ weight: '200 800', url: 'https://a/b.woff2' }]);
|
||||
const m = await planFonts({ langs: { en: { title: 'Ab', body: 'Ab' } }, title: 'T', body: 'B', titleAxis: null, bodyAxis: null, titleWeights: [700, 750], bodyWeights: [400], fallbackTitleAxis: null },
|
||||
async job => job.weights && job.kind === 'title' ? { cps: new Set([65, 98]), files: [{ file: 'title-700.woff2', weight: '700' }, { file: 'title-800.woff2', weight: '800' }] } : new Set([65, 98]));
|
||||
const css = fontsCss(m);
|
||||
expect(css).toContain("font-family: 'T'; font-weight: 700; font-synthesis: none; src: url(title-700.woff2)");
|
||||
expect(css).toContain("font-family: 'T'; font-weight: 800;");
|
||||
expect(css).toContain("font-family: 'B'; font-weight: 100 900;");
|
||||
});
|
||||
test('local font: a declared weight that cannot serve the asked weight', () => {
|
||||
expect(localWeightGap('400', [700, 750])).toEqual([700, 750]);
|
||||
expect(localWeightGap('100 900', [700, 750])).toEqual([]);
|
||||
expect(localWeightGap(undefined, [700])).toEqual([]);
|
||||
});
|
||||
test('shots check warns when the title was drawn at another weight', () => {
|
||||
const w = weightIssues([{ el: 'title', family: 'Plus Jakarta Sans', want: 750, served: ['400'] }]);
|
||||
expect(w.map(i => [i.level, i.code])).toEqual([['warn', 'font-weight']]);
|
||||
expect(w[0]!.msg).toContain('weight 750');
|
||||
expect(weightIssues([{ el: 'title', family: 'Inter', want: 750, served: ['100 900'] }])).toEqual([]);
|
||||
expect(weightIssues([{ el: 'title', family: 'X', want: 750, served: [] }])).toEqual([]); // not our face: no claim
|
||||
});
|
||||
});
|
||||
|
||||
describe('palette bg', () => {
|
||||
test('stops, one colour and a gradient string', () => {
|
||||
expect(bgCss(['#111111', '#222222', '#333333'], 180, 55)).toBe('linear-gradient(180deg, #111111 0%, #222222 55%, #333333 100%)');
|
||||
expect(bgCss(['#111111', '#222222'], 135)).toBe('linear-gradient(135deg, #111111 0%, #222222 100%)');
|
||||
expect(bgCss('#15171C')).toBe('#15171C');
|
||||
expect(bgCss('linear-gradient(160deg, #0b1736, #1d4ed8)')).toBe('linear-gradient(160deg, #0b1736, #1d4ed8)');
|
||||
for (const ok of ['#15171C', 'rgb(10, 20, 30)', 'navy', 'radial-gradient(circle, #000, #333)', ['#000', '#111'], ['#000', '#111', '#222']]) expect(bgIssue(ok)).toBeNull();
|
||||
});
|
||||
test('anything else is the validation error, in config validation and when resolved', () => {
|
||||
for (const bad of ['', 'url(x.png)', ['#000'], ['#000', '#111', '#222', '#333'], 42, ['#000', 'not a colour!']]) expect(bgIssue(bad)).toBe(BG_ERROR);
|
||||
const pal = { bg: 'url(x.png)', glow: '#fff', ink: '#fff', shadow: 'rgba(0,0,0,.4)' };
|
||||
expect(() => palette(pal as never, 'app')).toThrow(BG_ERROR);
|
||||
const errs = validateConfig({ app: { name: 'X', profile: 'app' }, languages: [{ id: 'en' }],
|
||||
shots: { copy: 'c.json', frames: 'f', out: 'o', scenes: [{ id: 'a', palette: pal, frames: [{ src: 'a.png' }] }] } } as never).filter(e => e.startsWith('shots.'));
|
||||
expect(errs).toEqual([`shots.scenes[a].palette.bg: ${BG_ERROR} (got "url(x.png)")`]);
|
||||
expect(paletteIssues({ bg: '#15171C', glow: '#fff', ink: '#fff', shadow: '#000' }, 'p')).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('contrast guard', () => {
|
||||
test('white ink on white is caught, white on dark passes', () => {
|
||||
const white = parseColour('rgb(255, 255, 255)')!;
|
||||
expect(contrastRatio(white, [0, 0, 0])).toBeCloseTo(21, 0);
|
||||
const bad = worstContrast(white, [[21, 23, 28], [250, 250, 250]]);
|
||||
expect(bad.worst).toEqual([250, 250, 250]);
|
||||
const w = contrastIssues([{ el: 'title', ink: 'rgb(255, 255, 255)', worst: bad.worst, ratio: bad.ratio }]);
|
||||
expect(w.map(i => [i.level, i.code])).toEqual([['warn', 'contrast']]);
|
||||
expect(w[0]!.msg).toContain('close to invisible');
|
||||
const good = worstContrast(white, [[21, 23, 28], [30, 30, 40]]);
|
||||
expect(contrastIssues([{ el: 'title', ink: '#fff', worst: good.worst, ratio: good.ratio }])).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('photo backgrounds', () => {
|
||||
test('config: palette.image / overlay and shots.feature.background', () => {
|
||||
const p = { bg: '#000', glow: '#fff', ink: '#fff', shadow: '#000' };
|
||||
expect(paletteIssues({ ...p, image: 'store/art/x.png', overlay: 0.5 }, 'p')).toEqual([]);
|
||||
expect(paletteIssues({ ...p, image: 'x.gif', overlay: 2 }, 'p')).toEqual(['p.image: a PNG, JPEG or WebP path (relative to the config)', 'p.overlay: a number from 0 to 0.9 (dark overlay over the image)']);
|
||||
expect(featureBackgroundIssues('store/art/hero.jpg')).toEqual([]);
|
||||
expect(featureBackgroundIssues({ image: 'a.png', overlay: 0.3 })).toEqual([]);
|
||||
expect(featureBackgroundIssues({ overlay: 0.3 })).toHaveLength(1);
|
||||
expect(featureBackgroundIssues({ image: 'a.png', overlay: 1 })).toEqual(['shots.feature.background.overlay: a number from 0 to 0.9']);
|
||||
expect(photoBackground(p as never, 'hero.png')).toEqual({ image: 'hero.png', overlay: 0.45 });
|
||||
expect(photoBackground({ ...p, image: 'a.png', overlay: 0.2 } as never)).toEqual({ image: 'a.png', overlay: 0.2 });
|
||||
expect(photoBackground(p as never)).toBeNull();
|
||||
expect(photoCss('/bgimg/0', 0.45)).toBe("linear-gradient(rgba(0,0,0,0.45), rgba(0,0,0,0.45)), url('/bgimg/0') center / cover no-repeat");
|
||||
});
|
||||
test('cover crop: centred, the box aspect, scale > 1 = too small', () => {
|
||||
const c = coverCrop(2048, 2048, 1024, 500);
|
||||
expect(c.scale).toBe(0.5);
|
||||
expect([c.x, c.y, c.w, c.h]).toEqual([0, 524, 2048, 1000]);
|
||||
const t = coverCrop(1000, 2000, 1080, 1920);
|
||||
expect(t.w / t.h).toBeCloseTo(1080 / 1920, 6);
|
||||
expect(t.x).toBeCloseTo(0, 6);
|
||||
expect(photoIssues({ w: 2048, h: 1000 }, 'a.png', 1024, 500)).toEqual([]);
|
||||
expect(photoIssues({ w: 800, h: 400 }, 'a.png', 1024, 500)[0]!.msg).toContain('smaller than the 1024x500 output');
|
||||
expect(photoIssues(null, 'a.png', 1024, 500)[0]!.level).toBe('error');
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue