Files
reader/web/test/icons.test.ts
hectorqin fbb6dc65cf feat(ui): one Sheet for every overlay; drop 来源与刮削
- ui/sheet.tsx Sheet/ConfirmSheet replace the centred Modal, SweetAlert confirms, DialogView overlays, the reader Panel, the shelf settings floating card, the two media dialogs and Mantine Modal.
- Phones: bottom sheet. Wide windows: right-edge drawer; compact sheets (confirm, prompt, pick) open next to the tap or focused control.
- One set of exits (close, Escape, backdrop), 关闭 label, focus starts on 取消, danger confirms.
- Remove sweetalert2 and the unused @mantine/notifications.
- Remove the 来源与刮削 media settings panel and its entries in 我的 and 影音设置; TMDB and MusicBrainz stay configurable in 系统设置 > 服务配置.
- docs/ui.md 3.7 describes the rule; tests and review scripts follow the new markup.
2026-10-21 07:30:27 +08:00

430 lines
21 KiB
TypeScript
Raw Permalink 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.
import { render } from '../src/shared/ui/render-root.ts';
// @vitest-environment jsdom
import { readFileSync } from 'node:fs';
import { join } from 'node:path';
import { describe, expect, it } from 'vitest';
import { ICON_ALIASES, ICON_CODEPOINTS, ICON_CODEPOINT_TABLE } from '../src/ui/icon-names.ts';
import { GLYPHS } from '../tools/icons/paths.mjs';
import { pathToSubpaths } from '../tools/icons/path.mjs';
import { Icon } from '../src/ui/icon.tsx';
import { IconButton } from '../src/ui/toolkit.tsx';
/**
* The icon set's contract with the font.
*
* These are the parts that cannot be caught by the type system and would fail
* *silently* in the browser: a code point the font does not define renders as a
* missing-glyph box, and a glyph name in the table that has no path in the source
* means the font and the table were built from different inputs. Both look like a
* stray square in a toolbar, which is easy to miss and impossible to trace back.
*
* The font is parsed here by reading its `cmap` directly rather than by embedding a
* font parser: the table is small, and a test that depends on a font library is a
* test that breaks when the library does.
*/
/** Reads the format-4 `cmap` of the bundled font and returns the code points it maps. */
function fontCodePoints(): Set<number> {
const font = readFileSync(join(process.cwd(), 'src/styles/reader-icons.ttf'));
const numTables = font.readUInt16BE(4);
let cmapOffset = 0;
for (let i = 0; i < numTables; i++) {
const entry = 12 + i * 16;
if (font.toString('ascii', entry, entry + 4) === 'cmap') cmapOffset = font.readUInt32BE(entry + 8);
}
expect(cmapOffset).toBeGreaterThan(0);
// Only one encoding record is written: Windows / Unicode BMP.
const subtable = cmapOffset + font.readUInt32BE(cmapOffset + 8);
expect(font.readUInt16BE(subtable)).toBe(4); // format 4
const segCount = font.readUInt16BE(subtable + 6) / 2;
const endBase = subtable + 14;
const startBase = endBase + segCount * 2 + 2;
const deltaBase = startBase + segCount * 2;
const mapped = new Set<number>();
for (let segment = 0; segment < segCount; segment++) {
const end = font.readUInt16BE(endBase + segment * 2);
const start = font.readUInt16BE(startBase + segment * 2);
const delta = font.readInt16BE(deltaBase + segment * 2);
for (let code = start; code <= end && code !== 0xffff; code++) {
// Every segment here maps one code to one glyph by addition.
if (((code + delta) & 0xffff) !== 0) mapped.add(code);
}
}
return mapped;
}
describe('the icon font and its code point table', () => {
const mapped = fontCodePoints();
const table = Object.values(ICON_CODEPOINT_TABLE).map((value) => value.codePointAt(0)!);
it('maps every code point the table declares', () => {
// A code point in the table that the font does not map is a missing-glyph box.
for (const code of table) {
expect(mapped.has(code), `0x${code.toString(16)} is not in the font cmap`).toBe(true);
}
});
it('does not map anything outside the private use area', () => {
// The set has to be unreachable by typing: a font that also mapped ASCII would
// turn ordinary text into icons wherever the family is applied.
for (const code of mapped) {
expect(code, `0x${code.toString(16)} is outside the private use area`).toBeGreaterThanOrEqual(0xe000);
expect(code).toBeLessThanOrEqual(0xf8ff);
}
});
it('has one code point per icon, with no duplicates', () => {
// Counted on the *distinct* code points rather than on the table's length,
// because the table carries aliases: `font` and `text-size` are two spellings
// of one glyph, and a set that maps a code point twice is a rename, not a
// second icon. What has to hold either way is that the font draws exactly the
// code points the table names — no more, no fewer.
const distinct = new Set(table);
expect(distinct.size).toBe(Object.keys(ICON_CODEPOINTS).length);
expect(mapped.size).toBe(distinct.size);
});
it('was built from the same glyph names as the table', () => {
// Catches the case where the font is rebuilt from edited paths but the table is
// stale (or the reverse): the names are the join between the two.
const tableNames = Object.keys(ICON_CODEPOINTS).sort();
const sourceNames = Object.keys(GLYPHS).sort();
expect(tableNames).toEqual(sourceNames);
});
it('does not give two different icons the same path', () => {
// A duplicated path is a wrong icon that no other assertion here can see: the
// name is in the table, the code point maps, the glyph has ink and fits the em —
// and it draws the other icon. `chevron-right` was exactly this, byte for byte
// the same as `chevron-left`, so the "next chapter" button pointed backwards.
const byPath = new Map<string, string[]>();
for (const [name, path] of Object.entries(GLYPHS as Record<string, string>)) {
const key = path.replace(/\s+/g, '');
byPath.set(key, [...(byPath.get(key) ?? []), name]);
}
const duplicates = [...byPath.values()].filter((names) => names.length > 1);
expect(duplicates, `these icons share a path: ${duplicates.map((n) => n.join(' = ')).join('; ')}`).toEqual([]);
});
it('points the two chevrons in opposite directions', () => {
// The mirror is what makes this pair useful; two left chevrons are a working
// glyph and a broken control. Measured on the flattened polylines rather than
// on the strings, because "which way does it point" is a fact about the
// geometry and a regex over the path data is not that fact.
const points = (name: 'chevron-left' | 'chevron-right') =>
pathToSubpaths((GLYPHS as Record<string, string>)[name]!).flat();
const tip = (name: 'chevron-left' | 'chevron-right') => {
const xs = points(name).map((p) => p[0]);
// The tip is the extreme point along the direction the chevron points.
return name === 'chevron-left' ? Math.min(...xs) : Math.max(...xs);
};
const back = (name: 'chevron-left' | 'chevron-right') => {
const xs = points(name).map((p) => p[0]);
return name === 'chevron-left' ? Math.max(...xs) : Math.min(...xs);
};
// A chevron's tip is its leading edge; for it to point left, the leftmost x
// has to be the tip and the two back corners have to be further right.
expect(tip('chevron-left'), 'chevron-left points left').toBeLessThan(back('chevron-left'));
expect(tip('chevron-right'), 'chevron-right points right').toBeGreaterThan(back('chevron-right'));
// And the two are a matched pair: the same span, mirrored about the centre.
const span = (name: 'chevron-left' | 'chevron-right') => {
const xs = points(name).map((p) => p[0]);
return Math.max(...xs) - Math.min(...xs);
};
expect(span('chevron-right')).toBeCloseTo(span('chevron-left'), 5);
});
/*
* The names are the product's, and they have to stop claiming to be someone
* else's.
*
* The set borrowed Font Awesome 6's class names verbatim for one round of the
* review (#40), on the theory that a shared vocabulary stops "the magnifier" from
* having three spellings. What it actually did was put a *known* name on unknown
* artwork: a reader who knows that set opens `paths.mjs` expecting the glyph the
* name promises, and gets ours. The names were reverted to the plain English the
* rest of this codebase already speaks, and this is what keeps them there — a
* one-line assertion, because the failure is a name quietly drifting back to the
* borrowed list rather than a shape being wrong.
*/
const BORROWED = [
'bars', 'magnifying-glass', 'xmark', 'file-lines', 'file-arrow-up', 'file-arrow-down',
'trash-can', 'arrows-rotate', 'table-columns', 'circle-info', 'triangle-exclamation',
'folder-open', 'ellipsis', 'power-off', 'right-from-bracket', 'circle-plus',
'backward-step', 'forward-step', 'volume-high', 'gear', 'books', 'pen',
'arrow-down-wide-short', 'up-down', 'font',
];
it('does not borrow another icon set’s names', () => {
const names = Object.keys(GLYPHS);
const borrowed = names.filter((name) => BORROWED.includes(name));
expect(borrowed, `these names are another set's: ${borrowed.join(', ')}`).toEqual([]);
// The *table* may keep a borrowed spelling as an alias so that code written
// against the set this repository is based on keeps compiling. What it may not
// do is add a glyph under one: a borrowed name on our own artwork is what the
// list above is about.
const aliasNames = Object.keys(ICON_ALIASES);
const borrowedAliases = aliasNames.filter((name) => BORROWED.includes(name));
expect(borrowedAliases.length, `an alias is a borrowed name: ${borrowedAliases.join(', ')}`).toBeGreaterThan(0);
});
it('points every alias at a code point the font already draws', () => {
// An alias is a second *spelling*, not a second glyph: two names for one code
// point is a rename, a name for a code point nothing draws is a missing-glyph
// box that no other assertion here can see.
const drawn = new Set(Object.values(ICON_CODEPOINTS));
for (const [name, code] of Object.entries(ICON_ALIASES)) {
expect(drawn.has(code), `${name} points at 0x${code.codePointAt(0)!.toString(16)}, which no glyph uses`).toBe(true);
}
});
it('names every glyph after the thing it draws, in the vocabulary the UI uses', () => {
// A glyph whose name has to be *looked up* is a glyph nobody adds correctly. The
// list is the whole vocabulary, so adding an icon is a deliberate act rather than
// a free-form string that happens to compile.
const VOCABULARY = [
'add-circle', 'arrow-left', 'book', 'check', 'chevron-left', 'chevron-right',
'clock', 'close', 'download', 'edit', 'eye', 'file-text', 'folder', 'indent',
'info', 'library', 'line-height', 'logout', 'menu', 'moon', 'more', 'pause',
'play', 'plus', 'refresh', 'search', 'settings', 'shelf', 'sign-out', 'sliders',
'sort', 'step-backward', 'step-forward', 'stop', 'sun', 'text-size', 'trash',
'tune', 'upload', 'volume', 'warning',
];
expect(Object.keys(GLYPHS).sort()).toEqual(VOCABULARY);
});
it('assigns code points in the same order as the source, starting at 0xE900', () => {
const names = Object.keys(GLYPHS);
names.forEach((name, index) => {
const expected = 0xe900 + index;
expect((ICON_CODEPOINT_TABLE as Record<string, string>)[name]!.codePointAt(0)).toBe(expected);
});
});
});
/**
* The font's vertical metrics.
*
* These are the numbers that decide whether an icon is *aligned*, and they are the ones
* this file did not check while the icons were half an em out of place: `cmap` was
* verified, the glyph names were verified, and the metrics — the only part a layout reads
* — were not.
*
* The contract has two halves and both are needed:
*
* - the ascent and descent must be equal, because a `1em` box is symmetric and every
* container in the UI centres a box. Asymmetric metrics make "centre the box" and
* "centre the glyph" two different operations, and the difference is exactly the
* asymmetry — which is how the icons were misaligned by a different amount in every
* row of the product.
* - no glyph's ink may leave that window. A glyph that reaches outside the em paints
* outside the box the layout aligned to, which is the same defect by another route,
* and it is invisible to every check that only reads a table.
*/
function fontTables() {
const font = readFileSync(join(process.cwd(), 'src/styles/reader-icons.ttf'));
const numTables = font.readUInt16BE(4);
const offsets: Record<string, number> = {};
for (let i = 0; i < numTables; i++) {
const entry = 12 + i * 16;
offsets[font.toString('ascii', entry, entry + 4)] = font.readUInt32BE(entry + 8);
}
const unitsPerEm = font.readUInt16BE(offsets.head + 18);
const numGlyphs = font.readUInt16BE(offsets.maxp + 4);
return {
font,
offsets,
unitsPerEm,
numGlyphs,
hheaAscent: font.readInt16BE(offsets.hhea + 4),
hheaDescent: font.readInt16BE(offsets.hhea + 6),
typoAscent: font.readInt16BE(offsets['OS/2'] + 68),
typoDescent: font.readInt16BE(offsets['OS/2'] + 70),
winAscent: font.readUInt16BE(offsets['OS/2'] + 74),
winDescent: font.readUInt16BE(offsets['OS/2'] + 76),
selection: font.readUInt16BE(offsets['OS/2'] + 62),
weightClass: font.readUInt16BE(offsets['OS/2'] + 4),
};
}
describe('the font metrics', () => {
it('declares a symmetric window, so a centred box is a centred glyph', () => {
const t = fontTables();
// The em's middle is the line the glyphs are drawn around, and the metrics have to
// say the same thing or every container that centres an icon centres it off by the
// difference. Zero would be symmetric too and is what the font used to declare by
// accident, which sends an engine to a fallback it has to guess at.
expect(t.hheaAscent).toBe(t.unitsPerEm / 2);
expect(t.hheaDescent).toBe(-t.unitsPerEm / 2);
expect(t.typoAscent).toBe(t.hheaAscent);
expect(t.typoDescent).toBe(t.hheaDescent);
});
it('declares Windows metrics that are unsigned and contain the em', () => {
const t = fontTables();
// These were written at the wrong offsets for as long as the font existed, so they
// read as 0 / 65506 (-30, read unsigned) — a window that does not contain the glyphs
// it describes. An engine that falls back to them computes a box instead of using one.
expect(t.winAscent).toBe(t.unitsPerEm);
expect(t.winDescent).toBe(t.unitsPerEm);
});
it('tells engines to use the typographic metrics, and that the face is regular', () => {
const t = fontTables();
// Without USE_TYPO_METRICS a Windows engine prefers the win pair, so getting the win
// metrics right above is only half of it.
expect(t.selection & 0x80).toBeTruthy();
expect(t.selection & 0x40).toBeTruthy(); // regular
expect(t.selection & 0x20).toBeFalsy(); // ... and not bold
expect(t.weightClass).toBe(400);
});
it('keeps every glyph inside the em the metrics declare', () => {
const t = fontTables();
// Walk `glyf` through `loca` and read each glyph's own bounding box — that box *is*
// the glyph's ink in its own coordinate system, so it is exactly what has to fit.
const offsets = [...Array(t.numGlyphs + 1)].map((_, i) => t.font.readUInt32BE(t.offsets.loca + 4 * i));
for (let gid = 1; gid <= t.numGlyphs; gid++) {
if (offsets[gid + 1] === offsets[gid]) continue;
const start = t.offsets.glyf + offsets[gid];
const yMin = t.font.readInt16BE(start + 4);
const yMax = t.font.readInt16BE(start + 8);
expect(yMin, `glyph ${gid} reaches below the descender`).toBeGreaterThanOrEqual(-t.unitsPerEm / 2);
expect(yMax, `glyph ${gid} reaches above the ascender`).toBeLessThanOrEqual(t.unitsPerEm / 2);
}
});
it('gives every non-blank glyph a non-empty bounding box', () => {
const t = fontTables();
// An empty box is a glyph a rasteriser is entitled to skip, and `more` was one: three
// dots authored as 0.06-unit strokes rounded to a single point on each axis. The
// button drew blank while `cmap` still mapped its code point, so every table-reading
// assertion above was satisfied by a font that could not draw its own ellipsis.
const offsets = [...Array(t.numGlyphs + 1)].map((_, i) => t.font.readUInt32BE(t.offsets.loca + 4 * i));
for (let gid = 1; gid <= t.numGlyphs; gid++) {
if (offsets[gid + 1] === offsets[gid]) continue;
const start = t.offsets.glyf + offsets[gid];
const numContours = t.font.readInt16BE(start);
if (numContours <= 0) continue;
expect(t.font.readInt16BE(start + 6) - t.font.readInt16BE(start + 2), `glyph ${gid} has no width`).toBeGreaterThan(0);
expect(t.font.readInt16BE(start + 8) - t.font.readInt16BE(start + 4), `glyph ${gid} has no height`).toBeGreaterThan(0);
}
});
});
describe('the icon box', () => {
it('renders the glyph and hides it from assistive technology when decorative', () => {
const host = document.createElement('div');
render(Icon({ name: 'close' }), host);
const span = host.querySelector('.icon')!;
expect(span.tagName.toLowerCase()).toBe('svg');
expect(span.children.length).toBeGreaterThan(0);
expect(span.getAttribute('aria-hidden')).toBe('true');
expect(span.hasAttribute('aria-label')).toBe(false);
});
it('exposes a label as its accessible name when it is not decorative', () => {
const host = document.createElement('div');
render(Icon({ name: 'close', label: '关闭' }), host);
const span = host.querySelector('.icon')!;
// `role="img"` is what makes a labelled span announce as an image rather than
// as a stray character, which is what a bare span containing a PUA code point
// would do.
expect(span.getAttribute('role')).toBe('img');
expect(span.getAttribute('aria-label')).toBe('关闭');
expect(span.hasAttribute('aria-hidden')).toBe(false);
});
});
/**
* The stylesheet's half of the contract.
*
* The font can only be right; whether the icon ends up where the font says is decided in
* CSS. These read `.icon` out of the real stylesheet, because the rules that matter are
* the ones that were wrong and a test that re-states them in its own words would have
* been updated alongside the bug.
*/
describe('the stylesheet', () => {
const css = readFileSync(join(process.cwd(), 'src/styles/reader.css'), 'utf8');
const iconRule = /(^|\n)\.icon \{([^}]*)\}/.exec(css)?.[2] ?? '';
const declaration = (property: string): string | undefined =>
new RegExp(`(?:^|;)\\s*${property}\\s*:\\s*([^;]+)`).exec(iconRule)?.[1]?.trim();
it('lets the font own the line box instead of imposing a square on it', () => {
// `line-height: 1` gives the icon the font's own line box, which is symmetric because
// the font's metrics are. A fixed `height: 1em` plus `line-height: 0` anchors a box at
// the baseline while the ink is drawn around it, so the ink lands at the box's top
// edge and everything that centres the box centres the glyph half an em off — which is
// exactly what shipped, measured at 0.496em across `menu`, `close` and `folder`.
expect(declaration('line-height')).toBe('1');
expect(declaration('height')).toBeUndefined();
});
it('keeps an explicit advance width', () => {
// The one thing the layout needs that the font cannot supply: a row of icons and
// labels has to be laid out the same way whatever glyphs are in it.
expect(declaration('width')).toBe('1em');
});
it('aligns on the middle for the rows that are not flex or grid', () => {
// In a paragraph, `baseline` would put the icon's baseline on the text's, and the icon
// has no baseline in the text sense — its ink is centred on the em.
expect(declaration('vertical-align')).toBe('middle');
});
it('does not move the glyph with a transform', () => {
// A transform would also move the ink inside a flex or grid container, where
// `vertical-align` is inert — but it moves the glyph at paint time, so the space the
// icon reserves stops agreeing with the space it appears to occupy.
expect(declaration('transform')).toBeUndefined();
});
it('is applied by every icon button, including the hand-built ones', () => {
// The glyph has to be `.icon` for any of the above to reach it, and a button that
// builds its own markup is where that gets forgotten.
const sources = [
'toolkit.tsx',
'reader-chrome.tsx',
'shelf-settings.tsx',
'sheet.tsx',
'dialog.tsx',
'pager.tsx',
];
for (const source of sources) {
const text = readFileSync(join(process.cwd(), 'src/ui', source), 'utf8');
// A raw `<button className="icon-button">` is the shape that drifts; the primitive
// and the icon-name type are what make the glyph's class a consequence rather than
// a convention.
expect(text.includes('className="icon-button'), `${source} hand-builds an icon button`).toBe(false);
expect(text.includes("className={'icon-button"), `${source} hand-builds an icon button`).toBe(false);
}
});
});
describe('icon buttons', () => {
it('carries the label as the accessible name and hides the glyph', () => {
const host = document.createElement('div');
render(IconButton({ label: '目录', icon: 'menu' }), host);
const button = host.querySelector('button')!;
// The name must be on the *button*: a glyph inside an aria-labelled button is
// read by some screen readers as well, which is why the glyph is aria-hidden.
expect(button.getAttribute('aria-label')).toBe('目录');
const glyph = button.querySelector('.icon')!;
expect(glyph.getAttribute('aria-hidden')).toBe('true');
expect(glyph.tagName.toLowerCase()).toBe('svg');
});
it('draws every icon button from the icon set rather than from text', () => {
const host = document.createElement('div');
render(IconButton({ label: '关闭', icon: 'close' }), host);
// A regression here is the whole point of the change: the old button took
// arbitrary children, and Unicode punctuation is what it was given.
expect(host.querySelector('.icon')).not.toBeNull();
expect(host.querySelector('svg path')).not.toBeNull();
});
});