From 8195e9baa8bc20b2109cbd8d5f6cf214f90ac4eb Mon Sep 17 00:00:00 2001 From: Ilia Mashkov Date: Thu, 5 Feb 2026 11:40:23 +0300 Subject: [PATCH] feat(getFontUrl): create a helper function to choose font url --- .../Font/lib/getFontUrl/getFontUrl.test.ts | 592 ++++++++++++++++++ .../Font/lib/getFontUrl/getFontUrl.ts | 29 + src/entities/Font/lib/index.ts | 2 + 3 files changed, 623 insertions(+) create mode 100644 src/entities/Font/lib/getFontUrl/getFontUrl.test.ts create mode 100644 src/entities/Font/lib/getFontUrl/getFontUrl.ts diff --git a/src/entities/Font/lib/getFontUrl/getFontUrl.test.ts b/src/entities/Font/lib/getFontUrl/getFontUrl.test.ts new file mode 100644 index 0000000..3902421 --- /dev/null +++ b/src/entities/Font/lib/getFontUrl/getFontUrl.test.ts @@ -0,0 +1,592 @@ +import { + describe, + expect, + it, +} from 'vitest'; +import type { UnifiedFont } from '../../model/types'; +import { getFontUrl } from './getFontUrl'; + +/** + * Helper function to create a minimal UnifiedFont mock for testing + */ +function createMockFont( + overrides: Partial = {}, +): UnifiedFont { + const baseFont: UnifiedFont = { + id: 'test-font', + name: 'Test Font', + provider: 'google', + category: 'sans-serif', + subsets: ['latin'], + variants: [], + styles: {}, + metadata: { + cachedAt: Date.now(), + }, + features: { + isVariable: false, + tags: [], + }, + }; + + return { ...baseFont, ...overrides }; +} + +describe('getFontUrl', () => { + describe('basic logic', () => { + it('returns URL for exact weight match in variants', () => { + const font = createMockFont({ + styles: { + variants: { + '400': 'https://example.com/font-400.woff2', + '700': 'https://example.com/font-700.woff2', + }, + }, + }); + + const result = getFontUrl(font, 400); + + expect(result).toBe('https://example.com/font-400.woff2'); + }); + + it('returns URL for weight 700', () => { + const font = createMockFont({ + styles: { + variants: { + '700': 'https://example.com/font-700.woff2', + }, + }, + }); + + const result = getFontUrl(font, 700); + + expect(result).toBe('https://example.com/font-700.woff2'); + }); + + it('returns URL for weight 100 (lightest)', () => { + const font = createMockFont({ + styles: { + variants: { + '100': 'https://example.com/font-100.woff2', + }, + }, + }); + + const result = getFontUrl(font, 100); + + expect(result).toBe('https://example.com/font-100.woff2'); + }); + + it('returns URL for weight 900 (boldest)', () => { + const font = createMockFont({ + styles: { + variants: { + '900': 'https://example.com/font-900.woff2', + }, + }, + }); + + const result = getFontUrl(font, 900); + + expect(result).toBe('https://example.com/font-900.woff2'); + }); + + it('returns URL for variable font (backend maps weight to VF URL)', () => { + const font = createMockFont({ + styles: { + variants: { + '400': 'https://example.com/font-variable.woff2', + '700': 'https://example.com/font-variable.woff2', + }, + }, + }); + + const result400 = getFontUrl(font, 400); + const result700 = getFontUrl(font, 700); + + expect(result400).toBe('https://example.com/font-variable.woff2'); + expect(result700).toBe('https://example.com/font-variable.woff2'); + }); + }); + + describe('fallback logic', () => { + it('falls back to regular when exact weight not found', () => { + const font = createMockFont({ + styles: { + regular: 'https://example.com/font-regular.woff2', + variants: { + '400': 'https://example.com/font-400.woff2', + }, + }, + }); + + const result = getFontUrl(font, 700); + + expect(result).toBe('https://example.com/font-regular.woff2'); + }); + + it('falls back to variant 400 when exact weight and regular not found', () => { + const font = createMockFont({ + styles: { + variants: { + '400': 'https://example.com/font-400.woff2', + }, + }, + }); + + const result = getFontUrl(font, 700); + + expect(result).toBe('https://example.com/font-400.woff2'); + }); + + it('falls back to variant regular when exact weight, regular, and 400 not found', () => { + const font = createMockFont({ + styles: { + variants: { + '700': 'https://example.com/font-700.woff2', + 'regular': 'https://example.com/font-regular.woff2', + }, + }, + }); + + const result = getFontUrl(font, 400); + + expect(result).toBe('https://example.com/font-regular.woff2'); + }); + + it('prefers regular over variants.400 for fallback', () => { + const font = createMockFont({ + styles: { + regular: 'https://example.com/font-regular.woff2', + variants: { + '400': 'https://example.com/font-400.woff2', + }, + }, + }); + + const result = getFontUrl(font, 700); + + expect(result).toBe('https://example.com/font-regular.woff2'); + }); + + it('returns undefined when no fallback options available', () => { + const font = createMockFont({ + styles: { + variants: { + '700': 'https://example.com/font-700.woff2', + }, + }, + }); + + const result = getFontUrl(font, 400); + + expect(result).toBeUndefined(); + }); + + it('returns undefined for font with empty styles', () => { + const font = createMockFont({ + styles: {}, + }); + + const result = getFontUrl(font, 400); + + expect(result).toBeUndefined(); + }); + + it('throws error for font with undefined styles (invalid font data)', () => { + const font = createMockFont({ + styles: undefined as any, + }); + + expect(() => getFontUrl(font, 400)).toThrow(); + }); + }); + + describe('edge cases', () => { + it('handles font with only regular URL (legacy format)', () => { + const font = createMockFont({ + styles: { + regular: 'https://example.com/font-regular.woff2', + }, + }); + + const result = getFontUrl(font, 700); + + expect(result).toBe('https://example.com/font-regular.woff2'); + }); + + it('handles font with only variants object', () => { + const font = createMockFont({ + styles: { + variants: { + '400': 'https://example.com/font-400.woff2', + '700': 'https://example.com/font-700.woff2', + }, + }, + }); + + const result400 = getFontUrl(font, 400); + const result700 = getFontUrl(font, 700); + + expect(result400).toBe('https://example.com/font-400.woff2'); + expect(result700).toBe('https://example.com/font-700.woff2'); + }); + + it('handles font with variants but no requested weight', () => { + const font = createMockFont({ + styles: { + variants: { + '400': 'https://example.com/font-400.woff2', + }, + }, + }); + + const result = getFontUrl(font, 700); + + expect(result).toBe('https://example.com/font-400.woff2'); + }); + + it('handles Google Fonts style with legacy URLs', () => { + const font = createMockFont({ + styles: { + regular: 'https://fonts.gstatic.com/s/roboto/v30/KFOmCnqEu92Fr1Mu72xKOzY.woff2', + bold: 'https://fonts.gstatic.com/s/roboto/v30/KFOlCnqEu92Fr1Mu72xWUlvAx05IsDqlA.woff2', + }, + }); + + const result = getFontUrl(font, 700); + + expect(result).toBe('https://fonts.gstatic.com/s/roboto/v30/KFOmCnqEu92Fr1Mu72xKOzY.woff2'); + }); + + it('handles Fontshare fonts with multiple weights', () => { + const font = createMockFont({ + styles: { + variants: { + '100': 'https://cdn.fontshare.com/wf/font-100.woff2', + '200': 'https://cdn.fontshare.com/wf/font-200.woff2', + '300': 'https://cdn.fontshare.com/wf/font-300.woff2', + '400': 'https://cdn.fontshare.com/wf/font-400.woff2', + '500': 'https://cdn.fontshare.com/wf/font-500.woff2', + '600': 'https://cdn.fontshare.com/wf/font-600.woff2', + '700': 'https://cdn.fontshare.com/wf/font-700.woff2', + '800': 'https://cdn.fontshare.com/wf/font-800.woff2', + '900': 'https://cdn.fontshare.com/wf/font-900.woff2', + }, + }, + }); + + // Test all valid weights + for (const weight of [100, 200, 300, 400, 500, 600, 700, 800, 900]) { + const result = getFontUrl(font, weight); + expect(result).toBe(`https://cdn.fontshare.com/wf/font-${weight}.woff2`); + } + }); + + it('handles font with partial weight coverage', () => { + const font = createMockFont({ + styles: { + variants: { + '400': 'https://example.com/font-regular.woff2', + '700': 'https://example.com/font-bold.woff2', + }, + }, + }); + + const result400 = getFontUrl(font, 400); + const result700 = getFontUrl(font, 700); + const result500 = getFontUrl(font, 500); + + expect(result400).toBe('https://example.com/font-regular.woff2'); + expect(result700).toBe('https://example.com/font-bold.woff2'); + expect(result500).toBe('https://example.com/font-regular.woff2'); // Fallback + }); + + it('handles font with variants.regular as fallback', () => { + const font = createMockFont({ + styles: { + variants: { + '700': 'https://example.com/font-bold.woff2', + 'regular': 'https://example.com/font-regular.woff2', + }, + }, + }); + + const result = getFontUrl(font, 400); + + expect(result).toBe('https://example.com/font-regular.woff2'); + }); + + it('handles empty variants object', () => { + const font = createMockFont({ + styles: { + variants: {}, + }, + }); + + const result = getFontUrl(font, 400); + + expect(result).toBeUndefined(); + }); + + it('returns undefined when variant URL is null and no fallback available', () => { + const font = createMockFont({ + styles: { + variants: { + '400': null as any, + '700': 'https://example.com/font-bold.woff2', + }, + }, + }); + + const result = getFontUrl(font, 400); + + // null is falsy, so it falls back to regular, 400, and then regular variant + // All are undefined, so returns undefined + expect(result).toBeUndefined(); + }); + }); + + describe('boundary tests', () => { + it('handles lowest valid weight (100)', () => { + const font = createMockFont({ + styles: { + variants: { + '100': 'https://example.com/font-100.woff2', + }, + }, + }); + + const result = getFontUrl(font, 100); + + expect(result).toBe('https://example.com/font-100.woff2'); + }); + + it('handles highest valid weight (900)', () => { + const font = createMockFont({ + styles: { + variants: { + '900': 'https://example.com/font-900.woff2', + }, + }, + }); + + const result = getFontUrl(font, 900); + + expect(result).toBe('https://example.com/font-900.woff2'); + }); + + it('handles middle weight (500)', () => { + const font = createMockFont({ + styles: { + variants: { + '500': 'https://example.com/font-500.woff2', + }, + }, + }); + + const result = getFontUrl(font, 500); + + expect(result).toBe('https://example.com/font-500.woff2'); + }); + }); + + describe('invalid weights', () => { + it('throws error for weight below 100', () => { + const font = createMockFont({ + styles: { + variants: { + '400': 'https://example.com/font-400.woff2', + }, + }, + }); + + expect(() => getFontUrl(font, 99)).toThrow('Invalid weight: 99'); + }); + + it('throws error for weight above 900', () => { + const font = createMockFont({ + styles: { + variants: { + '400': 'https://example.com/font-400.woff2', + }, + }, + }); + + expect(() => getFontUrl(font, 901)).toThrow('Invalid weight: 901'); + }); + + it('throws error for weight 0', () => { + const font = createMockFont({ + styles: { + variants: { + '400': 'https://example.com/font-400.woff2', + }, + }, + }); + + expect(() => getFontUrl(font, 0)).toThrow('Invalid weight: 0'); + }); + + it('throws error for negative weight', () => { + const font = createMockFont({ + styles: { + variants: { + '400': 'https://example.com/font-400.woff2', + }, + }, + }); + + expect(() => getFontUrl(font, -100)).toThrow('Invalid weight: -100'); + }); + + it('throws error for non-numeric weight', () => { + const font = createMockFont({ + styles: { + variants: { + '400': 'https://example.com/font-400.woff2', + }, + }, + }); + + // @ts-ignore - Testing invalid input type + expect(() => getFontUrl(font, '400' as any)).toThrow('Invalid weight: 400'); + }); + + it('throws error for decimal weight', () => { + const font = createMockFont({ + styles: { + variants: { + '400': 'https://example.com/font-400.woff2', + }, + }, + }); + + expect(() => getFontUrl(font, 450.5)).toThrow('Invalid weight: 450.5'); + }); + + it('throws error for weight with step of 50 (not supported)', () => { + const font = createMockFont({ + styles: { + variants: { + '400': 'https://example.com/font-400.woff2', + }, + }, + }); + + expect(() => getFontUrl(font, 450)).toThrow('Invalid weight: 450'); + }); + + it('throws error for weight with step of 10 (not supported)', () => { + const font = createMockFont({ + styles: { + variants: { + '400': 'https://example.com/font-400.woff2', + }, + }, + }); + + expect(() => getFontUrl(font, 410)).toThrow('Invalid weight: 410'); + }); + + it('throws error for NaN weight', () => { + const font = createMockFont({ + styles: { + variants: { + '400': 'https://example.com/font-400.woff2', + }, + }, + }); + + expect(() => getFontUrl(font, NaN)).toThrow('Invalid weight: NaN'); + }); + + it('throws error for Infinity weight', () => { + const font = createMockFont({ + styles: { + variants: { + '400': 'https://example.com/font-400.woff2', + }, + }, + }); + + expect(() => getFontUrl(font, Infinity)).toThrow('Invalid weight: Infinity'); + }); + + it('throws descriptive error message', () => { + const font = createMockFont({ + styles: { + variants: { + '400': 'https://example.com/font-400.woff2', + }, + }, + }); + + try { + getFontUrl(font, 999); + expect.fail('Expected function to throw'); + } catch (error) { + expect(error).toBeInstanceOf(Error); + expect((error as Error).message).toBe('Invalid weight: 999'); + } + }); + }); + + describe('provider-specific tests', () => { + it('handles Google Fonts with variable fonts', () => { + const font = createMockFont({ + provider: 'google', + styles: { + variants: { + '400': 'https://fonts.gstatic.com/s/roboto/v30/KFOmCnqEu92Fr1Mu72xKOzY.woff2', + '700': 'https://fonts.gstatic.com/s/roboto/v30/KFOmCnqEu92Fr1Mu72xKOzY.woff2', + }, + }, + }); + + const result400 = getFontUrl(font, 400); + const result700 = getFontUrl(font, 700); + + // Variable fonts return the same URL for all weights + expect(result400).toBe(result700); + }); + + it('handles Fontshare fonts with static weights', () => { + const font = createMockFont({ + provider: 'fontshare', + styles: { + variants: { + '400': 'https://cdn.fontshare.com/wf/satoshi-regular.woff2', + '700': 'https://cdn.fontshare.com/wf/satoshi-bold.woff2', + }, + }, + }); + + const result400 = getFontUrl(font, 400); + const result700 = getFontUrl(font, 700); + + expect(result400).toBe('https://cdn.fontshare.com/wf/satoshi-regular.woff2'); + expect(result700).toBe('https://cdn.fontshare.com/wf/satoshi-bold.woff2'); + expect(result400).not.toBe(result700); + }); + }); + + describe('all valid weights test', () => { + it('handles all valid weight values', () => { + const validWeights = [100, 200, 300, 400, 500, 600, 700, 800, 900]; + + validWeights.forEach(weight => { + const font = createMockFont({ + styles: { + variants: { + [weight.toString()]: `https://example.com/font-${weight}.woff2`, + }, + }, + }); + + const result = getFontUrl(font, weight); + expect(result).toBe(`https://example.com/font-${weight}.woff2`); + }); + }); + }); +}); diff --git a/src/entities/Font/lib/getFontUrl/getFontUrl.ts b/src/entities/Font/lib/getFontUrl/getFontUrl.ts new file mode 100644 index 0000000..667cf37 --- /dev/null +++ b/src/entities/Font/lib/getFontUrl/getFontUrl.ts @@ -0,0 +1,29 @@ +import type { + FontWeight, + UnifiedFont, +} from '../../model'; + +const SIZES = [100, 200, 300, 400, 500, 600, 700, 800, 900]; + +/** + * Constructs a URL for a font based on the provided font and weight. + * @param font - The font object. + * @param weight - The weight of the font. + * @returns The URL for the font. + */ +export function getFontUrl(font: UnifiedFont, weight: number): string | undefined { + if (!SIZES.includes(weight)) { + throw new Error(`Invalid weight: ${weight}`); + } + + const weightKey = weight.toString() as FontWeight; + + // 1. Try exact match (Backend now maps "100".."900" to VF URL if variable) + if (font.styles.variants?.[weightKey]) { + return font.styles.variants[weightKey]; + } + + // 2. Fallbacks for Static Fonts (if exact weight missing) + // Try 'regular' or '400' as safe defaults + return font.styles.regular || font.styles.variants?.['400'] || font.styles.variants?.['regular']; +} diff --git a/src/entities/Font/lib/index.ts b/src/entities/Font/lib/index.ts index d2b3e0d..8d7bad8 100644 --- a/src/entities/Font/lib/index.ts +++ b/src/entities/Font/lib/index.ts @@ -4,3 +4,5 @@ export { normalizeGoogleFont, normalizeGoogleFonts, } from './normalize/normalize'; + +export { getFontUrl } from './getFontUrl/getFontUrl';