Files
SpineViewer/SpineThumbnailGenerateServer/tests/watcher.test.js
T

212 lines
11 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'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;
});