let Font; let NativeModulesProxy; type MockAsset = { downloaded: boolean; downloadAsync: () => Promise<void>; localUri?: string }; type MockAssetOptions = { localUri?: string; downloaded?: boolean; downloadAsync?: any }; function _createMockAsset({ localUri = 'file:/test/test-font.ttf', ...otherOptions }: MockAssetOptions = {}): MockAsset { const mockAsset: MockAsset = { downloaded: false, downloadAsync: jest.fn(async () => { mockAsset.downloaded = true; mockAsset.localUri = localUri; }), ...otherOptions, }; return mockAsset; } beforeEach(() => { ({ NativeModulesProxy } = require('@unimodules/core')); NativeModulesProxy.ExpoFontLoader.loadAsync.mockImplementation(async () => {}); Font = require('expo-font'); }); afterEach(() => { jest.resetModules(); }); describe('within Expo client', () => { beforeAll(() => { jest.doMock('expo-constants', () => ({ manifest: {}, sessionId: 'testsession', systemFonts: ['Helvetica', 'Helvetica Neue'], appOwnership: 'expo', })); }); afterAll(() => { jest.unmock('expo-constants'); }); describe('loadAsync', () => { it(`completes after loading a font`, async () => { const NativeFontLoader = NativeModulesProxy.ExpoFontLoader; const mockAsset = _createMockAsset(); await Font.loadAsync('test-font', mockAsset); expect(mockAsset.downloaded).toBe(true); expect(NativeFontLoader.loadAsync).toHaveBeenCalledTimes(1); expect(NativeFontLoader.loadAsync.mock.calls[0]).toMatchSnapshot(); expect(Font.isLoaded('test-font')).toBe(true); expect(Font.isLoading('test-font')).toBe(false); }); it(`throws if downloading a font fails`, async () => { const NativeFontLoader = NativeModulesProxy.ExpoFontLoader; const mockAsset = { downloaded: false, downloadAsync: jest.fn(async () => {}), }; await expect(Font.loadAsync('test-font', mockAsset)).rejects.toMatchSnapshot(); expect(mockAsset.downloaded).toBe(false); expect(NativeFontLoader.loadAsync).not.toHaveBeenCalled(); expect(Font.isLoaded('test-font')).toBe(false); expect(Font.isLoading('test-font')).toBe(false); }); it(`throws if loading a downloaded font fails`, async () => { const NativeFontLoader = NativeModulesProxy.ExpoFontLoader; NativeFontLoader.loadAsync.mockImplementation(async () => { throw new Error('Intentional error from FontLoader mock'); }); const mockAsset = _createMockAsset(); await expect(Font.loadAsync('test-font', mockAsset)).rejects.toMatchSnapshot(); expect(mockAsset.downloaded).toBe(true); expect(NativeFontLoader.loadAsync).toHaveBeenCalled(); expect(Font.isLoaded('test-font')).toBe(false); expect(Font.isLoading('test-font')).toBe(false); }); it(`doesn't redownload a loaded font`, async () => { const NativeFontLoader = NativeModulesProxy.ExpoFontLoader; const mockAsset1 = _createMockAsset(); await Font.loadAsync('test-font', mockAsset1); expect(NativeFontLoader.loadAsync).toHaveBeenCalledTimes(1); const mockAsset2 = _createMockAsset(); await Font.loadAsync('test-font', mockAsset2); expect(NativeFontLoader.loadAsync).toHaveBeenCalledTimes(1); expect(Font.isLoaded('test-font')).toBe(true); expect(Font.isLoading('test-font')).toBe(false); }); it(`can load an already downloaded asset`, async () => { const mockAsset = _createMockAsset(); await Font.loadAsync('test-font', mockAsset); const loadPromise = Font.loadAsync('test-font', mockAsset); expect(Font.isLoading('test-font')).toBe(false); expect(Font.isLoaded('test-font')).toBe(true); await loadPromise; expect(Font.isLoading('test-font')).toBe(false); expect(Font.isLoaded('test-font')).toBe(true); }); it(`downloads a font that failed to load`, async () => { const NativeFontLoader = NativeModulesProxy.ExpoFontLoader; const mockAsset1 = _createMockAsset({ localUri: 'file:/test/test-font.ttf', downloadAsync: jest.fn(async () => {}), }); await expect(Font.loadAsync('test-font', mockAsset1)).rejects.toBeDefined(); expect(NativeFontLoader.loadAsync).not.toHaveBeenCalled(); expect(Font.isLoaded('test-font')).toBe(false); expect(Font.isLoading('test-font')).toBe(false); const mockAsset2 = _createMockAsset(); await Font.loadAsync('test-font', mockAsset2); expect(NativeFontLoader.loadAsync).toHaveBeenCalledTimes(1); expect(Font.isLoaded('test-font')).toBe(true); expect(Font.isLoading('test-font')).toBe(false); }); it(`coalesces concurrent loads`, async () => { const NativeFontLoader = NativeModulesProxy.ExpoFontLoader; const mockAsset1 = _createMockAsset(); const loadPromise1 = Font.loadAsync('test-font', mockAsset1); expect(Font.isLoaded('test-font')).toBe(false); expect(Font.isLoading('test-font')).toBe(true); const mockAsset2 = _createMockAsset(); const loadPromise2 = Font.loadAsync('test-font', mockAsset2); expect(Font.isLoaded('test-font')).toBe(false); expect(Font.isLoading('test-font')).toBe(true); await Promise.all([loadPromise1, loadPromise2]); expect(NativeFontLoader.loadAsync).toHaveBeenCalledTimes(1); expect(Font.isLoaded('test-font')).toBe(true); expect(Font.isLoading('test-font')).toBe(false); }); it(`rejects all coalesced loads`, async () => { const mockAsset1 = { downloaded: false, downloadAsync: jest.fn(async () => {}), }; const loadPromise1 = Font.loadAsync('test-font', mockAsset1); expect(Font.isLoaded('test-font')).toBe(false); expect(Font.isLoading('test-font')).toBe(true); const mockAsset2 = _createMockAsset(); const loadPromise2 = Font.loadAsync('test-font', mockAsset2); expect(Font.isLoaded('test-font')).toBe(false); expect(Font.isLoading('test-font')).toBe(true); await expect(loadPromise1).rejects.toBeDefined(); await expect(loadPromise2).rejects.toBeDefined(); expect(Font.isLoaded('test-font')).toBe(false); expect(Font.isLoading('test-font')).toBe(false); }); it(`accepts a map of fonts to multi-load`, async () => { await Font.loadAsync({ 'test-font-1': _createMockAsset({ localUri: 'file:/test/test-font-1.ttf', }), 'test-font-2': _createMockAsset({ localUri: 'file:/test/test-font-2.ttf', }), }); expect(Font.isLoaded('test-font-1')).toBe(true); expect(Font.isLoaded('test-font-2')).toBe(true); }); it(`rejects if any font in the map fails to load`, async () => { const mockAsset2 = { downloaded: false, downloadAsync: jest.fn(async () => {}), }; await expect( Font.loadAsync({ 'test-font-1': _createMockAsset({ localUri: 'file:/test/test-font-1.ttf', }), 'test-font-2': mockAsset2, }) ).rejects.toBeDefined(); // We don't guarantee whether the first font will have loaded or // even finished loading but the internal state should be // consistent expect(() => Font.isLoaded('test-font-1')).not.toThrow(); expect(() => Font.isLoading('test-font-1')).not.toThrow(); expect(Font.isLoaded('test-font-2')).toBe(false); }); it(`coalesces concurrent loads across maps`, async () => { const NativeFontLoader = NativeModulesProxy.ExpoFontLoader; const loadPromise1 = Font.loadAsync({ 'test-font-1': _createMockAsset({ localUri: 'file:/test/test-font-1.ttf', }), 'test-font-2': _createMockAsset({ localUri: 'file:/test/test-font-2.ttf', }), }); expect(Font.isLoaded('test-font-1')).toBe(false); expect(Font.isLoaded('test-font-2')).toBe(false); expect(Font.isLoading('test-font-1')).toBe(true); expect(Font.isLoading('test-font-2')).toBe(true); const loadPromise2 = Font.loadAsync({ 'test-font-1': _createMockAsset({ localUri: 'file:/test/test-font-1.ttf', }), }); expect(Font.isLoaded('test-font-1')).toBe(false); expect(Font.isLoading('test-font-1')).toBe(true); await Promise.all([loadPromise1, loadPromise2]); expect(NativeFontLoader.loadAsync).toHaveBeenCalledTimes(2); expect(Font.isLoaded('test-font-1')).toBe(true); expect(Font.isLoaded('test-font-2')).toBe(true); expect(Font.isLoading('test-font-1')).toBe(false); expect(Font.isLoading('test-font-2')).toBe(false); }); }); describe('processFontFamily', () => { let originalConsole; beforeEach(() => { originalConsole = console; }); afterEach(() => { console = originalConsole; // eslint-disable-line no-global-assign }); it(`handles empty values`, () => { expect(Font.processFontFamily(null)).toBeNull(); expect(Font.processFontFamily(undefined as any)).toBeUndefined(); }); it(`handles the system font`, () => { expect(Font.processFontFamily('System')).toBe('System'); }); it(`handles built-in fonts`, () => { expect(Font.processFontFamily('Helvetica')).toBe('Helvetica'); }); it(`defaults missing fonts to the system font`, () => { console.error = jest.fn(); const fontName = 'not-loaded'; expect(Font.isLoaded(fontName)).toBe(false); expect(Font.processFontFamily(fontName)).toBe('System'); expect(console.error).toHaveBeenCalled(); expect((console.error as jest.Mock).mock.calls[0]).toMatchSnapshot(); }); it(`defaults still-loading fonts to the system font`, () => { console.error = jest.fn(); const fontName = 'loading'; const mockAsset = _createMockAsset(); Font.loadAsync(fontName, mockAsset); expect(Font.isLoaded(fontName)).toBe(false); expect(Font.isLoading(fontName)).toBe(true); expect(Font.processFontFamily(fontName)).toBe('System'); expect(console.error).toHaveBeenCalled(); expect((console.error as jest.Mock).mock.calls[0]).toMatchSnapshot(); }); it(`scopes loaded names of loaded fonts`, async () => { const fontName = 'test-font'; const mockAsset = _createMockAsset(); await Font.loadAsync(fontName, mockAsset); expect(Font.isLoaded(fontName)).toBe(true); const processedFontFamily = Font.processFontFamily(fontName); expect(processedFontFamily).toContain(fontName); expect(processedFontFamily).toMatchSnapshot(); }); it(`doesn't re-process Expo fonts`, async () => { const fontName = 'test-font'; const mockAsset = _createMockAsset(); await Font.loadAsync(fontName, mockAsset); expect(Font.isLoaded(fontName)).toBe(true); const processedFontFamily = Font.processFontFamily(fontName); expect(Font.processFontFamily(processedFontFamily)).toBe(processedFontFamily); }); }); }); describe('in standalone app', () => { beforeAll(() => { jest.doMock('expo-constants', () => ({ manifest: {}, sessionId: 'testsession', systemFonts: ['Helvetica', 'Helvetica Neue'], appOwnership: 'standalone', })); }); afterAll(() => { jest.unmock('expo-constants'); }); // NOTE(brentvatne): we need to disable scoping on native side on iOS // in standalone apps: https://github.com/expo/expo/issues/5118 xit(`does not scope font names`, async () => { const fontName = 'test-font'; const mockAsset = _createMockAsset(); await Font.loadAsync(fontName, mockAsset); expect(Font.isLoaded(fontName)).toBe(true); const processedFontFamily = Font.processFontFamily(fontName); expect(processedFontFamily).toEqual(fontName); }); }); describe('in bare workflow', () => { beforeAll(() => { jest.doMock('expo-constants', () => ({ manifest: {}, sessionId: 'testsession', systemFonts: ['Helvetica', 'Helvetica Neue'], })); }); it(`does not scope font names`, async () => { const fontName = 'test-font'; const mockAsset = _createMockAsset(); await Font.loadAsync(fontName, mockAsset); expect(Font.isLoaded(fontName)).toBe(true); const processedFontFamily = Font.processFontFamily(fontName); expect(processedFontFamily).toEqual(fontName); }); });