feat(tools): skin.mjs 资源清单与 override 校验

框架文件树即可覆盖清单(不另建清单文件)。孤儿/尺寸不一致/Spine 缺件
一律报错中止, 不静默跳过。Spine 贴图豁免尺寸校验(坐标在 .atlas 内)。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-27 21:24:30 +08:00
co-authored by Claude Opus 5
parent 967641a7c7
commit 962c79866f
3 changed files with 294 additions and 0 deletions
@@ -0,0 +1,186 @@
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 {
writeFile(d.fw, 'audio/click.wav', 'aaaa');
writeFile(d.ov, 'audio/click.wav', 'bbbbbbbbbbbb');
const r = validateOverrides(d.fw, d.ov);
assert.deepEqual(r.errors, []);
} 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); }
});