mirror of
https://github.com/lucide-icons/lucide.git
synced 2026-05-18 07:54:49 +02:00
test(build-font): added comprehensive unit tests on build-font tool (#4315)
This commit is contained in:
3
.github/workflows/lucide-font.yml
vendored
3
.github/workflows/lucide-font.yml
vendored
@@ -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
|
||||
|
||||
|
||||
3
.github/workflows/release.yml
vendored
3
.github/workflows/release.yml
vendored
@@ -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:
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "",
|
||||
|
||||
141
tools/build-font/src/allocateCodepoints.spec.ts
Normal file
141
tools/build-font/src/allocateCodepoints.spec.ts
Normal 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.');
|
||||
});
|
||||
});
|
||||
63
tools/build-font/src/buildFont.spec.ts
Normal file
63
tools/build-font/src/buildFont.spec.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
45
tools/build-font/src/helpers.spec.ts
Normal file
45
tools/build-font/src/helpers.spec.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
101
tools/build-font/src/outlineSVGs.spec.ts
Normal file
101
tools/build-font/src/outlineSVGs.spec.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user