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);
});
});