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:
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)
|
||||
})
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user