/** * 冒烟:动态表情模块纯函数核心(motion / gifEncoder / emojiGen / sticker-skills) * 验证: * 1) motion:帧函数确定性、normalize 补参、帧时间序列、pickAnim 轮换、composeTransform 合法 * 2) gifEncoder:lzwEncode 子块结构 + 确定性(纯函数,用假像素序列) * 3) emojiGen:generateEmojis 产物带 anim、buildFrameSvg SVG 合法且含动画 transform、纯图形无文字 * 4) sticker-skills:12 技能 test 全部通过、分类白名单 motion/emotion、workspaces 统一 emoji、 * emotion-fit 评审覆盖 6 种标准情绪(与 emotion-params 生成决策同源) * 5) emojiAi:降级路径 + validate 白名单修正;情绪驱动降级(无 AI 时首张动画贴合情绪) */ 'use strict'; const fs = require('fs'); const path = require('path'); const vm = require('vm'); const ROOT = path.resolve(__dirname, '..'); const files = [ 'js/data/icons.js', 'js/data/styles.js', 'js/data/industries.js', 'js/data/builtin-icons.js', 'js/data/decorations.js', 'js/core/generator.js', 'js/core/iconlib.js', 'js/core/avatarGen.js', 'js/core/motion.js', 'js/core/gifEncoder.js', 'js/core/emojiGen.js', 'js/core/emojiAi.js', 'js/sticker-skills/core.js', 'js/sticker-skills/skills.js' ]; for (const f of files) { const src = fs.readFileSync(path.join(ROOT, f), 'utf8'); vm.runInThisContext(src, { filename: f }); } // Node 无头环境无 document/window:stub 掉 iconlib 的浏览器专属加载方法(生成路径走本地+行业图标池) if (typeof HGB_IconLib !== 'undefined') { HGB_IconLib.preload = async () => []; HGB_IconLib.searchLoaded = () => []; } let pass = 0, fail = 0; function check(name, ok, detail) { if (ok) { pass++; console.log(' ✅ ' + name + (detail ? ' — ' + detail : '')); } else { fail++; console.log(' ❌ ' + name + (detail ? ' — ' + detail : '')); } } /* ============ 1) motion ============ */ console.log('== 1) motion 动画引擎 =='); const anim = HGB_Motion.normalize({ type: 'bounce' }); check('normalize 补全默认参数', anim.type === 'bounce' && anim.speed === 1 && anim.amplitude === 1 && anim.fps === 12 && Math.abs(anim.duration - 1.2) < 1e-9, JSON.stringify(anim)); check('非法动画类型回退 bounce', HGB_Motion.normalize({ type: 'nope' }).type === 'bounce'); check('动画类型数 ≥6', HGB_Motion.animTypes().length >= 6, HGB_Motion.animTypes().map(a => a.id).join('/')); const n = HGB_Motion.frameCount({ type: 'pulse', duration: 1.2, fps: 12 }); check('帧数 = duration × fps', n === Math.round(1.2 * 12), 'frames=' + n); const f0 = HGB_Motion.apply({ x: 0, y: 0, w: 80, h: 80 }, 0, anim); const f1 = HGB_Motion.apply({ x: 0, y: 0, w: 80, h: 80 }, 0, anim); check('帧函数确定性(同输入同输出)', JSON.stringify(f0) === JSON.stringify(f1)); const fHalf = HGB_Motion.apply({ x: 0, y: 0, w: 80, h: 80 }, 0.25, anim); check('bounce t=0.25 在上升段(ty<0)', fHalf.ty < 0, 'ty=' + fHalf.ty); const ft = HGB_Motion.frameT(0, anim) + HGB_Motion.frameT(n - 1, anim); check('帧时间在 [0,1)', ft >= 0 && ft < 2); // pickAnim 轮换多样性 const picked = new Set(); for (let i = 0; i < 12; i++) picked.add(HGB_Motion.pickAnim(i).type); check('pickAnim 12 批动画类型多样(≥3 种)', picked.size >= 3, picked.size + ' 种'); // composeTransform const el = { x: 10, y: 20, w: 60, h: 60 }; const frame = { tx: 5, ty: -10, rot: 15, s: 1.1, opacity: 1 }; const tr = HGB_Motion.composeTransform(el, frame, 'translate(10 20) scale(2.5 2.5)'); check('composeTransform 合成合法(含基础 transform)', tr.includes('translate(10 20) scale(2.5 2.5)') && tr.includes('rotate(15)') && tr.includes('scale(1.1)') && tr.includes('translate(5 -10)'), tr.slice(0, 80) + '…'); check('composeTransform 无 0.00 残留', !/0\.00/.test(tr) && !/-0\.00/.test(tr)); /* ============ 2) gifEncoder ============ */ console.log('== 2) gifEncoder LZW =='); // 假像素序列(重复度高,触发字典增长):256 色体系,索引 1..10 循环 const fakeIndices = new Uint8Array(1000); for (let i = 0; i < 1000; i++) fakeIndices[i] = (i % 10) + 1; const b1 = HGB_GifEncoder.lzwEncode(fakeIndices, 8); const b2 = HGB_GifEncoder.lzwEncode(fakeIndices, 8); check('lzwEncode 输出子块结构(长度前缀 ≤255,0x00 结尾)', b1.length > 0 && b1[b1.length - 1] === 0 && b1.slice(0, -1).every((v, i) => i % 256 !== 0 ? v >= 0 && v <= 255 : v <= 255), '块数=' + Math.ceil((b1.length - 1) / 256)); check('lzwEncode 确定性', b1.length === b2.length && b1.every((v, i) => v === b2[i])); // 大范围随机索引(触发 clear code) const rand = new Uint8Array(2000); for (let i = 0; i < 2000; i++) rand[i] = Math.floor(Math.random() * 256); const b3 = HGB_GifEncoder.lzwEncode(rand, 8); check('lzwEncode 随机序列不崩溃', b3.length > 10 && b3[b3.length - 1] === 0); check('quantizeFrame 空像素安全', (function () { const r = HGB_GifEncoder.quantizeFrame({ width: 2, height: 2, data: new Uint8ClampedArray([0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]) }, true); return r.indices.length === 4 && r.indices.every(v => v === 0) && r.lct.length === 256 * 3; })()); /* ============ 3) emojiGen ============ */ console.log('== 3) emojiGen 生成与帧 SVG =='); const ind = HGB_Industries.find('food'); (async function main() { const plans = await HGB_EmojiGen.generateEmojiSet({ industry: ind, brand: '美食工坊' }); const animTypes = HGB_Motion.animTypes(); check('表情包 = 全部动画类型数', plans.length === animTypes.length, plans.length + ' 张'); check('每套 anim 合法且覆盖全部类型', plans.every(p => p.anim && HGB_Motion.normalize(p.anim).type) && new Set(plans.map(p => p.anim.type)).size === animTypes.length, [...new Set(plans.map(p => p.anim.type))].join('/')); check('同图:布局/配色完全一致', plans.every(p => p.layoutId === plans[0].layoutId && p.paletteName === plans[0].paletteName), plans[0].layoutId + '/' + plans[0].paletteName); check('全部布局在表情白名单', plans.every(p => HGB_EmojiGen.EMOJI_LAYOUTS.includes(p.layoutId)), [...new Set(plans.map(p => p.layoutId))].join('/')); const hasText = plans.some(p => (p.markElements || []).some(el => el.type === 'text')); check('表情纯图形无文字元素', !hasText); // expandAnimations 独立展开(自定义底图 → 同图 × 全动画) const base = { id: 'x', name: '测试', elements: [], layoutId: 'orb', paletteName: '学术蓝', bg: '#fff', concept: 'c', aiIcon: 'i' }; const ex = HGB_EmojiGen.expandAnimations(base); check('expandAnimations 同图展开全动画', ex.length === animTypes.length && ex.every(v => v.layoutId === 'orb' && v.id.startsWith('x-'))); // 帧 SVG const p0 = plans[0]; const svg = HGB_EmojiGen.buildFrameSvg(p0.elements, '#FFFFFF', 240, 0.25, p0.anim, {}); check('帧 SVG 是合法 XML 且含 viewBox', svg.includes('')); check('帧 SVG 含动画 transform(多次 translate 合成)', (svg.match(/translate\(/g) || []).length >= 4, (svg.match(/translate\(/g) || []).length + ' 次 translate'); check('帧 SVG 含背景 rect(白底)', svg.includes(' 0 && box.h > 0, box.w.toFixed(0) + '×' + box.h.toFixed(0)); /* ============ 4) sticker-skills ============ */ console.log('== 4) sticker-skills 技能库 =='); const SKILL_ALL = HGB_StickerSkills.all(); check('技能总数 = 12', SKILL_ALL.length === 12, '实际 ' + SKILL_ALL.length); check('分类白名单 = motion/emotion', HGB_StickerSkills.categories().every(c => ['motion', 'emotion'].includes(c.id)), HGB_StickerSkills.categories().map(c => c.id).join('/')); check('workspaces 统一 emoji', SKILL_ALL.every(s => HGB_StickerSkills.workspacesOf(s.id).includes('emoji'))); let sFail = 0; for (const s of SKILL_ALL) { const r = HGB_StickerSkills.test(s.id); if (!r || r.error || r.ok === false) { sFail++; console.log(' ❌ test 失败: ' + s.id + ' — ' + JSON.stringify(r)); } else check(s.id + ' test 通过', true); } check('12 技能 test 全通过', sFail === 0); // 评审闭环:emotion-fit 覆盖 6 种标准情绪(与 emotion-params 生成决策同源) const EFIT = [ ['开心', 'bounce', true], ['生气', 'shake', true], ['惊讶', 'heartbeat', true], ['难过', 'float', true], ['兴奋', 'hopspin', true], ['平静', 'breath', true], ['平静', 'bounce', false] ]; let eFitOk = true; for (const [kw, type, expect] of EFIT) { const r = HGB_StickerSkills.run('emotion-fit', { keyword: kw, type }); if (r.ok !== expect) { eFitOk = false; console.log(' ❌ emotion-fit 不匹配: ' + kw + '/' + type + ' → ' + JSON.stringify(r)); } } check('emotion-fit 六情绪映射评审生效', eFitOk); /* ============ 5) emojiAi 编排(无 HGB_AI → 降级路径) ============ */ console.log('== 5) emojiAi 编排与降级 =='); check('suggest 无 HGB_AI 返回 null(降级)', (await HGB_EmojiAI.suggest({ industry: ind })) === null); const msgs = HGB_EmojiAI.buildPrompt({ industry: ind, brand: '美食工坊' }); check('buildPrompt 系统+用户两条', msgs.length === 2 && msgs[0].role === 'system' && msgs[1].role === 'user'); const parsed = HGB_EmojiAI.extractJson('前文 {"a":1,"b":{"c":2}} 后文'); check('extractJson 提取嵌套 JSON', !!parsed && parsed.b.c === 2); const fixed = HGB_EmojiAI.validate({ overallConcept: '测试', base: { layoutId: 'not-a-layout', styleId: 'nope' }, anim: { type: 'bad', speed: 99, amplitude: -1, layered: true } }, { industry: ind }); check('validate 修正非法布局/风格/动画参数', HGB_EmojiGen.EMOJI_LAYOUTS.includes(fixed.base.layoutId) && fixed.anim.type !== 'bad' && fixed.anim.speed === 1 && fixed.anim.amplitude === 1, JSON.stringify(fixed)); check('validate layered 保留', fixed.anim.layered === true); const exL = HGB_EmojiGen.expandAnimations(base); check('分层动画交错开启', exL.some(v => v.anim.layered) && exL.some(v => !v.anim.layered)); // 情绪驱动降级(生成前置):AI 缺失时首张动画贴合用户情绪,且整组仍覆盖全部类型 const moodAngry = await HGB_EmojiGen.generateEmojiSet({ industry: ind, brand: '美食工坊', mood: 'angry' }); check('情绪降级:angry 首张 = shake', moodAngry[0].anim.type === 'shake', '首张 ' + moodAngry[0].anim.type); check('情绪降级:整组仍覆盖全部动画类型', new Set(moodAngry.map(p => p.anim.type)).size === animTypes.length); const moodCalm = await HGB_EmojiGen.generateEmojiSet({ industry: ind, brand: '美食工坊', mood: 'calm' }); check('情绪降级:calm 首张 = breath', moodCalm[0].anim.type === 'breath', '首张 ' + moodCalm[0].anim.type); const moodBad = await HGB_EmojiGen.generateEmojiSet({ industry: ind, brand: '美食工坊', mood: 'nope' }); check('情绪降级:非法情绪回退确定性轮换', moodBad[0].anim.type === HGB_Motion.normalize(HGB_Motion.pickAnim(0)).type, '首张 ' + moodBad[0].anim.type); // 情绪-动画映射注入 prompt(先审后生成) const msgsMood = HGB_EmojiAI.buildPrompt({ industry: ind, brand: '美食工坊', mood: 'angry' }); check('buildPrompt 注入情绪-动画映射', msgsMood[0].content.includes('生气→shake'), '含映射行'); console.log('\n结果: ' + pass + ' 通过, ' + fail + ' 失败'); process.exit(fail ? 1 : 0); })().catch(e => { console.error('❌ 异常:', e); process.exit(1); });