fonts.test.ts
Language TypeScript5.9 KB127 lines
/*** The display face has to be able to spell everyone's name.** A face subset to "A-Z 0-9 and the marks" renders "Rosa Galván" in two* typefaces: the display face and one á in a fallback, in a 26px heading. A* missing glyph is hard to see in review unless you are looking at that one* character and know what the face should look like, and from outside it reads* as an encoding problem while the bytes are right.** What this pins, and what it cannot: it checks names against the DECLARED* ranges in fonts.ts, not against the font binary: reading a brotli-compressed* woff2 cmap needs fonttools, and this suite runs offline with no dependency* the repo does not already carry. So the guard is only as true as the* declaration is kept in step with the pyftsubset command beside it. That is a* real limit and it is written down rather than papered over, but it still* catches the likely case, which is a person arriving with a character nobody* thought about.*/import { describe, expect, it } from 'vitest'import {FONT_FACES,DISPLAY_SUBSET_RANGES,inBodySubset,inDisplaySubset,} from '../src/fonts.js'import { THEME_CSS } from '../src/theme.js'import { MERIDIAN_PEOPLE } from '../../../config/meridian-people.js'import { ONBOARDING } from '../../../config/onboarding.js'/** Everything the display face is actually asked to draw. */const displayStrings = (): { what: string; text: string }[] => [...MERIDIAN_PEOPLE.map((p) => ({ what: `roster name ${p.name}`, text: p.name })),...ONBOARDING.flatMap((p) => [{ what: `plan subject ${p.person}`, text: p.person },{ what: `plan role ${p.role}`, text: p.role },...(p.trainedBy ? [{ what: `trainer ${p.trainedBy}`, text: p.trainedBy }] : []),...p.days.map((d) => ({ what: `day title "${d.title}"`, text: d.title })),...p.days.flatMap((d) =>d.sections.map((sec) => ({ what: `topic "${sec.title}"`, text: sec.title })),),]),]const hex = (ch: string): string =>`U+${ch.codePointAt(0)!.toString(16).toUpperCase().padStart(4, '0')}`/** A letter has to be in the display face itself; punctuation may fall through. */const isLetter = (ch: string): boolean => /\p{L}|\p{M}/u.test(ch)describe('every name the display face has to render', () => {it('has every LETTER in the display face itself, in both cases', () => {// Both cases: the eyebrow uppercases with CSS, so a lowercase á in the data// becomes Á on the page and it is the UPPERCASE glyph that has to exist.// Subsetting the one that appears in the source file is half a fix.//// Letters only, because a letter is the one thing that CANNOT fall back// gracefully: one Georgia á inside twenty display-face capitals is the bug this// file is named for. Punctuation is handled by the next test.for (const { what, text } of displayStrings()) {for (const ch of [...text, ...text.toUpperCase(), ...text.toLowerCase()]) {if (!isLetter(ch)) continueexpect(inDisplaySubset(ch),`${what}: "${ch}" (${hex(ch)}) is not in the display face subset, so it renders in Georgia`,).toBe(true)}}})it('has everything else in the body face, which is what the stack falls through to', () => {// The display face has no em dash, en dash, ellipsis or arrow: the glyphs are// absent from the font, so no subset can add them. Every "Name: heading"// on this board drew its dash in Georgia until the stack gained the body face.for (const { what, text } of displayStrings()) {for (const ch of text) {if (isLetter(ch)) continueexpect(inDisplaySubset(ch) || inBodySubset(ch),`${what}: "${ch}" (${hex(ch)}) is in neither face, so it falls to Georgia`,).toBe(true)}}})it('puts the body face ahead of Georgia in the display stack', () => {// The whole previous test is worthless if the stack does not actually// reach the body face before the serif.const stack = THEME_CSS.match(/--font-display:([^;]+);/)![1]!expect(stack.indexOf('Inter')).toBeGreaterThan(stack.indexOf('Anton'))expect(stack.indexOf('Georgia')).toBeGreaterThan(stack.indexOf('Inter'))})it('covers Galván and Ibáñez specifically, because those are the two on the roster today', () => {for (const ch of 'áÁñÑ') expect(inDisplaySubset(ch), ch).toBe(true)})it('has not been narrowed back to a list of the accents we happen to use', () => {// The fix was a RANGE, deliberately. A list of the characters currently on// the roster is the same decision that caused the bug, one hire later: it// would have carried Galván and still broken on a François or a João.for (const ch of 'çÇãÃõÕüÜøØåÅßæÆéÉ') expect(inDisplaySubset(ch), ch).toBe(true)})it('still excludes what it never needed, so the range is a decision and not a shrug', () => {// The U+00A0-00BF symbol block is out on purpose: a display heading has no// use for a section mark, and it is ~2KB of base64 on every page.for (const ch of '§¶©«»¿¡') expect(inDisplaySubset(ch), ch).toBe(false)})})describe('the embedded faces', () => {it('ships all three, as data URIs, because nothing else can reach them', () => {// CSP is default-src 'none' and the page is self-contained; a @font-face// pointing at a repo-relative file rendered in Georgia in production for// the entire time the board had been live.expect(FONT_FACES).toContain('font-family:"Anton"')expect((FONT_FACES.match(/font-family:"Inter"/g) ?? []).length).toBe(2)expect((FONT_FACES.match(/data:font\/woff2;base64,/g) ?? []).length).toBe(3)expect(FONT_FACES).not.toMatch(/url\((?!data:)/)})it('declares ranges that are ordered and non-empty', () => {expect(DISPLAY_SUBSET_RANGES.length).toBeGreaterThan(0)for (const [lo, hi] of DISPLAY_SUBSET_RANGES) expect(hi).toBeGreaterThanOrEqual(lo)})})