Files
feuerwerk/tests/ui/fonts.test.ts
T
adminandClaude Opus 5.5 f2282a81fb Stop loading the bold face the editor never draws in (#273)
The editor waits for every face in FACES before it starts. Source Sans 3
in 700 was one of them, but nothing in src/ sets a weight above 600, so
it cost a download and a wait for nothing.

- fonts.ts loads Source Sans 3 in 400, 500 and 600 plus Source Code Pro
  400. The 700 file (15 596 bytes) is gone from src/ui/fonts.
- A search for bold, font-weight and HTML headings in src/, web/*.html
  and the player found nothing that would fall back to 700.
- fonts.test.ts now checks both ways. A loaded Sans face must be set
  somewhere in src/, and every .woff2 in the font folder must be in
  FACES, so a stray file the glyph test would still read shows up too.
- The weight scan moved into usedWeights(), since two tests need it.

Tests:
- Gate on melektaus-atx: 197 files, 2856 tests (2855 + 1), exit 0.
- e2e 1-14: all passed, 492 checks, loaded faces ["400","500","600"].
- e2e 14 alone fails its two tab width checks, as known: it needs the
  documents 7-13 open. The font check was green there as well.
- dist/assets holds no 700 file after the build.
- Counter check: loading 700 again with its file fails the new
  "loads no face the editor never sets".
- Counter check: the file back without a FACES entry fails
  "... and no other" and names the file.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 21:27:46 +02:00

162 lines
6.6 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* The editor draws with the faces it ships (#202). A weight without a file falls back to the next
* one the browser has, and a character outside the Latin subset comes from the system font – in CI
* a different one than on the desk (#214). Both are held here against the files themselves.
*/
import { readdirSync, readFileSync } from 'node:fs';
import { join } from 'node:path';
import { brotliDecompressSync } from 'node:zlib';
import { describe, expect, it } from 'vitest';
import { FACES } from '../../src/ui/fonts';
const FONT_DIR = 'src/ui/fonts';
/** Every `.ts` file under `dir`. */
function sources(dir: string): string[] {
return readdirSync(dir, { withFileTypes: true }).flatMap((entry) => {
const path = join(dir, entry.name);
if (entry.isDirectory()) return sources(path);
return entry.name.endsWith('.ts') ? [path] : [];
});
}
/** The code of a file without its comments – a character in a comment is never drawn. */
function codeLines(file: string): { line: number; code: string }[] {
return readFileSync(file, 'utf8')
.split('\n')
.map((text, index) => ({ line: index + 1, text: text.trim() }))
.filter(({ text }) => !text.startsWith('//') && !text.startsWith('*') && !text.startsWith('/*'))
.map(({ line, text }) => ({ line, code: text.replace(/\s\/\/.*$/, '') }));
}
/** Reads a WOFF2 variable-length number (UIntBase128). */
function base128(bytes: Uint8Array, at: { i: number }): number {
let value = 0;
for (let n = 0; n < 5; n++) {
const byte = bytes[at.i++]!;
value = value * 128 + (byte & 0x7f);
if ((byte & 0x80) === 0) return value;
}
throw new Error('UIntBase128 longer than five bytes');
}
/** The code points a WOFF2 file maps to a glyph, read from its `cmap` table (format 4 and 12). */
function codePoints(file: string): Set<number> {
const bytes = new Uint8Array(readFileSync(file));
const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
expect(view.getUint32(0)).toBe(0x774f4632); // 'wOF2'
const numTables = view.getUint16(12);
const at = { i: 48 };
let cmap: { offset: number; length: number } | null = null;
let offset = 0;
for (let t = 0; t < numTables; t++) {
const flags = bytes[at.i++]!;
const tag = flags & 0x3f;
if (tag === 0x3f) at.i += 4;
const version = flags >> 6;
const origLength = base128(bytes, at);
// glyf (10) and loca (11) are transformed at version 0, every other table at any other version.
const transformed = tag === 10 || tag === 11 ? version === 0 : version !== 0;
const length = transformed ? base128(bytes, at) : origLength;
if (tag === 0) cmap = { offset, length };
offset += length;
}
const tables = brotliDecompressSync(bytes.subarray(at.i, at.i + view.getUint32(20)));
if (!cmap) throw new Error(`${file} has no cmap`);
const table = new DataView(tables.buffer, tables.byteOffset + cmap.offset, cmap.length);
const found = new Set<number>();
for (let s = 0; s < table.getUint16(2); s++) {
const sub = table.getUint32(4 + s * 8 + 4);
const format = table.getUint16(sub);
if (format === 12) {
for (let g = 0; g < table.getUint32(sub + 12); g++) {
const start = table.getUint32(sub + 16 + g * 12);
const end = table.getUint32(sub + 20 + g * 12);
for (let c = start; c <= end; c++) found.add(c);
}
} else if (format === 4) {
const segments = table.getUint16(sub + 6) / 2;
const ends = sub + 14;
const starts = ends + segments * 2 + 2;
const deltas = starts + segments * 2;
const ranges = deltas + segments * 2;
for (let g = 0; g < segments; g++) {
const start = table.getUint16(starts + g * 2);
const end = table.getUint16(ends + g * 2);
const delta = table.getUint16(deltas + g * 2);
const range = table.getUint16(ranges + g * 2);
for (let c = start; c <= end && c !== 0xffff; c++) {
const glyph =
range === 0 ? (c + delta) & 0xffff : table.getUint16(ranges + g * 2 + range + (c - start) * 2);
if (glyph !== 0) found.add(c);
}
}
}
}
return found;
}
/** The weights `src/` sets as `fontWeight`, plus the 400 a style without one draws in. */
function usedWeights(): Set<string> {
const used = new Set(['400']);
for (const file of sources('src'))
for (const [, setting] of readFileSync(file, 'utf8').matchAll(/fontWeight:([^,\n}]*)/g))
for (const [, weight] of setting!.matchAll(/'(\d{3}|bold|normal)'/g))
used.add(weight === 'bold' ? '700' : weight === 'normal' ? '400' : weight!);
return used;
}
describe('editor fonts', () => {
const loaded = new Set(
FACES.filter(([family]) => family === 'Source Sans 3').map(([, , weight]) => weight),
);
it('loads a face for every weight the editor sets (#214)', () => {
const used = usedWeights();
expect(used.has('500')).toBe(true);
for (const weight of used) expect(loaded, `weight ${weight}`).toContain(weight);
});
// Every face is waited for before the editor starts (#273).
it('loads no face the editor never sets', () => {
const used = usedWeights();
for (const weight of loaded) expect(used, `weight ${weight}`).toContain(weight);
});
it('ships a file for every face it loads, and no other', () => {
const files = readdirSync(FONT_DIR).filter((f) => f.endsWith('.woff2'));
for (const [, url, weight] of FACES)
expect(
files.some((f) => url.endsWith(f)),
`${url} (${weight})`,
).toBe(true);
for (const file of files)
expect(
FACES.some(([, url]) => url.endsWith(file)),
file,
).toBe(true);
});
it('reads the Latin subset from the files themselves', () => {
const sans = codePoints(join(FONT_DIR, 'source-sans-3-latin-400-normal.woff2'));
expect(sans.has('A'.charCodeAt(0))).toBe(true);
expect(sans.has('é'.charCodeAt(0))).toBe(true);
expect(sans.has('—'.charCodeAt(0))).toBe(true);
expect(sans.has('▶'.charCodeAt(0))).toBe(false);
});
// A symbol the subset lacks is drawn from whatever the system has – so it is an SVG icon instead.
it('has every character the editor writes in each shipped face of its UI font (#214)', () => {
const faces = readdirSync(FONT_DIR).filter((f) => f.startsWith('source-sans-3-'));
const covered = faces.map((f) => codePoints(join(FONT_DIR, f)));
const missing: string[] = [];
for (const file of sources('src'))
for (const { line, code } of codeLines(file))
for (const char of new Set(code))
if (char.codePointAt(0)! > 0x7e && covered.some((set) => !set.has(char.codePointAt(0)!)))
missing.push(`${file}:${line} ${char}`);
expect(missing).toEqual([]);
});
});