728380498b
Both names were vague or overloaded: - fontStore / FontStore -> fontCatalogStore / FontCatalogStore Three font-related stores live in this slice; the new name names the paginated catalog specifically. - appliedFontsManager / AppliedFontsManager -> fontLifecycleManager / FontLifecycleManager "Applied" collided with the filter-side appliedFilterStore (different meaning). The class actually orchestrates a load-use-evict lifecycle with FontBufferCache + FontEvictionPolicy + FontLoadQueue collaborators, so "Manager" is justified. Companion types file moved alongside (appliedFonts.ts -> fontLifecycle.ts). Directories, file basenames, factory (createFontStore -> createFontCatalogStore), and the AppliedFontsManagerDeps interface all renamed. All consumers (ComparisonView, SampleList, FontList, FontApplicator, FontVirtualList, FilterAndSortFonts bindings, createFontRowSizeResolver, mocks) updated.
319 lines
12 KiB
TypeScript
319 lines
12 KiB
TypeScript
/**
|
|
* @vitest-environment jsdom
|
|
*/
|
|
import { FontFetchError } from './errors';
|
|
import { FontLifecycleManager } from './fontLifecycleManager.svelte';
|
|
import { FontEvictionPolicy } from './utils/fontEvictionPolicy/FontEvictionPolicy';
|
|
|
|
class FakeBufferCache {
|
|
async get(_url: string): Promise<ArrayBuffer> {
|
|
return new ArrayBuffer(8);
|
|
}
|
|
evict(_url: string): void {}
|
|
clear(): void {}
|
|
}
|
|
|
|
/**
|
|
* Throws {@link FontFetchError} on every `get()` — simulates network/HTTP failure.
|
|
*/
|
|
class FailingBufferCache {
|
|
async get(url: string): Promise<never> {
|
|
throw new FontFetchError(url, new Error('network error'), 500);
|
|
}
|
|
evict(_url: string): void {}
|
|
clear(): void {}
|
|
}
|
|
|
|
const makeConfig = (id: string, overrides: Partial<{ weight: number; isVariable: boolean }> = {}) => ({
|
|
id,
|
|
name: id,
|
|
url: `https://example.com/${id}.woff2`,
|
|
weight: 400,
|
|
...overrides,
|
|
});
|
|
|
|
describe('FontLifecycleManager', () => {
|
|
let manager: FontLifecycleManager;
|
|
let eviction: FontEvictionPolicy;
|
|
let mockFontFaceSet: { add: ReturnType<typeof vi.fn>; delete: ReturnType<typeof vi.fn> };
|
|
|
|
beforeEach(() => {
|
|
vi.useFakeTimers();
|
|
eviction = new FontEvictionPolicy({ ttl: 60000 });
|
|
mockFontFaceSet = { add: vi.fn(), delete: vi.fn() };
|
|
|
|
Object.defineProperty(document, 'fonts', {
|
|
value: mockFontFaceSet,
|
|
configurable: true,
|
|
writable: true,
|
|
});
|
|
|
|
const MockFontFace = vi.fn(function(this: any, name: string, buffer: BufferSource) {
|
|
this.name = name;
|
|
this.buffer = buffer;
|
|
this.load = vi.fn().mockResolvedValue(this);
|
|
});
|
|
vi.stubGlobal('FontFace', MockFontFace);
|
|
|
|
manager = new FontLifecycleManager({ cache: new FakeBufferCache() as any, eviction });
|
|
});
|
|
|
|
afterEach(() => {
|
|
vi.clearAllTimers();
|
|
vi.useRealTimers();
|
|
vi.unstubAllGlobals();
|
|
});
|
|
|
|
describe('touch()', () => {
|
|
it('queues and loads a new font', async () => {
|
|
manager.touch([makeConfig('roboto')]);
|
|
await vi.advanceTimersByTimeAsync(50);
|
|
|
|
expect(manager.getFontStatus('roboto', 400)).toBe('loaded');
|
|
});
|
|
|
|
it('batches multiple fonts into a single queue flush', async () => {
|
|
manager.touch([makeConfig('lato'), makeConfig('inter')]);
|
|
await vi.advanceTimersByTimeAsync(50);
|
|
|
|
expect(mockFontFaceSet.add).toHaveBeenCalledTimes(2);
|
|
});
|
|
|
|
it('skips fonts that are already loaded', async () => {
|
|
manager.touch([makeConfig('lato')]);
|
|
await vi.advanceTimersByTimeAsync(50);
|
|
|
|
manager.touch([makeConfig('lato')]);
|
|
await vi.advanceTimersByTimeAsync(50);
|
|
|
|
expect(mockFontFaceSet.add).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it('skips fonts that are currently loading', async () => {
|
|
manager.touch([makeConfig('lato')]);
|
|
// simulate loading state before queue drains
|
|
manager.statuses.set('lato@400', 'loading');
|
|
manager.touch([makeConfig('lato')]);
|
|
await vi.advanceTimersByTimeAsync(50);
|
|
|
|
expect(mockFontFaceSet.add).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it('skips fonts that have exhausted retries', async () => {
|
|
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
|
|
const failManager = new FontLifecycleManager({ cache: new FailingBufferCache() as any, eviction });
|
|
|
|
// exhaust all 3 retries
|
|
for (let i = 0; i < 3; i++) {
|
|
failManager.statuses.delete('broken@400');
|
|
failManager.touch([makeConfig('broken')]);
|
|
await vi.advanceTimersByTimeAsync(50);
|
|
}
|
|
|
|
failManager.touch([makeConfig('broken')]);
|
|
await vi.advanceTimersByTimeAsync(50);
|
|
|
|
expect(failManager.getFontStatus('broken', 400)).toBe('error');
|
|
expect(mockFontFaceSet.add).not.toHaveBeenCalled();
|
|
consoleSpy.mockRestore();
|
|
});
|
|
|
|
it('does nothing after manager is destroyed', async () => {
|
|
manager.destroy();
|
|
manager.touch([makeConfig('roboto')]);
|
|
await vi.advanceTimersByTimeAsync(50);
|
|
|
|
expect(manager.statuses.size).toBe(0);
|
|
});
|
|
});
|
|
|
|
describe('queue processing', () => {
|
|
it('filters non-critical weights in data-saver mode', async () => {
|
|
(navigator as any).connection = { saveData: true };
|
|
|
|
manager.touch([
|
|
makeConfig('light', { weight: 300 }),
|
|
makeConfig('regular', { weight: 400 }),
|
|
makeConfig('bold', { weight: 700 }),
|
|
]);
|
|
await vi.advanceTimersByTimeAsync(50);
|
|
|
|
expect(manager.getFontStatus('light', 300)).toBeUndefined();
|
|
expect(manager.getFontStatus('regular', 400)).toBe('loaded');
|
|
expect(manager.getFontStatus('bold', 700)).toBe('loaded');
|
|
|
|
delete (navigator as any).connection;
|
|
});
|
|
|
|
it('loads variable fonts in data-saver mode regardless of weight', async () => {
|
|
(navigator as any).connection = { saveData: true };
|
|
|
|
manager.touch([makeConfig('vf', { weight: 300, isVariable: true })]);
|
|
await vi.advanceTimersByTimeAsync(50);
|
|
|
|
expect(manager.getFontStatus('vf', 300, true)).toBe('loaded');
|
|
|
|
delete (navigator as any).connection;
|
|
});
|
|
});
|
|
|
|
describe('Phase 1 — fetch', () => {
|
|
it('sets status to error on fetch failure', async () => {
|
|
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
|
|
const failManager = new FontLifecycleManager({ cache: new FailingBufferCache() as any, eviction });
|
|
|
|
failManager.touch([makeConfig('broken')]);
|
|
await vi.advanceTimersByTimeAsync(50);
|
|
|
|
expect(failManager.getFontStatus('broken', 400)).toBe('error');
|
|
consoleSpy.mockRestore();
|
|
});
|
|
|
|
it('logs a console error on fetch failure', async () => {
|
|
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
|
|
const failManager = new FontLifecycleManager({ cache: new FailingBufferCache() as any, eviction });
|
|
|
|
failManager.touch([makeConfig('broken')]);
|
|
await vi.advanceTimersByTimeAsync(50);
|
|
|
|
expect(consoleSpy).toHaveBeenCalled();
|
|
consoleSpy.mockRestore();
|
|
});
|
|
|
|
it('does not set error status or log for aborted fetches', async () => {
|
|
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
|
|
const abortingCache = {
|
|
async get(url: string): Promise<never> {
|
|
throw new FontFetchError(url, Object.assign(new Error('Aborted'), { name: 'AbortError' }));
|
|
},
|
|
evict() {},
|
|
clear() {},
|
|
};
|
|
const abortManager = new FontLifecycleManager({ cache: abortingCache as any, eviction });
|
|
|
|
abortManager.touch([makeConfig('aborted')]);
|
|
await vi.advanceTimersByTimeAsync(50);
|
|
|
|
// status is left as 'loading' (not 'error') — abort is not a retriable failure
|
|
expect(abortManager.getFontStatus('aborted', 400)).not.toBe('error');
|
|
expect(consoleSpy).not.toHaveBeenCalled();
|
|
consoleSpy.mockRestore();
|
|
});
|
|
});
|
|
|
|
describe('Phase 2 — parse', () => {
|
|
it('sets status to error on parse failure', async () => {
|
|
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
|
|
const FailingFontFace = vi.fn(function(this: any) {
|
|
this.load = vi.fn().mockRejectedValue(new Error('parse failed'));
|
|
});
|
|
vi.stubGlobal('FontFace', FailingFontFace);
|
|
|
|
manager.touch([makeConfig('broken')]);
|
|
await vi.advanceTimersByTimeAsync(50);
|
|
|
|
expect(manager.getFontStatus('broken', 400)).toBe('error');
|
|
consoleSpy.mockRestore();
|
|
});
|
|
|
|
it('logs a console error on parse failure', async () => {
|
|
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
|
|
const FailingFontFace = vi.fn(function(this: any) {
|
|
this.load = vi.fn().mockRejectedValue(new Error('parse failed'));
|
|
});
|
|
vi.stubGlobal('FontFace', FailingFontFace);
|
|
|
|
manager.touch([makeConfig('broken')]);
|
|
await vi.advanceTimersByTimeAsync(50);
|
|
|
|
expect(consoleSpy).toHaveBeenCalled();
|
|
consoleSpy.mockRestore();
|
|
});
|
|
});
|
|
|
|
describe('#purgeUnused', () => {
|
|
it('evicts fonts after TTL expires', async () => {
|
|
manager.touch([makeConfig('ephemeral')]);
|
|
await vi.advanceTimersByTimeAsync(50);
|
|
|
|
await vi.advanceTimersByTimeAsync(61000);
|
|
|
|
expect(manager.getFontStatus('ephemeral', 400)).toBeUndefined();
|
|
expect(mockFontFaceSet.delete).toHaveBeenCalled();
|
|
});
|
|
|
|
it('removes the evicted key from the eviction policy', async () => {
|
|
manager.touch([makeConfig('ephemeral')]);
|
|
await vi.advanceTimersByTimeAsync(50);
|
|
|
|
await vi.advanceTimersByTimeAsync(61000);
|
|
|
|
expect(Array.from(eviction.keys())).not.toContain('ephemeral@400');
|
|
});
|
|
|
|
it('refreshes TTL when font is re-touched before expiry', async () => {
|
|
const config = makeConfig('active');
|
|
manager.touch([config]);
|
|
await vi.advanceTimersByTimeAsync(50);
|
|
|
|
await vi.advanceTimersByTimeAsync(40000);
|
|
manager.touch([config]); // refresh at t≈40s
|
|
|
|
await vi.advanceTimersByTimeAsync(25000); // purge at t≈60s sees only ~20s elapsed → not evicted
|
|
|
|
expect(manager.getFontStatus('active', 400)).toBe('loaded');
|
|
});
|
|
|
|
it('does not evict pinned fonts', async () => {
|
|
manager.touch([makeConfig('pinned')]);
|
|
await vi.advanceTimersByTimeAsync(50);
|
|
|
|
manager.pin('pinned', 400);
|
|
await vi.advanceTimersByTimeAsync(61000);
|
|
|
|
expect(manager.getFontStatus('pinned', 400)).toBe('loaded');
|
|
expect(mockFontFaceSet.delete).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('evicts font after it is unpinned and TTL expires', async () => {
|
|
manager.touch([makeConfig('toggled')]);
|
|
await vi.advanceTimersByTimeAsync(50);
|
|
|
|
manager.pin('toggled', 400);
|
|
manager.unpin('toggled', 400);
|
|
await vi.advanceTimersByTimeAsync(61000);
|
|
|
|
expect(manager.getFontStatus('toggled', 400)).toBeUndefined();
|
|
expect(mockFontFaceSet.delete).toHaveBeenCalled();
|
|
});
|
|
});
|
|
|
|
describe('destroy()', () => {
|
|
it('clears all statuses', async () => {
|
|
manager.touch([makeConfig('roboto')]);
|
|
await vi.advanceTimersByTimeAsync(50);
|
|
|
|
manager.destroy();
|
|
|
|
expect(manager.statuses.size).toBe(0);
|
|
});
|
|
|
|
it('removes all loaded fonts from document.fonts', async () => {
|
|
manager.touch([makeConfig('roboto'), makeConfig('inter')]);
|
|
await vi.advanceTimersByTimeAsync(50);
|
|
|
|
manager.destroy();
|
|
|
|
expect(mockFontFaceSet.delete).toHaveBeenCalledTimes(2);
|
|
});
|
|
|
|
it('prevents further loading after destroy', async () => {
|
|
manager.destroy();
|
|
manager.touch([makeConfig('roboto')]);
|
|
await vi.advanceTimersByTimeAsync(50);
|
|
|
|
expect(manager.statuses.size).toBe(0);
|
|
});
|
|
});
|
|
});
|