import { test } from 'node:test' import assert from 'node:assert/strict' import * as THREE from 'three' import './test-support.mjs' const { loadCardTextures, disposeCardTextures } = await import('../src/cardTextures.ts') const { createCardMaterial, applyCardTextMask } = await import('../src/cardMaterial.ts') const { validateCardCatalog } = await import('../src/cardCatalog.ts') const asset = { name: 'test', artwork: '/card-art/test.png', mask: '/card-art/test-mask.png', revision: 'test', width: 10, height: 14, } test('cards without a text mask request only artwork/finish and retain the disabled shader path', async (t) => { const requests = [] const loaded = await loadCardTextures({ async loadAsync(path) { requests.push(path); return new THREE.Texture() } }, asset, 8) assert.deepEqual(requests, [asset.artwork, asset.mask]) assert.equal(Object.hasOwn(loaded, 'textMask'), false) assert.equal(loaded.artwork.colorSpace, THREE.SRGBColorSpace) assert.equal(loaded.mask.colorSpace, THREE.NoColorSpace) assert.equal(loaded.artwork.anisotropy, 8) const material = createCardMaterial(loaded.artwork, loaded.mask, new THREE.Texture(), new THREE.CubeTexture(), new THREE.Vector3()) t.after(() => { material.dispose(); disposeCardTextures(loaded) }) assert.equal(material.uniforms.hasTextMask.value, false) assert.equal(material.uniforms.textMask.value, null) }) test('optional text masks load as data and switching to an unmasked card clears protection without shader recompilation', async (t) => { const textMask = '/card-art/test-text-mask.png' const requests = [] const loaded = await loadCardTextures({ async loadAsync(path) { requests.push(path); return new THREE.Texture() } }, { ...asset, textMask }, 4) assert.deepEqual(requests, [asset.artwork, asset.mask, textMask]) assert.equal(loaded.textMask.colorSpace, THREE.NoColorSpace) assert.equal(loaded.textMask.anisotropy, 4) const material = createCardMaterial(loaded.artwork, loaded.mask, new THREE.Texture(), new THREE.CubeTexture(), new THREE.Vector3(), loaded.textMask) const version = material.version assert.equal(material.uniforms.hasTextMask.value, true) assert.equal(material.uniforms.textMask.value, loaded.textMask) applyCardTextMask(material) assert.equal(material.uniforms.hasTextMask.value, false) assert.equal(material.uniforms.textMask.value, null) applyCardTextMask(material, loaded.textMask) assert.equal(material.uniforms.hasTextMask.value, true) assert.equal(material.version, version) material.dispose() const spies = [loaded.artwork, loaded.mask, loaded.textMask].map(texture => t.mock.method(texture, 'dispose')) disposeCardTextures(loaded) assert.deepEqual(spies.map(spy => spy.mock.callCount()), [1, 1, 1]) }) test('advertised optional-mask failures reject loading and clean up every successful texture', async (t) => { const paths = [asset.artwork, asset.mask, '/card-art/test-text-mask.png'] for (const failingPath of paths) { const textures = paths.map(() => new THREE.Texture()) const spies = textures.map(texture => t.mock.method(texture, 'dispose')) const failure = new Error(`failed ${failingPath}`) await assert.rejects(loadCardTextures({ async loadAsync(path) { if (path === failingPath) throw failure return textures[paths.indexOf(path)] } }, { ...asset, textMask: paths[2] }), error => error === failure) assert.deepEqual(spies.map((spy, index) => spy.mock.callCount()), paths.map(path => path === failingPath ? 0 : 1)) } }) test('runtime catalog validation accepts missing/valid optional URLs and rejects malformed text-mask fields', () => { const catalog = { schemaVersion: 1, cards: [asset], errors: [] } assert.deepEqual(validateCardCatalog(catalog), catalog) const masked = { ...catalog, cards: [{ ...asset, textMask: '/card-art/test-text-mask.png?v=revision' }] } assert.deepEqual(validateCardCatalog(masked), masked) for (const textMask of [null, 1, false, '', '/reference/text.png', 'https://example.com/text.png']) { assert.throws(() => validateCardCatalog({ ...catalog, cards: [{ ...asset, textMask }] }), /invalid format/) } })