fix(test): materialize 夹具改用真 PNG 字节而非字符串
scaffold 产出的图片改为真 PNG 二进制数据,用不同 RGB 色值生成 尺寸相同但字节不同的图片(DEFAULT_BTN/DEFAULT_BG/SKINNED_BTN)。 Test 5 现可通过:validateOverrides 不再报 size-unreadable。 Test 7 补充 orphan 错误代码断言,防止因其它原因通过。 所有测试用 readBinary() 字节比较替代字符串比较。 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -4,25 +4,59 @@ import { join } from 'node:path';
|
|||||||
import { mkdirSync, writeFileSync, readFileSync, existsSync, rmSync, mkdtempSync, symlinkSync } from 'node:fs';
|
import { mkdirSync, writeFileSync, readFileSync, existsSync, rmSync, mkdtempSync, symlinkSync } from 'node:fs';
|
||||||
import { tmpdir } from 'node:os';
|
import { tmpdir } from 'node:os';
|
||||||
import { platform } from 'node:process';
|
import { platform } from 'node:process';
|
||||||
|
import { deflateSync } from 'node:zlib';
|
||||||
import { materialize, composeSkin, SkinValidationError } from '../lib/materialize.mjs';
|
import { materialize, composeSkin, SkinValidationError } from '../lib/materialize.mjs';
|
||||||
|
|
||||||
const LINK_TYPE = platform === 'win32' ? 'junction' : 'dir';
|
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) {
|
function put(dir, rel, content) {
|
||||||
const p = join(dir, ...rel.split('/'));
|
const p = join(dir, ...rel.split('/'));
|
||||||
mkdirSync(join(p, '..'), { recursive: true });
|
mkdirSync(join(p, '..'), { recursive: true });
|
||||||
writeFileSync(p, content);
|
writeFileSync(p, content);
|
||||||
}
|
}
|
||||||
function read(dir, rel) { return readFileSync(join(dir, ...rel.split('/')), 'utf8'); }
|
function read(dir, rel) { return readFileSync(join(dir, ...rel.split('/')), 'utf8'); }
|
||||||
|
function readBinary(dir, rel) { return readFileSync(join(dir, ...rel.split('/'))); }
|
||||||
|
|
||||||
/** 造一个「框架真源 + 挂 junction 的子游戏工程」的临时环境 */
|
/** 造一个「框架真源 + 挂 junction 的子游戏工程」的临时环境 */
|
||||||
function scaffold() {
|
function scaffold() {
|
||||||
const root = mkdtempSync(join(tmpdir(), 'youle-mat-'));
|
const root = mkdtempSync(join(tmpdir(), 'youle-mat-'));
|
||||||
const frameworkSrc = join(root, 'nexus', 'assets', 'framework');
|
const frameworkSrc = join(root, 'nexus', 'assets', 'framework');
|
||||||
mkdirSync(frameworkSrc, { recursive: true });
|
mkdirSync(frameworkSrc, { recursive: true });
|
||||||
put(frameworkSrc, 'ui/atlas-hall/btn.png', 'DEFAULT-BTN');
|
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/btn.png.meta', '{"uuid":"fw-btn-uuid"}');
|
||||||
put(frameworkSrc, 'ui/atlas-hall/bg.png', 'DEFAULT-BG');
|
put(frameworkSrc, 'ui/atlas-hall/bg.png', DEFAULT_BG);
|
||||||
put(frameworkSrc, 'core/net.ts', 'export const x=1;');
|
put(frameworkSrc, 'core/net.ts', 'export const x=1;');
|
||||||
|
|
||||||
const gameDir = join(root, 'games', 'demo');
|
const gameDir = join(root, 'games', 'demo');
|
||||||
@@ -41,8 +75,8 @@ test('materialize 把 junction 变成真实目录拷贝,并跳过缓存目录'
|
|||||||
const s = scaffold();
|
const s = scaffold();
|
||||||
try {
|
try {
|
||||||
materialize(s.gameDir, s.frameworkSrc, s.dest);
|
materialize(s.gameDir, s.frameworkSrc, s.dest);
|
||||||
assert.equal(read(s.dest, 'assets/framework/ui/atlas-hall/btn.png'), 'DEFAULT-BTN');
|
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.equal(read(s.dest, 'assets/game/theme.ts'), 'export const theme={};', '游戏资源被拷贝');
|
||||||
assert.ok(!existsSync(join(s.dest, 'library')), 'library 应被跳过');
|
assert.ok(!existsSync(join(s.dest, 'library')), 'library 应被跳过');
|
||||||
} finally { cleanup(s.root); }
|
} finally { cleanup(s.root); }
|
||||||
});
|
});
|
||||||
@@ -61,8 +95,8 @@ test('materialize 后写临时工程不影响框架真源', () => {
|
|||||||
const s = scaffold();
|
const s = scaffold();
|
||||||
try {
|
try {
|
||||||
materialize(s.gameDir, s.frameworkSrc, s.dest);
|
materialize(s.gameDir, s.frameworkSrc, s.dest);
|
||||||
writeFileSync(join(s.dest, 'assets', 'framework', 'ui', 'atlas-hall', 'btn.png'), 'MUTATED');
|
writeFileSync(join(s.dest, 'assets', 'framework', 'ui', 'atlas-hall', 'btn.png'), Buffer.from('MUTATED'));
|
||||||
assert.equal(read(s.frameworkSrc, 'ui/atlas-hall/btn.png'), 'DEFAULT-BTN', '真源必须只读');
|
assert.ok(readBinary(s.frameworkSrc, 'ui/atlas-hall/btn.png').equals(DEFAULT_BTN), '真源必须只读');
|
||||||
} finally { cleanup(s.root); }
|
} finally { cleanup(s.root); }
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -77,21 +111,21 @@ test('materialize 拒绝覆盖已存在的目标目录', () => {
|
|||||||
test('composeSkin 用 override 覆盖框架资源内容,保留框架 .meta', () => {
|
test('composeSkin 用 override 覆盖框架资源内容,保留框架 .meta', () => {
|
||||||
const s = scaffold();
|
const s = scaffold();
|
||||||
try {
|
try {
|
||||||
put(s.gameDir, 'assets/game/override/atlas-hall/btn.png', 'SKINNED-BTN');
|
put(s.gameDir, 'assets/game/override/atlas-hall/btn.png', SKINNED_BTN);
|
||||||
materialize(s.gameDir, s.frameworkSrc, s.dest);
|
materialize(s.gameDir, s.frameworkSrc, s.dest);
|
||||||
const r = composeSkin(s.dest);
|
const r = composeSkin(s.dest);
|
||||||
assert.deepEqual(r.matched, ['atlas-hall/btn.png']);
|
assert.deepEqual(r.matched, ['atlas-hall/btn.png'], 'matched 返回覆盖的文件');
|
||||||
assert.equal(r.frameworkTotal, 2);
|
assert.equal(r.frameworkTotal, 2, '框架资源总数');
|
||||||
assert.equal(read(s.dest, 'assets/framework/ui/atlas-hall/btn.png'), 'SKINNED-BTN');
|
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"}');
|
assert.equal(read(s.dest, 'assets/framework/ui/atlas-hall/btn.png.meta'), '{"uuid":"fw-btn-uuid"}', 'meta 文件不变');
|
||||||
assert.equal(read(s.dest, 'assets/framework/ui/atlas-hall/bg.png'), 'DEFAULT-BG', '未覆盖的保持默认');
|
assert.ok(readBinary(s.dest, 'assets/framework/ui/atlas-hall/bg.png').equals(DEFAULT_BG), '未覆盖的保持默认');
|
||||||
} finally { cleanup(s.root); }
|
} finally { cleanup(s.root); }
|
||||||
});
|
});
|
||||||
|
|
||||||
test('composeSkin 例外通道:override 自带 meta 时,uuid 被改回框架的值', () => {
|
test('composeSkin 例外通道:override 自带 meta 时,uuid 被改回框架的值', () => {
|
||||||
const s = scaffold();
|
const s = scaffold();
|
||||||
try {
|
try {
|
||||||
put(s.gameDir, 'assets/game/override/atlas-hall/btn.png', 'SKINNED-BTN');
|
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"}');
|
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);
|
materialize(s.gameDir, s.frameworkSrc, s.dest);
|
||||||
composeSkin(s.dest);
|
composeSkin(s.dest);
|
||||||
@@ -104,9 +138,22 @@ test('composeSkin 例外通道:override 自带 meta 时,uuid 被改回框架
|
|||||||
test('composeSkin 校验不过时抛 SkinValidationError 且不写任何文件', () => {
|
test('composeSkin 校验不过时抛 SkinValidationError 且不写任何文件', () => {
|
||||||
const s = scaffold();
|
const s = scaffold();
|
||||||
try {
|
try {
|
||||||
put(s.gameDir, 'assets/game/override/atlas-hall/typo.png', 'X');
|
put(s.gameDir, 'assets/game/override/atlas-hall/typo.png', DEFAULT_BG);
|
||||||
materialize(s.gameDir, s.frameworkSrc, s.dest);
|
materialize(s.gameDir, s.frameworkSrc, s.dest);
|
||||||
assert.throws(() => composeSkin(s.dest), SkinValidationError);
|
let validationErr;
|
||||||
assert.equal(read(s.dest, 'assets/framework/ui/atlas-hall/btn.png'), 'DEFAULT-BTN', '失败时不得部分写入');
|
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 错误必须被检测');
|
||||||
|
|
||||||
|
// 验证失败时不得部分写入
|
||||||
|
assert.ok(readBinary(s.dest, 'assets/framework/ui/atlas-hall/btn.png').equals(DEFAULT_BTN), '失败时不得部分写入');
|
||||||
} finally { cleanup(s.root); }
|
} finally { cleanup(s.root); }
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user