import { test } from 'node:test'; import assert from 'node:assert/strict'; import { join } from 'node:path'; import { mkdirSync, writeFileSync, rmSync, mkdtempSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { deflateSync } from 'node:zlib'; import { listAssets, readPngSize, validateOverrides } from '../lib/skin.mjs'; // ---- 测试辅助:零依赖生成指定尺寸的 PNG ---- function crc32(buf) { let c = ~0; for (const x of buf) { c ^= x; for (let i = 0; i < 8; i++) c = (c >>> 1) ^ (0xEDB88320 & -(c & 1)); } return ~c >>> 0; } function chunk(type, data) { const len = Buffer.alloc(4); len.writeUInt32BE(data.length); const td = Buffer.concat([Buffer.from(type, 'ascii'), data]); const c = Buffer.alloc(4); c.writeUInt32BE(crc32(td)); return Buffer.concat([len, td, c]); } function makePng(w, h) { const ihdr = Buffer.alloc(13); ihdr.writeUInt32BE(w, 0); ihdr.writeUInt32BE(h, 4); ihdr[8] = 8; ihdr[9] = 2; const raw = Buffer.alloc(h * (1 + w * 3)); return Buffer.concat([ Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]), chunk('IHDR', ihdr), chunk('IDAT', deflateSync(raw)), chunk('IEND', Buffer.alloc(0)), ]); } function writeFile(dir, rel, content) { const p = join(dir, ...rel.split('/')); mkdirSync(join(p, '..'), { recursive: true }); writeFileSync(p, content); return p; } function png(dir, rel, w = 64, h = 64) { return writeFile(dir, rel, makePng(w, h)); } function makeDirs() { const root = mkdtempSync(join(tmpdir(), 'youle-skin-')); const fw = join(root, 'ui'); const ov = join(root, 'override'); mkdirSync(fw, { recursive: true }); mkdirSync(ov, { recursive: true }); return { root, fw, ov }; } function cleanup(root) { rmSync(root, { recursive: true, force: true }); } // ---- listAssets ---- test('listAssets 返回正斜杠相对路径,排除 .meta 与 .gitkeep', () => { const d = makeDirs(); try { png(d.fw, 'atlas-hall/btn.png'); writeFile(d.fw, 'atlas-hall/btn.png.meta', '{}'); writeFile(d.fw, 'audio/.gitkeep', ''); writeFile(d.fw, 'audio/click.wav', 'x'); assert.deepEqual(listAssets(d.fw).sort(), ['atlas-hall/btn.png', 'audio/click.wav']); } finally { cleanup(d.root); } }); test('listAssets 对不存在的目录返回空数组', () => { assert.deepEqual(listAssets('/no/such/dir'), []); }); // ---- readPngSize ---- test('readPngSize 读出宽高;非 PNG 返回 null', () => { const d = makeDirs(); try { const p = png(d.fw, 'a.png', 128, 64); assert.deepEqual(readPngSize(p), { width: 128, height: 64 }); const w = writeFile(d.fw, 'b.wav', 'not a png'); assert.equal(readPngSize(w), null); } finally { cleanup(d.root); } }); // ---- validateOverrides:正常命中 ---- test('同名同尺寸图片:命中,无错误', () => { const d = makeDirs(); try { png(d.fw, 'atlas-hall/btn.png', 64, 64); png(d.ov, 'atlas-hall/btn.png', 64, 64); const r = validateOverrides(d.fw, d.ov); assert.deepEqual(r.errors, []); assert.deepEqual(r.matched, ['atlas-hall/btn.png']); } finally { cleanup(d.root); } }); test('框架有、子游戏未覆盖:不是错误', () => { const d = makeDirs(); try { png(d.fw, 'atlas-hall/a.png'); png(d.fw, 'atlas-hall/b.png'); png(d.ov, 'atlas-hall/a.png'); const r = validateOverrides(d.fw, d.ov); assert.deepEqual(r.errors, []); assert.deepEqual(r.matched, ['atlas-hall/a.png']); } finally { cleanup(d.root); } }); // ---- validateOverrides:孤儿 ---- test('override 在框架里找不到对应路径 → orphan', () => { const d = makeDirs(); try { png(d.fw, 'atlas-hall/btn.png'); png(d.ov, 'atlas-hall/typo.png'); const r = validateOverrides(d.fw, d.ov); assert.equal(r.errors.length, 1); assert.equal(r.errors[0].code, 'orphan'); assert.equal(r.errors[0].path, 'atlas-hall/typo.png'); } finally { cleanup(d.root); } }); test('同名不同扩展名 → orphan,且提示框架里的实际扩展名', () => { const d = makeDirs(); try { png(d.fw, 'atlas-hall/btn.png'); writeFile(d.ov, 'atlas-hall/btn.jpg', 'x'); const r = validateOverrides(d.fw, d.ov); assert.equal(r.errors[0].code, 'orphan'); assert.match(r.errors[0].message, /btn\.png/); } finally { cleanup(d.root); } }); // ---- validateOverrides:尺寸 ---- test('图片尺寸不同且未带 meta → size-mismatch', () => { const d = makeDirs(); try { png(d.fw, 'atlas-hall/btn.png', 64, 64); png(d.ov, 'atlas-hall/btn.png', 128, 128); const r = validateOverrides(d.fw, d.ov); assert.equal(r.errors.length, 1); assert.equal(r.errors[0].code, 'size-mismatch'); assert.match(r.errors[0].message, /64x64/); assert.match(r.errors[0].message, /128x128/); } finally { cleanup(d.root); } }); test('图片尺寸不同但自带 meta(例外通道)→ 通过', () => { const d = makeDirs(); try { png(d.fw, 'atlas-hall/btn.png', 64, 64); png(d.ov, 'atlas-hall/btn.png', 128, 128); writeFile(d.ov, 'atlas-hall/btn.png.meta', '{"ver":"1.0.0"}'); const r = validateOverrides(d.fw, d.ov); assert.deepEqual(r.errors, []); assert.deepEqual(r.matched, ['atlas-hall/btn.png']); } finally { cleanup(d.root); } }); test('非图片资源(音效)不做尺寸校验', () => { const d = makeDirs(); try { // 用真实的、有效的、尺寸不同的 PNG 字节,但扩展名是 .wav // 如果 isImage() 门控被删掉,因为尺寸不同会报 size-mismatch,用例失败 writeFile(d.fw, 'audio/click.wav', makePng(64, 64)); writeFile(d.ov, 'audio/click.wav', makePng(256, 256)); const r = validateOverrides(d.fw, d.ov); assert.deepEqual(r.errors, []); } finally { cleanup(d.root); } }); // ---- validateOverrides:尺寸不可读 ---- test('图片尺寸无法读取(非 PNG)→ size-unreadable', () => { const d = makeDirs(); try { png(d.fw, 'atlas-hall/btn.png', 64, 64); writeFile(d.ov, 'atlas-hall/btn.png', 'this is not a png but it is long enough to pass the 24-byte length check'); const r = validateOverrides(d.fw, d.ov); assert.equal(r.errors.length, 1); assert.equal(r.errors[0].code, 'size-unreadable'); assert.match(r.errors[0].message, /无法读取尺寸/); assert.match(r.errors[0].message, /PNG/); assert.equal(r.matched.length, 0); } finally { cleanup(d.root); } }); test('readPngSize 长度足够但签名不对 → 返回 null', () => { const d = makeDirs(); try { // 构造一个 24 字节以上、但 PNG 签名不对的数据 const buf = Buffer.alloc(30, 0x00); buf.write('NOTPNG', 1, 'ascii'); const p = writeFile(d.fw, 'test.png', buf); assert.equal(readPngSize(p), null); } finally { cleanup(d.root); } }); // ---- validateOverrides:Spine ---- test('Spine 整套覆盖 → 通过,且贴图豁免尺寸校验', () => { const d = makeDirs(); try { writeFile(d.fw, 'spine/hero/hero.json', '{}'); writeFile(d.fw, 'spine/hero/hero.atlas', 'atlas'); png(d.fw, 'spine/hero/hero.png', 64, 64); writeFile(d.ov, 'spine/hero/hero.json', '{"v":2}'); writeFile(d.ov, 'spine/hero/hero.atlas', 'atlas2'); png(d.ov, 'spine/hero/hero.png', 256, 256); // 尺寸不同也应通过 const r = validateOverrides(d.fw, d.ov); assert.deepEqual(r.errors, []); assert.equal(r.matched.length, 3); } finally { cleanup(d.root); } }); test('Spine 缺件 → spine-incomplete,并列出缺少的文件', () => { const d = makeDirs(); try { writeFile(d.fw, 'spine/hero/hero.json', '{}'); writeFile(d.fw, 'spine/hero/hero.atlas', 'atlas'); png(d.fw, 'spine/hero/hero.png', 64, 64); png(d.ov, 'spine/hero/hero.png', 64, 64); // 只换贴图 const r = validateOverrides(d.fw, d.ov); assert.equal(r.errors.length, 1); assert.equal(r.errors[0].code, 'spine-incomplete'); assert.match(r.errors[0].message, /hero\.json/); assert.match(r.errors[0].message, /hero\.atlas/); } finally { cleanup(d.root); } });