界面迁移更新

This commit is contained in:
2026-09-05 00:12:11 +08:00
parent e2762d631d
commit 0aa66e1ec7
3187 changed files with 667915 additions and 211 deletions
@@ -0,0 +1,147 @@
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 { buildGame } from '../build-game.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 SKINNED = 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 scaffold(gameVersion = '3.8.8') {
const root = mkdtempSync(join(tmpdir(), 'youle-build-'));
const hostProject = join(root, 'nexus');
const frameworkSrc = join(hostProject, 'assets', 'framework');
mkdirSync(frameworkSrc, { recursive: true });
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(frameworkDecoy, join(gameDir, 'assets', 'framework'), LINK_TYPE);
return { root, hostProject, frameworkSrc, frameworkDecoy, gamesDir, gameDir, workspace: join(root, 'build-workspace') };
}
function cleanup(root) { rmSync(root, { recursive: true, force: true }); }
function baseOpts(s, extra = {}) {
return {
gamesDir: s.gamesDir, frameworkSrc: s.frameworkSrc, hostProject: s.hostProject,
workspace: s.workspace, platform: 'web-mobile',
frameworkCommit: 'abc1234', now: '2026-08-27T00:00:00.000Z',
runBuild: () => {},
...extra,
};
}
test('buildGame 走通全流程:实体化 → 合成 → 调构建 → 写 build-info', () => {
const s = scaffold();
try {
put(s.gameDir, 'assets/game/override/atlas-hall/btn.png', SKINNED);
const calls = [];
const r = buildGame('demo', baseOpts(s, { runBuild: (dir, p) => calls.push([dir, p]) }));
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');
const info = JSON.parse(readFileSync(join(r.workspaceDir, 'build-info.json'), 'utf8'));
assert.equal(info.game, 'demo');
assert.equal(info.frameworkCommit, 'abc1234');
assert.equal(info.platform, 'web-mobile');
assert.deepEqual(info.skin, { matched: 1, frameworkTotal: 1 });
} finally { cleanup(s.root); }
});
test('buildGame 在引擎版本不一致时中止,且不调用构建', () => {
const s = scaffold('3.8.6');
try {
let called = false;
assert.throws(
() => buildGame('demo', baseOpts(s, { runBuild: () => { called = true; } })),
/引擎版本不一致/,
);
assert.equal(called, false);
} finally { cleanup(s.root); }
});
test('buildGame 在皮肤校验失败时中止,且不调用构建', () => {
const s = scaffold();
try {
put(s.gameDir, 'assets/game/override/atlas-hall/typo.png', 'X');
let called = false;
assert.throws(
() => buildGame('demo', baseOpts(s, { runBuild: () => { called = true; } })),
/皮肤覆盖校验未通过/,
);
assert.equal(called, false);
} finally { cleanup(s.root); }
});
test('buildGame 重复构建:清掉旧工作区后重建(幂等)', () => {
const s = scaffold();
try {
const opts = baseOpts(s);
buildGame('demo', opts);
const r2 = buildGame('demo', opts); // 不应因目标已存在而抛错
assert.ok(existsSync(join(r2.workspaceDir, 'build-info.json')));
} finally { cleanup(s.root); }
});
test('buildGame 对不存在的子游戏抛错', () => {
const s = scaffold();
try {
assert.throws(() => buildGame('nosuch', baseOpts(s)), /找不到子游戏工程/);
} finally { cleanup(s.root); }
});
@@ -0,0 +1,36 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { join } from 'node:path';
import { readFileSync, writeFileSync } from 'node:fs';
import { makeTempMonorepo, makeGameProject, cleanup } from './helpers.mjs';
import { bumpVersion } from '../bump-cocos.mjs';
test('bumpVersion 改写各工程 creator.version 并报告 from/to', () => {
const mono = makeTempMonorepo();
try {
const a = makeGameProject(mono.games, 'a', '3.8.8');
const changed = bumpVersion([mono.host, a], '3.8.9');
assert.equal(changed.length, 2);
assert.equal(changed[1].from, '3.8.8');
assert.equal(changed[1].to, '3.8.9');
const pkgA = JSON.parse(readFileSync(join(a, 'package.json'), 'utf8'));
assert.equal(pkgA.creator.version, '3.8.9');
const pkgHost = JSON.parse(readFileSync(join(mono.host, 'package.json'), 'utf8'));
assert.equal(pkgHost.creator.version, '3.8.9');
} finally {
cleanup(mono.root);
}
});
test('bumpVersion 对缺 creator 字段的工程补建后写入', () => {
const mono = makeTempMonorepo();
try {
const a = makeGameProject(mono.games, 'a', '3.8.8');
writeFileSync(join(a, 'package.json'), JSON.stringify({ name: 'a' }));
bumpVersion([a], '3.8.9');
const pkgA = JSON.parse(readFileSync(join(a, 'package.json'), 'utf8'));
assert.equal(pkgA.creator.version, '3.8.9');
} finally {
cleanup(mono.root);
}
});
@@ -0,0 +1,32 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { makeTempMonorepo, makeGameProject, cleanup } from './helpers.mjs';
import { checkVersions } from '../check-cocos-version.mjs';
test('checkVersions 全部一致时 mismatches 为空', () => {
const mono = makeTempMonorepo();
try {
makeGameProject(mono.games, 'a', '3.8.8');
makeGameProject(mono.games, 'b', '3.8.8');
const r = checkVersions(mono.host, mono.games);
assert.equal(r.expected, '3.8.8');
assert.deepEqual(r.mismatches, []);
} finally {
cleanup(mono.root);
}
});
test('checkVersions 报告与宿主不一致的工程', () => {
const mono = makeTempMonorepo();
try {
makeGameProject(mono.games, 'a', '3.8.8');
makeGameProject(mono.games, 'b', '3.8.6');
const r = checkVersions(mono.host, mono.games);
assert.equal(r.mismatches.length, 1);
assert.match(r.mismatches[0].project, /[\\/]b$/);
assert.equal(r.mismatches[0].version, '3.8.6');
assert.equal(r.mismatches[0].expected, '3.8.8');
} finally {
cleanup(mono.root);
}
});
@@ -0,0 +1,85 @@
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 { checkSkin } from '../check-skin.mjs';
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 put(dir, rel, content) {
const p = join(dir, ...rel.split('/'));
mkdirSync(join(p, '..'), { recursive: true });
writeFileSync(p, content);
}
function scaffold() {
const root = mkdtempSync(join(tmpdir(), 'youle-checkskin-'));
const frameworkUiDir = join(root, 'ui');
const gamesDir = join(root, 'games');
mkdirSync(frameworkUiDir, { recursive: true });
mkdirSync(join(gamesDir, 'demo', 'assets', 'game', 'override'), { recursive: true });
put(frameworkUiDir, 'atlas-hall/btn.png', makePng(64, 64));
put(frameworkUiDir, 'atlas-hall/bg.png', makePng(64, 64));
return { root, frameworkUiDir, gamesDir, overrideDir: join(gamesDir, 'demo', 'assets', 'game', 'override') };
}
test('checkSkin 统计命中数与框架资源总数', () => {
const s = scaffold();
try {
put(s.overrideDir, 'atlas-hall/btn.png', makePng(64, 64));
const r = checkSkin('demo', { gamesDir: s.gamesDir, frameworkUiDir: s.frameworkUiDir });
assert.deepEqual(r.errors, []);
assert.deepEqual(r.matched, ['atlas-hall/btn.png']);
assert.equal(r.frameworkTotal, 2);
} finally { rmSync(s.root, { recursive: true, force: true }); }
});
test('checkSkin 报出孤儿 override', () => {
const s = scaffold();
try {
put(s.overrideDir, 'atlas-hall/nope.png', makePng(64, 64));
const r = checkSkin('demo', { gamesDir: s.gamesDir, frameworkUiDir: s.frameworkUiDir });
assert.equal(r.errors.length, 1);
assert.equal(r.errors[0].code, 'orphan');
} finally { rmSync(s.root, { recursive: true, force: true }); }
});
test('checkSkin 对不存在的子游戏抛错', () => {
const s = scaffold();
try {
assert.throws(
() => checkSkin('nosuch', { gamesDir: s.gamesDir, frameworkUiDir: s.frameworkUiDir }),
/找不到子游戏工程/,
);
} finally { rmSync(s.root, { recursive: true, force: true }); }
});
test('checkSkin 对没有 override 目录的子游戏返回零命中而非报错', () => {
const s = scaffold();
try {
rmSync(s.overrideDir, { recursive: true, force: true });
const r = checkSkin('demo', { gamesDir: s.gamesDir, frameworkUiDir: s.frameworkUiDir });
assert.deepEqual(r.matched, []);
assert.deepEqual(r.errors, []);
} finally { rmSync(s.root, { recursive: true, force: true }); }
});
@@ -0,0 +1,22 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { spawnSync } from 'node:child_process';
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',
'check-skin.mjs', 'build-game.mjs',
];
for (const name of CLI) {
test(`${name} 被动态 import 时不崩溃(argv[1] undefined)`, () => {
const url = pathToFileURL(join(scriptsDir, name)).href;
const r = spawnSync(process.execPath,
['--input-type=module', '-e', `import(${JSON.stringify(url)}).then(()=>process.exit(0))`],
{ encoding: 'utf8' });
assert.equal(r.status, 0, `stderr: ${r.stderr}`);
assert.ok(!/TypeError/.test(r.stderr), `不应出现 TypeError: ${r.stderr}`);
});
}
@@ -0,0 +1,47 @@
import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
// 构造一个临时 monorepo:YouleNexus(host) + framework 真源 + 空 games/
export function makeTempMonorepo() {
const root = mkdtempSync(join(tmpdir(), 'youle-mono-'));
const host = join(root, 'YouleNexus');
const framework = join(host, 'assets', 'framework');
mkdirSync(framework, { recursive: true });
writeFileSync(
join(host, 'package.json'),
JSON.stringify({ name: 'YouleNexus', uuid: 'host-uuid', creator: { version: '3.8.8' } }, null, 2)
);
writeFileSync(join(framework, 'README.md'), '# framework src\n');
const games = join(root, 'games');
mkdirSync(games, { recursive: true });
return { root, host, framework, games };
}
// 在 gamesDir 下造一个子游戏工程(含 package.json)
export function makeGameProject(gamesDir, name, version = '3.8.8') {
const dir = join(gamesDir, name);
mkdirSync(join(dir, 'assets'), { recursive: true });
writeFileSync(
join(dir, 'package.json'),
JSON.stringify({ name, uuid: `${name}-uuid`, creator: { version } }, null, 2)
);
return dir;
}
// 造一个最小种子工程
export function makeSeed(root) {
const seed = join(root, 'templates', 'game-seed');
mkdirSync(join(seed, 'assets'), { recursive: true });
mkdirSync(join(seed, 'settings'), { recursive: true });
writeFileSync(
join(seed, 'package.json'),
JSON.stringify({ name: 'game-seed', uuid: 'seed-uuid', creator: { version: '3.8.8' } }, null, 2)
);
writeFileSync(join(seed, 'settings', 'v2.json'), '{}\n');
return seed;
}
export function cleanup(root) {
rmSync(root, { recursive: true, force: true });
}
@@ -0,0 +1,50 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { join } from 'node:path';
import { existsSync, readFileSync, writeFileSync } from 'node:fs';
import { makeTempMonorepo, cleanup } from './helpers.mjs';
import { ensureJunction, isLink } from '../lib/junction.mjs';
test('ensureJunction 创建链接后可经链接读到真源文件', () => {
const mono = makeTempMonorepo();
try {
const link = join(mono.games, 'demo-framework');
const status = ensureJunction(link, mono.framework);
assert.equal(status, 'created');
assert.ok(isLink(link));
assert.equal(readFileSync(join(link, 'README.md'), 'utf8'), '# framework src\n');
} finally {
cleanup(mono.root);
}
});
test('ensureJunction 对已存在链接返回 exists 且不抛错', () => {
const mono = makeTempMonorepo();
try {
const link = join(mono.games, 'demo-framework');
ensureJunction(link, mono.framework);
assert.equal(ensureJunction(link, mono.framework), 'exists');
} finally {
cleanup(mono.root);
}
});
test('ensureJunction 目标不存在时抛错', () => {
const mono = makeTempMonorepo();
try {
assert.throws(() => ensureJunction(join(mono.games, 'x'), join(mono.root, 'no-such')), /target 不存在/);
} finally {
cleanup(mono.root);
}
});
test('ensureJunction 拒绝覆盖已存在的真实目录/文件', () => {
const mono = makeTempMonorepo();
try {
const link = join(mono.games, 'realfile');
writeFileSync(link, 'x');
assert.throws(() => ensureJunction(link, mono.framework), /非链接/);
} finally {
cleanup(mono.root);
}
});
@@ -0,0 +1,230 @@
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); }
});
@@ -0,0 +1,50 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { join } from 'node:path';
import { existsSync, readFileSync } from 'node:fs';
import { makeTempMonorepo, makeSeed, cleanup } from './helpers.mjs';
import { newGame } from '../new-game.mjs';
import { isLink } from '../lib/junction.mjs';
test('newGame 生成工程:改身份 + 挂 framework junction + 铺 game 骨架', () => {
const mono = makeTempMonorepo();
try {
const seed = makeSeed(mono.root);
const result = newGame('doudizhu', { seedDir: seed, gamesDir: mono.games, frameworkSrc: mono.framework });
const dest = join(mono.games, 'doudizhu');
assert.equal(result.projectDir, dest);
const pkg = JSON.parse(readFileSync(join(dest, 'package.json'), 'utf8'));
assert.equal(pkg.name, 'doudizhu');
assert.notEqual(pkg.uuid, 'seed-uuid');
const link = join(dest, 'assets', 'framework');
assert.ok(isLink(link));
assert.equal(readFileSync(join(link, 'README.md'), 'utf8'), '# framework src\n');
assert.ok(existsSync(join(dest, 'assets', 'game', 'GameModule.ts')));
} finally {
cleanup(mono.root);
}
});
test('newGame 拒绝重名工程', () => {
const mono = makeTempMonorepo();
try {
const seed = makeSeed(mono.root);
const opts = { seedDir: seed, gamesDir: mono.games, frameworkSrc: mono.framework };
newGame('doudizhu', opts);
assert.throws(() => newGame('doudizhu', opts), /已存在/);
} finally {
cleanup(mono.root);
}
});
test('newGame 校验名称非空且合法', () => {
const mono = makeTempMonorepo();
try {
const seed = makeSeed(mono.root);
const opts = { seedDir: seed, gamesDir: mono.games, frameworkSrc: mono.framework };
assert.throws(() => newGame('', opts), /名称/);
assert.throws(() => newGame('Bad Name!', opts), /名称/);
} finally {
cleanup(mono.root);
}
});
@@ -0,0 +1,51 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { join, sep } from 'node:path';
import { writeFileSync, mkdirSync } from 'node:fs';
import { makeTempMonorepo, makeGameProject, cleanup } from './helpers.mjs';
import { readCreatorVersion, listGameProjects, BUILD_WORKSPACE, GAMES_DIR } from '../lib/paths.mjs';
test('readCreatorVersion 读取 package.json 的 creator.version', () => {
const mono = makeTempMonorepo();
try {
assert.equal(readCreatorVersion(mono.host), '3.8.8');
} finally {
cleanup(mono.root);
}
});
test('readCreatorVersion 缺字段返回 null', () => {
const mono = makeTempMonorepo();
try {
const dir = makeGameProject(mono.games, 'nover');
// 覆写成无 creator 字段
writeFileSync(join(dir, 'package.json'), JSON.stringify({ name: 'nover' }));
assert.equal(readCreatorVersion(dir), null);
} finally {
cleanup(mono.root);
}
});
test('listGameProjects 只列出含 package.json 的子目录', () => {
const mono = makeTempMonorepo();
try {
makeGameProject(mono.games, 'doudizhu');
makeGameProject(mono.games, 'niuniu');
mkdirSync(join(mono.games, 'not-a-project')); // 无 package.json,应被忽略
const found = listGameProjects(mono.games).map((p) => p.split(/[\\/]/).pop()).sort();
assert.deepEqual(found, ['doudizhu', 'niuniu']);
} finally {
cleanup(mono.root);
}
});
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}) 之下`,
);
});
@@ -0,0 +1,69 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { join } from 'node:path';
import { existsSync, readFileSync, mkdirSync, writeFileSync } from 'node:fs';
import { makeTempMonorepo, makeSeed, cleanup } from './helpers.mjs';
import { copySeed, setProjectIdentity, scaffoldGameAssets } from '../lib/scaffold.mjs';
test('copySeed 复制种子并清理缓存目录', () => {
const mono = makeTempMonorepo();
try {
const seed = makeSeed(mono.root);
mkdirSync(join(seed, 'library'), { recursive: true }); // 模拟缓存
writeFileSync(join(seed, 'library', 'x'), '1');
const dest = join(mono.games, 'demo');
copySeed(seed, dest);
assert.ok(existsSync(join(dest, 'package.json')));
assert.ok(existsSync(join(dest, 'settings', 'v2.json')));
assert.ok(!existsSync(join(dest, 'library')), 'library 应被清理');
} finally {
cleanup(mono.root);
}
});
test('copySeed 拒绝覆盖已存在目标', () => {
const mono = makeTempMonorepo();
try {
const seed = makeSeed(mono.root);
const dest = join(mono.games, 'demo');
copySeed(seed, dest);
assert.throws(() => copySeed(seed, dest), /已存在/);
} finally {
cleanup(mono.root);
}
});
test('setProjectIdentity 改 name 并生成新 uuid', () => {
const mono = makeTempMonorepo();
try {
const seed = makeSeed(mono.root);
const dest = join(mono.games, 'demo');
copySeed(seed, dest);
const pkg = setProjectIdentity(dest, 'doudizhu');
assert.equal(pkg.name, 'doudizhu');
assert.notEqual(pkg.uuid, 'seed-uuid');
assert.match(pkg.uuid, /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/);
const onDisk = JSON.parse(readFileSync(join(dest, 'package.json'), 'utf8'));
assert.equal(onDisk.name, 'doudizhu');
assert.equal(onDisk.creator.version, '3.8.8'); // 引擎版本保留
} finally {
cleanup(mono.root);
}
});
test('scaffoldGameAssets 铺出 game 骨架(GameModule/theme/override)', () => {
const mono = makeTempMonorepo();
try {
const seed = makeSeed(mono.root);
const dest = join(mono.games, 'demo');
copySeed(seed, dest);
scaffoldGameAssets(dest, 'doudizhu');
const gm = readFileSync(join(dest, 'assets', 'game', 'GameModule.ts'), 'utf8');
assert.match(gm, /doudizhu/);
assert.match(gm, /docs\/protocol/); // 提醒协议 SSOT
assert.ok(existsSync(join(dest, 'assets', 'game', 'theme.ts')));
assert.ok(existsSync(join(dest, 'assets', 'game', 'override', '.gitkeep')));
} finally {
cleanup(mono.root);
}
});
@@ -0,0 +1,36 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { join } from 'node:path';
import { readFileSync } from 'node:fs';
import { makeTempMonorepo, makeGameProject, cleanup } from './helpers.mjs';
import { setupLinks } from '../setup-links.mjs';
import { isLink } from '../lib/junction.mjs';
test('setupLinks 为每个子游戏工程建立 assets/framework junction', () => {
const mono = makeTempMonorepo();
try {
makeGameProject(mono.games, 'doudizhu');
makeGameProject(mono.games, 'niuniu');
const results = setupLinks(mono.games, mono.framework);
assert.equal(results.length, 2);
for (const r of results) {
assert.equal(r.status, 'created');
assert.ok(isLink(r.link));
assert.equal(readFileSync(join(r.link, 'README.md'), 'utf8'), '# framework src\n');
}
} finally {
cleanup(mono.root);
}
});
test('setupLinks 二次运行幂等(已存在则 exists)', () => {
const mono = makeTempMonorepo();
try {
makeGameProject(mono.games, 'doudizhu');
setupLinks(mono.games, mono.framework);
const second = setupLinks(mono.games, mono.framework);
assert.equal(second[0].status, 'exists');
} finally {
cleanup(mono.root);
}
});
@@ -0,0 +1,244 @@
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); }
});
@@ -0,0 +1,82 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { join } from 'node:path';
import { layerBucket, imageBuckets } from '../ui-migration/buckets.mjs';
import { loadSource } from '../ui-migration/gameabc-source.mjs';
import { ROOT } from '../lib/paths.mjs';
const REAL_SOURCE = join(ROOT, '..', 'projects', 'Game_Surface_3', 'output');
test('layerBucket 按名单判 login', () => {
assert.equal(layerBucket(1, 'Logo_Layer'), 'login');
assert.equal(layerBucket(2, 'Login_Layer'), 'login');
assert.equal(layerBucket(18, 'Auth_layer'), 'login');
assert.equal(layerBucket(16, 'Bind_Layer'), 'login');
assert.equal(layerBucket(29, 'phoneInfo'), 'login');
});
test('layerBucket 按 ID 段位判 hall / room / common', () => {
assert.equal(layerBucket(4, 'MainMenu_Layer'), 'hall');
assert.equal(layerBucket(26, 'Task_Layer'), 'hall');
assert.equal(layerBucket(50, 'MainScene_Layer'), 'room');
assert.equal(layerBucket(202, 'Player_Head_Score_Layer'), 'room');
assert.equal(layerBucket(418, 'ChatPannel_Layer'), 'room');
assert.equal(layerBucket(602, 'inputPannel'), 'common');
assert.equal(layerBucket(614, 'Loading_Layer'), 'common');
});
test('layerBucket 对命名笼统的层返回 unassigned(不猜)', () => {
for (const [id, name] of [[6, 'Layer6'], [13, 'Layer13'], [403, 'Layer403'],
[411, 'Layer411'], [601, 'Layer601'], [603, 'Layer603'], [618, 'Layer618']]) {
assert.equal(layerBucket(id, name), 'unassigned', `${name} 应为 unassigned`);
}
});
test('layerBucket 的 unassigned 优先于段位规则', () => {
// Layer601 若只按段位会判成 common,但名字笼统 → 必须 unassigned
assert.equal(layerBucket(601, 'Layer601'), 'unassigned');
});
test('imageBuckets 单一 bucket 的图归该 bucket', () => {
const source = {
objects: new Map([[1, { ObjectID: 1, ImageFileID: 7, BelongLayerID: 2 }]]),
layers: [{ layerId: 2, name: 'Login_Layer', objectIds: [1] }],
images: new Map([[7, { id: 7, bmp: 'a.png', frame_all: 1 }]]),
groups: [],
};
assert.equal(imageBuckets(source).get(7), 'login');
});
test('imageBuckets 跨 bucket 的图归 common', () => {
const source = {
objects: new Map([
[1, { ObjectID: 1, ImageFileID: 7, BelongLayerID: 2 }],
[2, { ObjectID: 2, ImageFileID: 7, BelongLayerID: 4 }],
]),
layers: [
{ layerId: 2, name: 'Login_Layer', objectIds: [1] },
{ layerId: 4, name: 'MainMenu_Layer', objectIds: [2] },
],
images: new Map([[7, { id: 7, bmp: 'a.png', frame_all: 1 }]]),
groups: [],
};
assert.equal(imageBuckets(source).get(7), 'common');
});
test('imageBuckets 无对象引用的图归 unassigned', () => {
const source = {
objects: new Map(),
layers: [],
images: new Map([[7, { id: 7, bmp: 'orphan.png', frame_all: 1 }]]),
groups: [],
};
assert.equal(imageBuckets(source).get(7), 'unassigned');
});
test('imageBuckets 在真实数据上给每张图一个归属(回归护栏)', () => {
const source = loadSource(REAL_SOURCE);
const m = imageBuckets(source);
assert.equal(m.size, source.images.size, '每张图都要有归属');
const valid = new Set(['login', 'hall', 'room', 'common', 'unassigned']);
for (const [id, b] of m) assert.ok(valid.has(b), `图 ${id} 的 bucket 非法: ${b}`);
});
@@ -0,0 +1,216 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { join } from 'node:path';
import { convertLayer, convertAll } from '../ui-migration/convert.mjs';
import { loadSource } from '../ui-migration/gameabc-source.mjs';
import { imageBuckets } from '../ui-migration/buckets.mjs';
import { ROOT } from '../lib/paths.mjs';
const REAL_SOURCE = join(ROOT, '..', 'projects', 'Game_Surface_3', 'output');
function tinySource() {
const objects = new Map([
[1, { ObjectID: 1, ObjectType: 2, ObjectName: '背景', Left: 0, Top: 0, Width: 1280, Height: 720,
BelongLayerID: 2, IndexOfLayer: 1, ImageFileID: 1, FrameStyle: 0, FrameIndex: 0,
L9: 0, T9: 0, R9: 0, B9: 0, GroupID: 5, VoiceFileID: 0, TimerInterval: 0, events: [] }],
[2, { ObjectID: 2, ObjectType: 2, ObjectName: '按钮', Left: 320, Top: 200, Width: 150, Height: 70,
BelongLayerID: 2, IndexOfLayer: 2, ImageFileID: 2, FrameStyle: 0, FrameIndex: 5,
L9: 12, T9: 8, R9: 12, B9: 8, GroupID: 5, VoiceFileID: 0, TimerInterval: 0,
events: ['mousedown'] }],
[3, { ObjectID: 3, ObjectType: 4, ObjectName: '昵称文字', Left: 100, Top: 50, Width: 76, Height: 25,
BelongLayerID: 2, IndexOfLayer: 3, ImageFileID: 0, FrameStyle: 0, FrameIndex: 0,
L9: 0, T9: 0, R9: 0, B9: 0, GroupID: 5, VoiceFileID: 0, TimerInterval: 0, events: [] }],
]);
return {
objects,
layers: [{ layerId: 2, name: 'Login_Layer', objectIds: [1, 2, 3] }],
groups: [{ groupId: 5, objectIds: [1, 2, 3] }],
images: new Map([
[1, { id: 1, bmp: '00001.png', w: 1, h: 1, frame_all: 1, w1: 1280, h1: 720, w_all: 1280, h_all: 720 }],
[2, { id: 2, bmp: '00014.png', w: 3, h: 4, frame_all: 12, w1: 150, h1: 70, w_all: 450, h_all: 280 }],
]),
};
}
test('convertLayer 产出层元信息与 bucket', () => {
const s = tinySource();
const doc = convertLayer(s, s.layers[0], imageBuckets(s));
assert.equal(doc.layerId, 2);
assert.equal(doc.name, 'Login_Layer');
assert.equal(doc.bucket, 'login');
assert.equal(doc.nodes.length, 3);
});
test('convertLayer 识别背景:层内首个 + 满屏 → stretch', () => {
const s = tinySource();
const doc = convertLayer(s, s.layers[0], imageBuckets(s));
assert.equal(doc.backgroundNodeId, 1);
const bg = doc.nodes.find((n) => n.id === 1);
assert.equal(bg.flags.stretch, true);
assert.equal(bg.widget.horizontal, 'stretch');
});
test('convertLayer 背景判定只认层内首个,但拉伸适用于所有满屏对象(spec §2.4)', () => {
const s = tinySource();
// 追加一个非首位、同样满屏的对象(如"创建房间遮罩"那类)——
// backgroundNodeId 仍应只认层内首个,但 flags.stretch/widget 对所有满屏对象一视同仁
s.objects.set(4, {
ObjectID: 4, ObjectType: 2, ObjectName: '第二张满屏遮罩', Left: 0, Top: 0, Width: 1280, Height: 720,
BelongLayerID: 2, IndexOfLayer: 4, ImageFileID: 1, FrameStyle: 0, FrameIndex: 0,
L9: 0, T9: 0, R9: 0, B9: 0, GroupID: 5, VoiceFileID: 0, TimerInterval: 0, events: [],
});
s.layers[0].objectIds.push(4);
s.groups[0].objectIds.push(4);
const doc = convertLayer(s, s.layers[0], imageBuckets(s));
assert.equal(doc.backgroundNodeId, 1, '背景仍应是层内首个对象');
const second = doc.nodes.find((n) => n.id === 4);
assert.equal(second.flags.stretch, true, '满屏对象都应拉伸,不论是否层内首个');
assert.equal(second.widget.horizontal, 'stretch');
});
test('convertLayer 非背景对象用三区锚点与转换坐标', () => {
const s = tinySource();
const btn = convertLayer(s, s.layers[0], imageBuckets(s)).nodes.find((n) => n.id === 2);
assert.deepEqual(btn.position, { x: -245, y: 125 });
assert.deepEqual(btn.widget, { horizontal: 'center', horizontalCenter: -245 });
});
test('convertLayer 九宫格非零 → sliced 并带 slice 值', () => {
const s = tinySource();
const btn = convertLayer(s, s.layers[0], imageBuckets(s)).nodes.find((n) => n.id === 2);
assert.equal(btn.sprite.type, 'sliced');
assert.deepEqual(btn.sprite.slice, { left: 12, right: 12, top: 8, bottom: 8 });
});
test('convertLayer 多帧图节点带 frame 与 frameSet', () => {
const s = tinySource();
const btn = convertLayer(s, s.layers[0], imageBuckets(s)).nodes.find((n) => n.id === 2);
assert.equal(btn.sprite.frame, 'atlas-login/00014_05.png');
assert.deepEqual(btn.sprite.frameSet, { prefix: 'atlas-login/00014', count: 12, pad: 2 });
});
test('convertLayer Type4 转 label 且不带 sprite/样式字段', () => {
const s = tinySource();
const txt = convertLayer(s, s.layers[0], imageBuckets(s)).nodes.find((n) => n.id === 3);
assert.equal(txt.kind, 'label');
assert.equal(txt.sprite, undefined);
assert.deepEqual(txt.size, { width: 76, height: 25 });
});
test('convertLayer 保留 legacy 承重字段', () => {
const s = tinySource();
const btn = convertLayer(s, s.layers[0], imageBuckets(s)).nodes.find((n) => n.id === 2);
assert.equal(btn.legacy.objectId, 2);
assert.equal(btn.legacy.imageFileId, 2);
assert.equal(btn.legacy.frameIndex, 5);
assert.equal(btn.legacy.groupId, 5);
assert.equal(btn.legacy.left, 320);
assert.equal(btn.legacy.top, 200);
assert.deepEqual(btn.legacy.events, ['mousedown']);
});
test('convertLayer 对象引用不存在的图时显式抛错(不兜底),消息可定位对象与图', () => {
const s = tinySource();
s.objects.set(4, {
ObjectID: 4, ObjectType: 2, ObjectName: '缺图对象', Left: 10, Top: 10, Width: 50, Height: 50,
BelongLayerID: 2, IndexOfLayer: 4, ImageFileID: 999, FrameStyle: 0, FrameIndex: 0,
L9: 0, T9: 0, R9: 0, B9: 0, GroupID: 5, VoiceFileID: 0, TimerInterval: 0, events: [],
});
s.layers[0].objectIds.push(4);
s.groups[0].objectIds.push(4);
assert.throws(
() => convertLayer(s, s.layers[0], imageBuckets(s)),
(err) => {
assert.ok(err.message.includes('对象 4'), `错误信息应包含对象 ID: ${err.message}`);
assert.ok(err.message.includes('999'), `错误信息应包含图 ID: ${err.message}`);
return true;
},
);
});
test('convertLayer 帧号越界:回退帧 1、标记 flags.frameIndexOutOfRange、收集 warning', () => {
const s = tinySource();
// 追加一张 frame_all=2 的图,对象却写着 FrameIndex=3——复现真实数据里 9/40 那类越界
s.images.set(3, { id: 3, bmp: '00099.png', w: 2, h: 1, frame_all: 2, w1: 50, h1: 50, w_all: 100, h_all: 50 });
s.objects.set(4, {
ObjectID: 4, ObjectType: 2, ObjectName: '越界帧对象', Left: 500, Top: 300, Width: 50, Height: 50,
BelongLayerID: 2, IndexOfLayer: 4, ImageFileID: 3, FrameStyle: 0, FrameIndex: 3,
L9: 0, T9: 0, R9: 0, B9: 0, GroupID: 5, VoiceFileID: 0, TimerInterval: 0, events: [],
});
s.layers[0].objectIds.push(4);
s.groups[0].objectIds.push(4);
const doc = convertLayer(s, s.layers[0], imageBuckets(s));
const n = doc.nodes.find((node) => node.id === 4);
assert.equal(n.flags.frameIndexOutOfRange, true, '越界应打标记');
assert.equal(n.sprite.frame, 'atlas-login/00099_1.png', '越界应回退为帧 1,不得抛错也不得跳过节点');
assert.equal(doc.warnings.length, 1);
const w = doc.warnings[0];
assert.equal(w.objectId, 4);
assert.equal(w.layerId, 2);
assert.equal(w.imageFileId, 3);
assert.equal(w.frameIndex, 3);
assert.equal(w.frameCount, 2);
assert.equal(typeof w.reason, 'string');
assert.ok(w.reason.length > 0, 'reason 必须可读,能定位到具体对象/图');
});
test('convertLayer 单帧图上的残留 FrameIndex 不产生 warning(frameCount<=1 本就与 FrameIndex 无关)', () => {
const s = tinySource();
// 单帧图(frame_all=1)却写着 FrameIndex=8——真实数据里 30/40 这一类,属无害残留
s.images.set(3, { id: 3, bmp: '00088.png', w: 1, h: 1, frame_all: 1, w1: 50, h1: 50, w_all: 50, h_all: 50 });
s.objects.set(4, {
ObjectID: 4, ObjectType: 2, ObjectName: '单帧残留对象', Left: 500, Top: 300, Width: 50, Height: 50,
BelongLayerID: 2, IndexOfLayer: 4, ImageFileID: 3, FrameStyle: 0, FrameIndex: 8,
L9: 0, T9: 0, R9: 0, B9: 0, GroupID: 5, VoiceFileID: 0, TimerInterval: 0, events: [],
});
s.layers[0].objectIds.push(4);
s.groups[0].objectIds.push(4);
const doc = convertLayer(s, s.layers[0], imageBuckets(s));
const n = doc.nodes.find((node) => node.id === 4);
assert.equal(n.flags.frameIndexOutOfRange, false);
assert.equal(n.sprite.frame, 'atlas-login/00088.png');
assert.equal(doc.warnings.length, 0);
});
test('convertLayer 输出 groups 供父节点分组', () => {
const s = tinySource();
const doc = convertLayer(s, s.layers[0], imageBuckets(s));
assert.deepEqual(doc.groups, [{ groupId: 5, nodeIds: [1, 2, 3] }]);
});
test('convertAll 的 manifest 含 ObjectID 全局索引(C 的承重依赖)', () => {
const s = tinySource();
const { manifest, warnings } = convertAll(s);
assert.equal(manifest.designWidth, 1280);
assert.deepEqual(manifest.objectIndex['2'], { layerId: 2, nodeId: 2 });
assert.equal(manifest.layers.length, 1);
assert.equal(manifest.layers[0].nodeCount, 3);
assert.deepEqual(warnings, [], 'tinySource 本身没有越界帧,不应产生 warning');
});
test('convertAll 在真实数据上覆盖全部对象与界面(回归护栏)', () => {
const source = loadSource(REAL_SOURCE);
const { layers, manifest, warnings } = convertAll(source);
assert.equal(layers.length, 55, '界面数');
const total = layers.reduce((s, l) => s + l.nodes.length, 0);
assert.equal(total, 991, '节点总数必须等于对象总数——不得丢对象');
assert.equal(Object.keys(manifest.objectIndex).length, 991, 'ObjectID 索引必须完整');
assert.equal(warnings.length, 10, '已知的帧号越界对象必须恰为 10 个——数量漂移应被人工关注');
// 内容级校验(不只是条数):objectIndex 每条的 layerId 必须等于该对象真实所属的层
// (source 里的 BelongLayerID)。全量遍历能杀掉"全部条目被写成同一个 layerId"
// 这类变异——55 个不同层里任何一层的对象被错误映射,都会在此处失败。
for (const [idStr, entry] of Object.entries(manifest.objectIndex)) {
const obj = source.objects.get(Number(idStr));
assert.equal(
entry.layerId,
obj.BelongLayerID,
`ObjectID ${idStr} 的 objectIndex.layerId(${entry.layerId}) 应等于其 BelongLayerID(${obj.BelongLayerID})`,
);
}
});
@@ -0,0 +1,83 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import {
DESIGN_WIDTH, DESIGN_HEIGHT, CANVAS_WIDTH, CANVAS_HEIGHT,
toCocosPosition, inferWidget, stretchWidget, isFullScreen, isOffscreen,
} from '../ui-migration/coords.mjs';
test('内容区基准保持 1280x720(数据源事实,不随设计分辨率改变)', () => {
assert.equal(DESIGN_WIDTH, 1280);
assert.equal(DESIGN_HEIGHT, 720);
});
test('设计分辨率画布为 1600x720(20:9 主流移动端比例)', () => {
assert.equal(CANVAS_WIDTH, 1600);
assert.equal(CANVAS_HEIGHT, 720);
});
test('内容区基准与设计分辨率画布是两个独立常量', () => {
// 坐标/三区/满屏/越界用 DESIGN_WIDTH(1280);落地 Layer/Group 尺寸用 CANVAS_WIDTH(1600)。
assert.notEqual(DESIGN_WIDTH, CANVAS_WIDTH);
});
test('toCocosPosition 用 spec 的验算样例', () => {
// spec §1.2: Left=320 Top=200 150x70 → x=-245 y=125
assert.deepEqual(toCocosPosition(320, 200, 150, 70), { x: -245, y: 125 });
});
test('toCocosPosition 左上角对象落在画布左上', () => {
// 0,0 的 100x50 → 中心在 (50,25) → x=50-640=-590, y=360-25=335
assert.deepEqual(toCocosPosition(0, 0, 100, 50), { x: -590, y: 335 });
});
test('toCocosPosition 满屏对象落在原点', () => {
assert.deepEqual(toCocosPosition(0, 0, 1280, 720), { x: 0, y: 0 });
});
test('inferWidget 中心点在左区 → 锚左', () => {
// cx = 10+100/2 = 60 < 320
assert.deepEqual(inferWidget(10, 100), { horizontal: 'left', left: 10 });
});
test('inferWidget 中心点在右区 → 锚右', () => {
// cx = 1100+100/2 = 1150 > 960;right = 1280 - 1200 = 80
assert.deepEqual(inferWidget(1100, 100), { horizontal: 'right', right: 80 });
});
test('inferWidget 中心点在中区 → 水平居中', () => {
// cx = 320+150/2 = 395 ∈ [320,960];horizontalCenter = 395-640 = -245
assert.deepEqual(inferWidget(320, 150), { horizontal: 'center', horizontalCenter: -245 });
});
test('inferWidget 分界值 320 与 960 归入中区(非严格小于/大于才算边区)', () => {
// cx 恰为 320:不满足 < 320 → 中区
assert.equal(inferWidget(220, 200).horizontal, 'center');
// cx 恰为 960:不满足 > 960 → 中区
assert.equal(inferWidget(860, 200).horizontal, 'center');
});
test('inferWidget 屏外负坐标自然落入左区', () => {
// cx = -286+100/2 = -236 < 320
assert.equal(inferWidget(-286, 100).horizontal, 'left');
});
test('stretchWidget 四边全 0', () => {
assert.deepEqual(stretchWidget(), {
horizontal: 'stretch', vertical: 'stretch', left: 0, right: 0, top: 0, bottom: 0,
});
});
test('isFullScreen 只认 >= 设计分辨率', () => {
assert.equal(isFullScreen(1280, 720), true);
assert.equal(isFullScreen(1300, 800), true);
assert.equal(isFullScreen(1279, 720), false);
assert.equal(isFullScreen(1280, 719), false);
});
test('isOffscreen 识别越界对象', () => {
assert.equal(isOffscreen(0, 0, 1280, 720), false, '恰好满屏不算越界');
assert.equal(isOffscreen(-286, 100, 50, 50), true, '左越界');
assert.equal(isOffscreen(100, -318, 50, 50), true, '上越界');
assert.equal(isOffscreen(1260, 100, 50, 50), true, '右越界');
assert.equal(isOffscreen(100, 700, 50, 50), true, '下越界');
});
@@ -0,0 +1,73 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { PNG } from 'pngjs';
import { splitSheet, reassemble, pixelsEqual, verifySheet } from '../ui-migration/frame-io.mjs';
/** 造一张 w×h 网格的测试图,每格填不同颜色,便于验证切分位置 */
function makeSheet(cols, rows, cellW, cellH) {
const png = new PNG({ width: cols * cellW, height: rows * cellH });
for (let r = 0; r < rows; r++) {
for (let c = 0; c < cols; c++) {
const idx = r * cols + c; // 行优先编号
for (let y = 0; y < cellH; y++) {
for (let x = 0; x < cellW; x++) {
const o = ((r * cellH + y) * png.width + (c * cellW + x)) << 2;
png.data[o] = (idx * 20) & 0xff; // R 编码帧号,便于断言
png.data[o + 1] = 0x40;
png.data[o + 2] = 0x80;
png.data[o + 3] = 0xff;
}
}
}
}
return PNG.sync.write(png);
}
const IMG = { id: 1, w_all: 60, h_all: 40, w: 3, h: 2, frame_all: 6, bmp: 'test.png', w1: 20, h1: 20 };
test('splitSheet 切出 frame_all 张,尺寸为单帧尺寸', () => {
const frames = splitSheet(makeSheet(3, 2, 20, 20), IMG);
assert.equal(frames.length, 6);
const first = PNG.sync.read(frames[0]);
assert.equal(first.width, 20);
assert.equal(first.height, 20);
});
test('splitSheet 按行优先取帧:下标 0..5 的 R 通道依次为 0,20,40,60,80,100', () => {
const frames = splitSheet(makeSheet(3, 2, 20, 20), IMG);
const reds = frames.map((b) => PNG.sync.read(b).data[0]);
assert.deepEqual(reds, [0, 20, 40, 60, 80, 100],
'若切成列优先,顺序会变为 0,60,20,80,40,100');
});
test('reassemble(splitSheet(x)) 的像素与 x 完全一致(完全验证的核心不变量)', () => {
const sheet = makeSheet(3, 2, 20, 20);
const back = reassemble(splitSheet(sheet, IMG), IMG);
assert.equal(pixelsEqual(sheet, back), true);
});
test('pixelsEqual 对像素不同的图返回 false', () => {
const a = makeSheet(3, 2, 20, 20);
const bPng = PNG.sync.read(a);
bPng.data[0] = (bPng.data[0] + 1) & 0xff; // 改一个字节
assert.equal(pixelsEqual(a, PNG.sync.write(bPng)), false);
});
test('pixelsEqual 忽略编码差异:同像素不同压缩等级仍为 true', () => {
const src = PNG.sync.read(makeSheet(3, 2, 20, 20));
const enc0 = PNG.sync.write(src, { deflateLevel: 0 });
const enc9 = PNG.sync.write(src, { deflateLevel: 9 });
assert.notEqual(Buffer.compare(enc0, enc9), 0, '前提:两种编码的文件字节确实不同');
assert.equal(pixelsEqual(enc0, enc9), true, '但像素数据必须判为相同');
});
test('verifySheet 对正常图返回 ok', () => {
assert.deepEqual(verifySheet(makeSheet(3, 2, 20, 20), IMG), { ok: true });
});
test('verifySheet 对尺寸与网格声明不符的图返回失败原因', () => {
const wrong = makeSheet(2, 2, 20, 20); // 实际 40x40,但 IMG 声明 60x40
const r = verifySheet(wrong, IMG);
assert.equal(r.ok, false);
assert.match(r.reason, /尺寸/);
});
@@ -0,0 +1,95 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { frameRect, padWidth, frameFileName, frameNames } from '../ui-migration/frames.mjs';
// 实测样例:00014.png 450x280,网格 3x4,单帧 150x70
const IMG_00014 = { id: 14, w_all: 450, h_all: 280, w: 3, h: 4, frame_all: 12, bmp: '00014.png', w1: 150, h1: 70 };
// 13 列 1 行
const IMG_00019 = { id: 19, w_all: 221, h_all: 28, w: 13, h: 1, frame_all: 13, bmp: '00019.png', w1: 17, h1: 28 };
const IMG_SINGLE = { id: 1, w_all: 1280, h_all: 720, w: 1, h: 1, frame_all: 1, bmp: '00001.png', w1: 1280, h1: 720 };
test('frameRect 帧 1 是左上角(1 基)', () => {
assert.deepEqual(frameRect(IMG_00014, 1), { x: 0, y: 0, width: 150, height: 70 });
});
test('frameRect 行优先:帧 4 换到第二行首列', () => {
// idx=3 → col = 3%3 = 0, row = 1
assert.deepEqual(frameRect(IMG_00014, 4), { x: 0, y: 70, width: 150, height: 70 });
});
test('frameRect 实证样例:「通知」FrameIndex=10 → 第 3 行第 0 列', () => {
// 已由读图验证:00014.png 第 3 行第 0 列即「通知」
assert.deepEqual(frameRect(IMG_00014, 10), { x: 0, y: 210, width: 150, height: 70 });
});
test('frameRect 实证样例:「设置」FrameIndex=11 → 第 3 行第 1 列', () => {
assert.deepEqual(frameRect(IMG_00014, 11), { x: 150, y: 210, width: 150, height: 70 });
});
test('frameRect 实证样例:「战绩」FrameIndex=4 → 第 1 行第 0 列', () => {
assert.deepEqual(frameRect(IMG_00014, 4), { x: 0, y: 70, width: 150, height: 70 });
});
test('frameRect 单行网格逐列推进', () => {
assert.deepEqual(frameRect(IMG_00019, 1), { x: 0, y: 0, width: 17, height: 28 });
assert.deepEqual(frameRect(IMG_00019, 13), { x: 204, y: 0, width: 17, height: 28 });
});
test('frameRect FrameIndex=0 按帧 1 处理', () => {
assert.deepEqual(frameRect(IMG_00014, 0), frameRect(IMG_00014, 1));
});
test('frameRect 越界抛 RangeError 且带图名', () => {
assert.throws(() => frameRect(IMG_00014, 13), RangeError);
assert.throws(() => frameRect(IMG_00014, 13), /00014\.png/);
});
test('frameRect undefined 作为 frameIndex 抛 RangeError', () => {
assert.throws(() => frameRect(IMG_00014, undefined), RangeError);
assert.throws(() => frameRect(IMG_00014, undefined), /00014\.png/);
});
test('frameRect NaN 作为 frameIndex 抛 RangeError', () => {
assert.throws(() => frameRect(IMG_00014, NaN), RangeError);
assert.throws(() => frameRect(IMG_00014, NaN), /00014\.png/);
});
test('padWidth 按帧总数决定补零位数', () => {
assert.equal(padWidth(9), 1);
assert.equal(padWidth(12), 2);
assert.equal(padWidth(100), 3);
});
test('frameFileName 1 基且补零,保证字典序等于帧序', () => {
assert.equal(frameFileName('00014.png', 1, 12), '00014_01.png');
assert.equal(frameFileName('00014.png', 10, 12), '00014_10.png');
const names = [frameFileName('00014.png', 2, 12), frameFileName('00014.png', 10, 12)];
assert.deepEqual([...names].sort(), ['00014_02.png', '00014_10.png'], '字典序必须等于帧序');
});
test('frameFileName 单帧图原样返回', () => {
assert.equal(frameFileName('00001.png', 1, 1), '00001.png');
});
test('frameFileName undefined 作为 frameIndex 抛 RangeError', () => {
assert.throws(() => frameFileName('00014.png', undefined, 12), RangeError);
assert.throws(() => frameFileName('00014.png', undefined, 12), /00014\.png/);
});
test('frameFileName NaN 作为 frameIndex 抛 RangeError', () => {
assert.throws(() => frameFileName('00014.png', NaN, 12), RangeError);
assert.throws(() => frameFileName('00014.png', NaN, 12), /00014\.png/);
});
test('frameFileName 越界抛 RangeError 且带图名', () => {
assert.throws(() => frameFileName('00014.png', 13, 12), RangeError);
assert.throws(() => frameFileName('00014.png', 13, 12), /00014\.png/);
});
test('frameNames 返回全部帧,下标 0 对应帧 1', () => {
const names = frameNames(IMG_00014);
assert.equal(names.length, 12);
assert.equal(names[0], '00014_01.png');
assert.equal(names[11], '00014_12.png');
assert.deepEqual(frameNames(IMG_SINGLE), ['00001.png']);
});
@@ -0,0 +1,122 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { join } from 'node:path';
import { mkdtempSync, writeFileSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { ROOT } from '../lib/paths.mjs';
import { loadSource, SourceError } from '../ui-migration/gameabc-source.mjs';
const REAL_SOURCE = join(ROOT, '..', 'projects', 'Game_Surface_3', 'output');
function fakeSource(overrides = {}) {
const dir = mkdtempSync(join(tmpdir(), 'gameabc-src-'));
const files = {
'gameabc_Object.json': {
ObjectList: [
{},
{ Property: { ObjectID: 1, ObjectType: 2, ObjectName: '背景', Left: 0, Top: 0, Width: 1280, Height: 720, BelongLayerID: 1, IndexOfLayer: 1, ImageFileID: 1, FrameStyle: 0, FrameIndex: 0, L9: 0, T9: 0, R9: 0, B9: 0, GroupID: 1, VoiceFileID: 0, TimerInterval: 0 }, Event: { mousedown: 1, mouseup: 1 } },
],
},
'gameabc_Layer.json': { LayerList: [{}, { Property: { LayerID: 1, LayerName: 'Logo_Layer' }, ObjectList: [1] }] },
'gameabc_GroupList.json': { GroupList: [{ GroupID: 1, ObjectList: [1] }] },
'gameabc_Image.json': { ImageFileList: [{}, { id: 1, w_all: 1280, h_all: 720, w: 1, h: 1, frame_all: 1, bmp: '00001.png', w1: 1280, h1: 720 }] },
...overrides,
};
for (const [name, content] of Object.entries(files)) {
writeFileSync(join(dir, name), JSON.stringify(content));
}
return dir;
}
test('loadSource 解析对象、层、组、图,并附上 events', () => {
const dir = fakeSource();
try {
const s = loadSource(dir);
assert.equal(s.objects.size, 1);
assert.equal(s.objects.get(1).ObjectName, '背景');
assert.deepEqual(s.objects.get(1).events, ['mousedown', 'mouseup']);
assert.deepEqual(s.layers, [{ layerId: 1, name: 'Logo_Layer', objectIds: [1] }]);
assert.deepEqual(s.groups, [{ groupId: 1, objectIds: [1] }]);
assert.equal(s.images.get(1).bmp, '00001.png');
} finally { rmSync(dir, { recursive: true, force: true }); }
});
test('loadSource 跳过源数据里的空占位槽 {}', () => {
const dir = fakeSource();
try {
const s = loadSource(dir);
assert.equal(s.objects.size, 1, '首个 {} 占位不应变成对象');
assert.equal(s.layers.length, 1);
} finally { rmSync(dir, { recursive: true, force: true }); }
});
test('loadSource 对网格与帧数不符的图显式抛错(不兜底)', () => {
const dir = fakeSource({
'gameabc_Image.json': { ImageFileList: [{}, { id: 1, w_all: 100, h_all: 100, w: 3, h: 4, frame_all: 11, bmp: 'bad.png', w1: 33, h1: 25 }] },
});
try {
assert.throws(() => loadSource(dir), SourceError);
assert.throws(() => loadSource(dir), /bad\.png/);
} finally { rmSync(dir, { recursive: true, force: true }); }
});
test('loadSource 对缺失文件显式抛错', () => {
const dir = mkdtempSync(join(tmpdir(), 'gameabc-empty-'));
try {
assert.throws(() => loadSource(dir), /缺少源数据文件/);
} finally { rmSync(dir, { recursive: true, force: true }); }
});
test('loadSource 缺少顶层数组键 ObjectList 时显式抛错', () => {
const dir = fakeSource({
'gameabc_Object.json': { Property: [] }, // 缺 ObjectList
});
try {
assert.throws(() => loadSource(dir), SourceError);
assert.throws(() => loadSource(dir), /ObjectList/);
assert.throws(() => loadSource(dir), /gameabc_Object\.json/);
} finally { rmSync(dir, { recursive: true, force: true }); }
});
test('loadSource 缺少顶层数组键 ImageFileList 时显式抛错', () => {
const dir = fakeSource({
'gameabc_Image.json': { id: 1 }, // 缺 ImageFileList
});
try {
assert.throws(() => loadSource(dir), SourceError);
assert.throws(() => loadSource(dir), /ImageFileList/);
assert.throws(() => loadSource(dir), /gameabc_Image\.json/);
} finally { rmSync(dir, { recursive: true, force: true }); }
});
test('loadSource 缺少顶层数组键 LayerList 时显式抛错', () => {
const dir = fakeSource({
'gameabc_Layer.json': { Property: {} }, // 缺 LayerList
});
try {
assert.throws(() => loadSource(dir), SourceError);
assert.throws(() => loadSource(dir), /LayerList/);
assert.throws(() => loadSource(dir), /gameabc_Layer\.json/);
} finally { rmSync(dir, { recursive: true, force: true }); }
});
test('loadSource 缺少顶层数组键 GroupList 时显式抛错', () => {
const dir = fakeSource({
'gameabc_GroupList.json': { data: [] }, // 缺 GroupList
});
try {
assert.throws(() => loadSource(dir), SourceError);
assert.throws(() => loadSource(dir), /GroupList/);
assert.throws(() => loadSource(dir), /gameabc_GroupList\.json/);
} finally { rmSync(dir, { recursive: true, force: true }); }
});
test('loadSource 在真实源数据上得到已知规模(回归护栏)', () => {
const s = loadSource(REAL_SOURCE);
assert.equal(s.objects.size, 991, '对象总数');
assert.equal(s.layers.length, 55, '界面数');
assert.equal(s.groups.length, 80, '分组数');
assert.equal(s.images.size, 439, '图片数');
const multi = [...s.images.values()].filter((i) => i.frame_all > 1);
assert.equal(multi.length, 139, '多帧图数');
});