/* ===================================================================== * sanitize-shapes.test.ts — AI 输出形状校正测试 * 保护对象:normSlides 内的 sanitizeShapes(等比化/收边/防压字) * AI 坐标自由输出,这些校正防止 clip-path 多边形变形、形状裁切、装饰压字 * ===================================================================== */ import { describe, it, expect } from 'vitest' import { normSlides } from '../src/core/ai' import type { SlideElement } from '../src/core/types' /* ---------- 测试数据工厂 ---------- */ function shape(id: string, over: Partial = {}): SlideElement { return { id, type: 'shape', x: 10, y: 10, w: 20, h: 8, content: '', style: { shapeType: 'rect', fill: 'accent' }, ...over } } function norm(elements: SlideElement[]): SlideElement[] { return normSlides([{ background: 'bg', elements }])[0].elements } describe('sanitizeShapes 等比化', () => { it('star 非正方形框 → 取小者,中心不变', () => { const els = norm([shape('a', { style: { shapeType: 'star', fill: 'accent' }, x: 40, y: 20, w: 20, h: 8 })]) expect(els[0].w).toBe(8) expect(els[0].h).toBe(8) expect(els[0].x).toBe(46) // 40 + (20-8)/2 expect(els[0].y).toBe(20) }) it('circle 扁宽框 → 正方形', () => { const els = norm([shape('a', { style: { shapeType: 'circle', fill: 'accent' }, x: 10, y: 30, w: 30, h: 10 })]) expect(els[0].w).toBe(10) expect(els[0].h).toBe(10) expect(els[0].x).toBe(20) // 10 + 10 expect(els[0].y).toBe(30) }) it('arrow 天然扁宽 → 不等比', () => { const els = norm([shape('a', { style: { shapeType: 'arrow', fill: 'accent' }, x: 10, y: 30, w: 30, h: 10 })]) expect(els[0].w).toBe(30) expect(els[0].h).toBe(10) }) }) describe('sanitizeShapes 收进画布', () => { it('形状超出下缘 → 收进', () => { const els = norm([shape('a', { x: 10, y: 95, w: 30, h: 15 })]) expect(els[0].y).toBe(85) expect(els[0].y + els[0].h).toBeLessThanOrEqual(100) }) it('形状超出右缘 → 收进', () => { const els = norm([shape('a', { x: 90, y: 10, w: 30, h: 8 })]) expect(els[0].x).toBe(70) }) }) describe('sanitizeShapes 防压字', () => { const title: SlideElement = { id: 't', type: 'title', x: 10, y: 8, w: 80, h: 10, content: '标题', style: {} } it('空胶囊压在 title 中部 → 移到 title 正下方', () => { const els = norm([title, shape('cap', { x: 10, y: 12, w: 40, h: 3 })]) const cap = els.find(e => e.id === 'cap')! expect(cap.y).toBe(19) // title.y + title.h + 1 }) it('空胶囊同时压 title 与 list 两个元素 → 丢弃', () => { const list: SlideElement = { id: 'l', type: 'list', x: 10, y: 14, w: 80, h: 40, content: '条目', style: {} } // cap y=10 h=10:与 title 相交高 10 > 5,与 list(y14起) 相交高 6 > 5 → 命中两个 const els = norm([title, list, shape('cap', { x: 10, y: 10, w: 40, h: 10 })]) expect(els.find(e => e.id === 'cap')).toBeUndefined() }) it('有文字的形状压字 → 不动', () => { const els = norm([title, shape('bad', { x: 10, y: 12, w: 40, h: 3, content: '标签' })]) const bad = els.find(e => e.id === 'bad')! expect(bad.y).toBe(12) }) it('贴标题下缘的合法胶囊(相交浅)→ 不误伤', () => { // cap y=16 h=4(至20),title 至 y=18,相交高 2 = h*0.5,不满足「>」判定 → 保持原位 const els = norm([title, shape('cap', { x: 10, y: 16, w: 40, h: 4 })]) const cap = els.find(e => e.id === 'cap')! expect(cap.y).toBe(16) }) }) describe('sanitizeShapes 怪异装饰兜底', () => { it('横贯页面的细矩形(分隔线/进度条)→ 丢弃', () => { const els = norm([shape('div', { x: 10, y: 50, w: 80, h: 3 })]) expect(els.find(e => e.id === 'div')).toBeUndefined() }) it('扁宽但不到横贯程度 → 保留', () => { const els = norm([shape('wide', { x: 10, y: 50, w: 50, h: 3 })]) expect(els.find(e => e.id === 'wide')).toBeDefined() }) it('多形状叠放拼图案(三角+矩形+圆拼「房子」)→ 只保留先出现的', () => { // 三角在上方,矩形/圆与其叠放 → 后两者丢弃 const els = norm([ shape('roof', { style: { shapeType: 'triangle', fill: 'accent' }, x: 40, y: 10, w: 20, h: 12 }), shape('body', { style: { fill: 'accent' }, x: 42, y: 20, w: 16, h: 15 }), shape('dot', { style: { shapeType: 'circle', fill: 'accent' }, x: 48, y: 24, w: 5, h: 5 }) ]) expect(els.find(e => e.id === 'roof')).toBeDefined() expect(els.find(e => e.id === 'body')).toBeUndefined() expect(els.find(e => e.id === 'dot')).toBeUndefined() }) it('不相交的独立装饰 → 互不影响', () => { const els = norm([ shape('a', { x: 5, y: 10, w: 10, h: 8 }), shape('b', { x: 60, y: 60, w: 10, h: 8 }) ]) expect(els.find(e => e.id === 'a')).toBeDefined() expect(els.find(e => e.id === 'b')).toBeDefined() }) it('空装饰超过 5 个 → 只保留前 5 个', () => { // 间距拉开互不叠放(w/h 最小钳制为 3,间隔须 >3),纯验数量兜底 const els = norm(Array.from({ length: 7 }, (_, i) => shape('d' + i, { x: i * 5, y: 50, w: 3, h: 3 }))) expect(els.filter(e => e.type === 'shape').length).toBe(5) }) it('有文字的形状(bubble 标签)→ 不参与丢弃', () => { const els = norm([ shape('tag1', { x: 10, y: 30, w: 15, h: 6, content: '要点' }), shape('tag2', { x: 12, y: 32, w: 15, h: 6, content: '标签' }) ]) expect(els.find(e => e.id === 'tag1')).toBeDefined() expect(els.find(e => e.id === 'tag2')).toBeDefined() }) })