From c81e6846c5bfc80266b67df155bf87f0894e095f Mon Sep 17 00:00:00 2001 From: Karsa Date: Fri, 24 Apr 2026 14:12:43 +0200 Subject: [PATCH] test(build-font): added comprehensive unit tests on build-font tool (#4315) --- .github/workflows/lucide-font.yml | 3 + .github/workflows/release.yml | 3 + package.json | 1 + tools/build-font/package.json | 3 +- .../build-font/src/allocateCodepoints.spec.ts | 141 ++++++++++++++++++ tools/build-font/src/buildFont.spec.ts | 63 ++++++++ tools/build-font/src/helpers.spec.ts | 45 ++++++ tools/build-font/src/outlineSVGs.spec.ts | 101 +++++++++++++ 8 files changed, 359 insertions(+), 1 deletion(-) create mode 100644 tools/build-font/src/allocateCodepoints.spec.ts create mode 100644 tools/build-font/src/buildFont.spec.ts create mode 100644 tools/build-font/src/helpers.spec.ts create mode 100644 tools/build-font/src/outlineSVGs.spec.ts diff --git a/.github/workflows/lucide-font.yml b/.github/workflows/lucide-font.yml index 60347eb52..6842a01ce 100644 --- a/.github/workflows/lucide-font.yml +++ b/.github/workflows/lucide-font.yml @@ -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 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index f108ea7cc..62a9a70b6 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -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: diff --git a/package.json b/package.json index 8e6486912..1eecaff75 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/tools/build-font/package.json b/tools/build-font/package.json index 10d947347..e2b1847ff 100644 --- a/tools/build-font/package.json +++ b/tools/build-font/package.json @@ -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": "", diff --git a/tools/build-font/src/allocateCodepoints.spec.ts b/tools/build-font/src/allocateCodepoints.spec.ts new file mode 100644 index 000000000..267a6bcc7 --- /dev/null +++ b/tools/build-font/src/allocateCodepoints.spec.ts @@ -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) { + 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.'); + }); +}); diff --git a/tools/build-font/src/buildFont.spec.ts b/tools/build-font/src/buildFont.spec.ts new file mode 100644 index 000000000..c50393132 --- /dev/null +++ b/tools/build-font/src/buildFont.spec.ts @@ -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'); + }); +}); diff --git a/tools/build-font/src/helpers.spec.ts b/tools/build-font/src/helpers.spec.ts new file mode 100644 index 000000000..2433d0bf7 --- /dev/null +++ b/tools/build-font/src/helpers.spec.ts @@ -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'); + }); +}); diff --git a/tools/build-font/src/outlineSVGs.spec.ts b/tools/build-font/src/outlineSVGs.spec.ts new file mode 100644 index 000000000..a52430298 --- /dev/null +++ b/tools/build-font/src/outlineSVGs.spec.ts @@ -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); + }); +});