Files
youle_cocos/cocoscreator_projects/scripts/test/skin.test.mjs
T
joywayerandClaude Opus 5 962c79866f feat(tools): skin.mjs 资源清单与 override 校验
框架文件树即可覆盖清单(不另建清单文件)。孤儿/尺寸不一致/Spine 缺件
一律报错中止, 不静默跳过。Spine 贴图豁免尺寸校验(坐标在 .atlas 内)。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-27 21:24:30 +08:00

187 lines
6.7 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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); }
});