跨任务视角发现的问题: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>
231 lines
11 KiB
JavaScript
231 lines
11 KiB
JavaScript
import { test } from 'node:test';
|
||
import assert from 'node:assert/strict';
|
||
import { join } from 'node:path';
|
||
import { mkdirSync, writeFileSync, readFileSync, existsSync, rmSync, mkdtempSync, symlinkSync } from 'node:fs';
|
||
import { tmpdir } from 'node:os';
|
||
import { platform } from 'node:process';
|
||
import { deflateSync } from 'node:zlib';
|
||
import { materialize, composeSkin, SkinValidationError } from '../lib/materialize.mjs';
|
||
|
||
const LINK_TYPE = platform === 'win32' ? 'junction' : 'dir';
|
||
|
||
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, rgb = [0, 0, 0]) {
|
||
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));
|
||
for (let y = 0; y < h; y++) {
|
||
const off = y * (1 + w * 3);
|
||
raw[off] = 0;
|
||
for (let x = 0; x < w; x++) {
|
||
raw[off + 1 + x * 3] = rgb[0]; raw[off + 2 + x * 3] = rgb[1]; raw[off + 3 + x * 3] = rgb[2];
|
||
}
|
||
}
|
||
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)),
|
||
]);
|
||
}
|
||
|
||
const DEFAULT_BTN = makePng(64, 64, [1, 2, 3]);
|
||
const DEFAULT_BG = makePng(64, 64, [4, 5, 6]);
|
||
const SKINNED_BTN = makePng(64, 64, [9, 9, 9]);
|
||
|
||
function put(dir, rel, content) {
|
||
const p = join(dir, ...rel.split('/'));
|
||
mkdirSync(join(p, '..'), { recursive: true });
|
||
writeFileSync(p, content);
|
||
}
|
||
function read(dir, rel) { return readFileSync(join(dir, ...rel.split('/')), 'utf8'); }
|
||
function readBinary(dir, rel) { return readFileSync(join(dir, ...rel.split('/'))); }
|
||
|
||
/**
|
||
* 造一个「框架真源 + 挂 junction 的子游戏工程」的临时环境。
|
||
*
|
||
* junction **故意不指向 frameworkSrc 本身**,而是指向一个内容相似、但多一个
|
||
* 标记文件的「诱饵」目录(frameworkDecoy)。这样"实体化后的 framework 到底是
|
||
* 从 junction 解引用来的,还是从真源 frameworkSrc 单独拷的"才是可判定的——
|
||
* 若两者是同一目录,字节完全相同,Critical 1(junction 排除分支在 Windows 上
|
||
* 从未生效)这个 bug 永远不会被测出来。
|
||
*/
|
||
function scaffold() {
|
||
const root = mkdtempSync(join(tmpdir(), 'youle-mat-'));
|
||
const frameworkSrc = join(root, 'nexus', 'assets', 'framework');
|
||
mkdirSync(frameworkSrc, { recursive: true });
|
||
put(frameworkSrc, 'ui/atlas-hall/btn.png', DEFAULT_BTN);
|
||
put(frameworkSrc, 'ui/atlas-hall/btn.png.meta', '{"uuid":"fw-btn-uuid"}');
|
||
put(frameworkSrc, 'ui/atlas-hall/bg.png', DEFAULT_BG);
|
||
put(frameworkSrc, 'core/net.ts', 'export const x=1;');
|
||
|
||
// 诱饵:junction 实际指向这里。内容与 frameworkSrc 一致,但多一个标记文件。
|
||
const frameworkDecoy = join(root, 'decoy', 'assets', 'framework');
|
||
mkdirSync(frameworkDecoy, { recursive: true });
|
||
put(frameworkDecoy, 'ui/atlas-hall/btn.png', DEFAULT_BTN);
|
||
put(frameworkDecoy, 'ui/atlas-hall/btn.png.meta', '{"uuid":"fw-btn-uuid"}');
|
||
put(frameworkDecoy, 'ui/atlas-hall/bg.png', DEFAULT_BG);
|
||
put(frameworkDecoy, 'core/net.ts', 'export const x=1;');
|
||
put(frameworkDecoy, 'ui/atlas-hall/__decoy_marker__.png', DEFAULT_BG); // 只存在于诱饵里
|
||
|
||
const gameDir = join(root, 'games', 'demo');
|
||
mkdirSync(join(gameDir, 'assets'), { recursive: true });
|
||
writeFileSync(join(gameDir, 'package.json'), JSON.stringify({ name: 'demo', creator: { version: '3.8.8' } }));
|
||
symlinkSync(frameworkDecoy, join(gameDir, 'assets', 'framework'), LINK_TYPE);
|
||
put(gameDir, 'assets/game/theme.ts', 'export const theme={};');
|
||
mkdirSync(join(gameDir, 'library'), { recursive: true }); // 缓存目录,应被跳过
|
||
writeFileSync(join(gameDir, 'library', 'junk'), 'x');
|
||
|
||
return { root, frameworkSrc, frameworkDecoy, gameDir, dest: join(root, 'workspace', 'demo') };
|
||
}
|
||
function cleanup(root) { rmSync(root, { recursive: true, force: true }); }
|
||
|
||
test('materialize 把 junction 变成真实目录拷贝,并跳过缓存目录', () => {
|
||
const s = scaffold();
|
||
try {
|
||
materialize(s.gameDir, s.frameworkSrc, s.dest);
|
||
assert.ok(readBinary(s.dest, 'assets/framework/ui/atlas-hall/btn.png').equals(DEFAULT_BTN), '框架 btn.png 被正确拷贝');
|
||
assert.equal(read(s.dest, 'assets/game/theme.ts'), 'export const theme={};', '游戏资源被拷贝');
|
||
assert.ok(!existsSync(join(s.dest, 'library')), 'library 应被跳过');
|
||
} finally { cleanup(s.root); }
|
||
});
|
||
|
||
test('materialize 只跳过工程根的缓存目录,不吞 assets 下的同名业务目录', () => {
|
||
const s = scaffold();
|
||
try {
|
||
put(s.gameDir, 'assets/game/native/bridge.ts', 'BRIDGE');
|
||
materialize(s.gameDir, s.frameworkSrc, s.dest);
|
||
assert.equal(read(s.dest, 'assets/game/native/bridge.ts'), 'BRIDGE', 'assets 下的 native 是业务目录,必须保留');
|
||
assert.ok(!existsSync(join(s.dest, 'library')), '工程根的 library 仍应被跳过');
|
||
} finally { cleanup(s.root); }
|
||
});
|
||
|
||
test('materialize 后写临时工程不影响框架真源', () => {
|
||
const s = scaffold();
|
||
try {
|
||
materialize(s.gameDir, s.frameworkSrc, s.dest);
|
||
writeFileSync(join(s.dest, 'assets', 'framework', 'ui', 'atlas-hall', 'btn.png'), Buffer.from('MUTATED'));
|
||
assert.ok(readBinary(s.frameworkSrc, 'ui/atlas-hall/btn.png').equals(DEFAULT_BTN), '真源必须只读');
|
||
} finally { cleanup(s.root); }
|
||
});
|
||
|
||
test('materialize 的 framework 来自真源 frameworkSrc,而非 junction 解引用(诱饵标记文件必须不存在)', () => {
|
||
const s = scaffold();
|
||
try {
|
||
materialize(s.gameDir, s.frameworkSrc, s.dest);
|
||
assert.ok(
|
||
!existsSync(join(s.dest, 'assets', 'framework', 'ui', 'atlas-hall', '__decoy_marker__.png')),
|
||
'framework 必须单独从 frameworkSrc 真源拷贝;诱饵(junction 目标)独有的标记文件不应出现在工作区里',
|
||
);
|
||
} finally { cleanup(s.root); }
|
||
});
|
||
|
||
test('materialize 拒绝覆盖已存在的目标目录', () => {
|
||
const s = scaffold();
|
||
try {
|
||
materialize(s.gameDir, s.frameworkSrc, s.dest);
|
||
assert.throws(() => materialize(s.gameDir, s.frameworkSrc, s.dest), /已存在/);
|
||
} finally { cleanup(s.root); }
|
||
});
|
||
|
||
test('composeSkin 用 override 覆盖框架资源内容,保留框架 .meta', () => {
|
||
const s = scaffold();
|
||
try {
|
||
put(s.gameDir, 'assets/game/override/atlas-hall/btn.png', SKINNED_BTN);
|
||
materialize(s.gameDir, s.frameworkSrc, s.dest);
|
||
const r = composeSkin(s.dest);
|
||
assert.deepEqual(r.matched, ['atlas-hall/btn.png'], 'matched 返回覆盖的文件');
|
||
assert.equal(r.frameworkTotal, 2, '框架资源总数');
|
||
assert.ok(readBinary(s.dest, 'assets/framework/ui/atlas-hall/btn.png').equals(SKINNED_BTN), '框架文件被 override 替换');
|
||
assert.equal(read(s.dest, 'assets/framework/ui/atlas-hall/btn.png.meta'), '{"uuid":"fw-btn-uuid"}', 'meta 文件不变');
|
||
assert.ok(readBinary(s.dest, 'assets/framework/ui/atlas-hall/bg.png').equals(DEFAULT_BG), '未覆盖的保持默认');
|
||
} finally { cleanup(s.root); }
|
||
});
|
||
|
||
test('composeSkin 例外通道:override 自带 meta 时,uuid 被改回框架的值', () => {
|
||
const s = scaffold();
|
||
try {
|
||
put(s.gameDir, 'assets/game/override/atlas-hall/btn.png', SKINNED_BTN);
|
||
put(s.gameDir, 'assets/game/override/atlas-hall/btn.png.meta', '{"uuid":"game-own-uuid","ver":"1.0.0"}');
|
||
materialize(s.gameDir, s.frameworkSrc, s.dest);
|
||
composeSkin(s.dest);
|
||
const meta = JSON.parse(read(s.dest, 'assets/framework/ui/atlas-hall/btn.png.meta'));
|
||
assert.equal(meta.uuid, 'fw-btn-uuid', 'uuid 必须沿用框架的');
|
||
assert.equal(meta.ver, '1.0.0', 'override meta 的其余字段保留');
|
||
} finally { cleanup(s.root); }
|
||
});
|
||
|
||
test('composeSkin 例外通道:框架侧 .meta 没有 uuid 时显式抛错(不得写出没有 uuid 的 .meta)', () => {
|
||
const s = scaffold();
|
||
try {
|
||
// 框架 bg.png 本身没有 .meta;给它补一份没有 uuid 字段的 .meta,
|
||
// 模拟 fwUuid === undefined 的场景(JSON.stringify 会静默丢掉 undefined 的键)
|
||
put(s.gameDir, 'assets/game/override/atlas-hall/bg.png', SKINNED_BTN);
|
||
put(s.gameDir, 'assets/game/override/atlas-hall/bg.png.meta', '{"ver":"1.0.0"}');
|
||
materialize(s.gameDir, s.frameworkSrc, s.dest);
|
||
// 框架侧补一份没有 uuid 的 .meta(模拟框架 meta 本身缺 uuid 的坏数据)
|
||
writeFileSync(join(s.dest, 'assets', 'framework', 'ui', 'atlas-hall', 'bg.png.meta'), '{"ver":"0.9.0"}');
|
||
|
||
let err;
|
||
assert.throws(() => {
|
||
try { composeSkin(s.dest); } catch (e) { err = e; throw e; }
|
||
}, SkinValidationError);
|
||
assert.equal(err.errors[0].code, 'meta-missing-uuid');
|
||
assert.match(err.errors[0].path, /bg\.png/, '错误须指向具体文件相对路径');
|
||
assert.match(err.errors[0].message, /demo/, '错误须指向子游戏名');
|
||
assert.match(err.errors[0].message, /uuid/, '错误须说明具体原因');
|
||
} finally { cleanup(s.root); }
|
||
});
|
||
|
||
test('composeSkin 例外通道:框架侧没有 .meta 时显式抛错(不得静默丢弃 override 的 meta)', () => {
|
||
const s = scaffold();
|
||
try {
|
||
// bg.png 在框架侧没有 .meta,但 override 给它带了一份 —— 之前的实现会静默跳过整段合并
|
||
put(s.gameDir, 'assets/game/override/atlas-hall/bg.png', SKINNED_BTN);
|
||
put(s.gameDir, 'assets/game/override/atlas-hall/bg.png.meta', '{"uuid":"game-own-uuid"}');
|
||
materialize(s.gameDir, s.frameworkSrc, s.dest);
|
||
|
||
let err;
|
||
assert.throws(() => {
|
||
try { composeSkin(s.dest); } catch (e) { err = e; throw e; }
|
||
}, SkinValidationError);
|
||
assert.equal(err.errors[0].code, 'meta-orphan');
|
||
assert.match(err.errors[0].path, /bg\.png/, '错误须指向具体文件相对路径');
|
||
assert.match(err.errors[0].message, /demo/, '错误须指向子游戏名');
|
||
assert.match(err.errors[0].message, /\.meta/, '错误须说明具体原因');
|
||
} finally { cleanup(s.root); }
|
||
});
|
||
|
||
test('composeSkin 校验不过时抛 SkinValidationError 且不写任何文件', () => {
|
||
const s = scaffold();
|
||
try {
|
||
// 同时放入一个合法的 override 和一个孤儿 override
|
||
put(s.gameDir, 'assets/game/override/atlas-hall/btn.png', SKINNED_BTN); // 合法:框架里有此文件
|
||
put(s.gameDir, 'assets/game/override/atlas-hall/typo.png', DEFAULT_BG); // 孤儿:框架里无此文件
|
||
materialize(s.gameDir, s.frameworkSrc, s.dest);
|
||
let validationErr;
|
||
assert.throws(() => {
|
||
try {
|
||
composeSkin(s.dest);
|
||
} catch (err) {
|
||
validationErr = err;
|
||
throw err;
|
||
}
|
||
}, SkinValidationError);
|
||
|
||
// 验证错误中确实有 orphan 错误指向孤儿文件
|
||
assert.ok(validationErr.errors.some(e => e.code === 'orphan' && e.path === 'atlas-hall/typo.png'), 'orphan 错误必须被检测');
|
||
|
||
// 验证即使合法的 btn.png 也没有被写入(校验失败时不得部分写入)
|
||
assert.ok(readBinary(s.dest, 'assets/framework/ui/atlas-hall/btn.png').equals(DEFAULT_BTN), '校验失败时不得部分写入:btn.png 应仍为框架默认值');
|
||
} finally { cleanup(s.root); }
|
||
});
|