Files

181 lines
12 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.
/**
* 冒烟:动态表情模块纯函数核心(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); });