import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { prefetchFont, clearFontPrefetch, takeSdfPrefetch, takeCanvasPrefetch, } from '../FontPrefetch.js'; import { loadFont as loadSdfFont, isFontLoaded } from '../SdfFontHandler.js'; import { loadFont as loadCanvasFont, isFontLoaded as isCanvasFontLoaded, } from '../CanvasFontHandler.js'; import { EventEmitter } from '../../../common/EventEmitter.js'; import type { Stage } from '../../Stage.js'; import type { ImageTextureProps } from '../../textures/ImageTexture.js'; const VALID_FONT_DATA = { chars: [{}], kernings: [], lightningMetrics: { ascender: 800, descender: -200, lineGap: 200, unitsPerEm: 1000, }, }; // Records every request so tests can assert what was fetched, when, and in // what order. Responses are keyed by URL and delivered on demand, letting a // test hold a request open to observe concurrency. class RecordingXHR { static requests: RecordingXHR[] = []; static responses: Record = {}; status = 200; response: unknown = null; responseType = ''; url = ''; onload: (() => void) | null = null; onerror: (() => void) | null = null; onreadystatechange: (() => void) | null = null; readyState = 0; open(_method: string, url: string): void { this.url = url; } send(): void { RecordingXHR.requests.push(this); } /** Deliver the configured response for this request's URL. */ respond(status = 200): void { this.status = status; this.response = RecordingXHR.responses[this.url] ?? null; this.readyState = 4; if (this.onreadystatechange !== null) this.onreadystatechange(); if (this.onload !== null) this.onload(); } /** Deliver every outstanding request. */ static respondAll(): void { RecordingXHR.requests.forEach((r) => r.respond()); } static reset(): void { RecordingXHR.requests = []; RecordingXHR.responses = {}; } } // `XMLHttpRequest.DONE` is read by fetchJson; the class stand-in must carry it. (RecordingXHR as unknown as { DONE: number }).DONE = 4; function makeFakeTexture() { const tex = new EventEmitter() as unknown as EventEmitter & { state: string; preventCleanup: boolean; setRenderableOwner: (owner: string, val: boolean) => void; }; tex.state = 'loading'; tex.preventCleanup = false; tex.setRenderableOwner = () => {}; return tex; } type FakeTexture = ReturnType; // Stage stub that records the props each atlas texture was created with, so // tests can assert whether the prefetched blob (or the URL) was used. function makeStage(): { stage: Stage; textures: FakeTexture[]; textureProps: ImageTextureProps[]; } { const textures: FakeTexture[] = []; const textureProps: ImageTextureProps[] = []; const stage = { txManager: { createTexture: (_type: string, props: ImageTextureProps) => { textureProps.push(props); const t = makeFakeTexture(); textures.push(t); return t; }, removeTextureFromCache: () => {}, }, } as unknown as Stage; return { stage, textures, textureProps }; } const flush = (): Promise => new Promise((r) => setTimeout(r, 0)); const sdfOpts = (fontFamily: string) => ({ fontFamily, atlasUrl: `${fontFamily}.png`, atlasDataUrl: `${fontFamily}.json`, }); describe('FontPrefetch — SDF', () => { let originalXHR: unknown; let originalBlob: unknown; beforeEach(() => { originalXHR = (globalThis as unknown as { XMLHttpRequest: unknown }) .XMLHttpRequest; (globalThis as unknown as { XMLHttpRequest: unknown }).XMLHttpRequest = RecordingXHR; // jsdom provides Blob; guard for environments that do not. originalBlob = (globalThis as unknown as { Blob: unknown }).Blob; RecordingXHR.reset(); clearFontPrefetch(); }); afterEach(() => { (globalThis as unknown as { XMLHttpRequest: unknown }).XMLHttpRequest = originalXHR; (globalThis as unknown as { Blob: unknown }).Blob = originalBlob; clearFontPrefetch(); }); it('requests the atlas description and the atlas image concurrently', () => { prefetchFont(sdfOpts('ConcurrentFont')); // Both are in flight before either has responded — the handler's own path // only starts the image fetch after the JSON resolves. expect(RecordingXHR.requests.map((r) => r.url).sort()).toEqual([ 'ConcurrentFont.json', 'ConcurrentFont.png', ]); }); it('starts no request for a font it has already prefetched', () => { prefetchFont(sdfOpts('DedupeFont')); expect(RecordingXHR.requests.length).toBe(2); prefetchFont(sdfOpts('DedupeFont')); expect(RecordingXHR.requests.length).toBe(2); }); it('hands the prefetched payload to loadFont instead of refetching', async () => { const font = sdfOpts('PrefetchHit'); const blob = new Blob(['atlas-bytes']); RecordingXHR.responses['PrefetchHit.json'] = VALID_FONT_DATA; RecordingXHR.responses['PrefetchHit.png'] = blob; prefetchFont(font); RecordingXHR.respondAll(); const prefetchRequestCount = RecordingXHR.requests.length; const { stage, textures, textureProps } = makeStage(); const promise = loadSdfFont( stage, font as Parameters[1], ); await flush(); textures[0]!.emit('loaded'); await promise; // No second trip to the network for either resource. expect(RecordingXHR.requests.length).toBe(prefetchRequestCount); // The atlas texture was built from the prefetched bytes... expect(textureProps[0]!.src).toBe(blob); // ...while still keying the texture cache by URL, so the blob path and the // URL path resolve to the same cache entry. expect(textureProps[0]!.key).toBe('PrefetchHit.png'); expect(isFontLoaded('PrefetchHit')).toBe(true); }); it('falls back to fetching when the prefetch failed', async () => { const font = sdfOpts('PrefetchFail'); // No responses registered -> fetchJson resolves null -> unusable prefetch. prefetchFont(font); RecordingXHR.respondAll(); RecordingXHR.reset(); RecordingXHR.responses['PrefetchFail.json'] = VALID_FONT_DATA; const { stage, textures, textureProps } = makeStage(); const promise = loadSdfFont( stage, font as Parameters[1], ); // The handler runs its own XHR for the atlas description. await flush(); RecordingXHR.respondAll(); await flush(); textures[0]!.emit('loaded'); await promise; expect(RecordingXHR.requests.map((r) => r.url)).toEqual([ 'PrefetchFail.json', ]); // Atlas falls back to the URL rather than a blob. expect(textureProps[0]!.src).toBe('PrefetchFail.png'); expect(isFontLoaded('PrefetchFail')).toBe(true); }); it('rejects a malformed atlas description rather than loading it', async () => { RecordingXHR.responses['Malformed.json'] = { not: 'font data' }; prefetchFont(sdfOpts('Malformed')); RecordingXHR.respondAll(); await expect(takeSdfPrefetch('Malformed')!.data).resolves.toBe(null); }); it('parses an atlas description that arrives as a string', async () => { RecordingXHR.responses['StringJson.json'] = JSON.stringify(VALID_FONT_DATA); prefetchFont(sdfOpts('StringJson')); RecordingXHR.respondAll(); await expect(takeSdfPrefetch('StringJson')!.data).resolves.toMatchObject({ chars: [{}], }); }); it('claims a prefetch only once', () => { prefetchFont(sdfOpts('OnceOnly')); expect(takeSdfPrefetch('OnceOnly')).not.toBe(undefined); expect(takeSdfPrefetch('OnceOnly')).toBe(undefined); }); it('ignores a canvas-typed descriptor even when it carries atlas urls', () => { prefetchFont({ ...sdfOpts('CanvasTyped'), type: 'canvas' }); expect(takeSdfPrefetch('CanvasTyped')).toBe(undefined); }); }); describe('FontPrefetch — web fonts', () => { class FakeFontFace { static created: FakeFontFace[] = []; constructor(public family: string, public source: string) { FakeFontFace.created.push(this); } load(): Promise { return Promise.resolve(this); } } let originalFontFace: unknown; beforeEach(() => { originalFontFace = (globalThis as unknown as { FontFace: unknown }) .FontFace; (globalThis as unknown as { FontFace: unknown }).FontFace = FakeFontFace; FakeFontFace.created = []; clearFontPrefetch(); }); afterEach(() => { (globalThis as unknown as { FontFace: unknown }).FontFace = originalFontFace; clearFontPrefetch(); }); it('starts the download for every registered name', async () => { prefetchFont({ fontFamily: ['WebPrimary', 'WebAlias'], fontUrl: 'web.woff2', }); expect(FakeFontFace.created.map((f) => f.family)).toEqual([ 'WebPrimary', 'WebAlias', ]); await expect(takeCanvasPrefetch('WebPrimary')).resolves.toHaveLength(2); }); it('resolves to null when the download fails, so the handler can retry', async () => { vi.spyOn(FakeFontFace.prototype, 'load').mockRejectedValueOnce( new Error('network'), ); prefetchFont({ fontFamily: 'WebFail', fontUrl: 'web.woff2' }); await expect(takeCanvasPrefetch('WebFail')).resolves.toBe(null); }); it('does nothing when the engine has no FontFace', () => { (globalThis as unknown as { FontFace: unknown }).FontFace = undefined; prefetchFont({ fontFamily: 'NoFontFace', fontUrl: 'web.woff2' }); expect(takeCanvasPrefetch('NoFontFace')).toBe(undefined); }); it('registers prefetched faces with the platform without reloading them', async () => { prefetchFont({ fontFamily: 'CanvasHit', fontUrl: 'web.woff2' }); const createdByPrefetch = FakeFontFace.created.length; const added: FakeFontFace[] = []; const stage = { platform: { addFont: (f: FakeFontFace) => added.push(f) }, } as unknown as Stage; await loadCanvasFont(stage, { fontFamily: 'CanvasHit', fontUrl: 'web.woff2', }); // No extra FontFace was constructed — the prefetched one was used. expect(FakeFontFace.created.length).toBe(createdByPrefetch); expect(added).toEqual(FakeFontFace.created); expect(isCanvasFontLoaded('CanvasHit')).toBe(true); }); it('loads normally when there is no prefetch to claim', async () => { const added: FakeFontFace[] = []; const stage = { platform: { addFont: (f: FakeFontFace) => added.push(f) }, } as unknown as Stage; await loadCanvasFont(stage, { fontFamily: 'CanvasMiss', fontUrl: 'web.woff2', }); expect(FakeFontFace.created.map((f) => f.family)).toEqual(['CanvasMiss']); expect(isCanvasFontLoaded('CanvasMiss')).toBe(true); }); });