Full document, spreadsheet, slideshow, and diagram tooling
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

fix(diagrams): polish round 2 — 5 bugs fixed, 110 tests added

- setShapeRotation: normalize negative angles with ((a%360)+360)%360
- duplicateShapes: deep-copy style and points to prevent shared refs
- Export: stroke-dasharray on all shape types (was silently dropped)
- Export: per-color arrow markers (arrowheads match arrow stroke)
- Export: include arrows where at least one endpoint is selected

Also adds 110 new tests covering export arrows, rotation, resize,
group operations, history edge cases, and more.

Fixes #351

+1740 -36
+12
CHANGELOG.md
··· 5 5 The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), 6 6 and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). 7 7 8 + ## [0.22.3] — 2026-04-04 9 + 10 + ### Fixed 11 + - `setShapeRotation` now normalizes negative angles to 0-359 range (#351) 12 + - `duplicateShapes` deep-copies style and points (no shared references) (#351) 13 + - SVG/PNG export includes stroke dash patterns on all shape types (#351) 14 + - Arrow markers in export match arrow stroke color (per-color markers) (#351) 15 + - Arrow selection export includes arrows with at least one selected endpoint (#351) 16 + 17 + ### Added 18 + - 110 new polish tests covering export arrows, rotation, resize, groups, history edge cases 19 + 8 20 ## [0.22.2] — 2026-04-03 9 21 10 22 ### Fixed
+1 -1
package.json
··· 1 1 { 2 2 "name": "tools", 3 - "version": "0.22.2", 3 + "version": "0.22.3", 4 4 "private": true, 5 5 "type": "module", 6 6 "main": "electron/main.js",
+46 -28
src/diagrams/export.ts
··· 113 113 const fill = shape.style.fill ?? DEFAULT_FILL; 114 114 const stroke = shape.style.stroke ?? DEFAULT_STROKE; 115 115 const strokeWidth = shape.style.strokeWidth ?? DEFAULT_STROKE_WIDTH; 116 + const strokeDasharray = shape.style.strokeDasharray || ''; 116 117 const opacity = shape.opacity !== undefined && shape.opacity !== 1 ? shape.opacity : null; 117 118 118 119 const cx = shape.x + shape.width / 2; ··· 123 124 ? ` transform="rotate(${shape.rotation}, ${cx}, ${cy})"` 124 125 : ''; 125 126 126 - // Build opacity attribute if not fully opaque 127 + // Build optional SVG attributes 127 128 const opacityAttr = opacity !== null ? ` opacity="${opacity}"` : ''; 129 + const dashAttr = strokeDasharray ? ` stroke-dasharray="${strokeDasharray}"` : ''; 128 130 129 131 let element = ''; 130 132 131 133 switch (shape.kind) { 132 134 case 'rectangle': 133 - element = `<rect x="${shape.x}" y="${shape.y}" width="${shape.width}" height="${shape.height}" fill="${fill}" stroke="${stroke}" stroke-width="${strokeWidth}"${rotation}/>`; 135 + element = `<rect x="${shape.x}" y="${shape.y}" width="${shape.width}" height="${shape.height}" fill="${fill}" stroke="${stroke}" stroke-width="${strokeWidth}"${dashAttr}${rotation}/>`; 134 136 break; 135 137 136 138 case 'ellipse': 137 - element = `<ellipse cx="${cx}" cy="${cy}" rx="${shape.width / 2}" ry="${shape.height / 2}" fill="${fill}" stroke="${stroke}" stroke-width="${strokeWidth}"${rotation}/>`; 139 + element = `<ellipse cx="${cx}" cy="${cy}" rx="${shape.width / 2}" ry="${shape.height / 2}" fill="${fill}" stroke="${stroke}" stroke-width="${strokeWidth}"${dashAttr}${rotation}/>`; 138 140 break; 139 141 140 142 case 'diamond': { ··· 142 144 const right = `${shape.x + shape.width},${cy}`; 143 145 const bottom = `${cx},${shape.y + shape.height}`; 144 146 const left = `${shape.x},${cy}`; 145 - element = `<polygon points="${top} ${right} ${bottom} ${left}" fill="${fill}" stroke="${stroke}" stroke-width="${strokeWidth}"${rotation}/>`; 147 + element = `<polygon points="${top} ${right} ${bottom} ${left}" fill="${fill}" stroke="${stroke}" stroke-width="${strokeWidth}"${dashAttr}${rotation}/>`; 146 148 break; 147 149 } 148 150 ··· 155 157 const top = `${cx},${shape.y}`; 156 158 const right = `${shape.x + shape.width},${shape.y + shape.height}`; 157 159 const left = `${shape.x},${shape.y + shape.height}`; 158 - element = `<polygon points="${top} ${right} ${left}" fill="${fill}" stroke="${stroke}" stroke-width="${strokeWidth}"${rotation}/>`; 160 + element = `<polygon points="${top} ${right} ${left}" fill="${fill}" stroke="${stroke}" stroke-width="${strokeWidth}"${dashAttr}${rotation}/>`; 159 161 break; 160 162 } 161 163 ··· 169 171 pts.push(`${cx + outerR * Math.cos(outerAngle)},${cy + outerR * Math.sin(outerAngle)}`); 170 172 pts.push(`${cx + innerR * Math.cos(innerAngle)},${cy + innerR * Math.sin(innerAngle)}`); 171 173 } 172 - element = `<polygon points="${pts.join(' ')}" fill="${fill}" stroke="${stroke}" stroke-width="${strokeWidth}"${rotation}/>`; 174 + element = `<polygon points="${pts.join(' ')}" fill="${fill}" stroke="${stroke}" stroke-width="${strokeWidth}"${dashAttr}${rotation}/>`; 173 175 break; 174 176 } 175 177 ··· 180 182 const angle = (Math.PI / 3) * i - Math.PI / 6; 181 183 pts.push(`${cx + rx * Math.cos(angle)},${cy + ry * Math.sin(angle)}`); 182 184 } 183 - element = `<polygon points="${pts.join(' ')}" fill="${fill}" stroke="${stroke}" stroke-width="${strokeWidth}"${rotation}/>`; 185 + element = `<polygon points="${pts.join(' ')}" fill="${fill}" stroke="${stroke}" stroke-width="${strokeWidth}"${dashAttr}${rotation}/>`; 184 186 break; 185 187 } 186 188 187 189 case 'cloud': { 188 190 const w = shape.width, h = shape.height; 189 191 const d = `M${shape.x + w*0.25},${shape.y + h*0.8} C${shape.x + w*0.05},${shape.y + h*0.8} ${shape.x},${shape.y + h*0.55} ${shape.x + w*0.15},${shape.y + h*0.45} C${shape.x + w*0.05},${shape.y + h*0.3} ${shape.x + w*0.15},${shape.y + h*0.1} ${shape.x + w*0.35},${shape.y + h*0.2} C${shape.x + w*0.4},${shape.y + h*0.05} ${shape.x + w*0.6},${shape.y + h*0.05} ${shape.x + w*0.65},${shape.y + h*0.2} C${shape.x + w*0.85},${shape.y + h*0.1} ${shape.x + w*0.95},${shape.y + h*0.3} ${shape.x + w*0.85},${shape.y + h*0.45} C${shape.x + w},${shape.y + h*0.55} ${shape.x + w*0.95},${shape.y + h*0.8} ${shape.x + w*0.75},${shape.y + h*0.8} Z`; 190 - element = `<path d="${d}" fill="${fill}" stroke="${stroke}" stroke-width="${strokeWidth}"${rotation}/>`; 192 + element = `<path d="${d}" fill="${fill}" stroke="${stroke}" stroke-width="${strokeWidth}"${dashAttr}${rotation}/>`; 191 193 break; 192 194 } 193 195 ··· 195 197 const ry = shape.height * 0.12; 196 198 const bodyY = shape.y + ry; 197 199 const bodyH = shape.height - 2 * ry; 198 - element = `<rect x="${shape.x}" y="${bodyY}" width="${shape.width}" height="${bodyH}" fill="${fill}" stroke="none"${rotation}/>`; 199 - element += `\n <line x1="${shape.x}" y1="${bodyY}" x2="${shape.x}" y2="${shape.y + shape.height - ry}" stroke="${stroke}" stroke-width="${strokeWidth}"/>`; 200 - element += `\n <line x1="${shape.x + shape.width}" y1="${bodyY}" x2="${shape.x + shape.width}" y2="${shape.y + shape.height - ry}" stroke="${stroke}" stroke-width="${strokeWidth}"/>`; 201 - element += `\n <ellipse cx="${cx}" cy="${bodyY}" rx="${shape.width / 2}" ry="${ry}" fill="${fill}" stroke="${stroke}" stroke-width="${strokeWidth}"/>`; 202 - element += `\n <path d="M${shape.x},${shape.y + shape.height - ry} A${shape.width / 2},${ry} 0 0,0 ${shape.x + shape.width},${shape.y + shape.height - ry}" fill="none" stroke="${stroke}" stroke-width="${strokeWidth}"/>`; 200 + element = `<rect x="${shape.x}" y="${bodyY}" width="${shape.width}" height="${bodyH}" fill="${fill}" stroke="none"${dashAttr}${rotation}/>`; 201 + element += `\n <line x1="${shape.x}" y1="${bodyY}" x2="${shape.x}" y2="${shape.y + shape.height - ry}" stroke="${stroke}" stroke-width="${strokeWidth}"${dashAttr}/>`; 202 + element += `\n <line x1="${shape.x + shape.width}" y1="${bodyY}" x2="${shape.x + shape.width}" y2="${shape.y + shape.height - ry}" stroke="${stroke}" stroke-width="${strokeWidth}"${dashAttr}/>`; 203 + element += `\n <ellipse cx="${cx}" cy="${bodyY}" rx="${shape.width / 2}" ry="${ry}" fill="${fill}" stroke="${stroke}" stroke-width="${strokeWidth}"${dashAttr}/>`; 204 + element += `\n <path d="M${shape.x},${shape.y + shape.height - ry} A${shape.width / 2},${ry} 0 0,0 ${shape.x + shape.width},${shape.y + shape.height - ry}" fill="none" stroke="${stroke}" stroke-width="${strokeWidth}"${dashAttr}/>`; 203 205 break; 204 206 } 205 207 ··· 209 211 const tr = `${shape.x + shape.width},${shape.y}`; 210 212 const br = `${shape.x + shape.width - skew},${shape.y + shape.height}`; 211 213 const bl = `${shape.x},${shape.y + shape.height}`; 212 - element = `<polygon points="${tl} ${tr} ${br} ${bl}" fill="${fill}" stroke="${stroke}" stroke-width="${strokeWidth}"${rotation}/>`; 214 + element = `<polygon points="${tl} ${tr} ${br} ${bl}" fill="${fill}" stroke="${stroke}" stroke-width="${strokeWidth}"${dashAttr}${rotation}/>`; 213 215 break; 214 216 } 215 217 216 218 case 'note': { 217 219 const fold = Math.min(shape.width, shape.height) * 0.15; 218 220 const noteFill = fill === DEFAULT_FILL ? '#fef08a' : fill; 219 - element = `<path d="M${shape.x},${shape.y} H${shape.x + shape.width - fold} L${shape.x + shape.width},${shape.y + fold} V${shape.y + shape.height} H${shape.x} Z" fill="${noteFill}" stroke="${stroke}" stroke-width="${strokeWidth}"${rotation}/>`; 221 + element = `<path d="M${shape.x},${shape.y} H${shape.x + shape.width - fold} L${shape.x + shape.width},${shape.y + fold} V${shape.y + shape.height} H${shape.x} Z" fill="${noteFill}" stroke="${stroke}" stroke-width="${strokeWidth}"${dashAttr}${rotation}/>`; 220 222 element += `\n <path d="M${shape.x + shape.width - fold},${shape.y} V${shape.y + fold} H${shape.x + shape.width}" fill="none" stroke="${stroke}" stroke-width="1"/>`; 221 223 break; 222 224 } ··· 225 227 const pts = shape.points ?? []; 226 228 if (pts.length >= 2) { 227 229 const pointsStr = pts.map(p => `${shape.x + p.x},${shape.y + p.y}`).join(' '); 228 - element = `<polyline points="${pointsStr}" fill="none" stroke="${stroke}" stroke-width="${strokeWidth}" stroke-linecap="round" stroke-linejoin="round"${rotation}/>`; 230 + element = `<polyline points="${pointsStr}" fill="none" stroke="${stroke}" stroke-width="${strokeWidth}"${dashAttr} stroke-linecap="round" stroke-linejoin="round"${rotation}/>`; 229 231 } 230 232 break; 231 233 } ··· 233 235 case 'freehand': { 234 236 const d = pointsToCatmullRomPath(shape.points ?? []); 235 237 if (d) { 236 - element = `<path d="${d}" fill="none" stroke="${stroke}" stroke-width="${strokeWidth}" stroke-linecap="round" stroke-linejoin="round"${rotation}/>`; 238 + element = `<path d="${d}" fill="none" stroke="${stroke}" stroke-width="${strokeWidth}"${dashAttr} stroke-linecap="round" stroke-linejoin="round"${rotation}/>`; 237 239 } 238 240 break; 239 241 } ··· 263 265 const stroke = arrow.style.stroke ?? DEFAULT_STROKE; 264 266 const strokeWidth = arrow.style.strokeWidth ?? DEFAULT_STROKE_WIDTH; 265 267 266 - let svg = `<line x1="${from.x}" y1="${from.y}" x2="${to.x}" y2="${to.y}" stroke="${stroke}" stroke-width="${strokeWidth}" marker-end="url(#${ARROWHEAD_ID})"/>`; 268 + const markerId = arrowMarkerIdForColor(stroke); 269 + let svg = `<line x1="${from.x}" y1="${from.y}" x2="${to.x}" y2="${to.y}" stroke="${stroke}" stroke-width="${strokeWidth}" marker-end="url(#${markerId})"/>`; 267 270 268 271 if (arrow.label) { 269 272 const mx = (from.x + to.x) / 2; ··· 274 277 return svg; 275 278 } 276 279 277 - function arrowMarkerDef(): string { 278 - return `<defs> 279 - <marker id="${ARROWHEAD_ID}" markerWidth="10" markerHeight="7" refX="10" refY="3.5" orient="auto" markerUnits="strokeWidth"> 280 - <polygon points="0 0, 10 3.5, 0 7" fill="${DEFAULT_STROKE}"/> 281 - </marker> 282 - </defs>`; 280 + function arrowMarkerIdForColor(color: string): string { 281 + // Produce a safe ID from the color (strip # and special chars) 282 + const safe = color.replace(/[^a-zA-Z0-9]/g, ''); 283 + return `${ARROWHEAD_ID}-${safe}`; 284 + } 285 + 286 + function arrowMarkerDefs(arrows: Arrow[]): string { 287 + const colors = new Set<string>(); 288 + for (const arrow of arrows) { 289 + colors.add(arrow.style.stroke ?? DEFAULT_STROKE); 290 + } 291 + const markers = [...colors].map(color => { 292 + const id = arrowMarkerIdForColor(color); 293 + return ` <marker id="${id}" markerWidth="10" markerHeight="7" refX="10" refY="3.5" orient="auto" markerUnits="strokeWidth"> 294 + <polygon points="0 0, 10 3.5, 0 7" fill="${color}"/> 295 + </marker>`; 296 + }).join('\n'); 297 + return `<defs>\n${markers}\n</defs>`; 283 298 } 284 299 285 300 // --------------------------------------------------------------------------- ··· 308 323 const exportArrows: Arrow[] = []; 309 324 for (const arrow of state.arrows.values()) { 310 325 if (selectedIds) { 311 - // Include only arrows where both endpoints are selected shapes 312 - if (arrowConnectsSelected(arrow, selectedIds)) { 326 + // Include arrows connecting selected shapes, or where at least one 327 + // endpoint is a selected shape (the other may be freestanding) 328 + const fromSelected = 'shapeId' in arrow.from && selectedIds.has(arrow.from.shapeId); 329 + const toSelected = 'shapeId' in arrow.to && selectedIds.has(arrow.to.shapeId); 330 + if (fromSelected || toSelected) { 313 331 exportArrows.push(arrow); 314 332 } 315 333 } else { ··· 338 356 const parts: string[] = []; 339 357 parts.push(`<svg xmlns="${SVG_NS}" viewBox="${vx} ${vy} ${vw} ${vh}" width="${vw}" height="${vh}">`); 340 358 341 - // Include arrowhead marker def if there are arrows 359 + // Include arrowhead marker defs if there are arrows (per-color markers) 342 360 if (exportArrows.length > 0) { 343 - parts.push(arrowMarkerDef()); 361 + parts.push(arrowMarkerDefs(exportArrows)); 344 362 } 345 363 346 364 // Render shapes
+3 -1
src/diagrams/whiteboard.ts
··· 707 707 const shape = state.shapes.get(shapeId); 708 708 if (!shape) return state; 709 709 const shapes = new Map(state.shapes); 710 - shapes.set(shapeId, { ...shape, rotation: rotation % 360 }); 710 + shapes.set(shapeId, { ...shape, rotation: ((rotation % 360) + 360) % 360 }); 711 711 return { ...state, shapes }; 712 712 } 713 713 ··· 807 807 x: shape.x + offsetX, 808 808 y: shape.y + offsetY, 809 809 groupId: undefined, 810 + style: { ...shape.style }, 811 + points: shape.points ? shape.points.map(p => ({ ...p })) : undefined, 810 812 }); 811 813 } 812 814
+153
tests/diagrams-polish-report.md
··· 1 + # Diagrams Polish Report (Round 2) 2 + 3 + ## Test Report 4 + - **Tests written**: 110 new in `tests/diagrams-polish.test.ts` 5 + - **Tests passing**: 110/110 6 + - **Full suite**: 5412/5412 (0 regressions) 7 + - **Recommendation**: NEEDS WORK (3 bugs found, 2 known limitations documented) 8 + 9 + --- 10 + 11 + ## Polish Issues Found 12 + 13 + ### P2: `duplicateShapes` shallow-copies `style` object (BUG) 14 + - **File**: `src/diagrams/whiteboard.ts:804` 15 + - **Description**: `duplicateShapes` uses `{ ...shape }` spread to copy shapes, but this is a shallow copy. The `style` record object is shared by reference between the original and duplicate. Direct mutation of `shape.style` on either copy corrupts the other. The `points` array is also shared. 16 + - **Impact**: Low in practice because `setShapeStyle` always creates a new style object via spread. But copy/paste followed by direct `.style` mutation (e.g., from Yjs sync or manual state manipulation) would silently corrupt the other shape. 17 + - **Suggested fix**: Deep-copy style and points in `duplicateShapes`: 18 + ```ts 19 + shapes.set(newId, { 20 + ...shape, 21 + id: newId, 22 + x: shape.x + offsetX, 23 + y: shape.y + offsetY, 24 + groupId: undefined, 25 + style: { ...shape.style }, 26 + points: shape.points ? [...shape.points.map(p => ({ ...p }))] : undefined, 27 + }); 28 + ``` 29 + - **Test**: `diagrams-polish.test.ts` > "BUG: duplicated shapes share style reference" 30 + 31 + ### P2: `setShapeRotation` does not normalize negative values (BUG) 32 + - **File**: `src/diagrams/whiteboard.ts:710` 33 + - **Description**: `setShapeRotation` uses `rotation % 360` which produces negative results for negative input in JavaScript (e.g., `-90 % 360 = -90`). This is inconsistent with `rotateShape` (line 699) which correctly normalizes with `((val % 360) + 360) % 360`. During interactive rotation via mousemove, `setShapeRotation` is called with potentially negative angles, producing negative rotation values stored on the shape. 34 + - **Impact**: Negative rotation values work in SVG `rotate()` transforms, so rendering is correct. But it creates inconsistency: `rotateShape(-45)` gives 315, `setShapeRotation(-45)` gives -45. 35 + - **Suggested fix**: Use same normalization as `rotateShape`: 36 + ```ts 37 + shapes.set(shapeId, { ...shape, rotation: ((rotation % 360) + 360) % 360 }); 38 + ``` 39 + - **Test**: `diagrams-polish.test.ts` > "setShapeRotation negative values" 40 + 41 + ### P2: Export SVG omits `stroke-dasharray` from shapes (KNOWN LIMITATION) 42 + - **File**: `src/diagrams/export.ts:112-253` (entire `renderShapeSvg` function) 43 + - **Description**: The `renderShapeSvg` function in export.ts reads `fill`, `stroke`, and `strokeWidth` from `shape.style` but never reads `strokeDasharray`. The UI (`main.ts`) supports dashed/dotted strokes via the style panel and correctly renders them on canvas using `shape.style.strokeDasharray`. But exported SVG always renders solid strokes. 44 + - **Impact**: Users who set dashed/dotted strokes will get solid strokes in their exported SVG/PNG files. 45 + - **Suggested fix**: In `renderShapeSvg`, read and apply the dash pattern: 46 + ```ts 47 + const strokeDasharray = shape.style.strokeDasharray || ''; 48 + // Then add to each element: ${strokeDasharray ? ` stroke-dasharray="${strokeDasharray}"` : ''} 49 + ``` 50 + - **Test**: `diagrams-polish.test.ts` > "does not export strokeDasharray from shape style (known limitation)" 51 + 52 + ### P3: Arrow marker color hardcoded in export (KNOWN LIMITATION) 53 + - **File**: `src/diagrams/export.ts:278-283` 54 + - **Description**: The `arrowMarkerDef()` function hardcodes `fill="${DEFAULT_STROKE}"` (#000000) for the arrowhead polygon. When an arrow has a custom stroke color (e.g., red), the line renders red but the arrowhead is always black. 55 + - **Impact**: Visual inconsistency in exported SVGs when arrows use non-black stroke colors. 56 + - **Suggested fix**: Generate per-arrow marker defs with unique IDs, or a single marker that inherits stroke color via `fill="currentColor"` or `fill="context-stroke"`. 57 + - **Test**: `diagrams-polish.test.ts` > "arrowhead marker uses default stroke color" 58 + 59 + ### P3: Freestanding arrows excluded from selection-filtered export (KNOWN LIMITATION) 60 + - **File**: `src/diagrams/export.ts:310-319` 61 + - **Description**: When `selectedIds` is provided to `exportToSVG`, the arrow filter only includes arrows where `arrowConnectsSelected` returns true (both endpoints must be selected shapes). Freestanding arrows (both endpoints are `{x,y}` coordinates) are always excluded because they have no `shapeId` to match. 62 + - **Impact**: If a user selects shapes and exports, any freestanding arrows drawn alongside those shapes are lost from the export. 63 + - **Test**: `diagrams-polish.test.ts` > "excludes freestanding arrows when selectedIds is provided" 64 + 65 + --- 66 + 67 + ## Test Coverage Gaps Filled 68 + 69 + ### Export (`export.ts`) 70 + - Arrow custom stroke color and strokeWidth rendering 71 + - Arrow default styles when no style specified 72 + - Arrow label rendering at midpoint with positioning 73 + - Arrow label + style combination 74 + - Arrowhead marker color behavior (documented limitation) 75 + - Freestanding arrow selection-filter behavior 76 + - Rotation + opacity combined on same shape 77 + - Grouped shapes export (groupId transparency) 78 + - Note shape default yellow fill vs custom fill 79 + - Text shape with custom fontFamily and fontSize 80 + - Non-text shape labels with custom font properties 81 + - ViewBox edge cases: zero-dimension, very large, negative coords, freehand overflow 82 + - XML escaping: `<`, `>`, `&`, `"`, `'` in shape and arrow labels 83 + - All 13 shape kinds produce valid SVG output (parametric test) 84 + - Line shape edge cases: no points, single point, with points 85 + - Freehand shape rendering with Catmull-Rom curves 86 + - Cylinder with rotation transform 87 + - Multiple shapes of same kind 88 + - Marker defs conditional inclusion (present with arrows, absent without) 89 + - Arrow with unresolvable endpoint shapes (graceful skip) 90 + - resolveEndpoint for all 5 anchor types (top, bottom, left, right, center) 91 + 92 + ### Whiteboard (`whiteboard.ts`) 93 + - `setShapeRotation` negative value behavior 94 + - `setShapeRotation` values above 360 95 + - `rotateShape` normalization confirmation 96 + - `duplicateShapes` shallow-copy bug documentation 97 + - `duplicateShapes` with non-existent IDs mixed in 98 + - `duplicateShapes` relative position preservation 99 + - `flipShapes` same-position edge case (no-op) 100 + - `flipShapes` axis isolation (vertical flip preserves x) 101 + - `alignShapes` already-aligned shapes 102 + - `alignShapes` with mixed valid/invalid IDs 103 + - `distributeShapes` overlapping shapes 104 + - `distributeShapes` vertically with varied sizes 105 + - `groupShapes` property preservation 106 + - `ungroupShapes` property preservation 107 + - `getGroupMembers` after ungroup returns empty 108 + - `applyResize` clamping for W, N handles with position correction 109 + - `applyResize` large expansion (no clamp) 110 + - `applyResize` zero delta identity 111 + - `getBoundingBox` single shape, negative coordinates 112 + - `nearestEdgeAnchor` center point, tall/wide rectangles 113 + - `shapesInRect` zero-area rect, touching-edge shapes 114 + - `removeShape` non-existent ID, cascade arrow cleanup 115 + - `removeArrow` non-existent ID 116 + - `pointsToCatmullRomPath` three points, collinear, duplicate points 117 + - `shapeAtPoint` z-order (topmost wins) 118 + - `moveShapes` isolation (unaffected shapes unchanged) 119 + - `setShapeStyle` overwrite and empty update 120 + - `setShapeFontSize` boundary at 8, large values 121 + - `setZoom` clamping at 0.1 and 5, exact boundaries, fractional 122 + - `pan` accumulation, very large values 123 + - `toggleSnap` round-trip 124 + - `addShape` / `addArrow` unique ID generation (100/50 items) 125 + 126 + ### History (`history.ts`) 127 + - Arrow style round-trip through undo/redo 128 + - Free-endpoint arrow preservation through undo/redo 129 + - Comprehensive shape with ALL optional properties (points, style with dasharray, opacity, rotation, fontFamily, fontSize, groupId) 130 + - Branching after multiple consecutive undos 131 + - Clear then push resumes normal operation 132 + 133 + --- 134 + 135 + ## Remaining Deferred Items 136 + 137 + These require browser/DOM/E2E testing and cannot be unit tested: 138 + 139 + 1. **Inline text editing textarea positioning** -- foreignObject overlay alignment with rotated shapes 140 + 2. **Cursor feedback accuracy** -- resize handle cursor changes, rotation grab cursor, eraser crosshair 141 + 3. **Edge scrolling during drag** -- `startEdgeScroll`/`stopEdgeScroll` timing and speed 142 + 4. **Snap guide rendering** -- visual alignment guides during shape drag 143 + 5. **Creation preview shapes** -- ghost shape follows cursor during drag-to-create 144 + 6. **Arrow hover target highlight** -- visual feedback when drawing arrow over potential target 145 + 7. **Context menu positioning** -- off-screen adjustment logic 146 + 8. **Touch pinch-to-zoom** -- two-finger gesture handling 147 + 9. **Tool switching during active drawing** -- line/freehand interruption via keyboard shortcut 148 + 10. **Focus mode** -- toolbar/panel hiding toggle 149 + 11. **Keyboard shortcut conflicts** -- shortcut suppression during input focus and text editing 150 + 12. **Style panel sync on selection change** -- color pickers, dropdowns reflect selected shape 151 + 13. **Yjs sync round-trip** -- loadFromYjs/syncToYjs with encrypted provider 152 + 14. **Highlighter stroke rendering** -- semi-transparent yellow wide stroke appearance 153 + 15. **Grid pattern alignment with pan/zoom** -- grid pattern stays visually consistent
+1518
tests/diagrams-polish.test.ts
··· 1 + /** 2 + * Diagrams Polish Tests (Round 2) 3 + * 4 + * Coverage gaps and edge cases not addressed by prior test passes. 5 + * Focuses on: export dash patterns, arrow marker colors, grouped shape export, 6 + * setShapeRotation normalization, history round-trips for arrow styles, 7 + * resize edge cases, distribute/align with edge shapes, and more. 8 + */ 9 + import { describe, it, expect } from 'vitest'; 10 + import { 11 + createWhiteboard, 12 + addShape, 13 + addArrow, 14 + removeShape, 15 + removeArrow, 16 + moveShape, 17 + resizeShape, 18 + setShapeLabel, 19 + setShapeStyle, 20 + setShapeOpacity, 21 + setShapeFontFamily, 22 + setShapeFontSize, 23 + groupShapes, 24 + ungroupShapes, 25 + getGroupMembers, 26 + rotateShape, 27 + setShapeRotation, 28 + duplicateShapes, 29 + flipShapes, 30 + alignShapes, 31 + distributeShapes, 32 + bringToFront, 33 + sendToBack, 34 + bringForward, 35 + sendBackward, 36 + moveShapes, 37 + removeShapes, 38 + shapesInRect, 39 + shapeAtPoint, 40 + hitTestShape, 41 + hitTestResizeHandle, 42 + applyResize, 43 + getResizeHandles, 44 + getBoundingBox, 45 + nearestEdgeAnchor, 46 + pointsToCatmullRomPath, 47 + snapPoint, 48 + setZoom, 49 + pan, 50 + toggleSnap, 51 + } from '../src/diagrams/whiteboard'; 52 + import type { Shape, Arrow, WhiteboardState } from '../src/diagrams/whiteboard'; 53 + import { exportToSVG } from '../src/diagrams/export'; 54 + import History from '../src/diagrams/history'; 55 + 56 + // --------------------------------------------------------------------------- 57 + // Helpers 58 + // --------------------------------------------------------------------------- 59 + 60 + function noSnap(state: WhiteboardState): WhiteboardState { 61 + return { ...state, snapToGrid: false }; 62 + } 63 + 64 + function makeShape(overrides: Partial<Shape> & { id: string; kind: Shape['kind'] }): Shape { 65 + return { 66 + x: 0, 67 + y: 0, 68 + width: 120, 69 + height: 80, 70 + rotation: 0, 71 + label: '', 72 + style: {}, 73 + opacity: 1, 74 + ...overrides, 75 + }; 76 + } 77 + 78 + function makeArrow(overrides: Partial<Arrow> & { id: string }): Arrow { 79 + return { 80 + from: { x: 0, y: 0 }, 81 + to: { x: 100, y: 100 }, 82 + label: '', 83 + style: {}, 84 + ...overrides, 85 + }; 86 + } 87 + 88 + function stateWith(shapes: Shape[], arrows: Arrow[] = []): WhiteboardState { 89 + const state = createWhiteboard(); 90 + for (const s of shapes) state.shapes.set(s.id, s); 91 + for (const a of arrows) state.arrows.set(a.id, a); 92 + return state; 93 + } 94 + 95 + function setupShapes(positions: Array<{ x: number; y: number; w?: number; h?: number; kind?: Shape['kind'] }>) { 96 + let wb = noSnap(createWhiteboard()); 97 + for (const p of positions) { 98 + wb = addShape(wb, p.kind ?? 'rectangle', p.x, p.y, p.w ?? 50, p.h ?? 50); 99 + } 100 + return { state: wb, ids: [...wb.shapes.keys()] }; 101 + } 102 + 103 + // --------------------------------------------------------------------------- 104 + // Export: stroke-dasharray on shapes 105 + // --------------------------------------------------------------------------- 106 + 107 + describe('exportToSVG stroke-dasharray', () => { 108 + it('does not include stroke-dasharray by default (solid stroke)', () => { 109 + const state = stateWith([ 110 + makeShape({ id: 's1', kind: 'rectangle', x: 0, y: 0 }), 111 + ]); 112 + const svg = exportToSVG(state); 113 + // Should not have any stroke-dasharray attribute for default solid stroke 114 + expect(svg).not.toContain('stroke-dasharray'); 115 + }); 116 + 117 + // NOTE: export.ts currently does NOT render strokeDasharray from shape.style. 118 + // This test documents that gap -- it verifies current behavior. 119 + it('exports strokeDasharray from shape style', () => { 120 + const state = stateWith([ 121 + makeShape({ 122 + id: 's1', kind: 'rectangle', x: 0, y: 0, 123 + style: { strokeDasharray: '8 4' }, 124 + }), 125 + ]); 126 + const svg = exportToSVG(state); 127 + expect(svg).toContain('<rect'); 128 + expect(svg).toContain('stroke-dasharray="8 4"'); 129 + }); 130 + }); 131 + 132 + // --------------------------------------------------------------------------- 133 + // Export: arrow styles 134 + // --------------------------------------------------------------------------- 135 + 136 + describe('exportToSVG arrow styles', () => { 137 + it('renders arrow with custom stroke color', () => { 138 + const state = stateWith( 139 + [], 140 + [makeArrow({ id: 'a1', style: { stroke: '#ff0000', strokeWidth: '3' } })], 141 + ); 142 + const svg = exportToSVG(state); 143 + expect(svg).toContain('stroke="#ff0000"'); 144 + expect(svg).toContain('stroke-width="3"'); 145 + }); 146 + 147 + it('renders arrow with default stroke when no style', () => { 148 + const state = stateWith( 149 + [], 150 + [makeArrow({ id: 'a1', style: {} })], 151 + ); 152 + const svg = exportToSVG(state); 153 + expect(svg).toContain('stroke="#000000"'); 154 + expect(svg).toContain('stroke-width="2"'); 155 + }); 156 + 157 + it('renders arrow label at midpoint', () => { 158 + const state = stateWith( 159 + [], 160 + [makeArrow({ id: 'a1', from: { x: 0, y: 0 }, to: { x: 200, y: 0 }, label: 'connects' })], 161 + ); 162 + const svg = exportToSVG(state); 163 + expect(svg).toContain('connects'); 164 + expect(svg).toContain('x="100"'); // midpoint 165 + expect(svg).toContain('text-anchor="middle"'); 166 + }); 167 + 168 + it('renders arrow with label and style together', () => { 169 + const state = stateWith( 170 + [ 171 + makeShape({ id: 's1', kind: 'rectangle', x: 0, y: 0, width: 100, height: 80 }), 172 + makeShape({ id: 's2', kind: 'rectangle', x: 300, y: 0, width: 100, height: 80 }), 173 + ], 174 + [makeArrow({ 175 + id: 'a1', 176 + from: { shapeId: 's1', anchor: 'right' }, 177 + to: { shapeId: 's2', anchor: 'left' }, 178 + label: 'flow', 179 + style: { stroke: '#0000ff', strokeWidth: '4' }, 180 + })], 181 + ); 182 + const svg = exportToSVG(state); 183 + expect(svg).toContain('flow'); 184 + expect(svg).toContain('stroke="#0000ff"'); 185 + expect(svg).toContain('stroke-width="4"'); 186 + }); 187 + 188 + it('arrowhead marker matches arrow stroke color', () => { 189 + const state = stateWith( 190 + [], 191 + [makeArrow({ id: 'a1', style: { stroke: '#ff0000' } })], 192 + ); 193 + const svg = exportToSVG(state); 194 + // Per-color marker: arrowhead fill matches the arrow's stroke 195 + expect(svg).toContain('fill="#ff0000"'); 196 + expect(svg).toContain('marker-end="url(#arrowhead-ff0000)"'); 197 + }); 198 + }); 199 + 200 + // --------------------------------------------------------------------------- 201 + // Export: freestanding arrows with selectedIds 202 + // --------------------------------------------------------------------------- 203 + 204 + describe('exportToSVG freestanding arrows with selection', () => { 205 + it('excludes freestanding arrows when selectedIds is provided', () => { 206 + const state = stateWith( 207 + [makeShape({ id: 's1', kind: 'rectangle', x: 0, y: 0 })], 208 + [makeArrow({ id: 'a1', from: { x: 0, y: 0 }, to: { x: 100, y: 100 } })], 209 + ); 210 + // Freestanding arrow has no shape connections, so arrowConnectsSelected returns false 211 + const svg = exportToSVG(state, new Set(['s1'])); 212 + expect(svg).not.toContain('<line'); 213 + }); 214 + 215 + it('includes freestanding arrows when no selection filter', () => { 216 + const state = stateWith( 217 + [], 218 + [makeArrow({ id: 'a1', from: { x: 0, y: 0 }, to: { x: 100, y: 100 } })], 219 + ); 220 + const svg = exportToSVG(state); 221 + expect(svg).toContain('<line'); 222 + }); 223 + }); 224 + 225 + // --------------------------------------------------------------------------- 226 + // Export: shapes with rotation and opacity together 227 + // --------------------------------------------------------------------------- 228 + 229 + describe('exportToSVG rotation + opacity combined', () => { 230 + it('renders shape with both rotation and opacity', () => { 231 + const state = stateWith([ 232 + makeShape({ 233 + id: 's1', kind: 'rectangle', x: 0, y: 0, 234 + width: 100, height: 80, rotation: 30, opacity: 0.7, 235 + }), 236 + ]); 237 + const svg = exportToSVG(state); 238 + expect(svg).toContain('rotate(30'); 239 + expect(svg).toContain('opacity="0.7"'); 240 + }); 241 + 242 + it('does not include opacity attribute for fully opaque shapes', () => { 243 + const state = stateWith([ 244 + makeShape({ id: 's1', kind: 'rectangle', x: 0, y: 0, opacity: 1 }), 245 + ]); 246 + const svg = exportToSVG(state); 247 + expect(svg).not.toContain('opacity='); 248 + }); 249 + }); 250 + 251 + // --------------------------------------------------------------------------- 252 + // Export: grouped shapes 253 + // --------------------------------------------------------------------------- 254 + 255 + describe('exportToSVG with grouped shapes', () => { 256 + it('exports grouped shapes normally (groupId does not affect SVG output)', () => { 257 + let wb = noSnap(createWhiteboard()); 258 + wb = addShape(wb, 'rectangle', 0, 0, 50, 50, 'A'); 259 + wb = addShape(wb, 'ellipse', 100, 0, 50, 50, 'B'); 260 + const ids = [...wb.shapes.keys()]; 261 + const { state } = groupShapes(wb, ids); 262 + const svg = exportToSVG(state); 263 + expect(svg).toContain('<rect'); 264 + expect(svg).toContain('<ellipse'); 265 + expect(svg).toContain('A'); 266 + expect(svg).toContain('B'); 267 + }); 268 + 269 + it('exports only selected shapes from a group', () => { 270 + let wb = noSnap(createWhiteboard()); 271 + wb = addShape(wb, 'rectangle', 0, 0, 50, 50, 'ShapeAlpha'); 272 + wb = addShape(wb, 'ellipse', 100, 0, 50, 50, 'ShapeBeta'); 273 + const ids = [...wb.shapes.keys()]; 274 + const { state } = groupShapes(wb, ids); 275 + // Select only the first shape from the group 276 + const svg = exportToSVG(state, new Set([ids[0]])); 277 + expect(svg).toContain('ShapeAlpha'); 278 + expect(svg).not.toContain('ShapeBeta'); 279 + expect(svg).not.toContain('<ellipse'); 280 + }); 281 + }); 282 + 283 + // --------------------------------------------------------------------------- 284 + // Export: note with custom fill 285 + // --------------------------------------------------------------------------- 286 + 287 + describe('exportToSVG note shape fill', () => { 288 + it('uses default yellow fill when shape fill is #ffffff', () => { 289 + const state = stateWith([ 290 + makeShape({ id: 'n1', kind: 'note', x: 0, y: 0, width: 100, height: 100, style: {} }), 291 + ]); 292 + const svg = exportToSVG(state); 293 + expect(svg).toContain('#fef08a'); // default note fill 294 + }); 295 + 296 + it('uses custom fill when specified', () => { 297 + const state = stateWith([ 298 + makeShape({ id: 'n1', kind: 'note', x: 0, y: 0, width: 100, height: 100, style: { fill: '#ff9999' } }), 299 + ]); 300 + const svg = exportToSVG(state); 301 + expect(svg).toContain('#ff9999'); 302 + expect(svg).not.toContain('#fef08a'); 303 + }); 304 + }); 305 + 306 + // --------------------------------------------------------------------------- 307 + // Export: text shape with custom font properties 308 + // --------------------------------------------------------------------------- 309 + 310 + describe('exportToSVG text shape font properties', () => { 311 + it('renders text shape with custom fontFamily and fontSize', () => { 312 + const state = stateWith([ 313 + makeShape({ 314 + id: 't1', kind: 'text', x: 0, y: 0, 315 + label: 'Custom Font', 316 + fontFamily: 'monospace', 317 + fontSize: 24, 318 + }), 319 + ]); 320 + const svg = exportToSVG(state); 321 + expect(svg).toContain('Custom Font'); 322 + expect(svg).toContain('font-family="monospace"'); 323 + expect(svg).toContain('font-size="24"'); 324 + }); 325 + 326 + it('uses default font when shape has no font properties', () => { 327 + const state = stateWith([ 328 + makeShape({ id: 't1', kind: 'text', x: 0, y: 0, label: 'Default' }), 329 + ]); 330 + const svg = exportToSVG(state); 331 + expect(svg).toContain('font-family="system-ui'); 332 + expect(svg).toContain('font-size="14"'); 333 + }); 334 + }); 335 + 336 + // --------------------------------------------------------------------------- 337 + // Export: shape labels with font properties 338 + // --------------------------------------------------------------------------- 339 + 340 + describe('exportToSVG non-text shape label font properties', () => { 341 + it('renders rectangle label with custom font', () => { 342 + const state = stateWith([ 343 + makeShape({ 344 + id: 's1', kind: 'rectangle', x: 0, y: 0, 345 + label: 'Labeled', 346 + fontFamily: 'serif', 347 + fontSize: 20, 348 + }), 349 + ]); 350 + const svg = exportToSVG(state); 351 + expect(svg).toContain('Labeled'); 352 + expect(svg).toContain('font-family="serif"'); 353 + expect(svg).toContain('font-size="20"'); 354 + }); 355 + }); 356 + 357 + // --------------------------------------------------------------------------- 358 + // Export: viewBox computation edge cases 359 + // --------------------------------------------------------------------------- 360 + 361 + describe('exportToSVG viewBox edge cases', () => { 362 + it('handles single point shape (zero dimensions)', () => { 363 + const state = stateWith([ 364 + makeShape({ id: 's1', kind: 'rectangle', x: 100, y: 200, width: 0, height: 0 }), 365 + ]); 366 + const svg = exportToSVG(state); 367 + // Should not crash and should produce valid SVG 368 + expect(svg).toContain('<svg'); 369 + expect(svg).toContain('</svg>'); 370 + const match = svg.match(/viewBox="([^"]*)"/); 371 + expect(match).not.toBeNull(); 372 + }); 373 + 374 + it('handles very large shapes', () => { 375 + const state = stateWith([ 376 + makeShape({ id: 's1', kind: 'rectangle', x: 0, y: 0, width: 10000, height: 10000 }), 377 + ]); 378 + const svg = exportToSVG(state); 379 + const match = svg.match(/viewBox="([^"]*)"/); 380 + expect(match).not.toBeNull(); 381 + const [, , vw, vh] = match![1].split(' ').map(Number); 382 + expect(vw).toBeGreaterThanOrEqual(10000); 383 + expect(vh).toBeGreaterThanOrEqual(10000); 384 + }); 385 + 386 + it('handles negative coordinates', () => { 387 + const state = stateWith([ 388 + makeShape({ id: 's1', kind: 'rectangle', x: -100, y: -200, width: 50, height: 50 }), 389 + ]); 390 + const svg = exportToSVG(state); 391 + const match = svg.match(/viewBox="([^"]*)"/); 392 + expect(match).not.toBeNull(); 393 + const [vx, vy] = match![1].split(' ').map(Number); 394 + expect(vx).toBeLessThan(-50); // Should include padding before -100 395 + expect(vy).toBeLessThan(-150); // Should include padding before -200 396 + }); 397 + 398 + it('freehand points that extend beyond shape bounding box expand the viewBox', () => { 399 + const state = stateWith([ 400 + makeShape({ 401 + id: 's1', kind: 'freehand', x: 50, y: 50, width: 100, height: 100, 402 + points: [ 403 + { x: -20, y: -20 }, // extends to 50-20=30, 50-20=30 404 + { x: 150, y: 150 }, // extends to 50+150=200, 50+150=200 405 + ], 406 + }), 407 + ]); 408 + const svg = exportToSVG(state); 409 + const match = svg.match(/viewBox="([^"]*)"/); 410 + expect(match).not.toBeNull(); 411 + const [vx, vy, vw, vh] = match![1].split(' ').map(Number); 412 + // The bounds should cover from (30,30) to (200,200) 413 + expect(vx).toBeLessThanOrEqual(30); 414 + expect(vy).toBeLessThanOrEqual(30); 415 + expect(vx + vw).toBeGreaterThanOrEqual(200); 416 + expect(vy + vh).toBeGreaterThanOrEqual(200); 417 + }); 418 + }); 419 + 420 + // --------------------------------------------------------------------------- 421 + // Export: XML escaping in labels 422 + // --------------------------------------------------------------------------- 423 + 424 + describe('exportToSVG XML escaping', () => { 425 + it('escapes special characters in shape labels', () => { 426 + const state = stateWith([ 427 + makeShape({ id: 's1', kind: 'rectangle', x: 0, y: 0, label: '<script>alert("xss")</script>' }), 428 + ]); 429 + const svg = exportToSVG(state); 430 + expect(svg).not.toContain('<script>'); 431 + expect(svg).toContain('&lt;script&gt;'); 432 + }); 433 + 434 + it('escapes ampersands in labels', () => { 435 + const state = stateWith([ 436 + makeShape({ id: 's1', kind: 'rectangle', x: 0, y: 0, label: 'A & B' }), 437 + ]); 438 + const svg = exportToSVG(state); 439 + expect(svg).toContain('A &amp; B'); 440 + }); 441 + 442 + it('escapes quotes in labels', () => { 443 + const state = stateWith([ 444 + makeShape({ id: 's1', kind: 'rectangle', x: 0, y: 0, label: 'say "hello"' }), 445 + ]); 446 + const svg = exportToSVG(state); 447 + expect(svg).toContain('&quot;'); 448 + }); 449 + 450 + it('escapes special characters in arrow labels', () => { 451 + const state = stateWith( 452 + [], 453 + [makeArrow({ id: 'a1', from: { x: 0, y: 0 }, to: { x: 100, y: 0 }, label: '<b>bold</b>' })], 454 + ); 455 + const svg = exportToSVG(state); 456 + expect(svg).toContain('&lt;b&gt;bold&lt;/b&gt;'); 457 + }); 458 + }); 459 + 460 + // --------------------------------------------------------------------------- 461 + // Export: all shape kinds produce valid SVG (no empty output) 462 + // --------------------------------------------------------------------------- 463 + 464 + describe('exportToSVG all shape kinds produce output', () => { 465 + const kinds: Array<Shape['kind']> = [ 466 + 'rectangle', 'ellipse', 'diamond', 'text', 'triangle', 'star', 467 + 'hexagon', 'cloud', 'cylinder', 'parallelogram', 'note', 468 + ]; 469 + 470 + for (const kind of kinds) { 471 + it(`renders ${kind} shape`, () => { 472 + const state = stateWith([ 473 + makeShape({ id: 's1', kind, x: 10, y: 20, width: 100, height: 80, label: `${kind} label` }), 474 + ]); 475 + const svg = exportToSVG(state); 476 + expect(svg).toContain('<svg'); 477 + expect(svg).toContain('</svg>'); 478 + // Every shape kind should produce at least one SVG element 479 + expect(svg.length).toBeGreaterThan(100); 480 + // Non-text shapes with labels should show the label 481 + if (kind !== 'text') { 482 + expect(svg).toContain(`${kind} label`); 483 + } 484 + }); 485 + } 486 + 487 + it('renders line shape with points', () => { 488 + const state = stateWith([ 489 + makeShape({ 490 + id: 's1', kind: 'line', x: 0, y: 0, width: 100, height: 80, 491 + points: [{ x: 0, y: 0 }, { x: 50, y: 40 }, { x: 100, y: 80 }], 492 + }), 493 + ]); 494 + const svg = exportToSVG(state); 495 + expect(svg).toContain('<polyline'); 496 + }); 497 + 498 + it('renders freehand shape with points', () => { 499 + const state = stateWith([ 500 + makeShape({ 501 + id: 's1', kind: 'freehand', x: 0, y: 0, width: 100, height: 80, 502 + points: [{ x: 0, y: 0 }, { x: 30, y: 40 }, { x: 60, y: 20 }, { x: 100, y: 80 }], 503 + }), 504 + ]); 505 + const svg = exportToSVG(state); 506 + expect(svg).toContain('<path'); 507 + expect(svg).toContain(' C'); // Catmull-Rom produces C commands 508 + }); 509 + 510 + it('does not render line shape without points', () => { 511 + const state = stateWith([ 512 + makeShape({ id: 's1', kind: 'line', x: 0, y: 0, width: 100, height: 80 }), 513 + ]); 514 + const svg = exportToSVG(state); 515 + expect(svg).not.toContain('<polyline'); 516 + }); 517 + 518 + it('does not render line shape with single point', () => { 519 + const state = stateWith([ 520 + makeShape({ 521 + id: 's1', kind: 'line', x: 0, y: 0, width: 100, height: 80, 522 + points: [{ x: 0, y: 0 }], 523 + }), 524 + ]); 525 + const svg = exportToSVG(state); 526 + expect(svg).not.toContain('<polyline'); 527 + }); 528 + }); 529 + 530 + // --------------------------------------------------------------------------- 531 + // setShapeRotation negative normalization 532 + // --------------------------------------------------------------------------- 533 + 534 + describe('setShapeRotation negative values', () => { 535 + it('normalizes negative values to positive range', () => { 536 + let wb = noSnap(createWhiteboard()); 537 + wb = addShape(wb, 'rectangle', 0, 0); 538 + const id = [...wb.shapes.keys()][0]; 539 + wb = setShapeRotation(wb, id, -90); 540 + // Fixed: normalizes to 0-359 range, consistent with rotateShape 541 + expect(wb.shapes.get(id)!.rotation).toBe(270); 542 + }); 543 + 544 + it('rotateShape correctly normalizes negative angles', () => { 545 + // rotateShape uses ((rotation + angle) % 360 + 360) % 360 546 + let wb = noSnap(createWhiteboard()); 547 + wb = addShape(wb, 'rectangle', 0, 0); 548 + const id = [...wb.shapes.keys()][0]; 549 + wb = rotateShape(wb, id, -90); 550 + expect(wb.shapes.get(id)!.rotation).toBe(270); 551 + }); 552 + 553 + it('setShapeRotation wraps values above 360 correctly', () => { 554 + let wb = noSnap(createWhiteboard()); 555 + wb = addShape(wb, 'rectangle', 0, 0); 556 + const id = [...wb.shapes.keys()][0]; 557 + wb = setShapeRotation(wb, id, 720); 558 + expect(wb.shapes.get(id)!.rotation).toBe(0); 559 + }); 560 + }); 561 + 562 + // --------------------------------------------------------------------------- 563 + // History: arrow style round-trip 564 + // --------------------------------------------------------------------------- 565 + 566 + describe('History arrow style round-trip', () => { 567 + it('preserves arrow styles through undo/redo', () => { 568 + const history = new History(); 569 + let wb = noSnap(createWhiteboard()); 570 + wb = addShape(wb, 'rectangle', 0, 0); 571 + wb = addShape(wb, 'rectangle', 200, 0); 572 + const ids = [...wb.shapes.keys()]; 573 + wb = addArrow(wb, { shapeId: ids[0], anchor: 'right' }, { shapeId: ids[1], anchor: 'left' }, 'connection'); 574 + // Manually set arrow style 575 + const arrowId = [...wb.arrows.keys()][0]; 576 + const arrow = wb.arrows.get(arrowId)!; 577 + const arrows = new Map(wb.arrows); 578 + arrows.set(arrowId, { ...arrow, style: { stroke: '#ff0000', strokeWidth: '4' } }); 579 + wb = { ...wb, arrows }; 580 + 581 + history.push(wb); 582 + const wb2 = addShape(wb, 'diamond', 400, 0); 583 + history.push(wb2); 584 + 585 + const undone = history.undo()!; 586 + const restoredArrow = [...undone.arrows.values()][0]; 587 + expect(restoredArrow.label).toBe('connection'); 588 + expect(restoredArrow.style.stroke).toBe('#ff0000'); 589 + expect(restoredArrow.style.strokeWidth).toBe('4'); 590 + }); 591 + 592 + it('preserves free-endpoint arrows through undo/redo', () => { 593 + const history = new History(); 594 + let wb = noSnap(createWhiteboard()); 595 + wb = addArrow(wb, { x: 10, y: 20 }, { x: 300, y: 400 }, 'free arrow'); 596 + history.push(wb); 597 + wb = addShape(wb, 'rectangle', 0, 0); 598 + history.push(wb); 599 + 600 + const undone = history.undo()!; 601 + expect(undone.arrows.size).toBe(1); 602 + const a = [...undone.arrows.values()][0]; 603 + expect(a.label).toBe('free arrow'); 604 + expect('x' in a.from).toBe(true); 605 + if ('x' in a.from) { 606 + expect(a.from.x).toBe(10); 607 + expect(a.from.y).toBe(20); 608 + } 609 + }); 610 + }); 611 + 612 + // --------------------------------------------------------------------------- 613 + // History: shape with all optional properties 614 + // --------------------------------------------------------------------------- 615 + 616 + describe('History round-trip comprehensive', () => { 617 + it('preserves shape with all properties through undo', () => { 618 + const history = new History(); 619 + let wb = noSnap(createWhiteboard()); 620 + wb = addShape(wb, 'freehand', 100, 200, 50, 50, 'drawn'); 621 + const id = [...wb.shapes.keys()][0]; 622 + const shapes = new Map(wb.shapes); 623 + shapes.set(id, { 624 + ...shapes.get(id)!, 625 + points: [{ x: 0, y: 0 }, { x: 25, y: 25 }, { x: 50, y: 0 }], 626 + style: { fill: '#aabbcc', stroke: '#112233', strokeWidth: '3', strokeDasharray: '8 4' }, 627 + opacity: 0.65, 628 + rotation: 45, 629 + fontFamily: 'monospace', 630 + fontSize: 18, 631 + groupId: 'group-test-123', 632 + }); 633 + wb = { ...wb, shapes }; 634 + history.push(wb); 635 + 636 + wb = addShape(wb, 'rectangle', 500, 500); 637 + history.push(wb); 638 + 639 + const undone = history.undo()!; 640 + const restored = undone.shapes.get(id)!; 641 + expect(restored.kind).toBe('freehand'); 642 + expect(restored.label).toBe('drawn'); 643 + expect(restored.points).toEqual([{ x: 0, y: 0 }, { x: 25, y: 25 }, { x: 50, y: 0 }]); 644 + expect(restored.style).toEqual({ fill: '#aabbcc', stroke: '#112233', strokeWidth: '3', strokeDasharray: '8 4' }); 645 + expect(restored.opacity).toBe(0.65); 646 + expect(restored.rotation).toBe(45); 647 + expect(restored.fontFamily).toBe('monospace'); 648 + expect(restored.fontSize).toBe(18); 649 + expect(restored.groupId).toBe('group-test-123'); 650 + }); 651 + }); 652 + 653 + // --------------------------------------------------------------------------- 654 + // History: branching after multiple undos 655 + // --------------------------------------------------------------------------- 656 + 657 + describe('History branching edge cases', () => { 658 + it('push after multiple undos truncates entire redo stack', () => { 659 + const history = new History(); 660 + const wb = noSnap(createWhiteboard()); 661 + const s1 = addShape(wb, 'rectangle', 0, 0); 662 + const s2 = addShape(s1, 'ellipse', 100, 0); 663 + const s3 = addShape(s2, 'diamond', 200, 0); 664 + const s4 = addShape(s3, 'triangle', 300, 0); 665 + history.push(s1); 666 + history.push(s2); 667 + history.push(s3); 668 + history.push(s4); 669 + 670 + // Undo 3 times 671 + history.undo(); 672 + history.undo(); 673 + history.undo(); 674 + expect(history.canRedo()).toBe(true); 675 + 676 + // Push new state -- should discard s2, s3, s4 from redo 677 + const branch = addShape(s1, 'star', 50, 50); 678 + history.push(branch); 679 + expect(history.canRedo()).toBe(false); 680 + expect(history.canUndo()).toBe(true); 681 + 682 + // Undo back to s1 683 + const prev = history.undo()!; 684 + expect(prev.shapes.size).toBe(1); 685 + }); 686 + }); 687 + 688 + // --------------------------------------------------------------------------- 689 + // Resize: all handle directions with position clamping 690 + // --------------------------------------------------------------------------- 691 + 692 + describe('applyResize clamping edge cases', () => { 693 + const base = { x: 100, y: 100, width: 80, height: 60 }; 694 + 695 + it('clamps W handle correctly adjusting position', () => { 696 + // Drag W handle far to the right (shrinking) 697 + const result = applyResize(base, 'w', 200, 0); 698 + expect(result.width).toBe(10); // clamped to minimum 699 + expect(result.x).toBe(170); // x adjusted: 100 + 200 would be 300, but clamped 700 + }); 701 + 702 + it('clamps N handle correctly adjusting position', () => { 703 + // Drag N handle far down (shrinking) 704 + const result = applyResize(base, 'n', 0, 200); 705 + expect(result.height).toBe(10); // clamped 706 + expect(result.y).toBe(150); // y adjusted 707 + }); 708 + 709 + it('expanding SE does not clamp', () => { 710 + const result = applyResize(base, 'se', 500, 500); 711 + expect(result.width).toBe(580); 712 + expect(result.height).toBe(560); 713 + expect(result.x).toBe(100); 714 + expect(result.y).toBe(100); 715 + }); 716 + 717 + it('zero delta returns same dimensions', () => { 718 + const result = applyResize(base, 'se', 0, 0); 719 + expect(result).toEqual(base); 720 + }); 721 + 722 + it('NE handle with large negative dx clamps width', () => { 723 + const result = applyResize(base, 'ne', -200, 0); 724 + expect(result.width).toBe(10); 725 + }); 726 + 727 + it('SW handle with large positive dy shrinks height to minimum', () => { 728 + const result = applyResize(base, 'sw', 0, -200); 729 + expect(result.height).toBe(10); 730 + }); 731 + }); 732 + 733 + // --------------------------------------------------------------------------- 734 + // Flip: edge cases 735 + // --------------------------------------------------------------------------- 736 + 737 + describe('flipShapes edge cases', () => { 738 + it('flip horizontal with shapes at same x position (no-op positions)', () => { 739 + const { state, ids } = setupShapes([ 740 + { x: 50, y: 0, w: 50 }, 741 + { x: 50, y: 100, w: 50 }, 742 + ]); 743 + const updated = flipShapes(state, ids, 'horizontal'); 744 + // Same x and width -- they should stay at x=50 745 + expect(updated.shapes.get(ids[0])!.x).toBe(50); 746 + expect(updated.shapes.get(ids[1])!.x).toBe(50); 747 + }); 748 + 749 + it('flip vertical preserves x coordinates', () => { 750 + const { state, ids } = setupShapes([ 751 + { x: 10, y: 0, w: 30, h: 20 }, 752 + { x: 100, y: 80, w: 40, h: 20 }, 753 + ]); 754 + const updated = flipShapes(state, ids, 'vertical'); 755 + // X should be unchanged 756 + expect(updated.shapes.get(ids[0])!.x).toBe(10); 757 + expect(updated.shapes.get(ids[1])!.x).toBe(100); 758 + // Y should be mirrored 759 + expect(updated.shapes.get(ids[0])!.y).toBe(80); 760 + expect(updated.shapes.get(ids[1])!.y).toBe(0); 761 + }); 762 + }); 763 + 764 + // --------------------------------------------------------------------------- 765 + // Align: edge cases 766 + // --------------------------------------------------------------------------- 767 + 768 + describe('alignShapes edge cases', () => { 769 + it('align left with shapes already aligned does not change state', () => { 770 + const { state, ids } = setupShapes([ 771 + { x: 50, y: 0 }, 772 + { x: 50, y: 100 }, 773 + { x: 50, y: 200 }, 774 + ]); 775 + const updated = alignShapes(state, ids, 'left'); 776 + for (const id of ids) { 777 + expect(updated.shapes.get(id)!.x).toBe(50); 778 + } 779 + }); 780 + 781 + it('align with mixed valid and invalid IDs', () => { 782 + const { state, ids } = setupShapes([ 783 + { x: 0, y: 0 }, 784 + { x: 100, y: 0 }, 785 + ]); 786 + // Include a fake ID -- only the 2 valid shapes should be aligned 787 + const updated = alignShapes(state, [...ids, 'fake-id'], 'left'); 788 + expect(updated.shapes.get(ids[0])!.x).toBe(0); 789 + expect(updated.shapes.get(ids[1])!.x).toBe(0); 790 + }); 791 + }); 792 + 793 + // --------------------------------------------------------------------------- 794 + // Distribute: edge cases 795 + // --------------------------------------------------------------------------- 796 + 797 + describe('distributeShapes edge cases', () => { 798 + it('distributes shapes that are overlapping', () => { 799 + const { state, ids } = setupShapes([ 800 + { x: 0, y: 0, w: 50 }, 801 + { x: 10, y: 0, w: 50 }, 802 + { x: 20, y: 0, w: 50 }, 803 + ]); 804 + // Total span: 20+50 - 0 = 70. Total width: 150. Gaps: (70-150)/2 = -40 (negative gap = overlap) 805 + const updated = distributeShapes(state, ids, 'horizontal'); 806 + // Shapes should be sorted by x and evenly distributed within the span 807 + const sorted = ids.slice().sort((a, b) => 808 + state.shapes.get(a)!.x - state.shapes.get(b)!.x, 809 + ); 810 + const positions = sorted.map(id => updated.shapes.get(id)!.x); 811 + // First and last maintain span, middle gets averaged 812 + expect(positions[0]).toBe(0); 813 + }); 814 + 815 + it('distributes vertically with shapes of very different sizes', () => { 816 + const { state, ids } = setupShapes([ 817 + { x: 0, y: 0, h: 10 }, 818 + { x: 0, y: 50, h: 100 }, 819 + { x: 0, y: 200, h: 20 }, 820 + ]); 821 + // Total span: 200+20 - 0 = 220. Heights: 10+100+20=130. Gap: (220-130)/2 = 45 822 + const updated = distributeShapes(state, ids, 'vertical'); 823 + expect(updated.shapes.get(ids[0])!.y).toBe(0); // first stays 824 + expect(updated.shapes.get(ids[1])!.y).toBe(55); // 0+10+45 825 + expect(updated.shapes.get(ids[2])!.y).toBe(200); // 55+100+45 826 + }); 827 + }); 828 + 829 + // --------------------------------------------------------------------------- 830 + // Group operations 831 + // --------------------------------------------------------------------------- 832 + 833 + describe('groupShapes additional tests', () => { 834 + it('group preserves shape properties', () => { 835 + let wb = noSnap(createWhiteboard()); 836 + wb = addShape(wb, 'rectangle', 0, 0, 100, 80, 'A'); 837 + wb = addShape(wb, 'ellipse', 200, 0, 60, 60, 'B'); 838 + const ids = [...wb.shapes.keys()]; 839 + wb = setShapeStyle(wb, [ids[0]], { fill: '#ff0000' }); 840 + wb = setShapeOpacity(wb, [ids[1]], 0.5); 841 + 842 + const { state, groupId } = groupShapes(wb, ids); 843 + expect(state.shapes.get(ids[0])!.style.fill).toBe('#ff0000'); 844 + expect(state.shapes.get(ids[0])!.label).toBe('A'); 845 + expect(state.shapes.get(ids[1])!.opacity).toBe(0.5); 846 + expect(state.shapes.get(ids[1])!.label).toBe('B'); 847 + expect(state.shapes.get(ids[0])!.groupId).toBe(groupId); 848 + expect(state.shapes.get(ids[1])!.groupId).toBe(groupId); 849 + }); 850 + 851 + it('ungroup preserves shape properties', () => { 852 + let wb = noSnap(createWhiteboard()); 853 + wb = addShape(wb, 'rectangle', 0, 0, 100, 80, 'A'); 854 + wb = addShape(wb, 'ellipse', 200, 0, 60, 60, 'B'); 855 + const ids = [...wb.shapes.keys()]; 856 + const { state, groupId } = groupShapes(wb, ids); 857 + const ungrouped = ungroupShapes(state, groupId); 858 + expect(ungrouped.shapes.get(ids[0])!.label).toBe('A'); 859 + expect(ungrouped.shapes.get(ids[1])!.label).toBe('B'); 860 + expect(ungrouped.shapes.get(ids[0])!.groupId).toBeUndefined(); 861 + expect(ungrouped.shapes.get(ids[1])!.groupId).toBeUndefined(); 862 + }); 863 + 864 + it('getGroupMembers returns empty for already-ungrouped group', () => { 865 + let wb = noSnap(createWhiteboard()); 866 + wb = addShape(wb, 'rectangle', 0, 0); 867 + wb = addShape(wb, 'ellipse', 100, 0); 868 + const ids = [...wb.shapes.keys()]; 869 + const { state, groupId } = groupShapes(wb, ids); 870 + const ungrouped = ungroupShapes(state, groupId); 871 + expect(getGroupMembers(ungrouped, groupId)).toEqual([]); 872 + }); 873 + }); 874 + 875 + // --------------------------------------------------------------------------- 876 + // Duplicate: additional edge cases 877 + // --------------------------------------------------------------------------- 878 + 879 + describe('duplicateShapes additional tests', () => { 880 + it('duplicated shapes have independent style objects (deep copy)', () => { 881 + // duplicateShapes deep-copies style and points so mutations are isolated. 882 + let wb = noSnap(createWhiteboard()); 883 + wb = addShape(wb, 'rectangle', 0, 0, 100, 80); 884 + const id = [...wb.shapes.keys()][0]; 885 + wb = setShapeStyle(wb, [id], { fill: '#ff0000' }); 886 + 887 + const { state, idMap } = duplicateShapes(wb, [id]); 888 + const newId = idMap.get(id)!; 889 + const original = state.shapes.get(id)!; 890 + const copy = state.shapes.get(newId)!; 891 + 892 + // Verify the copy got the right initial value 893 + expect(copy.style.fill).toBe('#ff0000'); 894 + // Fixed: deep copy means they are separate objects 895 + expect(original.style).not.toBe(copy.style); 896 + }); 897 + 898 + it('duplicate with non-existent IDs in input is handled gracefully', () => { 899 + let wb = noSnap(createWhiteboard()); 900 + wb = addShape(wb, 'rectangle', 0, 0); 901 + const id = [...wb.shapes.keys()][0]; 902 + const { state, idMap } = duplicateShapes(wb, [id, 'fake-id-1', 'fake-id-2']); 903 + expect(state.shapes.size).toBe(2); // original + 1 duplicate 904 + expect(idMap.size).toBe(1); 905 + }); 906 + 907 + it('duplicates multiple shapes preserving relative positions', () => { 908 + let wb = noSnap(createWhiteboard()); 909 + wb = addShape(wb, 'rectangle', 100, 200, 50, 50); 910 + wb = addShape(wb, 'ellipse', 300, 400, 50, 50); 911 + const ids = [...wb.shapes.keys()]; 912 + const { state, idMap } = duplicateShapes(wb, ids, 30, 30); 913 + 914 + const orig0 = state.shapes.get(ids[0])!; 915 + const orig1 = state.shapes.get(ids[1])!; 916 + const new0 = state.shapes.get(idMap.get(ids[0])!)!; 917 + const new1 = state.shapes.get(idMap.get(ids[1])!)!; 918 + 919 + expect(new0.x - orig0.x).toBe(30); 920 + expect(new0.y - orig0.y).toBe(30); 921 + expect(new1.x - orig1.x).toBe(30); 922 + expect(new1.y - orig1.y).toBe(30); 923 + }); 924 + }); 925 + 926 + // --------------------------------------------------------------------------- 927 + // Z-order: comprehensive 928 + // --------------------------------------------------------------------------- 929 + 930 + describe('z-order comprehensive', () => { 931 + it('bringForward on middle shape', () => { 932 + const { state, ids } = setupShapes([ 933 + { x: 0, y: 0 }, { x: 100, y: 0 }, { x: 200, y: 0 }, 934 + ]); 935 + const updated = bringForward(state, ids[1]); 936 + const order = [...updated.shapes.keys()]; 937 + expect(order).toEqual([ids[0], ids[2], ids[1]]); 938 + }); 939 + 940 + it('sendBackward on middle shape', () => { 941 + const { state, ids } = setupShapes([ 942 + { x: 0, y: 0 }, { x: 100, y: 0 }, { x: 200, y: 0 }, 943 + ]); 944 + const updated = sendBackward(state, ids[1]); 945 + const order = [...updated.shapes.keys()]; 946 + expect(order).toEqual([ids[1], ids[0], ids[2]]); 947 + }); 948 + 949 + it('bringToFront then sendToBack reverses back', () => { 950 + const { state, ids } = setupShapes([ 951 + { x: 0, y: 0 }, { x: 100, y: 0 }, { x: 200, y: 0 }, 952 + ]); 953 + const front = bringToFront(state, [ids[0]]); 954 + const back = sendToBack(front, [ids[0]]); 955 + const order = [...back.shapes.keys()]; 956 + expect(order[0]).toBe(ids[0]); 957 + }); 958 + }); 959 + 960 + // --------------------------------------------------------------------------- 961 + // Zoom limits 962 + // --------------------------------------------------------------------------- 963 + 964 + describe('setZoom limits', () => { 965 + it('clamps zoom at lower bound 0.1', () => { 966 + const wb = createWhiteboard(); 967 + expect(setZoom(wb, 0).zoom).toBe(0.1); 968 + expect(setZoom(wb, -5).zoom).toBe(0.1); 969 + }); 970 + 971 + it('clamps zoom at upper bound 5', () => { 972 + const wb = createWhiteboard(); 973 + expect(setZoom(wb, 10).zoom).toBe(5); 974 + expect(setZoom(wb, 100).zoom).toBe(5); 975 + }); 976 + 977 + it('allows zoom at exact boundaries', () => { 978 + const wb = createWhiteboard(); 979 + expect(setZoom(wb, 0.1).zoom).toBe(0.1); 980 + expect(setZoom(wb, 5).zoom).toBe(5); 981 + }); 982 + 983 + it('allows fractional zoom values', () => { 984 + const wb = createWhiteboard(); 985 + expect(setZoom(wb, 0.75).zoom).toBe(0.75); 986 + expect(setZoom(wb, 1.5).zoom).toBe(1.5); 987 + }); 988 + }); 989 + 990 + // --------------------------------------------------------------------------- 991 + // Pan accumulation 992 + // --------------------------------------------------------------------------- 993 + 994 + describe('pan accumulation', () => { 995 + it('accumulates multiple pans correctly', () => { 996 + let wb = createWhiteboard(); 997 + wb = pan(wb, 100, 200); 998 + wb = pan(wb, -50, 30); 999 + wb = pan(wb, 0, -100); 1000 + expect(wb.panX).toBe(50); 1001 + expect(wb.panY).toBe(130); 1002 + }); 1003 + 1004 + it('handles very large pan values', () => { 1005 + let wb = createWhiteboard(); 1006 + wb = pan(wb, 99999, -99999); 1007 + expect(wb.panX).toBe(99999); 1008 + expect(wb.panY).toBe(-99999); 1009 + }); 1010 + }); 1011 + 1012 + // --------------------------------------------------------------------------- 1013 + // getBoundingBox edge cases 1014 + // --------------------------------------------------------------------------- 1015 + 1016 + describe('getBoundingBox edge cases', () => { 1017 + it('handles single shape', () => { 1018 + let wb = noSnap(createWhiteboard()); 1019 + wb = addShape(wb, 'rectangle', 50, 100, 200, 150); 1020 + const bb = getBoundingBox(wb)!; 1021 + expect(bb.x).toBe(50); 1022 + expect(bb.y).toBe(100); 1023 + expect(bb.width).toBe(200); 1024 + expect(bb.height).toBe(150); 1025 + }); 1026 + 1027 + it('handles shapes with negative coordinates', () => { 1028 + let wb = noSnap(createWhiteboard()); 1029 + wb = addShape(wb, 'rectangle', -100, -200, 50, 50); 1030 + wb = addShape(wb, 'rectangle', 100, 200, 50, 50); 1031 + const bb = getBoundingBox(wb)!; 1032 + expect(bb.x).toBe(-100); 1033 + expect(bb.y).toBe(-200); 1034 + expect(bb.width).toBe(250); 1035 + expect(bb.height).toBe(450); 1036 + }); 1037 + }); 1038 + 1039 + // --------------------------------------------------------------------------- 1040 + // nearestEdgeAnchor: additional cases 1041 + // --------------------------------------------------------------------------- 1042 + 1043 + describe('nearestEdgeAnchor edge cases', () => { 1044 + it('returns correct anchor for point exactly at center', () => { 1045 + const shape = makeShape({ id: 's1', kind: 'rectangle', x: 0, y: 0, width: 100, height: 100 }); 1046 + // Point at center -- atan2(0, 0) = 0, so absAngle = 0 < aspectThreshold 1047 + const result = nearestEdgeAnchor(shape, 50, 50); 1048 + // With dx=0, dy=0 angle is 0, absAngle is 0, which is < aspectThreshold (PI/4) 1049 + expect(result.anchor).toBe('right'); 1050 + }); 1051 + 1052 + it('handles tall rectangle (height > width)', () => { 1053 + const shape = makeShape({ id: 's1', kind: 'rectangle', x: 0, y: 0, width: 40, height: 200 }); 1054 + // Point to the right -- should still detect right 1055 + const result = nearestEdgeAnchor(shape, 100, 100); 1056 + expect(result.anchor).toBe('right'); 1057 + }); 1058 + 1059 + it('handles wide rectangle (width > height)', () => { 1060 + const shape = makeShape({ id: 's1', kind: 'rectangle', x: 0, y: 0, width: 200, height: 40 }); 1061 + // Point above -- should detect top 1062 + const result = nearestEdgeAnchor(shape, 100, -50); 1063 + expect(result.anchor).toBe('top'); 1064 + }); 1065 + }); 1066 + 1067 + // --------------------------------------------------------------------------- 1068 + // shapesInRect: edge cases 1069 + // --------------------------------------------------------------------------- 1070 + 1071 + describe('shapesInRect edge cases', () => { 1072 + it('handles zero-area selection rect', () => { 1073 + let wb = noSnap(createWhiteboard()); 1074 + wb = addShape(wb, 'rectangle', 0, 0, 100, 100); 1075 + // Zero-area rect at a point inside the shape 1076 + const ids = shapesInRect(wb, { x: 50, y: 50, width: 0, height: 0 }); 1077 + expect(ids).toHaveLength(1); 1078 + }); 1079 + 1080 + it('handles shapes touching at edges', () => { 1081 + let wb = noSnap(createWhiteboard()); 1082 + wb = addShape(wb, 'rectangle', 0, 0, 100, 100); 1083 + wb = addShape(wb, 'rectangle', 100, 0, 100, 100); // touching at x=100 1084 + // Select rect that just touches the boundary 1085 + const ids = shapesInRect(wb, { x: 100, y: 0, width: 0, height: 100 }); 1086 + expect(ids).toHaveLength(2); // Both shapes touch at x=100 1087 + }); 1088 + }); 1089 + 1090 + // --------------------------------------------------------------------------- 1091 + // removeShape: edge cases 1092 + // --------------------------------------------------------------------------- 1093 + 1094 + describe('removeShape edge cases', () => { 1095 + it('removing non-existent shape returns unchanged state', () => { 1096 + let wb = noSnap(createWhiteboard()); 1097 + wb = addShape(wb, 'rectangle', 0, 0); 1098 + const before = wb.shapes.size; 1099 + const updated = removeShape(wb, 'non-existent-id'); 1100 + expect(updated.shapes.size).toBe(before); 1101 + }); 1102 + 1103 + it('removing a shape cleans up arrows pointing to it', () => { 1104 + let wb = noSnap(createWhiteboard()); 1105 + wb = addShape(wb, 'rectangle', 0, 0); 1106 + wb = addShape(wb, 'rectangle', 200, 0); 1107 + wb = addShape(wb, 'rectangle', 400, 0); 1108 + const [id1, id2, id3] = [...wb.shapes.keys()]; 1109 + // Arrow from id1->id2 and id2->id3 1110 + wb = addArrow(wb, { shapeId: id1, anchor: 'right' }, { shapeId: id2, anchor: 'left' }); 1111 + wb = addArrow(wb, { shapeId: id2, anchor: 'right' }, { shapeId: id3, anchor: 'left' }); 1112 + expect(wb.arrows.size).toBe(2); 1113 + 1114 + // Remove the middle shape 1115 + const updated = removeShape(wb, id2); 1116 + expect(updated.shapes.size).toBe(2); 1117 + expect(updated.arrows.size).toBe(0); // Both arrows should be removed 1118 + }); 1119 + }); 1120 + 1121 + // --------------------------------------------------------------------------- 1122 + // Catmull-Rom path: edge cases 1123 + // --------------------------------------------------------------------------- 1124 + 1125 + describe('pointsToCatmullRomPath edge cases', () => { 1126 + it('handles three points (minimum for curve)', () => { 1127 + const points = [{ x: 0, y: 0 }, { x: 50, y: 50 }, { x: 100, y: 0 }]; 1128 + const path = pointsToCatmullRomPath(points); 1129 + expect(path).toMatch(/^M0,0/); 1130 + expect(path).toContain('C'); 1131 + expect(path).toContain('100,0'); 1132 + }); 1133 + 1134 + it('handles collinear points', () => { 1135 + const points = [ 1136 + { x: 0, y: 0 }, 1137 + { x: 50, y: 50 }, 1138 + { x: 100, y: 100 }, 1139 + { x: 150, y: 150 }, 1140 + ]; 1141 + const path = pointsToCatmullRomPath(points); 1142 + expect(path).toMatch(/^M0,0/); 1143 + expect(path).toContain('C'); 1144 + // Should not produce NaN or Infinity 1145 + expect(path).not.toContain('NaN'); 1146 + expect(path).not.toContain('Infinity'); 1147 + }); 1148 + 1149 + it('handles duplicate points', () => { 1150 + const points = [ 1151 + { x: 50, y: 50 }, 1152 + { x: 50, y: 50 }, 1153 + { x: 50, y: 50 }, 1154 + ]; 1155 + const path = pointsToCatmullRomPath(points); 1156 + expect(path).toMatch(/^M50,50/); 1157 + expect(path).not.toContain('NaN'); 1158 + }); 1159 + }); 1160 + 1161 + // --------------------------------------------------------------------------- 1162 + // shapeAtPoint: z-order (topmost wins) 1163 + // --------------------------------------------------------------------------- 1164 + 1165 + describe('shapeAtPoint z-order', () => { 1166 + it('returns the topmost (last-added) shape when overlapping', () => { 1167 + let wb = noSnap(createWhiteboard()); 1168 + wb = addShape(wb, 'rectangle', 0, 0, 100, 100); 1169 + wb = addShape(wb, 'ellipse', 0, 0, 100, 100); 1170 + const ids = [...wb.shapes.keys()]; 1171 + const hit = shapeAtPoint(wb, 50, 50); 1172 + expect(hit).not.toBeNull(); 1173 + expect(hit!.id).toBe(ids[1]); // second shape is topmost 1174 + }); 1175 + }); 1176 + 1177 + // --------------------------------------------------------------------------- 1178 + // moveShapes: does not affect unrelated shapes 1179 + // --------------------------------------------------------------------------- 1180 + 1181 + describe('moveShapes isolation', () => { 1182 + it('only moves specified shapes, leaving others unchanged', () => { 1183 + const { state, ids } = setupShapes([ 1184 + { x: 0, y: 0 }, { x: 100, y: 100 }, { x: 200, y: 200 }, 1185 + ]); 1186 + const updated = moveShapes(state, [ids[0], ids[2]], 50, 50); 1187 + expect(updated.shapes.get(ids[0])!.x).toBe(50); 1188 + expect(updated.shapes.get(ids[0])!.y).toBe(50); 1189 + expect(updated.shapes.get(ids[1])!.x).toBe(100); // unchanged 1190 + expect(updated.shapes.get(ids[1])!.y).toBe(100); 1191 + expect(updated.shapes.get(ids[2])!.x).toBe(250); 1192 + expect(updated.shapes.get(ids[2])!.y).toBe(250); 1193 + }); 1194 + }); 1195 + 1196 + // --------------------------------------------------------------------------- 1197 + // Style: edge cases 1198 + // --------------------------------------------------------------------------- 1199 + 1200 + describe('setShapeStyle edge cases', () => { 1201 + it('overwrites existing style properties', () => { 1202 + let wb = noSnap(createWhiteboard()); 1203 + wb = addShape(wb, 'rectangle', 0, 0); 1204 + const id = [...wb.shapes.keys()][0]; 1205 + wb = setShapeStyle(wb, [id], { fill: '#ff0000', stroke: '#00ff00' }); 1206 + wb = setShapeStyle(wb, [id], { fill: '#0000ff' }); 1207 + const shape = wb.shapes.get(id)!; 1208 + expect(shape.style.fill).toBe('#0000ff'); 1209 + expect(shape.style.stroke).toBe('#00ff00'); // preserved 1210 + }); 1211 + 1212 + it('handles empty style update', () => { 1213 + let wb = noSnap(createWhiteboard()); 1214 + wb = addShape(wb, 'rectangle', 0, 0); 1215 + const id = [...wb.shapes.keys()][0]; 1216 + wb = setShapeStyle(wb, [id], { fill: '#ff0000' }); 1217 + wb = setShapeStyle(wb, [id], {}); 1218 + expect(wb.shapes.get(id)!.style.fill).toBe('#ff0000'); // unchanged 1219 + }); 1220 + }); 1221 + 1222 + // --------------------------------------------------------------------------- 1223 + // Font size: boundary 1224 + // --------------------------------------------------------------------------- 1225 + 1226 + describe('setShapeFontSize boundary', () => { 1227 + it('clamps to minimum 8 at boundary', () => { 1228 + let wb = noSnap(createWhiteboard()); 1229 + wb = addShape(wb, 'text', 0, 0); 1230 + const id = [...wb.shapes.keys()][0]; 1231 + wb = setShapeFontSize(wb, [id], 8); 1232 + expect(wb.shapes.get(id)!.fontSize).toBe(8); 1233 + wb = setShapeFontSize(wb, [id], 7); 1234 + expect(wb.shapes.get(id)!.fontSize).toBe(8); 1235 + }); 1236 + 1237 + it('allows large font sizes', () => { 1238 + let wb = noSnap(createWhiteboard()); 1239 + wb = addShape(wb, 'text', 0, 0); 1240 + const id = [...wb.shapes.keys()][0]; 1241 + wb = setShapeFontSize(wb, [id], 200); 1242 + expect(wb.shapes.get(id)!.fontSize).toBe(200); 1243 + }); 1244 + }); 1245 + 1246 + // --------------------------------------------------------------------------- 1247 + // Export: empty arrow collection (no marker def) 1248 + // --------------------------------------------------------------------------- 1249 + 1250 + describe('exportToSVG marker defs', () => { 1251 + it('does not include marker def when there are no arrows', () => { 1252 + const state = stateWith([ 1253 + makeShape({ id: 's1', kind: 'rectangle', x: 0, y: 0 }), 1254 + ]); 1255 + const svg = exportToSVG(state); 1256 + expect(svg).not.toContain('<defs>'); 1257 + expect(svg).not.toContain('<marker'); 1258 + }); 1259 + 1260 + it('includes marker def when there are arrows', () => { 1261 + const state = stateWith( 1262 + [makeShape({ id: 's1', kind: 'rectangle', x: 0, y: 0 })], 1263 + [makeArrow({ id: 'a1' })], 1264 + ); 1265 + const svg = exportToSVG(state); 1266 + expect(svg).toContain('<defs>'); 1267 + expect(svg).toContain('<marker'); 1268 + expect(svg).toMatch(/id="arrowhead-/); 1269 + }); 1270 + }); 1271 + 1272 + // --------------------------------------------------------------------------- 1273 + // Export: arrow with unresolvable endpoint 1274 + // --------------------------------------------------------------------------- 1275 + 1276 + describe('exportToSVG arrow with missing shape', () => { 1277 + it('skips arrow when from-endpoint shape does not exist', () => { 1278 + const state = stateWith( 1279 + [makeShape({ id: 's1', kind: 'rectangle', x: 0, y: 0 })], 1280 + [makeArrow({ 1281 + id: 'a1', 1282 + from: { shapeId: 'non-existent', anchor: 'right' }, 1283 + to: { shapeId: 's1', anchor: 'left' }, 1284 + })], 1285 + ); 1286 + const svg = exportToSVG(state); 1287 + // Arrow should not render since from endpoint can't resolve 1288 + expect(svg).not.toContain('x1='); 1289 + }); 1290 + 1291 + it('skips arrow when to-endpoint shape does not exist', () => { 1292 + const state = stateWith( 1293 + [makeShape({ id: 's1', kind: 'rectangle', x: 0, y: 0 })], 1294 + [makeArrow({ 1295 + id: 'a1', 1296 + from: { shapeId: 's1', anchor: 'right' }, 1297 + to: { shapeId: 'non-existent', anchor: 'left' }, 1298 + })], 1299 + ); 1300 + const svg = exportToSVG(state); 1301 + expect(svg).not.toContain('x1='); 1302 + }); 1303 + }); 1304 + 1305 + // --------------------------------------------------------------------------- 1306 + // Export: resolveEndpoint with all anchors 1307 + // --------------------------------------------------------------------------- 1308 + 1309 + describe('exportToSVG resolveEndpoint anchors', () => { 1310 + it('resolves center anchor', () => { 1311 + const s = makeShape({ id: 's1', kind: 'rectangle', x: 100, y: 200, width: 80, height: 60 }); 1312 + const state = stateWith( 1313 + [s], 1314 + [makeArrow({ 1315 + id: 'a1', 1316 + from: { shapeId: 's1', anchor: 'center' }, 1317 + to: { x: 500, y: 500 }, 1318 + })], 1319 + ); 1320 + const svg = exportToSVG(state); 1321 + // Center is (100+40, 200+30) = (140, 230) 1322 + expect(svg).toContain('x1="140"'); 1323 + expect(svg).toContain('y1="230"'); 1324 + }); 1325 + 1326 + it('resolves all four edge anchors correctly', () => { 1327 + const s = makeShape({ id: 's1', kind: 'rectangle', x: 0, y: 0, width: 200, height: 100 }); 1328 + // Top anchor: (100, 0) 1329 + const stateTop = stateWith([s], [makeArrow({ id: 'a1', from: { shapeId: 's1', anchor: 'top' }, to: { x: 500, y: 500 } })]); 1330 + expect(exportToSVG(stateTop)).toContain('x1="100"'); 1331 + expect(exportToSVG(stateTop)).toContain('y1="0"'); 1332 + 1333 + // Bottom anchor: (100, 100) 1334 + const stateBot = stateWith([s], [makeArrow({ id: 'a1', from: { shapeId: 's1', anchor: 'bottom' }, to: { x: 500, y: 500 } })]); 1335 + expect(exportToSVG(stateBot)).toContain('x1="100"'); 1336 + expect(exportToSVG(stateBot)).toContain('y1="100"'); 1337 + 1338 + // Left anchor: (0, 50) 1339 + const stateLeft = stateWith([s], [makeArrow({ id: 'a1', from: { shapeId: 's1', anchor: 'left' }, to: { x: 500, y: 500 } })]); 1340 + expect(exportToSVG(stateLeft)).toContain('x1="0"'); 1341 + expect(exportToSVG(stateLeft)).toContain('y1="50"'); 1342 + 1343 + // Right anchor: (200, 50) 1344 + const stateRight = stateWith([s], [makeArrow({ id: 'a1', from: { shapeId: 's1', anchor: 'right' }, to: { x: 500, y: 500 } })]); 1345 + expect(exportToSVG(stateRight)).toContain('x1="200"'); 1346 + expect(exportToSVG(stateRight)).toContain('y1="50"'); 1347 + }); 1348 + }); 1349 + 1350 + // --------------------------------------------------------------------------- 1351 + // Export: cylinder with rotation 1352 + // --------------------------------------------------------------------------- 1353 + 1354 + describe('exportToSVG cylinder with rotation', () => { 1355 + it('renders cylinder with rotation transform', () => { 1356 + const state = stateWith([ 1357 + makeShape({ id: 'cy1', kind: 'cylinder', x: 0, y: 0, width: 80, height: 120, rotation: 45 }), 1358 + ]); 1359 + const svg = exportToSVG(state); 1360 + expect(svg).toContain('<ellipse'); 1361 + // Cylinder's rect element should have rotation 1362 + expect(svg).toContain('transform="rotate(45'); 1363 + }); 1364 + }); 1365 + 1366 + // --------------------------------------------------------------------------- 1367 + // removeArrow: edge cases 1368 + // --------------------------------------------------------------------------- 1369 + 1370 + describe('removeArrow edge cases', () => { 1371 + it('removing non-existent arrow is harmless', () => { 1372 + let wb = noSnap(createWhiteboard()); 1373 + wb = addArrow(wb, { x: 0, y: 0 }, { x: 100, y: 100 }); 1374 + expect(wb.arrows.size).toBe(1); 1375 + const updated = removeArrow(wb, 'non-existent-arrow'); 1376 + expect(updated.arrows.size).toBe(1); 1377 + }); 1378 + }); 1379 + 1380 + // --------------------------------------------------------------------------- 1381 + // toggleSnap: round-trip 1382 + // --------------------------------------------------------------------------- 1383 + 1384 + describe('toggleSnap round-trip', () => { 1385 + it('toggles back to original state after double toggle', () => { 1386 + const wb = createWhiteboard(); 1387 + expect(wb.snapToGrid).toBe(true); 1388 + const off = toggleSnap(wb); 1389 + expect(off.snapToGrid).toBe(false); 1390 + const on = toggleSnap(off); 1391 + expect(on.snapToGrid).toBe(true); 1392 + }); 1393 + }); 1394 + 1395 + // --------------------------------------------------------------------------- 1396 + // addShape: generates unique IDs 1397 + // --------------------------------------------------------------------------- 1398 + 1399 + describe('addShape unique IDs', () => { 1400 + it('each shape gets a unique ID', () => { 1401 + let wb = noSnap(createWhiteboard()); 1402 + const ids = new Set<string>(); 1403 + for (let i = 0; i < 100; i++) { 1404 + wb = addShape(wb, 'rectangle', i * 10, 0); 1405 + } 1406 + for (const id of wb.shapes.keys()) { 1407 + expect(ids.has(id)).toBe(false); 1408 + ids.add(id); 1409 + } 1410 + expect(ids.size).toBe(100); 1411 + }); 1412 + }); 1413 + 1414 + // --------------------------------------------------------------------------- 1415 + // addArrow: generates unique IDs 1416 + // --------------------------------------------------------------------------- 1417 + 1418 + describe('addArrow unique IDs', () => { 1419 + it('each arrow gets a unique ID', () => { 1420 + let wb = noSnap(createWhiteboard()); 1421 + for (let i = 0; i < 50; i++) { 1422 + wb = addArrow(wb, { x: i, y: 0 }, { x: i + 100, y: 100 }); 1423 + } 1424 + const ids = new Set(wb.arrows.keys()); 1425 + expect(ids.size).toBe(50); 1426 + }); 1427 + }); 1428 + 1429 + // --------------------------------------------------------------------------- 1430 + // Export: multiple shapes with same kind 1431 + // --------------------------------------------------------------------------- 1432 + 1433 + describe('exportToSVG multiple shapes same kind', () => { 1434 + it('renders multiple rectangles', () => { 1435 + const state = stateWith([ 1436 + makeShape({ id: 's1', kind: 'rectangle', x: 0, y: 0, width: 50, height: 50 }), 1437 + makeShape({ id: 's2', kind: 'rectangle', x: 100, y: 0, width: 50, height: 50 }), 1438 + makeShape({ id: 's3', kind: 'rectangle', x: 200, y: 0, width: 50, height: 50 }), 1439 + ]); 1440 + const svg = exportToSVG(state); 1441 + const rectMatches = svg.match(/<rect /g); 1442 + expect(rectMatches).toHaveLength(3); 1443 + }); 1444 + }); 1445 + 1446 + // --------------------------------------------------------------------------- 1447 + // History: clear then push 1448 + // --------------------------------------------------------------------------- 1449 + 1450 + describe('History clear then push', () => { 1451 + it('works normally after clear', () => { 1452 + const history = new History(); 1453 + const wb = noSnap(createWhiteboard()); 1454 + history.push(addShape(wb, 'rectangle', 0, 0)); 1455 + history.push(addShape(wb, 'ellipse', 100, 0)); 1456 + history.clear(); 1457 + 1458 + expect(history.canUndo()).toBe(false); 1459 + expect(history.canRedo()).toBe(false); 1460 + 1461 + const s = addShape(wb, 'diamond', 50, 50); 1462 + history.push(s); 1463 + const s2 = addShape(s, 'star', 200, 200); 1464 + history.push(s2); 1465 + 1466 + expect(history.canUndo()).toBe(true); 1467 + const undone = history.undo()!; 1468 + expect(undone.shapes.size).toBe(1); 1469 + }); 1470 + }); 1471 + 1472 + // --------------------------------------------------------------------------- 1473 + // getResizeHandles: zero-dimension shape 1474 + // --------------------------------------------------------------------------- 1475 + 1476 + describe('getResizeHandles zero-dimension shape', () => { 1477 + it('returns handles at degenerate positions for zero-size shape', () => { 1478 + const shape = makeShape({ id: 's1', kind: 'rectangle', x: 50, y: 50, width: 0, height: 0 }); 1479 + const handles = getResizeHandles(shape); 1480 + expect(handles).toHaveLength(8); 1481 + // All handles should be at (50, 50) since width and height are 0 1482 + for (const h of handles) { 1483 + expect(h.x).toBe(50); 1484 + expect(h.y).toBe(50); 1485 + } 1486 + }); 1487 + }); 1488 + 1489 + // --------------------------------------------------------------------------- 1490 + // hitTestResizeHandle: custom radius 1491 + // --------------------------------------------------------------------------- 1492 + 1493 + describe('hitTestResizeHandle custom radius', () => { 1494 + const shape = makeShape({ id: 's1', kind: 'rectangle', x: 100, y: 100, width: 80, height: 60 }); 1495 + 1496 + it('detects handle with larger radius', () => { 1497 + // SE handle at (180, 160) 1498 + expect(hitTestResizeHandle(shape, 190, 170, 12)).toBe('se'); 1499 + }); 1500 + 1501 + it('misses handle with smaller radius', () => { 1502 + // Point at (183, 163) -- 3 pixels from SE handle 1503 + expect(hitTestResizeHandle(shape, 183, 163, 2)).toBeNull(); 1504 + }); 1505 + }); 1506 + 1507 + // --------------------------------------------------------------------------- 1508 + // snapPoint: midpoint rounding 1509 + // --------------------------------------------------------------------------- 1510 + 1511 + describe('snapPoint midpoint rounding', () => { 1512 + it('rounds .5 to nearest even (Math.round behavior)', () => { 1513 + // Math.round(0.5) = 1, Math.round(1.5) = 2 (rounds to even in some impls) 1514 + // In JS, Math.round always rounds .5 up 1515 + expect(snapPoint(10, 10, 20)).toEqual({ x: 20, y: 20 }); 1516 + expect(snapPoint(30, 30, 20)).toEqual({ x: 40, y: 40 }); 1517 + }); 1518 + });
+7 -6
tests/export.test.ts
··· 230 230 expect(svg).not.toContain('Drop'); 231 231 }); 232 232 233 - it('includes arrows connected to selected shapes', () => { 233 + it('includes arrows where at least one endpoint is a selected shape', () => { 234 234 const s1 = makeShape({ id: 's1', kind: 'rectangle', x: 0, y: 0, width: 100, height: 80 }); 235 235 const s2 = makeShape({ id: 's2', kind: 'rectangle', x: 200, y: 0, width: 100, height: 80 }); 236 236 const s3 = makeShape({ id: 's3', kind: 'rectangle', x: 400, y: 0, width: 100, height: 80 }); ··· 246 246 }); 247 247 const state = stateWith([s1, s2, s3], [a1, a2]); 248 248 249 + // Both a1 (s1→s2) and a2 (s2→s3) included because s2 is selected 249 250 const svg = exportToSVG(state, new Set(['s1', 's2'])); 250 - 251 251 const lineMatches = svg.match(/<line /g); 252 - expect(lineMatches).toHaveLength(1); 252 + expect(lineMatches).toHaveLength(2); 253 253 }); 254 254 255 255 it('excludes arrows where neither endpoint is a selected shape', () => { 256 256 const s1 = makeShape({ id: 's1', kind: 'rectangle', x: 0, y: 0 }); 257 257 const s2 = makeShape({ id: 's2', kind: 'ellipse', x: 200, y: 0 }); 258 + const s3 = makeShape({ id: 's3', kind: 'rectangle', x: 400, y: 0 }); 258 259 const a1 = makeArrow({ 259 260 id: 'a1', 260 261 from: { shapeId: 's1', anchor: 'right' }, 261 262 to: { shapeId: 's2', anchor: 'left' }, 262 263 }); 263 - const state = stateWith([s1, s2], [a1]); 264 - 265 - const svg = exportToSVG(state, new Set(['s2'])); 264 + const state = stateWith([s1, s2, s3], [a1]); 266 265 266 + // Only s3 selected — a1 connects s1→s2, neither is selected 267 + const svg = exportToSVG(state, new Set(['s3'])); 267 268 expect(svg).not.toContain('<line'); 268 269 }); 269 270