1import { loaded, loadPromises } from '../memory'; 2 3let Font; 4let NativeModulesProxy; 5 6function clearMemory() { 7 for (const key of Object.keys(loaded)) { 8 delete loaded[key]; 9 } 10 for (const key of Object.keys(loadPromises)) { 11 delete loadPromises[key]; 12 } 13} 14type MockAsset = { downloaded: boolean; downloadAsync: () => Promise<void>; localUri?: string }; 15type MockAssetOptions = { localUri?: string; downloaded?: boolean; downloadAsync?: any }; 16 17function _createMockAsset({ 18 localUri = 'file:/test/test-font.ttf', 19 ...otherOptions 20}: MockAssetOptions = {}): MockAsset { 21 const mockAsset: MockAsset = { 22 downloaded: false, 23 downloadAsync: jest.fn(async () => { 24 mockAsset.downloaded = true; 25 mockAsset.localUri = localUri; 26 }), 27 ...otherOptions, 28 }; 29 return mockAsset; 30} 31 32beforeEach(() => { 33 ({ NativeModulesProxy } = require('expo-modules-core')); 34 NativeModulesProxy.ExpoFontLoader.loadAsync.mockImplementation(async () => {}); 35 Font = require('expo-font'); 36}); 37 38afterEach(async () => { 39 clearMemory(); 40 jest.resetModules(); 41}); 42 43describe('within Expo client', () => { 44 beforeAll(() => { 45 jest.doMock('expo-constants', () => ({ 46 manifest: {}, 47 sessionId: 'testsession', 48 systemFonts: ['Helvetica', 'Helvetica Neue'], 49 appOwnership: 'expo', 50 })); 51 }); 52 53 afterEach(async () => { 54 clearMemory(); 55 }); 56 57 afterAll(() => { 58 jest.unmock('expo-constants'); 59 }); 60 61 describe('loadAsync', () => { 62 afterEach(async () => { 63 clearMemory(); 64 }); 65 66 it(`completes after loading a font`, async () => { 67 const NativeFontLoader = NativeModulesProxy.ExpoFontLoader; 68 69 const mockAsset = _createMockAsset(); 70 await Font.loadAsync('test-font', mockAsset); 71 72 expect(mockAsset.downloaded).toBe(true); 73 expect(NativeFontLoader.loadAsync).toHaveBeenCalledTimes(1); 74 expect(NativeFontLoader.loadAsync.mock.calls[0]).toMatchSnapshot(); 75 expect(Font.isLoaded('test-font')).toBe(true); 76 expect(Font.isLoading('test-font')).toBe(false); 77 }); 78 79 it(`throws if downloading a font fails`, async () => { 80 const NativeFontLoader = NativeModulesProxy.ExpoFontLoader; 81 82 const mockAsset = { 83 downloaded: false, 84 downloadAsync: jest.fn(async () => {}), 85 }; 86 await expect(Font.loadAsync('test-font', mockAsset)).rejects.toMatchSnapshot(); 87 88 expect(mockAsset.downloaded).toBe(false); 89 expect(NativeFontLoader.loadAsync).not.toHaveBeenCalled(); 90 expect(Font.isLoaded('test-font')).toBe(false); 91 expect(Font.isLoading('test-font')).toBe(false); 92 }); 93 94 it(`throws if loading a downloaded font fails`, async () => { 95 const NativeFontLoader = NativeModulesProxy.ExpoFontLoader; 96 NativeFontLoader.loadAsync.mockImplementation(async () => { 97 throw new Error('Intentional error from FontLoader mock'); 98 }); 99 100 const mockAsset = _createMockAsset(); 101 await expect(Font.loadAsync('test-font', mockAsset)).rejects.toMatchSnapshot(); 102 103 expect(mockAsset.downloaded).toBe(true); 104 expect(NativeFontLoader.loadAsync).toHaveBeenCalled(); 105 expect(Font.isLoaded('test-font')).toBe(false); 106 expect(Font.isLoading('test-font')).toBe(false); 107 }); 108 109 it(`doesn't redownload a loaded font`, async () => { 110 const NativeFontLoader = NativeModulesProxy.ExpoFontLoader; 111 112 const mockAsset1 = _createMockAsset(); 113 await Font.loadAsync('test-font', mockAsset1); 114 expect(NativeFontLoader.loadAsync).toHaveBeenCalledTimes(1); 115 116 const mockAsset2 = _createMockAsset(); 117 await Font.loadAsync('test-font', mockAsset2); 118 expect(NativeFontLoader.loadAsync).toHaveBeenCalledTimes(1); 119 expect(Font.isLoaded('test-font')).toBe(true); 120 expect(Font.isLoading('test-font')).toBe(false); 121 }); 122 123 it(`can load an already downloaded asset`, async () => { 124 const mockAsset = _createMockAsset(); 125 await Font.loadAsync('test-font', mockAsset); 126 127 const loadPromise = Font.loadAsync('test-font', mockAsset); 128 expect(Font.isLoading('test-font')).toBe(false); 129 expect(Font.isLoaded('test-font')).toBe(true); 130 131 await loadPromise; 132 expect(Font.isLoading('test-font')).toBe(false); 133 expect(Font.isLoaded('test-font')).toBe(true); 134 }); 135 136 it(`downloads a font that failed to load`, async () => { 137 const NativeFontLoader = NativeModulesProxy.ExpoFontLoader; 138 139 const mockAsset1 = _createMockAsset({ 140 localUri: 'file:/test/test-font.ttf', 141 downloadAsync: jest.fn(async () => {}), 142 }); 143 await expect(Font.loadAsync('test-font', mockAsset1)).rejects.toBeDefined(); 144 expect(NativeFontLoader.loadAsync).not.toHaveBeenCalled(); 145 expect(Font.isLoaded('test-font')).toBe(false); 146 expect(Font.isLoading('test-font')).toBe(false); 147 148 const mockAsset2 = _createMockAsset(); 149 await Font.loadAsync('test-font', mockAsset2); 150 expect(NativeFontLoader.loadAsync).toHaveBeenCalledTimes(1); 151 expect(Font.isLoaded('test-font')).toBe(true); 152 expect(Font.isLoading('test-font')).toBe(false); 153 }); 154 155 it(`coalesces concurrent loads`, async () => { 156 const NativeFontLoader = NativeModulesProxy.ExpoFontLoader; 157 158 const mockAsset1 = _createMockAsset(); 159 const loadPromise1 = Font.loadAsync('test-font', mockAsset1); 160 expect(Font.isLoaded('test-font')).toBe(false); 161 expect(Font.isLoading('test-font')).toBe(true); 162 163 const mockAsset2 = _createMockAsset(); 164 const loadPromise2 = Font.loadAsync('test-font', mockAsset2); 165 expect(Font.isLoaded('test-font')).toBe(false); 166 expect(Font.isLoading('test-font')).toBe(true); 167 168 await Promise.all([loadPromise1, loadPromise2]); 169 expect(NativeFontLoader.loadAsync).toHaveBeenCalledTimes(1); 170 expect(Font.isLoaded('test-font')).toBe(true); 171 expect(Font.isLoading('test-font')).toBe(false); 172 }); 173 174 it(`rejects all coalesced loads`, async () => { 175 const mockAsset1 = { 176 downloaded: false, 177 downloadAsync: jest.fn(async () => {}), 178 }; 179 const loadPromise1 = Font.loadAsync('test-font', mockAsset1); 180 expect(Font.isLoaded('test-font')).toBe(false); 181 expect(Font.isLoading('test-font')).toBe(true); 182 183 const mockAsset2 = _createMockAsset(); 184 const loadPromise2 = Font.loadAsync('test-font', mockAsset2); 185 expect(Font.isLoaded('test-font')).toBe(false); 186 expect(Font.isLoading('test-font')).toBe(true); 187 188 await expect(loadPromise1).rejects.toBeDefined(); 189 await expect(loadPromise2).rejects.toBeDefined(); 190 expect(Font.isLoaded('test-font')).toBe(false); 191 expect(Font.isLoading('test-font')).toBe(false); 192 }); 193 194 it(`accepts a map of fonts to multi-load`, async () => { 195 await Font.loadAsync({ 196 'test-font-1': _createMockAsset({ 197 localUri: 'file:/test/test-font-1.ttf', 198 }), 199 'test-font-2': _createMockAsset({ 200 localUri: 'file:/test/test-font-2.ttf', 201 }), 202 }); 203 expect(Font.isLoaded('test-font-1')).toBe(true); 204 expect(Font.isLoaded('test-font-2')).toBe(true); 205 }); 206 207 it(`rejects if any font in the map fails to load`, async () => { 208 const mockAsset2 = { 209 downloaded: false, 210 downloadAsync: jest.fn(async () => {}), 211 }; 212 213 await expect( 214 Font.loadAsync({ 215 'test-font-1': _createMockAsset({ 216 localUri: 'file:/test/test-font-1.ttf', 217 }), 218 'test-font-2': mockAsset2, 219 }) 220 ).rejects.toBeDefined(); 221 222 // We don't guarantee whether the first font will have loaded or 223 // even finished loading but the internal state should be 224 // consistent 225 expect(() => Font.isLoaded('test-font-1')).not.toThrow(); 226 expect(() => Font.isLoading('test-font-1')).not.toThrow(); 227 expect(Font.isLoaded('test-font-2')).toBe(false); 228 }); 229 230 it(`coalesces concurrent loads across maps`, async () => { 231 const NativeFontLoader = NativeModulesProxy.ExpoFontLoader; 232 233 const loadPromise1 = Font.loadAsync({ 234 'test-font-1': _createMockAsset({ 235 localUri: 'file:/test/test-font-1.ttf', 236 }), 237 'test-font-2': _createMockAsset({ 238 localUri: 'file:/test/test-font-2.ttf', 239 }), 240 }); 241 expect(Font.isLoaded('test-font-1')).toBe(false); 242 expect(Font.isLoaded('test-font-2')).toBe(false); 243 expect(Font.isLoading('test-font-1')).toBe(true); 244 expect(Font.isLoading('test-font-2')).toBe(true); 245 246 const loadPromise2 = Font.loadAsync({ 247 'test-font-1': _createMockAsset({ 248 localUri: 'file:/test/test-font-1.ttf', 249 }), 250 }); 251 expect(Font.isLoaded('test-font-1')).toBe(false); 252 expect(Font.isLoading('test-font-1')).toBe(true); 253 254 await Promise.all([loadPromise1, loadPromise2]); 255 expect(NativeFontLoader.loadAsync).toHaveBeenCalledTimes(2); 256 expect(Font.isLoaded('test-font-1')).toBe(true); 257 expect(Font.isLoaded('test-font-2')).toBe(true); 258 expect(Font.isLoading('test-font-1')).toBe(false); 259 expect(Font.isLoading('test-font-2')).toBe(false); 260 }); 261 }); 262 263 describe('processFontFamily', () => { 264 let originalConsole; 265 266 beforeEach(() => { 267 originalConsole = console; 268 }); 269 270 afterEach(() => { 271 clearMemory(); 272 console = originalConsole; // eslint-disable-line no-global-assign 273 }); 274 275 it(`handles empty values`, () => { 276 expect(Font.processFontFamily(null)).toBeNull(); 277 expect(Font.processFontFamily(undefined as any)).toBeUndefined(); 278 }); 279 280 it(`handles the system font`, () => { 281 expect(Font.processFontFamily('System')).toBe('System'); 282 }); 283 284 it(`handles built-in fonts`, () => { 285 expect(Font.processFontFamily('Helvetica')).toBe('Helvetica'); 286 }); 287 288 it(`defaults missing fonts to the system font`, () => { 289 console.warn = jest.fn(); 290 291 const fontName = 'not-loaded'; 292 expect(Font.isLoaded(fontName)).toBe(false); 293 expect(Font.processFontFamily(fontName)).toBe('ExpoFont-testsession-not-loaded'); 294 expect(console.warn).toHaveBeenCalled(); 295 expect((console.warn as jest.Mock).mock.calls[0]).toMatchSnapshot(); 296 }); 297 298 it(`defaults still-loading fonts to the system font`, () => { 299 console.warn = jest.fn(); 300 301 const fontName = 'loading'; 302 const mockAsset = _createMockAsset(); 303 Font.loadAsync(fontName, mockAsset); 304 expect(Font.isLoaded(fontName)).toBe(false); 305 expect(Font.isLoading(fontName)).toBe(true); 306 307 expect(Font.processFontFamily(fontName)).toBe('ExpoFont-testsession-loading'); 308 expect(console.warn).toHaveBeenCalled(); 309 expect((console.warn as jest.Mock).mock.calls[0]).toMatchSnapshot(); 310 }); 311 312 it(`scopes loaded names of loaded fonts`, async () => { 313 const fontName = 'test-font'; 314 const mockAsset = _createMockAsset(); 315 await Font.loadAsync(fontName, mockAsset); 316 expect(Font.isLoaded(fontName)).toBe(true); 317 318 const processedFontFamily = Font.processFontFamily(fontName); 319 expect(processedFontFamily).toContain(fontName); 320 expect(processedFontFamily).toMatchSnapshot(); 321 }); 322 323 it(`doesn't re-process Expo fonts`, async () => { 324 const fontName = 'test-font'; 325 const mockAsset = _createMockAsset(); 326 await Font.loadAsync(fontName, mockAsset); 327 expect(Font.isLoaded(fontName)).toBe(true); 328 329 const processedFontFamily = Font.processFontFamily(fontName); 330 expect(Font.processFontFamily(processedFontFamily)).toBe(processedFontFamily); 331 }); 332 }); 333}); 334 335describe('in standalone app', () => { 336 beforeAll(() => { 337 jest.doMock('expo-constants', () => ({ 338 manifest: {}, 339 sessionId: 'testsession', 340 systemFonts: ['Helvetica', 'Helvetica Neue'], 341 appOwnership: 'standalone', 342 })); 343 }); 344 345 afterAll(() => { 346 jest.unmock('expo-constants'); 347 }); 348 afterEach(() => { 349 clearMemory(); 350 }); 351 352 // NOTE(brentvatne): we need to disable scoping on native side on iOS 353 // in standalone apps: https://github.com/expo/expo/issues/5118 354 xit(`does not scope font names`, async () => { 355 const fontName = 'test-font'; 356 const mockAsset = _createMockAsset(); 357 await Font.loadAsync(fontName, mockAsset); 358 expect(Font.isLoaded(fontName)).toBe(true); 359 360 const processedFontFamily = Font.processFontFamily(fontName); 361 expect(processedFontFamily).toEqual(fontName); 362 }); 363}); 364 365describe('in bare workflow', () => { 366 beforeAll(() => { 367 jest.doMock('expo-constants', () => ({ 368 manifest: {}, 369 sessionId: 'testsession', 370 systemFonts: ['Helvetica', 'Helvetica Neue'], 371 })); 372 }); 373 afterEach(() => { 374 clearMemory(); 375 }); 376 377 it(`does not scope font names`, async () => { 378 const fontName = 'test-font'; 379 const mockAsset = _createMockAsset(); 380 await Font.loadAsync(fontName, mockAsset); 381 expect(Font.isLoaded(fontName)).toBe(true); 382 383 const processedFontFamily = Font.processFontFamily(fontName); 384 expect(processedFontFamily).toEqual(fontName); 385 }); 386}); 387