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 的子游戏工程」的临时环境 */ 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;'); 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(frameworkSrc, 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, 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 拒绝覆盖已存在的目标目录', () => { 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 校验不过时抛 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); } });