diff --git a/src/lib/FormatManager.ts b/src/lib/FormatManager.ts new file mode 100644 index 0000000..88f95e6 --- /dev/null +++ b/src/lib/FormatManager.ts @@ -0,0 +1,65 @@ +import AssetManager from './AssetManager'; + +interface FormatConstructor { + new (...args: any[]): T; +} + +interface Format { + load: (data: ArrayBuffer) => T; +} + +class FormatManager { + #assetManager: AssetManager; + #loaded = new Map(); + #loading = new Map>(); + + constructor(assetManager: AssetManager) { + this.#assetManager = assetManager; + } + + get>( + path: string, + FormatClass: FormatConstructor, + ...formatConstructorArgs: any[] + ): Promise { + const cacheKey = `${path}:${FormatClass.prototype.constructor.name}:${formatConstructorArgs}`; + + const loaded = this.#loaded.get(cacheKey); + if (loaded) { + return Promise.resolve(loaded); + } + + const alreadyLoading = this.#loading.get(cacheKey); + if (alreadyLoading) { + return alreadyLoading; + } + + const loading = this.#load(cacheKey, path, FormatClass, formatConstructorArgs); + this.#loading.set(cacheKey, loading); + + return loading; + } + + async #load>( + cacheKey: string, + path: string, + FormatClass: FormatConstructor, + formatConstructorArgs: any[], + ): Promise { + let instance: T; + try { + const data = await this.#assetManager.get(path); + instance = new FormatClass(...formatConstructorArgs).load(data); + + this.#loaded.set(cacheKey, instance); + } finally { + this.#loading.delete(cacheKey); + } + + return instance; + } +} + +export default FormatManager; + +export { FormatManager }; diff --git a/src/spec/FormatManager.spec.ts b/src/spec/FormatManager.spec.ts new file mode 100644 index 0000000..20382e5 --- /dev/null +++ b/src/spec/FormatManager.spec.ts @@ -0,0 +1,87 @@ +import FormatManager from '../lib/FormatManager'; +import AssetManager from '../lib/AssetManager'; +import { describe, expect, Mock, test, vi } from 'vitest'; + +const getMockAssetManager = () => { + const assetManager = new AssetManager('http://example.local', true); + assetManager.get = vi.fn(); + return assetManager; +}; + +class SimpleFormat { + #simpleField: number; + #loaded = false; + + constructor(simpleArg: number) { + this.#simpleField = simpleArg; + } + + get simpleField() { + return this.#simpleField; + } + + get loaded() { + return this.#loaded; + } + + load(data: ArrayBuffer) { + this.#loaded = true; + return this; + } +} + +describe('FormatManager', () => { + describe('get', () => { + test('should return new format instance when not previously loaded', async () => { + const mockAssetManager = getMockAssetManager(); + const formatManager = new FormatManager(mockAssetManager); + + const instance = await formatManager.get('foo', SimpleFormat, 7); + + expect(mockAssetManager.get).toHaveBeenCalledWith('foo'); + expect(instance).toBeInstanceOf(SimpleFormat); + expect(instance.simpleField).toBe(7); + expect(instance.loaded).toBe(true); + }); + + test('should return new format instance when previously loaded with different constructor args', async () => { + const mockAssetManager = getMockAssetManager(); + const formatManager = new FormatManager(mockAssetManager); + + const instance1 = await formatManager.get('foo', SimpleFormat, 7); + const instance2 = await formatManager.get('foo', SimpleFormat, 8); + + expect(mockAssetManager.get).toHaveBeenCalledWith('foo'); + + expect(instance1).toBeInstanceOf(SimpleFormat); + expect(instance1.simpleField).toBe(7); + expect(instance1.loaded).toBe(true); + + expect(instance2).toBeInstanceOf(SimpleFormat); + expect(instance2.simpleField).toBe(8); + expect(instance2.loaded).toBe(true); + expect(instance2 !== instance1).toBe(true); + }); + + test('should return existing format instance when previously loaded', async () => { + const mockAssetManager = getMockAssetManager(); + const formatManager = new FormatManager(mockAssetManager); + + const instance1 = await formatManager.get('foo', SimpleFormat, 7); + const instance2 = await formatManager.get('foo', SimpleFormat, 7); + + expect(mockAssetManager.get).toHaveBeenCalledOnce(); + expect(instance1).toBeInstanceOf(SimpleFormat); + expect(instance1).toBe(instance2); + }); + + test('should throw when asset data is not available', async () => { + const mockAssetManager = getMockAssetManager(); + const formatManager = new FormatManager(mockAssetManager); + + (mockAssetManager.get as Mock).mockRejectedValue(new Error('ohno')); + + await expect(formatManager.get('foo', SimpleFormat, 7)).rejects.toBeInstanceOf(Error); + }); + }); +});