Jan2026 fixes (#1077)
* fix release script * fix svgcanvas edge cases * Update path-actions.js * add modern js * update deps * Update CHANGES.md
This commit is contained in:
322
tests/e2e/group-transforms.spec.js
Normal file
322
tests/e2e/group-transforms.spec.js
Normal file
@@ -0,0 +1,322 @@
|
||||
import { test, expect } from './fixtures.js'
|
||||
import { setSvgSource, visitAndApproveStorage } from './helpers.js'
|
||||
|
||||
test.describe('Group transform preservation', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await visitAndApproveStorage(page)
|
||||
})
|
||||
|
||||
test('preserve group translate transform on click, move, and rotate', async ({ page }) => {
|
||||
// Load SVG with group containing translate transform
|
||||
await setSvgSource(page, `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1290 810">
|
||||
<g transform="translate(91.56,99.67)">
|
||||
<path
|
||||
transform="matrix(0,-1,-1,0,30.1,68.3)"
|
||||
d="M 58.3,0 C 58.3,0 57.8,30.2 29.1,30.2 0.3,30.2 0,0 0,0 Z"
|
||||
fill="none"
|
||||
stroke="#000000"
|
||||
stroke-width="1"
|
||||
/>
|
||||
<path
|
||||
transform="rotate(-90,167.15,-98.85)"
|
||||
d="M 58.3,0 C 58.3,0 57.8,30.2 29.1,30.2 0.3,30.2 0,0 0,0 Z"
|
||||
fill="none"
|
||||
stroke="#000000"
|
||||
stroke-width="1"
|
||||
/>
|
||||
<path
|
||||
transform="rotate(-90,49.3,19)"
|
||||
d="M 0,0 H 58.3 V 235.7 H 0 Z"
|
||||
fill="none"
|
||||
stroke="#000000"
|
||||
stroke-width="1"
|
||||
/>
|
||||
</g>
|
||||
</svg>`)
|
||||
|
||||
// Wait for SVG to be loaded
|
||||
await page.waitForSelector('#svgroot', { timeout: 5000 })
|
||||
|
||||
// Click on one of the paths inside the group
|
||||
// This should select the parent group
|
||||
const firstPath = page.locator('#svg_2')
|
||||
await firstPath.click()
|
||||
|
||||
// Verify the group was selected (not the individual path)
|
||||
const selectedGroup = page.locator('#svg_1')
|
||||
await expect(selectedGroup).toBeVisible()
|
||||
|
||||
// Test 1: Verify group transform is preserved after click
|
||||
let groupTransform = await selectedGroup.getAttribute('transform')
|
||||
expect(groupTransform).toContain('translate(91.56')
|
||||
expect(groupTransform).toContain('99.67')
|
||||
|
||||
// Test 2: Move 100 pixels to the left using arrow keys
|
||||
// Press Left arrow 10 times (each press moves 10 pixels with grid snapping)
|
||||
for (let i = 0; i < 10; i++) {
|
||||
await page.keyboard.press('ArrowLeft')
|
||||
}
|
||||
|
||||
// Verify group transform still contains the original translate
|
||||
groupTransform = await selectedGroup.getAttribute('transform')
|
||||
expect(groupTransform).toContain('translate(91.56')
|
||||
expect(groupTransform).toContain('99.67')
|
||||
// And now also has a translate for the movement
|
||||
expect(groupTransform).toMatch(/translate\([^)]+\).*translate\([^)]+\)/)
|
||||
|
||||
// Test 3: Rotate the group
|
||||
await page.locator('#angle').evaluate(el => {
|
||||
const input = el.shadowRoot.querySelector('elix-number-spin-box')
|
||||
input.value = '5'
|
||||
input.dispatchEvent(new Event('change', { bubbles: true }))
|
||||
})
|
||||
|
||||
// Verify group transform has both rotate and original translate
|
||||
groupTransform = await selectedGroup.getAttribute('transform')
|
||||
expect(groupTransform).toContain('rotate(5')
|
||||
expect(groupTransform).toContain('translate(91.56')
|
||||
expect(groupTransform).toContain('99.67')
|
||||
|
||||
// Verify child paths still have their own transforms
|
||||
const path1Transform = await page.locator('#svg_2').getAttribute('transform')
|
||||
const path2Transform = await page.locator('#svg_3').getAttribute('transform')
|
||||
const path3Transform = await page.locator('#svg_4').getAttribute('transform')
|
||||
|
||||
expect(path1Transform).toContain('matrix')
|
||||
expect(path2Transform).toContain('rotate(-90')
|
||||
expect(path3Transform).toContain('rotate(-90')
|
||||
})
|
||||
|
||||
test('multiple arrow key movements preserve group transform', async ({ page }) => {
|
||||
await setSvgSource(page, `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 640 480">
|
||||
<g id="testGroup" transform="translate(100,100)">
|
||||
<rect id="testRect" x="0" y="0" width="50" height="50" fill="red"/>
|
||||
</g>
|
||||
</svg>`)
|
||||
|
||||
await page.waitForSelector('#svgroot', { timeout: 5000 })
|
||||
|
||||
// Select the group by clicking the rect
|
||||
await page.locator('#testRect').click()
|
||||
|
||||
// Move right 5 times
|
||||
for (let i = 0; i < 5; i++) {
|
||||
await page.keyboard.press('ArrowRight')
|
||||
}
|
||||
|
||||
// Move down 3 times
|
||||
for (let i = 0; i < 3; i++) {
|
||||
await page.keyboard.press('ArrowDown')
|
||||
}
|
||||
|
||||
// Verify original transform is still there
|
||||
const groupTransform = await page.locator('#testGroup').getAttribute('transform')
|
||||
expect(groupTransform).toContain('translate(100')
|
||||
expect(groupTransform).toContain('100)')
|
||||
})
|
||||
|
||||
test('rotation followed by movement preserves both transforms', async ({ page }) => {
|
||||
await setSvgSource(page, `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 640 480">
|
||||
<g id="testGroup" transform="translate(200,150)">
|
||||
<circle id="testCircle" cx="25" cy="25" r="20" fill="blue"/>
|
||||
</g>
|
||||
</svg>`)
|
||||
|
||||
await page.waitForSelector('#svgroot', { timeout: 5000 })
|
||||
|
||||
// Select the group
|
||||
await page.locator('#testCircle').click()
|
||||
|
||||
// Rotate first
|
||||
await page.locator('#angle').evaluate(el => {
|
||||
const input = el.shadowRoot.querySelector('elix-number-spin-box')
|
||||
input.value = '45'
|
||||
input.dispatchEvent(new Event('change', { bubbles: true }))
|
||||
})
|
||||
|
||||
// Then move
|
||||
await page.keyboard.press('ArrowLeft')
|
||||
await page.keyboard.press('ArrowLeft')
|
||||
|
||||
// Verify both rotate and translate are present
|
||||
const groupTransform = await page.locator('#testGroup').getAttribute('transform')
|
||||
expect(groupTransform).toContain('rotate(45')
|
||||
expect(groupTransform).toContain('translate(200')
|
||||
expect(groupTransform).toContain('150)')
|
||||
})
|
||||
|
||||
test('multiple movements preserve group structure without flattening', async ({ page }) => {
|
||||
await setSvgSource(page, `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 640 480">
|
||||
<g id="testGroup" transform="translate(100,100)">
|
||||
<rect id="testRect" x="0" y="0" width="50" height="50" fill="green"/>
|
||||
</g>
|
||||
</svg>`)
|
||||
|
||||
await page.waitForSelector('#svgroot', { timeout: 5000 })
|
||||
|
||||
// Click to select the group
|
||||
const rect = page.locator('#testRect')
|
||||
await rect.click()
|
||||
|
||||
// Store original transform
|
||||
const originalTransform = await page.locator('#testGroup').getAttribute('transform')
|
||||
expect(originalTransform).toContain('translate(100')
|
||||
|
||||
// First movement: move right and down using keyboard
|
||||
for (let i = 0; i < 5; i++) {
|
||||
await page.keyboard.press('ArrowRight')
|
||||
}
|
||||
for (let i = 0; i < 5; i++) {
|
||||
await page.keyboard.press('ArrowDown')
|
||||
}
|
||||
|
||||
// Verify group still has transform attribute (not flattened to children)
|
||||
let groupTransform = await page.locator('#testGroup').getAttribute('transform')
|
||||
expect(groupTransform).toContain('translate')
|
||||
// Verify original transform is preserved
|
||||
expect(groupTransform).toContain('100')
|
||||
|
||||
// Most importantly: verify child has no transform (not flattened)
|
||||
let rectTransform = await rect.getAttribute('transform')
|
||||
expect(rectTransform).toBeNull()
|
||||
|
||||
// Second movement: move left and up
|
||||
for (let i = 0; i < 3; i++) {
|
||||
await page.keyboard.press('ArrowLeft')
|
||||
}
|
||||
for (let i = 0; i < 3; i++) {
|
||||
await page.keyboard.press('ArrowUp')
|
||||
}
|
||||
|
||||
// Verify group still has transform
|
||||
groupTransform = await page.locator('#testGroup').getAttribute('transform')
|
||||
expect(groupTransform).toContain('translate')
|
||||
|
||||
// Critical: child should STILL have no transform
|
||||
rectTransform = await rect.getAttribute('transform')
|
||||
expect(rectTransform).toBeNull()
|
||||
|
||||
// Third movement: ensure consistency
|
||||
for (let i = 0; i < 2; i++) {
|
||||
await page.keyboard.press('ArrowRight')
|
||||
}
|
||||
|
||||
// Final verification: group has transforms, child does not
|
||||
groupTransform = await page.locator('#testGroup').getAttribute('transform')
|
||||
expect(groupTransform).toContain('translate')
|
||||
rectTransform = await rect.getAttribute('transform')
|
||||
expect(rectTransform).toBeNull()
|
||||
})
|
||||
|
||||
test('ungroup preserves element positions without jumping', async ({ page }) => {
|
||||
// Test the real bug case: group with translate containing paths with complex transforms
|
||||
await setSvgSource(page, `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 640 480">
|
||||
<g id="testGroup" transform="translate(100,50)">
|
||||
<path id="path1" transform="matrix(0,-1,-1,0,30,60)" d="M 10,0 L 30,0 L 30,40 L 10,40 Z" fill="blue"/>
|
||||
<path id="path2" transform="rotate(-90,80,-50)" d="M 10,0 L 30,0 L 30,40 L 10,40 Z" fill="red"/>
|
||||
<path id="path3" transform="rotate(-90,30,10)" d="M 0,0 H 50 V 200 H 0 Z" fill="green"/>
|
||||
</g>
|
||||
</svg>`)
|
||||
|
||||
await page.waitForSelector('#svgroot', { timeout: 5000 })
|
||||
|
||||
// Get initial bounding boxes before ungrouping
|
||||
const path1Box = await page.locator('#path1').boundingBox()
|
||||
const path2Box = await page.locator('#path2').boundingBox()
|
||||
const path3Box = await page.locator('#path3').boundingBox()
|
||||
|
||||
// Click to select the group
|
||||
await page.locator('#testGroup').click()
|
||||
|
||||
// Ungroup via keyboard shortcut or UI
|
||||
await page.keyboard.press('Control+Shift+G')
|
||||
|
||||
// Wait for ungroup to complete
|
||||
await page.waitForTimeout(100)
|
||||
|
||||
// Verify paths still exist and have transforms
|
||||
const path1Transform = await page.locator('#path1').getAttribute('transform')
|
||||
const path2Transform = await page.locator('#path2').getAttribute('transform')
|
||||
const path3Transform = await page.locator('#path3').getAttribute('transform')
|
||||
|
||||
// All paths should have transforms (group's translate prepended to original)
|
||||
expect(path1Transform).toBeTruthy()
|
||||
expect(path2Transform).toBeTruthy()
|
||||
expect(path3Transform).toBeTruthy()
|
||||
|
||||
// Critical: bounding boxes should not change (no visual jump)
|
||||
const path1BoxAfter = await page.locator('#path1').boundingBox()
|
||||
const path2BoxAfter = await page.locator('#path2').boundingBox()
|
||||
const path3BoxAfter = await page.locator('#path3').boundingBox()
|
||||
|
||||
// Allow 1px tolerance for rounding
|
||||
expect(Math.abs(path1BoxAfter.x - path1Box.x)).toBeLessThan(2)
|
||||
expect(Math.abs(path1BoxAfter.y - path1Box.y)).toBeLessThan(2)
|
||||
expect(Math.abs(path2BoxAfter.x - path2Box.x)).toBeLessThan(2)
|
||||
expect(Math.abs(path2BoxAfter.y - path2Box.y)).toBeLessThan(2)
|
||||
expect(Math.abs(path3BoxAfter.x - path3Box.x)).toBeLessThan(2)
|
||||
expect(Math.abs(path3BoxAfter.y - path3Box.y)).toBeLessThan(2)
|
||||
})
|
||||
|
||||
test('drag after ungroup works correctly without jumps', async ({ page }) => {
|
||||
await setSvgSource(page, `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 640 480">
|
||||
<g id="testGroup" transform="translate(100,100)">
|
||||
<rect id="rect1" x="0" y="0" width="50" height="50" fill="red"/>
|
||||
<rect id="rect2" x="60" y="0" width="50" height="50" fill="blue"/>
|
||||
<rect id="rect3" x="120" y="0" width="50" height="50" fill="green"/>
|
||||
</g>
|
||||
</svg>`)
|
||||
|
||||
await page.waitForSelector('#svgroot', { timeout: 5000 })
|
||||
|
||||
// Select the group by clicking one of its children
|
||||
await page.locator('#rect1').click()
|
||||
|
||||
// Ungroup
|
||||
await page.keyboard.press('Control+Shift+G')
|
||||
await page.waitForTimeout(100)
|
||||
|
||||
// All elements should still be selected after ungroup
|
||||
// Get their positions before drag
|
||||
const rect1Before = await page.locator('#rect1').boundingBox()
|
||||
const rect2Before = await page.locator('#rect2').boundingBox()
|
||||
const rect3Before = await page.locator('#rect3').boundingBox()
|
||||
|
||||
// Drag all selected elements using arrow keys
|
||||
for (let i = 0; i < 5; i++) {
|
||||
await page.keyboard.press('ArrowRight')
|
||||
}
|
||||
for (let i = 0; i < 5; i++) {
|
||||
await page.keyboard.press('ArrowDown')
|
||||
}
|
||||
|
||||
// Get positions after drag
|
||||
const rect1After = await page.locator('#rect1').boundingBox()
|
||||
const rect2After = await page.locator('#rect2').boundingBox()
|
||||
const rect3After = await page.locator('#rect3').boundingBox()
|
||||
|
||||
// All elements should have moved by approximately the same amount
|
||||
const rect1Delta = { x: rect1After.x - rect1Before.x, y: rect1After.y - rect1Before.y }
|
||||
const rect2Delta = { x: rect2After.x - rect2Before.x, y: rect2After.y - rect2Before.y }
|
||||
const rect3Delta = { x: rect3After.x - rect3Before.x, y: rect3After.y - rect3Before.y }
|
||||
|
||||
// All should have moved approximately 50px right and 50px down (with grid snapping)
|
||||
expect(rect1Delta.x).toBeGreaterThan(40)
|
||||
expect(rect1Delta.y).toBeGreaterThan(40)
|
||||
|
||||
// Deltas should be similar for all elements (moved together)
|
||||
expect(Math.abs(rect1Delta.x - rect2Delta.x)).toBeLessThan(5)
|
||||
expect(Math.abs(rect1Delta.y - rect2Delta.y)).toBeLessThan(5)
|
||||
expect(Math.abs(rect1Delta.x - rect3Delta.x)).toBeLessThan(5)
|
||||
expect(Math.abs(rect1Delta.y - rect3Delta.y)).toBeLessThan(5)
|
||||
|
||||
// Verify transforms are consolidated (not accumulating)
|
||||
const rect1Transform = await page.locator('#rect1').getAttribute('transform')
|
||||
|
||||
// Should have single consolidated transforms, not multiple stacked
|
||||
// Transform can be null (no transform) or contain at most one translate
|
||||
if (rect1Transform) {
|
||||
expect((rect1Transform.match(/translate/g) || []).length).toBeLessThanOrEqual(1)
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -126,4 +126,165 @@ test.describe('Regression issues', () => {
|
||||
})
|
||||
expect(Number(widthPx)).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
test('issue 462: dragging element with complex matrix transforms stays stable', async ({ page }) => {
|
||||
// This tests the fix for issue #462 where elements with complex matrix transforms
|
||||
// in nested groups would jump around when dragged
|
||||
await setSvgSource(page, `<svg width="640" height="480" xmlns="http://www.w3.org/2000/svg">
|
||||
<g class="layer">
|
||||
<title>Layer 1</title>
|
||||
<g id="svg_1" transform="skewX(30) translate(-3,4) rotate(3)">
|
||||
<g id="svg_2" transform="skewX(10) translate(-3,4) rotate(10)">
|
||||
<circle cx="40.61157" cy="40" fill="blue" id="svg_3" r="20" stroke="#000000" stroke-width="2" transform="translate(250,-50) rotate(45) scale(1.5)"/>
|
||||
</g>
|
||||
</g>
|
||||
</g>
|
||||
</svg>`)
|
||||
|
||||
await page.waitForSelector('#svgroot', { timeout: 5000 })
|
||||
|
||||
// Get the circle element and its initial bounding box
|
||||
const circle = page.locator('#svg_3')
|
||||
await circle.click()
|
||||
|
||||
// Get initial position via getBoundingClientRect
|
||||
const initialBBox = await circle.evaluate(el => {
|
||||
const rect = el.getBoundingClientRect()
|
||||
return { x: rect.x, y: rect.y, width: rect.width, height: rect.height }
|
||||
})
|
||||
|
||||
// Move using arrow keys (small movements to test stability)
|
||||
await page.keyboard.press('ArrowRight')
|
||||
await page.keyboard.press('ArrowDown')
|
||||
|
||||
// Get position after movement
|
||||
const afterMoveBBox = await circle.evaluate(el => {
|
||||
const rect = el.getBoundingClientRect()
|
||||
return { x: rect.x, y: rect.y, width: rect.width, height: rect.height }
|
||||
})
|
||||
|
||||
// The element should have moved roughly in the expected direction
|
||||
// Due to transforms, the actual pixel movement may vary, but it should be reasonable
|
||||
// Key check: The element should NOT have jumped wildly (e.g., more than 200px difference)
|
||||
const deltaX = Math.abs(afterMoveBBox.x - initialBBox.x)
|
||||
const deltaY = Math.abs(afterMoveBBox.y - initialBBox.y)
|
||||
|
||||
// Movement should be small and controlled (less than 100px for a single arrow key press)
|
||||
expect(deltaX).toBeLessThan(100)
|
||||
expect(deltaY).toBeLessThan(100)
|
||||
|
||||
// Element dimensions should remain stable (not get distorted)
|
||||
expect(Math.abs(afterMoveBBox.width - initialBBox.width)).toBeLessThan(5)
|
||||
expect(Math.abs(afterMoveBBox.height - initialBBox.height)).toBeLessThan(5)
|
||||
})
|
||||
|
||||
test('issue 391: selection box position after ungrouping and path edit', async ({ page }) => {
|
||||
// This tests the fix for issue #391 where selection boxes and path edit points
|
||||
// were not at correct positions after ungrouping and double-clicking to edit a path
|
||||
// Uses a simplified version of a complex SVG with nested groups
|
||||
await setSvgSource(page, `<svg width="640" height="480" xmlns="http://www.w3.org/2000/svg">
|
||||
<g class="layer">
|
||||
<title>Layer 1</title>
|
||||
<g id="svg_1" transform="translate(100, 100)">
|
||||
<path id="svg_2" d="M 0,0 L 50,0 L 50,50 L 0,50 Z" fill="#ff0000" stroke="#000000" stroke-width="2"/>
|
||||
<path id="svg_3" d="M 60,0 L 110,0 L 110,50 L 60,50 Z" fill="#00ff00" stroke="#000000" stroke-width="2"/>
|
||||
</g>
|
||||
</g>
|
||||
</svg>`)
|
||||
|
||||
await page.waitForSelector('#svgroot', { timeout: 5000 })
|
||||
|
||||
// Select the group using force click to bypass svgroot intercept
|
||||
const group = page.locator('#svg_1')
|
||||
await group.click({ force: true })
|
||||
|
||||
// Ungroup using keyboard shortcut Ctrl+Shift+G
|
||||
await page.keyboard.press('Control+Shift+g')
|
||||
|
||||
// Wait for ungrouping to complete
|
||||
await page.waitForTimeout(300)
|
||||
|
||||
// Select the first path
|
||||
const path = page.locator('#svg_2')
|
||||
await path.click({ force: true })
|
||||
|
||||
// Wait for selection to be processed
|
||||
await page.waitForTimeout(200)
|
||||
|
||||
// Get the path's screen position
|
||||
const pathBBox = await path.evaluate(el => {
|
||||
const rect = el.getBoundingClientRect()
|
||||
return { x: rect.x, y: rect.y, width: rect.width, height: rect.height, cx: rect.x + rect.width / 2, cy: rect.y + rect.height / 2 }
|
||||
})
|
||||
|
||||
// Verify the path still has reasonable coordinates after ungrouping
|
||||
// The path should now have its transform baked in (translated by 100,100)
|
||||
expect(pathBBox.width).toBeGreaterThan(0)
|
||||
expect(pathBBox.height).toBeGreaterThan(0)
|
||||
|
||||
// Double-click to enter path edit mode
|
||||
await path.dblclick({ force: true })
|
||||
|
||||
// Wait for path edit mode
|
||||
await page.waitForTimeout(300)
|
||||
|
||||
// Check for path point grips (pointgrip_0 is the first control point)
|
||||
const pointGrip = page.locator('#pathpointgrip_0')
|
||||
const pointGripVisible = await pointGrip.isVisible().catch(() => false)
|
||||
|
||||
// If path edit mode activated, verify control point positions
|
||||
if (pointGripVisible) {
|
||||
const pointGripBBox = await pointGrip.evaluate(el => {
|
||||
const rect = el.getBoundingClientRect()
|
||||
return { x: rect.x, y: rect.y }
|
||||
})
|
||||
|
||||
// The first point should be near the top-left of the path
|
||||
// After ungrouping with translate(100,100), the path moves
|
||||
// Allow reasonable tolerance
|
||||
const tolerance = 100
|
||||
expect(Math.abs(pointGripBBox.x - pathBBox.x)).toBeLessThan(tolerance)
|
||||
expect(Math.abs(pointGripBBox.y - pathBBox.y)).toBeLessThan(tolerance)
|
||||
}
|
||||
|
||||
// Verify the path's d attribute was updated correctly after ungrouping
|
||||
const dAttr = await path.getAttribute('d')
|
||||
expect(dAttr).toBeTruthy()
|
||||
})
|
||||
|
||||
test('issue 404: border width during resize at zoom', async ({ page }) => {
|
||||
// This tests the fix for issue #404 where border width appeared incorrect
|
||||
// during resize when zoom was not at 100%
|
||||
await setSvgSource(page, `<svg width="640" height="480" xmlns="http://www.w3.org/2000/svg">
|
||||
<g class="layer">
|
||||
<title>Layer 1</title>
|
||||
<rect id="svg_1" x="100" y="100" width="200" height="150" fill="#00ff00" stroke="#000000" stroke-width="10"/>
|
||||
</g>
|
||||
</svg>`)
|
||||
|
||||
await page.waitForSelector('#svgroot', { timeout: 5000 })
|
||||
|
||||
// Set zoom to 150%
|
||||
await page.evaluate(() => {
|
||||
window.svgEditor.svgCanvas.setZoom(1.5)
|
||||
})
|
||||
|
||||
// Wait for zoom to apply
|
||||
await page.waitForTimeout(200)
|
||||
|
||||
// Select the rectangle
|
||||
const rect = page.locator('#svg_1')
|
||||
await rect.click({ force: true })
|
||||
|
||||
// Get the initial stroke-width
|
||||
const initialStrokeWidth = await rect.getAttribute('stroke-width')
|
||||
expect(initialStrokeWidth).toBe('10')
|
||||
|
||||
// After any interaction, stroke-width should remain constant
|
||||
await page.keyboard.press('ArrowRight')
|
||||
await page.waitForTimeout(100)
|
||||
|
||||
const afterMoveStrokeWidth = await rect.getAttribute('stroke-width')
|
||||
expect(afterMoveStrokeWidth).toBe('10')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -59,4 +59,20 @@ describe('locale loader', () => {
|
||||
expect(result.langParam).toBe('en')
|
||||
expect(t('common.ok')).toBe('OK')
|
||||
})
|
||||
|
||||
test('uses navigator.language with supported locale', async () => {
|
||||
Reflect.deleteProperty(navigator, 'userLanguage')
|
||||
setNavigatorProp('language', 'de')
|
||||
|
||||
const result = await putLocale('', goodLangs)
|
||||
expect(result.langParam).toBe('de')
|
||||
})
|
||||
|
||||
test('uses explicit lang parameter over navigator', async () => {
|
||||
setNavigatorProp('userLanguage', 'de')
|
||||
setNavigatorProp('language', 'de')
|
||||
|
||||
const result = await putLocale('en', goodLangs)
|
||||
expect(result.langParam).toBe('en')
|
||||
})
|
||||
})
|
||||
|
||||
124
tests/unit/blur-event.test.js
Normal file
124
tests/unit/blur-event.test.js
Normal file
@@ -0,0 +1,124 @@
|
||||
import SvgCanvas from '../../packages/svgcanvas/svgcanvas.js'
|
||||
|
||||
describe('blur-event', () => {
|
||||
let svgCanvas
|
||||
|
||||
const createSvgCanvas = () => {
|
||||
document.body.textContent = ''
|
||||
const svgEditor = document.createElement('div')
|
||||
svgEditor.id = 'svg_editor'
|
||||
const svgcanvas = document.createElement('div')
|
||||
svgcanvas.style.visibility = 'hidden'
|
||||
svgcanvas.id = 'svgcanvas'
|
||||
const workarea = document.createElement('div')
|
||||
workarea.id = 'workarea'
|
||||
workarea.append(svgcanvas)
|
||||
const toolsLeft = document.createElement('div')
|
||||
toolsLeft.id = 'tools_left'
|
||||
svgEditor.append(workarea, toolsLeft)
|
||||
document.body.append(svgEditor)
|
||||
|
||||
svgCanvas = new SvgCanvas(document.getElementById('svgcanvas'), {
|
||||
canvas_expansion: 3,
|
||||
dimensions: [640, 480],
|
||||
initFill: {
|
||||
color: 'FF0000',
|
||||
opacity: 1
|
||||
},
|
||||
initStroke: {
|
||||
width: 5,
|
||||
color: '000000',
|
||||
opacity: 1
|
||||
},
|
||||
initOpacity: 1,
|
||||
imgPath: '../editor/images',
|
||||
langPath: 'locale/',
|
||||
extPath: 'extensions/',
|
||||
extensions: [],
|
||||
initTool: 'select',
|
||||
wireframe: false
|
||||
})
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
createSvgCanvas()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
document.body.textContent = ''
|
||||
})
|
||||
|
||||
it('does not create a filter or history when setting blur to 0 on a new element', () => {
|
||||
const rect = svgCanvas.addSVGElementsFromJson({
|
||||
element: 'rect',
|
||||
attr: {
|
||||
id: 'rect-blur-zero',
|
||||
x: 10,
|
||||
y: 20,
|
||||
width: 30,
|
||||
height: 40
|
||||
}
|
||||
})
|
||||
|
||||
svgCanvas.selectOnly([rect], true)
|
||||
const undoSize = svgCanvas.undoMgr.getUndoStackSize()
|
||||
|
||||
svgCanvas.setBlur(0, true)
|
||||
|
||||
expect(rect.hasAttribute('filter')).toBe(false)
|
||||
expect(svgCanvas.getSvgContent().querySelector('#rect-blur-zero_blur')).toBeNull()
|
||||
expect(svgCanvas.undoMgr.getUndoStackSize()).toBe(undoSize)
|
||||
})
|
||||
|
||||
it('creates a blur filter and records a single history entry', () => {
|
||||
const rect = svgCanvas.addSVGElementsFromJson({
|
||||
element: 'rect',
|
||||
attr: {
|
||||
id: 'rect-blur-create',
|
||||
x: 10,
|
||||
y: 20,
|
||||
width: 30,
|
||||
height: 40
|
||||
}
|
||||
})
|
||||
|
||||
svgCanvas.selectOnly([rect], true)
|
||||
const undoSize = svgCanvas.undoMgr.getUndoStackSize()
|
||||
|
||||
svgCanvas.setBlur(1.2, true)
|
||||
|
||||
expect(rect.getAttribute('filter')).toBe('url(#rect-blur-create_blur)')
|
||||
const filter = svgCanvas.getSvgContent().querySelector('#rect-blur-create_blur')
|
||||
expect(filter).toBeTruthy()
|
||||
expect(filter.querySelector('feGaussianBlur').getAttribute('stdDeviation')).toBe('1.2')
|
||||
expect(svgCanvas.undoMgr.getUndoStackSize()).toBe(undoSize + 1)
|
||||
})
|
||||
|
||||
it('removes blur and supports undo/redo', () => {
|
||||
const rect = svgCanvas.addSVGElementsFromJson({
|
||||
element: 'rect',
|
||||
attr: {
|
||||
id: 'rect-blur-undo',
|
||||
x: 10,
|
||||
y: 20,
|
||||
width: 30,
|
||||
height: 40
|
||||
}
|
||||
})
|
||||
|
||||
svgCanvas.selectOnly([rect], true)
|
||||
svgCanvas.setBlur(2, true)
|
||||
|
||||
const undoSize = svgCanvas.undoMgr.getUndoStackSize()
|
||||
svgCanvas.setBlur(0, true)
|
||||
|
||||
expect(rect.hasAttribute('filter')).toBe(false)
|
||||
expect(svgCanvas.undoMgr.getUndoStackSize()).toBe(undoSize + 1)
|
||||
|
||||
svgCanvas.undoMgr.undo()
|
||||
expect(rect.getAttribute('filter')).toBe('url(#rect-blur-undo_blur)')
|
||||
|
||||
svgCanvas.undoMgr.redo()
|
||||
expect(rect.hasAttribute('filter')).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -38,6 +38,18 @@ describe('clearSvgContentElementInit', () => {
|
||||
expect(svgContent.getAttribute('xmlns')).toBe('http://www.w3.org/2000/svg')
|
||||
})
|
||||
|
||||
it('resets stale svgcontent attributes', () => {
|
||||
const { canvas, svgContent } = buildCanvas(false)
|
||||
svgContent.setAttribute('viewBox', '0 0 10 10')
|
||||
svgContent.setAttribute('class', 'stale')
|
||||
initClear(canvas)
|
||||
|
||||
clearSvgContentElementInit()
|
||||
|
||||
expect(svgContent.getAttribute('viewBox')).toBe(null)
|
||||
expect(svgContent.getAttribute('class')).toBe(null)
|
||||
})
|
||||
|
||||
it('honors show_outside_canvas by leaving overflow visible', () => {
|
||||
const { canvas, svgContent } = buildCanvas(true)
|
||||
initClear(canvas)
|
||||
|
||||
@@ -15,6 +15,7 @@ describe('coords', function () {
|
||||
* @returns {void}
|
||||
*/
|
||||
beforeEach(function () {
|
||||
elemId = 1
|
||||
const svgroot = document.createElementNS(NS.SVG, 'svg')
|
||||
svgroot.id = 'svgroot'
|
||||
root.append(svgroot)
|
||||
@@ -28,21 +29,28 @@ describe('coords', function () {
|
||||
*/
|
||||
{
|
||||
getSvgRoot: () => { return svg },
|
||||
getSvgContent: () => { return svg },
|
||||
getDOMDocument () { return null },
|
||||
getDOMContainer () { return null }
|
||||
}
|
||||
)
|
||||
const drawing = {
|
||||
getNextId () { return String(elemId++) }
|
||||
}
|
||||
const mockDataStorage = {
|
||||
get (elem, key) { return null },
|
||||
has (elem, key) { return false }
|
||||
}
|
||||
coords.init(
|
||||
/**
|
||||
* @implements {module:coords.EditorContext}
|
||||
*/
|
||||
{
|
||||
getGridSnapping () { return false },
|
||||
getDrawing () {
|
||||
return {
|
||||
getNextId () { return String(elemId++) }
|
||||
}
|
||||
}
|
||||
getDrawing () { return drawing },
|
||||
getCurrentDrawing () { return drawing },
|
||||
getDataStorage () { return mockDataStorage },
|
||||
getSvgRoot () { return svg }
|
||||
}
|
||||
)
|
||||
})
|
||||
@@ -166,6 +174,41 @@ describe('coords', function () {
|
||||
assert.equal(circle.getAttribute('r'), '125')
|
||||
})
|
||||
|
||||
it('Test remapElement flips radial gradients on negative scale', function () {
|
||||
const defs = document.createElementNS(NS.SVG, 'defs')
|
||||
svg.append(defs)
|
||||
|
||||
const grad = document.createElementNS(NS.SVG, 'radialGradient')
|
||||
grad.id = 'grad1'
|
||||
grad.setAttribute('cx', '0.2')
|
||||
grad.setAttribute('cy', '0.3')
|
||||
grad.setAttribute('fx', '0.4')
|
||||
grad.setAttribute('fy', '0.5')
|
||||
defs.append(grad)
|
||||
|
||||
const rect = document.createElementNS(NS.SVG, 'rect')
|
||||
rect.setAttribute('x', '0')
|
||||
rect.setAttribute('y', '0')
|
||||
rect.setAttribute('width', '10')
|
||||
rect.setAttribute('height', '10')
|
||||
rect.setAttribute('fill', 'url(#grad1)')
|
||||
svg.append(rect)
|
||||
|
||||
const m = svg.createSVGMatrix()
|
||||
m.a = -1
|
||||
m.d = 1
|
||||
m.e = 0
|
||||
m.f = 0
|
||||
|
||||
coords.remapElement(rect, { x: 0, y: 0, width: 10, height: 10 }, m)
|
||||
|
||||
const newId = rect.getAttribute('fill').replace('url(#', '').replace(')', '')
|
||||
const mirrored = defs.ownerDocument.getElementById(newId)
|
||||
assert.ok(mirrored)
|
||||
assert.equal(mirrored.getAttribute('cx'), '0.8')
|
||||
assert.equal(mirrored.getAttribute('fx'), '0.6')
|
||||
})
|
||||
|
||||
it('Test remapElement(translate) for ellipse', function () {
|
||||
const ellipse = document.createElementNS(NS.SVG, 'ellipse')
|
||||
ellipse.setAttribute('cx', '200')
|
||||
@@ -304,4 +347,669 @@ describe('coords', function () {
|
||||
assert.equal(text.getAttribute('x'), '150')
|
||||
assert.equal(text.getAttribute('y'), '50')
|
||||
})
|
||||
|
||||
it('Does not throw with grid snapping enabled and detached elements', function () {
|
||||
coords.init({
|
||||
getGridSnapping () { return true },
|
||||
getDrawing () {
|
||||
return {
|
||||
getNextId () { return String(elemId++) }
|
||||
}
|
||||
},
|
||||
getCurrentDrawing () {
|
||||
return {
|
||||
getNextId () { return String(elemId++) }
|
||||
}
|
||||
}
|
||||
})
|
||||
const rect = document.createElementNS(NS.SVG, 'rect')
|
||||
rect.setAttribute('width', '10')
|
||||
rect.setAttribute('height', '10')
|
||||
const attrs = { x: 0, y: 0, width: 10, height: 10 }
|
||||
const m = svg.createSVGMatrix().translate(5, 5)
|
||||
coords.remapElement(rect, attrs, m)
|
||||
assert.equal(rect.getAttribute('x'), '5')
|
||||
assert.equal(rect.getAttribute('y'), '5')
|
||||
})
|
||||
|
||||
it('Clones and flips linearGradient on horizontal flip', function () {
|
||||
const defs = document.createElementNS(NS.SVG, 'defs')
|
||||
svg.append(defs)
|
||||
const grad = document.createElementNS(NS.SVG, 'linearGradient')
|
||||
grad.id = 'grad1'
|
||||
grad.setAttribute('x1', '0')
|
||||
grad.setAttribute('x2', '1')
|
||||
grad.setAttribute('y1', '0')
|
||||
grad.setAttribute('y2', '0')
|
||||
defs.append(grad)
|
||||
|
||||
const rect = document.createElementNS(NS.SVG, 'rect')
|
||||
rect.setAttribute('fill', 'url(#grad1)')
|
||||
svg.append(rect)
|
||||
|
||||
const attrs = { x: 0, y: 0, width: 10, height: 10 }
|
||||
const m = svg.createSVGMatrix()
|
||||
m.a = -1
|
||||
m.d = 1
|
||||
coords.remapElement(rect, attrs, m)
|
||||
|
||||
const grads = defs.querySelectorAll('linearGradient')
|
||||
assert.equal(grads.length, 2)
|
||||
const cloned = [...grads].find(g => g.id !== 'grad1')
|
||||
assert.ok(cloned)
|
||||
assert.equal(rect.getAttribute('fill'), `url(#${cloned.id})`)
|
||||
assert.equal(cloned.getAttribute('x1'), '1')
|
||||
assert.equal(cloned.getAttribute('x2'), '0')
|
||||
})
|
||||
|
||||
it('Skips gradient cloning for external URL references', function () {
|
||||
const rect = document.createElementNS(NS.SVG, 'rect')
|
||||
rect.setAttribute('fill', 'url(external.svg#grad)')
|
||||
svg.append(rect)
|
||||
|
||||
const attrs = { x: 0, y: 0, width: 10, height: 10 }
|
||||
const m = svg.createSVGMatrix()
|
||||
m.a = -1
|
||||
m.d = 1
|
||||
coords.remapElement(rect, attrs, m)
|
||||
|
||||
assert.equal(rect.getAttribute('fill'), 'url(external.svg#grad)')
|
||||
assert.equal(svg.querySelectorAll('linearGradient').length, 0)
|
||||
})
|
||||
|
||||
it('Keeps arc radii positive and toggles sweep on reflection', function () {
|
||||
const path = document.createElementNS(NS.SVG, 'path')
|
||||
path.setAttribute('d', 'M0 0 A10 5 30 0 0 30 20')
|
||||
svg.append(path)
|
||||
|
||||
const m = svg.createSVGMatrix()
|
||||
m.a = -2
|
||||
m.d = 1
|
||||
coords.remapElement(path, {}, m)
|
||||
|
||||
const d = path.getAttribute('d')
|
||||
const match = /A\s*([-\d.]+),([-\d.]+)\s+([-\d.]+)\s+(\d+)\s+(\d+)\s+([-\d.]+),([-\d.]+)/.exec(d)
|
||||
assert.ok(match, `Unexpected path d: ${d}`)
|
||||
const [, rx, ry, angle, largeArc, sweep, x, y] = match
|
||||
assert.equal(Number(rx), 20)
|
||||
assert.equal(Number(ry), 5)
|
||||
assert.equal(Number(angle), -30)
|
||||
assert.equal(Number(largeArc), 0)
|
||||
assert.equal(Number(sweep), 1)
|
||||
assert.equal(Number(x), -60)
|
||||
assert.equal(Number(y), 20)
|
||||
})
|
||||
|
||||
// Additional tests for branch coverage
|
||||
it('Test remapElement with radial gradient and negative scale', function () {
|
||||
const defs = document.createElementNS(NS.SVG, 'defs')
|
||||
svg.append(defs)
|
||||
|
||||
const grad = document.createElementNS(NS.SVG, 'radialGradient')
|
||||
grad.id = 'radialGrad1'
|
||||
grad.setAttribute('cx', '50%')
|
||||
grad.setAttribute('cy', '50%')
|
||||
grad.setAttribute('r', '50%')
|
||||
grad.setAttribute('fx', '30%')
|
||||
grad.setAttribute('fy', '30%')
|
||||
defs.append(grad)
|
||||
|
||||
const rect = document.createElementNS(NS.SVG, 'rect')
|
||||
rect.setAttribute('fill', 'url(#radialGrad1)')
|
||||
rect.setAttribute('width', '100')
|
||||
rect.setAttribute('height', '100')
|
||||
svg.append(rect)
|
||||
|
||||
const attrs = { x: 0, y: 0, width: 100, height: 100 }
|
||||
const m = svg.createSVGMatrix()
|
||||
m.a = -1
|
||||
m.d = -1
|
||||
coords.remapElement(rect, attrs, m)
|
||||
|
||||
// Should create a mirrored gradient or keep original
|
||||
assert.ok(svg.querySelectorAll('radialGradient').length >= 1)
|
||||
})
|
||||
|
||||
it('Test remapElement with image and negative scale', function () {
|
||||
const image = document.createElementNS(NS.SVG, 'image')
|
||||
image.setAttribute('x', '10')
|
||||
image.setAttribute('y', '10')
|
||||
image.setAttribute('width', '100')
|
||||
image.setAttribute('height', '80')
|
||||
svg.append(image)
|
||||
|
||||
const attrs = { x: 10, y: 10, width: 100, height: 80 }
|
||||
const m = svg.createSVGMatrix()
|
||||
m.a = -1
|
||||
m.d = 1
|
||||
coords.remapElement(image, attrs, m)
|
||||
|
||||
// Image with negative scale should get matrix transform or have updated attributes
|
||||
assert.ok(image.transform.baseVal.numberOfItems > 0 || image.getAttribute('width') !== '100')
|
||||
})
|
||||
|
||||
it('Test remapElement with foreignObject', function () {
|
||||
const fo = document.createElementNS(NS.SVG, 'foreignObject')
|
||||
fo.setAttribute('x', '10')
|
||||
fo.setAttribute('y', '10')
|
||||
fo.setAttribute('width', '100')
|
||||
fo.setAttribute('height', '80')
|
||||
svg.append(fo)
|
||||
|
||||
const attrs = { x: 10, y: 10, width: 100, height: 80 }
|
||||
const m = svg.createSVGMatrix()
|
||||
m.a = 2
|
||||
m.d = 2
|
||||
m.e = 50
|
||||
m.f = 50
|
||||
coords.remapElement(fo, attrs, m)
|
||||
|
||||
assert.equal(Number.parseFloat(fo.getAttribute('x')), 70)
|
||||
assert.equal(Number.parseFloat(fo.getAttribute('y')), 70)
|
||||
assert.equal(Number.parseFloat(fo.getAttribute('width')), 200)
|
||||
assert.equal(Number.parseFloat(fo.getAttribute('height')), 160)
|
||||
})
|
||||
|
||||
it('Test remapElement with use element (should skip)', function () {
|
||||
const use = document.createElementNS(NS.SVG, 'use')
|
||||
use.setAttribute('x', '10')
|
||||
use.setAttribute('y', '10')
|
||||
use.setAttribute('href', '#someId')
|
||||
svg.append(use)
|
||||
|
||||
const attrs = { x: 10, y: 10 }
|
||||
const m = svg.createSVGMatrix()
|
||||
m.a = 2
|
||||
m.d = 2
|
||||
m.e = 50
|
||||
m.f = 50
|
||||
coords.remapElement(use, attrs, m)
|
||||
|
||||
// Use elements should not be remapped, attributes remain unchanged
|
||||
assert.equal(use.getAttribute('x'), '10')
|
||||
assert.equal(use.getAttribute('y'), '10')
|
||||
})
|
||||
|
||||
it('Test remapElement with text element', function () {
|
||||
const text = document.createElementNS(NS.SVG, 'text')
|
||||
text.setAttribute('x', '50')
|
||||
text.setAttribute('y', '50')
|
||||
text.textContent = 'Test'
|
||||
svg.append(text)
|
||||
|
||||
const attrs = { x: 50, y: 50 }
|
||||
const m = svg.createSVGMatrix()
|
||||
m.a = 1
|
||||
m.d = 1
|
||||
m.e = 10
|
||||
m.f = 20
|
||||
coords.remapElement(text, attrs, m)
|
||||
|
||||
assert.equal(Number.parseFloat(text.getAttribute('x')), 60)
|
||||
assert.equal(Number.parseFloat(text.getAttribute('y')), 70)
|
||||
})
|
||||
|
||||
it('Test remapElement with tspan element', function () {
|
||||
const text = document.createElementNS(NS.SVG, 'text')
|
||||
text.setAttribute('x', '50')
|
||||
text.setAttribute('y', '50')
|
||||
const tspan = document.createElementNS(NS.SVG, 'tspan')
|
||||
tspan.setAttribute('x', '55')
|
||||
tspan.setAttribute('y', '55')
|
||||
tspan.textContent = 'Test'
|
||||
text.append(tspan)
|
||||
svg.append(text)
|
||||
|
||||
const attrs = { x: 55, y: 55 }
|
||||
const m = svg.createSVGMatrix()
|
||||
m.a = 1
|
||||
m.d = 1
|
||||
m.e = 5
|
||||
m.f = 10
|
||||
coords.remapElement(tspan, attrs, m)
|
||||
|
||||
assert.equal(Number.parseFloat(tspan.getAttribute('x')), 60)
|
||||
assert.equal(Number.parseFloat(tspan.getAttribute('y')), 65)
|
||||
})
|
||||
|
||||
it('Test remapElement with gradient in userSpaceOnUse mode', function () {
|
||||
const defs = document.createElementNS(NS.SVG, 'defs')
|
||||
svg.append(defs)
|
||||
|
||||
const grad = document.createElementNS(NS.SVG, 'linearGradient')
|
||||
grad.id = 'userSpaceGrad'
|
||||
grad.setAttribute('gradientUnits', 'userSpaceOnUse')
|
||||
grad.setAttribute('x1', '0%')
|
||||
grad.setAttribute('x2', '100%')
|
||||
defs.append(grad)
|
||||
|
||||
const rect = document.createElementNS(NS.SVG, 'rect')
|
||||
rect.setAttribute('fill', 'url(#userSpaceGrad)')
|
||||
rect.setAttribute('width', '100')
|
||||
rect.setAttribute('height', '100')
|
||||
svg.append(rect)
|
||||
|
||||
const initialGradCount = svg.querySelectorAll('linearGradient').length
|
||||
|
||||
const attrs = { x: 0, y: 0, width: 100, height: 100 }
|
||||
const m = svg.createSVGMatrix()
|
||||
m.a = -1
|
||||
m.d = 1
|
||||
coords.remapElement(rect, attrs, m)
|
||||
|
||||
// userSpaceOnUse gradients should not be mirrored
|
||||
assert.equal(svg.querySelectorAll('linearGradient').length, initialGradCount)
|
||||
assert.equal(rect.getAttribute('fill'), 'url(#userSpaceGrad)')
|
||||
})
|
||||
|
||||
it('Test remapElement with polyline', function () {
|
||||
const polyline = document.createElementNS(NS.SVG, 'polyline')
|
||||
polyline.setAttribute('points', '10,10 20,20 30,10')
|
||||
svg.append(polyline)
|
||||
|
||||
const attrs = {
|
||||
points: [
|
||||
{ x: 10, y: 10 },
|
||||
{ x: 20, y: 20 },
|
||||
{ x: 30, y: 10 }
|
||||
]
|
||||
}
|
||||
const m = svg.createSVGMatrix()
|
||||
m.a = 2
|
||||
m.d = 2
|
||||
m.e = 5
|
||||
m.f = 5
|
||||
coords.remapElement(polyline, attrs, m)
|
||||
|
||||
const points = polyline.getAttribute('points')
|
||||
// Points should be transformed
|
||||
assert.ok(points !== '10,10 20,20 30,10')
|
||||
})
|
||||
|
||||
it('Test remapElement with polygon', function () {
|
||||
const polygon = document.createElementNS(NS.SVG, 'polygon')
|
||||
polygon.setAttribute('points', '10,10 20,10 15,20')
|
||||
svg.append(polygon)
|
||||
|
||||
const attrs = {
|
||||
points: [
|
||||
{ x: 10, y: 10 },
|
||||
{ x: 20, y: 10 },
|
||||
{ x: 15, y: 20 }
|
||||
]
|
||||
}
|
||||
const m = svg.createSVGMatrix()
|
||||
m.a = 2
|
||||
m.d = 2
|
||||
m.e = 10
|
||||
m.f = 10
|
||||
coords.remapElement(polygon, attrs, m)
|
||||
|
||||
const points = polygon.getAttribute('points')
|
||||
// Points should be transformed
|
||||
assert.ok(points !== '10,10 20,10 15,20')
|
||||
})
|
||||
|
||||
it('Test remapElement with g (group) element', function () {
|
||||
const g = document.createElementNS(NS.SVG, 'g')
|
||||
svg.append(g)
|
||||
|
||||
const attrs = {}
|
||||
const m = svg.createSVGMatrix()
|
||||
m.a = 2
|
||||
m.d = 2
|
||||
coords.remapElement(g, attrs, m)
|
||||
|
||||
// Group elements get handled (may or may not add transform)
|
||||
// Just verify it doesn't crash
|
||||
assert.ok(g !== null)
|
||||
})
|
||||
|
||||
it('Test flipBoxCoordinate with percentage values', function () {
|
||||
const defs = document.createElementNS(NS.SVG, 'defs')
|
||||
svg.append(defs)
|
||||
|
||||
const grad = document.createElementNS(NS.SVG, 'linearGradient')
|
||||
grad.id = 'percentGrad'
|
||||
grad.setAttribute('x1', '25%')
|
||||
grad.setAttribute('x2', '75%')
|
||||
defs.append(grad)
|
||||
|
||||
const rect = document.createElementNS(NS.SVG, 'rect')
|
||||
rect.setAttribute('fill', 'url(#percentGrad)')
|
||||
rect.setAttribute('width', '100')
|
||||
rect.setAttribute('height', '100')
|
||||
svg.append(rect)
|
||||
|
||||
const attrs = { x: 0, y: 0, width: 100, height: 100 }
|
||||
const m = svg.createSVGMatrix()
|
||||
m.a = -1
|
||||
m.d = 1
|
||||
coords.remapElement(rect, attrs, m)
|
||||
|
||||
// Should create a new gradient with flipped percentages or keep original
|
||||
const newGrads = svg.querySelectorAll('linearGradient')
|
||||
assert.ok(newGrads.length >= 1)
|
||||
// Verify rect still has gradient
|
||||
assert.ok(rect.getAttribute('fill').includes('url'))
|
||||
})
|
||||
|
||||
it('Test remapElement with negative width/height', function () {
|
||||
const rect = document.createElementNS(NS.SVG, 'rect')
|
||||
rect.setAttribute('x', '100')
|
||||
rect.setAttribute('y', '100')
|
||||
rect.setAttribute('width', '50')
|
||||
rect.setAttribute('height', '50')
|
||||
svg.append(rect)
|
||||
|
||||
const attrs = { x: 100, y: 100, width: 50, height: 50 }
|
||||
const m = svg.createSVGMatrix()
|
||||
m.a = -1
|
||||
m.d = -1
|
||||
coords.remapElement(rect, attrs, m)
|
||||
|
||||
// Width and height should remain positive
|
||||
assert.ok(Number.parseFloat(rect.getAttribute('width')) > 0)
|
||||
assert.ok(Number.parseFloat(rect.getAttribute('height')) > 0)
|
||||
})
|
||||
|
||||
it('Test remapElement with path containing curves', function () {
|
||||
const path = document.createElementNS(NS.SVG, 'path')
|
||||
path.setAttribute('d', 'M10,10 C20,20 30,30 40,40')
|
||||
svg.append(path)
|
||||
|
||||
const attrs = {}
|
||||
const m = svg.createSVGMatrix()
|
||||
m.a = 2
|
||||
m.d = 2
|
||||
m.e = 5
|
||||
m.f = 5
|
||||
coords.remapElement(path, attrs, m)
|
||||
|
||||
const d = path.getAttribute('d')
|
||||
// Path should be transformed (coordinates change)
|
||||
assert.ok(d !== 'M10,10 C20,20 30,30 40,40')
|
||||
})
|
||||
|
||||
it('Test remapElement with stroke gradient', function () {
|
||||
const defs = document.createElementNS(NS.SVG, 'defs')
|
||||
svg.append(defs)
|
||||
|
||||
const grad = document.createElementNS(NS.SVG, 'linearGradient')
|
||||
grad.id = 'strokeGrad'
|
||||
grad.setAttribute('x1', '0%')
|
||||
grad.setAttribute('x2', '100%')
|
||||
defs.append(grad)
|
||||
|
||||
const rect = document.createElementNS(NS.SVG, 'rect')
|
||||
rect.setAttribute('stroke', 'url(#strokeGrad)')
|
||||
rect.setAttribute('width', '100')
|
||||
rect.setAttribute('height', '100')
|
||||
svg.append(rect)
|
||||
|
||||
const attrs = { x: 0, y: 0, width: 100, height: 100 }
|
||||
const m = svg.createSVGMatrix()
|
||||
m.a = -1
|
||||
m.d = 1
|
||||
coords.remapElement(rect, attrs, m)
|
||||
|
||||
// Should mirror the stroke gradient or keep original
|
||||
assert.ok(svg.querySelectorAll('linearGradient').length >= 1)
|
||||
// Verify stroke attribute is preserved
|
||||
assert.ok(rect.getAttribute('stroke').includes('url'))
|
||||
})
|
||||
|
||||
it('Test remapElement with invalid gradient reference', function () {
|
||||
const rect = document.createElementNS(NS.SVG, 'rect')
|
||||
rect.setAttribute('fill', 'url(#nonexistentGrad)')
|
||||
rect.setAttribute('width', '100')
|
||||
rect.setAttribute('height', '100')
|
||||
svg.append(rect)
|
||||
|
||||
const attrs = { x: 0, y: 0, width: 100, height: 100 }
|
||||
const m = svg.createSVGMatrix()
|
||||
m.a = -1
|
||||
m.d = 1
|
||||
coords.remapElement(rect, attrs, m)
|
||||
|
||||
// Should not crash, gradient stays as is
|
||||
assert.equal(rect.getAttribute('fill'), 'url(#nonexistentGrad)')
|
||||
})
|
||||
|
||||
it('Test remapElement with rect and skewX transform', function () {
|
||||
const rect = document.createElementNS(NS.SVG, 'rect')
|
||||
rect.setAttribute('x', '10')
|
||||
rect.setAttribute('y', '10')
|
||||
rect.setAttribute('width', '50')
|
||||
rect.setAttribute('height', '50')
|
||||
svg.append(rect)
|
||||
|
||||
const m = svg.createSVGMatrix()
|
||||
m.a = 1
|
||||
m.b = 0.5
|
||||
m.c = 0
|
||||
m.d = 1
|
||||
|
||||
const changes = { x: 10, y: 10, width: 50, height: 50 }
|
||||
coords.remapElement(rect, changes, m)
|
||||
|
||||
// Should apply transform for skew
|
||||
assert.ok(true) // Just test it doesn't crash
|
||||
})
|
||||
|
||||
it('Test remapElement with ellipse and negative radii', function () {
|
||||
const ellipse = document.createElementNS(NS.SVG, 'ellipse')
|
||||
ellipse.setAttribute('cx', '50')
|
||||
ellipse.setAttribute('cy', '50')
|
||||
ellipse.setAttribute('rx', '30')
|
||||
ellipse.setAttribute('ry', '20')
|
||||
svg.append(ellipse)
|
||||
|
||||
const m = svg.createSVGMatrix()
|
||||
m.a = -1
|
||||
m.d = -1
|
||||
|
||||
const changes = { cx: 50, cy: 50, rx: 30, ry: 20 }
|
||||
coords.remapElement(ellipse, changes, m)
|
||||
|
||||
// Radii should remain positive
|
||||
assert.ok(Number.parseFloat(ellipse.getAttribute('rx')) > 0)
|
||||
assert.ok(Number.parseFloat(ellipse.getAttribute('ry')) > 0)
|
||||
})
|
||||
|
||||
it('Test remapElement with circle and scale', function () {
|
||||
const circle = document.createElementNS(NS.SVG, 'circle')
|
||||
circle.setAttribute('cx', '50')
|
||||
circle.setAttribute('cy', '50')
|
||||
circle.setAttribute('r', '25')
|
||||
svg.append(circle)
|
||||
|
||||
const m = svg.createSVGMatrix()
|
||||
m.a = 2
|
||||
m.d = 2
|
||||
|
||||
const changes = { cx: 50, cy: 50, r: 25 }
|
||||
coords.remapElement(circle, changes, m)
|
||||
|
||||
assert.ok(circle.getAttribute('cx') !== '50' ||
|
||||
circle.getAttribute('r') !== '25')
|
||||
})
|
||||
|
||||
it('Test remapElement with line and rotation', function () {
|
||||
const line = document.createElementNS(NS.SVG, 'line')
|
||||
line.setAttribute('x1', '0')
|
||||
line.setAttribute('y1', '0')
|
||||
line.setAttribute('x2', '10')
|
||||
line.setAttribute('y2', '10')
|
||||
svg.append(line)
|
||||
|
||||
const m = svg.createSVGMatrix()
|
||||
m.a = 0
|
||||
m.b = 1
|
||||
m.c = -1
|
||||
m.d = 0
|
||||
|
||||
const changes = { x1: 0, y1: 0, x2: 10, y2: 10 }
|
||||
coords.remapElement(line, changes, m)
|
||||
|
||||
// Line should be remapped
|
||||
assert.ok(true)
|
||||
})
|
||||
|
||||
it('Test remapElement with path d attribute update', function () {
|
||||
const path = document.createElementNS(NS.SVG, 'path')
|
||||
path.setAttribute('d', 'M 10,10 L 20,20')
|
||||
svg.append(path)
|
||||
|
||||
const m = svg.createSVGMatrix()
|
||||
m.a = 2
|
||||
m.d = 2
|
||||
|
||||
const changes = { d: 'M 10,10 L 20,20' }
|
||||
coords.remapElement(path, changes, m)
|
||||
|
||||
assert.ok(path.getAttribute('d') !== null)
|
||||
})
|
||||
|
||||
it('Test remapElement with rect having both fill and stroke gradients', function () {
|
||||
const fillGrad = document.createElementNS(NS.SVG, 'linearGradient')
|
||||
fillGrad.setAttribute('id', 'fillGradientTest')
|
||||
fillGrad.setAttribute('x1', '0')
|
||||
fillGrad.setAttribute('x2', '1')
|
||||
svg.append(fillGrad)
|
||||
|
||||
const strokeGrad = document.createElementNS(NS.SVG, 'linearGradient')
|
||||
strokeGrad.setAttribute('id', 'strokeGradientTest')
|
||||
strokeGrad.setAttribute('y1', '0')
|
||||
strokeGrad.setAttribute('y2', '1')
|
||||
svg.append(strokeGrad)
|
||||
|
||||
const rect = document.createElementNS(NS.SVG, 'rect')
|
||||
rect.setAttribute('fill', 'url(#fillGradientTest)')
|
||||
rect.setAttribute('stroke', 'url(#strokeGradientTest)')
|
||||
rect.setAttribute('width', '100')
|
||||
rect.setAttribute('height', '100')
|
||||
svg.append(rect)
|
||||
|
||||
const attrs = { x: 0, y: 0, width: 100, height: 100 }
|
||||
const m = svg.createSVGMatrix()
|
||||
m.a = -1
|
||||
m.d = 1
|
||||
coords.remapElement(rect, attrs, m)
|
||||
|
||||
assert.ok(svg.querySelectorAll('linearGradient').length >= 2)
|
||||
})
|
||||
|
||||
it('Test remapElement with zero-width rect', function () {
|
||||
const rect = document.createElementNS(NS.SVG, 'rect')
|
||||
rect.setAttribute('x', '10')
|
||||
rect.setAttribute('y', '10')
|
||||
rect.setAttribute('width', '0')
|
||||
rect.setAttribute('height', '50')
|
||||
svg.append(rect)
|
||||
|
||||
const m = svg.createSVGMatrix()
|
||||
m.a = 2
|
||||
m.d = 2
|
||||
|
||||
const changes = { x: 10, y: 10, width: 0, height: 50 }
|
||||
coords.remapElement(rect, changes, m)
|
||||
|
||||
assert.ok(true) // Should not crash
|
||||
})
|
||||
|
||||
it('Test remapElement with zero-height rect', function () {
|
||||
const rect = document.createElementNS(NS.SVG, 'rect')
|
||||
rect.setAttribute('x', '10')
|
||||
rect.setAttribute('y', '10')
|
||||
rect.setAttribute('width', '50')
|
||||
rect.setAttribute('height', '0')
|
||||
svg.append(rect)
|
||||
|
||||
const m = svg.createSVGMatrix()
|
||||
m.a = 2
|
||||
m.d = 2
|
||||
|
||||
const changes = { x: 10, y: 10, width: 50, height: 0 }
|
||||
coords.remapElement(rect, changes, m)
|
||||
|
||||
assert.ok(true) // Should not crash
|
||||
})
|
||||
|
||||
it('Test remapElement with zero-radius circle', function () {
|
||||
const circle = document.createElementNS(NS.SVG, 'circle')
|
||||
circle.setAttribute('cx', '50')
|
||||
circle.setAttribute('cy', '50')
|
||||
circle.setAttribute('r', '0')
|
||||
svg.append(circle)
|
||||
|
||||
const m = svg.createSVGMatrix()
|
||||
m.a = 2
|
||||
m.d = 2
|
||||
|
||||
const changes = { cx: 50, cy: 50, r: 0 }
|
||||
coords.remapElement(circle, changes, m)
|
||||
|
||||
assert.ok(true) // Should not crash
|
||||
})
|
||||
|
||||
it('Test remapElement with symbol element', function () {
|
||||
const symbol = document.createElementNS(NS.SVG, 'symbol')
|
||||
symbol.setAttribute('viewBox', '0 0 100 100')
|
||||
svg.append(symbol)
|
||||
|
||||
const m = svg.createSVGMatrix()
|
||||
m.a = 2
|
||||
m.d = 2
|
||||
|
||||
const changes = {}
|
||||
coords.remapElement(symbol, changes, m)
|
||||
|
||||
assert.ok(true)
|
||||
})
|
||||
|
||||
it('Test remapElement with defs element', function () {
|
||||
const defs = document.createElementNS(NS.SVG, 'defs')
|
||||
svg.append(defs)
|
||||
|
||||
const m = svg.createSVGMatrix()
|
||||
m.a = 2
|
||||
m.d = 2
|
||||
|
||||
const changes = {}
|
||||
coords.remapElement(defs, changes, m)
|
||||
|
||||
assert.ok(true)
|
||||
})
|
||||
|
||||
it('Test remapElement with marker element', function () {
|
||||
const marker = document.createElementNS(NS.SVG, 'marker')
|
||||
marker.setAttribute('markerWidth', '10')
|
||||
marker.setAttribute('markerHeight', '10')
|
||||
svg.append(marker)
|
||||
|
||||
const m = svg.createSVGMatrix()
|
||||
m.a = 2
|
||||
m.d = 2
|
||||
|
||||
const changes = {}
|
||||
coords.remapElement(marker, changes, m)
|
||||
|
||||
assert.ok(true)
|
||||
})
|
||||
|
||||
it('Test remapElement with style element', function () {
|
||||
const style = document.createElementNS(NS.SVG, 'style')
|
||||
style.textContent = '.cls { fill: red; }'
|
||||
svg.append(style)
|
||||
|
||||
const m = svg.createSVGMatrix()
|
||||
m.a = 2
|
||||
m.d = 2
|
||||
|
||||
const changes = {}
|
||||
coords.remapElement(style, changes, m)
|
||||
|
||||
assert.ok(true)
|
||||
})
|
||||
})
|
||||
|
||||
89
tests/unit/copy-elem.test.js
Normal file
89
tests/unit/copy-elem.test.js
Normal file
@@ -0,0 +1,89 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { copyElem } from '../../packages/svgcanvas/core/copy-elem.js'
|
||||
import dataStorage from '../../packages/svgcanvas/core/dataStorage.js'
|
||||
|
||||
const NS_SVG = 'http://www.w3.org/2000/svg'
|
||||
|
||||
const buildIdGenerator = () => {
|
||||
let next = 0
|
||||
return () => `svg_${++next}`
|
||||
}
|
||||
|
||||
describe('copyElem', () => {
|
||||
it('clones elements and assigns new ids', () => {
|
||||
const getNextId = buildIdGenerator()
|
||||
const group = document.createElementNS(NS_SVG, 'g')
|
||||
group.id = 'old_group'
|
||||
group.setAttribute('fill', 'red')
|
||||
const rect = document.createElementNS(NS_SVG, 'rect')
|
||||
rect.id = 'old_rect'
|
||||
group.append(rect)
|
||||
|
||||
const cloned = copyElem(group, getNextId)
|
||||
|
||||
expect(cloned.id).toBe('svg_1')
|
||||
expect(cloned.getAttribute('fill')).toBe('red')
|
||||
expect(cloned.querySelector('rect')?.id).toBe('svg_2')
|
||||
})
|
||||
|
||||
it('preserves mixed content order', () => {
|
||||
const getNextId = buildIdGenerator()
|
||||
const text = document.createElementNS(NS_SVG, 'text')
|
||||
text.append(document.createTextNode('hello '))
|
||||
const tspan = document.createElementNS(NS_SVG, 'tspan')
|
||||
tspan.append(document.createTextNode('world'))
|
||||
text.append(tspan)
|
||||
text.append(document.createTextNode('!'))
|
||||
|
||||
const cloned = copyElem(text, getNextId)
|
||||
|
||||
expect(cloned.childNodes[0].nodeType).toBe(Node.TEXT_NODE)
|
||||
expect(cloned.childNodes[0].nodeValue).toBe('hello ')
|
||||
expect(cloned.childNodes[1].nodeName.toLowerCase()).toBe('tspan')
|
||||
expect(cloned.childNodes[2].nodeType).toBe(Node.TEXT_NODE)
|
||||
expect(cloned.childNodes[2].nodeValue).toBe('!')
|
||||
expect(cloned.textContent).toBe('hello world!')
|
||||
})
|
||||
|
||||
it('copies gsvg dataStorage to the cloned element', () => {
|
||||
const getNextId = buildIdGenerator()
|
||||
const group = document.createElementNS(NS_SVG, 'g')
|
||||
const innerSvg = document.createElementNS(NS_SVG, 'svg')
|
||||
innerSvg.append(document.createElementNS(NS_SVG, 'rect'))
|
||||
group.append(innerSvg)
|
||||
dataStorage.put(group, 'gsvg', innerSvg)
|
||||
|
||||
const cloned = copyElem(group, getNextId)
|
||||
const clonedSvg = cloned.firstElementChild
|
||||
|
||||
expect(dataStorage.has(cloned, 'gsvg')).toBe(true)
|
||||
expect(dataStorage.get(cloned, 'gsvg')).toBe(clonedSvg)
|
||||
expect(dataStorage.get(cloned, 'gsvg')).not.toBe(innerSvg)
|
||||
})
|
||||
|
||||
it('copies symbol/ref dataStorage to the cloned element', () => {
|
||||
const getNextId = buildIdGenerator()
|
||||
const symbol = document.createElementNS(NS_SVG, 'symbol')
|
||||
symbol.id = 'sym1'
|
||||
const use = document.createElementNS(NS_SVG, 'use')
|
||||
use.setAttribute('href', '#sym1')
|
||||
dataStorage.put(use, 'ref', symbol)
|
||||
dataStorage.put(use, 'symbol', symbol)
|
||||
|
||||
const cloned = copyElem(use, getNextId)
|
||||
|
||||
expect(dataStorage.get(cloned, 'ref')).toBe(symbol)
|
||||
expect(dataStorage.get(cloned, 'symbol')).toBe(symbol)
|
||||
})
|
||||
|
||||
it('prevents default click behaviour on cloned images', () => {
|
||||
const getNextId = buildIdGenerator()
|
||||
const image = document.createElementNS(NS_SVG, 'image')
|
||||
|
||||
const cloned = copyElem(image, getNextId)
|
||||
const evt = new MouseEvent('click', { bubbles: true, cancelable: true })
|
||||
cloned.dispatchEvent(evt)
|
||||
|
||||
expect(evt.defaultPrevented).toBe(true)
|
||||
})
|
||||
})
|
||||
@@ -17,6 +17,20 @@ describe('dataStorage', () => {
|
||||
expect(dataStorage.get(el2, 'color')).toBe('blue')
|
||||
})
|
||||
|
||||
it('returns safe defaults for missing or invalid elements', () => {
|
||||
const el = document.createElement('div')
|
||||
|
||||
expect(dataStorage.get(el, 'missing')).toBeUndefined()
|
||||
expect(dataStorage.has(el, 'missing')).toBe(false)
|
||||
expect(dataStorage.remove(el, 'missing')).toBe(false)
|
||||
|
||||
expect(dataStorage.get(null, 'missing')).toBeUndefined()
|
||||
expect(dataStorage.has(null, 'missing')).toBe(false)
|
||||
expect(dataStorage.remove(null, 'missing')).toBe(false)
|
||||
|
||||
expect(() => dataStorage.put(null, 'key', 'value')).not.toThrow()
|
||||
})
|
||||
|
||||
it('removes values and cleans up empty element maps', () => {
|
||||
const el = document.createElement('span')
|
||||
dataStorage.put(el, 'foo', 1)
|
||||
|
||||
75
tests/unit/draw-context.test.js
Normal file
75
tests/unit/draw-context.test.js
Normal file
@@ -0,0 +1,75 @@
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
||||
import * as draw from '../../packages/svgcanvas/core/draw.js'
|
||||
import dataStorage from '../../packages/svgcanvas/core/dataStorage.js'
|
||||
|
||||
const NS_SVG = 'http://www.w3.org/2000/svg'
|
||||
|
||||
describe('draw context', () => {
|
||||
let currentGroup = null
|
||||
/** @type {{event: string, arg: any}[]} */
|
||||
const calls = []
|
||||
let svgContent
|
||||
let editGroup
|
||||
let sibling
|
||||
|
||||
const canvas = {
|
||||
getDataStorage: () => dataStorage,
|
||||
getSvgContent: () => svgContent,
|
||||
clearSelection: () => {},
|
||||
call: (event, arg) => {
|
||||
calls.push({ event, arg })
|
||||
},
|
||||
getCurrentGroup: () => currentGroup,
|
||||
setCurrentGroup: (group) => {
|
||||
currentGroup = group
|
||||
}
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
draw.init(canvas)
|
||||
draw.leaveContext()
|
||||
|
||||
currentGroup = null
|
||||
calls.length = 0
|
||||
document.body.innerHTML = ''
|
||||
|
||||
svgContent = document.createElementNS(NS_SVG, 'svg')
|
||||
svgContent.id = 'svgcontent'
|
||||
editGroup = document.createElementNS(NS_SVG, 'g')
|
||||
editGroup.id = 'edit'
|
||||
sibling = document.createElementNS(NS_SVG, 'rect')
|
||||
sibling.id = 'sib'
|
||||
sibling.setAttribute('opacity', 'inherit')
|
||||
svgContent.append(editGroup, sibling)
|
||||
document.body.append(svgContent)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
draw.leaveContext()
|
||||
document.body.innerHTML = ''
|
||||
})
|
||||
|
||||
it('ignores unknown element ids', () => {
|
||||
expect(() => draw.setContext('does-not-exist')).not.toThrow()
|
||||
expect(currentGroup).toBe(null)
|
||||
expect(calls.length).toBe(0)
|
||||
})
|
||||
|
||||
it('handles non-numeric opacity and restores it', () => {
|
||||
draw.setContext(editGroup)
|
||||
|
||||
expect(currentGroup).toBe(editGroup)
|
||||
expect(calls[0]).toStrictEqual({ event: 'contextset', arg: editGroup })
|
||||
expect(sibling.getAttribute('opacity')).toBe('0.33')
|
||||
expect(sibling.getAttribute('style')).toBe('pointer-events: none')
|
||||
expect(dataStorage.get(sibling, 'orig_opac')).toBe('inherit')
|
||||
|
||||
draw.leaveContext()
|
||||
|
||||
expect(currentGroup).toBe(null)
|
||||
expect(calls[1]).toStrictEqual({ event: 'contextset', arg: null })
|
||||
expect(sibling.getAttribute('opacity')).toBe('inherit')
|
||||
expect(sibling.getAttribute('style')).toBe('pointer-events: inherit')
|
||||
expect(dataStorage.has(sibling, 'orig_opac')).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -670,6 +670,24 @@ describe('draw.Drawing', function () {
|
||||
cleanupSVG(svg)
|
||||
})
|
||||
|
||||
it('Test cloneLayer() with non-element nodes', function () {
|
||||
const drawing = new draw.Drawing(svg)
|
||||
const layers = setupSVGWith3Layers(svg)
|
||||
const layer3 = layers[2]
|
||||
createSomeElementsInGroup(layer3)
|
||||
layer3.insertBefore(document.createTextNode('\n '), layer3.childNodes[1])
|
||||
layer3.append(document.createComment('test-comment'))
|
||||
drawing.identifyLayers()
|
||||
|
||||
const clone = drawing.cloneLayer('clone2')
|
||||
|
||||
assert.ok(clone)
|
||||
assert.ok([...clone.childNodes].some(node => node.nodeType === Node.TEXT_NODE))
|
||||
assert.ok([...clone.childNodes].some(node => node.nodeType === Node.COMMENT_NODE))
|
||||
|
||||
cleanupSVG(svg)
|
||||
})
|
||||
|
||||
it('Test getLayerVisibility()', function () {
|
||||
const drawing = new draw.Drawing(svg)
|
||||
setupSVGWith3Layers(svg)
|
||||
|
||||
235
tests/unit/elem-get-set.test.js
Normal file
235
tests/unit/elem-get-set.test.js
Normal file
@@ -0,0 +1,235 @@
|
||||
import { beforeEach, afterEach, describe, expect, it } from 'vitest'
|
||||
import { NS } from '../../packages/svgcanvas/core/namespaces.js'
|
||||
import * as history from '../../packages/svgcanvas/core/history.js'
|
||||
import dataStorage from '../../packages/svgcanvas/core/dataStorage.js'
|
||||
import { init as initElemGetSet } from '../../packages/svgcanvas/core/elem-get-set.js'
|
||||
import * as undo from '../../packages/svgcanvas/core/undo.js'
|
||||
|
||||
const createSvgElement = (name) => {
|
||||
return document.createElementNS(NS.SVG, name)
|
||||
}
|
||||
|
||||
describe('elem-get-set', () => {
|
||||
/** @type {any} */
|
||||
let canvas
|
||||
/** @type {any[]} */
|
||||
let historyStack
|
||||
/** @type {SVGSVGElement} */
|
||||
let svgContent
|
||||
|
||||
beforeEach(() => {
|
||||
historyStack = []
|
||||
svgContent = /** @type {SVGSVGElement} */ (createSvgElement('svg'))
|
||||
canvas = {
|
||||
history,
|
||||
zoom: 1,
|
||||
contentW: 100,
|
||||
contentH: 100,
|
||||
selectorManager: {
|
||||
requestSelector () {
|
||||
return { resize () {} }
|
||||
}
|
||||
},
|
||||
pathActions: {
|
||||
zoomChange () {},
|
||||
clear () {}
|
||||
},
|
||||
runExtensions () {},
|
||||
call () {},
|
||||
getDOMDocument () { return document },
|
||||
getSvgContent () { return svgContent },
|
||||
getSelectedElements () { return this.selectedElements || [] },
|
||||
getDataStorage () { return dataStorage },
|
||||
getZoom () { return this.zoom },
|
||||
setZoom (value) { this.zoom = value },
|
||||
getResolution () {
|
||||
return {
|
||||
w: Number(svgContent.getAttribute('width')) / this.zoom,
|
||||
h: Number(svgContent.getAttribute('height')) / this.zoom,
|
||||
zoom: this.zoom
|
||||
}
|
||||
},
|
||||
addCommandToHistory (cmd) {
|
||||
historyStack.push(cmd)
|
||||
}
|
||||
}
|
||||
svgContent.setAttribute('width', '100')
|
||||
svgContent.setAttribute('height', '100')
|
||||
initElemGetSet(canvas)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
while (svgContent.firstChild) {
|
||||
svgContent.firstChild.remove()
|
||||
}
|
||||
})
|
||||
|
||||
it('setGroupTitle() inserts title and undo removes it', () => {
|
||||
const g = createSvgElement('g')
|
||||
svgContent.append(g)
|
||||
canvas.selectedElements = [g]
|
||||
|
||||
canvas.setGroupTitle('Hello')
|
||||
expect(g.firstChild?.nodeName).toBe('title')
|
||||
expect(g.firstChild?.textContent).toBe('Hello')
|
||||
expect(historyStack).toHaveLength(1)
|
||||
|
||||
historyStack[0].unapply(null)
|
||||
expect(g.querySelector('title')).toBeNull()
|
||||
|
||||
historyStack[0].apply(null)
|
||||
expect(g.querySelector('title')?.textContent).toBe('Hello')
|
||||
})
|
||||
|
||||
it('setGroupTitle() updates title text with undo/redo', () => {
|
||||
const g = createSvgElement('g')
|
||||
const title = createSvgElement('title')
|
||||
title.textContent = 'Old'
|
||||
g.append(title)
|
||||
svgContent.append(g)
|
||||
canvas.selectedElements = [g]
|
||||
|
||||
canvas.setGroupTitle('New')
|
||||
expect(g.querySelector('title')?.textContent).toBe('New')
|
||||
expect(historyStack).toHaveLength(1)
|
||||
|
||||
historyStack[0].unapply(null)
|
||||
expect(g.querySelector('title')?.textContent).toBe('Old')
|
||||
|
||||
historyStack[0].apply(null)
|
||||
expect(g.querySelector('title')?.textContent).toBe('New')
|
||||
})
|
||||
|
||||
it('setGroupTitle() removes title and undo restores it', () => {
|
||||
const g = createSvgElement('g')
|
||||
const title = createSvgElement('title')
|
||||
title.textContent = 'Label'
|
||||
g.append(title)
|
||||
svgContent.append(g)
|
||||
canvas.selectedElements = [g]
|
||||
|
||||
canvas.setGroupTitle('')
|
||||
expect(g.querySelector('title')).toBeNull()
|
||||
expect(historyStack).toHaveLength(1)
|
||||
|
||||
historyStack[0].unapply(null)
|
||||
expect(g.querySelector('title')?.textContent).toBe('Label')
|
||||
|
||||
historyStack[0].apply(null)
|
||||
expect(g.querySelector('title')).toBeNull()
|
||||
})
|
||||
|
||||
it('setDocumentTitle() inserts and removes title with undo/redo', () => {
|
||||
canvas.setDocumentTitle('Doc')
|
||||
const docTitle = svgContent.querySelector(':scope > title')
|
||||
expect(docTitle?.textContent).toBe('Doc')
|
||||
expect(historyStack).toHaveLength(1)
|
||||
|
||||
historyStack[0].unapply(null)
|
||||
expect(svgContent.querySelector(':scope > title')).toBeNull()
|
||||
|
||||
historyStack[0].apply(null)
|
||||
expect(svgContent.querySelector(':scope > title')?.textContent).toBe('Doc')
|
||||
})
|
||||
|
||||
it('setDocumentTitle() does nothing when empty and no title exists', () => {
|
||||
canvas.setDocumentTitle('')
|
||||
expect(svgContent.querySelector(':scope > title')).toBeNull()
|
||||
expect(historyStack).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('setBBoxZoom() returns the computed zoom for zero-size bbox', () => {
|
||||
canvas.zoom = 1
|
||||
canvas.selectedElements = [createSvgElement('rect')]
|
||||
|
||||
const bbox = { width: 0, height: 0, x: 0, y: 0, factor: 2 }
|
||||
const result = canvas.setBBoxZoom(bbox, 100, 100)
|
||||
|
||||
expect(result?.zoom).toBe(2)
|
||||
expect(canvas.getZoom()).toBe(2)
|
||||
})
|
||||
|
||||
it('setImageURL() records undo even when image fails to load', () => {
|
||||
const originalImage = globalThis.Image
|
||||
try {
|
||||
globalThis.Image = class FakeImage {
|
||||
constructor () {
|
||||
this.width = 10
|
||||
this.height = 10
|
||||
this.onload = null
|
||||
this.onerror = null
|
||||
}
|
||||
|
||||
get src () {
|
||||
return this._src
|
||||
}
|
||||
|
||||
set src (value) {
|
||||
this._src = value
|
||||
this.onerror && this.onerror(new Error('load failed'))
|
||||
}
|
||||
}
|
||||
|
||||
const image = createSvgElement('image')
|
||||
image.setAttribute('href', 'old.png')
|
||||
svgContent.append(image)
|
||||
canvas.selectedElements = [image]
|
||||
|
||||
canvas.setImageURL('bad.png')
|
||||
expect(image.getAttribute('href')).toBe('bad.png')
|
||||
expect(historyStack).toHaveLength(1)
|
||||
|
||||
historyStack[0].unapply(null)
|
||||
expect(image.getAttribute('href')).toBe('old.png')
|
||||
|
||||
historyStack[0].apply(null)
|
||||
expect(image.getAttribute('href')).toBe('bad.png')
|
||||
} finally {
|
||||
globalThis.Image = originalImage
|
||||
}
|
||||
})
|
||||
|
||||
it('setRectRadius() preserves attribute absence on undo', () => {
|
||||
const rect = createSvgElement('rect')
|
||||
svgContent.append(rect)
|
||||
canvas.selectedElements = [rect]
|
||||
|
||||
canvas.setRectRadius('5')
|
||||
expect(rect.getAttribute('rx')).toBe('5')
|
||||
expect(rect.getAttribute('ry')).toBe('5')
|
||||
expect(historyStack).toHaveLength(1)
|
||||
|
||||
historyStack[0].unapply(null)
|
||||
expect(rect.hasAttribute('rx')).toBe(false)
|
||||
expect(rect.hasAttribute('ry')).toBe(false)
|
||||
})
|
||||
|
||||
it('undo updates contentW/contentH for svgContent size changes', () => {
|
||||
const svg = createSvgElement('svg')
|
||||
|
||||
const localCanvas = {
|
||||
contentW: 100,
|
||||
contentH: 100,
|
||||
getSvgContent () { return svg },
|
||||
clearSelection () {},
|
||||
pathActions: { clear () {} },
|
||||
call () {}
|
||||
}
|
||||
undo.init(localCanvas)
|
||||
|
||||
svg.setAttribute('width', '200')
|
||||
svg.setAttribute('height', '150')
|
||||
localCanvas.contentW = 200
|
||||
localCanvas.contentH = 150
|
||||
const cmd = new history.ChangeElementCommand(svg, { width: 100, height: 100 })
|
||||
localCanvas.undoMgr.addCommandToHistory(cmd)
|
||||
|
||||
localCanvas.undoMgr.undo()
|
||||
expect(localCanvas.contentW).toBe(100)
|
||||
expect(localCanvas.contentH).toBe(100)
|
||||
|
||||
localCanvas.undoMgr.redo()
|
||||
expect(localCanvas.contentW).toBe(200)
|
||||
expect(localCanvas.contentH).toBe(150)
|
||||
})
|
||||
})
|
||||
194
tests/unit/event.test.js
Normal file
194
tests/unit/event.test.js
Normal file
@@ -0,0 +1,194 @@
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
||||
import { NS } from '../../packages/svgcanvas/core/namespaces.js'
|
||||
import { init as initEvent } from '../../packages/svgcanvas/core/event.js'
|
||||
|
||||
const createSvgElement = (name) => {
|
||||
return document.createElementNS(NS.SVG, name)
|
||||
}
|
||||
|
||||
describe('event', () => {
|
||||
/** @type {HTMLDivElement} */
|
||||
let root
|
||||
/** @type {any} */
|
||||
let canvas
|
||||
/** @type {HTMLDivElement} */
|
||||
let svgcanvas
|
||||
/** @type {SVGSVGElement} */
|
||||
let svgcontent
|
||||
/** @type {SVGGElement} */
|
||||
let contentGroup
|
||||
/** @type {SVGRectElement} */
|
||||
let rubberBox
|
||||
|
||||
beforeEach(() => {
|
||||
root = document.createElement('div')
|
||||
root.id = 'root'
|
||||
document.body.append(root)
|
||||
|
||||
svgcanvas = document.createElement('div')
|
||||
svgcanvas.id = 'svgcanvas'
|
||||
root.append(svgcanvas)
|
||||
|
||||
svgcontent = /** @type {SVGSVGElement} */ (createSvgElement('svg'))
|
||||
svgcontent.id = 'svgcontent'
|
||||
root.append(svgcontent)
|
||||
|
||||
contentGroup = /** @type {SVGGElement} */ (createSvgElement('g'))
|
||||
svgcontent.append(contentGroup)
|
||||
|
||||
contentGroup.getScreenCTM = () => ({
|
||||
inverse: () => ({
|
||||
a: 1,
|
||||
b: 0,
|
||||
c: 0,
|
||||
d: 1,
|
||||
e: 0,
|
||||
f: 0
|
||||
})
|
||||
})
|
||||
|
||||
Object.defineProperty(contentGroup, 'transform', {
|
||||
value: { baseVal: { numberOfItems: 0 } },
|
||||
configurable: true
|
||||
})
|
||||
|
||||
rubberBox = /** @type {SVGRectElement} */ (createSvgElement('rect'))
|
||||
|
||||
canvas = {
|
||||
spaceKey: false,
|
||||
started: false,
|
||||
rootSctm: { a: 1, b: 0, c: 0, d: 1, e: 0, f: 0 },
|
||||
rubberBox: null,
|
||||
selectorManager: {
|
||||
selectorParentGroup: createSvgElement('g'),
|
||||
getRubberBandBox () {
|
||||
return rubberBox
|
||||
}
|
||||
},
|
||||
$id (id) {
|
||||
return document.getElementById(id)
|
||||
},
|
||||
getDataStorage () {
|
||||
return { get () {} }
|
||||
},
|
||||
getSelectedElements () {
|
||||
return []
|
||||
},
|
||||
getZoom () {
|
||||
return 1
|
||||
},
|
||||
getStyle () {
|
||||
return { opacity: 1 }
|
||||
},
|
||||
getSvgRoot () {
|
||||
return svgcontent
|
||||
},
|
||||
getCurConfig () {
|
||||
return { gridSnapping: false, showRulers: false }
|
||||
},
|
||||
setRootSctm (m) {
|
||||
this.rootSctm = m
|
||||
},
|
||||
getrootSctm () {
|
||||
return this.rootSctm
|
||||
},
|
||||
getStarted () {
|
||||
return this.started
|
||||
},
|
||||
setStarted (started) {
|
||||
this.started = started
|
||||
},
|
||||
setStartX (x) {
|
||||
this.startX = x
|
||||
},
|
||||
setStartY (y) {
|
||||
this.startY = y
|
||||
},
|
||||
getStartX () {
|
||||
return this.startX
|
||||
},
|
||||
getStartY () {
|
||||
return this.startY
|
||||
},
|
||||
setRStartX (x) {
|
||||
this.rStartX = x
|
||||
},
|
||||
setRStartY (y) {
|
||||
this.rStartY = y
|
||||
},
|
||||
getMouseTarget () {
|
||||
return contentGroup
|
||||
},
|
||||
getCurrentMode () {
|
||||
return this.currentMode || 'zoom'
|
||||
},
|
||||
setCurrentMode (mode) {
|
||||
this.currentMode = mode
|
||||
},
|
||||
setMode () {},
|
||||
setLastClickPoint () {},
|
||||
setStartTransform () {},
|
||||
clearSelection () {},
|
||||
setCurrentResizeMode () {},
|
||||
setJustSelected () {},
|
||||
pathActions: {
|
||||
clear () {}
|
||||
},
|
||||
setRubberBox (box) {
|
||||
this.rubberBox = box
|
||||
},
|
||||
getRubberBox () {
|
||||
return this.rubberBox
|
||||
},
|
||||
runExtensions () {
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
initEvent(canvas)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
root.remove()
|
||||
})
|
||||
|
||||
it('mouseDownEvent() zoom mode uses clientY for rubberbox y', () => {
|
||||
canvas.setCurrentMode('zoom')
|
||||
canvas.mouseDownEvent({
|
||||
clientX: 10,
|
||||
clientY: 20,
|
||||
button: 0,
|
||||
altKey: false,
|
||||
shiftKey: false,
|
||||
preventDefault () {},
|
||||
target: contentGroup
|
||||
})
|
||||
|
||||
expect(rubberBox.getAttribute('x')).toBe('10')
|
||||
expect(rubberBox.getAttribute('y')).toBe('20')
|
||||
})
|
||||
|
||||
it('mouseOutEvent() dispatches mouseup with coordinates', () => {
|
||||
canvas.setCurrentMode('rect')
|
||||
canvas.setStarted(true)
|
||||
|
||||
/** @type {{ x: number, y: number }|null} */
|
||||
let received = null
|
||||
svgcanvas.addEventListener('mouseup', (evt) => {
|
||||
received = { x: evt.clientX, y: evt.clientY }
|
||||
})
|
||||
|
||||
canvas.mouseOutEvent(new MouseEvent('mouseleave', { clientX: 15, clientY: 25 }))
|
||||
|
||||
expect(received).toEqual({ x: 15, y: 25 })
|
||||
})
|
||||
|
||||
it('mouseDownEvent() returns early if root group is missing', () => {
|
||||
while (svgcontent.firstChild) {
|
||||
svgcontent.firstChild.remove()
|
||||
}
|
||||
expect(() => {
|
||||
canvas.mouseDownEvent({ button: 0 })
|
||||
}).not.toThrow()
|
||||
})
|
||||
})
|
||||
@@ -474,6 +474,47 @@ describe('history', function () {
|
||||
change.apply()
|
||||
assert.equal(justCalled, 'setHref')
|
||||
|
||||
// Ensure numeric zero values are not treated like "remove attribute".
|
||||
const rectZero = document.createElementNS(NS.SVG, 'rect')
|
||||
rectZero.setAttribute('x', '5')
|
||||
change = new history.ChangeElementCommand(rectZero, { x: 0 })
|
||||
change.unapply()
|
||||
assert.equal(rectZero.getAttribute('x'), '0')
|
||||
change.apply()
|
||||
assert.equal(rectZero.getAttribute('x'), '5')
|
||||
|
||||
// Ensure "#href" can be removed when the previous value was null.
|
||||
const rectHref = document.createElementNS(NS.SVG, 'rect')
|
||||
rectHref.setAttribute('href', '#newhref')
|
||||
let calls = []
|
||||
utilities.mock({
|
||||
getHref (elem) {
|
||||
assert.equal(elem, rectHref)
|
||||
calls.push('getHref')
|
||||
return rectHref.getAttribute('href')
|
||||
},
|
||||
setHref (elem, val) {
|
||||
assert.equal(elem, rectHref)
|
||||
calls.push('setHref')
|
||||
rectHref.setAttribute('href', val)
|
||||
},
|
||||
getRotationAngle () { return 0 }
|
||||
})
|
||||
|
||||
calls = []
|
||||
change = new history.ChangeElementCommand(rectHref, { '#href': null })
|
||||
assert.deepEqual(calls, ['getHref'])
|
||||
|
||||
calls = []
|
||||
change.unapply()
|
||||
assert.equal(rectHref.hasAttribute('href'), false)
|
||||
assert.deepEqual(calls, [])
|
||||
|
||||
calls = []
|
||||
change.apply()
|
||||
assert.equal(rectHref.getAttribute('href'), '#newhref')
|
||||
assert.deepEqual(calls, ['setHref'])
|
||||
|
||||
const line = document.createElementNS(NS.SVG, 'line')
|
||||
line.setAttribute('class', 'newClass')
|
||||
change = new history.ChangeElementCommand(line, { class: 'oldClass' })
|
||||
@@ -524,4 +565,29 @@ describe('history', function () {
|
||||
|
||||
MockCommand.prototype.unapply = function () { /* empty fn */ }
|
||||
})
|
||||
|
||||
it('Test BatchCommand with elements() method', function () {
|
||||
const batch = new history.BatchCommand('test batch with elements')
|
||||
|
||||
// Create some mock commands that reference elements
|
||||
class MockElementCommand {
|
||||
constructor (elem) { this.elem = elem }
|
||||
elements () { return [this.elem] }
|
||||
apply () { /* empty fn */ }
|
||||
unapply () { /* empty fn */ }
|
||||
getText () { return 'mock' }
|
||||
}
|
||||
|
||||
const elem1 = document.createElementNS(NS.SVG, 'rect')
|
||||
const cmd1 = new MockElementCommand(elem1)
|
||||
batch.addSubCommand(cmd1)
|
||||
|
||||
const elems = batch.elements()
|
||||
assert.ok(Array.isArray(elems))
|
||||
})
|
||||
|
||||
it('Test BatchCommand getText()', function () {
|
||||
const batch = new history.BatchCommand('my test batch')
|
||||
assert.equal(batch.getText(), 'my test batch')
|
||||
})
|
||||
})
|
||||
|
||||
60
tests/unit/historyrecording.test.js
Normal file
60
tests/unit/historyrecording.test.js
Normal file
@@ -0,0 +1,60 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { NS } from '../../packages/svgcanvas/core/namespaces.js'
|
||||
import HistoryRecordingService from '../../packages/svgcanvas/core/historyrecording.js'
|
||||
|
||||
const createSvgElement = (name) => document.createElementNS(NS.SVG, name)
|
||||
|
||||
describe('HistoryRecordingService', () => {
|
||||
it('does not record empty batch commands', () => {
|
||||
const stack = []
|
||||
const hrService = new HistoryRecordingService({
|
||||
addCommandToHistory (cmd) {
|
||||
stack.push(cmd)
|
||||
}
|
||||
})
|
||||
|
||||
hrService.startBatchCommand('Empty').endBatchCommand()
|
||||
expect(stack).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('does not record nested empty batch commands', () => {
|
||||
const stack = []
|
||||
const hrService = new HistoryRecordingService({
|
||||
addCommandToHistory (cmd) {
|
||||
stack.push(cmd)
|
||||
}
|
||||
})
|
||||
|
||||
hrService.startBatchCommand('Outer').startBatchCommand('Inner').endBatchCommand().endBatchCommand()
|
||||
expect(stack).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('records subcommands as a single batch command', () => {
|
||||
const stack = []
|
||||
const hrService = new HistoryRecordingService({
|
||||
addCommandToHistory (cmd) {
|
||||
stack.push(cmd)
|
||||
}
|
||||
})
|
||||
|
||||
const svg = createSvgElement('svg')
|
||||
const rect = createSvgElement('rect')
|
||||
svg.append(rect)
|
||||
|
||||
hrService.startBatchCommand('Batch').insertElement(rect).endBatchCommand()
|
||||
expect(stack).toHaveLength(1)
|
||||
expect(stack[0].type()).toBe('BatchCommand')
|
||||
expect(stack[0].stack).toHaveLength(1)
|
||||
expect(stack[0].stack[0].type()).toBe('InsertElementCommand')
|
||||
})
|
||||
|
||||
it('NO_HISTORY does not throw and does not record', () => {
|
||||
const svg = createSvgElement('svg')
|
||||
const rect = createSvgElement('rect')
|
||||
svg.append(rect)
|
||||
|
||||
expect(() => {
|
||||
HistoryRecordingService.NO_HISTORY.startBatchCommand('Noop').insertElement(rect).endBatchCommand()
|
||||
}).not.toThrow()
|
||||
})
|
||||
})
|
||||
131
tests/unit/json.test.js
Normal file
131
tests/unit/json.test.js
Normal file
@@ -0,0 +1,131 @@
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
||||
import { NS } from '../../packages/svgcanvas/core/namespaces.js'
|
||||
import * as utilities from '../../packages/svgcanvas/core/utilities.js'
|
||||
import {
|
||||
init as initJson,
|
||||
addSVGElementsFromJson,
|
||||
getJsonFromSvgElements
|
||||
} from '../../packages/svgcanvas/core/json.js'
|
||||
|
||||
const createSvgElement = (name) => document.createElementNS(NS.SVG, name)
|
||||
|
||||
describe('json', () => {
|
||||
/** @type {HTMLDivElement} */
|
||||
let root
|
||||
/** @type {SVGSVGElement} */
|
||||
let svgRoot
|
||||
/** @type {SVGGElement} */
|
||||
let layer
|
||||
|
||||
beforeEach(() => {
|
||||
root = document.createElement('div')
|
||||
root.id = 'root'
|
||||
document.body.append(root)
|
||||
|
||||
svgRoot = /** @type {SVGSVGElement} */ (createSvgElement('svg'))
|
||||
svgRoot.id = 'svgroot'
|
||||
root.append(svgRoot)
|
||||
|
||||
layer = /** @type {SVGGElement} */ (createSvgElement('g'))
|
||||
layer.id = 'layer1'
|
||||
svgRoot.append(layer)
|
||||
|
||||
utilities.init({
|
||||
getSvgRoot: () => svgRoot
|
||||
})
|
||||
|
||||
initJson({
|
||||
getDOMDocument: () => document,
|
||||
getSvgRoot: () => svgRoot,
|
||||
getDrawing: () => ({ getCurrentLayer: () => layer }),
|
||||
getCurrentGroup: () => null,
|
||||
getCurShape: () => ({
|
||||
fill: 'none',
|
||||
stroke: '#000000',
|
||||
stroke_width: 1,
|
||||
stroke_dasharray: 'none',
|
||||
stroke_linejoin: 'miter',
|
||||
stroke_linecap: 'butt',
|
||||
stroke_opacity: 1,
|
||||
fill_opacity: 1,
|
||||
opacity: 1
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
root.remove()
|
||||
})
|
||||
|
||||
it('getJsonFromSvgElements() ignores comment nodes', () => {
|
||||
const g = createSvgElement('g')
|
||||
const comment = document.createComment('hi')
|
||||
const rect = createSvgElement('rect')
|
||||
rect.setAttribute('x', '1')
|
||||
g.append(comment, rect)
|
||||
|
||||
const json = getJsonFromSvgElements(g)
|
||||
expect(json.element).toBe('g')
|
||||
expect(json.children).toHaveLength(1)
|
||||
expect(json.children[0].element).toBe('rect')
|
||||
})
|
||||
|
||||
it('addSVGElementsFromJson() does not treat missing id as "undefined"', () => {
|
||||
const existing = createSvgElement('rect')
|
||||
existing.id = 'undefined'
|
||||
layer.append(existing)
|
||||
|
||||
const circle = addSVGElementsFromJson({
|
||||
element: 'circle',
|
||||
attr: {
|
||||
cx: 0,
|
||||
cy: 0,
|
||||
r: 5
|
||||
}
|
||||
})
|
||||
|
||||
expect(layer.querySelector('#undefined')).toBe(existing)
|
||||
expect(circle?.tagName).toBe('circle')
|
||||
expect(layer.contains(circle)).toBe(true)
|
||||
})
|
||||
|
||||
it('addSVGElementsFromJson() replaces children when reusing an element by id', () => {
|
||||
const group = createSvgElement('g')
|
||||
group.id = 'reuse'
|
||||
const oldChild = createSvgElement('rect')
|
||||
group.append(oldChild)
|
||||
layer.append(group)
|
||||
|
||||
addSVGElementsFromJson({
|
||||
element: 'g',
|
||||
attr: { id: 'reuse' },
|
||||
children: [
|
||||
{
|
||||
element: 'circle',
|
||||
attr: { id: 'newChild' }
|
||||
}
|
||||
]
|
||||
})
|
||||
|
||||
expect(group.children).toHaveLength(1)
|
||||
expect(group.firstElementChild?.tagName).toBe('circle')
|
||||
expect(group.querySelector('rect')).toBeNull()
|
||||
})
|
||||
|
||||
it('addSVGElementsFromJson() handles ids that are not valid CSS selectors', () => {
|
||||
const rect = createSvgElement('rect')
|
||||
rect.id = 'a:b'
|
||||
layer.append(rect)
|
||||
|
||||
expect(() => {
|
||||
addSVGElementsFromJson({
|
||||
element: 'rect',
|
||||
attr: {
|
||||
id: 'a:b',
|
||||
x: 10
|
||||
}
|
||||
})
|
||||
}).not.toThrow()
|
||||
expect(rect.getAttribute('x')).toBe('10')
|
||||
})
|
||||
})
|
||||
99
tests/unit/layer.test.js
Normal file
99
tests/unit/layer.test.js
Normal file
@@ -0,0 +1,99 @@
|
||||
import { strict as assert } from 'node:assert'
|
||||
import { NS } from '../../packages/svgcanvas/core/namespaces.js'
|
||||
import Layer from '../../packages/svgcanvas/core/layer.js'
|
||||
|
||||
describe('Layer', function () {
|
||||
it('preserves inline styles while applying pointer-events', function () {
|
||||
const svg = document.createElementNS(NS.SVG, 'svg')
|
||||
const group = document.createElementNS(NS.SVG, 'g')
|
||||
group.setAttribute('style', 'fill: red; opacity: 0.5; pointer-events: none;')
|
||||
|
||||
const child = document.createElementNS(NS.SVG, 'rect')
|
||||
child.setAttribute('style', 'stroke: blue; opacity: 0.25; pointer-events: none;')
|
||||
group.append(child)
|
||||
svg.append(group)
|
||||
|
||||
const layer = new Layer('Layer 1', group)
|
||||
|
||||
assert.equal(group.style.getPropertyValue('fill'), 'red')
|
||||
assert.equal(group.style.getPropertyValue('opacity'), '0.5')
|
||||
assert.equal(group.style.getPropertyValue('pointer-events'), 'none')
|
||||
assert.equal(child.style.getPropertyValue('stroke'), 'blue')
|
||||
assert.equal(child.style.getPropertyValue('opacity'), '0.25')
|
||||
assert.equal(child.style.getPropertyValue('pointer-events'), 'inherit')
|
||||
|
||||
layer.activate()
|
||||
assert.equal(group.style.getPropertyValue('fill'), 'red')
|
||||
assert.equal(group.style.getPropertyValue('opacity'), '0.5')
|
||||
assert.equal(group.style.getPropertyValue('pointer-events'), 'all')
|
||||
|
||||
layer.deactivate()
|
||||
assert.equal(group.style.getPropertyValue('fill'), 'red')
|
||||
assert.equal(group.style.getPropertyValue('opacity'), '0.5')
|
||||
assert.equal(group.style.getPropertyValue('pointer-events'), 'none')
|
||||
})
|
||||
|
||||
it('manages layer metadata and lifecycle helpers', function () {
|
||||
const svg = document.createElementNS(NS.SVG, 'svg')
|
||||
const anchor = document.createElementNS(NS.SVG, 'g')
|
||||
anchor.setAttribute('class', 'anchor')
|
||||
svg.append(anchor)
|
||||
|
||||
const layer = new Layer('Layer 1', anchor, svg)
|
||||
const group = layer.getGroup()
|
||||
|
||||
assert.equal(layer.getName(), 'Layer 1')
|
||||
assert.equal(group.previousSibling, anchor)
|
||||
assert.ok(group.classList.contains('layer'))
|
||||
assert.equal(group.style.getPropertyValue('pointer-events'), 'all')
|
||||
|
||||
const title = layer.getTitleElement()
|
||||
assert.ok(title)
|
||||
assert.equal(title.textContent, 'Layer 1')
|
||||
|
||||
layer.setVisible(false)
|
||||
assert.equal(group.getAttribute('display'), 'none')
|
||||
assert.equal(layer.isVisible(), false)
|
||||
|
||||
layer.setVisible(true)
|
||||
assert.equal(group.getAttribute('display'), 'inline')
|
||||
assert.equal(layer.isVisible(), true)
|
||||
|
||||
assert.equal(layer.getOpacity(), 1)
|
||||
layer.setOpacity(0.25)
|
||||
assert.equal(layer.getOpacity(), 0.25)
|
||||
layer.setOpacity(2)
|
||||
assert.equal(layer.getOpacity(), 0.25)
|
||||
|
||||
const rect = document.createElementNS(NS.SVG, 'rect')
|
||||
const circle = document.createElementNS(NS.SVG, 'circle')
|
||||
layer.appendChildren([rect, circle])
|
||||
assert.ok(group.contains(rect))
|
||||
assert.ok(group.contains(circle))
|
||||
|
||||
const hrCalls = []
|
||||
const hrService = {
|
||||
changeElement: (...args) => {
|
||||
hrCalls.push(args)
|
||||
}
|
||||
}
|
||||
const renamed = layer.setName('Renamed', hrService)
|
||||
assert.equal(renamed, 'Renamed')
|
||||
assert.equal(layer.getName(), 'Renamed')
|
||||
assert.equal(title.textContent, 'Renamed')
|
||||
assert.equal(hrCalls.length, 1)
|
||||
assert.equal(hrCalls[0][0], title)
|
||||
assert.deepEqual(hrCalls[0][1], { '#text': 'Layer 1' })
|
||||
|
||||
assert.equal(Layer.isLayer(group), true)
|
||||
assert.equal(Layer.isLayer(document.createElementNS(NS.SVG, 'rect')), false)
|
||||
|
||||
const appended = new Layer('Layer 2', null, svg)
|
||||
assert.equal(svg.lastChild, appended.getGroup())
|
||||
|
||||
const removedGroup = layer.removeGroup()
|
||||
assert.equal(removedGroup, group)
|
||||
assert.equal(group.parentNode, null)
|
||||
assert.equal(layer.getGroup(), undefined)
|
||||
})
|
||||
})
|
||||
@@ -93,6 +93,14 @@ describe('math', function () {
|
||||
'Modified matrix matching identity values should be identity'
|
||||
)
|
||||
|
||||
const mAlmostIdentity = svg.createSVGMatrix()
|
||||
mAlmostIdentity.a = 1 + 5e-11
|
||||
mAlmostIdentity.f = 5e-11
|
||||
assert.ok(
|
||||
isIdentity(mAlmostIdentity),
|
||||
'Matrix close to identity should be considered identity'
|
||||
)
|
||||
|
||||
m.e = 10
|
||||
assert.notOk(isIdentity(m), 'Matrix with translation is not identity')
|
||||
})
|
||||
@@ -107,6 +115,22 @@ describe('math', function () {
|
||||
'No arguments should return identity matrix'
|
||||
)
|
||||
|
||||
// Ensure single matrix returns a new matrix and does not mutate the input
|
||||
const tiny = svg.createSVGMatrix()
|
||||
tiny.b = 1e-12
|
||||
const tinyResult = matrixMultiply(tiny)
|
||||
assert.notStrictEqual(
|
||||
tinyResult,
|
||||
tiny,
|
||||
'Single-argument call should return a new matrix instance'
|
||||
)
|
||||
assert.equal(
|
||||
tiny.b,
|
||||
1e-12,
|
||||
'Input matrix should not be mutated by rounding'
|
||||
)
|
||||
assert.equal(tinyResult.b, 0, 'Result should round near-zero values to 0')
|
||||
|
||||
// Translate there and back
|
||||
const tr1 = svg.createSVGMatrix().translate(100, 50)
|
||||
const tr2 = svg.createSVGMatrix().translate(-90, 0)
|
||||
@@ -317,4 +341,30 @@ describe('math', function () {
|
||||
'Rectangles touching at the edge should not be considered intersecting'
|
||||
)
|
||||
})
|
||||
|
||||
it('Test svgedit.math.rectsIntersect() with zero width', function () {
|
||||
const { rectsIntersect } = math
|
||||
const r1 = { x: 0, y: 0, width: 0, height: 50 }
|
||||
const r2 = { x: 0, y: 0, width: 50, height: 50 }
|
||||
|
||||
const result = rectsIntersect(r1, r2)
|
||||
assert.ok(result !== undefined)
|
||||
})
|
||||
|
||||
it('Test svgedit.math.rectsIntersect() with zero height', function () {
|
||||
const { rectsIntersect } = math
|
||||
const r1 = { x: 0, y: 0, width: 50, height: 0 }
|
||||
const r2 = { x: 0, y: 0, width: 50, height: 50 }
|
||||
|
||||
const result = rectsIntersect(r1, r2)
|
||||
assert.ok(result !== undefined)
|
||||
})
|
||||
|
||||
it('Test svgedit.math.rectsIntersect() with negative coords', function () {
|
||||
const { rectsIntersect } = math
|
||||
const r1 = { x: -50, y: -50, width: 100, height: 100 }
|
||||
const r2 = { x: 0, y: 0, width: 50, height: 50 }
|
||||
|
||||
assert.ok(rectsIntersect(r1, r2), 'Should intersect with negative coordinates')
|
||||
})
|
||||
})
|
||||
|
||||
23
tests/unit/namespaces.test.js
Normal file
23
tests/unit/namespaces.test.js
Normal file
@@ -0,0 +1,23 @@
|
||||
import { NS, getReverseNS } from '../../packages/svgcanvas/core/namespaces.js'
|
||||
|
||||
describe('namespaces', function () {
|
||||
it('exposes common namespace constants', function () {
|
||||
assert.equal(NS.SVG, 'http://www.w3.org/2000/svg')
|
||||
assert.equal(NS.XLINK, 'http://www.w3.org/1999/xlink')
|
||||
assert.equal(NS.XML, 'http://www.w3.org/XML/1998/namespace')
|
||||
assert.equal(NS.XMLNS, 'http://www.w3.org/2000/xmlns/')
|
||||
})
|
||||
|
||||
it('creates a reverse namespace lookup', function () {
|
||||
const reverse = getReverseNS()
|
||||
|
||||
assert.equal(reverse[NS.SVG], 'svg')
|
||||
assert.equal(reverse[NS.XLINK], 'xlink')
|
||||
assert.equal(reverse[NS.SE], 'se')
|
||||
assert.equal(reverse[NS.OI], 'oi')
|
||||
assert.equal(reverse[NS.XML], 'xml')
|
||||
assert.equal(reverse[NS.XMLNS], 'xmlns')
|
||||
assert.equal(reverse[NS.HTML], 'html')
|
||||
assert.equal(reverse[NS.MATH], 'math')
|
||||
})
|
||||
})
|
||||
@@ -4,8 +4,6 @@ import Paint from '../../packages/svgcanvas/core/paint.js'
|
||||
const createLinear = (id) => {
|
||||
const grad = document.createElementNS('http://www.w3.org/2000/svg', 'linearGradient')
|
||||
if (id) grad.id = id
|
||||
grad.setAttribute('x1', '0')
|
||||
grad.setAttribute('x2', '1')
|
||||
return grad
|
||||
}
|
||||
|
||||
@@ -27,13 +25,13 @@ describe('Paint', () => {
|
||||
expect(paint.radialGradient).toBeNull()
|
||||
})
|
||||
|
||||
it('copies a solid color paint including alpha', () => {
|
||||
it('normalizes solid colors and copies alpha', () => {
|
||||
const base = new Paint({ solidColor: '#00ff00', alpha: 65 })
|
||||
const copy = new Paint({ copy: base })
|
||||
|
||||
expect(copy.type).toBe('solidColor')
|
||||
expect(copy.alpha).toBe(65)
|
||||
expect(copy.solidColor).toBe('#00ff00')
|
||||
expect(copy.solidColor).toBe('00ff00')
|
||||
expect(copy.linearGradient).toBeNull()
|
||||
expect(copy.radialGradient).toBeNull()
|
||||
})
|
||||
@@ -50,14 +48,28 @@ describe('Paint', () => {
|
||||
|
||||
it('resolves linked linear gradients via href/xlink:href', () => {
|
||||
const referenced = createLinear('refGrad')
|
||||
referenced.setAttribute('gradientUnits', 'userSpaceOnUse')
|
||||
const stop0 = document.createElementNS('http://www.w3.org/2000/svg', 'stop')
|
||||
stop0.setAttribute('offset', '0')
|
||||
stop0.setAttribute('stop-color', '#000000')
|
||||
stop0.setAttribute('stop-opacity', '1')
|
||||
const stop1 = document.createElementNS('http://www.w3.org/2000/svg', 'stop')
|
||||
stop1.setAttribute('offset', '1')
|
||||
stop1.setAttribute('stop-color', '#ffffff')
|
||||
stop1.setAttribute('stop-opacity', '1')
|
||||
referenced.append(stop0, stop1)
|
||||
document.body.append(referenced)
|
||||
const referencing = createLinear('linkGrad')
|
||||
referencing.setAttribute('xlink:href', '#refGrad')
|
||||
referencing.setAttribute('x2', '0.5')
|
||||
|
||||
const paint = new Paint({ linearGradient: referencing })
|
||||
expect(paint.type).toBe('linearGradient')
|
||||
expect(paint.linearGradient).not.toBeNull()
|
||||
expect(paint.linearGradient?.id).toBe('refGrad')
|
||||
expect(paint.linearGradient?.getAttribute('gradientUnits')).toBe('userSpaceOnUse')
|
||||
expect(paint.linearGradient?.getAttribute('x2')).toBe('0.5')
|
||||
expect(paint.linearGradient?.querySelectorAll('stop')).toHaveLength(2)
|
||||
expect(paint.linearGradient?.hasAttribute('xlink:href')).toBe(false)
|
||||
})
|
||||
|
||||
it('creates radial gradients from provided element when no href is set', () => {
|
||||
@@ -69,4 +81,490 @@ describe('Paint', () => {
|
||||
expect(paint.radialGradient?.id).toBe('rad1')
|
||||
expect(paint.linearGradient).toBeNull()
|
||||
})
|
||||
|
||||
it('resolves multi-level gradient chains and strips href', () => {
|
||||
const base = createLinear('baseGrad')
|
||||
base.setAttribute('gradientUnits', 'userSpaceOnUse')
|
||||
base.setAttribute('y2', '0.75')
|
||||
const baseStop = document.createElementNS('http://www.w3.org/2000/svg', 'stop')
|
||||
baseStop.setAttribute('offset', '0')
|
||||
baseStop.setAttribute('stop-color', '#111111')
|
||||
base.append(baseStop)
|
||||
|
||||
const mid = createLinear('midGrad')
|
||||
mid.setAttribute('href', '#baseGrad')
|
||||
mid.setAttribute('x1', '0.2')
|
||||
document.body.append(base, mid)
|
||||
|
||||
const top = createLinear('topGrad')
|
||||
top.setAttribute('xlink:href', '#midGrad')
|
||||
top.setAttribute('x2', '0.9')
|
||||
|
||||
const paint = new Paint({ linearGradient: top })
|
||||
expect(paint.linearGradient?.getAttribute('x2')).toBe('0.9')
|
||||
expect(paint.linearGradient?.getAttribute('x1')).toBe('0.2')
|
||||
expect(paint.linearGradient?.getAttribute('y2')).toBe('0.75')
|
||||
expect(paint.linearGradient?.getAttribute('gradientUnits')).toBe('userSpaceOnUse')
|
||||
expect(paint.linearGradient?.querySelectorAll('stop')).toHaveLength(1)
|
||||
expect(paint.linearGradient?.hasAttribute('href')).toBe(false)
|
||||
expect(paint.linearGradient?.hasAttribute('xlink:href')).toBe(false)
|
||||
|
||||
base.remove()
|
||||
mid.remove()
|
||||
})
|
||||
|
||||
it('should handle paint with null linearGradient', () => {
|
||||
const paint = new Paint({ linearGradient: null })
|
||||
expect(paint.type).toBe('none')
|
||||
expect(paint.linearGradient).toBe(null)
|
||||
})
|
||||
|
||||
it('should handle paint with undefined radialGradient', () => {
|
||||
const paint = new Paint({ radialGradient: undefined })
|
||||
expect(paint.type).toBe('none')
|
||||
})
|
||||
|
||||
it('should handle paint with solidColor', () => {
|
||||
const paint = new Paint({ solidColor: '#ff0000' })
|
||||
expect(paint.type).toBe('solidColor')
|
||||
})
|
||||
|
||||
it('should handle paint with alpha value', () => {
|
||||
const paint = new Paint({ alpha: 0.5 })
|
||||
expect(paint.alpha).toBe(0.5)
|
||||
})
|
||||
|
||||
it('should handle radialGradient with href chain', () => {
|
||||
const base = createRadial('baseRadialGrad')
|
||||
base.setAttribute('cx', '0.5')
|
||||
base.setAttribute('cy', '0.5')
|
||||
base.setAttribute('r', '0.5')
|
||||
document.body.append(base)
|
||||
|
||||
const top = createRadial('topRadialGrad')
|
||||
top.setAttribute('href', '#baseRadialGrad')
|
||||
top.setAttribute('fx', '0.3')
|
||||
|
||||
const paint = new Paint({ radialGradient: top })
|
||||
expect(paint.radialGradient?.getAttribute('fx')).toBe('0.3')
|
||||
expect(paint.radialGradient?.getAttribute('cx')).toBe('0.5')
|
||||
|
||||
base.remove()
|
||||
})
|
||||
|
||||
it('should handle linearGradient with no stops', () => {
|
||||
const grad = createLinear('noStopsGrad')
|
||||
const paint = new Paint({ linearGradient: grad })
|
||||
expect(paint.linearGradient?.querySelectorAll('stop')).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('should copy paint object with type none', () => {
|
||||
const original = new Paint({})
|
||||
const copy = new Paint({ copy: original })
|
||||
expect(copy.type).toBe('none')
|
||||
expect(copy.solidColor).toBe(null)
|
||||
})
|
||||
|
||||
it('should copy paint object with solidColor', () => {
|
||||
const original = new Paint({ solidColor: '#ff0000' })
|
||||
const copy = new Paint({ copy: original, alpha: 75 })
|
||||
expect(copy.type).toBe('solidColor')
|
||||
expect(copy.solidColor).toBe('ff0000')
|
||||
expect(copy.alpha).toBe(original.alpha)
|
||||
})
|
||||
|
||||
it('should copy paint object with linearGradient', () => {
|
||||
const grad = createLinear('copyLinearGrad')
|
||||
const original = new Paint({ linearGradient: grad })
|
||||
const copy = new Paint({ copy: original })
|
||||
expect(copy.type).toBe('linearGradient')
|
||||
expect(copy.linearGradient).not.toBe(original.linearGradient)
|
||||
expect(copy.linearGradient?.id).toBe('copyLinearGrad')
|
||||
})
|
||||
|
||||
it('should copy paint object with radialGradient', () => {
|
||||
const grad = createRadial('copyRadialGrad')
|
||||
document.body.append(grad)
|
||||
const original = new Paint({ radialGradient: grad })
|
||||
const copy = new Paint({ copy: original })
|
||||
expect(copy.type).toBe('radialGradient')
|
||||
expect(copy.radialGradient).not.toBe(original.radialGradient)
|
||||
expect(copy.radialGradient?.id).toBe('copyRadialGrad')
|
||||
grad.remove()
|
||||
})
|
||||
|
||||
it('should handle gradient with invalid href reference', () => {
|
||||
const grad = createLinear('invalidHrefGrad')
|
||||
grad.setAttribute('href', '#nonExistentGradient')
|
||||
const paint = new Paint({ linearGradient: grad })
|
||||
expect(paint.linearGradient?.id).toBe('invalidHrefGrad')
|
||||
})
|
||||
|
||||
it('should normalize alpha values correctly', () => {
|
||||
const paint1 = new Paint({ alpha: 150 })
|
||||
expect(paint1.alpha).toBe(100)
|
||||
const paint2 = new Paint({ alpha: -10 })
|
||||
expect(paint2.alpha).toBe(0)
|
||||
const paint3 = new Paint({ alpha: 'invalid' })
|
||||
expect(paint3.alpha).toBe(100)
|
||||
})
|
||||
|
||||
it('should handle solidColor with none value', () => {
|
||||
const paint = new Paint({ solidColor: 'none' })
|
||||
expect(paint.type).toBe('solidColor')
|
||||
expect(paint.solidColor).toBe('none')
|
||||
})
|
||||
|
||||
it('should normalize solidColor without hash', () => {
|
||||
const paint = new Paint({ solidColor: 'red' })
|
||||
expect(paint.type).toBe('solidColor')
|
||||
expect(paint.solidColor).toBe('red')
|
||||
})
|
||||
|
||||
it('should handle linearGradient with url() format in href', () => {
|
||||
const base = createLinear('baseUrlGrad')
|
||||
base.setAttribute('x1', '0')
|
||||
base.setAttribute('x2', '1')
|
||||
document.body.append(base)
|
||||
|
||||
const top = createLinear('topUrlGrad')
|
||||
top.setAttribute('href', 'url(#baseUrlGrad)')
|
||||
|
||||
const paint = new Paint({ linearGradient: top })
|
||||
expect(paint.linearGradient?.getAttribute('x1')).toBe('0')
|
||||
expect(paint.linearGradient?.getAttribute('x2')).toBe('1')
|
||||
|
||||
base.remove()
|
||||
})
|
||||
|
||||
it('should handle gradient with empty string attributes', () => {
|
||||
const base = createLinear('baseEmptyGrad')
|
||||
base.setAttribute('x1', '0.5')
|
||||
document.body.append(base)
|
||||
|
||||
const top = createLinear('topEmptyGrad')
|
||||
top.setAttribute('href', '#baseEmptyGrad')
|
||||
top.setAttribute('x1', '')
|
||||
|
||||
const paint = new Paint({ linearGradient: top })
|
||||
// Empty attribute should be replaced by inherited value
|
||||
expect(paint.linearGradient?.getAttribute('x1')).toBe('0.5')
|
||||
|
||||
base.remove()
|
||||
})
|
||||
|
||||
it('should handle gradient with stops inheritance', () => {
|
||||
const base = createLinear('baseStopsGrad')
|
||||
const stop1 = document.createElementNS('http://www.w3.org/2000/svg', 'stop')
|
||||
stop1.setAttribute('offset', '0')
|
||||
const stop2 = document.createElementNS('http://www.w3.org/2000/svg', 'stop')
|
||||
stop2.setAttribute('offset', '1')
|
||||
base.append(stop1, stop2)
|
||||
document.body.append(base)
|
||||
|
||||
const top = createLinear('topNoStopsGrad')
|
||||
top.setAttribute('href', '#baseStopsGrad')
|
||||
|
||||
const paint = new Paint({ linearGradient: top })
|
||||
expect(paint.linearGradient?.querySelectorAll('stop')).toHaveLength(2)
|
||||
|
||||
base.remove()
|
||||
})
|
||||
|
||||
it('should handle mismatched gradient types', () => {
|
||||
const base = createLinear('baseMismatchGrad')
|
||||
document.body.append(base)
|
||||
|
||||
const top = createRadial('topMismatchGrad')
|
||||
top.setAttribute('href', '#baseMismatchGrad')
|
||||
|
||||
const paint = new Paint({ radialGradient: top })
|
||||
// Should not inherit from mismatched type
|
||||
expect(paint.radialGradient?.id).toBe('topMismatchGrad')
|
||||
|
||||
base.remove()
|
||||
})
|
||||
|
||||
it('should handle circular gradient references', () => {
|
||||
const grad1 = createLinear('circularGrad1')
|
||||
grad1.setAttribute('href', '#circularGrad2')
|
||||
document.body.append(grad1)
|
||||
|
||||
const grad2 = createLinear('circularGrad2')
|
||||
grad2.setAttribute('href', '#circularGrad1')
|
||||
document.body.append(grad2)
|
||||
|
||||
const paint = new Paint({ linearGradient: grad1 })
|
||||
// Should handle circular reference without infinite loop
|
||||
expect(paint.linearGradient?.id).toBe('circularGrad1')
|
||||
|
||||
grad1.remove()
|
||||
grad2.remove()
|
||||
})
|
||||
|
||||
it('should normalize alpha with null value', () => {
|
||||
const paint = new Paint({ alpha: null })
|
||||
expect(paint.alpha).toBe(0)
|
||||
})
|
||||
|
||||
it('should normalize alpha with undefined', () => {
|
||||
const paint = new Paint({ alpha: undefined })
|
||||
expect(paint.alpha).toBe(100)
|
||||
})
|
||||
|
||||
it('should normalize solidColor with empty string', () => {
|
||||
const paint = new Paint({ solidColor: '' })
|
||||
expect(paint.type).toBe('none')
|
||||
expect(paint.solidColor).toBe(null)
|
||||
})
|
||||
|
||||
it('should normalize solidColor with whitespace', () => {
|
||||
const paint = new Paint({ solidColor: ' ' })
|
||||
expect(paint.type).toBe('solidColor')
|
||||
expect(paint.solidColor).toBe(null)
|
||||
})
|
||||
|
||||
it('should handle extractHrefId with path in URL', () => {
|
||||
const grad = createLinear('pathGrad')
|
||||
grad.setAttribute('href', 'file.svg#targetGrad')
|
||||
document.body.append(grad)
|
||||
|
||||
const paint = new Paint({ linearGradient: grad })
|
||||
expect(paint.linearGradient?.id).toBe('pathGrad')
|
||||
|
||||
grad.remove()
|
||||
})
|
||||
|
||||
it('should handle gradient without ownerDocument', () => {
|
||||
const grad = createLinear('noDocGrad')
|
||||
const paint = new Paint({ linearGradient: grad })
|
||||
expect(paint.linearGradient?.id).toBe('noDocGrad')
|
||||
})
|
||||
|
||||
it('should copy paint with null linearGradient', () => {
|
||||
const original = new Paint({ linearGradient: null })
|
||||
const copy = new Paint({ copy: original })
|
||||
expect(copy.type).toBe('none')
|
||||
expect(copy.linearGradient).toBe(null)
|
||||
})
|
||||
|
||||
it('should handle href with double quotes in url()', () => {
|
||||
const base = createLinear('doubleQuoteGrad')
|
||||
base.setAttribute('x1', '0.25')
|
||||
document.body.append(base)
|
||||
|
||||
const top = createLinear('topDoubleQuoteGrad')
|
||||
top.setAttribute('href', 'url("#doubleQuoteGrad")')
|
||||
|
||||
const paint = new Paint({ linearGradient: top })
|
||||
expect(paint.linearGradient?.getAttribute('x1')).toBe('0.25')
|
||||
|
||||
base.remove()
|
||||
})
|
||||
|
||||
it('should handle href with single quotes in url()', () => {
|
||||
const base = createLinear('singleQuoteGrad')
|
||||
base.setAttribute('y1', '0.75')
|
||||
document.body.append(base)
|
||||
|
||||
const top = createLinear('topSingleQuoteGrad')
|
||||
top.setAttribute('href', "url('#singleQuoteGrad')")
|
||||
|
||||
const paint = new Paint({ linearGradient: top })
|
||||
expect(paint.linearGradient?.getAttribute('y1')).toBe('0.75')
|
||||
|
||||
base.remove()
|
||||
})
|
||||
|
||||
it('should handle gradient with non-matching tagName case', () => {
|
||||
const base = createLinear('baseCaseGrad')
|
||||
document.body.append(base)
|
||||
|
||||
const top = createRadial('topCaseGrad')
|
||||
top.setAttribute('href', '#baseCaseGrad')
|
||||
|
||||
const paint = new Paint({ radialGradient: top })
|
||||
// Should not inherit from wrong gradient type
|
||||
expect(paint.radialGradient?.id).toBe('topCaseGrad')
|
||||
|
||||
base.remove()
|
||||
})
|
||||
|
||||
it('should handle gradient href with just hash', () => {
|
||||
const base = createLinear('hashOnlyGrad')
|
||||
base.setAttribute('x2', '1')
|
||||
document.body.append(base)
|
||||
|
||||
const top = createLinear('topHashGrad')
|
||||
top.setAttribute('href', '#hashOnlyGrad')
|
||||
|
||||
const paint = new Paint({ linearGradient: top })
|
||||
expect(paint.linearGradient?.getAttribute('x2')).toBe('1')
|
||||
|
||||
base.remove()
|
||||
})
|
||||
|
||||
it('should handle invalid alpha values', () => {
|
||||
const paint1 = new Paint({ alpha: NaN })
|
||||
expect(paint1.alpha).toBe(100)
|
||||
|
||||
const paint2 = new Paint({ alpha: Infinity })
|
||||
expect(paint2.alpha).toBe(100)
|
||||
|
||||
const paint3 = new Paint({ alpha: -Infinity })
|
||||
expect(paint3.alpha).toBe(100)
|
||||
})
|
||||
|
||||
it('should handle copy with missing clone method', () => {
|
||||
const original = new Paint({ linearGradient: createLinear('copyGrad') })
|
||||
original.linearGradient = { id: 'fake', cloneNode: null }
|
||||
const copy = new Paint({ copy: original })
|
||||
expect(copy.linearGradient).toBe(null)
|
||||
})
|
||||
|
||||
it('should handle alpha at exact boundaries', () => {
|
||||
const paint1 = new Paint({ alpha: 0 })
|
||||
expect(paint1.alpha).toBe(0)
|
||||
|
||||
const paint2 = new Paint({ alpha: 100 })
|
||||
expect(paint2.alpha).toBe(100)
|
||||
|
||||
const paint3 = new Paint({ alpha: 50 })
|
||||
expect(paint3.alpha).toBe(50)
|
||||
})
|
||||
|
||||
it('should handle gradient with null getAttribute', () => {
|
||||
const grad = createLinear('nullAttrGrad')
|
||||
const paint = new Paint({ linearGradient: grad })
|
||||
expect(paint.linearGradient?.id).toBe('nullAttrGrad')
|
||||
})
|
||||
|
||||
it('should handle referenced gradient with no attributes', () => {
|
||||
const base = createLinear('emptyAttrGrad')
|
||||
document.body.append(base)
|
||||
|
||||
const top = createLinear('topEmptyAttrGrad')
|
||||
top.setAttribute('href', '#emptyAttrGrad')
|
||||
|
||||
const paint = new Paint({ linearGradient: top })
|
||||
expect(paint.linearGradient?.id).toBe('topEmptyAttrGrad')
|
||||
|
||||
base.remove()
|
||||
})
|
||||
|
||||
it('should handle href with spaces in url()', () => {
|
||||
const base = createLinear('spacesGrad')
|
||||
base.setAttribute('gradientUnits', 'userSpaceOnUse')
|
||||
document.body.append(base)
|
||||
|
||||
const top = createLinear('topSpacesGrad')
|
||||
top.setAttribute('href', 'url( #spacesGrad )')
|
||||
|
||||
const paint = new Paint({ linearGradient: top })
|
||||
expect(paint.linearGradient?.getAttribute('gradientUnits')).toBe('userSpaceOnUse')
|
||||
|
||||
base.remove()
|
||||
})
|
||||
|
||||
it('should handle solidColor with hash prefix', () => {
|
||||
const paint = new Paint({ solidColor: '#ff0000' })
|
||||
expect(paint.type).toBe('solidColor')
|
||||
expect(paint.solidColor).toBe('ff0000')
|
||||
})
|
||||
|
||||
it('should handle solidColor without hash prefix', () => {
|
||||
const paint = new Paint({ solidColor: 'blue' })
|
||||
expect(paint.type).toBe('solidColor')
|
||||
expect(paint.solidColor).toBe('blue')
|
||||
})
|
||||
|
||||
it('should handle gradient with id attribute skip', () => {
|
||||
const base = createLinear('idTestGrad')
|
||||
base.setAttribute('x1', '0.1')
|
||||
base.setAttribute('id', 'differentId')
|
||||
document.body.append(base)
|
||||
|
||||
const top = createLinear('topIdTestGrad')
|
||||
top.setAttribute('href', '#idTestGrad')
|
||||
|
||||
const paint = new Paint({ linearGradient: top })
|
||||
// Should not copy id attribute
|
||||
expect(paint.linearGradient?.id).not.toBe('differentId')
|
||||
|
||||
base.remove()
|
||||
})
|
||||
|
||||
it('should handle gradient with xlink:href attribute skip', () => {
|
||||
const base = createLinear('xlinkTestGrad')
|
||||
base.setAttribute('y1', '0.2')
|
||||
document.body.append(base)
|
||||
|
||||
const top = createLinear('topXlinkTestGrad')
|
||||
top.setAttribute('xlink:href', '#xlinkTestGrad')
|
||||
|
||||
const paint = new Paint({ linearGradient: top })
|
||||
expect(paint.linearGradient?.getAttribute('y1')).toBe('0.2')
|
||||
// xlink:href should be removed
|
||||
expect(paint.linearGradient?.hasAttribute('xlink:href')).toBe(false)
|
||||
|
||||
base.remove()
|
||||
})
|
||||
|
||||
it('should handle href pointing to path with hash', () => {
|
||||
const grad = createLinear('pathHashGrad')
|
||||
grad.setAttribute('href', 'images/file.svg#someGrad')
|
||||
|
||||
const paint = new Paint({ linearGradient: grad })
|
||||
expect(paint.linearGradient?.id).toBe('pathHashGrad')
|
||||
})
|
||||
|
||||
it('should handle href ending with just hash', () => {
|
||||
const grad = createLinear('trailingHashGrad')
|
||||
grad.setAttribute('href', 'file.svg#')
|
||||
|
||||
const paint = new Paint({ linearGradient: grad })
|
||||
expect(paint.linearGradient?.id).toBe('trailingHashGrad')
|
||||
})
|
||||
|
||||
it('should handle href with no hash', () => {
|
||||
const grad = createLinear('noHashGrad')
|
||||
grad.setAttribute('href', 'file.svg')
|
||||
|
||||
const paint = new Paint({ linearGradient: grad })
|
||||
expect(paint.linearGradient?.id).toBe('noHashGrad')
|
||||
})
|
||||
|
||||
it('should handle empty href attribute', () => {
|
||||
const grad = createLinear('emptyHrefGrad')
|
||||
grad.setAttribute('href', '')
|
||||
|
||||
const paint = new Paint({ linearGradient: grad })
|
||||
expect(paint.linearGradient?.id).toBe('emptyHrefGrad')
|
||||
})
|
||||
|
||||
it('should handle gradient with null ownerDocument fallback', () => {
|
||||
const grad = document.createElementNS('http://www.w3.org/2000/svg', 'linearGradient')
|
||||
grad.setAttribute('id', 'nullDocGrad2')
|
||||
// Don't append to document
|
||||
|
||||
const paint = new Paint({ linearGradient: grad })
|
||||
expect(paint.linearGradient?.id).toBe('nullDocGrad2')
|
||||
})
|
||||
|
||||
it('should handle radialGradient with xlink:href', () => {
|
||||
const grad = createRadial('xlinkRadial')
|
||||
grad.setAttributeNS('http://www.w3.org/1999/xlink', 'xlink:href', '#baseRadial')
|
||||
|
||||
const paint = new Paint({ radialGradient: grad })
|
||||
expect(paint.radialGradient?.id).toBe('xlinkRadial')
|
||||
})
|
||||
|
||||
it('should handle gradient with both href and xlink:href', () => {
|
||||
const grad = createLinear('dualHref')
|
||||
grad.setAttribute('href', '#newer')
|
||||
grad.setAttributeNS('http://www.w3.org/1999/xlink', 'xlink:href', '#older')
|
||||
|
||||
const paint = new Paint({ linearGradient: grad })
|
||||
expect(paint.linearGradient?.id).toBe('dualHref')
|
||||
})
|
||||
})
|
||||
|
||||
135
tests/unit/paste-elem.test.js
Normal file
135
tests/unit/paste-elem.test.js
Normal file
@@ -0,0 +1,135 @@
|
||||
import SvgCanvas from '../../packages/svgcanvas/svgcanvas.js'
|
||||
import { NS } from '../../packages/svgcanvas/core/namespaces.js'
|
||||
|
||||
describe('paste-elem', () => {
|
||||
let svgCanvas
|
||||
|
||||
const createSvgCanvas = () => {
|
||||
document.body.textContent = ''
|
||||
const svgEditor = document.createElement('div')
|
||||
svgEditor.id = 'svg_editor'
|
||||
const svgcanvas = document.createElement('div')
|
||||
svgcanvas.style.visibility = 'hidden'
|
||||
svgcanvas.id = 'svgcanvas'
|
||||
const workarea = document.createElement('div')
|
||||
workarea.id = 'workarea'
|
||||
workarea.append(svgcanvas)
|
||||
const toolsLeft = document.createElement('div')
|
||||
toolsLeft.id = 'tools_left'
|
||||
svgEditor.append(workarea, toolsLeft)
|
||||
document.body.append(svgEditor)
|
||||
|
||||
svgCanvas = new SvgCanvas(document.getElementById('svgcanvas'), {
|
||||
canvas_expansion: 3,
|
||||
dimensions: [640, 480],
|
||||
initFill: {
|
||||
color: 'FF0000',
|
||||
opacity: 1
|
||||
},
|
||||
initStroke: {
|
||||
width: 5,
|
||||
color: '000000',
|
||||
opacity: 1
|
||||
},
|
||||
initOpacity: 1,
|
||||
imgPath: '../editor/images',
|
||||
langPath: 'locale/',
|
||||
extPath: 'extensions/',
|
||||
extensions: [],
|
||||
initTool: 'select',
|
||||
wireframe: false
|
||||
})
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
createSvgCanvas()
|
||||
sessionStorage.clear()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
document.body.textContent = ''
|
||||
sessionStorage.clear()
|
||||
})
|
||||
|
||||
it('pastes copied elements and assigns new IDs', () => {
|
||||
const rect = svgCanvas.addSVGElementsFromJson({
|
||||
element: 'rect',
|
||||
attr: {
|
||||
id: 'rect-original',
|
||||
x: 10,
|
||||
y: 20,
|
||||
width: 30,
|
||||
height: 40
|
||||
}
|
||||
})
|
||||
|
||||
svgCanvas.selectOnly([rect], true)
|
||||
svgCanvas.copySelectedElements()
|
||||
|
||||
const undoSize = svgCanvas.undoMgr.getUndoStackSize()
|
||||
svgCanvas.pasteElements('in_place')
|
||||
|
||||
expect(svgCanvas.undoMgr.getUndoStackSize()).toBe(undoSize + 1)
|
||||
const pasted = svgCanvas.getSelectedElements()[0]
|
||||
expect(pasted).toBeTruthy()
|
||||
expect(pasted.tagName).toBe('rect')
|
||||
expect(pasted.id).not.toBe('rect-original')
|
||||
|
||||
expect(svgCanvas.getSvgContent().querySelector('#rect-original')).toBeTruthy()
|
||||
expect(svgCanvas.getSvgContent().querySelector('#' + pasted.id)).toBe(pasted)
|
||||
})
|
||||
|
||||
it('remaps internal url(#id) references when pasting', () => {
|
||||
const group = svgCanvas.addSVGElementsFromJson({
|
||||
element: 'g',
|
||||
attr: { id: 'group-original' }
|
||||
})
|
||||
|
||||
const defs = document.createElementNS(NS.SVG, 'defs')
|
||||
const gradient = document.createElementNS(NS.SVG, 'linearGradient')
|
||||
gradient.id = 'grad-original'
|
||||
const stop = document.createElementNS(NS.SVG, 'stop')
|
||||
stop.setAttribute('offset', '0%')
|
||||
stop.setAttribute('stop-color', '#000')
|
||||
gradient.append(stop)
|
||||
defs.append(gradient)
|
||||
|
||||
const rect = document.createElementNS(NS.SVG, 'rect')
|
||||
rect.id = 'rect-with-fill'
|
||||
rect.setAttribute('x', '0')
|
||||
rect.setAttribute('y', '0')
|
||||
rect.setAttribute('width', '10')
|
||||
rect.setAttribute('height', '10')
|
||||
rect.setAttribute('fill', 'url(#grad-original)')
|
||||
group.append(defs, rect)
|
||||
|
||||
svgCanvas.selectOnly([group], true)
|
||||
svgCanvas.copySelectedElements()
|
||||
svgCanvas.pasteElements('in_place')
|
||||
|
||||
const pastedGroup = svgCanvas.getSelectedElements()[0]
|
||||
const pastedGradient = pastedGroup.querySelector('linearGradient')
|
||||
const pastedRect = pastedGroup.querySelector('rect')
|
||||
|
||||
expect(pastedGradient).toBeTruthy()
|
||||
expect(pastedRect).toBeTruthy()
|
||||
expect(pastedGradient.id).not.toBe('grad-original')
|
||||
expect(pastedRect.getAttribute('fill')).toBe('url(#' + pastedGradient.id + ')')
|
||||
})
|
||||
|
||||
it('does not throw on invalid clipboard JSON', () => {
|
||||
sessionStorage.setItem(svgCanvas.getClipboardID(), 'not-json')
|
||||
const undoSize = svgCanvas.undoMgr.getUndoStackSize()
|
||||
|
||||
expect(() => svgCanvas.pasteElements('in_place')).not.toThrow()
|
||||
expect(svgCanvas.undoMgr.getUndoStackSize()).toBe(undoSize)
|
||||
})
|
||||
|
||||
it('does not throw on empty clipboard', () => {
|
||||
sessionStorage.setItem(svgCanvas.getClipboardID(), '[]')
|
||||
const undoSize = svgCanvas.undoMgr.getUndoStackSize()
|
||||
|
||||
expect(() => svgCanvas.pasteElements('in_place')).not.toThrow()
|
||||
expect(svgCanvas.undoMgr.getUndoStackSize()).toBe(undoSize)
|
||||
})
|
||||
})
|
||||
613
tests/unit/path-actions.test.js
Normal file
613
tests/unit/path-actions.test.js
Normal file
@@ -0,0 +1,613 @@
|
||||
import 'pathseg'
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
|
||||
import { init as pathActionsInit, pathActionsMethod } from '../../packages/svgcanvas/core/path-actions.js'
|
||||
import { init as utilitiesInit } from '../../packages/svgcanvas/core/utilities.js'
|
||||
import { init as unitsInit } from '../../packages/svgcanvas/core/units.js'
|
||||
import { NS } from '../../packages/svgcanvas/core/namespaces.js'
|
||||
|
||||
describe('PathActions', () => {
|
||||
let svgRoot
|
||||
let pathElement
|
||||
let svgCanvas
|
||||
let mockPath
|
||||
|
||||
beforeEach(() => {
|
||||
// Create mock SVG elements
|
||||
svgRoot = document.createElementNS(NS.SVG, 'svg')
|
||||
svgRoot.setAttribute('width', '640')
|
||||
svgRoot.setAttribute('height', '480')
|
||||
document.body.append(svgRoot)
|
||||
|
||||
pathElement = document.createElementNS(NS.SVG, 'path')
|
||||
pathElement.setAttribute('id', 'path1')
|
||||
pathElement.setAttribute('d', 'M10,10 L50,50 L90,10 z')
|
||||
svgRoot.append(pathElement)
|
||||
|
||||
// Create mock path object (simulating the path module's internal Path class)
|
||||
mockPath = {
|
||||
elem: pathElement,
|
||||
segs: [
|
||||
{ index: 0, item: { x: 10, y: 10 }, type: 2, selected: false, move: vi.fn() },
|
||||
{ index: 1, item: { x: 50, y: 50 }, type: 4, selected: false, move: vi.fn() },
|
||||
{ index: 2, item: { x: 90, y: 10 }, type: 4, selected: false, move: vi.fn() }
|
||||
],
|
||||
selected_pts: [],
|
||||
matrix: null,
|
||||
show: vi.fn(() => mockPath),
|
||||
update: vi.fn(() => mockPath),
|
||||
init: vi.fn(() => mockPath),
|
||||
setPathContext: vi.fn(),
|
||||
storeD: vi.fn(),
|
||||
selectPt: vi.fn(),
|
||||
addPtsToSelection: vi.fn(),
|
||||
removePtFromSelection: vi.fn(),
|
||||
clearSelection: vi.fn(),
|
||||
setSegType: vi.fn(),
|
||||
movePts: vi.fn(),
|
||||
moveCtrl: vi.fn(),
|
||||
addSeg: vi.fn(),
|
||||
deleteSeg: vi.fn(),
|
||||
endChanges: vi.fn(),
|
||||
dragctrl: false,
|
||||
dragging: null,
|
||||
cur_pt: null,
|
||||
oldbbox: { x: 0, y: 0, width: 100, height: 100 }
|
||||
}
|
||||
|
||||
// Mock svgCanvas
|
||||
svgCanvas = {
|
||||
getSvgRoot: () => svgRoot,
|
||||
getZoom: () => 1,
|
||||
setCurrentMode: vi.fn(),
|
||||
getCurrentMode: vi.fn(() => 'select'),
|
||||
clearSelection: vi.fn(),
|
||||
addToSelection: vi.fn(),
|
||||
deleteSelectedElements: vi.fn(),
|
||||
call: vi.fn(),
|
||||
getSelectedElements: vi.fn(() => [pathElement]),
|
||||
getDrawnPath: vi.fn(() => null),
|
||||
setDrawnPath: vi.fn(),
|
||||
getPath_: vi.fn(() => mockPath),
|
||||
getId: vi.fn(() => 'svg_1'),
|
||||
getNextId: vi.fn(() => 'svg_2'),
|
||||
setStarted: vi.fn(),
|
||||
addPointGrip: vi.fn(),
|
||||
addCtrlGrip: vi.fn(() => {
|
||||
const grip = document.createElementNS(NS.SVG, 'circle')
|
||||
grip.setAttribute('cx', '0')
|
||||
grip.setAttribute('cy', '0')
|
||||
grip.setAttribute('r', '4')
|
||||
return grip
|
||||
}),
|
||||
getCtrlLine: vi.fn(() => {
|
||||
const line = document.createElementNS(NS.SVG, 'line')
|
||||
return line
|
||||
}),
|
||||
replacePathSeg: vi.fn(),
|
||||
getGridSnapping: vi.fn(() => false),
|
||||
getOpacity: vi.fn(() => 1),
|
||||
round: (val) => Math.round(val),
|
||||
getRoundDigits: vi.fn(() => 2),
|
||||
addSVGElementsFromJson: vi.fn((json) => {
|
||||
const elem = document.createElementNS(NS.SVG, json.element)
|
||||
if (json.attr) {
|
||||
Object.entries(json.attr).forEach(([key, value]) => {
|
||||
elem.setAttribute(key, value)
|
||||
})
|
||||
}
|
||||
return elem
|
||||
}),
|
||||
createSVGElement: vi.fn((config) => {
|
||||
const elem = document.createElementNS(NS.SVG, config.element)
|
||||
if (config.attr) {
|
||||
Object.entries(config.attr).forEach(([key, value]) => {
|
||||
elem.setAttribute(key, value)
|
||||
})
|
||||
}
|
||||
return elem
|
||||
}),
|
||||
selectorManager: {
|
||||
getRubberBandBox: vi.fn(() => {
|
||||
const rect = document.createElementNS(NS.SVG, 'rect')
|
||||
rect.setAttribute('id', 'selectorRubberBand')
|
||||
return rect
|
||||
}),
|
||||
requestSelector: vi.fn(() => ({
|
||||
showGrips: vi.fn()
|
||||
}))
|
||||
},
|
||||
getRubberBox: vi.fn(() => null),
|
||||
setRubberBox: vi.fn((box) => box),
|
||||
getPointFromGrip: vi.fn((point) => point),
|
||||
getGripPt: vi.fn((seg) => ({ x: seg.item.x, y: seg.item.y })),
|
||||
getContainer: vi.fn(() => svgRoot),
|
||||
getMouseTarget: vi.fn(() => pathElement),
|
||||
smoothControlPoints: vi.fn(),
|
||||
removePath_: vi.fn(),
|
||||
recalcRotatedPath: vi.fn(),
|
||||
remapElement: vi.fn(),
|
||||
addCommandToHistory: vi.fn(),
|
||||
reorientGrads: vi.fn(),
|
||||
setLinkControlPoints: vi.fn(),
|
||||
contentW: 640
|
||||
}
|
||||
|
||||
// Create selector parent group
|
||||
const selectorParentGroup = document.createElementNS(NS.SVG, 'g')
|
||||
selectorParentGroup.id = 'selectorParentGroup'
|
||||
svgRoot.append(selectorParentGroup)
|
||||
|
||||
// Create pathpointgrip container
|
||||
const pathpointgripContainer = document.createElementNS(NS.SVG, 'g')
|
||||
pathpointgripContainer.id = 'pathpointgrip_container'
|
||||
svgRoot.append(pathpointgripContainer)
|
||||
|
||||
// Initialize modules
|
||||
utilitiesInit(svgCanvas)
|
||||
unitsInit(svgCanvas)
|
||||
pathActionsInit(svgCanvas)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
document.body.textContent = ''
|
||||
})
|
||||
|
||||
describe('Class instantiation', () => {
|
||||
it('should export pathActionsMethod as singleton instance', () => {
|
||||
expect(pathActionsMethod).toBeDefined()
|
||||
expect(typeof pathActionsMethod.mouseDown).toBe('function')
|
||||
expect(typeof pathActionsMethod.mouseMove).toBe('function')
|
||||
expect(typeof pathActionsMethod.mouseUp).toBe('function')
|
||||
})
|
||||
|
||||
it('should have all public methods', () => {
|
||||
const publicMethods = [
|
||||
'mouseDown',
|
||||
'mouseMove',
|
||||
'mouseUp',
|
||||
'toEditMode',
|
||||
'toSelectMode',
|
||||
'addSubPath',
|
||||
'select',
|
||||
'reorient',
|
||||
'clear',
|
||||
'resetOrientation',
|
||||
'zoomChange',
|
||||
'getNodePoint',
|
||||
'linkControlPoints',
|
||||
'clonePathNode',
|
||||
'opencloseSubPath',
|
||||
'deletePathNode',
|
||||
'smoothPolylineIntoPath',
|
||||
'setSegType',
|
||||
'moveNode',
|
||||
'fixEnd',
|
||||
'convertPath'
|
||||
]
|
||||
|
||||
publicMethods.forEach(method => {
|
||||
expect(typeof pathActionsMethod[method]).toBe('function')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('mouseDown', () => {
|
||||
it('should handle mouse down in path mode', () => {
|
||||
svgCanvas.getCurrentMode.mockReturnValue('path')
|
||||
svgCanvas.getDrawnPath.mockReturnValue(null)
|
||||
|
||||
const mockEvent = { target: pathElement, shiftKey: false }
|
||||
const result = pathActionsMethod.mouseDown(mockEvent, pathElement, 100, 100)
|
||||
|
||||
expect(svgCanvas.addPointGrip).toHaveBeenCalled()
|
||||
expect(result).toBeUndefined()
|
||||
})
|
||||
|
||||
it('should handle mouse down on existing path point', () => {
|
||||
// First enter edit mode to initialize path
|
||||
pathActionsMethod.toEditMode(pathElement)
|
||||
svgCanvas.getCurrentMode.mockReturnValue('pathedit')
|
||||
|
||||
const grip = document.createElementNS(NS.SVG, 'circle')
|
||||
grip.id = 'pathpointgrip_0'
|
||||
const mockEvent = { target: grip, shiftKey: false }
|
||||
|
||||
pathActionsMethod.mouseDown(mockEvent, grip, 100, 100)
|
||||
|
||||
expect(mockPath.clearSelection).toHaveBeenCalled()
|
||||
expect(mockPath.addPtsToSelection).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('mouseMove', () => {
|
||||
it('should handle mouse move in path mode', () => {
|
||||
svgCanvas.getCurrentMode.mockReturnValue('path')
|
||||
const drawnPath = document.createElementNS(NS.SVG, 'path')
|
||||
drawnPath.setAttribute('d', 'M10,10 L50,50')
|
||||
svgCanvas.getDrawnPath.mockReturnValue(drawnPath)
|
||||
|
||||
pathActionsMethod.mouseMove(120, 120)
|
||||
|
||||
// Should update path stretchy line
|
||||
expect(svgCanvas.replacePathSeg).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should handle dragging path points', () => {
|
||||
pathActionsMethod.toEditMode(pathElement)
|
||||
svgCanvas.getCurrentMode.mockReturnValue('pathedit')
|
||||
mockPath.dragging = [100, 100]
|
||||
|
||||
pathActionsMethod.mouseMove(110, 110)
|
||||
|
||||
expect(mockPath.movePts).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('mouseUp', () => {
|
||||
it('should handle mouse up in path mode', () => {
|
||||
svgCanvas.getCurrentMode.mockReturnValue('path')
|
||||
const drawnPath = document.createElementNS(NS.SVG, 'path')
|
||||
svgCanvas.getDrawnPath.mockReturnValue(drawnPath)
|
||||
|
||||
const mockEvent = { target: pathElement }
|
||||
const result = pathActionsMethod.mouseUp(mockEvent, drawnPath, 100, 100)
|
||||
|
||||
expect(result).toEqual({ keep: true, element: drawnPath })
|
||||
})
|
||||
|
||||
it('should finalize path point dragging', () => {
|
||||
pathActionsMethod.toEditMode(pathElement)
|
||||
svgCanvas.getCurrentMode.mockReturnValue('pathedit')
|
||||
mockPath.dragging = [100, 100]
|
||||
mockPath.cur_pt = 1
|
||||
|
||||
const mockEvent = { target: pathElement, shiftKey: false }
|
||||
pathActionsMethod.mouseUp(mockEvent, pathElement, 105, 105)
|
||||
|
||||
expect(mockPath.update).toHaveBeenCalled()
|
||||
expect(mockPath.endChanges).toHaveBeenCalledWith('Move path point(s)')
|
||||
})
|
||||
})
|
||||
|
||||
describe('toEditMode', () => {
|
||||
it('should switch to path edit mode', () => {
|
||||
pathActionsMethod.toEditMode(pathElement)
|
||||
|
||||
expect(svgCanvas.setCurrentMode).toHaveBeenCalledWith('pathedit')
|
||||
expect(svgCanvas.clearSelection).toHaveBeenCalled()
|
||||
expect(mockPath.show).toHaveBeenCalledWith(true)
|
||||
expect(mockPath.update).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('toSelectMode', () => {
|
||||
it('should switch to select mode', () => {
|
||||
pathActionsMethod.toEditMode(pathElement)
|
||||
pathActionsMethod.toSelectMode(pathElement)
|
||||
|
||||
expect(svgCanvas.setCurrentMode).toHaveBeenCalledWith('select')
|
||||
expect(mockPath.show).toHaveBeenCalledWith(false)
|
||||
expect(svgCanvas.clearSelection).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should select element if it was the path element', () => {
|
||||
pathActionsMethod.toEditMode(pathElement)
|
||||
pathActionsMethod.toSelectMode(pathElement)
|
||||
|
||||
expect(svgCanvas.call).toHaveBeenCalledWith('selected', [pathElement])
|
||||
expect(svgCanvas.addToSelection).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('addSubPath', () => {
|
||||
it('should enable subpath mode', () => {
|
||||
pathActionsMethod.addSubPath(true)
|
||||
|
||||
expect(svgCanvas.setCurrentMode).toHaveBeenCalledWith('path')
|
||||
})
|
||||
|
||||
it('should disable subpath mode', () => {
|
||||
pathActionsMethod.toEditMode(pathElement)
|
||||
pathActionsMethod.addSubPath(false)
|
||||
|
||||
expect(mockPath.init).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('select', () => {
|
||||
it('should select a path and enter edit mode if already current', () => {
|
||||
pathActionsMethod.select(pathElement)
|
||||
pathActionsMethod.select(pathElement)
|
||||
|
||||
expect(svgCanvas.setCurrentMode).toHaveBeenCalledWith('pathedit')
|
||||
})
|
||||
})
|
||||
|
||||
describe('reorient', () => {
|
||||
it('should reorient a rotated path', () => {
|
||||
pathElement.setAttribute('transform', 'rotate(45 50 50)')
|
||||
svgCanvas.getSelectedElements.mockReturnValue([pathElement])
|
||||
|
||||
pathActionsMethod.reorient()
|
||||
|
||||
expect(svgCanvas.addCommandToHistory).toHaveBeenCalled()
|
||||
expect(svgCanvas.call).toHaveBeenCalledWith('changed', [pathElement])
|
||||
})
|
||||
|
||||
it('should do nothing if no element selected', () => {
|
||||
svgCanvas.getSelectedElements.mockReturnValue([])
|
||||
|
||||
pathActionsMethod.reorient()
|
||||
|
||||
expect(svgCanvas.addCommandToHistory).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('clear', () => {
|
||||
it('should clear drawn path', () => {
|
||||
const drawnPath = document.createElementNS(NS.SVG, 'path')
|
||||
drawnPath.id = 'svg_1'
|
||||
const stretchy = document.createElementNS(NS.SVG, 'path')
|
||||
stretchy.id = 'path_stretch_line'
|
||||
svgRoot.append(drawnPath)
|
||||
svgRoot.append(stretchy)
|
||||
|
||||
svgCanvas.getDrawnPath.mockReturnValue(drawnPath)
|
||||
|
||||
pathActionsMethod.clear()
|
||||
|
||||
expect(svgCanvas.setDrawnPath).toHaveBeenCalledWith(null)
|
||||
expect(svgCanvas.setStarted).toHaveBeenCalledWith(false)
|
||||
})
|
||||
|
||||
it('should switch to select mode if in pathedit mode', () => {
|
||||
svgCanvas.getCurrentMode.mockReturnValue('pathedit')
|
||||
svgCanvas.getDrawnPath.mockReturnValue(null)
|
||||
|
||||
pathActionsMethod.clear()
|
||||
|
||||
expect(svgCanvas.setCurrentMode).toHaveBeenCalledWith('select')
|
||||
})
|
||||
})
|
||||
|
||||
describe('resetOrientation', () => {
|
||||
it('should reset path orientation', () => {
|
||||
pathElement.setAttribute('transform', 'rotate(45 50 50)')
|
||||
|
||||
const result = pathActionsMethod.resetOrientation(pathElement)
|
||||
|
||||
expect(svgCanvas.reorientGrads).toHaveBeenCalled()
|
||||
expect(result).toBeUndefined()
|
||||
})
|
||||
|
||||
it('should return false for non-path elements', () => {
|
||||
const rect = document.createElementNS(NS.SVG, 'rect')
|
||||
|
||||
const result = pathActionsMethod.resetOrientation(rect)
|
||||
|
||||
expect(result).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('zoomChange', () => {
|
||||
it('should update path on zoom change in pathedit mode', () => {
|
||||
pathActionsMethod.toEditMode(pathElement)
|
||||
svgCanvas.getCurrentMode.mockReturnValue('pathedit')
|
||||
|
||||
pathActionsMethod.zoomChange()
|
||||
|
||||
expect(mockPath.update).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should do nothing if not in pathedit mode', () => {
|
||||
svgCanvas.getCurrentMode.mockReturnValue('select')
|
||||
|
||||
pathActionsMethod.zoomChange()
|
||||
|
||||
expect(mockPath.update).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('getNodePoint', () => {
|
||||
it('should return selected node point', () => {
|
||||
mockPath.selected_pts = [1]
|
||||
svgCanvas.getPath_.mockReturnValue(mockPath)
|
||||
|
||||
const result = pathActionsMethod.getNodePoint()
|
||||
|
||||
expect(result).toEqual({
|
||||
x: 50,
|
||||
y: 50,
|
||||
type: 4
|
||||
})
|
||||
})
|
||||
|
||||
it('should return first point if no selection', () => {
|
||||
mockPath.selected_pts = []
|
||||
svgCanvas.getPath_.mockReturnValue(mockPath)
|
||||
|
||||
const result = pathActionsMethod.getNodePoint()
|
||||
|
||||
expect(result.x).toBeDefined()
|
||||
expect(result.y).toBeDefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('linkControlPoints', () => {
|
||||
it('should set link control points flag', () => {
|
||||
pathActionsMethod.linkControlPoints(true)
|
||||
|
||||
expect(svgCanvas.setLinkControlPoints).toHaveBeenCalledWith(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('clonePathNode', () => {
|
||||
it('should clone selected path nodes', () => {
|
||||
pathActionsMethod.toEditMode(pathElement)
|
||||
mockPath.selected_pts = [1]
|
||||
|
||||
pathActionsMethod.clonePathNode()
|
||||
|
||||
expect(mockPath.storeD).toHaveBeenCalled()
|
||||
expect(mockPath.addSeg).toHaveBeenCalled()
|
||||
expect(mockPath.init).toHaveBeenCalled()
|
||||
expect(mockPath.endChanges).toHaveBeenCalledWith('Clone path node(s)')
|
||||
})
|
||||
})
|
||||
|
||||
describe('deletePathNode', () => {
|
||||
it('should delete selected path nodes', () => {
|
||||
pathActionsMethod.toEditMode(pathElement)
|
||||
mockPath.selected_pts = [1]
|
||||
|
||||
// Mock canDeleteNodes property
|
||||
Object.defineProperty(pathActionsMethod, 'canDeleteNodes', {
|
||||
get: () => true,
|
||||
configurable: true
|
||||
})
|
||||
|
||||
// Mock pathSegList on the element
|
||||
Object.defineProperty(pathElement, 'pathSegList', {
|
||||
value: {
|
||||
numberOfItems: 3,
|
||||
getItem: vi.fn((i) => ({
|
||||
pathSegType: i === 0 ? 2 : 4 // M then L segments
|
||||
})),
|
||||
removeItem: vi.fn()
|
||||
},
|
||||
configurable: true
|
||||
})
|
||||
|
||||
pathActionsMethod.deletePathNode()
|
||||
|
||||
expect(mockPath.storeD).toHaveBeenCalled()
|
||||
expect(mockPath.deleteSeg).toHaveBeenCalled()
|
||||
expect(mockPath.init).toHaveBeenCalled()
|
||||
expect(mockPath.clearSelection).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('smoothPolylineIntoPath', () => {
|
||||
it('should convert polyline to smooth path', () => {
|
||||
const polyline = document.createElementNS(NS.SVG, 'polyline')
|
||||
polyline.setAttribute('points', '10,10 50,50 90,10 130,50')
|
||||
|
||||
const mockPoints = {
|
||||
numberOfItems: 4,
|
||||
getItem: vi.fn((i) => {
|
||||
const points = [[10, 10], [50, 50], [90, 10], [130, 50]]
|
||||
return { x: points[i][0], y: points[i][1] }
|
||||
})
|
||||
}
|
||||
Object.defineProperty(polyline, 'points', {
|
||||
get: () => mockPoints,
|
||||
configurable: true
|
||||
})
|
||||
|
||||
const result = pathActionsMethod.smoothPolylineIntoPath(polyline)
|
||||
|
||||
expect(svgCanvas.addSVGElementsFromJson).toHaveBeenCalled()
|
||||
expect(result).toBeDefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('setSegType', () => {
|
||||
it('should set path segment type', () => {
|
||||
pathActionsMethod.toEditMode(pathElement)
|
||||
|
||||
pathActionsMethod.setSegType(6)
|
||||
|
||||
expect(mockPath.setSegType).toHaveBeenCalledWith(6)
|
||||
})
|
||||
})
|
||||
|
||||
describe('moveNode', () => {
|
||||
it('should move selected path node', () => {
|
||||
pathActionsMethod.toEditMode(pathElement)
|
||||
mockPath.selected_pts = [1]
|
||||
|
||||
pathActionsMethod.moveNode('x', 60)
|
||||
|
||||
expect(mockPath.segs[1].move).toHaveBeenCalled()
|
||||
expect(mockPath.endChanges).toHaveBeenCalledWith('Move path point')
|
||||
})
|
||||
|
||||
it('should do nothing if no points selected', () => {
|
||||
pathActionsMethod.toEditMode(pathElement)
|
||||
mockPath.selected_pts = []
|
||||
|
||||
// When no points selected, should return early
|
||||
pathActionsMethod.moveNode('x', 60)
|
||||
|
||||
// Verify no seg.move was called
|
||||
mockPath.segs.forEach(seg => {
|
||||
expect(seg.move).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('convertPath', () => {
|
||||
it('should convert path to relative coordinates', () => {
|
||||
const path = document.createElementNS(NS.SVG, 'path')
|
||||
path.setAttribute('d', 'M10,10 L50,50 L90,10 z')
|
||||
|
||||
const result = pathActionsMethod.convertPath(path, true)
|
||||
|
||||
expect(result).toBeDefined()
|
||||
expect(typeof result).toBe('string')
|
||||
expect(result).toContain('m') // Should have relative move command
|
||||
})
|
||||
|
||||
it('should convert path to absolute coordinates', () => {
|
||||
const path = document.createElementNS(NS.SVG, 'path')
|
||||
path.setAttribute('d', 'm10,10 l40,40 l40,-40 z')
|
||||
|
||||
const result = pathActionsMethod.convertPath(path, false)
|
||||
|
||||
expect(result).toBeDefined()
|
||||
expect(typeof result).toBe('string')
|
||||
expect(result).toContain('M') // Should have absolute move command
|
||||
})
|
||||
})
|
||||
|
||||
describe('Private field encapsulation', () => {
|
||||
it('should not expose private fields', () => {
|
||||
const privateFields = ['subpath', 'newPoint', 'firstCtrl', 'currentPath', 'hasMoved']
|
||||
|
||||
privateFields.forEach(field => {
|
||||
expect(pathActionsMethod[field]).toBeUndefined()
|
||||
expect(pathActionsMethod[`#${field}`]).toBeUndefined()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('Integration scenarios', () => {
|
||||
it('should handle complete path drawing workflow', () => {
|
||||
// Start drawing
|
||||
svgCanvas.getCurrentMode.mockReturnValue('path')
|
||||
svgCanvas.getDrawnPath.mockReturnValue(null)
|
||||
|
||||
// First point
|
||||
pathActionsMethod.mouseDown({ target: svgRoot }, svgRoot, 10, 10)
|
||||
expect(svgCanvas.addPointGrip).toHaveBeenCalled()
|
||||
|
||||
// Add more points
|
||||
const drawnPath = document.createElementNS(NS.SVG, 'path')
|
||||
drawnPath.setAttribute('d', 'M10,10 L50,50')
|
||||
svgCanvas.getDrawnPath.mockReturnValue(drawnPath)
|
||||
|
||||
pathActionsMethod.mouseMove(50, 50)
|
||||
expect(svgCanvas.replacePathSeg).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should handle path editing with transform', () => {
|
||||
pathElement.setAttribute('transform', 'translate(10,10) rotate(45)')
|
||||
mockPath.matrix = { a: 0.707, b: 0.707, c: -0.707, d: 0.707, e: 10, f: 10 }
|
||||
|
||||
pathActionsMethod.toEditMode(pathElement)
|
||||
|
||||
expect(mockPath.show).toHaveBeenCalledWith(true)
|
||||
expect(mockPath.update).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -2,6 +2,7 @@
|
||||
import 'pathseg'
|
||||
import { NS } from '../../packages/svgcanvas/core/namespaces.js'
|
||||
import * as utilities from '../../packages/svgcanvas/core/utilities.js'
|
||||
import { convertPath as convertPathActions } from '../../packages/svgcanvas/core/path-actions.js'
|
||||
import * as pathModule from '../../packages/svgcanvas/core/path.js'
|
||||
import { Path, Segment } from '../../packages/svgcanvas/core/path-method.js'
|
||||
import { init as unitsInit } from '../../packages/svgcanvas/core/units.js'
|
||||
@@ -41,6 +42,12 @@ describe('path', function () {
|
||||
]
|
||||
}
|
||||
|
||||
it('Test svgedit.path.init exposes recalcRotatedPath', function () {
|
||||
const [mockPathContext] = getMockContexts()
|
||||
pathModule.init(mockPathContext)
|
||||
assert.equal(typeof mockPathContext.recalcRotatedPath, 'function')
|
||||
})
|
||||
|
||||
it('Test svgedit.path.replacePathSeg', function () {
|
||||
const path = document.createElementNS(NS.SVG, 'path')
|
||||
path.setAttribute('d', 'M0,0 L10,11 L20,21Z')
|
||||
@@ -137,6 +144,63 @@ describe('path', function () {
|
||||
assert.equal(path.pathSegList.getItem(1).y, 15)
|
||||
})
|
||||
|
||||
it('Test svgedit.path.Segment.move for quadratic curve', function () {
|
||||
const path = document.createElementNS(NS.SVG, 'path')
|
||||
path.setAttribute('d', 'M0,0 Q11,12 15,16')
|
||||
|
||||
const [mockPathContext, mockUtilitiesContext] = getMockContexts()
|
||||
pathModule.init(mockPathContext)
|
||||
utilities.init(mockUtilitiesContext)
|
||||
const pathObj = new Path(path)
|
||||
|
||||
pathObj.segs[1].move(-3, 4)
|
||||
const seg = path.pathSegList.getItem(1)
|
||||
|
||||
assert.equal(seg.pathSegTypeAsLetter, 'Q')
|
||||
assert.equal(seg.x, 12)
|
||||
assert.equal(seg.y, 20)
|
||||
assert.equal(seg.x1, 11)
|
||||
assert.equal(seg.y1, 12)
|
||||
})
|
||||
|
||||
it('Test svgedit.path.Segment.move for smooth cubic curve', function () {
|
||||
const path = document.createElementNS(NS.SVG, 'path')
|
||||
path.setAttribute('d', 'M0,0 S13,14 15,16')
|
||||
|
||||
const [mockPathContext, mockUtilitiesContext] = getMockContexts()
|
||||
pathModule.init(mockPathContext)
|
||||
utilities.init(mockUtilitiesContext)
|
||||
const pathObj = new Path(path)
|
||||
|
||||
pathObj.segs[1].move(5, -6)
|
||||
const seg = path.pathSegList.getItem(1)
|
||||
|
||||
assert.equal(seg.pathSegTypeAsLetter, 'S')
|
||||
assert.equal(seg.x, 20)
|
||||
assert.equal(seg.y, 10)
|
||||
assert.equal(seg.x2, 18)
|
||||
assert.equal(seg.y2, 8)
|
||||
})
|
||||
|
||||
it('Test moving start point moves next quadratic control point', function () {
|
||||
const path = document.createElementNS(NS.SVG, 'path')
|
||||
path.setAttribute('d', 'M0,0 Q10,0 20,0')
|
||||
|
||||
const [mockPathContext, mockUtilitiesContext] = getMockContexts()
|
||||
pathModule.init(mockPathContext)
|
||||
utilities.init(mockUtilitiesContext)
|
||||
const pathObj = new Path(path)
|
||||
|
||||
pathObj.segs[0].move(5, 5)
|
||||
const seg = path.pathSegList.getItem(1)
|
||||
|
||||
assert.equal(seg.pathSegTypeAsLetter, 'Q')
|
||||
assert.equal(seg.x, 20)
|
||||
assert.equal(seg.y, 0)
|
||||
assert.equal(seg.x1, 15)
|
||||
assert.equal(seg.y1, 5)
|
||||
})
|
||||
|
||||
it('Test svgedit.path.Segment.moveCtrl', function () {
|
||||
const path = document.createElementNS(NS.SVG, 'path')
|
||||
path.setAttribute('d', 'M0,0 C11,12 13,14 15,16 Z')
|
||||
@@ -179,4 +243,269 @@ describe('path', function () {
|
||||
const rel = pathModule.convertPath(path, true)
|
||||
assert.equal(rel, 'm40,55l20,0l0,20')
|
||||
})
|
||||
|
||||
it('Test convertPath resets after closepath when relative', function () {
|
||||
unitsInit({
|
||||
getRoundDigits () { return 5 }
|
||||
})
|
||||
|
||||
const path = document.createElementNS(NS.SVG, 'path')
|
||||
path.setAttribute('d', 'M10,10 L20,10 Z L15,10')
|
||||
const expected = 'm10,10l10,0zl5,0'
|
||||
|
||||
assert.equal(pathModule.convertPath(path, true), expected)
|
||||
assert.equal(convertPathActions(path, true), expected)
|
||||
})
|
||||
|
||||
it('Test recalcRotatedPath preserves zero control points', function () {
|
||||
const svg = document.createElementNS(NS.SVG, 'svg')
|
||||
const path = document.createElementNS(NS.SVG, 'path')
|
||||
path.setAttribute('d', 'M0,0 C0,10 0,20 30,30')
|
||||
path.setAttribute('transform', 'rotate(45 0 0)')
|
||||
svg.append(path)
|
||||
|
||||
const [mockPathContext, mockUtilitiesContext] = getMockContexts(svg)
|
||||
pathModule.init(mockPathContext)
|
||||
utilities.init(mockUtilitiesContext)
|
||||
const pathObj = new Path(path)
|
||||
pathObj.oldbbox = utilities.getBBox(path)
|
||||
|
||||
pathModule.recalcRotatedPath()
|
||||
|
||||
const seg = path.pathSegList.getItem(1)
|
||||
assert.equal(seg.pathSegTypeAsLetter, 'C')
|
||||
assert.closeTo(seg.x1, 0, 1e-6)
|
||||
assert.closeTo(seg.y1, 10, 1e-6)
|
||||
assert.closeTo(seg.x2, 0, 1e-6)
|
||||
assert.closeTo(seg.y2, 20, 1e-6)
|
||||
assert.closeTo(seg.x, 30, 1e-6)
|
||||
assert.closeTo(seg.y, 30, 1e-6)
|
||||
})
|
||||
|
||||
it('Test convertPath handles relative arcs', function () {
|
||||
unitsInit({
|
||||
getRoundDigits () { return 5 }
|
||||
})
|
||||
|
||||
const path = document.createElementNS(NS.SVG, 'path')
|
||||
path.setAttribute('d', 'M0,0 a10,20 30 0 1 40,50')
|
||||
|
||||
const abs = pathModule.convertPath(path)
|
||||
assert.ok(abs.includes('A10,20 30 0 1 40,50'))
|
||||
|
||||
const rel = pathModule.convertPath(path, true)
|
||||
assert.ok(rel.includes('a10,20 30 0 1 40,50'))
|
||||
})
|
||||
|
||||
it('Test recalcRotatedPath with no current path', function () {
|
||||
const [mockPathContext] = getMockContexts()
|
||||
pathModule.init(mockPathContext)
|
||||
// path is null initially after init
|
||||
pathModule.recalcRotatedPath()
|
||||
// Should not throw
|
||||
})
|
||||
|
||||
it('Test recalcRotatedPath with path without rotation', function () {
|
||||
const svg = document.createElementNS(NS.SVG, 'svg')
|
||||
const path = document.createElementNS(NS.SVG, 'path')
|
||||
path.setAttribute('d', 'M0,0 L10,10')
|
||||
svg.append(path)
|
||||
|
||||
const [mockPathContext, mockUtilitiesContext] = getMockContexts(svg)
|
||||
pathModule.init(mockPathContext)
|
||||
utilities.init(mockUtilitiesContext)
|
||||
const pathObj = new Path(path)
|
||||
pathObj.oldbbox = utilities.getBBox(path)
|
||||
|
||||
pathModule.recalcRotatedPath()
|
||||
// Should not throw, and path should remain unchanged
|
||||
assert.equal(path.getAttribute('d'), 'M0,0 L10,10')
|
||||
})
|
||||
|
||||
it('Test recalcRotatedPath with path without oldbbox', function () {
|
||||
const svg = document.createElementNS(NS.SVG, 'svg')
|
||||
const path = document.createElementNS(NS.SVG, 'path')
|
||||
path.setAttribute('d', 'M0,0 L10,10')
|
||||
path.setAttribute('transform', 'rotate(45 0 0)')
|
||||
svg.append(path)
|
||||
|
||||
const [mockPathContext] = getMockContexts(svg)
|
||||
pathModule.init(mockPathContext)
|
||||
const pathObj = new Path(path)
|
||||
pathObj.oldbbox = null
|
||||
|
||||
pathModule.recalcRotatedPath()
|
||||
// Should not throw
|
||||
})
|
||||
|
||||
it('Test Segment class with various pathSegTypes', function () {
|
||||
const path = document.createElementNS(NS.SVG, 'path')
|
||||
path.setAttribute('d', 'M0,0 H10 V10 Z')
|
||||
|
||||
const seg1 = new Segment(0, path.pathSegList.getItem(0))
|
||||
assert.equal(seg1.index, 0)
|
||||
|
||||
const seg2 = new Segment(1, path.pathSegList.getItem(1))
|
||||
assert.equal(seg2.type, 12) // PATHSEG_LINETO_HORIZONTAL_ABS
|
||||
|
||||
const seg3 = new Segment(2, path.pathSegList.getItem(2))
|
||||
assert.equal(seg3.type, 14) // PATHSEG_LINETO_VERTICAL_ABS
|
||||
})
|
||||
|
||||
it('Test convertPath with smooth cubic curve', function () {
|
||||
const path = document.createElementNS(NS.SVG, 'path')
|
||||
path.setAttribute('d', 'M0,0 S10,10 20,20')
|
||||
|
||||
const result = pathModule.convertPath(path, false)
|
||||
assert.ok(result.includes('S'))
|
||||
})
|
||||
|
||||
it('Test convertPath with quadratic curve', function () {
|
||||
const path = document.createElementNS(NS.SVG, 'path')
|
||||
path.setAttribute('d', 'M0,0 Q10,10 20,20')
|
||||
|
||||
const result = pathModule.convertPath(path, false)
|
||||
assert.ok(result.includes('Q'))
|
||||
})
|
||||
|
||||
it('Test Path.update with no pathSegList', function () {
|
||||
const svg = document.createElementNS(NS.SVG, 'svg')
|
||||
const rect = document.createElementNS(NS.SVG, 'rect')
|
||||
svg.append(rect)
|
||||
|
||||
const [mockPathContext, mockUtilitiesContext] = getMockContexts(svg)
|
||||
utilities.init(mockUtilitiesContext)
|
||||
pathModule.init(mockPathContext)
|
||||
|
||||
try {
|
||||
const pathObj = new Path(rect)
|
||||
pathObj.update()
|
||||
} catch (e) {
|
||||
// Expected for non-path elements
|
||||
assert.ok(true)
|
||||
}
|
||||
})
|
||||
|
||||
it('Test convertPath with smooth quadratic curve', function () {
|
||||
const path = document.createElementNS(NS.SVG, 'path')
|
||||
path.setAttribute('d', 'M0,0 Q10,10 20,0 T40,0')
|
||||
|
||||
const result = pathModule.convertPath(path, false)
|
||||
assert.ok(result.includes('T'))
|
||||
})
|
||||
|
||||
it('Test convertPath with mixed absolute and relative commands', function () {
|
||||
const path = document.createElementNS(NS.SVG, 'path')
|
||||
path.setAttribute('d', 'M0,0 L10,10 l5,5 L20,20')
|
||||
|
||||
const abs = pathModule.convertPath(path, false)
|
||||
assert.ok(abs.includes('L'))
|
||||
|
||||
const rel = pathModule.convertPath(path, true)
|
||||
assert.ok(rel.includes('l'))
|
||||
})
|
||||
|
||||
it('Test convertPath with horizontal and vertical lines', function () {
|
||||
const path = document.createElementNS(NS.SVG, 'path')
|
||||
path.setAttribute('d', 'M0,0 H10 V10 h5 v5')
|
||||
|
||||
const result = pathModule.convertPath(path)
|
||||
assert.ok(result.length > 0)
|
||||
})
|
||||
|
||||
it('Test Segment with arc command', function () {
|
||||
const path = document.createElementNS(NS.SVG, 'path')
|
||||
path.setAttribute('d', 'M0,0 A10,10 0 0 1 20,20')
|
||||
|
||||
const seg = new Segment(1, path.pathSegList.getItem(1))
|
||||
assert.equal(seg.type, 10) // PATHSEG_ARC_ABS
|
||||
})
|
||||
|
||||
it('Test convertPath with quadratic bezier', function () {
|
||||
const path = document.createElementNS(NS.SVG, 'path')
|
||||
path.setAttribute('d', 'M0,0 Q10,10 20,0')
|
||||
|
||||
const result = pathModule.convertPath(path)
|
||||
assert.ok(result.length > 0)
|
||||
})
|
||||
|
||||
it('Test convertPath with smooth quadratic', function () {
|
||||
const path = document.createElementNS(NS.SVG, 'path')
|
||||
path.setAttribute('d', 'M0,0 Q10,10 20,0 T30,0')
|
||||
|
||||
const result = pathModule.convertPath(path)
|
||||
assert.ok(result.length > 0)
|
||||
})
|
||||
|
||||
it('Test convertPath with arc sweep flags', function () {
|
||||
const path = document.createElementNS(NS.SVG, 'path')
|
||||
path.setAttribute('d', 'M0,0 A10,10 0 1 0 20,20')
|
||||
|
||||
const result = pathModule.convertPath(path)
|
||||
assert.ok(result.length > 0)
|
||||
})
|
||||
|
||||
it('Test convertPath with relative arc', function () {
|
||||
const path = document.createElementNS(NS.SVG, 'path')
|
||||
path.setAttribute('d', 'M10,10 a5,5 0 0 1 10,10')
|
||||
|
||||
const result = pathModule.convertPath(path)
|
||||
assert.ok(result.length > 0)
|
||||
})
|
||||
|
||||
it('Test convertPath with close path', function () {
|
||||
const path = document.createElementNS(NS.SVG, 'path')
|
||||
path.setAttribute('d', 'M0,0 L10,0 L10,10 Z')
|
||||
|
||||
const result = pathModule.convertPath(path)
|
||||
assert.ok(result.includes('Z') || result.includes('z'))
|
||||
})
|
||||
|
||||
it('Test convertPath with mixed case commands', function () {
|
||||
const path = document.createElementNS(NS.SVG, 'path')
|
||||
path.setAttribute('d', 'M0,0 L10,10 l5,5 C20,20 25,25 30,20')
|
||||
|
||||
const result = pathModule.convertPath(path)
|
||||
assert.ok(result.length > 0)
|
||||
})
|
||||
|
||||
it('Test Segment getItem', function () {
|
||||
const path = document.createElementNS(NS.SVG, 'path')
|
||||
path.setAttribute('d', 'M0,0 L10,10 L20,20')
|
||||
|
||||
const seg = new Segment(1, path.pathSegList.getItem(1))
|
||||
assert.ok(seg.type)
|
||||
})
|
||||
|
||||
it('Test convertPath with relative smooth cubic', function () {
|
||||
const path = document.createElementNS(NS.SVG, 'path')
|
||||
path.setAttribute('d', 'M0,0 C10,10 20,10 30,0 s10,10 20,0')
|
||||
|
||||
const result = pathModule.convertPath(path)
|
||||
assert.ok(result.length > 0)
|
||||
})
|
||||
|
||||
it('Test convertPath with negative coordinates', function () {
|
||||
const path = document.createElementNS(NS.SVG, 'path')
|
||||
path.setAttribute('d', 'M-10,-10 L-20,-20 L-30,-15')
|
||||
|
||||
const result = pathModule.convertPath(path)
|
||||
assert.ok(result.length > 0)
|
||||
})
|
||||
|
||||
it('Test convertPath with decimal coordinates', function () {
|
||||
const path = document.createElementNS(NS.SVG, 'path')
|
||||
path.setAttribute('d', 'M0.5,0.5 L10.25,10.75')
|
||||
|
||||
const result = pathModule.convertPath(path)
|
||||
assert.ok(result.length > 0)
|
||||
})
|
||||
|
||||
it('Test Segment with move command', function () {
|
||||
const path = document.createElementNS(NS.SVG, 'path')
|
||||
path.setAttribute('d', 'M10,10 L20,20')
|
||||
|
||||
const seg = new Segment(0, path.pathSegList.getItem(0))
|
||||
assert.equal(seg.type, 2) // PATHSEG_MOVETO_ABS
|
||||
})
|
||||
})
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,17 +1,172 @@
|
||||
import { NS } from '../../packages/svgcanvas/core/namespaces.js'
|
||||
import * as sanitize from '../../packages/svgcanvas/core/sanitize.js'
|
||||
import * as utilities from '../../packages/svgcanvas/core/utilities.js'
|
||||
|
||||
describe('sanitize', function () {
|
||||
const svg = document.createElementNS(NS.SVG, 'svg')
|
||||
/** @type {HTMLDivElement} */
|
||||
let container
|
||||
/** @type {SVGSVGElement} */
|
||||
let svg
|
||||
let originalWarn
|
||||
|
||||
it('Test sanitizeSvg() strips ws from style attr', function () {
|
||||
const rect = document.createElementNS(NS.SVG, 'rect')
|
||||
rect.setAttribute('style', 'stroke: blue ;\t\tstroke-width :\t\t40;')
|
||||
const createSvgElement = (name) => document.createElementNS(NS.SVG, name)
|
||||
|
||||
beforeEach(() => {
|
||||
originalWarn = console.warn
|
||||
console.warn = () => {}
|
||||
container = document.createElement('div')
|
||||
svg = /** @type {SVGSVGElement} */ (createSvgElement('svg'))
|
||||
container.append(svg)
|
||||
document.body.append(container)
|
||||
|
||||
utilities.init({
|
||||
getSvgRoot: () => svg
|
||||
})
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
container.remove()
|
||||
console.warn = originalWarn
|
||||
})
|
||||
|
||||
it('sanitizeSvg() strips ws from style attr', function () {
|
||||
const rect = createSvgElement('rect')
|
||||
rect.setAttribute('style', 'stroke: blue ;\t\tstroke-width :\t\t40; vector-effect: non-scaling-stroke;')
|
||||
// sanitizeSvg() requires the node to have a parent and a document.
|
||||
svg.append(rect)
|
||||
sanitize.sanitizeSvg(rect)
|
||||
|
||||
assert.equal(rect.getAttribute('stroke'), 'blue')
|
||||
assert.equal(rect.getAttribute('stroke-width'), '40')
|
||||
assert.equal(rect.hasAttribute('style'), false)
|
||||
assert.equal(rect.hasAttribute('vector-effect'), false)
|
||||
})
|
||||
|
||||
it('sanitizeSvg() removes disallowed attributes but keeps data-*', function () {
|
||||
const rect = createSvgElement('rect')
|
||||
rect.setAttribute('onclick', 'alert(1)')
|
||||
rect.setAttribute('data-note', 'safe')
|
||||
svg.append(rect)
|
||||
|
||||
sanitize.sanitizeSvg(rect)
|
||||
|
||||
assert.equal(rect.hasAttribute('onclick'), false)
|
||||
assert.equal(rect.getAttribute('data-note'), 'safe')
|
||||
})
|
||||
|
||||
it('sanitizeSvg() mirrors xlink:href to href', function () {
|
||||
const image = createSvgElement('image')
|
||||
image.setAttributeNS(NS.XLINK, 'xlink:href', 'http://example.com/test.png')
|
||||
svg.append(image)
|
||||
|
||||
sanitize.sanitizeSvg(image)
|
||||
|
||||
assert.equal(image.getAttribute('href'), 'http://example.com/test.png')
|
||||
assert.equal(image.hasAttributeNS(NS.XLINK, 'href'), false)
|
||||
})
|
||||
|
||||
it('sanitizeSvg() drops non-local hrefs on local-only elements', function () {
|
||||
const gradient = createSvgElement('linearGradient')
|
||||
gradient.setAttribute('href', 'http://example.com/grad')
|
||||
svg.append(gradient)
|
||||
|
||||
sanitize.sanitizeSvg(gradient)
|
||||
|
||||
assert.equal(gradient.hasAttribute('href'), false)
|
||||
})
|
||||
|
||||
it('sanitizeSvg() removes <use> without href', function () {
|
||||
const use = createSvgElement('use')
|
||||
svg.append(use)
|
||||
|
||||
sanitize.sanitizeSvg(use)
|
||||
|
||||
assert.equal(use.parentNode, null)
|
||||
assert.equal(svg.querySelector('use'), null)
|
||||
})
|
||||
|
||||
it('sanitizeSvg() keeps <use> with a local href', function () {
|
||||
const symbol = createSvgElement('symbol')
|
||||
symbol.id = 'icon'
|
||||
symbol.setAttribute('viewBox', '0 0 200 100')
|
||||
svg.append(symbol)
|
||||
|
||||
const use = createSvgElement('use')
|
||||
use.setAttribute('href', '#icon')
|
||||
svg.append(use)
|
||||
|
||||
sanitize.sanitizeSvg(use)
|
||||
|
||||
assert.equal(use.parentNode, svg)
|
||||
assert.equal(use.getAttribute('href'), '#icon')
|
||||
assert.equal(use.hasAttribute('width'), false)
|
||||
assert.equal(use.hasAttribute('height'), false)
|
||||
})
|
||||
|
||||
it('sanitizeSvg() removes non-local url() paint references', function () {
|
||||
const rect = createSvgElement('rect')
|
||||
rect.setAttribute('fill', 'url(http://example.com/pat)')
|
||||
svg.append(rect)
|
||||
|
||||
sanitize.sanitizeSvg(rect)
|
||||
|
||||
assert.equal(rect.hasAttribute('fill'), false)
|
||||
})
|
||||
|
||||
it('sanitizeSvg() trims and removes text nodes', function () {
|
||||
const text = createSvgElement('text')
|
||||
text.append(document.createTextNode(' Hello '), document.createTextNode(' '))
|
||||
svg.append(text)
|
||||
|
||||
sanitize.sanitizeSvg(text)
|
||||
|
||||
assert.equal(text.textContent, 'Hello')
|
||||
})
|
||||
|
||||
it('sanitizeSvg() removes unsupported elements but keeps children', function () {
|
||||
const unknown = createSvgElement('foo')
|
||||
const rect = createSvgElement('rect')
|
||||
unknown.append(rect)
|
||||
svg.append(unknown)
|
||||
|
||||
sanitize.sanitizeSvg(unknown)
|
||||
|
||||
assert.equal(svg.querySelector('foo'), null)
|
||||
assert.equal(rect.parentNode, svg)
|
||||
})
|
||||
|
||||
it('sanitizeSvg() handles element with id attribute', function () {
|
||||
const rect = createSvgElement('rect')
|
||||
rect.setAttribute('id', 'myRect')
|
||||
rect.setAttribute('x', '10')
|
||||
rect.setAttribute('y', '20')
|
||||
svg.append(rect)
|
||||
|
||||
sanitize.sanitizeSvg(rect)
|
||||
|
||||
assert.equal(rect.getAttribute('id'), 'myRect')
|
||||
})
|
||||
|
||||
it('sanitizeSvg() handles comment nodes', function () {
|
||||
const g = createSvgElement('g')
|
||||
const comment = document.createComment('This is a comment')
|
||||
g.append(comment)
|
||||
svg.append(g)
|
||||
|
||||
sanitize.sanitizeSvg(g)
|
||||
assert.ok(true)
|
||||
})
|
||||
|
||||
it('sanitizeSvg() handles nested groups', function () {
|
||||
const g1 = createSvgElement('g')
|
||||
const g2 = createSvgElement('g')
|
||||
const rect = createSvgElement('rect')
|
||||
g2.append(rect)
|
||||
g1.append(g2)
|
||||
svg.append(g1)
|
||||
|
||||
sanitize.sanitizeSvg(g1)
|
||||
|
||||
assert.ok(svg.querySelector('rect'))
|
||||
})
|
||||
})
|
||||
|
||||
479
tests/unit/select-module.test.js
Normal file
479
tests/unit/select-module.test.js
Normal file
@@ -0,0 +1,479 @@
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
|
||||
import { init as selectInit, getSelectorManager, Selector, SelectorManager } from '../../packages/svgcanvas/core/select.js'
|
||||
import { NS } from '../../packages/svgcanvas/core/namespaces.js'
|
||||
|
||||
describe('Select Module', () => {
|
||||
let svgRoot
|
||||
let svgContent
|
||||
let svgCanvas
|
||||
let rectElement
|
||||
let circleElement
|
||||
|
||||
beforeEach(() => {
|
||||
// Create mock SVG elements
|
||||
svgRoot = document.createElementNS(NS.SVG, 'svg')
|
||||
svgRoot.setAttribute('width', '640')
|
||||
svgRoot.setAttribute('height', '480')
|
||||
document.body.append(svgRoot)
|
||||
|
||||
svgContent = document.createElementNS(NS.SVG, 'g')
|
||||
svgContent.setAttribute('id', 'svgcontent')
|
||||
svgRoot.append(svgContent)
|
||||
|
||||
rectElement = document.createElementNS(NS.SVG, 'rect')
|
||||
rectElement.setAttribute('id', 'rect1')
|
||||
rectElement.setAttribute('x', '10')
|
||||
rectElement.setAttribute('y', '10')
|
||||
rectElement.setAttribute('width', '100')
|
||||
rectElement.setAttribute('height', '50')
|
||||
svgContent.append(rectElement)
|
||||
|
||||
circleElement = document.createElementNS(NS.SVG, 'circle')
|
||||
circleElement.setAttribute('id', 'circle1')
|
||||
circleElement.setAttribute('cx', '200')
|
||||
circleElement.setAttribute('cy', '200')
|
||||
circleElement.setAttribute('r', '50')
|
||||
svgContent.append(circleElement)
|
||||
|
||||
// Mock data storage
|
||||
const mockDataStorage = {
|
||||
_storage: new Map(),
|
||||
put: function (element, key, value) {
|
||||
if (!this._storage.has(element)) {
|
||||
this._storage.set(element, new Map())
|
||||
}
|
||||
this._storage.get(element).set(key, value)
|
||||
},
|
||||
get: function (element, key) {
|
||||
return this._storage.has(element) ? this._storage.get(element).get(key) : undefined
|
||||
},
|
||||
has: function (element, key) {
|
||||
return this._storage.has(element) && this._storage.get(element).has(key)
|
||||
}
|
||||
}
|
||||
|
||||
// Mock svgCanvas
|
||||
svgCanvas = {
|
||||
getSvgRoot: () => svgRoot,
|
||||
getSvgContent: () => svgContent,
|
||||
getZoom: () => 1,
|
||||
getDataStorage: () => mockDataStorage,
|
||||
curConfig: {
|
||||
imgPath: 'images',
|
||||
dimensions: [640, 480]
|
||||
},
|
||||
createSVGElement: vi.fn((config) => {
|
||||
const elem = document.createElementNS(NS.SVG, config.element)
|
||||
if (config.attr) {
|
||||
Object.entries(config.attr).forEach(([key, value]) => {
|
||||
elem.setAttribute(key, String(value))
|
||||
})
|
||||
}
|
||||
return elem
|
||||
})
|
||||
}
|
||||
|
||||
// Initialize select module
|
||||
selectInit(svgCanvas)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
document.body.textContent = ''
|
||||
})
|
||||
|
||||
describe('Module initialization', () => {
|
||||
it('should initialize and return SelectorManager singleton', () => {
|
||||
const manager = getSelectorManager()
|
||||
expect(manager).toBeDefined()
|
||||
expect(manager).toBeInstanceOf(SelectorManager)
|
||||
})
|
||||
|
||||
it('should return the same SelectorManager instance', () => {
|
||||
const manager1 = getSelectorManager()
|
||||
const manager2 = getSelectorManager()
|
||||
expect(manager1).toBe(manager2)
|
||||
})
|
||||
|
||||
it('should not expose private selectorManager field', () => {
|
||||
const manager = getSelectorManager()
|
||||
expect(manager.selectorManager).toBeUndefined()
|
||||
expect(manager.selectorManager_).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('SelectorManager class', () => {
|
||||
let manager
|
||||
|
||||
beforeEach(() => {
|
||||
manager = getSelectorManager()
|
||||
})
|
||||
|
||||
it('should have initialized all required properties', () => {
|
||||
expect(manager.selectorParentGroup).toBeDefined()
|
||||
expect(manager.rubberBandBox).toBeNull()
|
||||
expect(manager.selectors).toEqual([])
|
||||
expect(manager.selectorMap).toEqual({})
|
||||
expect(manager.selectorGrips).toBeDefined()
|
||||
expect(manager.selectorGripsGroup).toBeDefined()
|
||||
expect(manager.rotateGripConnector).toBeDefined()
|
||||
expect(manager.rotateGrip).toBeDefined()
|
||||
})
|
||||
|
||||
it('should have all 8 selector grips', () => {
|
||||
const directions = ['nw', 'n', 'ne', 'e', 'se', 's', 'sw', 'w']
|
||||
directions.forEach(dir => {
|
||||
expect(manager.selectorGrips[dir]).toBeDefined()
|
||||
expect(manager.selectorGrips[dir].tagName).toBe('circle')
|
||||
})
|
||||
})
|
||||
|
||||
it('should create selectorParentGroup in DOM', () => {
|
||||
const parentGroup = svgRoot.querySelector('#selectorParentGroup')
|
||||
expect(parentGroup).toBeDefined()
|
||||
expect(parentGroup).toBe(manager.selectorParentGroup)
|
||||
})
|
||||
|
||||
describe('requestSelector', () => {
|
||||
it('should create a new selector for an element', () => {
|
||||
const selector = manager.requestSelector(rectElement)
|
||||
expect(selector).toBeInstanceOf(Selector)
|
||||
expect(selector.selectedElement).toBe(rectElement)
|
||||
expect(selector.locked).toBe(true)
|
||||
})
|
||||
|
||||
it('should return existing selector for same element', () => {
|
||||
const selector1 = manager.requestSelector(rectElement)
|
||||
const selector2 = manager.requestSelector(rectElement)
|
||||
expect(selector1).toBe(selector2)
|
||||
})
|
||||
|
||||
it('should reuse unlocked selectors', () => {
|
||||
const selector1 = manager.requestSelector(rectElement)
|
||||
manager.releaseSelector(rectElement)
|
||||
const selector2 = manager.requestSelector(circleElement)
|
||||
expect(selector1).toBe(selector2)
|
||||
expect(selector2.selectedElement).toBe(circleElement)
|
||||
})
|
||||
|
||||
it('should create multiple selectors when needed', () => {
|
||||
const selector1 = manager.requestSelector(rectElement)
|
||||
const selector2 = manager.requestSelector(circleElement)
|
||||
expect(selector1).not.toBe(selector2)
|
||||
expect(manager.selectors.length).toBe(2)
|
||||
})
|
||||
|
||||
it('should return null for null element', () => {
|
||||
const selector = manager.requestSelector(null)
|
||||
expect(selector).toBeNull()
|
||||
})
|
||||
|
||||
it('should add selector to selectorMap', () => {
|
||||
const selector = manager.requestSelector(rectElement)
|
||||
expect(manager.selectorMap[rectElement.id]).toBe(selector)
|
||||
})
|
||||
})
|
||||
|
||||
describe('releaseSelector', () => {
|
||||
it('should unlock selector', () => {
|
||||
const selector = manager.requestSelector(rectElement)
|
||||
expect(selector.locked).toBe(true)
|
||||
manager.releaseSelector(rectElement)
|
||||
expect(selector.locked).toBe(false)
|
||||
})
|
||||
|
||||
it('should remove selector from selectorMap', () => {
|
||||
manager.requestSelector(rectElement)
|
||||
expect(manager.selectorMap[rectElement.id]).toBeDefined()
|
||||
manager.releaseSelector(rectElement)
|
||||
expect(manager.selectorMap[rectElement.id]).toBeUndefined()
|
||||
})
|
||||
|
||||
it('should clear selectedElement', () => {
|
||||
const selector = manager.requestSelector(rectElement)
|
||||
manager.releaseSelector(rectElement)
|
||||
expect(selector.selectedElement).toBeNull()
|
||||
})
|
||||
|
||||
it('should hide selector group', () => {
|
||||
const selector = manager.requestSelector(rectElement)
|
||||
manager.releaseSelector(rectElement)
|
||||
expect(selector.selectorGroup.getAttribute('display')).toBe('none')
|
||||
})
|
||||
|
||||
it('should handle null element gracefully', () => {
|
||||
expect(() => manager.releaseSelector(null)).not.toThrow()
|
||||
})
|
||||
})
|
||||
|
||||
describe('getRubberBandBox', () => {
|
||||
it('should create rubber band box on first call', () => {
|
||||
const rubberBand = manager.getRubberBandBox()
|
||||
expect(rubberBand).toBeDefined()
|
||||
expect(rubberBand.tagName).toBe('rect')
|
||||
expect(rubberBand.id).toBe('selectorRubberBand')
|
||||
})
|
||||
|
||||
it('should return same rubber band box on subsequent calls', () => {
|
||||
const rubberBand1 = manager.getRubberBandBox()
|
||||
const rubberBand2 = manager.getRubberBandBox()
|
||||
expect(rubberBand1).toBe(rubberBand2)
|
||||
})
|
||||
|
||||
it('should have correct initial display state', () => {
|
||||
const rubberBand = manager.getRubberBandBox()
|
||||
expect(rubberBand.getAttribute('display')).toBe('none')
|
||||
})
|
||||
})
|
||||
|
||||
describe('initGroup', () => {
|
||||
it('should reset selectors and selectorMap', () => {
|
||||
manager.requestSelector(rectElement)
|
||||
manager.initGroup()
|
||||
expect(manager.selectors).toEqual([])
|
||||
expect(manager.selectorMap).toEqual({})
|
||||
})
|
||||
|
||||
it('should recreate selectorParentGroup', () => {
|
||||
const oldGroup = manager.selectorParentGroup
|
||||
manager.initGroup()
|
||||
const newGroup = manager.selectorParentGroup
|
||||
expect(newGroup).not.toBe(oldGroup)
|
||||
expect(newGroup.id).toBe('selectorParentGroup')
|
||||
})
|
||||
|
||||
it('should create canvasBackground if not exists', () => {
|
||||
// Remove any existing background
|
||||
const existing = document.getElementById('canvasBackground')
|
||||
if (existing) existing.remove()
|
||||
|
||||
manager.initGroup()
|
||||
const background = document.getElementById('canvasBackground')
|
||||
expect(background).toBeDefined()
|
||||
expect(background.tagName).toBe('svg')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('Selector class', () => {
|
||||
let manager
|
||||
let selector
|
||||
|
||||
beforeEach(() => {
|
||||
manager = getSelectorManager()
|
||||
selector = manager.requestSelector(rectElement)
|
||||
})
|
||||
|
||||
it('should have correct initial properties', () => {
|
||||
expect(selector.id).toBeDefined()
|
||||
expect(selector.selectedElement).toBe(rectElement)
|
||||
expect(selector.locked).toBe(true)
|
||||
expect(selector.selectorGroup).toBeDefined()
|
||||
expect(selector.selectorRect).toBeDefined()
|
||||
})
|
||||
|
||||
it('should have all grip coordinates initialized', () => {
|
||||
const expectedGrips = ['nw', 'n', 'ne', 'e', 'se', 's', 'sw', 'w']
|
||||
expectedGrips.forEach(grip => {
|
||||
expect(selector.gripCoords[grip]).toBeDefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('reset', () => {
|
||||
it('should update selectedElement', () => {
|
||||
selector.reset(circleElement)
|
||||
expect(selector.selectedElement).toBe(circleElement)
|
||||
})
|
||||
|
||||
it('should lock the selector', () => {
|
||||
selector.locked = false
|
||||
selector.reset(circleElement)
|
||||
expect(selector.locked).toBe(true)
|
||||
})
|
||||
|
||||
it('should show selectorGroup', () => {
|
||||
selector.selectorGroup.setAttribute('display', 'none')
|
||||
selector.reset(circleElement)
|
||||
expect(selector.selectorGroup.getAttribute('display')).toBe('inline')
|
||||
})
|
||||
})
|
||||
|
||||
describe('resize', () => {
|
||||
it('should update selectorRect d attribute', () => {
|
||||
selector.resize()
|
||||
const d = selector.selectorRect.getAttribute('d')
|
||||
expect(d).toBeTruthy()
|
||||
expect(d).toMatch(/^M/)
|
||||
})
|
||||
|
||||
it('should update grip coordinates', () => {
|
||||
selector.resize()
|
||||
expect(selector.gripCoords.nw).toBeDefined()
|
||||
expect(Array.isArray(selector.gripCoords.nw)).toBe(true)
|
||||
expect(selector.gripCoords.nw.length).toBe(2)
|
||||
})
|
||||
|
||||
it('should use provided bbox when given', () => {
|
||||
const customBbox = { x: 50, y: 50, width: 200, height: 100 }
|
||||
selector.resize(customBbox)
|
||||
const d = selector.selectorRect.getAttribute('d')
|
||||
expect(d).toBeTruthy()
|
||||
})
|
||||
})
|
||||
|
||||
describe('showGrips', () => {
|
||||
it('should show grips when true', () => {
|
||||
selector.showGrips(true)
|
||||
expect(selector.hasGrips).toBe(true)
|
||||
expect(manager.selectorGripsGroup.getAttribute('display')).toBe('inline')
|
||||
})
|
||||
|
||||
it('should hide grips when false', () => {
|
||||
selector.showGrips(true)
|
||||
selector.showGrips(false)
|
||||
expect(selector.hasGrips).toBe(false)
|
||||
expect(manager.selectorGripsGroup.getAttribute('display')).toBe('none')
|
||||
})
|
||||
|
||||
it('should append gripsGroup to selectorGroup when showing', () => {
|
||||
selector.showGrips(true)
|
||||
expect(selector.selectorGroup.contains(manager.selectorGripsGroup)).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('updateGripCursors (static)', () => {
|
||||
it('should update cursor styles for rotated elements', () => {
|
||||
Selector.updateGripCursors(45)
|
||||
const updatedCursor = manager.selectorGrips.nw.getAttribute('style')
|
||||
// After 45-degree rotation, cursors should shift
|
||||
expect(updatedCursor).toBeTruthy()
|
||||
expect(updatedCursor).toMatch(/cursor:/)
|
||||
})
|
||||
|
||||
it('should handle negative angles', () => {
|
||||
expect(() => Selector.updateGripCursors(-45)).not.toThrow()
|
||||
})
|
||||
|
||||
it('should handle zero angle', () => {
|
||||
Selector.updateGripCursors(0)
|
||||
expect(manager.selectorGrips.nw.getAttribute('style')).toMatch(/nw-resize/)
|
||||
})
|
||||
|
||||
it('should handle 360-degree rotation', () => {
|
||||
Selector.updateGripCursors(360)
|
||||
expect(manager.selectorGrips.nw.getAttribute('style')).toMatch(/nw-resize/)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('Integration scenarios', () => {
|
||||
let manager
|
||||
|
||||
beforeEach(() => {
|
||||
manager = getSelectorManager()
|
||||
})
|
||||
|
||||
it('should handle multiple element selection workflow', () => {
|
||||
const selector1 = manager.requestSelector(rectElement)
|
||||
const selector2 = manager.requestSelector(circleElement)
|
||||
|
||||
expect(selector1.selectedElement).toBe(rectElement)
|
||||
expect(selector2.selectedElement).toBe(circleElement)
|
||||
expect(manager.selectors.length).toBe(2)
|
||||
|
||||
selector1.showGrips(true)
|
||||
expect(selector1.hasGrips).toBe(true)
|
||||
|
||||
manager.releaseSelector(rectElement)
|
||||
expect(selector1.locked).toBe(false)
|
||||
})
|
||||
|
||||
it('should handle selector reuse efficiently', () => {
|
||||
// Create and release multiple selectors
|
||||
const s1 = manager.requestSelector(rectElement)
|
||||
manager.releaseSelector(rectElement)
|
||||
|
||||
const s2 = manager.requestSelector(circleElement)
|
||||
manager.releaseSelector(circleElement)
|
||||
|
||||
const s3 = manager.requestSelector(rectElement)
|
||||
|
||||
// Should reuse the same selector object
|
||||
expect(s1).toBe(s2)
|
||||
expect(s2).toBe(s3)
|
||||
expect(manager.selectors.length).toBe(1)
|
||||
})
|
||||
|
||||
it('should handle element with transforms', () => {
|
||||
rectElement.setAttribute('transform', 'rotate(45 60 35)')
|
||||
const selector = manager.requestSelector(rectElement)
|
||||
|
||||
expect(() => selector.resize()).not.toThrow()
|
||||
expect(selector.selectorRect.getAttribute('d')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('should handle group elements', () => {
|
||||
const group = document.createElementNS(NS.SVG, 'g')
|
||||
group.setAttribute('id', 'testgroup')
|
||||
group.append(rectElement.cloneNode())
|
||||
svgContent.append(group)
|
||||
|
||||
const selector = manager.requestSelector(group)
|
||||
expect(() => selector.resize()).not.toThrow()
|
||||
})
|
||||
|
||||
it('should handle rubber band box for multi-select', () => {
|
||||
const rubberBand = manager.getRubberBandBox()
|
||||
|
||||
rubberBand.setAttribute('x', '10')
|
||||
rubberBand.setAttribute('y', '10')
|
||||
rubberBand.setAttribute('width', '100')
|
||||
rubberBand.setAttribute('height', '100')
|
||||
rubberBand.setAttribute('display', 'inline')
|
||||
|
||||
expect(rubberBand.getAttribute('display')).toBe('inline')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Edge cases', () => {
|
||||
let manager
|
||||
|
||||
beforeEach(() => {
|
||||
manager = getSelectorManager()
|
||||
})
|
||||
|
||||
it('should handle elements with zero dimensions', () => {
|
||||
const zeroRect = document.createElementNS(NS.SVG, 'rect')
|
||||
zeroRect.setAttribute('id', 'zerorect')
|
||||
zeroRect.setAttribute('width', '0')
|
||||
zeroRect.setAttribute('height', '0')
|
||||
svgContent.append(zeroRect)
|
||||
|
||||
const selector = manager.requestSelector(zeroRect)
|
||||
expect(() => selector.resize()).not.toThrow()
|
||||
})
|
||||
|
||||
it('should handle elements without id', () => {
|
||||
const noIdRect = document.createElementNS(NS.SVG, 'rect')
|
||||
svgContent.append(noIdRect)
|
||||
|
||||
const selector = manager.requestSelector(noIdRect)
|
||||
expect(selector).toBeDefined()
|
||||
})
|
||||
|
||||
it('should handle requesting same element twice without release', () => {
|
||||
const selector1 = manager.requestSelector(rectElement)
|
||||
const selector2 = manager.requestSelector(rectElement)
|
||||
|
||||
expect(selector1).toBe(selector2)
|
||||
expect(selector1.locked).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Private field encapsulation', () => {
|
||||
it('should not expose SelectModule private field', () => {
|
||||
const manager = getSelectorManager()
|
||||
expect(manager.selectorManager).toBeUndefined()
|
||||
expect(manager['#selectorManager']).toBeUndefined()
|
||||
})
|
||||
})
|
||||
})
|
||||
176
tests/unit/selected-elem.test.js
Normal file
176
tests/unit/selected-elem.test.js
Normal file
@@ -0,0 +1,176 @@
|
||||
import SvgCanvas from '../../packages/svgcanvas/svgcanvas.js'
|
||||
import { NS } from '../../packages/svgcanvas/core/namespaces.js'
|
||||
|
||||
describe('selected-elem', () => {
|
||||
let svgCanvas
|
||||
|
||||
const createSvgCanvas = () => {
|
||||
document.body.textContent = ''
|
||||
const svgEditor = document.createElement('div')
|
||||
svgEditor.id = 'svg_editor'
|
||||
const svgcanvas = document.createElement('div')
|
||||
svgcanvas.style.visibility = 'hidden'
|
||||
svgcanvas.id = 'svgcanvas'
|
||||
const workarea = document.createElement('div')
|
||||
workarea.id = 'workarea'
|
||||
workarea.append(svgcanvas)
|
||||
const toolsLeft = document.createElement('div')
|
||||
toolsLeft.id = 'tools_left'
|
||||
svgEditor.append(workarea, toolsLeft)
|
||||
document.body.append(svgEditor)
|
||||
|
||||
svgCanvas = new SvgCanvas(document.getElementById('svgcanvas'), {
|
||||
canvas_expansion: 3,
|
||||
dimensions: [640, 480],
|
||||
initFill: {
|
||||
color: 'FF0000',
|
||||
opacity: 1
|
||||
},
|
||||
initStroke: {
|
||||
width: 5,
|
||||
color: '000000',
|
||||
opacity: 1
|
||||
},
|
||||
initOpacity: 1,
|
||||
imgPath: '../editor/images',
|
||||
langPath: 'locale/',
|
||||
extPath: 'extensions/',
|
||||
extensions: [],
|
||||
initTool: 'select',
|
||||
wireframe: false
|
||||
})
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
createSvgCanvas()
|
||||
sessionStorage.clear()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
document.body.textContent = ''
|
||||
sessionStorage.clear()
|
||||
})
|
||||
|
||||
it('copies selection without requiring context menu DOM', () => {
|
||||
const rect = svgCanvas.addSVGElementsFromJson({
|
||||
element: 'rect',
|
||||
attr: {
|
||||
id: 'rect-copy',
|
||||
x: 10,
|
||||
y: 20,
|
||||
width: 30,
|
||||
height: 40
|
||||
}
|
||||
})
|
||||
|
||||
svgCanvas.selectOnly([rect], true)
|
||||
|
||||
expect(() => svgCanvas.copySelectedElements()).not.toThrow()
|
||||
|
||||
const raw = sessionStorage.getItem(svgCanvas.getClipboardID())
|
||||
expect(raw).toBeTruthy()
|
||||
const parsed = JSON.parse(raw)
|
||||
expect(parsed).toHaveLength(1)
|
||||
expect(parsed[0].element).toBe('rect')
|
||||
expect(parsed[0].attr.id).toBe('rect-copy')
|
||||
})
|
||||
|
||||
it('moves element to bottom even with whitespace/title/defs nodes', () => {
|
||||
const rect1 = svgCanvas.addSVGElementsFromJson({
|
||||
element: 'rect',
|
||||
attr: {
|
||||
id: 'rect-bottom-1',
|
||||
x: 10,
|
||||
y: 10,
|
||||
width: 10,
|
||||
height: 10
|
||||
}
|
||||
})
|
||||
const rect2 = svgCanvas.addSVGElementsFromJson({
|
||||
element: 'rect',
|
||||
attr: {
|
||||
id: 'rect-bottom-2',
|
||||
x: 30,
|
||||
y: 10,
|
||||
width: 10,
|
||||
height: 10
|
||||
}
|
||||
})
|
||||
|
||||
const parent = svgCanvas.addSVGElementsFromJson({
|
||||
element: 'g',
|
||||
attr: { id: 'move-bottom-container' }
|
||||
})
|
||||
parent.append(rect1, rect2)
|
||||
parent.insertBefore(document.createTextNode('\n'), parent.firstChild)
|
||||
const title = document.createElementNS(NS.SVG, 'title')
|
||||
title.textContent = 'Layer'
|
||||
parent.insertBefore(title, rect1)
|
||||
const defs = document.createElementNS(NS.SVG, 'defs')
|
||||
parent.insertBefore(defs, rect1)
|
||||
|
||||
svgCanvas.selectOnly([rect2], true)
|
||||
const undoSize = svgCanvas.undoMgr.getUndoStackSize()
|
||||
|
||||
expect(() => svgCanvas.moveToBottomSelectedElement()).not.toThrow()
|
||||
expect(svgCanvas.undoMgr.getUndoStackSize()).toBe(undoSize + 1)
|
||||
|
||||
const order = Array.from(parent.childNodes)
|
||||
.filter((n) => n.nodeType === 1)
|
||||
.map((n) => (n.tagName === 'title' || n.tagName === 'defs') ? n.tagName : n.id)
|
||||
|
||||
expect(order).toEqual(['title', 'defs', 'rect-bottom-2', 'rect-bottom-1'])
|
||||
})
|
||||
|
||||
it('ungroups a <use> when it is the first element child', () => {
|
||||
const defs = svgCanvas.getSvgContent().querySelector('defs') ||
|
||||
svgCanvas.getSvgContent().appendChild(document.createElementNS(NS.SVG, 'defs'))
|
||||
|
||||
const symbol = document.createElementNS(NS.SVG, 'symbol')
|
||||
symbol.id = 'symbol-test'
|
||||
const symRect = document.createElementNS(NS.SVG, 'rect')
|
||||
symRect.setAttribute('x', '10')
|
||||
symRect.setAttribute('y', '20')
|
||||
symRect.setAttribute('width', '30')
|
||||
symRect.setAttribute('height', '40')
|
||||
symbol.append(symRect)
|
||||
defs.append(symbol)
|
||||
|
||||
const container = svgCanvas.addSVGElementsFromJson({
|
||||
element: 'g',
|
||||
attr: { id: 'use-container' }
|
||||
})
|
||||
const use = svgCanvas.addSVGElementsFromJson({
|
||||
element: 'use',
|
||||
attr: { id: 'use-test', href: '#symbol-test' }
|
||||
})
|
||||
container.append(use)
|
||||
svgCanvas.setUseData(use)
|
||||
svgCanvas.selectOnly([use], true)
|
||||
|
||||
expect(() => svgCanvas.ungroupSelectedElement()).not.toThrow()
|
||||
|
||||
expect(container.querySelector('use')).toBeNull()
|
||||
const group = container.firstElementChild
|
||||
expect(group).toBeTruthy()
|
||||
expect(group.tagName).toBe('g')
|
||||
expect(group.querySelector('rect')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('does not crash ungrouping a <use> without href', () => {
|
||||
const use = svgCanvas.addSVGElementsFromJson({
|
||||
element: 'use',
|
||||
attr: { id: 'use-no-href' }
|
||||
})
|
||||
svgCanvas.selectOnly([use], true)
|
||||
|
||||
const originalWarn = console.warn
|
||||
console.warn = () => {}
|
||||
try {
|
||||
expect(() => svgCanvas.ungroupSelectedElement()).not.toThrow()
|
||||
} finally {
|
||||
console.warn = originalWarn
|
||||
}
|
||||
expect(svgCanvas.getSvgContent().querySelector('#use-no-href')).toBeTruthy()
|
||||
})
|
||||
})
|
||||
@@ -1,5 +1,5 @@
|
||||
/* eslint-disable max-len, no-console */
|
||||
import SvgCanvas from '../../packages/svgcanvas'
|
||||
import SvgCanvas from '../../packages/svgcanvas/svgcanvas.js'
|
||||
|
||||
describe('Basic Module', function () {
|
||||
// helper functions
|
||||
@@ -110,6 +110,27 @@ describe('Basic Module', function () {
|
||||
})
|
||||
|
||||
describe('Import Module', function () {
|
||||
it('Test setSvgString handles empty SVG', function () {
|
||||
const ok = svgCanvas.setSvgString(
|
||||
'<svg xmlns="' + svgns + '"></svg>'
|
||||
)
|
||||
assert.equal(ok, true, 'Expected setSvgString to succeed')
|
||||
|
||||
const svgContent = document.getElementById('svgcontent')
|
||||
const w = Number(svgContent.getAttribute('width'))
|
||||
const h = Number(svgContent.getAttribute('height'))
|
||||
assert.equal(
|
||||
Number.isFinite(w) && w > 0,
|
||||
true,
|
||||
'Width is a positive number (got ' + svgContent.getAttribute('width') + ')'
|
||||
)
|
||||
assert.equal(
|
||||
Number.isFinite(h) && h > 0,
|
||||
true,
|
||||
'Height is a positive number (got ' + svgContent.getAttribute('height') + ')'
|
||||
)
|
||||
})
|
||||
|
||||
it('Test import use', function () {
|
||||
svgCanvas.setSvgString(
|
||||
"<svg xmlns='http://www.w3.org/2000/svg' xmlns:xlink='http://www.w3.org/1999/xlink' width='400' x='300'>" +
|
||||
@@ -262,5 +283,20 @@ describe('Basic Module', function () {
|
||||
assert.notEqual(rects.item(0).getAttribute('stroke'), 'url(#svg_2)', 'Rectangle stroke value not remapped')
|
||||
assert.notEqual(uses.item(0).getAttributeNS(xlinkns, 'href'), '#svg_3')
|
||||
})
|
||||
|
||||
it('Test importing SVG without width/height/viewBox', function () {
|
||||
const imported = svgCanvas.importSvgString(
|
||||
'<svg xmlns="' + svgns + '">' +
|
||||
'<rect width="20" height="20" fill="blue"/>' +
|
||||
'</svg>'
|
||||
)
|
||||
assert.equal((imported && imported.nodeName), 'use', 'Imported as a <use> element')
|
||||
const t = imported.getAttribute('transform') || ''
|
||||
assert.equal(
|
||||
t.includes('Infinity') || t.includes('NaN'),
|
||||
false,
|
||||
'Transform is finite (got ' + t + ')'
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
506
tests/unit/text-actions.test.js
Normal file
506
tests/unit/text-actions.test.js
Normal file
@@ -0,0 +1,506 @@
|
||||
import 'pathseg'
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
|
||||
import { init as textActionsInit, textActionsMethod } from '../../packages/svgcanvas/core/text-actions.js'
|
||||
import { init as utilitiesInit } from '../../packages/svgcanvas/core/utilities.js'
|
||||
import { NS } from '../../packages/svgcanvas/core/namespaces.js'
|
||||
|
||||
describe('TextActions', () => {
|
||||
let svgCanvas
|
||||
let svgRoot
|
||||
let textElement
|
||||
let inputElement
|
||||
let mockSelectorManager
|
||||
|
||||
beforeEach(() => {
|
||||
// Create mock SVG elements
|
||||
svgRoot = document.createElementNS(NS.SVG, 'svg')
|
||||
svgRoot.setAttribute('width', '640')
|
||||
svgRoot.setAttribute('height', '480')
|
||||
document.body.append(svgRoot)
|
||||
|
||||
textElement = document.createElementNS(NS.SVG, 'text')
|
||||
textElement.setAttribute('x', '100')
|
||||
textElement.setAttribute('y', '100')
|
||||
textElement.setAttribute('id', 'text1')
|
||||
textElement.textContent = 'Test'
|
||||
svgRoot.append(textElement)
|
||||
|
||||
// Mock text measurement methods
|
||||
textElement.getStartPositionOfChar = vi.fn((i) => ({ x: 100 + i * 10, y: 100 }))
|
||||
textElement.getEndPositionOfChar = vi.fn((i) => ({ x: 100 + (i + 1) * 10, y: 100 }))
|
||||
textElement.getCharNumAtPosition = vi.fn(() => 0)
|
||||
textElement.getBBox = vi.fn(() => ({
|
||||
x: 100,
|
||||
y: 90,
|
||||
width: 40,
|
||||
height: 20
|
||||
}))
|
||||
|
||||
inputElement = document.createElement('input')
|
||||
inputElement.type = 'text'
|
||||
document.body.append(inputElement)
|
||||
|
||||
// Create mock selector group
|
||||
const selectorParentGroup = document.createElementNS(NS.SVG, 'g')
|
||||
selectorParentGroup.id = 'selectorParentGroup'
|
||||
svgRoot.append(selectorParentGroup)
|
||||
|
||||
// Mock selector manager
|
||||
mockSelectorManager = {
|
||||
requestSelector: vi.fn(() => ({
|
||||
showGrips: vi.fn()
|
||||
}))
|
||||
}
|
||||
|
||||
// Mock svgCanvas
|
||||
svgCanvas = {
|
||||
getSvgRoot: () => svgRoot,
|
||||
getZoom: () => 1,
|
||||
setCurrentMode: vi.fn(),
|
||||
clearSelection: vi.fn(),
|
||||
addToSelection: vi.fn(),
|
||||
deleteSelectedElements: vi.fn(),
|
||||
call: vi.fn(),
|
||||
getSelectedElements: () => [textElement],
|
||||
getCurrentMode: () => 'select',
|
||||
selectorManager: mockSelectorManager,
|
||||
getrootSctm: () => svgRoot.getScreenCTM?.() || { a: 1, b: 0, c: 0, d: 1, e: 0, f: 0 },
|
||||
$click: vi.fn(),
|
||||
contentW: 640,
|
||||
textActions: textActionsMethod
|
||||
}
|
||||
|
||||
// Initialize utilities and text-actions modules
|
||||
utilitiesInit(svgCanvas)
|
||||
textActionsInit(svgCanvas)
|
||||
textActionsMethod.setInputElem(inputElement)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
document.body.textContent = ''
|
||||
})
|
||||
|
||||
describe('Class instantiation', () => {
|
||||
it('should export textActionsMethod as singleton instance', () => {
|
||||
expect(textActionsMethod).toBeDefined()
|
||||
expect(typeof textActionsMethod.select).toBe('function')
|
||||
expect(typeof textActionsMethod.start).toBe('function')
|
||||
})
|
||||
|
||||
it('should have all public methods', () => {
|
||||
const publicMethods = [
|
||||
'select',
|
||||
'start',
|
||||
'mouseDown',
|
||||
'mouseMove',
|
||||
'mouseUp',
|
||||
'setCursor',
|
||||
'toEditMode',
|
||||
'toSelectMode',
|
||||
'setInputElem',
|
||||
'clear',
|
||||
'init'
|
||||
]
|
||||
|
||||
publicMethods.forEach(method => {
|
||||
expect(typeof textActionsMethod[method]).toBe('function')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('setInputElem', () => {
|
||||
it('should set the input element', () => {
|
||||
const newInput = document.createElement('input')
|
||||
textActionsMethod.setInputElem(newInput)
|
||||
// Method should not throw and should be callable
|
||||
expect(true).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('select', () => {
|
||||
it('should set current text element and enter edit mode', () => {
|
||||
textActionsMethod.select(textElement, 100, 100)
|
||||
expect(svgCanvas.setCurrentMode).toHaveBeenCalledWith('textedit')
|
||||
})
|
||||
})
|
||||
|
||||
describe('start', () => {
|
||||
it('should start editing a text element', () => {
|
||||
textActionsMethod.start(textElement)
|
||||
expect(svgCanvas.setCurrentMode).toHaveBeenCalledWith('textedit')
|
||||
})
|
||||
})
|
||||
|
||||
describe('init', () => {
|
||||
it('should initialize text editing for current element', () => {
|
||||
textActionsMethod.start(textElement)
|
||||
textActionsMethod.init()
|
||||
|
||||
// Verify text measurement methods were called
|
||||
expect(textElement.getStartPositionOfChar).toHaveBeenCalled()
|
||||
expect(textElement.getEndPositionOfChar).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should handle empty text content', () => {
|
||||
const emptyText = document.createElementNS(NS.SVG, 'text')
|
||||
emptyText.textContent = ''
|
||||
emptyText.getStartPositionOfChar = vi.fn(() => ({ x: 100, y: 100 }))
|
||||
emptyText.getEndPositionOfChar = vi.fn(() => ({ x: 100, y: 100 }))
|
||||
emptyText.getBBox = vi.fn(() => ({ x: 100, y: 90, width: 0, height: 20 }))
|
||||
emptyText.removeEventListener = vi.fn()
|
||||
emptyText.addEventListener = vi.fn()
|
||||
svgRoot.append(emptyText)
|
||||
|
||||
textActionsMethod.start(emptyText)
|
||||
textActionsMethod.init()
|
||||
|
||||
expect(true).toBe(true) // Should not throw
|
||||
})
|
||||
|
||||
it('should return early if no current text', () => {
|
||||
textActionsMethod.init()
|
||||
// Should not throw when called without a current text element
|
||||
expect(true).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('toEditMode', () => {
|
||||
it('should switch to text edit mode', () => {
|
||||
textActionsMethod.start(textElement)
|
||||
|
||||
expect(svgCanvas.setCurrentMode).toHaveBeenCalledWith('textedit')
|
||||
expect(mockSelectorManager.requestSelector).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should accept x, y coordinates for cursor positioning', () => {
|
||||
textActionsMethod.start(textElement)
|
||||
textActionsMethod.toEditMode(100, 100)
|
||||
|
||||
expect(svgCanvas.setCurrentMode).toHaveBeenCalledWith('textedit')
|
||||
})
|
||||
})
|
||||
|
||||
describe('toSelectMode', () => {
|
||||
it('should switch to select mode', () => {
|
||||
textActionsMethod.start(textElement)
|
||||
textActionsMethod.toSelectMode(false)
|
||||
|
||||
expect(svgCanvas.setCurrentMode).toHaveBeenCalledWith('select')
|
||||
})
|
||||
|
||||
it('should select element when selectElem is true', () => {
|
||||
textActionsMethod.start(textElement)
|
||||
textActionsMethod.toSelectMode(true)
|
||||
|
||||
expect(svgCanvas.clearSelection).toHaveBeenCalled()
|
||||
expect(svgCanvas.call).toHaveBeenCalled()
|
||||
expect(svgCanvas.addToSelection).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should delete empty text elements', () => {
|
||||
const emptyText = document.createElementNS(NS.SVG, 'text')
|
||||
emptyText.textContent = ''
|
||||
emptyText.getBBox = vi.fn(() => ({ x: 100, y: 90, width: 0, height: 20 }))
|
||||
emptyText.removeEventListener = vi.fn()
|
||||
emptyText.addEventListener = vi.fn()
|
||||
emptyText.style = {}
|
||||
svgRoot.append(emptyText)
|
||||
|
||||
textActionsMethod.start(emptyText)
|
||||
textActionsMethod.toSelectMode(false)
|
||||
|
||||
expect(svgCanvas.deleteSelectedElements).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('clear', () => {
|
||||
it('should exit text edit mode if currently in it', () => {
|
||||
svgCanvas.getCurrentMode = () => 'textedit'
|
||||
textActionsMethod.start(textElement)
|
||||
textActionsMethod.clear()
|
||||
|
||||
expect(svgCanvas.setCurrentMode).toHaveBeenCalledWith('select')
|
||||
})
|
||||
|
||||
it('should do nothing if not in text edit mode', () => {
|
||||
svgCanvas.getCurrentMode = () => 'select'
|
||||
const callCount = svgCanvas.setCurrentMode.mock.calls.length
|
||||
textActionsMethod.clear()
|
||||
|
||||
expect(svgCanvas.setCurrentMode.mock.calls.length).toBe(callCount)
|
||||
})
|
||||
})
|
||||
|
||||
describe('mouseDown', () => {
|
||||
it('should handle mouse down event', () => {
|
||||
textActionsMethod.start(textElement)
|
||||
|
||||
const mockEvent = { pageX: 100, pageY: 100 }
|
||||
textActionsMethod.mouseDown(mockEvent, textElement, 100, 100)
|
||||
|
||||
// Should set focus (via private method)
|
||||
expect(true).toBe(true) // Method executed without error
|
||||
})
|
||||
})
|
||||
|
||||
describe('mouseMove', () => {
|
||||
it('should handle mouse move event', () => {
|
||||
textActionsMethod.start(textElement)
|
||||
textActionsMethod.mouseMove(110, 100)
|
||||
|
||||
// Method should execute without error
|
||||
expect(true).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('mouseUp', () => {
|
||||
it('should handle mouse up event', () => {
|
||||
textActionsMethod.start(textElement)
|
||||
|
||||
const mockEvent = { target: textElement, pageX: 100, pageY: 100 }
|
||||
textActionsMethod.mouseUp(mockEvent, 100, 100)
|
||||
|
||||
// Method should execute without error
|
||||
expect(true).toBe(true)
|
||||
})
|
||||
|
||||
it('should exit text mode if clicked outside text element', () => {
|
||||
textActionsMethod.start(textElement)
|
||||
|
||||
const otherElement = document.createElementNS(NS.SVG, 'rect')
|
||||
const mockEvent = { target: otherElement, pageX: 200, pageY: 200 }
|
||||
|
||||
textActionsMethod.mouseDown(mockEvent, textElement, 200, 200)
|
||||
textActionsMethod.mouseUp(mockEvent, 200, 200)
|
||||
|
||||
// Should have called toSelectMode
|
||||
expect(svgCanvas.setCurrentMode).toHaveBeenCalledWith('select')
|
||||
})
|
||||
})
|
||||
|
||||
describe('setCursor', () => {
|
||||
it('should set cursor position', () => {
|
||||
textActionsMethod.start(textElement)
|
||||
textActionsMethod.init()
|
||||
textActionsMethod.setCursor(0)
|
||||
|
||||
// Method should execute without error
|
||||
expect(true).toBe(true)
|
||||
})
|
||||
|
||||
it('should accept undefined index', () => {
|
||||
textActionsMethod.start(textElement)
|
||||
textActionsMethod.init()
|
||||
textActionsMethod.setCursor(undefined)
|
||||
|
||||
// Should not throw
|
||||
expect(true).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Private methods encapsulation', () => {
|
||||
it('should not expose private methods', () => {
|
||||
const privateMethodNames = [
|
||||
'#setCursor',
|
||||
'#setSelection',
|
||||
'#getIndexFromPoint',
|
||||
'#setCursorFromPoint',
|
||||
'#setEndSelectionFromPoint',
|
||||
'#screenToPt',
|
||||
'#ptToScreen',
|
||||
'#selectAll',
|
||||
'#selectWord'
|
||||
]
|
||||
|
||||
privateMethodNames.forEach(method => {
|
||||
expect(textActionsMethod[method]).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
it('should not expose private fields', () => {
|
||||
const privateFieldNames = [
|
||||
'#curtext',
|
||||
'#textinput',
|
||||
'#cursor',
|
||||
'#selblock',
|
||||
'#blinker',
|
||||
'#chardata',
|
||||
'#textbb',
|
||||
'#matrix',
|
||||
'#lastX',
|
||||
'#lastY',
|
||||
'#allowDbl'
|
||||
]
|
||||
|
||||
privateFieldNames.forEach(field => {
|
||||
expect(textActionsMethod[field]).toBeUndefined()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('Integration scenarios', () => {
|
||||
it('should handle complete edit workflow', () => {
|
||||
// Start editing
|
||||
textActionsMethod.start(textElement)
|
||||
expect(svgCanvas.setCurrentMode).toHaveBeenCalledWith('textedit')
|
||||
|
||||
// Initialize
|
||||
textActionsMethod.init()
|
||||
|
||||
// Simulate mouse interaction
|
||||
textActionsMethod.mouseDown({ pageX: 100, pageY: 100 }, textElement, 100, 100)
|
||||
textActionsMethod.mouseMove(110, 100)
|
||||
textActionsMethod.mouseUp({ target: textElement, pageX: 110, pageY: 100 }, 110, 100)
|
||||
|
||||
// Exit edit mode
|
||||
textActionsMethod.toSelectMode(true)
|
||||
expect(svgCanvas.setCurrentMode).toHaveBeenCalledWith('select')
|
||||
})
|
||||
|
||||
it('should handle text with transform attribute', () => {
|
||||
textElement.setAttribute('transform', 'rotate(45 100 100)')
|
||||
|
||||
textActionsMethod.start(textElement)
|
||||
textActionsMethod.init()
|
||||
|
||||
// Should handle transformed text without error
|
||||
expect(true).toBe(true)
|
||||
})
|
||||
|
||||
it('should handle empty text element', () => {
|
||||
textElement.textContent = ''
|
||||
textActionsMethod.start(textElement)
|
||||
textActionsMethod.init()
|
||||
textActionsMethod.toSelectMode(true)
|
||||
expect(svgCanvas.deleteSelectedElements).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should handle text element without parent', () => {
|
||||
const orphanText = document.createElementNS(NS.SVG, 'text')
|
||||
orphanText.textContent = 'Orphan'
|
||||
orphanText.getStartPositionOfChar = vi.fn((i) => ({ x: 100 + i * 10, y: 100 }))
|
||||
orphanText.getEndPositionOfChar = vi.fn((i) => ({ x: 100 + (i + 1) * 10, y: 100 }))
|
||||
orphanText.getBBox = vi.fn(() => ({ x: 100, y: 90, width: 60, height: 20 }))
|
||||
|
||||
textActionsMethod.start(orphanText)
|
||||
textActionsMethod.init()
|
||||
expect(true).toBe(true)
|
||||
})
|
||||
|
||||
it('should handle setCursor with undefined index', () => {
|
||||
textActionsMethod.start(textElement)
|
||||
textActionsMethod.init()
|
||||
textActionsMethod.setCursor(undefined)
|
||||
expect(true).toBe(true)
|
||||
})
|
||||
|
||||
it('should handle setCursor with empty input', () => {
|
||||
inputElement.value = ''
|
||||
textActionsMethod.start(textElement)
|
||||
textActionsMethod.init()
|
||||
textActionsMethod.setCursor()
|
||||
expect(true).toBe(true)
|
||||
})
|
||||
|
||||
it('should handle text with no transform', () => {
|
||||
textElement.removeAttribute('transform')
|
||||
textActionsMethod.start(textElement)
|
||||
textActionsMethod.init()
|
||||
expect(true).toBe(true)
|
||||
})
|
||||
|
||||
it('should handle getIndexFromPoint with single character', () => {
|
||||
textElement.textContent = 'A'
|
||||
textActionsMethod.start(textElement)
|
||||
textActionsMethod.init()
|
||||
textActionsMethod.mouseDown({ pageX: 100, pageY: 100 }, textElement, 100, 100)
|
||||
expect(true).toBe(true)
|
||||
})
|
||||
|
||||
it('should handle getIndexFromPoint outside text range', () => {
|
||||
textElement.getCharNumAtPosition = vi.fn(() => -1)
|
||||
textActionsMethod.start(textElement)
|
||||
textActionsMethod.init()
|
||||
textActionsMethod.mouseDown({ pageX: 50, pageY: 100 }, textElement, 50, 100)
|
||||
expect(true).toBe(true)
|
||||
})
|
||||
|
||||
it('should handle getIndexFromPoint at end of text', () => {
|
||||
textElement.getCharNumAtPosition = vi.fn(() => 100)
|
||||
textActionsMethod.start(textElement)
|
||||
textActionsMethod.init()
|
||||
textActionsMethod.mouseDown({ pageX: 200, pageY: 100 }, textElement, 200, 100)
|
||||
expect(true).toBe(true)
|
||||
})
|
||||
|
||||
it('should handle mouseUp clicking outside text', () => {
|
||||
const outsideElement = document.createElementNS(NS.SVG, 'rect')
|
||||
textActionsMethod.start(textElement)
|
||||
textActionsMethod.init()
|
||||
textActionsMethod.mouseDown({ pageX: 100, pageY: 100 }, textElement, 100, 100)
|
||||
textActionsMethod.mouseUp({ target: outsideElement, pageX: 101, pageY: 101 }, 101, 101)
|
||||
expect(svgCanvas.setCurrentMode).toHaveBeenCalledWith('select')
|
||||
})
|
||||
|
||||
it('should handle toEditMode with no arguments', () => {
|
||||
textActionsMethod.start(textElement)
|
||||
textActionsMethod.toEditMode()
|
||||
expect(svgCanvas.setCurrentMode).toHaveBeenCalledWith('textedit')
|
||||
})
|
||||
|
||||
it('should handle toSelectMode without selectElem', () => {
|
||||
textActionsMethod.start(textElement)
|
||||
textActionsMethod.init()
|
||||
textActionsMethod.toSelectMode(false)
|
||||
expect(svgCanvas.setCurrentMode).toHaveBeenCalledWith('select')
|
||||
})
|
||||
|
||||
it('should handle clear when not in textedit mode', () => {
|
||||
const originalGetMode = svgCanvas.getCurrentMode
|
||||
svgCanvas.getCurrentMode = vi.fn(() => 'select')
|
||||
textActionsMethod.clear()
|
||||
svgCanvas.getCurrentMode = originalGetMode
|
||||
expect(true).toBe(true)
|
||||
})
|
||||
|
||||
it('should handle init with no current text', () => {
|
||||
textActionsMethod.init()
|
||||
expect(true).toBe(true)
|
||||
})
|
||||
|
||||
it('should handle mouseMove during selection', () => {
|
||||
textActionsMethod.start(textElement)
|
||||
textActionsMethod.init()
|
||||
textActionsMethod.mouseDown({ pageX: 100, pageY: 100 }, textElement, 100, 100)
|
||||
textActionsMethod.mouseMove(120, 100)
|
||||
textActionsMethod.mouseMove(130, 100)
|
||||
expect(true).toBe(true)
|
||||
})
|
||||
|
||||
it('should handle mouseMove without shift key', () => {
|
||||
const evt = { shiftKey: false, clientX: 100, clientY: 100 }
|
||||
textActionsMethod.mouseMove(10, 20, evt)
|
||||
expect(true).toBe(true)
|
||||
})
|
||||
|
||||
it('should handle mouseDown with different mouse button', () => {
|
||||
const evt = { button: 2 }
|
||||
textActionsMethod.mouseDown(evt, null, 10, 20)
|
||||
expect(true).toBe(true)
|
||||
})
|
||||
|
||||
it('should handle mouseUp with valid cursor position', () => {
|
||||
const elem = document.createElementNS(NS.SVG, 'text')
|
||||
elem.textContent = 'test'
|
||||
const evt = { target: elem }
|
||||
textActionsMethod.mouseUp(evt, elem, 10, 20)
|
||||
expect(true).toBe(true)
|
||||
})
|
||||
|
||||
it('should handle toSelectMode with valid element', () => {
|
||||
const elem = document.createElementNS(NS.SVG, 'text')
|
||||
textActionsMethod.toSelectMode(elem)
|
||||
expect(true).toBe(true)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -88,4 +88,22 @@ describe('units', function () {
|
||||
assert.equal(units.convertUnit(42), 1.1113)
|
||||
assert.equal(units.convertUnit(42, 'px'), 42)
|
||||
})
|
||||
|
||||
it('Test svgedit.units.convertUnit() with mm', function () {
|
||||
assert.equal(units.convertUnit(42, 'mm'), 11.1125)
|
||||
})
|
||||
|
||||
it('Test svgedit.units.convertUnit() with in', function () {
|
||||
assert.equal(units.convertUnit(96, 'in'), 1)
|
||||
})
|
||||
|
||||
it('Test svgedit.units.convertUnit() with pt', function () {
|
||||
const result = units.convertUnit(72, 'pt')
|
||||
assert.ok(result > 0)
|
||||
})
|
||||
|
||||
it('Test svgedit.units.convertUnit() with pc', function () {
|
||||
const result = units.convertUnit(96, 'pc')
|
||||
assert.ok(result > 0)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -372,4 +372,40 @@ describe('utilities', function () {
|
||||
assert.equal(mockCount.addToSelection, 0)
|
||||
assert.equal(mockCount.addCommandToHistory, 0)
|
||||
})
|
||||
|
||||
it('Test isNullish with null', function () {
|
||||
const { isNullish } = utilities
|
||||
const result = isNullish(null)
|
||||
assert.ok(result === true)
|
||||
})
|
||||
|
||||
it('Test isNullish with undefined', function () {
|
||||
const { isNullish } = utilities
|
||||
const result = isNullish(undefined)
|
||||
assert.ok(result === true)
|
||||
})
|
||||
|
||||
it('Test isNullish with value', function () {
|
||||
const { isNullish } = utilities
|
||||
const result = isNullish('test')
|
||||
assert.ok(result === false)
|
||||
})
|
||||
|
||||
it('Test isNullish with zero', function () {
|
||||
const { isNullish } = utilities
|
||||
const result = isNullish(0)
|
||||
assert.ok(result === false)
|
||||
})
|
||||
|
||||
it('Test isNullish with empty string', function () {
|
||||
const { isNullish } = utilities
|
||||
const result = isNullish('')
|
||||
assert.ok(result === false)
|
||||
})
|
||||
|
||||
it('Test isNullish with boolean false', function () {
|
||||
const { isNullish } = utilities
|
||||
const result = isNullish(false)
|
||||
assert.ok(result === false)
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user