fix(skin): 修复最终评审发现的 6 处缺陷(junction 排除失效/meta 静默丢失等)
跨任务视角发现的问题: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>
This commit is contained in:
@@ -13,7 +13,9 @@
|
||||
在子游戏间完全相同;框架不带默认,这部分就要在每个子游戏各存一份且改了不会传播。
|
||||
2. **只放「所有子游戏都需要」的平台界面资源**。判定问句:*下一个子游戏还需要它吗?*
|
||||
「部分子游戏共用」**不构成**进框架的理由——放进去等于让其余子游戏白背包体。
|
||||
3. **目录划分 = 图集划分**。Cocos 的 Auto Atlas 只对同目录散图生效,故目录结构是性能设计:
|
||||
3. **目录划分 = 图集划分**。Cocos 的 Auto Atlas 只对同目录散图生效,故目录结构是性能设计。
|
||||
以下 **8 个目录**是可覆盖资源范围的全部(`scripts/lib/skin.mjs` 的 `COVERABLE_DIRS` 白名单,
|
||||
与此表逐字对应):
|
||||
| 目录 | 用途 |
|
||||
|---|---|
|
||||
| `atlas-common/` | 全程常驻:按钮、面板底、图标、通用弹窗 |
|
||||
@@ -21,6 +23,10 @@
|
||||
| `standalone/` | 超图集尺寸上限的大图、整屏背景(不进图集) |
|
||||
| `spine/` | Spine 骨骼动画,**每个资源一个子目录**,含骨骼数据/atlas/贴图 |
|
||||
| `audio/` / `font/` | 平台音效 / 字体 |
|
||||
|
||||
**`theme/` 不在此列**——那是 §4.2 规定的**框架 TypeScript 代码**位置(类型契约 + 默认值 +
|
||||
合并 + Provider),不是资源,**不可覆盖**、也不计入"框架可覆盖资源共 N 个"的统计。子游戏若在
|
||||
`override/theme/` 下放文件会被判为 `orphan`(框架里没有对应的可覆盖路径)。
|
||||
4. **图片资源统一用 PNG**。非 PNG 图片不做尺寸校验(`scripts/lib/skin.mjs`),会削弱保护。
|
||||
|
||||
## 子游戏怎么换肤
|
||||
|
||||
@@ -80,7 +80,17 @@ if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href)
|
||||
process.exit(1);
|
||||
}
|
||||
const pIdx = process.argv.indexOf('--platform');
|
||||
const targetPlatform = pIdx > -1 ? process.argv[pIdx + 1] : undefined;
|
||||
let targetPlatform;
|
||||
if (pIdx > -1) {
|
||||
const val = process.argv[pIdx + 1];
|
||||
// 用户明确写了 --platform 却没给值(或紧跟下一个 flag):显式报错,
|
||||
// 不能落到 buildGame 的 `?? 'android'` 默认值——那会把"用户指定"悄悄变成"猜的"(第二准则)。
|
||||
if (!val || val.startsWith('--')) {
|
||||
console.error('用法: npm run build-game <game> [-- --platform <platform>](--platform 后必须跟平台名,如 android)');
|
||||
process.exit(1);
|
||||
}
|
||||
targetPlatform = val;
|
||||
}
|
||||
try {
|
||||
const r = buildGame(name, targetPlatform ? { platform: targetPlatform } : {});
|
||||
console.log(`皮肤命中 ${r.skin.matched} 处,框架可覆盖资源共 ${r.skin.frameworkTotal} 个`);
|
||||
|
||||
@@ -3,7 +3,7 @@ import { join } from 'node:path';
|
||||
import { existsSync } from 'node:fs';
|
||||
import { pathToFileURL } from 'node:url';
|
||||
import { FRAMEWORK_UI_DIR, GAMES_DIR, gameOverrideDir } from './lib/paths.mjs';
|
||||
import { listAssets, validateOverrides } from './lib/skin.mjs';
|
||||
import { listCoverableAssets, validateOverrides } from './lib/skin.mjs';
|
||||
|
||||
/** 校验某子游戏的皮肤覆盖。opts 可注入路径用于测试。 */
|
||||
export function checkSkin(gameName, opts = {}) {
|
||||
@@ -16,7 +16,7 @@ export function checkSkin(gameName, opts = {}) {
|
||||
}
|
||||
// override 目录不存在 = 该子游戏不换肤,合法
|
||||
const { matched, errors } = validateOverrides(frameworkUiDir, gameOverrideDir(projectDir));
|
||||
return { matched, errors, frameworkTotal: listAssets(frameworkUiDir).length };
|
||||
return { matched, errors, frameworkTotal: listCoverableAssets(frameworkUiDir).length };
|
||||
}
|
||||
|
||||
// CLI 入口:有错误则退出码 1
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { cpSync, mkdirSync, existsSync, readFileSync, writeFileSync, copyFileSync } from 'node:fs';
|
||||
import { join, posix, dirname, relative, sep, resolve } from 'node:path';
|
||||
import { listAssets, validateOverrides } from './skin.mjs';
|
||||
import { join, posix, dirname, relative, sep, resolve, basename } from 'node:path';
|
||||
import { listCoverableAssets, validateOverrides } from './skin.mjs';
|
||||
import { gameOverrideDir, frameworkUiDirOf } from './paths.mjs';
|
||||
|
||||
/**
|
||||
* 工程缓存目录:实体化时跳过。
|
||||
@@ -27,17 +28,19 @@ export function materialize(gameDir, frameworkSrc, destDir) {
|
||||
mkdirSync(destDir, { recursive: true });
|
||||
|
||||
// 1) 拷工程本体,跳过缓存目录与 assets/framework(后者单独从真源拷)
|
||||
const frameworkLink = join(gameDir, 'assets', 'framework');
|
||||
const frameworkRel = join('assets', 'framework');
|
||||
const gameDirNorm = resolve(gameDir);
|
||||
cpSync(gameDir, destDir, {
|
||||
recursive: true,
|
||||
dereference: true,
|
||||
filter: (src) => {
|
||||
if (src === frameworkLink) return false;
|
||||
// 处理 Windows UNC 长路径前缀 (\\?\) 导致 relative() 失败的问题
|
||||
// 处理 Windows UNC 长路径前缀 (\\?\):cpSync 传入的 src 在 Windows 上
|
||||
// 每一层都带该前缀,若不归一化,下面任何基于 rel 的比较都会恒假
|
||||
// (包括曾经直接 `src === frameworkLink` 的写法——那是 Critical 1 的成因)。
|
||||
const srcNorm = resolve(src.replace(/^\\\\\?\\/, ''));
|
||||
const rel = relative(gameDirNorm, srcNorm);
|
||||
if (!rel) return true; // gameDir 自身
|
||||
if (rel === frameworkRel) return false; // junction:单独从真源拷贝,不经此路径解引用
|
||||
return !CACHE_DIRS.has(rel.split(sep)[0]); // 只看第一段:锚定到工程根
|
||||
},
|
||||
});
|
||||
@@ -51,8 +54,10 @@ export function materialize(gameDir, frameworkSrc, destDir) {
|
||||
* 先整体校验,不通过则抛错且**不写任何文件**(避免半成品)。
|
||||
*/
|
||||
export function composeSkin(destDir) {
|
||||
const frameworkUiDir = join(destDir, 'assets', 'framework', 'ui');
|
||||
const overrideDir = join(destDir, 'assets', 'game', 'override');
|
||||
// 路径推导唯一权威在 paths.mjs(check-skin 用同一套),composeSkin 不再自己拼一份地图
|
||||
const frameworkUiDir = frameworkUiDirOf(destDir);
|
||||
const overrideDir = gameOverrideDir(destDir);
|
||||
const gameName = basename(destDir);
|
||||
|
||||
const { matched, errors } = validateOverrides(frameworkUiDir, overrideDir);
|
||||
if (errors.length) throw new SkinValidationError(errors);
|
||||
@@ -69,11 +74,28 @@ export function composeSkin(destDir) {
|
||||
const fwMeta = `${to}.meta`;
|
||||
if (existsSync(ovMeta) && existsSync(fwMeta)) {
|
||||
const fwUuid = JSON.parse(readFileSync(fwMeta, 'utf8')).uuid;
|
||||
if (!fwUuid) {
|
||||
// 静默通过会写出没有 uuid 的 .meta——Cocos 重新生成 UUID,框架 Prefab 对该
|
||||
// 资源的引用在发布包里断裂,这正是"保留 .meta"整个设计要防的唯一失效模式。
|
||||
throw new SkinValidationError([{
|
||||
path: rel,
|
||||
code: 'meta-missing-uuid',
|
||||
message: `${gameName}: 框架侧 .meta 没有 uuid(${fwMeta}),无法为例外通道保留引用,请先修复框架该文件的 .meta`,
|
||||
}]);
|
||||
}
|
||||
const merged = JSON.parse(readFileSync(ovMeta, 'utf8'));
|
||||
merged.uuid = fwUuid;
|
||||
writeFileSync(fwMeta, `${JSON.stringify(merged, null, 2)}\n`);
|
||||
} else if (existsSync(ovMeta) && !existsSync(fwMeta)) {
|
||||
// 框架侧没有 .meta 时整段合并被跳过,override 自带的 meta 会被无声丢弃——
|
||||
// 同样违反"下游不兜底":必须显式报错,而不是悄悄忽略 override 的意图。
|
||||
throw new SkinValidationError([{
|
||||
path: rel,
|
||||
code: 'meta-orphan',
|
||||
message: `${gameName}: override 提供了 ${rel}.meta,但框架侧没有对应的 .meta(${fwMeta}),无法合并 uuid(例外通道要求框架侧资源本身已有 .meta)`,
|
||||
}]);
|
||||
}
|
||||
}
|
||||
|
||||
return { matched, frameworkTotal: listAssets(frameworkUiDir).length };
|
||||
return { matched, frameworkTotal: listCoverableAssets(frameworkUiDir).length };
|
||||
}
|
||||
|
||||
@@ -8,7 +8,6 @@ export const HOST_PROJECT = join(ROOT, 'YouleNexus');
|
||||
export const FRAMEWORK_SRC = join(HOST_PROJECT, 'assets', 'framework');
|
||||
export const GAMES_DIR = join(ROOT, 'games');
|
||||
export const SEED_DIR = join(ROOT, 'templates', 'game-seed');
|
||||
export const FRAMEWORK_UI_DIR = join(FRAMEWORK_SRC, 'ui');
|
||||
export const BUILD_WORKSPACE = join(ROOT, 'build-workspace');
|
||||
|
||||
// 子游戏的皮肤覆盖目录
|
||||
@@ -16,6 +15,14 @@ export function gameOverrideDir(projectDir) {
|
||||
return join(projectDir, 'assets', 'game', 'override');
|
||||
}
|
||||
|
||||
// 某工程(宿主真源,或实体化后的临时工作区)的 framework/ui 目录。
|
||||
// check-skin 与 build-game(composeSkin) 共用同一份推导,不各自拼路径(唯一权威)。
|
||||
export function frameworkUiDirOf(projectDir) {
|
||||
return join(projectDir, 'assets', 'framework', 'ui');
|
||||
}
|
||||
|
||||
export const FRAMEWORK_UI_DIR = frameworkUiDirOf(HOST_PROJECT);
|
||||
|
||||
export function readCreatorVersion(projectDir) {
|
||||
try {
|
||||
const pkg = JSON.parse(readFileSync(join(projectDir, 'package.json'), 'utf8'));
|
||||
|
||||
@@ -5,6 +5,15 @@ const IMAGE_EXTS = new Set(['.png', '.jpg', '.jpeg', '.webp']);
|
||||
const SPINE_ROOT = 'spine'; // framework/ui/spine/<资源名>/ 为一套 Spine
|
||||
const SKIPPED = new Set(['.gitkeep']);
|
||||
|
||||
/**
|
||||
* 可被子游戏覆盖的框架资源目录(spec §2 规则 3 枚举的 8 个目录)。
|
||||
* `theme/` 不在其中——那是 §4.2 规定的框架 TS 代码位置,不是资源,不可覆盖。
|
||||
*/
|
||||
const COVERABLE_DIRS = new Set([
|
||||
'atlas-common', 'atlas-login', 'atlas-hall', 'atlas-room',
|
||||
'standalone', 'spine', 'audio', 'font',
|
||||
]);
|
||||
|
||||
/** 列出目录下所有资源的相对路径(正斜杠),排除 .meta 与 .gitkeep。 */
|
||||
export function listAssets(dir) {
|
||||
if (!existsSync(dir)) return [];
|
||||
@@ -20,6 +29,15 @@ export function listAssets(dir) {
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* 列出框架 UI 目录下**可被覆盖**的资源(白名单目录,见 COVERABLE_DIRS)。
|
||||
* `frameworkUiDir` 下还有 `theme/`、`README.md` 等非资源内容,
|
||||
* 不属于可覆盖范围,也不计入"框架可覆盖资源共 N 个"的统计。
|
||||
*/
|
||||
export function listCoverableAssets(frameworkUiDir) {
|
||||
return listAssets(frameworkUiDir).filter((rel) => COVERABLE_DIRS.has(rel.split(posix.sep)[0]));
|
||||
}
|
||||
|
||||
/** 读 PNG 宽高;非 PNG 或读取失败返回 null。 */
|
||||
export function readPngSize(file) {
|
||||
try {
|
||||
@@ -39,7 +57,7 @@ const spineGroup = (rel) => rel.split(posix.sep).slice(0, 2).join(posix.sep);
|
||||
* 返回 { matched: 相对路径[], errors: {path, code, message}[] }。
|
||||
*/
|
||||
export function validateOverrides(frameworkUiDir, overrideDir) {
|
||||
const fwList = listAssets(frameworkUiDir);
|
||||
const fwList = listCoverableAssets(frameworkUiDir);
|
||||
const fwSet = new Set(fwList);
|
||||
const ovList = listAssets(overrideDir);
|
||||
const ovSet = new Set(ovList);
|
||||
|
||||
@@ -54,13 +54,20 @@ function scaffold(gameVersion = '3.8.8') {
|
||||
writeFileSync(join(hostProject, 'package.json'), JSON.stringify({ name: 'YouleNexus', creator: { version: '3.8.8' } }));
|
||||
put(frameworkSrc, 'ui/atlas-hall/btn.png', DEFAULT_BTN);
|
||||
|
||||
// junction 故意指向一个诱饵目录而非 frameworkSrc 自身,与 materialize.test.mjs 同理:
|
||||
// 否则"经 junction 拷来的字节"与"从真源拷来的字节"完全相同,测不出 Critical 1 那类 bug。
|
||||
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/__decoy_marker__.png', DEFAULT_BTN);
|
||||
|
||||
const gamesDir = join(root, 'games');
|
||||
const gameDir = join(gamesDir, 'demo');
|
||||
mkdirSync(join(gameDir, 'assets'), { recursive: true });
|
||||
writeFileSync(join(gameDir, 'package.json'), JSON.stringify({ name: 'demo', creator: { version: gameVersion } }));
|
||||
symlinkSync(frameworkSrc, join(gameDir, 'assets', 'framework'), LINK_TYPE);
|
||||
symlinkSync(frameworkDecoy, join(gameDir, 'assets', 'framework'), LINK_TYPE);
|
||||
|
||||
return { root, hostProject, frameworkSrc, gamesDir, gameDir, workspace: join(root, 'build-workspace') };
|
||||
return { root, hostProject, frameworkSrc, frameworkDecoy, gamesDir, gameDir, workspace: join(root, 'build-workspace') };
|
||||
}
|
||||
function cleanup(root) { rmSync(root, { recursive: true, force: true }); }
|
||||
|
||||
@@ -83,6 +90,8 @@ test('buildGame 走通全流程:实体化 → 合成 → 调构建 → 写 bui
|
||||
|
||||
assert.ok(readFileSync(join(r.workspaceDir, 'assets', 'framework', 'ui', 'atlas-hall', 'btn.png')).equals(SKINNED));
|
||||
assert.ok(readFileSync(join(s.frameworkSrc, 'ui', 'atlas-hall', 'btn.png')).equals(DEFAULT_BTN), '框架真源必须只读:合成只能发生在临时工作区');
|
||||
assert.ok(!existsSync(join(r.workspaceDir, 'assets', 'framework', 'ui', 'atlas-hall', '__decoy_marker__.png')),
|
||||
'framework 必须来自 frameworkSrc 真源,不能残留 junction 诱饵目标独有的文件(Critical 1)');
|
||||
assert.equal(calls.length, 1);
|
||||
assert.equal(calls[0][0], r.workspaceDir);
|
||||
assert.equal(calls[0][1], 'web-mobile');
|
||||
|
||||
@@ -5,7 +5,10 @@ import { fileURLToPath, pathToFileURL } from 'node:url';
|
||||
import { dirname, join } from 'node:path';
|
||||
|
||||
const scriptsDir = join(dirname(fileURLToPath(import.meta.url)), '..');
|
||||
const CLI = ['setup-links.mjs', 'new-game.mjs', 'check-cocos-version.mjs', 'bump-cocos.mjs'];
|
||||
const CLI = [
|
||||
'setup-links.mjs', 'new-game.mjs', 'check-cocos-version.mjs', 'bump-cocos.mjs',
|
||||
'check-skin.mjs', 'build-game.mjs',
|
||||
];
|
||||
|
||||
for (const name of CLI) {
|
||||
test(`${name} 被动态 import 时不崩溃(argv[1] undefined)`, () => {
|
||||
|
||||
@@ -49,7 +49,15 @@ function put(dir, rel, content) {
|
||||
function read(dir, rel) { return readFileSync(join(dir, ...rel.split('/')), 'utf8'); }
|
||||
function readBinary(dir, rel) { return readFileSync(join(dir, ...rel.split('/'))); }
|
||||
|
||||
/** 造一个「框架真源 + 挂 junction 的子游戏工程」的临时环境 */
|
||||
/**
|
||||
* 造一个「框架真源 + 挂 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');
|
||||
@@ -59,15 +67,24 @@ function scaffold() {
|
||||
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(frameworkSrc, join(gameDir, 'assets', 'framework'), LINK_TYPE);
|
||||
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, gameDir, dest: join(root, 'workspace', 'demo') };
|
||||
return { root, frameworkSrc, frameworkDecoy, gameDir, dest: join(root, 'workspace', 'demo') };
|
||||
}
|
||||
function cleanup(root) { rmSync(root, { recursive: true, force: true }); }
|
||||
|
||||
@@ -100,6 +117,17 @@ test('materialize 后写临时工程不影响框架真源', () => {
|
||||
} 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 {
|
||||
@@ -135,6 +163,47 @@ test('composeSkin 例外通道:override 自带 meta 时,uuid 被改回框架
|
||||
} 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 {
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { join } from 'node:path';
|
||||
import { join, sep } from 'node:path';
|
||||
import { writeFileSync, mkdirSync } from 'node:fs';
|
||||
import { makeTempMonorepo, makeGameProject, cleanup } from './helpers.mjs';
|
||||
import { readCreatorVersion, listGameProjects } from '../lib/paths.mjs';
|
||||
import { readCreatorVersion, listGameProjects, BUILD_WORKSPACE, GAMES_DIR } from '../lib/paths.mjs';
|
||||
|
||||
test('readCreatorVersion 读取 package.json 的 creator.version', () => {
|
||||
const mono = makeTempMonorepo();
|
||||
@@ -42,3 +42,10 @@ test('listGameProjects 只列出含 package.json 的子目录', () => {
|
||||
test('listGameProjects 对不存在的目录返回空数组', () => {
|
||||
assert.deepEqual(listGameProjects('/no/such/dir'), []);
|
||||
});
|
||||
|
||||
test('BUILD_WORKSPACE 不在 GAMES_DIR 之下(红线③:临时工程不得置于 games/ 下)', () => {
|
||||
assert.ok(
|
||||
BUILD_WORKSPACE !== GAMES_DIR && !BUILD_WORKSPACE.startsWith(GAMES_DIR + sep),
|
||||
`BUILD_WORKSPACE(${BUILD_WORKSPACE}) 不应落在 GAMES_DIR(${GAMES_DIR}) 之下`,
|
||||
);
|
||||
});
|
||||
|
||||
@@ -4,7 +4,7 @@ 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';
|
||||
import { listAssets, listCoverableAssets, readPngSize, validateOverrides } from '../lib/skin.mjs';
|
||||
|
||||
// ---- 测试辅助:零依赖生成指定尺寸的 PNG ----
|
||||
function crc32(buf) {
|
||||
@@ -59,6 +59,36 @@ 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();
|
||||
|
||||
@@ -269,9 +269,13 @@ npm run build-game <name> [--platform android]
|
||||
3. 皮肤合成 assets/game/override/** 覆盖到 assets/framework/ui/**
|
||||
按 §3.2 校验;任一不合法即中止;打印命中统计
|
||||
4. 构建 调 Cocos CLI 在临时工程上构建
|
||||
5. 产出 拷至 dist/<name>/,写 build-info.json
|
||||
5. 产出 写 build-info.json(拷至 dist/<name>/ 本期未实现,见下)
|
||||
```
|
||||
|
||||
> **`dist/<name>/` 拷贝:本期未实现。** `build-game.mjs` 目前止步于 `build-workspace/<name>/`
|
||||
> 内调用 Cocos CLI 构建 + 写 `build-info.json`;把构建产物拷到独立的 `dist/<name>/` 尚未落地
|
||||
> (构建产物的实际目录结构未经真实构建验证,贸然实现拷贝逻辑等于凭空猜测路径)。
|
||||
|
||||
### 5.1 四条硬规则
|
||||
|
||||
**① 绝不在框架真源上做合成。**
|
||||
|
||||
Reference in New Issue
Block a user