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