import { test } from 'node:test' import assert from 'node:assert/strict' import { mkdtemp, rm, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { resolve } from 'node:path' import { PNG } from 'pngjs' import { scanCardCatalog } from './card-catalog.mjs' function png(width, height, pixel) { const image = new PNG({ width, height }) for (let offset = 0; offset < image.data.length; offset += 4) { image.data.set(pixel, offset) } return PNG.sync.write(image) } test('catalog pairs filename-based card art with validated masks', async (t) => { const root = await mkdtemp(resolve(tmpdir(), 'card-catalog-')) t.after(() => rm(root, { recursive: true })) await Promise.all([ writeFile(resolve(root, 'david.png'), png(10, 14, [20, 40, 60, 255])), writeFile(resolve(root, 'david-mask.png'), png(10, 14, [128, 128, 128, 255])), ]) const catalog = await scanCardCatalog(root) assert.deepEqual(catalog.errors, []) assert.equal(catalog.cards.length, 1) assert.equal(catalog.cards[0].name, 'david') assert.match(catalog.cards[0].artwork, /^\/card-art\/david\.png\?v=[0-9a-f]{16}$/) assert.equal(catalog.cards[0].width, 10) assert.equal(catalog.cards[0].height, 14) }) test('catalog reports missing pairs and invalid runtime images explicitly', async (t) => { const root = await mkdtemp(resolve(tmpdir(), 'card-catalog-invalid-')) t.after(() => rm(root, { recursive: true })) await Promise.all([ writeFile(resolve(root, 'orphan.png'), png(10, 14, [20, 40, 60, 255])), writeFile(resolve(root, 'mask-only-mask.png'), png(10, 14, [0, 0, 0, 255])), writeFile(resolve(root, 'bad.png'), png(10, 14, [20, 40, 60, 254])), writeFile(resolve(root, 'bad-mask.png'), png(10, 14, [20, 21, 20, 255])), ]) const catalog = await scanCardCatalog(root) assert.deepEqual(catalog.cards, []) assert.ok(catalog.errors.some(error => error.includes('Missing orphan-mask.png'))) assert.ok(catalog.errors.some(error => error.includes('Missing mask-only.png'))) assert.ok(catalog.errors.some(error => error.includes('bad.png must be fully opaque'))) }) test('optional text masks are discovered without becoming artwork or orphan finish masks', async (t) => { const root = await mkdtemp(resolve(tmpdir(), 'card-text-mask-')) t.after(() => rm(root, { recursive: true })) const artwork = png(10, 14, [20, 40, 60, 255]) const finish = png(10, 14, [128, 128, 128, 255]) await Promise.all([ writeFile(resolve(root, 'Card name.png'), artwork), writeFile(resolve(root, 'Card name-mask.png'), finish), ]) const legacy = await scanCardCatalog(root) assert.equal(legacy.cards[0].textMask, undefined) const { createHash } = await import('node:crypto') assert.equal(legacy.cards[0].revision, createHash('sha256').update(artwork).update(finish).digest('hex').slice(0, 16)) await writeFile(resolve(root, 'Card name-text-mask.png'), png(10, 14, [255, 255, 255, 255])) const masked = await scanCardCatalog(root) assert.deepEqual(masked.errors, []) assert.equal(masked.cards.length, 1) assert.match(masked.cards[0].textMask, /^\/card-art\/Card%20name-text-mask\.png\?v=[0-9a-f]{16}$/) assert.notEqual(masked.cards[0].revision, legacy.cards[0].revision) await writeFile(resolve(root, 'Card name-text-mask.png'), png(10, 14, [0, 0, 0, 255])) assert.notEqual((await scanCardCatalog(root)).cards[0].revision, masked.cards[0].revision) await rm(resolve(root, 'Card name-text-mask.png')) assert.deepEqual(await scanCardCatalog(root), legacy) }) test('invalid optional text masks reject the card and orphan masks name the correct missing artwork', async (t) => { const root = await mkdtemp(resolve(tmpdir(), 'invalid-card-text-mask-')) t.after(() => rm(root, { recursive: true })) await Promise.all([ writeFile(resolve(root, 'card.png'), png(10, 14, [20, 40, 60, 255])), writeFile(resolve(root, 'card-mask.png'), png(10, 14, [128, 128, 128, 255])), writeFile(resolve(root, 'orphan-text-mask.png'), png(10, 14, [0, 0, 0, 255])), ]) for (const [bytes, error] of [ [png(5, 7, [0, 0, 0, 255]), 'must match artwork dimensions'], [png(10, 14, [0, 1, 0, 255]), 'must be an opaque grayscale mask'], [png(10, 14, [0, 0, 0, 254]), 'must be an opaque grayscale mask'], [Buffer.from('broken PNG'), 'is not a readable PNG'], ]) { await writeFile(resolve(root, 'card-text-mask.png'), bytes) const catalog = await scanCardCatalog(root) assert.deepEqual(catalog.cards, []) assert.ok(catalog.errors.some(message => message.includes('card-text-mask.png') && message.includes(error))) assert.ok(catalog.errors.includes('Missing orphan.png for orphan-text-mask.png')) assert.ok(!catalog.errors.some(message => message.includes('orphan-text.png'))) } })