181 lines
12 KiB
JavaScript
181 lines
12 KiB
JavaScript
/**
|
||
* 冒烟:动态表情模块纯函数核心(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('<?xml') && svg.includes('<svg') && svg.includes('viewBox=') && svg.includes('</svg>'));
|
||
check('帧 SVG 含动画 transform(多次 translate 合成)', (svg.match(/translate\(/g) || []).length >= 4, (svg.match(/translate\(/g) || []).length + ' 次 translate');
|
||
check('帧 SVG 含背景 rect(白底)', svg.includes('<rect x='));
|
||
const svgT = HGB_EmojiGen.buildFrameSvg(p0.elements, 'transparent', 240, 0, p0.anim, {});
|
||
check('透明背景无画布背景 rect', !/viewBox="[^"]*"><rect/.test(svgT));
|
||
const svgA = HGB_EmojiGen.buildFrameSvg(p0.elements, '#FFFFFF', 240, 0.1, p0.anim, {});
|
||
check('不同帧 SVG 有差异(确实在动)', svgA !== svg || svgA.length !== svg.length);
|
||
// contentBox
|
||
const box = HGB_EmojiGen.contentBox(p0.elements);
|
||
check('contentBox 存在且有效', !!box && box.w > 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); });
|