Files
youle_cocos/cocoscreator_projects/scripts/test/materialize.test.mjs
T
joywayerandClaude Opus 5 8e35556c33 feat(tools): materialize 实体化与皮肤合成
junction 变真实拷贝(发布链路不依赖 junction), 框架真源全程只读。
合成先整体校验再写, 不产生半成品; 例外通道把 override meta 的 uuid
改回框架原值以保持 Prefab 引用不断。

修复:Windows UNC 长路径前缀导致 cpSync filter 中 relative() 失败,
规范化 src 路径后正确计算相对位置进行缓存目录过滤。

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

113 lines
5.3 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 { materialize, composeSkin, SkinValidationError } from '../lib/materialize.mjs';
const LINK_TYPE = platform === 'win32' ? 'junction' : 'dir';
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'); }
/** 造一个「框架真源 + 挂 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.equal(read(s.dest, 'assets/framework/ui/atlas-hall/btn.png'), 'DEFAULT-BTN');
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'), 'MUTATED');
assert.equal(read(s.frameworkSrc, 'ui/atlas-hall/btn.png'), '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']);
assert.equal(r.frameworkTotal, 2);
assert.equal(read(s.dest, 'assets/framework/ui/atlas-hall/btn.png'), 'SKINNED-BTN');
assert.equal(read(s.dest, 'assets/framework/ui/atlas-hall/btn.png.meta'), '{"uuid":"fw-btn-uuid"}');
assert.equal(read(s.dest, 'assets/framework/ui/atlas-hall/bg.png'), '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 {
put(s.gameDir, 'assets/game/override/atlas-hall/typo.png', 'X');
materialize(s.gameDir, s.frameworkSrc, s.dest);
assert.throws(() => composeSkin(s.dest), SkinValidationError);
assert.equal(read(s.dest, 'assets/framework/ui/atlas-hall/btn.png'), 'DEFAULT-BTN', '失败时不得部分写入');
} finally { cleanup(s.root); }
});