// 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('').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'); }); });