Files
u-ppt/tests/norm-slides-enhance.test.ts

237 lines
11 KiB
TypeScript
Raw Permalink Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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.
/* =====================================================================
* 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)
})
})