212 lines
11 KiB
JavaScript
212 lines
11 KiB
JavaScript
'use strict';
|
||
|
||
// 不需要 Eagle 的回归测试。用临时素材库模拟「拖入一批文件」,覆盖:
|
||
// 1. 历史素材被忽略、静默之后才触发、只报增量;
|
||
// 2. **跨目录配对**——Eagle 会把每个文件拆成独立的 <id>.info 目录,
|
||
// 一套资产的骨架 / atlas / 贴图分散在不同目录里,必须能关联起来;
|
||
// 3. 本地模块在「页面同时有 window 和 module」时仍然挂到 window 上。
|
||
const assert = require('node:assert/strict');
|
||
const fs = require('node:fs');
|
||
const os = require('node:os');
|
||
const path = require('node:path');
|
||
const vm = require('node:vm');
|
||
const { createLibraryWatcher, QUIET_MS } = require('../js/library-watcher.js');
|
||
const { scanSpineItems } = require('../js/spine-assets.js');
|
||
const utils = require('../js/spine-utils.js');
|
||
const { withTimeout } = require('../js/renderer.js');
|
||
|
||
// —— 超时保护:卡住的 Promise 必须被拒绝,否则队列会被永久堵死 ——
|
||
(async () => {
|
||
assert.equal(await withTimeout(Promise.resolve('ok'), 1000, '测试'), 'ok', '正常 Promise 应原样返回');
|
||
await assert.rejects(
|
||
() => withTimeout(new Promise(() => {}), 30, '测试'),
|
||
/超时/,
|
||
'永不 resolve 的 Promise 必须被超时拒绝',
|
||
);
|
||
})().catch((error) => {
|
||
console.error(error);
|
||
process.exitCode = 1;
|
||
});
|
||
|
||
// —— 回归:Eagle 插件页面里也存在 module 对象,本地模块必须优先挂 window ——
|
||
const pageSandbox = {
|
||
require, console, setTimeout, clearTimeout, setInterval, clearInterval,
|
||
window: {}, module: { exports: {} },
|
||
document: { getElementById: () => null, createElement: () => ({}) },
|
||
};
|
||
pageSandbox.self = pageSandbox;
|
||
vm.createContext(pageSandbox);
|
||
for (const file of ['library-watcher.js', 'spine-assets.js', 'spine-utils.js']) {
|
||
vm.runInContext(fs.readFileSync(path.join(__dirname, '..', 'js', file), 'utf8'), pageSandbox, { filename: file });
|
||
}
|
||
assert.ok(pageSandbox.window.SpineThumbLibraryWatcher, '存在 module 对象时也必须挂到 window');
|
||
assert.ok(pageSandbox.window.SpineThumbSpineAssets, '存在 module 对象时也必须挂到 window');
|
||
assert.ok(pageSandbox.window.SpineUtils, '存在 module 对象时也必须挂到 window');
|
||
|
||
// —— 复合后缀:Eagle 只按最后一个点拆 name/ext,atlas.txt 会被当成纯文本素材 ——
|
||
assert.deepEqual(utils.splitCompoundName('EmoticonSpine.atlas.txt'), { name: 'EmoticonSpine', ext: 'atlas.txt' });
|
||
assert.deepEqual(utils.splitCompoundName('EmoticonSpine.skel.bytes'), { name: 'EmoticonSpine', ext: 'skel.bytes' });
|
||
assert.equal(
|
||
utils.splitCompoundName('hero.skel'), null,
|
||
'普通后缀不动(否则会把 Eagle 里显式支持的类型也拆坏)',
|
||
);
|
||
assert.equal(utils.splitCompoundName('notes.txt'), null, '普通 txt 不是 Spine 素材,不碰');
|
||
assert.equal(utils.splitCompoundName('.atlas.txt'), null, '没有主干名就不改');
|
||
|
||
// —— 搭一个仿真素材库:<root>/images/<id>.info/<file> ——
|
||
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'spine-thumb-server-'));
|
||
const imagesDir = path.join(root, 'images');
|
||
fs.mkdirSync(imagesDir, { recursive: true });
|
||
fs.writeFileSync(path.join(root, 'metadata.json'), JSON.stringify({ folders: [] }));
|
||
|
||
const MINIMAL_ATLAS = ['hero.png', 'size: 64,64', 'format: RGBA8888', 'filter: Linear,Linear', 'repeat: none', ''].join('\n');
|
||
// 真实 Spine JSON 的最小形态:必须有 skeleton.spine 版本字段才会被判为骨骼数据。
|
||
const MINIMAL_SPINE_JSON = JSON.stringify({ skeleton: { spine: '3.8.55' }, bones: [{ name: 'root' }] });
|
||
const LEGACY_ATLAS = ['legacy.png', 'size: 64,64', 'format: RGBA8888', 'filter: Linear,Linear', 'repeat: none', ''].join('\n');
|
||
const ATLAS_FOR_DUP = ['dup.png', 'size: 64,64', 'format: RGBA8888', 'filter: Linear,Linear', 'repeat: none', ''].join('\n');
|
||
const ATLAS_FOLD_A = ['dup2.png', 'size: 64,64', 'format: RGBA8888', 'filter: Linear,Linear', 'repeat: none', ''].join('\n');
|
||
|
||
function makeItem(id, files, folders, deleted) {
|
||
const dir = path.join(imagesDir, id + '.info');
|
||
fs.mkdirSync(dir, { recursive: true });
|
||
// Eagle 的每个 item 目录都必须有 metadata.json,spine-utils 靠它判断这是不是一个 item。
|
||
fs.writeFileSync(path.join(dir, 'metadata.json'), JSON.stringify({
|
||
id, name: id, folders: folders || [], isDeleted: !!deleted,
|
||
}));
|
||
for (const [name, content] of Object.entries(files)) {
|
||
fs.writeFileSync(path.join(dir, name), content === true ? 'x' : content);
|
||
}
|
||
return dir;
|
||
}
|
||
|
||
// 兜底超时,避免配对逻辑异常时测试挂住不退出。
|
||
setTimeout(() => {
|
||
console.error('测试超时(60 秒)');
|
||
process.exit(1);
|
||
}, 60000).unref();
|
||
|
||
// 历史素材:监听建立之前就存在,不能处理。
|
||
makeItem('old-skel', { 'spineboy.skel': true });
|
||
makeItem('old-atlas', { 'spineboy.atlas': MINIMAL_ATLAS });
|
||
makeItem('old-tex', { 'spineboy.png': true });
|
||
// 历史里还留着一套 legacy 资产的 atlas + 贴图,用来验证新增素材不能借用它们。
|
||
makeItem('old-legacy-atlas', { 'legacy.atlas': LEGACY_ATLAS });
|
||
makeItem('old-legacy-tex', { 'legacy.png': true });
|
||
|
||
const batches = [];
|
||
const watcher = createLibraryWatcher({ onBatch: (batch) => batches.push(batch), onLog: () => {} });
|
||
assert.equal(watcher.start(root), true, '监听应当建立成功');
|
||
|
||
const wait = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
||
|
||
(async () => {
|
||
// 模拟一次拖入:一套分散在三处的完整资产 + 一张普通图片 + 只有骨架的残缺资产。
|
||
makeItem('new-skel', { 'hero.skel': true });
|
||
makeItem('new-atlas', { 'hero.atlas': MINIMAL_ATLAS });
|
||
makeItem('new-tex', { 'hero.png': true });
|
||
makeItem('new-photo', { 'photo.png': true });
|
||
makeItem('new-broken', { 'lonely.skel': true });
|
||
makeItem('new-json', { 'notes.json': '{"hello":"world"}' });
|
||
makeItem('new-skeljson', { 'beast.json': MINIMAL_SPINE_JSON });
|
||
// 只有骨架,配套的 legacy.atlas / legacy.png 都在历史素材里 → 不该跨批次配上
|
||
makeItem('new-orphan', { 'legacy.skel': true });
|
||
await wait(QUIET_MS + 1200);
|
||
|
||
assert.equal(batches.length, 1, '静默之后应当只触发一批');
|
||
assert.equal(batches[0].reason, 'quiet');
|
||
assert.deepEqual(
|
||
batches[0].added.slice().sort(),
|
||
['new-atlas.info', 'new-broken.info', 'new-json.info', 'new-orphan.info', 'new-photo.info', 'new-skel.info', 'new-skeljson.info', 'new-tex.info'],
|
||
);
|
||
|
||
const spine = await scanSpineItems(imagesDir, batches[0].added, utils);
|
||
const names = spine.map((item) => item.dirName).sort();
|
||
assert.deepEqual(
|
||
names,
|
||
['new-atlas.info', 'new-broken.info', 'new-orphan.info', 'new-skel.info', 'new-skeljson.info'],
|
||
'普通图片和普通 JSON 不算 Spine 资产,真实的 Spine JSON 要算',
|
||
);
|
||
|
||
// 关键:骨架在 new-skel.info 里,atlas 在 new-atlas.info 里,贴图在 new-tex.info 里,
|
||
// 三者必须被判成同一套完整资产。
|
||
const hero = spine.find((item) => item.dirName === 'new-skel.info');
|
||
assert.equal(hero.complete, true, '跨目录的三件套必须配成完整资产');
|
||
// 日志要显示 Eagle 里的文件名和文件夹路径,扫描结果必须带上这两项。
|
||
assert.equal(hero.entryName, 'hero.skel', '要带上 item 目录里的入口文件名');
|
||
assert.deepEqual(hero.folders, [], '未归入文件夹的素材 folders 为空数组');
|
||
assert.equal(path.basename(hero.atlasPath || ''), 'hero.atlas', '要找到另一个目录里的 atlas');
|
||
assert.equal(path.basename(hero.texturePaths['hero.png'] || ''), 'hero.png', '要找到 atlas 引用的贴图');
|
||
assert.equal(hero.pages, 1);
|
||
assert.equal(hero.missingTextures, 0);
|
||
|
||
const broken = spine.find((item) => item.dirName === 'new-broken.info');
|
||
assert.equal(broken.complete, false, '只有骨架不算完整');
|
||
assert.equal(broken.missingTextures, 0, '没有 atlas 时缺图计数为 0');
|
||
|
||
// 配对范围只限本次新增:历史素材里有 legacy.atlas 和 legacy.png,
|
||
// 但新导入的 legacy.skel 不能去借用它们。
|
||
const orphan = spine.find((item) => item.dirName === 'new-orphan.info');
|
||
assert.equal(orphan.complete, false, '新增素材不能借用历史素材的配套文件');
|
||
assert.equal(orphan.atlasPath, null, '不应该跨批次找到历史 atlas');
|
||
|
||
// 第二批:只报新增的那个。
|
||
makeItem('new-skel2', { 'boss.skel': true });
|
||
await wait(QUIET_MS + 1200);
|
||
assert.equal(batches.length, 2, '第二批应当再触发一次');
|
||
assert.deepEqual(batches[1].added, ['new-skel2.info']);
|
||
|
||
// 分批导入(模拟 Eagle 的「重复添加提示」把一次导入拆成两段):
|
||
// 先只写入骨架,配套的 atlas / 贴图过一会儿才写入。
|
||
makeItem('dup-skel', { 'dup.skel': true });
|
||
await wait(QUIET_MS + 1200);
|
||
const firstPass = await scanSpineItems(imagesDir, ['dup-skel.info'], utils);
|
||
assert.equal(firstPass[0].complete, false, '只有骨架时不算完整');
|
||
|
||
makeItem('dup-atlas', { 'dup.atlas': ATLAS_FOR_DUP });
|
||
makeItem('dup-tex', { 'dup.png': true });
|
||
await wait(QUIET_MS + 1200);
|
||
// plugin.js 的「待观察」会把上一批的目录和新一批一起送进来,这里模拟同样的调用。
|
||
const secondPass = await scanSpineItems(imagesDir, ['dup-skel.info', 'dup-atlas.info', 'dup-tex.info'], utils);
|
||
const dup = secondPass.find((item) => item.dirName === 'dup-skel.info');
|
||
assert.equal(dup.complete, true, '配套补齐后应当能配成完整资产');
|
||
assert.equal(dup.missingTextures, 0);
|
||
|
||
// 同名素材分属不同 Eagle 文件夹时不能互相配对:文件夹 A 里有完整三件套,
|
||
// 文件夹 B 里只有一个同名骨架,B 不应该借用 A 的 atlas。
|
||
makeItem('fold-a-skel', { 'dup2.skel': true }, ['folderA']);
|
||
makeItem('fold-a-atlas', { 'dup2.atlas': ATLAS_FOLD_A }, ['folderA']);
|
||
makeItem('fold-a-tex', { 'dup2.png': true }, ['folderA']);
|
||
makeItem('fold-b-skel', { 'dup2.skel': true }, ['folderB']);
|
||
const crossFolder = await scanSpineItems(
|
||
imagesDir,
|
||
['fold-a-skel.info', 'fold-a-atlas.info', 'fold-a-tex.info', 'fold-b-skel.info'],
|
||
utils,
|
||
);
|
||
const inA = crossFolder.find((item) => item.dirName === 'fold-a-skel.info');
|
||
assert.equal(inA.complete, true, '同一 Eagle 文件夹里的三件套要能配上');
|
||
assert.deepEqual(inA.folders, ['folderA'], '要带上 Eagle 文件夹 id,日志才能还原路径');
|
||
assert.equal(path.basename(inA.atlasPath), 'dup2.atlas');
|
||
const inB = crossFolder.find((item) => item.dirName === 'fold-b-skel.info');
|
||
assert.equal(inB.complete, false, '不能借用别的 Eagle 文件夹里的同名 atlas');
|
||
assert.equal(inB.atlasPath, null);
|
||
|
||
// 回收站素材:Eagle 删除只是把 metadata 标成 isDeleted,目录和文件都还在磁盘上。
|
||
// 三件套齐全也不能进结果,否则会给回收站里的素材渲染缩略图。
|
||
makeItem('trash-skel', { 'gone.skel': true }, [], true);
|
||
makeItem('trash-atlas', { 'gone.atlas': ATLAS_FOR_DUP }, [], true);
|
||
makeItem('trash-tex', { 'gone.png': true }, [], true);
|
||
const trashed = await scanSpineItems(
|
||
imagesDir,
|
||
['trash-skel.info', 'trash-atlas.info', 'trash-tex.info'],
|
||
utils,
|
||
);
|
||
assert.deepEqual(trashed, [], '回收站里的素材不能被当成待生成资产');
|
||
|
||
watcher.stop();
|
||
fs.rmSync(root, { recursive: true, force: true });
|
||
console.log('PASS: 静默检测、历史忽略、增量识别、跨目录配对、回收站过滤、浏览器加载方式');
|
||
})().catch((error) => {
|
||
console.error(error);
|
||
process.exitCode = 1;
|
||
});
|