'use strict'; // 不需要 Eagle 的回归测试。用临时素材库模拟「拖入一批文件」,覆盖: // 1. 历史素材被忽略、静默之后才触发、只报增量; // 2. **跨目录配对**——Eagle 会把每个文件拆成独立的 .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, '没有主干名就不改'); // —— 搭一个仿真素材库:/images/.info/ —— 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; });