UNPKG

@spearwolf/twopoint5d

Version:

Create 2.5D realtime graphics and pixelart with WebGL and three.js

566 lines 24.8 kB
import { getSubscriptionCount, on } from '@spearwolf/eventize'; import { ImageLoader } from 'three/webgpu'; import { describe, expect, test, vi } from 'vitest'; import { TextureResource, TextureResourceEvents, TextureResourceSubtypes } from './TextureResource.js'; import { TextureStore, TextureStoreEvents } from './TextureStore.js'; const flushMicrotasks = () => new Promise((resolve) => setTimeout(resolve, 0)); describe('TextureStore', () => { test('create', () => { const store = new TextureStore(); expect(store).toBeInstanceOf(TextureStore); }); test('on', () => { const store = new TextureStore(); const wait = store.on('foo', ['atlas', 'imageCoords'], ([atlas, coords]) => { atlas.randomFrame(); coords.flipDiagonal(); }); expect(wait).toBeInstanceOf(Function); wait(); }); describe('parse() input safety', () => { test('does not mutate data.defaultTextureClasses', () => { const store = new TextureStore(); const data = { defaultTextureClasses: ['nearest', 'no-flipy'], items: {}, }; store.parse(data); expect(data.defaultTextureClasses).toEqual(['nearest', 'no-flipy']); expect(store.defaultTextureClasses).toEqual(['nearest', 'no-flipy']); }); test('does not mutate item.texture (textureClasses) arrays', () => { const store = new TextureStore(); const textureClasses = ['nearest', 'flipy']; const data = { defaultTextureClasses: [], items: { tex: { imageUrl: 'foo.png', texture: textureClasses, }, }, }; store.parse(data); expect(textureClasses).toEqual(['nearest', 'flipy']); }); test('re-parsing with same defaultTextureClasses still applies them', () => { const store = new TextureStore(); const data = { defaultTextureClasses: ['linear'], items: {}, }; store.parse(data); store.parse(data); expect(store.defaultTextureClasses).toEqual(['linear']); }); }); describe('dispose()', () => { test('dispose() emits OnDispose on store and on each resource exactly once', () => { const store = new TextureStore(); const data = { defaultTextureClasses: [], items: { a: { imageUrl: 'a.png' }, b: { imageUrl: 'b.png' } }, }; store.parse(data); const storeDispose = vi.fn(); on(store, 'dispose', storeDispose); let resourceCount = 0; const resourceDisposes = { a: 0, b: 0 }; store.onResource('a', (r) => { resourceCount++; on(r, 'dispose', () => { resourceDisposes['a']++; }); }); store.onResource('b', (r) => { resourceCount++; on(r, 'dispose', () => { resourceDisposes['b']++; }); }); expect(resourceCount).toBe(2); expect(() => store.dispose()).not.toThrow(); expect(storeDispose).toHaveBeenCalledTimes(1); expect(resourceDisposes).toEqual({ a: 1, b: 1 }); }); test('TextureResource.dispose() is idempotent and does not throw', () => { const resource = TextureResource.fromImage('x', 'x.png'); expect(() => resource.dispose()).not.toThrow(); expect(() => resource.dispose()).not.toThrow(); }); }); describe('defaultTextureClasses as signal (§4.6)', () => { test('changing defaultTextureClasses propagates merged classes into existing resources on next parse()', () => { const store = new TextureStore(); store.parse({ defaultTextureClasses: ['nearest'], items: { a: { imageUrl: 'a.png' } }, }); let resource; store.onResource('a', (r) => { resource = r; }); expect(resource?.textureClasses).toEqual(['nearest']); store.defaultTextureClasses = ['linear', 'no-flipy']; store.parse({ defaultTextureClasses: ['linear', 'no-flipy'], items: { a: { imageUrl: 'a.png' } }, }); expect(resource?.textureClasses?.sort()).toEqual(['linear', 'no-flipy'].sort()); }); test('assigning defaultTextureClasses with equal content is a no-op (cmp)', () => { const store = new TextureStore(); const before = ['nearest', 'flipy']; store.defaultTextureClasses = before; store.defaultTextureClasses = ['nearest', 'flipy']; expect(store.defaultTextureClasses).toEqual(['nearest', 'flipy']); }); }); describe('parse() batching (§6.4)', () => { test('OnReady fires once after all resources are added', () => { const store = new TextureStore(); let readyCount = 0; let resourcesAtReady = 0; on(store, TextureStoreEvents.Ready, () => { readyCount++; store.onResource('a', () => { resourcesAtReady++; }); store.onResource('b', () => { resourcesAtReady++; }); }); store.parse({ defaultTextureClasses: [], items: { a: { imageUrl: 'a.png' }, b: { imageUrl: 'b.png' } }, }); expect(readyCount).toBe(1); expect(resourcesAtReady).toBe(2); }); }); describe('central TextureFactory (§3.3, §6.1)', () => { test('TextureStore exposes a `textureFactory` that all resources share', () => { const store = new TextureStore(); const stubRenderer = { getMaxAnisotropy: () => 16 }; store.renderer = stubRenderer; store.parse({ defaultTextureClasses: [], items: { a: { imageUrl: 'a.png' }, b: { imageUrl: 'b.png' } }, }); const factory = store.textureFactory; expect(factory).toBeDefined(); let resA; let resB; store.onResource('a', (r) => { resA = r; }); store.onResource('b', (r) => { resB = r; }); expect(resA?.textureFactory).toBe(factory); expect(resB?.textureFactory).toBe(factory); const stubRenderer2 = { getMaxAnisotropy: () => 8 }; store.renderer = stubRenderer2; const factory2 = store.textureFactory; expect(factory2).not.toBe(factory); expect(resA?.textureFactory).toBe(factory2); expect(resB?.textureFactory).toBe(factory2); }); }); describe('on()/get() accept both string-literal and constant forms', () => { test('string-literal form: type narrows correctly for single subtype and tuple', () => { const store = new TextureStore(); const u1 = store.on('a', 'texture', (val) => { void val; }); const u2 = store.on('a', ['atlas', 'imageCoords'], ([atlas, coords]) => { atlas.randomFrame(); coords.flipDiagonal(); }); expect(typeof u1).toBe('function'); expect(typeof u2).toBe('function'); u1(); u2(); }); test('constant form via TextureResourceSubtypes produces the same runtime + type behavior', () => { const store = new TextureStore(); const u1 = store.on('a', TextureResourceSubtypes.Texture, () => { }); const u2 = store.on('a', [TextureResourceSubtypes.Atlas, TextureResourceSubtypes.ImageCoords], () => { }); expect(typeof u1).toBe('function'); expect(typeof u2).toBe('function'); u1(); u2(); }); test('get() resolves with the correctly-typed tuple for the string-literal form', async () => { const store = new TextureStore(); store.parse({ defaultTextureClasses: [], items: { a: { imageUrl: 'a.png' } } }); const p = store.get('a', ['texture', 'imageCoords']); const ac = new AbortController(); ac.abort(); const pAborted = store.get('a', ['texture', 'imageCoords'], { signal: ac.signal }); await expect(pAborted).rejects.toThrow(); void p; store.dispose(); }); }); describe('event constants (§4.3, §4.7)', () => { test('TextureStoreEvents constants match emitted event names', () => { const store = new TextureStore(); const ready = vi.fn(); const dispose = vi.fn(); on(store, TextureStoreEvents.Ready, ready); on(store, TextureStoreEvents.Dispose, dispose); store.parse({ defaultTextureClasses: [], items: {} }); store.dispose(); expect(ready).toHaveBeenCalledTimes(1); expect(dispose).toHaveBeenCalledTimes(1); }); test('TextureResourceSubtypes covers all subtypes', () => { expect(Object.values(TextureResourceSubtypes).sort()).toEqual(['atlas', 'frameBasedAnimations', 'imageCoords', 'texture', 'tileSet'].sort()); }); test('TextureResourceEvents reuses subtype values', () => { expect(TextureResourceEvents.Atlas).toBe(TextureResourceSubtypes.Atlas); }); }); describe('error events instead of console.error (BUG-11)', () => { test("TextureStore.load() emits 'error' on fetch failure", async () => { const fetchMock = vi.spyOn(globalThis, 'fetch').mockRejectedValue(new Error('boom')); try { const store = new TextureStore(); const errorHandler = vi.fn(); on(store, 'error', errorHandler); store.load('http://example.test/bad.json'); await flushMicrotasks(); await flushMicrotasks(); expect(errorHandler).toHaveBeenCalledTimes(1); const event = errorHandler.mock.calls[0][0]; expect(event.source).toBe('fetch'); } finally { fetchMock.mockRestore(); } }); test("TextureStore.load() emits 'error' on parse failure", async () => { const fetchMock = vi.spyOn(globalThis, 'fetch').mockResolvedValue(new Response('not-json')); try { const store = new TextureStore(); const errorHandler = vi.fn(); on(store, 'error', errorHandler); store.load('http://example.test/bad.json'); await flushMicrotasks(); await flushMicrotasks(); expect(errorHandler).toHaveBeenCalledTimes(1); expect(errorHandler.mock.calls[0][0].source).toBe('parse'); } finally { fetchMock.mockRestore(); } }); }); describe('whenResource() / abortable get() (BUG-10)', () => { test('whenResource() resolves when the resource is present at call time', async () => { const store = new TextureStore(); store.parse({ defaultTextureClasses: [], items: { a: { imageUrl: 'a.png' } } }); const resource = await store.whenResource('a'); expect(resource.id).toBe('a'); }); test('whenResource() rejects after first ready if the id is missing', async () => { const store = new TextureStore(); store.parse({ defaultTextureClasses: [], items: { other: { imageUrl: 'o.png' } } }); await expect(store.whenResource('missing')).rejects.toThrow(/missing/); }); test('whenResource() waits until parse() is called', async () => { const store = new TextureStore(); const p = store.whenResource('a'); setTimeout(() => { store.parse({ defaultTextureClasses: [], items: { a: { imageUrl: 'a.png' } } }); }, 0); const resource = await p; expect(resource.id).toBe('a'); }); test('get() with AbortSignal rejects when aborted', async () => { const store = new TextureStore(); const ac = new AbortController(); const p = store.get('never', 'texture', { signal: ac.signal }); ac.abort(); await expect(p).rejects.toThrow(/aborted/i); }); }); describe('clearUnused() (BUG-9)', () => { test('removes and disposes resources with refCount === 0; keeps subscribed ones', () => { const store = new TextureStore(); const data = { defaultTextureClasses: [], items: { a: { imageUrl: 'a.png' }, b: { imageUrl: 'b.png' }, c: { imageUrl: 'c.png' }, }, }; store.parse(data); const unsubA = store.on('a', 'imageCoords', () => { }); const resources = {}; store.onResource('a', (r) => { resources['a'] = r; }); store.onResource('b', (r) => { resources['b'] = r; }); store.onResource('c', (r) => { resources['c'] = r; }); expect(resources['a'].refCount).toBe(1); expect(resources['b'].refCount).toBe(0); expect(resources['c'].refCount).toBe(0); const disposedB = vi.fn(); const disposedC = vi.fn(); on(resources['b'], 'dispose', disposedB); on(resources['c'], 'dispose', disposedC); const removed = store.clearUnused(); expect(removed).toBe(2); expect(disposedB).toHaveBeenCalledTimes(1); expect(disposedC).toHaveBeenCalledTimes(1); let resourceASeen; store.onResource('a', (r) => { resourceASeen = r; }); expect(resourceASeen).toBe(resources['a']); let resourceBSeen; store.onResource('b', (r) => { resourceBSeen = r; }); expect(resourceBSeen).toBeUndefined(); unsubA(); }); }); describe('on()/get() listener bookkeeping (BUG-8)', () => { test('unsubscribe() removes the OnDispose listener', () => { const store = new TextureStore(); const base = getSubscriptionCount(store); const unsub1 = store.on('foo', 'texture', () => { }); const unsub2 = store.on('bar', 'texture', () => { }); const peak = getSubscriptionCount(store); expect(peak).toBeGreaterThan(base); unsub1(); unsub2(); expect(getSubscriptionCount(store)).toBe(base); }); }); describe('parse() update path (BUG-3)', () => { test('frameBasedAnimationsData is updated on existing TileSet resources', () => { const store = new TextureStore(); const initialData = { defaultTextureClasses: [], items: { ts: { imageUrl: 'tiles.png', tileSet: { tileWidth: 16, tileHeight: 16 }, frameBasedAnimations: { walk: { duration: 1, tileIds: [1, 2, 3] } }, }, }, }; store.parse(initialData); let resource; store.onResource('ts', (r) => { resource = r; }); expect(resource?.frameBasedAnimationsData).toEqual({ walk: { duration: 1, tileIds: [1, 2, 3] } }); const updated = { defaultTextureClasses: [], items: { ts: { imageUrl: 'tiles.png', tileSet: { tileWidth: 16, tileHeight: 16 }, frameBasedAnimations: { run: { duration: 0.5, tileIds: [4, 5] } }, }, }, }; store.parse(updated); expect(resource?.frameBasedAnimationsData).toEqual({ run: { duration: 0.5, tileIds: [4, 5] } }); }); test('frameBasedAnimationsData is updated on existing Atlas resources', () => { const store = new TextureStore(); const initial = { defaultTextureClasses: [], items: { a: { atlasUrl: 'atlas.json', frameBasedAnimations: { idle: { duration: 1, frameNameQuery: 'idle.*' } }, }, }, }; store.parse(initial); let resource; store.onResource('a', (r) => { resource = r; }); expect(resource?.frameBasedAnimationsData).toEqual({ idle: { duration: 1, frameNameQuery: 'idle.*' } }); const updated = { defaultTextureClasses: [], items: { a: { atlasUrl: 'atlas.json', frameBasedAnimations: { jump: { duration: 0.3, frameNameQuery: 'jump.*' } }, }, }, }; store.parse(updated); expect(resource?.frameBasedAnimationsData).toEqual({ jump: { duration: 0.3, frameNameQuery: 'jump.*' } }); }); test('TextureResource.fromAtlas accepts initial frameBasedAnimations data', () => { const resource = TextureResource.fromAtlas('a', 'atlas.json', undefined, undefined, { idle: { duration: 1, frameNameQuery: 'idle.*' }, }); expect(resource.frameBasedAnimationsData).toEqual({ idle: { duration: 1, frameNameQuery: 'idle.*' } }); }); test('TextureResource.fromImage accepts (but ignores) frameBasedAnimationsData setter without a signal', () => { const resource = TextureResource.fromImage('i', 'img.png'); resource.frameBasedAnimationsData = { x: { duration: 1, tileIds: [1] } }; expect(resource.frameBasedAnimationsData).toEqual({ x: { duration: 1, tileIds: [1] } }); }); }); describe('TextureResource.load() initial firing (lookbook regression)', () => { test('image-load effect runs even when factory + imageUrl are already set before load()', async () => { let resolveLoad; const loadP = new Promise((r) => { resolveLoad = r; }); const loadSpy = vi .spyOn(ImageLoader.prototype, 'loadAsync') .mockImplementationOnce(() => loadP); const factory = { create(img) { return { tag: img.tag, name: '', disposed: false, dispose() { } }; }, }; const resource = TextureResource.fromImage('rx', 'first.png'); resource.textureFactory = factory; resource.load(); resolveLoad({ width: 10, height: 10, tag: 'live' }); await flushMicrotasks(); await flushMicrotasks(); expect(resource.texture?.tag).toBe('live'); loadSpy.mockRestore(); resource.dispose(); }); }); describe('TextureResource.load() image race (BUG-4)', () => { test('stale image result after imageUrl change does not overwrite fresh texture', async () => { let resolveFirst; let resolveSecond; const firstP = new Promise((r) => { resolveFirst = r; }); const secondP = new Promise((r) => { resolveSecond = r; }); const loadSpy = vi .spyOn(ImageLoader.prototype, 'loadAsync') .mockImplementationOnce(() => firstP) .mockImplementationOnce(() => secondP); const stubTextures = []; const factory = { create(img) { const tex = { tag: img.tag, disposed: false, name: '', dispose() { this.disposed = true; }, }; stubTextures.push(tex); return tex; }, }; const resource = TextureResource.fromImage('rx', 'first.png'); resource.load(); resource.textureFactory = factory; resource.imageUrl = 'second.png'; resolveFirst({ width: 100, height: 50, tag: 'first' }); await flushMicrotasks(); expect(stubTextures.some((t) => t.tag === 'first')).toBe(false); expect(resource.texture).toBeUndefined(); resolveSecond({ width: 200, height: 100, tag: 'second' }); await flushMicrotasks(); expect(resource.texture?.tag).toBe('second'); loadSpy.mockRestore(); resource.dispose(); }); test('texture is disposed when load resolves after dispose', async () => { let resolveLoad; const loadP = new Promise((r) => { resolveLoad = r; }); const loadSpy = vi .spyOn(ImageLoader.prototype, 'loadAsync') .mockImplementationOnce(() => loadP); const createdTextures = []; const factory = { create(img) { const tex = { tag: img.tag, disposed: false, name: '', dispose() { this.disposed = true; }, }; createdTextures.push(tex); return tex; }, }; const resource = TextureResource.fromImage('ry', 'pending.png'); resource.load(); resource.textureFactory = factory; resource.dispose(); resolveLoad({ width: 10, height: 10, tag: 'pending' }); await flushMicrotasks(); for (const t of createdTextures) { expect(t.disposed).toBe(true); } expect(resource.texture).toBeUndefined(); loadSpy.mockRestore(); }); }); describe('static load()', () => { test('awaits whenReady() before resolving — resource is present after await', async () => { const fetchMock = vi.spyOn(globalThis, 'fetch').mockResolvedValue(new Response(JSON.stringify({ defaultTextureClasses: [], items: { tex: { imageUrl: 'img.png' } }, }))); try { const store = await TextureStore.load('http://example.test/data.json'); expect(store).toBeInstanceOf(TextureStore); let resourceSeen; store.onResource('tex', (r) => { resourceSeen = r; }); expect(resourceSeen).toBeDefined(); expect(resourceSeen?.id).toBe('tex'); } finally { fetchMock.mockRestore(); } }); }); describe('TextureResource.fromX input safety', () => { test('fromImage does not mutate textureClasses', () => { const cls = ['nearest', 'flipy']; TextureResource.fromImage('a', 'img.png', cls); expect(cls).toEqual(['nearest', 'flipy']); }); test('fromTileSet does not mutate textureClasses', () => { const cls = ['nearest', 'flipy']; TextureResource.fromTileSet('a', 'img.png', { tileWidth: 16, tileHeight: 16 }, cls); expect(cls).toEqual(['nearest', 'flipy']); }); test('fromAtlas does not mutate textureClasses', () => { const cls = ['nearest', 'flipy']; TextureResource.fromAtlas('a', 'atlas.json', undefined, cls); expect(cls).toEqual(['nearest', 'flipy']); }); }); }); //# sourceMappingURL=TextureStore.spec.js.map