test(build-font): added comprehensive unit tests on build-font tool (#4315)

This commit is contained in:
Karsa
2026-04-24 14:12:43 +02:00
committed by GitHub
parent 804d25586b
commit c81e6846c5
8 changed files with 359 additions and 1 deletions

View File

@@ -23,6 +23,9 @@ jobs:
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Execute unit tests for build-font
run: pnpm test:font
- name: Create font in ./lucide-font
run: pnpm build:font

View File

@@ -136,6 +136,9 @@ jobs:
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Execute unit tests for build-font
run: pnpm test:font
- name: Create font in ./lucide-font
run: pnpm build:font --saveCodePoints
env:

View File

@@ -4,6 +4,7 @@
"build": "pnpm -r --filter './packages/**' build",
"test": "pnpm -r --filter './packages/**' test",
"test:update": "pnpm -r --filter './packages/**' --filter !'./packages/lucide-angular' test -- -u",
"test:font": "pnpm --filter build-font test",
"lucide": "pnpm --filter lucide",
"lucide-angular": "pnpm --filter lucide-angular",
"lucide-react": "pnpm --filter lucide-react",

View File

@@ -6,7 +6,8 @@
"main": "main.ts",
"type": "module",
"scripts": {
"start": "node ./src/main.ts"
"start": "node ./src/main.ts",
"test": "vitest run"
},
"keywords": [],
"author": "",

View File

@@ -0,0 +1,141 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { put } from '@vercel/blob';
import { allocateCodePoints } from './allocateCodepoints.ts';
vi.mock('@vercel/blob', () => ({
put: vi.fn(),
}));
function mockLatestCodePoints(codePoints: Record<string, number>) {
const response = {
json: vi.fn().mockResolvedValue(structuredClone(codePoints)),
};
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(response));
}
describe('allocateCodePoints', () => {
beforeEach(() => {
vi.spyOn(console, 'log').mockImplementation(() => {});
vi.spyOn(console, 'warn').mockImplementation(() => {});
});
afterEach(() => {
vi.restoreAllMocks();
vi.unstubAllGlobals();
});
it('keeps existing code points and allocates new ones in sequence', async () => {
mockLatestCodePoints({ camera: 57400 });
const codePoints = await allocateCodePoints({
iconsWithAliases: [
['camera', ['camera-old']],
['new-icon', []],
],
});
expect(codePoints.camera).toBe(57400);
expect(codePoints['camera-old']).toBe(57400);
expect(codePoints['new-icon']).toBe(57401);
expect(fetch).toHaveBeenCalledWith(
'https://geoxmjocxfnaryc4.public.blob.vercel-storage.com/latest/font/codepoints.json',
);
});
it('preserves legacy alias code points when alias and canonical differ', async () => {
mockLatestCodePoints({ camera: 57400, shutter: 57405 });
const codePoints = await allocateCodePoints({
iconsWithAliases: [['camera', ['shutter']]],
});
expect(codePoints.camera).toBe(57400);
expect(codePoints.shutter).toBe(57405);
expect(console.log).toHaveBeenCalledWith(
expect.stringContaining('adding a legacy alias codepoint'),
);
});
it('throws when a legacy alias code point is already allocated and fixes are disabled', async () => {
mockLatestCodePoints({ 'icon-b': 57402, 'icon-a': 57400, 'alias-a': 57402 });
await expect(
allocateCodePoints({
iconsWithAliases: [
['icon-b', []],
['icon-a', ['alias-a']],
],
}),
).rejects.toThrow(
"The code point for alias 'alias-a' for 'icon-a' is already allocated to: 'icon-b'.",
);
});
it('throws when two canonical icons share the same code point without allowFixes', async () => {
mockLatestCodePoints({ 'icon-a': 57400, 'icon-b': 57400 });
await expect(
allocateCodePoints({
iconsWithAliases: [
['icon-a', []],
['icon-b', []],
],
}),
).rejects.toThrow(
"We couldn't assign a unique codepoint for 'icon-b', since '57400' was already taken up by 'icon-a'",
);
});
it('skips conflicting legacy alias code points when fixes are enabled', async () => {
mockLatestCodePoints({ 'icon-b': 57402, 'icon-a': 57400, 'alias-a': 57402 });
const codePoints = await allocateCodePoints({
allowFixes: true,
iconsWithAliases: [
['icon-b', []],
['icon-a', ['alias-a']],
],
});
expect(codePoints['icon-a']).toBe(57400);
expect(codePoints['alias-a']).toBe(57400);
expect(console.warn).toHaveBeenCalledWith(
expect.stringContaining("we're simply not adding this custom alias codepoint"),
);
});
it('reassigns duplicate canonical code points when fixes are enabled', async () => {
mockLatestCodePoints({ 'icon-a': 57400, 'icon-b': 57400 });
const codePoints = await allocateCodePoints({
allowFixes: true,
iconsWithAliases: [
['icon-a', []],
['icon-b', []],
],
});
expect(codePoints['icon-a']).toBe(57400);
expect(codePoints['icon-b']).toBe(57401);
expect(console.warn).toHaveBeenCalledWith(
expect.stringContaining("Assigning a new codepoint for 'icon-b'"),
);
});
it('uploads the merged code point map when saveCodePoints is enabled', async () => {
mockLatestCodePoints({ camera: 57400 });
await allocateCodePoints({
saveCodePoints: true,
iconsWithAliases: [['camera', ['camera-old']]],
});
expect(put).toHaveBeenCalledWith(
'latest/font/codepoints.json',
JSON.stringify({ camera: 57400, 'camera-old': 57400 }, null, 2),
{ access: 'public', allowOverwrite: true },
);
expect(console.log).toHaveBeenCalledWith('Code points uploaded to Vercel Blob Storage.');
});
});

View File

@@ -0,0 +1,63 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { buildFont } from './buildFont.ts';
import svgtofont from 'svgtofont';
vi.mock('svgtofont', () => ({
default: vi.fn(),
}));
describe('buildFont', () => {
beforeEach(() => {
vi.spyOn(console, 'time').mockImplementation(() => {});
vi.spyOn(console, 'timeEnd').mockImplementation(() => {});
vi.spyOn(console, 'log').mockImplementation(() => {});
});
afterEach(() => {
vi.restoreAllMocks();
});
it('calls svgtofont with expected settings and unicode resolver', async () => {
vi.mocked(svgtofont).mockResolvedValue(undefined);
await buildFont({
inputDir: '/icons',
targetDir: '/dist',
fontName: 'lucide',
classNamePrefix: 'icon',
codePoints: { camera: 57400 },
startUnicode: 57400,
});
expect(console.time).toHaveBeenCalledWith('Font generation');
expect(console.timeEnd).toHaveBeenCalledWith('Font generation');
expect(svgtofont).toHaveBeenCalledTimes(1);
const [options] = vi.mocked(svgtofont).mock.calls[0];
expect(options.src).toBe('/icons');
expect(options.dist).toBe('/dist');
expect(options.fontName).toBe('lucide');
expect(options.classNamePrefix).toBe('icon');
expect(options.addLigatures).toBe(true);
expect(options.getIconUnicode('camera')).toEqual([String.fromCharCode(57400), 57400]);
expect(() => options.getIconUnicode('missing')).toThrow('No codepoint found for icon: missing');
});
it('logs errors from svgtofont and still finishes timing', async () => {
const error = new Error('svgtofont failed');
vi.mocked(svgtofont).mockRejectedValue(error);
await buildFont({
inputDir: '/icons',
targetDir: '/dist',
fontName: 'lucide',
classNamePrefix: 'icon',
codePoints: { camera: 57400 },
startUnicode: 57400,
});
expect(console.log).toHaveBeenCalledWith(error);
expect(console.timeEnd).toHaveBeenCalledWith('Font generation');
});
});

View File

@@ -0,0 +1,45 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { hasMissingCodePoints } from './helpers.ts';
describe('hasMissingCodePoints', () => {
beforeEach(() => {
vi.spyOn(console, 'log').mockImplementation(() => {});
});
afterEach(() => {
vi.restoreAllMocks();
});
it('returns false when all canonical names and aliases are present', () => {
const hasMissing = hasMissingCodePoints(
[
['camera', ['snapshot']],
['heart', []],
],
{
camera: 57400,
snapshot: 57400,
heart: 57401,
},
);
expect(hasMissing).toBe(false);
expect(console.log).not.toHaveBeenCalled();
});
it('returns true and logs the first missing icon or alias', () => {
const hasMissing = hasMissingCodePoints(
[
['camera', ['snapshot']],
['heart', []],
],
{
camera: 57400,
heart: 57401,
},
);
expect(hasMissing).toBe(true);
expect(console.log).toHaveBeenCalledWith('Missing code point for icon/alias: snapshot');
});
});

View File

@@ -0,0 +1,101 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { outlineSVG } from './outlineSVGs.ts';
import { promises as fs } from 'fs';
import SVGFixer from 'oslllo-svg-fixer';
import path from 'path';
vi.mock('fs', () => ({
promises: {
mkdir: vi.fn(),
copyFile: vi.fn(),
},
}));
vi.mock('oslllo-svg-fixer', () => ({
default: vi.fn(),
}));
describe('outlineSVG', () => {
beforeEach(() => {
vi.clearAllMocks();
vi.spyOn(console, 'time').mockImplementation(() => {});
vi.spyOn(console, 'timeEnd').mockImplementation(() => {});
vi.spyOn(console, 'log').mockImplementation(() => {});
vi.mocked(fs.mkdir).mockResolvedValue(undefined);
vi.mocked(fs.copyFile).mockResolvedValue(undefined);
vi.mocked(SVGFixer).mockReturnValue({
fix: vi.fn().mockResolvedValue(undefined),
});
});
afterEach(() => {
vi.restoreAllMocks();
});
it('creates outlined files and duplicates aliases', async () => {
await outlineSVG({
iconsDir: '/icons',
outlinedDir: '/outlined',
iconsWithAliases: [
['camera', ['snapshot', 'shutter']],
['heart', []],
],
});
expect(fs.mkdir).toHaveBeenCalledWith('/outlined');
expect(SVGFixer).toHaveBeenCalledWith('/icons', '/outlined', {
showProgressBar: true,
traceResolution: 800,
});
expect(fs.copyFile).toHaveBeenCalledWith(
path.join('/outlined', 'camera.svg'),
path.join('/outlined', 'snapshot.svg'),
);
expect(fs.copyFile).toHaveBeenCalledWith(
path.join('/outlined', 'camera.svg'),
path.join('/outlined', 'shutter.svg'),
);
expect(console.log).toHaveBeenCalledWith('Copied camera.svg to snapshot.svg');
expect(console.timeEnd).toHaveBeenCalledWith('icon outliner');
});
it('logs copy failures per alias without aborting', async () => {
const copyError = new Error('copy failed');
vi.mocked(fs.copyFile).mockImplementation((_, destinationPath) => {
if (destinationPath === path.join('/outlined', 'snapshot.svg')) {
return Promise.reject(copyError);
}
return Promise.resolve();
});
await outlineSVG({
iconsDir: '/icons',
outlinedDir: '/outlined',
iconsWithAliases: [['camera', ['snapshot', 'shutter']]],
});
expect(console.log).toHaveBeenCalledWith(
`Failed to copy ${path.join('/outlined', 'camera.svg')} to ${path.join('/outlined', 'snapshot.svg')}:`,
copyError,
);
expect(fs.copyFile).toHaveBeenCalledTimes(2);
});
it('catches and logs top-level errors', async () => {
const fixError = new Error('fix failed');
vi.mocked(SVGFixer).mockReturnValue({
fix: vi.fn().mockRejectedValue(fixError),
});
await outlineSVG({
iconsDir: '/icons',
outlinedDir: '/outlined',
iconsWithAliases: [['camera', []]],
});
expect(console.log).toHaveBeenCalledWith(fixError);
});
});