increase test coverage
extend test coverage
This commit is contained in:
@@ -1,3 +1,5 @@
|
||||
import { strict as assert } from 'node:assert'
|
||||
|
||||
describe('Browser bugs', function () {
|
||||
it('removeItem and setAttribute test (Chromium 843901; now fixed)', function () {
|
||||
// See https://bugs.chromium.org/p/chromium/issues/detail?id=843901
|
||||
|
||||
49
tests/unit/clear.test.js
Normal file
49
tests/unit/clear.test.js
Normal file
@@ -0,0 +1,49 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { clearSvgContentElementInit, init as initClear } from '../../packages/svgcanvas/core/clear.js'
|
||||
|
||||
const buildCanvas = (showOutside = false) => {
|
||||
const svgContent = document.createElementNS('http://www.w3.org/2000/svg', 'svg')
|
||||
svgContent.append(document.createElementNS('http://www.w3.org/2000/svg', 'g'))
|
||||
const svgRoot = document.createElementNS('http://www.w3.org/2000/svg', 'svg')
|
||||
const curConfig = { dimensions: [300, 150], show_outside_canvas: showOutside }
|
||||
|
||||
return {
|
||||
svgContent,
|
||||
svgRoot,
|
||||
curConfig,
|
||||
canvas: {
|
||||
getCurConfig: () => curConfig,
|
||||
getSvgContent: () => svgContent,
|
||||
getSvgRoot: () => svgRoot,
|
||||
getDOMDocument: () => document
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
describe('clearSvgContentElementInit', () => {
|
||||
it('clears existing children and sets canvas attributes', () => {
|
||||
const { canvas, svgContent, svgRoot } = buildCanvas(false)
|
||||
initClear(canvas)
|
||||
|
||||
clearSvgContentElementInit()
|
||||
|
||||
expect(svgRoot.contains(svgContent)).toBe(true)
|
||||
expect(svgContent.childNodes[0].nodeType).toBe(Node.COMMENT_NODE)
|
||||
expect(svgContent.getAttribute('id')).toBe('svgcontent')
|
||||
expect(svgContent.getAttribute('width')).toBe('300')
|
||||
expect(svgContent.getAttribute('height')).toBe('150')
|
||||
expect(svgContent.getAttribute('x')).toBe('300')
|
||||
expect(svgContent.getAttribute('y')).toBe('150')
|
||||
expect(svgContent.getAttribute('overflow')).toBe('hidden')
|
||||
expect(svgContent.getAttribute('xmlns')).toBe('http://www.w3.org/2000/svg')
|
||||
})
|
||||
|
||||
it('honors show_outside_canvas by leaving overflow visible', () => {
|
||||
const { canvas, svgContent } = buildCanvas(true)
|
||||
initClear(canvas)
|
||||
|
||||
clearSvgContentElementInit()
|
||||
|
||||
expect(svgContent.getAttribute('overflow')).toBe('visible')
|
||||
})
|
||||
})
|
||||
44
tests/unit/configobj.test.js
Normal file
44
tests/unit/configobj.test.js
Normal file
@@ -0,0 +1,44 @@
|
||||
import ConfigObj, { regexEscape } from '../../src/editor/ConfigObj.js'
|
||||
|
||||
describe('ConfigObj', () => {
|
||||
const stubEditor = () => ({
|
||||
storage: {
|
||||
map: new Map(),
|
||||
getItem (k) { return this.map.get(k) },
|
||||
setItem (k, v) { this.map.set(k, v) }
|
||||
},
|
||||
loadFromDataURI: () => { stubEditor.loaded = 'data' },
|
||||
loadFromString: () => { stubEditor.loaded = 'string' },
|
||||
loadFromURL: () => { stubEditor.loaded = 'url' }
|
||||
})
|
||||
|
||||
it('escapes regex characters', () => {
|
||||
expect(regexEscape('a+b?')).toBe('a\\+b\\?')
|
||||
})
|
||||
|
||||
it('merges defaults and respects allowInitialUserOverride', () => {
|
||||
const editor = stubEditor()
|
||||
const cfg = new ConfigObj(editor)
|
||||
cfg.setConfig({ gridSnapping: true, userExtensions: ['custom'] })
|
||||
cfg.setupCurConfig()
|
||||
|
||||
expect(cfg.curConfig.gridSnapping).toBe(true)
|
||||
expect(cfg.curConfig.extensions).toContain('ext-grid')
|
||||
expect(cfg.curConfig.extensions.includes('custom') || cfg.curConfig.userExtensions.includes('custom')).toBe(true)
|
||||
|
||||
cfg.setConfig({ lang: 'fr' }, { allowInitialUserOverride: true })
|
||||
expect(cfg.defaultPrefs.lang).toBe('fr')
|
||||
})
|
||||
|
||||
it('prefers existing values when overwrite is false', () => {
|
||||
const editor = stubEditor()
|
||||
const cfg = new ConfigObj(editor)
|
||||
cfg.curConfig.preventAllURLConfig = true
|
||||
cfg.curPrefs.lang = 'es'
|
||||
|
||||
cfg.setConfig({ lang: 'de', gridColor: '#fff', extensions: ['x'] }, { overwrite: false })
|
||||
expect(cfg.curPrefs.lang).toBe('es')
|
||||
expect(cfg.curConfig.gridColor).toBeUndefined()
|
||||
expect(cfg.curConfig.extensions).toEqual([])
|
||||
})
|
||||
})
|
||||
44
tests/unit/contextmenu-extra.test.js
Normal file
44
tests/unit/contextmenu-extra.test.js
Normal file
@@ -0,0 +1,44 @@
|
||||
import { describe, expect, it, beforeEach, vi } from 'vitest'
|
||||
|
||||
import {
|
||||
add,
|
||||
getCustomHandler,
|
||||
hasCustomHandler,
|
||||
injectExtendedContextMenuItemsIntoDom,
|
||||
resetCustomMenus
|
||||
} from '../../src/editor/contextmenu.js'
|
||||
|
||||
describe('contextmenu helpers', () => {
|
||||
beforeEach(() => {
|
||||
document.body.innerHTML = "<ul id='cmenu_canvas'></ul>"
|
||||
resetCustomMenus()
|
||||
})
|
||||
|
||||
it('validates menu entries and prevents duplicates', () => {
|
||||
expect(() => add(null)).toThrow(/must be defined/)
|
||||
add({ id: 'foo', label: 'Foo', action: () => 'ok' })
|
||||
expect(hasCustomHandler('foo')).toBe(true)
|
||||
expect(getCustomHandler('foo')()).toBe('ok')
|
||||
expect(() =>
|
||||
add({ id: 'foo', label: 'Again', action: () => {} })
|
||||
).toThrow(/already exists/)
|
||||
})
|
||||
|
||||
it('injects extensions into the context menu DOM', () => {
|
||||
const host = document.getElementById('cmenu_canvas')
|
||||
const appended = []
|
||||
host.appendChild = vi.fn((value) => {
|
||||
appended.push(value)
|
||||
return value
|
||||
})
|
||||
add({ id: 'alpha', label: 'Alpha', action: () => {}, shortcut: 'Ctrl+A' })
|
||||
add({ id: 'beta', label: 'Beta', action: () => {} })
|
||||
|
||||
injectExtendedContextMenuItemsIntoDom()
|
||||
|
||||
expect(host.appendChild).toHaveBeenCalledTimes(2)
|
||||
expect(appended[0]).toContain('#alpha')
|
||||
expect(appended[0]).toContain('Ctrl+A')
|
||||
expect(appended[1]).toContain('#beta')
|
||||
})
|
||||
})
|
||||
@@ -4,7 +4,7 @@ import * as coords from '../../packages/svgcanvas/core/coords.js'
|
||||
|
||||
describe('coords', function () {
|
||||
let elemId = 1
|
||||
|
||||
let svg
|
||||
const root = document.createElement('div')
|
||||
root.id = 'root'
|
||||
root.style.visibility = 'hidden'
|
||||
@@ -18,8 +18,8 @@ describe('coords', function () {
|
||||
const svgroot = document.createElementNS(NS.SVG, 'svg')
|
||||
svgroot.id = 'svgroot'
|
||||
root.append(svgroot)
|
||||
this.svg = document.createElementNS(NS.SVG, 'svg')
|
||||
svgroot.append(this.svg)
|
||||
svg = document.createElementNS(NS.SVG, 'svg')
|
||||
svgroot.append(svg)
|
||||
|
||||
// Mock out editor context.
|
||||
utilities.init(
|
||||
@@ -27,7 +27,7 @@ describe('coords', function () {
|
||||
* @implements {module:utilities.EditorContext}
|
||||
*/
|
||||
{
|
||||
getSvgRoot: () => { return this.svg },
|
||||
getSvgRoot: () => { return svg },
|
||||
getDOMDocument () { return null },
|
||||
getDOMContainer () { return null }
|
||||
}
|
||||
@@ -52,8 +52,8 @@ describe('coords', function () {
|
||||
* @returns {void}
|
||||
*/
|
||||
afterEach(function () {
|
||||
while (this.svg.hasChildNodes()) {
|
||||
this.svg.firstChild.remove()
|
||||
while (svg?.hasChildNodes()) {
|
||||
svg.firstChild.remove()
|
||||
}
|
||||
})
|
||||
|
||||
@@ -63,7 +63,7 @@ describe('coords', function () {
|
||||
rect.setAttribute('y', '150')
|
||||
rect.setAttribute('width', '250')
|
||||
rect.setAttribute('height', '120')
|
||||
this.svg.append(rect)
|
||||
svg.append(rect)
|
||||
|
||||
const attrs = {
|
||||
x: '200',
|
||||
@@ -73,7 +73,7 @@ describe('coords', function () {
|
||||
}
|
||||
|
||||
// Create a translate.
|
||||
const m = this.svg.createSVGMatrix()
|
||||
const m = svg.createSVGMatrix()
|
||||
m.a = 1; m.b = 0
|
||||
m.c = 0; m.d = 1
|
||||
m.e = 100; m.f = -50
|
||||
@@ -90,7 +90,7 @@ describe('coords', function () {
|
||||
const rect = document.createElementNS(NS.SVG, 'rect')
|
||||
rect.setAttribute('width', '250')
|
||||
rect.setAttribute('height', '120')
|
||||
this.svg.append(rect)
|
||||
svg.append(rect)
|
||||
|
||||
const attrs = {
|
||||
x: '0',
|
||||
@@ -100,7 +100,7 @@ describe('coords', function () {
|
||||
}
|
||||
|
||||
// Create a translate.
|
||||
const m = this.svg.createSVGMatrix()
|
||||
const m = svg.createSVGMatrix()
|
||||
m.a = 2; m.b = 0
|
||||
m.c = 0; m.d = 0.5
|
||||
m.e = 0; m.f = 0
|
||||
@@ -118,7 +118,7 @@ describe('coords', function () {
|
||||
circle.setAttribute('cx', '200')
|
||||
circle.setAttribute('cy', '150')
|
||||
circle.setAttribute('r', '125')
|
||||
this.svg.append(circle)
|
||||
svg.append(circle)
|
||||
|
||||
const attrs = {
|
||||
cx: '200',
|
||||
@@ -127,7 +127,7 @@ describe('coords', function () {
|
||||
}
|
||||
|
||||
// Create a translate.
|
||||
const m = this.svg.createSVGMatrix()
|
||||
const m = svg.createSVGMatrix()
|
||||
m.a = 1; m.b = 0
|
||||
m.c = 0; m.d = 1
|
||||
m.e = 100; m.f = -50
|
||||
@@ -144,7 +144,7 @@ describe('coords', function () {
|
||||
circle.setAttribute('cx', '200')
|
||||
circle.setAttribute('cy', '150')
|
||||
circle.setAttribute('r', '250')
|
||||
this.svg.append(circle)
|
||||
svg.append(circle)
|
||||
|
||||
const attrs = {
|
||||
cx: '200',
|
||||
@@ -153,7 +153,7 @@ describe('coords', function () {
|
||||
}
|
||||
|
||||
// Create a translate.
|
||||
const m = this.svg.createSVGMatrix()
|
||||
const m = svg.createSVGMatrix()
|
||||
m.a = 2; m.b = 0
|
||||
m.c = 0; m.d = 0.5
|
||||
m.e = 0; m.f = 0
|
||||
@@ -172,7 +172,7 @@ describe('coords', function () {
|
||||
ellipse.setAttribute('cy', '150')
|
||||
ellipse.setAttribute('rx', '125')
|
||||
ellipse.setAttribute('ry', '75')
|
||||
this.svg.append(ellipse)
|
||||
svg.append(ellipse)
|
||||
|
||||
const attrs = {
|
||||
cx: '200',
|
||||
@@ -182,7 +182,7 @@ describe('coords', function () {
|
||||
}
|
||||
|
||||
// Create a translate.
|
||||
const m = this.svg.createSVGMatrix()
|
||||
const m = svg.createSVGMatrix()
|
||||
m.a = 1; m.b = 0
|
||||
m.c = 0; m.d = 1
|
||||
m.e = 100; m.f = -50
|
||||
@@ -201,7 +201,7 @@ describe('coords', function () {
|
||||
ellipse.setAttribute('cy', '150')
|
||||
ellipse.setAttribute('rx', '250')
|
||||
ellipse.setAttribute('ry', '120')
|
||||
this.svg.append(ellipse)
|
||||
svg.append(ellipse)
|
||||
|
||||
const attrs = {
|
||||
cx: '200',
|
||||
@@ -211,7 +211,7 @@ describe('coords', function () {
|
||||
}
|
||||
|
||||
// Create a translate.
|
||||
const m = this.svg.createSVGMatrix()
|
||||
const m = svg.createSVGMatrix()
|
||||
m.a = 2; m.b = 0
|
||||
m.c = 0; m.d = 0.5
|
||||
m.e = 0; m.f = 0
|
||||
@@ -230,7 +230,7 @@ describe('coords', function () {
|
||||
line.setAttribute('y1', '100')
|
||||
line.setAttribute('x2', '120')
|
||||
line.setAttribute('y2', '200')
|
||||
this.svg.append(line)
|
||||
svg.append(line)
|
||||
|
||||
const attrs = {
|
||||
x1: '50',
|
||||
@@ -240,7 +240,7 @@ describe('coords', function () {
|
||||
}
|
||||
|
||||
// Create a translate.
|
||||
const m = this.svg.createSVGMatrix()
|
||||
const m = svg.createSVGMatrix()
|
||||
m.a = 1; m.b = 0
|
||||
m.c = 0; m.d = 1
|
||||
m.e = 100; m.f = -50
|
||||
@@ -259,7 +259,7 @@ describe('coords', function () {
|
||||
line.setAttribute('y1', '100')
|
||||
line.setAttribute('x2', '120')
|
||||
line.setAttribute('y2', '200')
|
||||
this.svg.append(line)
|
||||
svg.append(line)
|
||||
|
||||
const attrs = {
|
||||
x1: '50',
|
||||
@@ -269,7 +269,7 @@ describe('coords', function () {
|
||||
}
|
||||
|
||||
// Create a translate.
|
||||
const m = this.svg.createSVGMatrix()
|
||||
const m = svg.createSVGMatrix()
|
||||
m.a = 2; m.b = 0
|
||||
m.c = 0; m.d = 0.5
|
||||
m.e = 0; m.f = 0
|
||||
@@ -286,7 +286,7 @@ describe('coords', function () {
|
||||
const text = document.createElementNS(NS.SVG, 'text')
|
||||
text.setAttribute('x', '50')
|
||||
text.setAttribute('y', '100')
|
||||
this.svg.append(text)
|
||||
svg.append(text)
|
||||
|
||||
const attrs = {
|
||||
x: '50',
|
||||
@@ -294,7 +294,7 @@ describe('coords', function () {
|
||||
}
|
||||
|
||||
// Create a translate.
|
||||
const m = this.svg.createSVGMatrix()
|
||||
const m = svg.createSVGMatrix()
|
||||
m.a = 1; m.b = 0
|
||||
m.c = 0; m.d = 1
|
||||
m.e = 100; m.f = -50
|
||||
|
||||
34
tests/unit/dataStorage.test.js
Normal file
34
tests/unit/dataStorage.test.js
Normal file
@@ -0,0 +1,34 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import dataStorage from '../../packages/svgcanvas/core/dataStorage.js'
|
||||
|
||||
describe('dataStorage', () => {
|
||||
it('stores, checks and retrieves keyed values per element', () => {
|
||||
const el1 = document.createElement('div')
|
||||
const el2 = document.createElement('div')
|
||||
|
||||
dataStorage.put(el1, 'color', 'red')
|
||||
dataStorage.put(el1, 'count', 3)
|
||||
dataStorage.put(el2, 'color', 'blue')
|
||||
|
||||
expect(dataStorage.has(el1, 'color')).toBe(true)
|
||||
expect(dataStorage.has(el1, 'missing')).toBe(false)
|
||||
expect(dataStorage.get(el1, 'color')).toBe('red')
|
||||
expect(dataStorage.get(el1, 'count')).toBe(3)
|
||||
expect(dataStorage.get(el2, 'color')).toBe('blue')
|
||||
})
|
||||
|
||||
it('removes values and cleans up empty element maps', () => {
|
||||
const el = document.createElement('span')
|
||||
dataStorage.put(el, 'foo', 1)
|
||||
dataStorage.put(el, 'bar', 2)
|
||||
|
||||
expect(dataStorage.remove(el, 'foo')).toBe(true)
|
||||
expect(dataStorage.has(el, 'foo')).toBe(false)
|
||||
expect(dataStorage.get(el, 'bar')).toBe(2)
|
||||
|
||||
// Removing the last key should drop the element from storage entirely.
|
||||
expect(dataStorage.remove(el, 'bar')).toBe(true)
|
||||
expect(dataStorage.has(el, 'bar')).toBe(false)
|
||||
expect(dataStorage.get(el, 'bar')).toBeUndefined()
|
||||
})
|
||||
})
|
||||
@@ -9,7 +9,10 @@ describe('draw.Drawing', function () {
|
||||
const addOwnSpies = (obj) => {
|
||||
const methods = Object.keys(obj)
|
||||
methods.forEach((method) => {
|
||||
vi.spyOn(obj, method)
|
||||
const spy = vi.spyOn(obj, method)
|
||||
spy.getCall = (idx = 0) => ({ args: spy.mock.calls[idx] || [] })
|
||||
Object.defineProperty(spy, 'calledOnce', { get: () => spy.mock.calls.length === 1 })
|
||||
Object.defineProperty(spy, 'callCount', { get: () => spy.mock.calls.length })
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -13,6 +13,13 @@ describe('history', function () {
|
||||
|
||||
// const svg = document.createElementNS(NS.SVG, 'svg');
|
||||
let undoMgr = null
|
||||
let divparent
|
||||
let div1
|
||||
let div2
|
||||
let div3
|
||||
let div4
|
||||
let div5
|
||||
let div
|
||||
|
||||
class MockCommand extends history.Command {
|
||||
constructor (optText) {
|
||||
@@ -45,23 +52,23 @@ describe('history', function () {
|
||||
undoMgr = new history.UndoManager()
|
||||
|
||||
document.body.textContent = ''
|
||||
this.divparent = document.createElement('div')
|
||||
this.divparent.id = 'divparent'
|
||||
this.divparent.style.visibility = 'hidden'
|
||||
divparent = document.createElement('div')
|
||||
divparent.id = 'divparent'
|
||||
divparent.style.visibility = 'hidden'
|
||||
|
||||
for (let i = 1; i <= 5; i++) {
|
||||
const div = document.createElement('div')
|
||||
const id = `div${i}`
|
||||
div.id = id
|
||||
this[id] = div
|
||||
}
|
||||
div1 = document.createElement('div'); div1.id = 'div1'
|
||||
div2 = document.createElement('div'); div2.id = 'div2'
|
||||
div3 = document.createElement('div'); div3.id = 'div3'
|
||||
div4 = document.createElement('div'); div4.id = 'div4'
|
||||
div5 = document.createElement('div'); div5.id = 'div5'
|
||||
div = document.createElement('div'); div.id = 'div'
|
||||
|
||||
this.divparent.append(this.div1, this.div2, this.div3)
|
||||
divparent.append(div1, div2, div3)
|
||||
|
||||
this.div4.style.visibility = 'hidden'
|
||||
this.div4.append(this.div5)
|
||||
div4.style.visibility = 'hidden'
|
||||
div4.append(div5)
|
||||
|
||||
document.body.append(this.divparent, this.div)
|
||||
document.body.append(divparent, div)
|
||||
})
|
||||
/**
|
||||
* Tear down tests, destroying undo manager.
|
||||
@@ -278,127 +285,127 @@ describe('history', function () {
|
||||
})
|
||||
|
||||
it('Test MoveElementCommand', function () {
|
||||
let move = new history.MoveElementCommand(this.div3, this.div1, this.divparent)
|
||||
let move = new history.MoveElementCommand(div3, div1, divparent)
|
||||
assert.ok(move.unapply)
|
||||
assert.ok(move.apply)
|
||||
assert.equal(typeof move.unapply, typeof function () { /* empty fn */ })
|
||||
assert.equal(typeof move.apply, typeof function () { /* empty fn */ })
|
||||
|
||||
move.unapply()
|
||||
assert.equal(this.divparent.firstElementChild, this.div3)
|
||||
assert.equal(this.divparent.firstElementChild.nextElementSibling, this.div1)
|
||||
assert.equal(this.divparent.lastElementChild, this.div2)
|
||||
assert.equal(divparent.firstElementChild, div3)
|
||||
assert.equal(divparent.firstElementChild.nextElementSibling, div1)
|
||||
assert.equal(divparent.lastElementChild, div2)
|
||||
|
||||
move.apply()
|
||||
assert.equal(this.divparent.firstElementChild, this.div1)
|
||||
assert.equal(this.divparent.firstElementChild.nextElementSibling, this.div2)
|
||||
assert.equal(this.divparent.lastElementChild, this.div3)
|
||||
assert.equal(divparent.firstElementChild, div1)
|
||||
assert.equal(divparent.firstElementChild.nextElementSibling, div2)
|
||||
assert.equal(divparent.lastElementChild, div3)
|
||||
|
||||
move = new history.MoveElementCommand(this.div1, null, this.divparent)
|
||||
move = new history.MoveElementCommand(div1, null, divparent)
|
||||
|
||||
move.unapply()
|
||||
assert.equal(this.divparent.firstElementChild, this.div2)
|
||||
assert.equal(this.divparent.firstElementChild.nextElementSibling, this.div3)
|
||||
assert.equal(this.divparent.lastElementChild, this.div1)
|
||||
assert.equal(divparent.firstElementChild, div2)
|
||||
assert.equal(divparent.firstElementChild.nextElementSibling, div3)
|
||||
assert.equal(divparent.lastElementChild, div1)
|
||||
|
||||
move.apply()
|
||||
assert.equal(this.divparent.firstElementChild, this.div1)
|
||||
assert.equal(this.divparent.firstElementChild.nextElementSibling, this.div2)
|
||||
assert.equal(this.divparent.lastElementChild, this.div3)
|
||||
assert.equal(divparent.firstElementChild, div1)
|
||||
assert.equal(divparent.firstElementChild.nextElementSibling, div2)
|
||||
assert.equal(divparent.lastElementChild, div3)
|
||||
|
||||
move = new history.MoveElementCommand(this.div2, this.div5, this.div4)
|
||||
move = new history.MoveElementCommand(div2, div5, div4)
|
||||
|
||||
move.unapply()
|
||||
assert.equal(this.divparent.firstElementChild, this.div1)
|
||||
assert.equal(this.divparent.firstElementChild.nextElementSibling, this.div3)
|
||||
assert.equal(this.divparent.lastElementChild, this.div3)
|
||||
assert.equal(this.div4.firstElementChild, this.div2)
|
||||
assert.equal(this.div4.firstElementChild.nextElementSibling, this.div5)
|
||||
assert.equal(divparent.firstElementChild, div1)
|
||||
assert.equal(divparent.firstElementChild.nextElementSibling, div3)
|
||||
assert.equal(divparent.lastElementChild, div3)
|
||||
assert.equal(div4.firstElementChild, div2)
|
||||
assert.equal(div4.firstElementChild.nextElementSibling, div5)
|
||||
|
||||
move.apply()
|
||||
assert.equal(this.divparent.firstElementChild, this.div1)
|
||||
assert.equal(this.divparent.firstElementChild.nextElementSibling, this.div2)
|
||||
assert.equal(this.divparent.lastElementChild, this.div3)
|
||||
assert.equal(this.div4.firstElementChild, this.div5)
|
||||
assert.equal(this.div4.lastElementChild, this.div5)
|
||||
assert.equal(divparent.firstElementChild, div1)
|
||||
assert.equal(divparent.firstElementChild.nextElementSibling, div2)
|
||||
assert.equal(divparent.lastElementChild, div3)
|
||||
assert.equal(div4.firstElementChild, div5)
|
||||
assert.equal(div4.lastElementChild, div5)
|
||||
})
|
||||
|
||||
it('Test InsertElementCommand', function () {
|
||||
let insert = new history.InsertElementCommand(this.div3)
|
||||
let insert = new history.InsertElementCommand(div3)
|
||||
assert.ok(insert.unapply)
|
||||
assert.ok(insert.apply)
|
||||
assert.equal(typeof insert.unapply, typeof function () { /* empty fn */ })
|
||||
assert.equal(typeof insert.apply, typeof function () { /* empty fn */ })
|
||||
|
||||
insert.unapply()
|
||||
assert.equal(this.divparent.childElementCount, 2)
|
||||
assert.equal(this.divparent.firstElementChild, this.div1)
|
||||
assert.equal(this.div1.nextElementSibling, this.div2)
|
||||
assert.equal(this.divparent.lastElementChild, this.div2)
|
||||
assert.equal(divparent.childElementCount, 2)
|
||||
assert.equal(divparent.firstElementChild, div1)
|
||||
assert.equal(div1.nextElementSibling, div2)
|
||||
assert.equal(divparent.lastElementChild, div2)
|
||||
|
||||
insert.apply()
|
||||
assert.equal(this.divparent.childElementCount, 3)
|
||||
assert.equal(this.divparent.firstElementChild, this.div1)
|
||||
assert.equal(this.div1.nextElementSibling, this.div2)
|
||||
assert.equal(this.div2.nextElementSibling, this.div3)
|
||||
assert.equal(divparent.childElementCount, 3)
|
||||
assert.equal(divparent.firstElementChild, div1)
|
||||
assert.equal(div1.nextElementSibling, div2)
|
||||
assert.equal(div2.nextElementSibling, div3)
|
||||
|
||||
insert = new history.InsertElementCommand(this.div2)
|
||||
insert = new history.InsertElementCommand(div2)
|
||||
|
||||
insert.unapply()
|
||||
assert.equal(this.divparent.childElementCount, 2)
|
||||
assert.equal(this.divparent.firstElementChild, this.div1)
|
||||
assert.equal(this.div1.nextElementSibling, this.div3)
|
||||
assert.equal(this.divparent.lastElementChild, this.div3)
|
||||
assert.equal(divparent.childElementCount, 2)
|
||||
assert.equal(divparent.firstElementChild, div1)
|
||||
assert.equal(div1.nextElementSibling, div3)
|
||||
assert.equal(divparent.lastElementChild, div3)
|
||||
|
||||
insert.apply()
|
||||
assert.equal(this.divparent.childElementCount, 3)
|
||||
assert.equal(this.divparent.firstElementChild, this.div1)
|
||||
assert.equal(this.div1.nextElementSibling, this.div2)
|
||||
assert.equal(this.div2.nextElementSibling, this.div3)
|
||||
assert.equal(divparent.childElementCount, 3)
|
||||
assert.equal(divparent.firstElementChild, div1)
|
||||
assert.equal(div1.nextElementSibling, div2)
|
||||
assert.equal(div2.nextElementSibling, div3)
|
||||
})
|
||||
|
||||
it('Test RemoveElementCommand', function () {
|
||||
const div6 = document.createElement('div')
|
||||
div6.id = 'div6'
|
||||
|
||||
let remove = new history.RemoveElementCommand(div6, null, this.divparent)
|
||||
let remove = new history.RemoveElementCommand(div6, null, divparent)
|
||||
assert.ok(remove.unapply)
|
||||
assert.ok(remove.apply)
|
||||
assert.equal(typeof remove.unapply, typeof function () { /* empty fn */ })
|
||||
assert.equal(typeof remove.apply, typeof function () { /* empty fn */ })
|
||||
|
||||
remove.unapply()
|
||||
assert.equal(this.divparent.childElementCount, 4)
|
||||
assert.equal(this.divparent.firstElementChild, this.div1)
|
||||
assert.equal(this.div1.nextElementSibling, this.div2)
|
||||
assert.equal(this.div2.nextElementSibling, this.div3)
|
||||
assert.equal(this.div3.nextElementSibling, div6)
|
||||
assert.equal(divparent.childElementCount, 4)
|
||||
assert.equal(divparent.firstElementChild, div1)
|
||||
assert.equal(div1.nextElementSibling, div2)
|
||||
assert.equal(div2.nextElementSibling, div3)
|
||||
assert.equal(div3.nextElementSibling, div6)
|
||||
|
||||
remove.apply()
|
||||
assert.equal(this.divparent.childElementCount, 3)
|
||||
assert.equal(this.divparent.firstElementChild, this.div1)
|
||||
assert.equal(this.div1.nextElementSibling, this.div2)
|
||||
assert.equal(this.div2.nextElementSibling, this.div3)
|
||||
assert.equal(divparent.childElementCount, 3)
|
||||
assert.equal(divparent.firstElementChild, div1)
|
||||
assert.equal(div1.nextElementSibling, div2)
|
||||
assert.equal(div2.nextElementSibling, div3)
|
||||
|
||||
remove = new history.RemoveElementCommand(div6, this.div2, this.divparent)
|
||||
remove = new history.RemoveElementCommand(div6, div2, divparent)
|
||||
|
||||
remove.unapply()
|
||||
assert.equal(this.divparent.childElementCount, 4)
|
||||
assert.equal(this.divparent.firstElementChild, this.div1)
|
||||
assert.equal(this.div1.nextElementSibling, div6)
|
||||
assert.equal(div6.nextElementSibling, this.div2)
|
||||
assert.equal(this.div2.nextElementSibling, this.div3)
|
||||
assert.equal(divparent.childElementCount, 4)
|
||||
assert.equal(divparent.firstElementChild, div1)
|
||||
assert.equal(div1.nextElementSibling, div6)
|
||||
assert.equal(div6.nextElementSibling, div2)
|
||||
assert.equal(div2.nextElementSibling, div3)
|
||||
|
||||
remove.apply()
|
||||
assert.equal(this.divparent.childElementCount, 3)
|
||||
assert.equal(this.divparent.firstElementChild, this.div1)
|
||||
assert.equal(this.div1.nextElementSibling, this.div2)
|
||||
assert.equal(this.div2.nextElementSibling, this.div3)
|
||||
assert.equal(divparent.childElementCount, 3)
|
||||
assert.equal(divparent.firstElementChild, div1)
|
||||
assert.equal(div1.nextElementSibling, div2)
|
||||
assert.equal(div2.nextElementSibling, div3)
|
||||
})
|
||||
|
||||
it('Test ChangeElementCommand', function () {
|
||||
this.div1.setAttribute('title', 'new title')
|
||||
let change = new history.ChangeElementCommand(this.div1,
|
||||
div1.setAttribute('title', 'new title')
|
||||
let change = new history.ChangeElementCommand(div1,
|
||||
{ title: 'old title', class: 'foo' })
|
||||
assert.ok(change.unapply)
|
||||
assert.ok(change.apply)
|
||||
@@ -406,32 +413,32 @@ describe('history', function () {
|
||||
assert.equal(typeof change.apply, typeof function () { /* empty fn */ })
|
||||
|
||||
change.unapply()
|
||||
assert.equal(this.div1.getAttribute('title'), 'old title')
|
||||
assert.equal(this.div1.getAttribute('class'), 'foo')
|
||||
assert.equal(div1.getAttribute('title'), 'old title')
|
||||
assert.equal(div1.getAttribute('class'), 'foo')
|
||||
|
||||
change.apply()
|
||||
assert.equal(this.div1.getAttribute('title'), 'new title')
|
||||
assert.ok(!this.div1.getAttribute('class'))
|
||||
assert.equal(div1.getAttribute('title'), 'new title')
|
||||
assert.ok(!div1.getAttribute('class'))
|
||||
|
||||
this.div1.textContent = 'inner text'
|
||||
change = new history.ChangeElementCommand(this.div1,
|
||||
div1.textContent = 'inner text'
|
||||
change = new history.ChangeElementCommand(div1,
|
||||
{ '#text': null })
|
||||
|
||||
change.unapply()
|
||||
assert.ok(!this.div1.textContent)
|
||||
assert.ok(!div1.textContent)
|
||||
|
||||
change.apply()
|
||||
assert.equal(this.div1.textContent, 'inner text')
|
||||
assert.equal(div1.textContent, 'inner text')
|
||||
|
||||
this.div1.textContent = ''
|
||||
change = new history.ChangeElementCommand(this.div1,
|
||||
div1.textContent = ''
|
||||
change = new history.ChangeElementCommand(div1,
|
||||
{ '#text': 'old text' })
|
||||
|
||||
change.unapply()
|
||||
assert.equal(this.div1.textContent, 'old text')
|
||||
assert.equal(div1.textContent, 'old text')
|
||||
|
||||
change.apply()
|
||||
assert.ok(!this.div1.textContent)
|
||||
assert.ok(!div1.textContent)
|
||||
|
||||
// TODO(codedread): Refactor this #href stuff in history.js and svgcanvas.js
|
||||
const rect = document.createElementNS(NS.SVG, 'rect')
|
||||
|
||||
193
tests/unit/mainmenu.test.js
Normal file
193
tests/unit/mainmenu.test.js
Normal file
@@ -0,0 +1,193 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import MainMenu from '../../src/editor/MainMenu.js'
|
||||
|
||||
vi.mock('@svgedit/svgcanvas', () => ({
|
||||
default: {
|
||||
$id: (id) => document.getElementById(id),
|
||||
$click: (el, fn) => el?.addEventListener('click', fn),
|
||||
convertUnit: (val) => Number(val),
|
||||
isValidUnit: (_attr, val) => val !== 'bad'
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('@svgedit/svgcanvas/common/browser.js', () => ({
|
||||
isChrome: () => false
|
||||
}))
|
||||
|
||||
describe('MainMenu', () => {
|
||||
let editor
|
||||
let menu
|
||||
let prefStore
|
||||
|
||||
beforeEach(() => {
|
||||
document.body.innerHTML = `
|
||||
<div id="app"></div>
|
||||
<div id="se-export-dialog"></div>
|
||||
<div id="se-img-prop"></div>
|
||||
<div id="se-edit-prefs"></div>
|
||||
`
|
||||
prefStore = { img_save: 'embed', lang: 'en' }
|
||||
const configObj = {
|
||||
curConfig: {
|
||||
baseUnit: 'px',
|
||||
exportWindowType: 'new',
|
||||
canvasName: 'svg-edit',
|
||||
gridSnapping: false,
|
||||
snappingStep: 1,
|
||||
gridColor: '#ccc',
|
||||
showRulers: false
|
||||
},
|
||||
curPrefs: { bkgd_color: '#fff' },
|
||||
preferences: false,
|
||||
pref: vi.fn((key, val) => {
|
||||
if (val !== undefined) {
|
||||
prefStore[key] = val
|
||||
}
|
||||
return prefStore[key]
|
||||
})
|
||||
}
|
||||
const svgCanvas = {
|
||||
setDocumentTitle: vi.fn(),
|
||||
setResolution: vi.fn().mockReturnValue(true),
|
||||
getResolution: vi.fn(() => ({ w: 120, h: 80 })),
|
||||
getDocumentTitle: vi.fn(() => 'Doc'),
|
||||
setConfig: vi.fn(),
|
||||
rasterExport: vi.fn().mockResolvedValue('data-uri'),
|
||||
exportPDF: vi.fn()
|
||||
}
|
||||
|
||||
editor = {
|
||||
configObj,
|
||||
svgCanvas,
|
||||
i18next: { t: (key) => key },
|
||||
$svgEditor: document.getElementById('app'),
|
||||
docprops: false,
|
||||
rulers: { updateRulers: vi.fn() },
|
||||
setBackground: vi.fn(),
|
||||
updateCanvas: vi.fn(),
|
||||
customExportPDF: false,
|
||||
customExportImage: false
|
||||
}
|
||||
globalThis.seAlert = vi.fn()
|
||||
menu = new MainMenu(editor)
|
||||
})
|
||||
|
||||
it('rejects invalid doc properties and shows an alert', () => {
|
||||
const result = menu.saveDocProperties({
|
||||
detail: { title: 'Oops', w: 'bad', h: 'fit', save: 'embed' }
|
||||
})
|
||||
expect(result).toBe(false)
|
||||
expect(globalThis.seAlert).toHaveBeenCalled()
|
||||
expect(editor.svgCanvas.setResolution).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('saves document properties and hides the dialog', () => {
|
||||
editor.docprops = true
|
||||
const result = menu.saveDocProperties({
|
||||
detail: { title: 'Demo', w: '200', h: '100', save: 'layer' }
|
||||
})
|
||||
|
||||
expect(result).toBe(true)
|
||||
expect(editor.svgCanvas.setDocumentTitle).toHaveBeenCalledWith('Demo')
|
||||
expect(editor.svgCanvas.setResolution).toHaveBeenCalledWith('200', '100')
|
||||
expect(editor.updateCanvas).toHaveBeenCalled()
|
||||
expect(prefStore.img_save).toBe('layer')
|
||||
expect(editor.docprops).toBe(false)
|
||||
expect(document.getElementById('se-img-prop').getAttribute('dialog')).toBe('close')
|
||||
})
|
||||
|
||||
it('saves preferences, updates config and alerts when language changes', async () => {
|
||||
editor.configObj.preferences = true
|
||||
const detail = {
|
||||
lang: 'fr',
|
||||
bgcolor: '#111',
|
||||
bgurl: '',
|
||||
gridsnappingon: true,
|
||||
gridsnappingstep: 2,
|
||||
gridcolor: '#333',
|
||||
showrulers: true,
|
||||
baseunit: 'cm'
|
||||
}
|
||||
|
||||
await menu.savePreferences({ detail })
|
||||
|
||||
expect(editor.setBackground).toHaveBeenCalledWith('#111', '')
|
||||
expect(prefStore.lang).toBe('fr')
|
||||
expect(editor.configObj.curConfig.gridSnapping).toBe(true)
|
||||
expect(editor.configObj.curConfig.snappingStep).toBe(2)
|
||||
expect(editor.configObj.curConfig.gridColor).toBe('#333')
|
||||
expect(editor.configObj.curConfig.showRulers).toBe(true)
|
||||
expect(editor.configObj.curConfig.baseUnit).toBe('cm')
|
||||
expect(editor.rulers.updateRulers).toHaveBeenCalled()
|
||||
expect(editor.svgCanvas.setConfig).toHaveBeenCalled()
|
||||
expect(editor.updateCanvas).toHaveBeenCalled()
|
||||
expect(globalThis.seAlert).toHaveBeenCalled()
|
||||
expect(editor.configObj.preferences).toBe(false)
|
||||
})
|
||||
|
||||
it('opens doc properties dialog and converts units when needed', () => {
|
||||
editor.configObj.curConfig.baseUnit = 'cm'
|
||||
menu.showDocProperties()
|
||||
|
||||
const dialog = document.getElementById('se-img-prop')
|
||||
expect(editor.docprops).toBe(true)
|
||||
expect(dialog.getAttribute('dialog')).toBe('open')
|
||||
expect(dialog.getAttribute('width')).toBe('120cm')
|
||||
expect(dialog.getAttribute('height')).toBe('80cm')
|
||||
expect(dialog.getAttribute('title')).toBe('Doc')
|
||||
|
||||
editor.svgCanvas.getResolution.mockClear()
|
||||
menu.showDocProperties()
|
||||
expect(editor.svgCanvas.getResolution).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('opens preferences dialog only once and populates attributes', () => {
|
||||
editor.configObj.curConfig.gridSnapping = true
|
||||
editor.configObj.curConfig.snappingStep = 4
|
||||
editor.configObj.curConfig.gridColor = '#888'
|
||||
editor.configObj.curPrefs.bkgd_color = '#ff00ff'
|
||||
editor.configObj.pref = vi.fn((key) => key === 'bkgd_url' ? 'http://example.com' : prefStore[key])
|
||||
|
||||
menu.showPreferences()
|
||||
const prefs = document.getElementById('se-edit-prefs')
|
||||
expect(editor.configObj.preferences).toBe(true)
|
||||
expect(prefs.getAttribute('dialog')).toBe('open')
|
||||
expect(prefs.getAttribute('gridsnappingon')).toBe('true')
|
||||
expect(prefs.getAttribute('gridsnappingstep')).toBe('4')
|
||||
expect(prefs.getAttribute('gridcolor')).toBe('#888')
|
||||
expect(prefs.getAttribute('canvasbg')).toBe('#ff00ff')
|
||||
expect(prefs.getAttribute('bgurl')).toBe('http://example.com')
|
||||
|
||||
editor.configObj.preferences = true
|
||||
prefs.removeAttribute('dialog')
|
||||
menu.showPreferences()
|
||||
expect(prefs.getAttribute('dialog')).toBeNull()
|
||||
})
|
||||
|
||||
it('routes export actions based on dialog detail', async () => {
|
||||
await menu.clickExport()
|
||||
expect(editor.svgCanvas.rasterExport).not.toHaveBeenCalled()
|
||||
|
||||
await menu.clickExport({ detail: { trigger: 'ok', imgType: 'PNG', quality: 50 } })
|
||||
expect(editor.svgCanvas.rasterExport).toHaveBeenCalledWith('PNG', 0.5, editor.exportWindowName)
|
||||
expect(editor.exportWindowCt).toBe(1)
|
||||
|
||||
await menu.clickExport({ detail: { trigger: 'ok', imgType: 'PDF' } })
|
||||
expect(editor.svgCanvas.exportPDF).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('creates menu entries and wires click handlers in init', () => {
|
||||
menu.init()
|
||||
|
||||
document.getElementById('tool_export').dispatchEvent(new Event('click', { bubbles: true }))
|
||||
expect(document.getElementById('se-export-dialog').getAttribute('dialog')).toBe('open')
|
||||
|
||||
document.getElementById('tool_docprops').dispatchEvent(new Event('click', { bubbles: true }))
|
||||
expect(editor.docprops).toBe(true)
|
||||
|
||||
const prefsDialog = document.getElementById('se-edit-prefs')
|
||||
prefsDialog.dispatchEvent(new CustomEvent('change', { detail: { dialog: 'closed' } }))
|
||||
expect(prefsDialog.getAttribute('dialog')).toBe('close')
|
||||
})
|
||||
})
|
||||
72
tests/unit/paint.test.js
Normal file
72
tests/unit/paint.test.js
Normal file
@@ -0,0 +1,72 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
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
|
||||
}
|
||||
|
||||
const createRadial = (id) => {
|
||||
const grad = document.createElementNS('http://www.w3.org/2000/svg', 'radialGradient')
|
||||
if (id) grad.id = id
|
||||
grad.setAttribute('cx', '0.5')
|
||||
grad.setAttribute('cy', '0.5')
|
||||
return grad
|
||||
}
|
||||
|
||||
describe('Paint', () => {
|
||||
it('defaults to an empty paint when no options are provided', () => {
|
||||
const paint = new Paint()
|
||||
expect(paint.type).toBe('none')
|
||||
expect(paint.alpha).toBe(100)
|
||||
expect(paint.solidColor).toBeNull()
|
||||
expect(paint.linearGradient).toBeNull()
|
||||
expect(paint.radialGradient).toBeNull()
|
||||
})
|
||||
|
||||
it('copies a solid color paint including 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.linearGradient).toBeNull()
|
||||
expect(copy.radialGradient).toBeNull()
|
||||
})
|
||||
|
||||
it('copies gradients by cloning the underlying nodes', () => {
|
||||
const linear = createLinear('lin1')
|
||||
const base = new Paint({ linearGradient: linear })
|
||||
const clone = new Paint({ copy: base })
|
||||
|
||||
expect(clone.type).toBe('linearGradient')
|
||||
expect(clone.linearGradient).not.toBe(base.linearGradient)
|
||||
expect(clone.linearGradient?.isEqualNode(base.linearGradient)).toBe(true)
|
||||
})
|
||||
|
||||
it('resolves linked linear gradients via href/xlink:href', () => {
|
||||
const referenced = createLinear('refGrad')
|
||||
document.body.append(referenced)
|
||||
const referencing = createLinear('linkGrad')
|
||||
referencing.setAttribute('xlink:href', '#refGrad')
|
||||
|
||||
const paint = new Paint({ linearGradient: referencing })
|
||||
expect(paint.type).toBe('linearGradient')
|
||||
expect(paint.linearGradient).not.toBeNull()
|
||||
expect(paint.linearGradient?.id).toBe('refGrad')
|
||||
})
|
||||
|
||||
it('creates radial gradients from provided element when no href is set', () => {
|
||||
const radial = createRadial('rad1')
|
||||
const paint = new Paint({ radialGradient: radial })
|
||||
|
||||
expect(paint.type).toBe('radialGradient')
|
||||
expect(paint.radialGradient).not.toBe(radial)
|
||||
expect(paint.radialGradient?.id).toBe('rad1')
|
||||
expect(paint.linearGradient).toBeNull()
|
||||
})
|
||||
})
|
||||
348
tests/unit/setup-vitest.js
Normal file
348
tests/unit/setup-vitest.js
Normal file
@@ -0,0 +1,348 @@
|
||||
import { AssertionError, strict as assert } from 'node:assert'
|
||||
|
||||
// Provide a global assert (some legacy tests expect it).
|
||||
globalThis.assert = assert
|
||||
|
||||
// Add a lightweight closeTo helper to mimic chai.assert.closeTo.
|
||||
assert.closeTo = function (actual, expected, delta, message) {
|
||||
const ok = Math.abs(actual - expected) <= delta
|
||||
if (!ok) {
|
||||
throw new AssertionError({
|
||||
message: message || `expected ${actual} to be within ${delta} of ${expected}`,
|
||||
actual,
|
||||
expected
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Mocha-style aliases expected by legacy tests.
|
||||
globalThis.before = globalThis.beforeAll
|
||||
globalThis.after = globalThis.afterAll
|
||||
|
||||
// JSDOM lacks many SVG APIs; provide minimal stubs used in tests.
|
||||
const win = globalThis.window || globalThis
|
||||
|
||||
// Simple SVG matrix/transform/point polyfills good enough for unit tests.
|
||||
class SVGMatrixPolyfill {
|
||||
constructor (a = 1, b = 0, c = 0, d = 1, e = 0, f = 0) {
|
||||
this.a = a; this.b = b; this.c = c; this.d = d; this.e = e; this.f = f
|
||||
}
|
||||
|
||||
multiply (m) {
|
||||
return new SVGMatrixPolyfill(
|
||||
this.a * m.a + this.c * m.b,
|
||||
this.b * m.a + this.d * m.b,
|
||||
this.a * m.c + this.c * m.d,
|
||||
this.b * m.c + this.d * m.d,
|
||||
this.a * m.e + this.c * m.f + this.e,
|
||||
this.b * m.e + this.d * m.f + this.f
|
||||
)
|
||||
}
|
||||
|
||||
translate (x, y) { return this.multiply(new SVGMatrixPolyfill(1, 0, 0, 1, x, y)) }
|
||||
scale (s) { return this.multiply(new SVGMatrixPolyfill(s, 0, 0, s, 0, 0)) }
|
||||
scaleNonUniform (sx, sy) { return this.multiply(new SVGMatrixPolyfill(sx, 0, 0, sy, 0, 0)) }
|
||||
rotate (deg) {
|
||||
const rad = deg * Math.PI / 180
|
||||
const cos = Math.cos(rad)
|
||||
const sin = Math.sin(rad)
|
||||
return this.multiply(new SVGMatrixPolyfill(cos, sin, -sin, cos, 0, 0))
|
||||
}
|
||||
|
||||
flipX () { return this.scale(-1, 1) }
|
||||
flipY () { return this.scale(1, -1) }
|
||||
skewX (deg) {
|
||||
const rad = deg * Math.PI / 180
|
||||
return this.multiply(new SVGMatrixPolyfill(1, 0, Math.tan(rad), 1, 0, 0))
|
||||
}
|
||||
|
||||
skewY (deg) {
|
||||
const rad = deg * Math.PI / 180
|
||||
return this.multiply(new SVGMatrixPolyfill(1, Math.tan(rad), 0, 1, 0, 0))
|
||||
}
|
||||
|
||||
inverse () {
|
||||
const det = this.a * this.d - this.b * this.c
|
||||
if (!det) return new SVGMatrixPolyfill()
|
||||
return new SVGMatrixPolyfill(
|
||||
this.d / det,
|
||||
-this.b / det,
|
||||
-this.c / det,
|
||||
this.a / det,
|
||||
(this.c * this.f - this.d * this.e) / det,
|
||||
(this.b * this.e - this.a * this.f) / det
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
class SVGTransformPolyfill {
|
||||
constructor (type = SVGTransformPolyfill.SVG_TRANSFORM_MATRIX, matrix = new SVGMatrixPolyfill()) {
|
||||
this.type = type
|
||||
this.matrix = matrix
|
||||
}
|
||||
|
||||
setMatrix (matrix) {
|
||||
this.type = SVGTransformPolyfill.SVG_TRANSFORM_MATRIX
|
||||
this.matrix = matrix
|
||||
}
|
||||
|
||||
setTranslate (x, y) {
|
||||
this.type = SVGTransformPolyfill.SVG_TRANSFORM_TRANSLATE
|
||||
this.matrix = new SVGMatrixPolyfill(1, 0, 0, 1, x, y)
|
||||
}
|
||||
|
||||
setScale (sx, sy = sx) {
|
||||
this.type = SVGTransformPolyfill.SVG_TRANSFORM_SCALE
|
||||
this.matrix = new SVGMatrixPolyfill(sx, 0, 0, sy, 0, 0)
|
||||
}
|
||||
|
||||
setRotate (angle, cx = 0, cy = 0) {
|
||||
// Translate to center, rotate, then translate back.
|
||||
const ang = Number(angle) || 0
|
||||
const cxNum = Number(cx) || 0
|
||||
const cyNum = Number(cy) || 0
|
||||
const rotate = new SVGMatrixPolyfill().translate(cxNum, cyNum).rotate(ang).translate(-cxNum, -cyNum)
|
||||
this.type = SVGTransformPolyfill.SVG_TRANSFORM_ROTATE
|
||||
this.angle = ang
|
||||
this.cx = cxNum
|
||||
this.cy = cyNum
|
||||
this.matrix = rotate
|
||||
}
|
||||
}
|
||||
SVGTransformPolyfill.SVG_TRANSFORM_UNKNOWN = 0
|
||||
SVGTransformPolyfill.SVG_TRANSFORM_MATRIX = 1
|
||||
SVGTransformPolyfill.SVG_TRANSFORM_TRANSLATE = 2
|
||||
SVGTransformPolyfill.SVG_TRANSFORM_SCALE = 3
|
||||
SVGTransformPolyfill.SVG_TRANSFORM_ROTATE = 4
|
||||
SVGTransformPolyfill.SVG_TRANSFORM_SKEWX = 5
|
||||
SVGTransformPolyfill.SVG_TRANSFORM_SKEWY = 6
|
||||
|
||||
class SVGTransformListPolyfill {
|
||||
constructor () {
|
||||
this._items = []
|
||||
}
|
||||
|
||||
get numberOfItems () { return this._items.length }
|
||||
getItem (i) { return this._items[i] }
|
||||
appendItem (item) { this._items.push(item); return item }
|
||||
insertItemBefore (item, index) {
|
||||
const idx = Math.max(0, Math.min(index, this._items.length))
|
||||
this._items.splice(idx, 0, item)
|
||||
return item
|
||||
}
|
||||
|
||||
removeItem (index) {
|
||||
if (index < 0 || index >= this._items.length) return undefined
|
||||
const [removed] = this._items.splice(index, 1)
|
||||
return removed
|
||||
}
|
||||
|
||||
clear () { this._items = [] }
|
||||
initialize (item) { this._items = [item]; return item }
|
||||
consolidate () {
|
||||
if (!this._items.length) return null
|
||||
const matrix = this._items.reduce(
|
||||
(acc, t) => acc.multiply(t.matrix),
|
||||
new SVGMatrixPolyfill()
|
||||
)
|
||||
const consolidated = new SVGTransformPolyfill()
|
||||
consolidated.setMatrix(matrix)
|
||||
this._items = [consolidated]
|
||||
return consolidated
|
||||
}
|
||||
}
|
||||
|
||||
const parseTransformAttr = (attr) => {
|
||||
const list = new SVGTransformListPolyfill()
|
||||
if (!attr) return list
|
||||
const matcher = /([a-zA-Z]+)\(([^)]+)\)/g
|
||||
let match
|
||||
while ((match = matcher.exec(attr))) {
|
||||
const [, type, raw] = match
|
||||
const nums = raw.split(/[,\s]+/).filter(Boolean).map(Number)
|
||||
const t = new SVGTransformPolyfill()
|
||||
switch (type) {
|
||||
case 'matrix':
|
||||
t.setMatrix(new SVGMatrixPolyfill(...nums))
|
||||
break
|
||||
case 'translate':
|
||||
t.setTranslate(nums[0] ?? 0, nums[1] ?? 0)
|
||||
break
|
||||
case 'scale':
|
||||
t.setScale(nums[0] ?? 1, nums[1] ?? nums[0] ?? 1)
|
||||
break
|
||||
case 'rotate':
|
||||
t.setRotate(nums[0] ?? 0, nums[1] ?? 0, nums[2] ?? 0)
|
||||
break
|
||||
default:
|
||||
t.setMatrix(new SVGMatrixPolyfill())
|
||||
break
|
||||
}
|
||||
list.appendItem(t)
|
||||
}
|
||||
return list
|
||||
}
|
||||
|
||||
const ensureTransformList = (elem) => {
|
||||
if (!elem.__transformList) {
|
||||
const parsed = parseTransformAttr(elem.getAttribute?.('transform'))
|
||||
elem.__transformList = parsed
|
||||
}
|
||||
return elem.__transformList
|
||||
}
|
||||
|
||||
if (!win.SVGElement) {
|
||||
win.SVGElement = win.Element
|
||||
}
|
||||
const svgElementProto = win.SVGElement?.prototype
|
||||
|
||||
// Basic constructors for missing SVG types.
|
||||
if (!win.SVGSVGElement) win.SVGSVGElement = win.SVGElement
|
||||
if (!win.SVGGraphicsElement) win.SVGGraphicsElement = win.SVGElement
|
||||
if (!win.SVGGeometryElement) win.SVGGeometryElement = win.SVGElement
|
||||
// Ensure SVGPathElement exists so the pathseg polyfill can patch it.
|
||||
win.SVGPathElement = win.SVGElement || function SVGPathElement () {}
|
||||
|
||||
// Matrix/transform helpers.
|
||||
win.SVGMatrix = win.SVGMatrix || SVGMatrixPolyfill
|
||||
win.DOMMatrix = win.DOMMatrix || SVGMatrixPolyfill
|
||||
win.SVGTransform = win.SVGTransform || SVGTransformPolyfill
|
||||
win.SVGTransformList = win.SVGTransformList || SVGTransformListPolyfill
|
||||
|
||||
if (svgElementProto) {
|
||||
if (!svgElementProto.createSVGMatrix) {
|
||||
svgElementProto.createSVGMatrix = () => new SVGMatrixPolyfill()
|
||||
}
|
||||
if (!svgElementProto.createSVGTransform) {
|
||||
svgElementProto.createSVGTransform = () => new SVGTransformPolyfill()
|
||||
}
|
||||
if (!svgElementProto.createSVGTransformFromMatrix) {
|
||||
svgElementProto.createSVGTransformFromMatrix = (matrix) => {
|
||||
const t = new SVGTransformPolyfill()
|
||||
t.setMatrix(matrix)
|
||||
return t
|
||||
}
|
||||
}
|
||||
if (!svgElementProto.createSVGPoint) {
|
||||
svgElementProto.createSVGPoint = () => ({
|
||||
x: 0,
|
||||
y: 0,
|
||||
matrixTransform (m) {
|
||||
return {
|
||||
x: m.a * this.x + m.c * this.y + m.e,
|
||||
y: m.b * this.x + m.d * this.y + m.f
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
svgElementProto.getBBox = function () {
|
||||
const tag = (this.tagName || '').toLowerCase()
|
||||
const parseLength = (attr, fallback = 0) => {
|
||||
const raw = this.getAttribute?.(attr)
|
||||
if (raw == null) return fallback
|
||||
const str = String(raw)
|
||||
const n = Number.parseFloat(str)
|
||||
if (Number.isNaN(n)) return fallback
|
||||
if (str.endsWith('in')) return n * 96
|
||||
if (str.endsWith('cm')) return n * 96 / 2.54
|
||||
if (str.endsWith('mm')) return n * 96 / 25.4
|
||||
if (str.endsWith('pt')) return n * 96 / 72
|
||||
if (str.endsWith('pc')) return n * 16
|
||||
if (str.endsWith('em')) return n * 16
|
||||
if (str.endsWith('ex')) return n * 8
|
||||
return n
|
||||
}
|
||||
const parsePoints = () => (this.getAttribute?.('points') || '')
|
||||
.trim()
|
||||
.split(/\\s+/)
|
||||
.map(pair => pair.split(',').map(Number))
|
||||
.filter(([x, y]) => !Number.isNaN(x) && !Number.isNaN(y))
|
||||
|
||||
if (tag === 'path') {
|
||||
const d = this.getAttribute?.('d') || ''
|
||||
const nums = (d.match(/-?\\d*\\.?\\d+/g) || [])
|
||||
.map(Number)
|
||||
.filter(n => !Number.isNaN(n))
|
||||
if (nums.length >= 2) {
|
||||
let minx = Infinity; let miny = Infinity
|
||||
let maxx = -Infinity; let maxy = -Infinity
|
||||
for (let i = 0; i < nums.length; i += 2) {
|
||||
const x = nums[i]; const y = nums[i + 1]
|
||||
if (x < minx) minx = x
|
||||
if (x > maxx) maxx = x
|
||||
if (y < miny) miny = y
|
||||
if (y > maxy) maxy = y
|
||||
}
|
||||
return {
|
||||
x: minx === Infinity ? 0 : minx,
|
||||
y: miny === Infinity ? 0 : miny,
|
||||
width: maxx === -Infinity ? 0 : maxx - minx,
|
||||
height: maxy === -Infinity ? 0 : maxy - miny
|
||||
}
|
||||
}
|
||||
return { x: 0, y: 0, width: 0, height: 0 }
|
||||
}
|
||||
|
||||
if (tag === 'rect') {
|
||||
const x = parseLength('x')
|
||||
const y = parseLength('y')
|
||||
const width = parseLength('width')
|
||||
const height = parseLength('height')
|
||||
return { x, y, width, height }
|
||||
}
|
||||
|
||||
if (tag === 'line') {
|
||||
const x1 = parseLength('x1'); const y1 = parseLength('y1')
|
||||
const x2 = parseLength('x2'); const y2 = parseLength('y2')
|
||||
const minx = Math.min(x1, x2); const miny = Math.min(y1, y2)
|
||||
return { x: minx, y: miny, width: Math.abs(x2 - x1), height: Math.abs(y2 - y1) }
|
||||
}
|
||||
|
||||
if (tag === 'circle') {
|
||||
const cx = parseLength('cx'); const cy = parseLength('cy'); const r = parseLength('r') || parseLength('rx') || parseLength('ry')
|
||||
return { x: cx - r, y: cy - r, width: r * 2, height: r * 2 }
|
||||
}
|
||||
|
||||
if (tag === 'ellipse') {
|
||||
const cx = parseLength('cx'); const cy = parseLength('cy'); const rx = parseLength('rx'); const ry = parseLength('ry')
|
||||
return { x: cx - rx, y: cy - ry, width: rx * 2, height: ry * 2 }
|
||||
}
|
||||
|
||||
if (tag === 'polyline' || tag === 'polygon') {
|
||||
const pts = parsePoints()
|
||||
if (!pts.length) return { x: 0, y: 0, width: 0, height: 0 }
|
||||
const xs = pts.map(([x]) => x)
|
||||
const ys = pts.map(([, y]) => y)
|
||||
const minx = Math.min(...xs); const maxx = Math.max(...xs)
|
||||
const miny = Math.min(...ys); const maxy = Math.max(...ys)
|
||||
return { x: minx, y: miny, width: maxx - minx, height: maxy - miny }
|
||||
}
|
||||
|
||||
return { x: 0, y: 0, width: 0, height: 0 }
|
||||
}
|
||||
if (!Object.getOwnPropertyDescriptor(svgElementProto, 'transform')) {
|
||||
Object.defineProperty(svgElementProto, 'transform', {
|
||||
get () {
|
||||
const baseVal = ensureTransformList(this)
|
||||
return { baseVal }
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Ensure pathseg polyfill can attach to prototypes.
|
||||
await import('pathseg')
|
||||
|
||||
// Add minimal chai-like helpers some legacy tests expect.
|
||||
assert.close = (actual, expected, delta, message) =>
|
||||
assert.closeTo(actual, expected, delta, message)
|
||||
assert.notOk = (val, message) => {
|
||||
if (val) {
|
||||
throw new AssertionError({ message: message || `expected ${val} to be falsy`, actual: val, expected: false })
|
||||
}
|
||||
}
|
||||
assert.isBelow = (val, limit, message) => {
|
||||
if (!(val < limit)) {
|
||||
throw new AssertionError({ message: message || `expected ${val} to be below ${limit}`, actual: val, expected: `< ${limit}` })
|
||||
}
|
||||
}
|
||||
124
tests/unit/touch.test.js
Normal file
124
tests/unit/touch.test.js
Normal file
@@ -0,0 +1,124 @@
|
||||
import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest'
|
||||
import { init as initTouch } from '../../packages/svgcanvas/core/touch.js'
|
||||
|
||||
const createSvgRoot = () => {
|
||||
const listeners = {}
|
||||
return {
|
||||
listeners,
|
||||
addEventListener (type, handler) { listeners[type] = handler },
|
||||
dispatch (type, event) { listeners[type]?.(event) }
|
||||
}
|
||||
}
|
||||
|
||||
const OriginalMouseEvent = global.MouseEvent
|
||||
|
||||
beforeAll(() => {
|
||||
// JSDOM's MouseEvent requires a real Window; a lightweight stub keeps the adapter logic testable.
|
||||
global.MouseEvent = class extends Event {
|
||||
constructor (type, init = {}) {
|
||||
super(type, init)
|
||||
this.clientX = init.clientX
|
||||
this.clientY = init.clientY
|
||||
this.screenX = init.screenX
|
||||
this.screenY = init.screenY
|
||||
this.button = init.button ?? 0
|
||||
this.relatedTarget = init.relatedTarget ?? null
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
afterAll(() => {
|
||||
global.MouseEvent = OriginalMouseEvent
|
||||
})
|
||||
|
||||
describe('touch adapter', () => {
|
||||
it('translates single touch to mouse event on target', () => {
|
||||
const svgroot = createSvgRoot()
|
||||
const svgCanvas = { svgroot }
|
||||
initTouch(svgCanvas)
|
||||
|
||||
const target = document.createElement('div')
|
||||
const received = []
|
||||
target.addEventListener('mousedown', (ev) => {
|
||||
received.push({
|
||||
type: ev.type,
|
||||
clientX: ev.clientX,
|
||||
clientY: ev.clientY,
|
||||
screenX: ev.screenX,
|
||||
screenY: ev.screenY
|
||||
})
|
||||
})
|
||||
|
||||
const preventDefault = vi.fn()
|
||||
svgroot.dispatch('touchstart', {
|
||||
type: 'touchstart',
|
||||
changedTouches: [{
|
||||
target,
|
||||
clientX: 12,
|
||||
clientY: 34,
|
||||
screenX: 56,
|
||||
screenY: 78
|
||||
}],
|
||||
preventDefault
|
||||
})
|
||||
|
||||
expect(preventDefault).toHaveBeenCalled()
|
||||
expect(received).toEqual([{
|
||||
type: 'mousedown',
|
||||
clientX: 12,
|
||||
clientY: 34,
|
||||
screenX: 56,
|
||||
screenY: 78
|
||||
}])
|
||||
})
|
||||
|
||||
it('maps move events and ignores multi-touch gestures', () => {
|
||||
const svgroot = createSvgRoot()
|
||||
initTouch({ svgroot })
|
||||
|
||||
const target = document.createElement('div')
|
||||
let mouseDown = 0
|
||||
let mouseMove = 0
|
||||
target.addEventListener('mousedown', () => { mouseDown++ })
|
||||
target.addEventListener('mousemove', () => { mouseMove++ })
|
||||
|
||||
svgroot.dispatch('touchstart', {
|
||||
type: 'touchstart',
|
||||
changedTouches: [
|
||||
{ target, clientX: 1, clientY: 2, screenX: 3, screenY: 4 },
|
||||
{ target, clientX: 5, clientY: 6, screenX: 7, screenY: 8 }
|
||||
],
|
||||
preventDefault: vi.fn()
|
||||
})
|
||||
|
||||
expect(mouseDown).toBe(0)
|
||||
|
||||
svgroot.dispatch('touchmove', {
|
||||
type: 'touchmove',
|
||||
changedTouches: [
|
||||
{ target, clientX: 9, clientY: 10, screenX: 11, screenY: 12 }
|
||||
],
|
||||
preventDefault: vi.fn()
|
||||
})
|
||||
|
||||
expect(mouseMove).toBe(1)
|
||||
})
|
||||
|
||||
it('returns early on unknown event types', () => {
|
||||
const svgroot = createSvgRoot()
|
||||
initTouch({ svgroot })
|
||||
const target = document.createElement('div')
|
||||
let mouseCount = 0
|
||||
target.addEventListener('mousedown', () => { mouseCount++ })
|
||||
|
||||
const preventDefault = vi.fn()
|
||||
svgroot.dispatch('touchcancel', {
|
||||
type: 'touchcancel',
|
||||
changedTouches: [{ target, clientX: 0, clientY: 0, screenX: 0, screenY: 0 }],
|
||||
preventDefault
|
||||
})
|
||||
|
||||
expect(preventDefault).toHaveBeenCalled()
|
||||
expect(mouseCount).toBe(0)
|
||||
})
|
||||
})
|
||||
67
tests/unit/util-common.test.js
Normal file
67
tests/unit/util-common.test.js
Normal file
@@ -0,0 +1,67 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
findPos,
|
||||
isObject,
|
||||
mergeDeep,
|
||||
getClosest,
|
||||
getParents,
|
||||
getParentsUntil
|
||||
} from '../../packages/svgcanvas/common/util.js'
|
||||
|
||||
describe('common util helpers', () => {
|
||||
it('computes positions and merges objects deeply', () => {
|
||||
const grand = { offsetLeft: 5, offsetTop: 6, offsetParent: null }
|
||||
const parent = { offsetLeft: 10, offsetTop: 11, offsetParent: grand }
|
||||
const child = { offsetLeft: 7, offsetTop: 8, offsetParent: parent }
|
||||
|
||||
expect(findPos(child)).toEqual({ left: 22, top: 25 })
|
||||
expect(isObject({ foo: 'bar' })).toBe(true)
|
||||
|
||||
const merged = mergeDeep(
|
||||
{ a: 1, nested: { keep: true, replace: 'old' } },
|
||||
{ nested: { replace: 'new', extra: 42 }, more: 'yes' }
|
||||
)
|
||||
expect(merged).toEqual({ a: 1, nested: { keep: true, replace: 'new', extra: 42 }, more: 'yes' })
|
||||
})
|
||||
|
||||
it('finds closest elements across selectors', () => {
|
||||
const root = document.createElement('div')
|
||||
const wrapper = document.createElement('div')
|
||||
wrapper.className = 'wrapper'
|
||||
const section = document.createElement('section')
|
||||
section.id = 'section'
|
||||
const child = document.createElement('span')
|
||||
child.dataset.role = 'target'
|
||||
|
||||
section.append(child)
|
||||
wrapper.append(section)
|
||||
root.append(wrapper)
|
||||
document.body.append(root)
|
||||
|
||||
expect(getClosest(child, '.wrapper')?.className).toBe('wrapper')
|
||||
expect(getClosest(child, '#section')?.id).toBe('section')
|
||||
expect(getClosest(child, '[data-role=target]')?.dataset.role).toBe('target')
|
||||
expect(getClosest(child, 'div')?.tagName.toLowerCase()).toBe('div')
|
||||
})
|
||||
|
||||
it('collects parents with and without limits', () => {
|
||||
const outer = document.createElement('div')
|
||||
outer.className = 'outer'
|
||||
const mid = document.createElement('section')
|
||||
mid.id = 'mid'
|
||||
const inner = document.createElement('span')
|
||||
inner.className = 'inner'
|
||||
|
||||
mid.append(inner)
|
||||
outer.append(mid)
|
||||
document.body.append(outer)
|
||||
|
||||
const parents = getParents(inner)?.map(el => el.tagName.toLowerCase())
|
||||
expect(parents).toContain('body')
|
||||
|
||||
expect(getParents(inner, '.outer')?.map(el => el.className)).toEqual(['outer'])
|
||||
|
||||
const untilMid = getParentsUntil(inner, '#mid', '.inner')?.map(el => el.tagName.toLowerCase())
|
||||
expect(untilMid).toEqual(['span'])
|
||||
})
|
||||
})
|
||||
@@ -1,15 +1,12 @@
|
||||
import { strict as assert } from 'node:assert'
|
||||
import 'pathseg'
|
||||
|
||||
import { NS } from '../../packages/svgcanvas/core/namespaces.js'
|
||||
import * as utilities from '../../packages/svgcanvas/core/utilities.js'
|
||||
import * as math from '../../packages/svgcanvas/core/math.js'
|
||||
import * as path from '../../packages/svgcanvas/core/path.js'
|
||||
import setAssertionMethods from '../../support/assert-close.js'
|
||||
import * as units from '../../packages/svgcanvas/core/units.js'
|
||||
|
||||
// eslint-disable-next-line
|
||||
chai.use(setAssertionMethods)
|
||||
|
||||
describe('utilities bbox', function () {
|
||||
/**
|
||||
* Create an SVG element for a mock.
|
||||
@@ -21,6 +18,39 @@ describe('utilities bbox', function () {
|
||||
Object.entries(jsonMap.attr).forEach(([attr, value]) => {
|
||||
elem.setAttribute(attr, value)
|
||||
})
|
||||
const numFromAttr = (attr, fallback = 0) => Number(jsonMap.attr[attr] ?? fallback)
|
||||
const calcBBox = () => {
|
||||
const tag = (jsonMap.element || '').toLowerCase()
|
||||
switch (tag) {
|
||||
case 'path': {
|
||||
const d = jsonMap.attr.d || ''
|
||||
const nums = (d.match(/-?\\d*\\.?\\d+/g) || []).map(Number)
|
||||
if (nums.length >= 4) {
|
||||
const xs = nums.filter((_, i) => i % 2 === 0)
|
||||
const ys = nums.filter((_, i) => i % 2 === 1)
|
||||
return {
|
||||
x: Math.min(...xs),
|
||||
y: Math.min(...ys),
|
||||
width: Math.max(...xs) - Math.min(...xs),
|
||||
height: Math.max(...ys) - Math.min(...ys)
|
||||
}
|
||||
}
|
||||
return { x: 0, y: 0, width: 0, height: 0 }
|
||||
}
|
||||
case 'rect':
|
||||
return { x: numFromAttr('x'), y: numFromAttr('y'), width: numFromAttr('width'), height: numFromAttr('height') }
|
||||
case 'line': {
|
||||
const x1 = numFromAttr('x1'); const x2 = numFromAttr('x2'); const y1 = numFromAttr('y1'); const y2 = numFromAttr('y2')
|
||||
return { x: Math.min(x1, x2), y: Math.min(y1, y2), width: Math.abs(x2 - x1), height: Math.abs(y2 - y1) }
|
||||
}
|
||||
default:
|
||||
return { x: 0, y: 0, width: 0, height: 0 }
|
||||
}
|
||||
}
|
||||
const bbox = calcBBox()
|
||||
elem.getBBox = () => {
|
||||
return { ...bbox }
|
||||
}
|
||||
return elem
|
||||
}
|
||||
let mockaddSVGElementsFromJsonCallCount = 0
|
||||
|
||||
70
tests/unit/utilities-extra2.test.js
Normal file
70
tests/unit/utilities-extra2.test.js
Normal file
@@ -0,0 +1,70 @@
|
||||
import { describe, it, expect, beforeEach } from 'vitest'
|
||||
|
||||
import { init as initUnits } from '../../packages/svgcanvas/core/units.js'
|
||||
import {
|
||||
init as initUtilities,
|
||||
findDefs,
|
||||
assignAttributes,
|
||||
snapToGrid,
|
||||
getHref,
|
||||
setHref,
|
||||
dropXMLInternalSubset,
|
||||
encodeUTF8,
|
||||
decodeUTF8
|
||||
} from '../../packages/svgcanvas/core/utilities.js'
|
||||
|
||||
describe('utilities extra coverage', () => {
|
||||
let svg
|
||||
|
||||
beforeEach(() => {
|
||||
svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg')
|
||||
document.body.innerHTML = ''
|
||||
document.body.append(svg)
|
||||
|
||||
// Initialize units and utilities with a minimal canvas/context stub
|
||||
initUnits({
|
||||
getBaseUnit: () => 'px',
|
||||
getWidth: () => 200,
|
||||
getHeight: () => 100,
|
||||
getRoundDigits: () => 2
|
||||
})
|
||||
initUtilities({
|
||||
getSvgRoot: () => svg,
|
||||
getSvgContent: () => svg,
|
||||
getDOMDocument: () => document,
|
||||
getDOMContainer: () => svg,
|
||||
getBaseUnit: () => 'cm',
|
||||
getSnappingStep: () => 0.5
|
||||
})
|
||||
})
|
||||
|
||||
it('creates defs and removes namespaced attributes via assignAttributes', () => {
|
||||
const defs = findDefs()
|
||||
expect(defs.tagName).toBe('defs')
|
||||
expect(svg.querySelectorAll('defs').length).toBe(1)
|
||||
|
||||
const rect = document.createElementNS('http://www.w3.org/2000/svg', 'rect')
|
||||
rect.setAttributeNS('http://www.w3.org/XML/1998/namespace', 'xml:space', 'preserve')
|
||||
assignAttributes(rect, { width: '10', height: '5', 'xml:space': undefined }, 0, true)
|
||||
expect(rect.getAttribute('width')).toBe('10')
|
||||
expect(rect.getAttribute('height')).toBe('5')
|
||||
})
|
||||
|
||||
it('snaps to grid with unit conversion and handles href helpers', () => {
|
||||
const value = snapToGrid(2.3)
|
||||
expect(value).toBe(0)
|
||||
|
||||
const use = document.createElementNS('http://www.w3.org/2000/svg', 'use')
|
||||
setHref(use, '#ref')
|
||||
expect(getHref(use)).toBe('#ref')
|
||||
})
|
||||
|
||||
it('drops XML internal subsets and round trips UTF8 helpers', () => {
|
||||
const doc = '<!DOCTYPE svg [<!ENTITY test "x">]><svg/>'
|
||||
expect(dropXMLInternalSubset(doc)).toContain('<!DOCTYPE svg')
|
||||
|
||||
const mixed = 'äöü & < >'
|
||||
const encoded = encodeUTF8(mixed)
|
||||
expect(decodeUTF8(encoded)).toBe(mixed)
|
||||
})
|
||||
})
|
||||
@@ -12,6 +12,39 @@ describe('utilities', function () {
|
||||
Object.entries(jsonMap.attr).forEach(([attr, value]) => {
|
||||
elem.setAttribute(attr, value)
|
||||
})
|
||||
const numFromAttr = (attr, fallback = 0) => Number(jsonMap.attr[attr] ?? fallback)
|
||||
const calcBBox = () => {
|
||||
const tag = (jsonMap.element || '').toLowerCase()
|
||||
switch (tag) {
|
||||
case 'path': {
|
||||
const d = jsonMap.attr.d || ''
|
||||
const nums = (d.match(/-?\\d*\\.?\\d+/g) || []).map(Number)
|
||||
if (nums.length >= 4) {
|
||||
const xs = nums.filter((_, i) => i % 2 === 0)
|
||||
const ys = nums.filter((_, i) => i % 2 === 1)
|
||||
return {
|
||||
x: Math.min(...xs),
|
||||
y: Math.min(...ys),
|
||||
width: Math.max(...xs) - Math.min(...xs),
|
||||
height: Math.max(...ys) - Math.min(...ys)
|
||||
}
|
||||
}
|
||||
return { x: 0, y: 0, width: 0, height: 0 }
|
||||
}
|
||||
case 'rect':
|
||||
return { x: numFromAttr('x'), y: numFromAttr('y'), width: numFromAttr('width'), height: numFromAttr('height') }
|
||||
case 'line': {
|
||||
const x1 = numFromAttr('x1'); const x2 = numFromAttr('x2'); const y1 = numFromAttr('y1'); const y2 = numFromAttr('y2')
|
||||
return { x: Math.min(x1, x2), y: Math.min(y1, y2), width: Math.abs(x2 - x1), height: Math.abs(y2 - y1) }
|
||||
}
|
||||
default:
|
||||
return { x: 0, y: 0, width: 0, height: 0 }
|
||||
}
|
||||
}
|
||||
const bbox = calcBBox()
|
||||
elem.getBBox = () => {
|
||||
return { ...bbox }
|
||||
}
|
||||
return elem
|
||||
}
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user