跨任务视角发现的问题:Critical1 materialize.mjs 的 junction 排除比较在 Windows 上因长路径前缀恒假、从未生效,且测试夹具的 junction 指向真源自身, 掩盖了此问题;Critical2 composeSkin 例外通道在 uuid 缺失/框架侧无 meta 时 静默失败,违反第二准则;Important3 可覆盖范围误把 theme/ 下的框架 TS 代码 也纳入;Important5 cli-entry 测试漏注册 check-skin/build-game;Important6 composeSkin 与 check-skin 各自拼路径,未共用 paths.mjs 权威推导;Important7 --platform 缺值时静默落到默认平台。逐条修复并补测试,Critical1 用诱饵框架 目录验证过能真正杀掉该 bug(变异推演见 fix report)。 spec §5 step5 的 dist/<name>/ 拷贝按要求标注为本期未实现,不实现。 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
245 lines
9.4 KiB
JavaScript
245 lines
9.4 KiB
JavaScript
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, listCoverableAssets, 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'), []);
|
||
});
|
||
|
||
// ---- listCoverableAssets:Important 3——可覆盖范围须限定为 spec §2 规则 3 的 8 个目录 ----
|
||
test('listCoverableAssets 排除 theme/ 与 README.md,只保留 8 个白名单目录下的资源', () => {
|
||
const d = makeDirs();
|
||
try {
|
||
png(d.fw, 'atlas-hall/btn.png');
|
||
writeFile(d.fw, 'README.md', '# framework ui');
|
||
writeFile(d.fw, 'theme/default-theme.ts', 'export const DEFAULT_THEME = {};');
|
||
writeFile(d.fw, 'theme/types.ts', 'export interface ThemeConfig {}');
|
||
const covered = listCoverableAssets(d.fw).sort();
|
||
assert.deepEqual(covered, ['atlas-hall/btn.png']);
|
||
assert.ok(!covered.some((f) => f.startsWith('theme/')), 'theme/ 下的框架代码不属于可覆盖资源');
|
||
assert.ok(!covered.includes('README.md'), 'README.md 不是可覆盖资源');
|
||
} finally { cleanup(d.root); }
|
||
});
|
||
|
||
test('validateOverrides:override 放一个 .ts 到 theme/ 会被判为 orphan(不在可覆盖范围内)', () => {
|
||
const d = makeDirs();
|
||
try {
|
||
png(d.fw, 'atlas-hall/btn.png');
|
||
writeFile(d.fw, 'theme/resolve-theme.ts', 'export function resolveTheme() {}');
|
||
// 子游戏企图覆盖框架代码
|
||
writeFile(d.ov, 'theme/resolve-theme.ts', 'export function resolveTheme() { /* hijacked */ }');
|
||
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, 'theme/resolve-theme.ts');
|
||
assert.equal(r.matched.length, 0, 'theme 覆盖不应被当作合法命中');
|
||
} finally { cleanup(d.root); }
|
||
});
|
||
|
||
// ---- 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); }
|
||
});
|