| 1 | import { describe, expect, it } from 'vitest' |
| 2 | import { |
| 3 | buildIconElementHtml, |
| 4 | buildShapeElementHtml, |
| 5 | isValidBlockId, |
| 6 | isValidColor, |
| 7 | type InsertShapeType |
| 8 | } from '../../../src/renderer/src/components/session-detail/workspace/insert-shapes' |
| 9 | |
| 10 | const VALID_BLOCK_ID = 'select-arcsin1-abcd1234' |
| 11 | const LAYOUT = { left: 100, top: 200, width: 240, height: 120, zIndex: 12 } |
| 12 | |
| 13 | describe('buildShapeElementHtml', () => { |
| 14 | const types: InsertShapeType[] = [ |
| 15 | 'rect', |
| 16 | 'rounded-rect', |
| 17 | 'ellipse', |
| 18 | 'triangle', |
| 19 | 'diamond', |
| 20 | 'pentagon', |
| 21 | 'hexagon', |
| 22 | 'parallelogram', |
| 23 | 'trapezoid', |
| 24 | 'star-5', |
| 25 | 'line', |
| 26 | 'arrow-right', |
| 27 | 'chevron-right' |
| 28 | ] |
| 29 | |
| 30 | types.forEach((type) => { |
| 31 | it(`produces a selectable shape fragment for "${type}"`, () => { |
| 32 | const html = buildShapeElementHtml({ ...LAYOUT, blockId: VALID_BLOCK_ID, type }) |
| 33 | expect(html).toContain(`data-block-id="${VALID_BLOCK_ID}"`) |
| 34 | expect(html).toContain('data-ppt-edit-kind="shape"') |
| 35 | expect(html).toContain(`data-ppt-shape-type="${type}"`) |
| 36 | expect(html).toContain('position:absolute') |
| 37 | expect(html).toContain('left:100px') |
| 38 | expect(html).toContain('top:200px') |
| 39 | expect(html).toContain('width:240px') |
| 40 | expect(html).toContain('height:120px') |
| 41 | expect(html).toContain('z-index:12') |
| 42 | expect(html).toContain('<svg') |
| 43 | expect(html).toContain('</svg>') |
| 44 | }) |
| 45 | }) |
| 46 | |
| 47 | it('line uses non-scaling-stroke so stroke width stays stable when stretched', () => { |
| 48 | const line = buildShapeElementHtml({ ...LAYOUT, blockId: VALID_BLOCK_ID, type: 'line' }) |
| 49 | expect(line).toContain('vector-effect="non-scaling-stroke"') |
| 50 | }) |
| 51 | |
| 52 | it('arrow-right is a solid filled block arrow (not a thin stroked path)', () => { |
| 53 | const arrow = buildShapeElementHtml({ |
| 54 | ...LAYOUT, |
| 55 | blockId: VALID_BLOCK_ID, |
| 56 | type: 'arrow-right' |
| 57 | }) |
| 58 | expect(arrow).toContain('<polygon') |
| 59 | // filled block arrow must not be stroke-only |
| 60 | expect(arrow).toMatch(/fill="#[0-9a-fA-F]+"/) |
| 61 | }) |
| 62 | |
| 63 | it('polygon-based shapes render a <polygon> element', () => { |
| 64 | const polygonShapes: InsertShapeType[] = [ |
| 65 | 'triangle', |
| 66 | 'diamond', |
| 67 | 'pentagon', |
| 68 | 'hexagon', |
| 69 | 'parallelogram', |
| 70 | 'trapezoid', |
| 71 | 'star-5', |
| 72 | 'chevron-right' |
| 73 | ] |
| 74 | polygonShapes.forEach((type) => { |
| 75 | const html = buildShapeElementHtml({ ...LAYOUT, blockId: VALID_BLOCK_ID, type }) |
| 76 | expect(html).toContain('<polygon') |
| 77 | }) |
| 78 | }) |
| 79 | |
| 80 | it('rejects an invalid blockId', () => { |
| 81 | expect(() => |
| 82 | buildShapeElementHtml({ ...LAYOUT, blockId: 'evil"><script>', type: 'rect' }) |
| 83 | ).toThrow(/invalid blockId/) |
| 84 | }) |
| 85 | |
| 86 | it('rejects an unknown shape type', () => { |
| 87 | expect(() => |
| 88 | buildShapeElementHtml({ |
| 89 | ...LAYOUT, |
| 90 | blockId: VALID_BLOCK_ID, |
| 91 | type: 'definitely-not-real' as InsertShapeType |
| 92 | }) |
| 93 | ).toThrow(/unknown shape type/) |
| 94 | }) |
| 95 | |
| 96 | it('rejects a color that would break out of the attribute', () => { |
| 97 | expect(() => |
| 98 | buildShapeElementHtml({ |
| 99 | ...LAYOUT, |
| 100 | blockId: VALID_BLOCK_ID, |
| 101 | type: 'rect', |
| 102 | fill: 'red"/><script>alert(1)</script><rect x="' |
| 103 | }) |
| 104 | ).toThrow(/invalid color/) |
| 105 | }) |
| 106 | |
| 107 | it('never emits script tags or inline event handlers', () => { |
| 108 | const html = buildShapeElementHtml({ ...LAYOUT, blockId: VALID_BLOCK_ID, type: 'rect' }) |
| 109 | expect(html).not.toMatch(/<script/i) |
| 110 | expect(html).not.toMatch(/on\w+\s*=/i) |
| 111 | expect(html).not.toMatch(/javascript:/i) |
| 112 | }) |
| 113 | }) |
| 114 | |
| 115 | describe('buildIconElementHtml', () => { |
| 116 | it('produces a selectable icon fragment with currentColor stroke', () => { |
| 117 | const html = buildIconElementHtml({ ...LAYOUT, blockId: VALID_BLOCK_ID, iconId: 'lightbulb' }) |
| 118 | expect(html).toContain(`data-block-id="${VALID_BLOCK_ID}"`) |
| 119 | expect(html).toContain('data-ppt-edit-kind="icon"') |
| 120 | expect(html).toContain('data-ppt-icon-id="lightbulb"') |
| 121 | expect(html).toContain('stroke="currentColor"') |
| 122 | expect(html).toContain('color:#3f4b35') |
| 123 | expect(html).toContain('<svg') |
| 124 | }) |
| 125 | |
| 126 | it('numbered badge renders a solid filled circle with the number', () => { |
| 127 | const html = buildIconElementHtml({ |
| 128 | ...LAYOUT, |
| 129 | blockId: VALID_BLOCK_ID, |
| 130 | iconId: 'number-3' |
| 131 | }) |
| 132 | expect(html).toContain('data-ppt-icon-id="number-3"') |
| 133 | expect(html).toContain('<circle') |
| 134 | expect(html).toContain('fill="currentColor"') |
| 135 | expect(html).toContain('>3</') |
| 136 | // badge must not carry the stroke-icon attrs on the outer svg |
| 137 | expect(html).not.toMatch(/svg[^>]*stroke="currentColor"/) |
| 138 | }) |
| 139 | |
| 140 | it('rejects an unknown iconId', () => { |
| 141 | expect(() => |
| 142 | buildIconElementHtml({ ...LAYOUT, blockId: VALID_BLOCK_ID, iconId: 'definitely-not-real' }) |
| 143 | ).toThrow(/unknown iconId/) |
| 144 | }) |
| 145 | |
| 146 | it('serializes circle/line/rect nodes correctly', () => { |
| 147 | const html = buildIconElementHtml({ ...LAYOUT, blockId: VALID_BLOCK_ID, iconId: 'target' }) |
| 148 | // target icon has 3 nested circles |
| 149 | const circleMatches = html.match(/<circle /g) |
| 150 | expect(circleMatches).not.toBeNull() |
| 151 | expect(circleMatches!.length).toBe(3) |
| 152 | }) |
| 153 | |
| 154 | it('never emits script tags or inline event handlers', () => { |
| 155 | const html = buildIconElementHtml({ ...LAYOUT, blockId: VALID_BLOCK_ID, iconId: 'star' }) |
| 156 | expect(html).not.toMatch(/<script/i) |
| 157 | expect(html).not.toMatch(/on\w+\s*=/i) |
| 158 | expect(html).not.toMatch(/javascript:/i) |
| 159 | expect(html).not.toMatch(/<foreignObject/i) |
| 160 | }) |
| 161 | }) |
| 162 | |
| 163 | describe('isValidBlockId / isValidColor', () => { |
| 164 | it('accepts the canonical generated id shape', () => { |
| 165 | expect(isValidBlockId('select-arcsin1-abcd1234')).toBe(true) |
| 166 | expect(isValidBlockId('select-arcsin1-Xy_9-AB12')).toBe(true) |
| 167 | }) |
| 168 | |
| 169 | it('rejects anything that is not the generated id shape', () => { |
| 170 | expect(isValidBlockId('')).toBe(false) |
| 171 | expect(isValidBlockId('foo')).toBe(false) |
| 172 | expect(isValidBlockId('"><script>')).toBe(false) |
| 173 | }) |
| 174 | |
| 175 | it('accepts hex and named colors, rejects expressions', () => { |
| 176 | expect(isValidColor('#abc')).toBe(true) |
| 177 | expect(isValidColor('#aabbcc')).toBe(true) |
| 178 | expect(isValidColor('red')).toBe(true) |
| 179 | expect(isValidColor('transparent')).toBe(true) |
| 180 | expect(isValidColor('currentColor')).toBe(true) |
| 181 | expect(isValidColor('url(foo)')).toBe(false) |
| 182 | expect(isValidColor('expression(alert(1))')).toBe(false) |
| 183 | }) |
| 184 | }) |
| 185 |