修复: 生成质量机制根治——max_tokens截断检测+自动重试/icon白名单机械归一/空image剔除/金句引号伪元素治理/文本溢出扩高+重叠校正/图表y轴自适应防窄幅失真/stat缩字兜底/饼图图例流式排布/卡片icon字体锁定

This commit is contained in:
lxy
2026-08-30 17:27:58 +08:00
parent f7d994842a
commit 4e68773642
11 changed files with 1204 additions and 102 deletions
+236
View File
@@ -0,0 +1,236 @@
/* =====================================================================
* norm-slides-enhance.test.ts — 生成层质量修复测试
* 覆盖:空文本元素剔除 / 文本重叠机械校正 / chart 数据形状校正
* ===================================================================== */
import { describe, it, expect } from 'vitest'
import { normSlides } from '../src/core/ai'
import type { SlideElement } from '../src/core/types'
function el(id: string, over: Partial<SlideElement> = {}): SlideElement {
return { id, type: 'text', x: 10, y: 10, w: 40, h: 10, content: '内容', style: {}, ...over }
}
function norm(elements: SlideElement[]): SlideElement[] {
return normSlides([{ background: 'bg', elements }])[0].elements
}
describe('空文本元素剔除', () => {
it.each(['title', 'text', 'quote', 'list', 'card', 'stat'] as const)('%s content 空白 → 剔除', (t) => {
const els = norm([el('a', { type: t, content: ' \n ' }), el('b', { y: 50 })])
expect(els.map(e => e.id)).toEqual(['b'])
})
it.each(['chart', 'table'] as const)('%s 空 content 不剔除', (t) => {
const els = norm([el('a', { type: t, content: '' })])
expect(els).toHaveLength(1)
})
it('小空形状(面积 ≤8% 画布)= 装饰,保留;大空框(>8%)= 残缺文本框,丢弃', () => {
// 剔除层不处理 shape,去留由 sanitizeShapes「大空框」装饰纪律决定(阈值 w%×h% > 800
const small = norm([el('a', { type: 'shape', content: '', w: 5, h: 5, style: { shapeType: 'rect', fill: '#e74c3c' } })])
expect(small).toHaveLength(1)
const big = norm([el('a', { type: 'shape', content: '', w: 40, h: 30, style: { shapeType: 'rect', fill: '#e74c3c' } })])
expect(big).toHaveLength(0)
})
it('空 image/videoAI 占位)→ 剔除,防渲染成空白矩形框', () => {
const els = norm([el('a', { type: 'image', content: '' }), el('b', { type: 'video', content: '' }), el('c')])
expect(els.map(e => e.id)).toEqual(['c'])
})
it('有内容的 image/video 保留', () => {
const els = norm([el('a', { type: 'image', content: 'data:image/png;base64,xx' }), el('b', { type: 'video', content: 'https://v/1.mp4' })])
expect(els).toHaveLength(2)
})
it('有 segments 的元素即使 content 空也保留', () => {
const e = el('a', { content: '' })
// normSegments 输入格式:行对象数组,每行含 segments
;(e as any).segments = [{ segments: [{ text: '富文本' }] }]
expect(norm([e])).toHaveLength(1)
})
})
describe('文本重叠机械校正', () => {
it('显著相交(>30%)→ 后者下移至前者下缘 + 最小间距', () => {
// a: 10,10 40x10b: 10,15 40x10 → 相交 40x5=200,小元素面积 400,占比 50%>30%
// 第一阶段移至 b 下缘 20,第二阶段再保 1.5 最小间距 → 21.5
const els = norm([el('a'), el('b', { y: 15 })])
expect(els.find(e => e.id === 'b')!.y).toBe(21.5)
})
it('轻微相交(≤30%,有意叠加)→ 不动', () => {
// a:10-20 b:18-28 相交高 2,小面积 400,占比 5%…精确:40x2=80,占比 20%
// 相交不显著 → 第一阶段不动;但同列 gap 仅 -2 <1.5 → 第二阶段保最小间距至 21.5
const els = norm([el('a'), el('b', { y: 18 })])
expect(els.find(e => e.id === 'b')!.y).toBe(21.5)
})
it('轻微相交变体(部分重叠但占比≤30%)→ 不动', () => {
// a: 10,10 40x10b: 20,18 40x12 → 相交 30x2=60,小面积 400,占比 15%
// 相交不显著 → 第一阶段不动;同列 gap <1.5 → 第二阶段保最小间距至 21.5
const els = norm([el('a'), el('b', { x: 20, y: 18, w: 40, h: 12 })])
expect(els.find(e => e.id === 'b')!.y).toBe(21.5)
})
it('下移会出画布 → 缩高贴底', () => {
// a: 10-100b(90-100) 平移到 y=100 后需缩高收进画布(h 钳 3,y 贴底 97)
const els = norm([el('a', { y: 95, h: 5, w: 40 }), el('b', { y: 90, h: 10, w: 40 })])
const b = els.find(e => e.id === 'b')!
expect(b.h).toBe(3)
expect(b.y).toBe(97)
expect(b.y + b.h).toBeLessThanOrEqual(100)
})
it('chart/shape 背景元素不参与重叠校正', () => {
const chart = el('c', { type: 'chart', content: '[{"label":"a","value":1}]', x: 10, y: 10, w: 60, h: 40, style: { chartType: 'bar' } })
const text = el('t', { y: 20 })
const els = norm([chart, text])
expect(els.find(e => e.id === 't')!.y).toBe(20)
})
})
describe('chart 数据形状校正', () => {
it('pie 多系列 → 取第一系列,values 长度=labels 长度', () => {
const pie = el('p', {
type: 'chart', x: 30, y: 30, w: 40, h: 30,
content: JSON.stringify({ series: ['Q1', 'Q2'], items: [{ label: '华东', values: [120, 150] }, { label: '华南', values: [80, 90] }] }),
style: { chartType: 'pie' }
})
const out = norm([pie])[0]
const data = JSON.parse(out.content)
expect(data).toHaveLength(2)
expect(data[0]).toEqual({ label: '华东', value: 120 })
expect(data[1]).toEqual({ label: '华南', value: 80 })
})
it('pie 单系列已是正确格式 → 不变', () => {
const src = [{ label: 'a', value: 1 }, { label: 'b', value: 2 }]
const pie = el('p', { type: 'chart', content: JSON.stringify(src), style: { chartType: 'doughnut' } })
expect(JSON.parse(norm([pie])[0].content)).toEqual(src)
})
it('radar 指标<3 → 纠正为 bar', () => {
const radar = el('r', {
type: 'chart',
content: '[{"label":"a","value":1},{"label":"b","value":2}]',
style: { chartType: 'radar' }
})
const out = norm([radar])[0]
expect(out.style.chartType).toBe('bar')
})
it('radar 指标≥3 → 保持 radar', () => {
const radar = el('r', {
type: 'chart',
content: '[{"label":"a","value":1},{"label":"b","value":2},{"label":"c","value":3}]',
style: { chartType: 'radar' }
})
expect(norm([radar])[0].style.chartType).toBe('radar')
})
})
describe('icon 白名单归一化', () => {
it.each(['✅', '⚠️', '💡', '🎯', '📌', '🔍', '⭐'])('白名单 icon %s 保留(去 VS16 归一化)', (icon) => {
const els = normSlides([{ background: 'bg', elements: [{ id: 'a', type: 'card', x: 10, y: 10, w: 40, h: 20, content: '标题\n正文', style: { icon } }] }])
expect(els[0].elements[0].style.icon).toBe(icon.replace(//g, ''))
})
it.each(['❗', '✔️', '❌', '☑️', '🔥', '🚀', '✨'])('非白名单 icon %s 丢弃(留空)', (icon) => {
const els = normSlides([{ background: 'bg', elements: [{ id: 'a', type: 'card', x: 10, y: 10, w: 40, h: 20, content: '标题\n正文', style: { icon } }] }])
expect(els[0].elements[0].style.icon).toBeUndefined()
})
})
describe('空 shape 金句底座框剔除(dropEmptyElements', () => {
it('空 shape opacity<0.15 → 剔除;opacity 达标且色距足够 → 保留', () => {
// a/b x 拉开,避免触发「叠放装饰丢弃后出现者」的装饰纪律干扰本用例
const els = norm([
el('a', { type: 'shape', content: '', w: 5, h: 5, x: 5, style: { shapeType: 'rect', fill: '#e74c3c', opacity: 0.1 } }),
el('b', { type: 'shape', content: '', w: 5, h: 5, x: 60, style: { shapeType: 'rect', fill: '#e74c3c', opacity: 0.5 } }),
el('c')
])
expect(els.map(e => e.id)).toEqual(['b', 'c'])
})
it('空 shape fill 与背景色距 <60(视觉隐形)→ 剔除', () => {
const els = norm([
el('a', { type: 'shape', content: '', w: 5, h: 5, style: { shapeType: 'rect', fill: '#ffffff' } }),
el('b')
])
expect(els.map(e => e.id)).toEqual(['b'])
})
it('带内容的 shape 即使低透明度也不剔除', () => {
const els = norm([el('a', { type: 'shape', content: '文字', w: 20, h: 10, style: { fill: '#e74c3c', opacity: 0.05 } })])
expect(els).toHaveLength(1)
})
})
describe('金句引号剥离 → decoQuote 转换(sanitizeQuoteDeco', () => {
it('quote 首尾成对弯引号包裹 → 剥离并置 style.decoQuote=true', () => {
const els = norm([el('q', { type: 'quote', content: '“少即是多”', style: { fontSize: 44 } })])
expect(els[0].content).toBe('少即是多')
expect(els[0].style.decoQuote).toBe(true)
})
it('quote 「」包裹 → 剥离并置 decoQuote', () => {
const els = norm([el('q', { type: 'quote', content: '「内容正文」', style: {} })])
expect(els[0].content).toBe('内容正文')
expect(els[0].style.decoQuote).toBe(true)
})
it('quote content 只含引号字符(剥后为空)→ 整元素剔除', () => {
const els = norm([el('q', { type: 'quote', content: '“”' }), el('b')])
expect(els.map(e => e.id)).toEqual(['b'])
})
it('quote 无引号字符 → content 与 style 不动', () => {
const els = norm([el('q', { type: 'quote', content: '没有引号的正文', style: {} })])
expect(els[0].content).toBe('没有引号的正文')
expect(els[0].style.decoQuote).toBeUndefined()
})
it('非 quote 类型的引号字符不剥离(正文合法引用)', () => {
const els = norm([el('t', { type: 'text', content: '他说“你好”', style: {} })])
expect(els[0].content).toBe('他说“你好”')
expect(els[0].style.decoQuote).toBeUndefined()
})
})
describe('文本容量估算扩高(sanitizeOverflow + estimateTextH', () => {
it('长文本 h 不足 → 扩高(不超画布)', () => {
// 60 字正文 24px、宽 40%:每行约 (1280*0.4)/(24*1.05)≈20 字 → 3 行 → 需 h≈(3*24*1.5+0.8*24)/720*100≈17.7
const els = norm([el('t', { content: '一'.repeat(60), w: 40, h: 6, style: { fontSize: 24 } })])
expect(els[0].h).toBeGreaterThan(6)
expect(els[0].y + els[0].h).toBeLessThanOrEqual(100)
})
it('短文本 h 已足 → 不动', () => {
const els = norm([el('t', { content: '短', w: 40, h: 10, style: { fontSize: 24 } })])
expect(els[0].h).toBe(10)
})
it('扩高出画布 → 降字号一档(不低于 0.75×)', () => {
// 长文 + 低 y + 大字号:容量远超剩余空间 → 降字号
const els = norm([el('t', { y: 60, h: 10, w: 30, content: '一'.repeat(200), style: { fontSize: 48 } })])
expect(els[0].style.fontSize).toBeLessThan(48)
expect(els[0].style.fontSize!).toBeGreaterThanOrEqual(48 * 0.75)
expect(els[0].y + els[0].h).toBeLessThanOrEqual(100)
})
it('card 标题按 1.5em 折算容量(长标题单行放不下时扩高)', () => {
const els = norm([el('c', {
type: 'card', w: 25, h: 12, style: { fontSize: 20 },
content: '这是一个特别长的卡片标题超过一行折行\n正文内容'
})])
expect(els[0].h).toBeGreaterThan(12)
})
it('重叠缩高不低于容量下限:下移空间不足时缩字号而不是硬裁', () => {
// a 在上方;b y=90 与 a 相交 → 平移空间只剩 10%,长文本容量 >10 → 应缩字号而非 h=3 硬裁
const els = norm([el('a', { h: 6 }), el('b', { y: 90, h: 5, w: 40, content: '一'.repeat(80), style: { fontSize: 24 } })])
const b = els.find(e => e.id === 'b')!
expect(b.style.fontSize).toBeLessThan(24)
expect(b.y + b.h).toBeLessThanOrEqual(100)
})
})