mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-04-30 11:11:53 +00:00
fully refactor mask editor into vue-based (#6629)
## Summary
This PR refactors the mask editor from a vanilla JavaScript
implementation to Vue 3 + Composition API, aligning it with the ComfyUI
frontend's modern architecture. This is a structural refactor without UI
changes - all visual appearances and user interactions remain identical.
Net change: +1,700 lines (mostly tests)
## Changes
- Converted from class-based managers to Vue 3 Composition API
- Migrated state management to Pinia stores (maskEditorStore,
maskEditorDataStore)
- Split monolithic managers into focused composables:
- useBrushDrawing - Brush rendering and drawing logic
- useCanvasManager - Canvas lifecycle and operations
- useCanvasTools - Tool-specific canvas operations
- usePanAndZoom - Pan and zoom functionality
- useToolManager - Tool selection and coordination
- useKeyboard - Keyboard shortcuts
- useMaskEditorLoader/Saver - Data loading and saving
- useCoordinateTransform - Coordinate system transformations
- Replaced imperative DOM manipulation with Vue components
- Added comprehensive test coverage
## What This PR Does NOT Change
Preserved Original Styling:
- Original CSS retained in packages/design-system/src/css/style.css
- Some generic controls (DropdownControl, SliderControl, ToggleControl)
preserved as-is
- Future migration to Tailwind and PrimeVue components is planned but
out of scope for this PR
Preserved Core Functionality:
- Drawing algorithms and brush rendering logic remain unchanged
- Pan/zoom calculations preserved
- Canvas operations (composite modes, image processing) unchanged
- Tool behaviors (brush, color select, paint bucket) identical
- No changes to mask generation or export logic
DO NOT Review:
- CSS styling choices (preserved from original)
- Drawing algorithm implementations (unchanged)
- Canvas rendering logic (ported as-is)
- UI/UX changes (none exist)
- Component library choices (future work)
┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-6629-fully-refactor-mask-editor-into-vue-based-2a46d73d36508114ab8bd2984b4b54e4)
by [Unito](https://www.unito.io)
This commit is contained in:
495
tests-ui/tests/composables/maskeditor/useCanvasHistory.test.ts
Normal file
495
tests-ui/tests/composables/maskeditor/useCanvasHistory.test.ts
Normal file
@@ -0,0 +1,495 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { nextTick } from 'vue'
|
||||
import { useCanvasHistory } from '@/composables/maskeditor/useCanvasHistory'
|
||||
|
||||
let mockMaskCanvas: any
|
||||
let mockRgbCanvas: any
|
||||
let mockMaskCtx: any
|
||||
let mockRgbCtx: any
|
||||
|
||||
const mockStore = {
|
||||
maskCanvas: null as any,
|
||||
rgbCanvas: null as any,
|
||||
maskCtx: null as any,
|
||||
rgbCtx: null as any
|
||||
}
|
||||
|
||||
vi.mock('@/stores/maskEditorStore', () => ({
|
||||
useMaskEditorStore: vi.fn(() => mockStore)
|
||||
}))
|
||||
|
||||
describe('useCanvasHistory', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
let rafCallCount = 0
|
||||
vi.spyOn(window, 'requestAnimationFrame').mockImplementation(
|
||||
(cb: FrameRequestCallback) => {
|
||||
if (rafCallCount++ < 100) {
|
||||
setTimeout(() => cb(0), 0)
|
||||
}
|
||||
return rafCallCount
|
||||
}
|
||||
)
|
||||
vi.spyOn(window, 'alert').mockImplementation(() => {})
|
||||
|
||||
const createMockImageData = () => {
|
||||
return {
|
||||
data: new Uint8ClampedArray(100 * 100 * 4),
|
||||
width: 100,
|
||||
height: 100
|
||||
} as ImageData
|
||||
}
|
||||
|
||||
mockMaskCtx = {
|
||||
getImageData: vi.fn(() => createMockImageData()),
|
||||
putImageData: vi.fn()
|
||||
}
|
||||
|
||||
mockRgbCtx = {
|
||||
getImageData: vi.fn(() => createMockImageData()),
|
||||
putImageData: vi.fn()
|
||||
}
|
||||
|
||||
mockMaskCanvas = {
|
||||
width: 100,
|
||||
height: 100
|
||||
}
|
||||
|
||||
mockRgbCanvas = {
|
||||
width: 100,
|
||||
height: 100
|
||||
}
|
||||
|
||||
mockStore.maskCanvas = mockMaskCanvas
|
||||
mockStore.rgbCanvas = mockRgbCanvas
|
||||
mockStore.maskCtx = mockMaskCtx
|
||||
mockStore.rgbCtx = mockRgbCtx
|
||||
})
|
||||
|
||||
describe('initialization', () => {
|
||||
it('should initialize with default values', () => {
|
||||
const history = useCanvasHistory()
|
||||
|
||||
expect(history.canUndo.value).toBe(false)
|
||||
expect(history.canRedo.value).toBe(false)
|
||||
})
|
||||
|
||||
it('should save initial state', () => {
|
||||
const history = useCanvasHistory()
|
||||
|
||||
history.saveInitialState()
|
||||
|
||||
expect(mockMaskCtx.getImageData).toHaveBeenCalledWith(0, 0, 100, 100)
|
||||
expect(mockRgbCtx.getImageData).toHaveBeenCalledWith(0, 0, 100, 100)
|
||||
expect(history.canUndo.value).toBe(false)
|
||||
expect(history.canRedo.value).toBe(false)
|
||||
})
|
||||
|
||||
it('should wait for canvas to be ready', () => {
|
||||
const rafSpy = vi.spyOn(window, 'requestAnimationFrame')
|
||||
|
||||
mockStore.maskCanvas = { ...mockMaskCanvas, width: 0, height: 0 }
|
||||
|
||||
const history = useCanvasHistory()
|
||||
history.saveInitialState()
|
||||
|
||||
expect(rafSpy).toHaveBeenCalled()
|
||||
|
||||
mockStore.maskCanvas = mockMaskCanvas
|
||||
})
|
||||
|
||||
it('should wait for context to be ready', () => {
|
||||
const rafSpy = vi.spyOn(window, 'requestAnimationFrame')
|
||||
|
||||
mockStore.maskCtx = null
|
||||
|
||||
const history = useCanvasHistory()
|
||||
history.saveInitialState()
|
||||
|
||||
expect(rafSpy).toHaveBeenCalled()
|
||||
|
||||
mockStore.maskCtx = mockMaskCtx
|
||||
})
|
||||
})
|
||||
|
||||
describe('saveState', () => {
|
||||
it('should save a new state', () => {
|
||||
const history = useCanvasHistory()
|
||||
|
||||
history.saveInitialState()
|
||||
mockMaskCtx.getImageData.mockClear()
|
||||
mockRgbCtx.getImageData.mockClear()
|
||||
|
||||
history.saveState()
|
||||
|
||||
expect(mockMaskCtx.getImageData).toHaveBeenCalledWith(0, 0, 100, 100)
|
||||
expect(mockRgbCtx.getImageData).toHaveBeenCalledWith(0, 0, 100, 100)
|
||||
expect(history.canUndo.value).toBe(true)
|
||||
})
|
||||
|
||||
it('should clear redo states when saving new state', () => {
|
||||
const history = useCanvasHistory()
|
||||
|
||||
history.saveInitialState()
|
||||
history.saveState()
|
||||
history.saveState()
|
||||
history.undo()
|
||||
|
||||
expect(history.canRedo.value).toBe(true)
|
||||
|
||||
history.saveState()
|
||||
|
||||
expect(history.canRedo.value).toBe(false)
|
||||
})
|
||||
|
||||
it('should respect maxStates limit', () => {
|
||||
const history = useCanvasHistory(3)
|
||||
|
||||
history.saveInitialState()
|
||||
history.saveState()
|
||||
history.saveState()
|
||||
history.saveState()
|
||||
history.saveState()
|
||||
|
||||
expect(history.canUndo.value).toBe(true)
|
||||
|
||||
let undoCount = 0
|
||||
while (history.canUndo.value && undoCount < 10) {
|
||||
history.undo()
|
||||
undoCount++
|
||||
}
|
||||
|
||||
expect(undoCount).toBe(2)
|
||||
})
|
||||
|
||||
it('should call saveInitialState if not initialized', () => {
|
||||
const history = useCanvasHistory()
|
||||
|
||||
history.saveState()
|
||||
|
||||
expect(mockMaskCtx.getImageData).toHaveBeenCalled()
|
||||
expect(mockRgbCtx.getImageData).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should not save state if context is missing', () => {
|
||||
const history = useCanvasHistory()
|
||||
|
||||
history.saveInitialState()
|
||||
|
||||
mockStore.maskCtx = null
|
||||
mockMaskCtx.getImageData.mockClear()
|
||||
mockRgbCtx.getImageData.mockClear()
|
||||
|
||||
history.saveState()
|
||||
|
||||
expect(mockMaskCtx.getImageData).not.toHaveBeenCalled()
|
||||
|
||||
mockStore.maskCtx = mockMaskCtx
|
||||
})
|
||||
})
|
||||
|
||||
describe('undo', () => {
|
||||
it('should undo to previous state', () => {
|
||||
const history = useCanvasHistory()
|
||||
|
||||
history.saveInitialState()
|
||||
history.saveState()
|
||||
|
||||
history.undo()
|
||||
|
||||
expect(mockMaskCtx.putImageData).toHaveBeenCalled()
|
||||
expect(mockRgbCtx.putImageData).toHaveBeenCalled()
|
||||
expect(history.canUndo.value).toBe(false)
|
||||
expect(history.canRedo.value).toBe(true)
|
||||
})
|
||||
|
||||
it('should show alert when no undo states available', () => {
|
||||
const alertSpy = vi.spyOn(window, 'alert')
|
||||
const history = useCanvasHistory()
|
||||
|
||||
history.saveInitialState()
|
||||
history.undo()
|
||||
|
||||
expect(alertSpy).toHaveBeenCalledWith('No more undo states available')
|
||||
})
|
||||
|
||||
it('should undo multiple times', () => {
|
||||
const history = useCanvasHistory()
|
||||
|
||||
history.saveInitialState()
|
||||
history.saveState()
|
||||
history.saveState()
|
||||
history.saveState()
|
||||
|
||||
history.undo()
|
||||
expect(history.canUndo.value).toBe(true)
|
||||
|
||||
history.undo()
|
||||
expect(history.canUndo.value).toBe(true)
|
||||
|
||||
history.undo()
|
||||
expect(history.canUndo.value).toBe(false)
|
||||
})
|
||||
|
||||
it('should not undo beyond first state', () => {
|
||||
const alertSpy = vi.spyOn(window, 'alert')
|
||||
const history = useCanvasHistory()
|
||||
|
||||
history.saveInitialState()
|
||||
history.saveState()
|
||||
|
||||
history.undo()
|
||||
history.undo()
|
||||
|
||||
expect(alertSpy).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('redo', () => {
|
||||
it('should redo to next state', () => {
|
||||
const history = useCanvasHistory()
|
||||
|
||||
history.saveInitialState()
|
||||
history.saveState()
|
||||
history.undo()
|
||||
|
||||
mockMaskCtx.putImageData.mockClear()
|
||||
mockRgbCtx.putImageData.mockClear()
|
||||
|
||||
history.redo()
|
||||
|
||||
expect(mockMaskCtx.putImageData).toHaveBeenCalled()
|
||||
expect(mockRgbCtx.putImageData).toHaveBeenCalled()
|
||||
expect(history.canRedo.value).toBe(false)
|
||||
expect(history.canUndo.value).toBe(true)
|
||||
})
|
||||
|
||||
it('should show alert when no redo states available', () => {
|
||||
const alertSpy = vi.spyOn(window, 'alert')
|
||||
const history = useCanvasHistory()
|
||||
|
||||
history.saveInitialState()
|
||||
history.redo()
|
||||
|
||||
expect(alertSpy).toHaveBeenCalledWith('No more redo states available')
|
||||
})
|
||||
|
||||
it('should redo multiple times', () => {
|
||||
const history = useCanvasHistory()
|
||||
|
||||
history.saveInitialState()
|
||||
history.saveState()
|
||||
history.saveState()
|
||||
history.saveState()
|
||||
|
||||
history.undo()
|
||||
history.undo()
|
||||
history.undo()
|
||||
|
||||
history.redo()
|
||||
expect(history.canRedo.value).toBe(true)
|
||||
|
||||
history.redo()
|
||||
expect(history.canRedo.value).toBe(true)
|
||||
|
||||
history.redo()
|
||||
expect(history.canRedo.value).toBe(false)
|
||||
})
|
||||
|
||||
it('should not redo beyond last state', () => {
|
||||
const alertSpy = vi.spyOn(window, 'alert')
|
||||
const history = useCanvasHistory()
|
||||
|
||||
history.saveInitialState()
|
||||
history.saveState()
|
||||
history.undo()
|
||||
|
||||
history.redo()
|
||||
history.redo()
|
||||
|
||||
expect(alertSpy).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('clearStates', () => {
|
||||
it('should clear all states', () => {
|
||||
const history = useCanvasHistory()
|
||||
|
||||
history.saveInitialState()
|
||||
history.saveState()
|
||||
history.saveState()
|
||||
|
||||
history.clearStates()
|
||||
|
||||
expect(history.canUndo.value).toBe(false)
|
||||
expect(history.canRedo.value).toBe(false)
|
||||
})
|
||||
|
||||
it('should allow saving initial state after clear', () => {
|
||||
const history = useCanvasHistory()
|
||||
|
||||
history.saveInitialState()
|
||||
history.clearStates()
|
||||
|
||||
mockMaskCtx.getImageData.mockClear()
|
||||
mockRgbCtx.getImageData.mockClear()
|
||||
|
||||
history.saveInitialState()
|
||||
|
||||
expect(mockMaskCtx.getImageData).toHaveBeenCalled()
|
||||
expect(mockRgbCtx.getImageData).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('canUndo computed', () => {
|
||||
it('should be false with no states', () => {
|
||||
const history = useCanvasHistory()
|
||||
|
||||
expect(history.canUndo.value).toBe(false)
|
||||
})
|
||||
|
||||
it('should be false with only initial state', () => {
|
||||
const history = useCanvasHistory()
|
||||
|
||||
history.saveInitialState()
|
||||
|
||||
expect(history.canUndo.value).toBe(false)
|
||||
})
|
||||
|
||||
it('should be true after saving a state', () => {
|
||||
const history = useCanvasHistory()
|
||||
|
||||
history.saveInitialState()
|
||||
history.saveState()
|
||||
|
||||
expect(history.canUndo.value).toBe(true)
|
||||
})
|
||||
|
||||
it('should be false after undoing to first state', () => {
|
||||
const history = useCanvasHistory()
|
||||
|
||||
history.saveInitialState()
|
||||
history.saveState()
|
||||
history.undo()
|
||||
|
||||
expect(history.canUndo.value).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('canRedo computed', () => {
|
||||
it('should be false with no undo', () => {
|
||||
const history = useCanvasHistory()
|
||||
|
||||
history.saveInitialState()
|
||||
history.saveState()
|
||||
|
||||
expect(history.canRedo.value).toBe(false)
|
||||
})
|
||||
|
||||
it('should be true after undo', () => {
|
||||
const history = useCanvasHistory()
|
||||
|
||||
history.saveInitialState()
|
||||
history.saveState()
|
||||
history.undo()
|
||||
|
||||
expect(history.canRedo.value).toBe(true)
|
||||
})
|
||||
|
||||
it('should be false after redo to last state', () => {
|
||||
const history = useCanvasHistory()
|
||||
|
||||
history.saveInitialState()
|
||||
history.saveState()
|
||||
history.undo()
|
||||
history.redo()
|
||||
|
||||
expect(history.canRedo.value).toBe(false)
|
||||
})
|
||||
|
||||
it('should be false after saving new state', () => {
|
||||
const history = useCanvasHistory()
|
||||
|
||||
history.saveInitialState()
|
||||
history.saveState()
|
||||
history.undo()
|
||||
|
||||
expect(history.canRedo.value).toBe(true)
|
||||
|
||||
history.saveState()
|
||||
|
||||
expect(history.canRedo.value).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('restoreState', () => {
|
||||
it('should not restore if context is missing', () => {
|
||||
const history = useCanvasHistory()
|
||||
|
||||
history.saveInitialState()
|
||||
history.saveState()
|
||||
|
||||
mockStore.maskCtx = null
|
||||
mockMaskCtx.putImageData.mockClear()
|
||||
mockRgbCtx.putImageData.mockClear()
|
||||
|
||||
history.undo()
|
||||
|
||||
expect(mockMaskCtx.putImageData).not.toHaveBeenCalled()
|
||||
|
||||
mockStore.maskCtx = mockMaskCtx
|
||||
})
|
||||
})
|
||||
|
||||
describe('edge cases', () => {
|
||||
it('should handle rapid state saves', async () => {
|
||||
const history = useCanvasHistory()
|
||||
|
||||
history.saveInitialState()
|
||||
|
||||
for (let i = 0; i < 10; i++) {
|
||||
history.saveState()
|
||||
await nextTick()
|
||||
}
|
||||
|
||||
expect(history.canUndo.value).toBe(true)
|
||||
})
|
||||
|
||||
it('should handle maxStates of 1', () => {
|
||||
const history = useCanvasHistory(1)
|
||||
|
||||
history.saveInitialState()
|
||||
history.saveState()
|
||||
|
||||
expect(history.canUndo.value).toBe(false)
|
||||
})
|
||||
|
||||
it('should handle undo/redo cycling', () => {
|
||||
const history = useCanvasHistory()
|
||||
|
||||
history.saveInitialState()
|
||||
history.saveState()
|
||||
history.saveState()
|
||||
|
||||
history.undo()
|
||||
history.redo()
|
||||
history.undo()
|
||||
history.redo()
|
||||
history.undo()
|
||||
|
||||
expect(history.canRedo.value).toBe(true)
|
||||
expect(history.canUndo.value).toBe(true)
|
||||
})
|
||||
|
||||
it('should handle zero-sized canvas', () => {
|
||||
mockMaskCanvas.width = 0
|
||||
mockMaskCanvas.height = 0
|
||||
|
||||
const history = useCanvasHistory()
|
||||
|
||||
history.saveInitialState()
|
||||
|
||||
expect(window.requestAnimationFrame).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
})
|
||||
336
tests-ui/tests/composables/maskeditor/useCanvasManager.test.ts
Normal file
336
tests-ui/tests/composables/maskeditor/useCanvasManager.test.ts
Normal file
@@ -0,0 +1,336 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { MaskBlendMode } from '@/extensions/core/maskeditor/types'
|
||||
import { useCanvasManager } from '@/composables/maskeditor/useCanvasManager'
|
||||
const mockStore = {
|
||||
imgCanvas: null as any,
|
||||
maskCanvas: null as any,
|
||||
rgbCanvas: null as any,
|
||||
imgCtx: null as any,
|
||||
maskCtx: null as any,
|
||||
rgbCtx: null as any,
|
||||
canvasBackground: null as any,
|
||||
maskColor: { r: 0, g: 0, b: 0 },
|
||||
maskBlendMode: MaskBlendMode.Black,
|
||||
maskOpacity: 0.8
|
||||
}
|
||||
|
||||
vi.mock('@/stores/maskEditorStore', () => ({
|
||||
useMaskEditorStore: vi.fn(() => mockStore)
|
||||
}))
|
||||
|
||||
function createMockImage(width: number, height: number): HTMLImageElement {
|
||||
return {
|
||||
width,
|
||||
height
|
||||
} as HTMLImageElement
|
||||
}
|
||||
|
||||
describe('useCanvasManager', () => {
|
||||
let mockImageData: ImageData
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
|
||||
mockImageData = {
|
||||
data: new Uint8ClampedArray(100 * 100 * 4),
|
||||
width: 100,
|
||||
height: 100
|
||||
} as ImageData
|
||||
|
||||
mockStore.imgCtx = {
|
||||
drawImage: vi.fn()
|
||||
}
|
||||
|
||||
mockStore.maskCtx = {
|
||||
drawImage: vi.fn(),
|
||||
getImageData: vi.fn(() => mockImageData),
|
||||
putImageData: vi.fn(),
|
||||
globalCompositeOperation: 'source-over',
|
||||
fillStyle: ''
|
||||
}
|
||||
|
||||
mockStore.rgbCtx = {
|
||||
drawImage: vi.fn()
|
||||
}
|
||||
|
||||
mockStore.imgCanvas = {
|
||||
width: 0,
|
||||
height: 0
|
||||
}
|
||||
|
||||
mockStore.maskCanvas = {
|
||||
width: 0,
|
||||
height: 0,
|
||||
style: {
|
||||
mixBlendMode: '',
|
||||
opacity: ''
|
||||
}
|
||||
}
|
||||
|
||||
mockStore.rgbCanvas = {
|
||||
width: 0,
|
||||
height: 0
|
||||
}
|
||||
|
||||
mockStore.canvasBackground = {
|
||||
style: {
|
||||
backgroundColor: ''
|
||||
}
|
||||
}
|
||||
|
||||
mockStore.maskColor = { r: 0, g: 0, b: 0 }
|
||||
mockStore.maskBlendMode = MaskBlendMode.Black
|
||||
mockStore.maskOpacity = 0.8
|
||||
})
|
||||
|
||||
describe('invalidateCanvas', () => {
|
||||
it('should set canvas dimensions', async () => {
|
||||
const manager = useCanvasManager()
|
||||
|
||||
const origImage = createMockImage(512, 512)
|
||||
const maskImage = createMockImage(512, 512)
|
||||
|
||||
await manager.invalidateCanvas(origImage, maskImage, null)
|
||||
|
||||
expect(mockStore.imgCanvas.width).toBe(512)
|
||||
expect(mockStore.imgCanvas.height).toBe(512)
|
||||
expect(mockStore.maskCanvas.width).toBe(512)
|
||||
expect(mockStore.maskCanvas.height).toBe(512)
|
||||
expect(mockStore.rgbCanvas.width).toBe(512)
|
||||
expect(mockStore.rgbCanvas.height).toBe(512)
|
||||
})
|
||||
|
||||
it('should draw original image', async () => {
|
||||
const manager = useCanvasManager()
|
||||
|
||||
const origImage = createMockImage(512, 512)
|
||||
const maskImage = createMockImage(512, 512)
|
||||
|
||||
await manager.invalidateCanvas(origImage, maskImage, null)
|
||||
|
||||
expect(mockStore.imgCtx.drawImage).toHaveBeenCalledWith(
|
||||
origImage,
|
||||
0,
|
||||
0,
|
||||
512,
|
||||
512
|
||||
)
|
||||
})
|
||||
|
||||
it('should draw paint image when provided', async () => {
|
||||
const manager = useCanvasManager()
|
||||
|
||||
const origImage = createMockImage(512, 512)
|
||||
const maskImage = createMockImage(512, 512)
|
||||
const paintImage = createMockImage(512, 512)
|
||||
|
||||
await manager.invalidateCanvas(origImage, maskImage, paintImage)
|
||||
|
||||
expect(mockStore.rgbCtx.drawImage).toHaveBeenCalledWith(
|
||||
paintImage,
|
||||
0,
|
||||
0,
|
||||
512,
|
||||
512
|
||||
)
|
||||
})
|
||||
|
||||
it('should not draw paint image when null', async () => {
|
||||
const manager = useCanvasManager()
|
||||
|
||||
const origImage = createMockImage(512, 512)
|
||||
const maskImage = createMockImage(512, 512)
|
||||
|
||||
await manager.invalidateCanvas(origImage, maskImage, null)
|
||||
|
||||
expect(mockStore.rgbCtx.drawImage).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should prepare mask', async () => {
|
||||
const manager = useCanvasManager()
|
||||
|
||||
const origImage = createMockImage(512, 512)
|
||||
const maskImage = createMockImage(512, 512)
|
||||
|
||||
await manager.invalidateCanvas(origImage, maskImage, null)
|
||||
|
||||
expect(mockStore.maskCtx.drawImage).toHaveBeenCalled()
|
||||
expect(mockStore.maskCtx.getImageData).toHaveBeenCalled()
|
||||
expect(mockStore.maskCtx.putImageData).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should throw error when canvas missing', async () => {
|
||||
const manager = useCanvasManager()
|
||||
|
||||
mockStore.imgCanvas = null
|
||||
|
||||
const origImage = createMockImage(512, 512)
|
||||
const maskImage = createMockImage(512, 512)
|
||||
|
||||
await expect(
|
||||
manager.invalidateCanvas(origImage, maskImage, null)
|
||||
).rejects.toThrow('Canvas elements or contexts not available')
|
||||
})
|
||||
|
||||
it('should throw error when context missing', async () => {
|
||||
const manager = useCanvasManager()
|
||||
|
||||
mockStore.imgCtx = null
|
||||
|
||||
const origImage = createMockImage(512, 512)
|
||||
const maskImage = createMockImage(512, 512)
|
||||
|
||||
await expect(
|
||||
manager.invalidateCanvas(origImage, maskImage, null)
|
||||
).rejects.toThrow('Canvas elements or contexts not available')
|
||||
})
|
||||
})
|
||||
|
||||
describe('updateMaskColor', () => {
|
||||
it('should update mask color for black blend mode', async () => {
|
||||
const manager = useCanvasManager()
|
||||
|
||||
mockStore.maskBlendMode = MaskBlendMode.Black
|
||||
mockStore.maskColor = { r: 0, g: 0, b: 0 }
|
||||
|
||||
await manager.updateMaskColor()
|
||||
|
||||
expect(mockStore.maskCtx.fillStyle).toBe('rgb(0, 0, 0)')
|
||||
expect(mockStore.maskCanvas.style.mixBlendMode).toBe('initial')
|
||||
expect(mockStore.maskCanvas.style.opacity).toBe('0.8')
|
||||
expect(mockStore.canvasBackground.style.backgroundColor).toBe(
|
||||
'rgba(0,0,0,1)'
|
||||
)
|
||||
})
|
||||
|
||||
it('should update mask color for white blend mode', async () => {
|
||||
const manager = useCanvasManager()
|
||||
|
||||
mockStore.maskBlendMode = MaskBlendMode.White
|
||||
mockStore.maskColor = { r: 255, g: 255, b: 255 }
|
||||
|
||||
await manager.updateMaskColor()
|
||||
|
||||
expect(mockStore.maskCtx.fillStyle).toBe('rgb(255, 255, 255)')
|
||||
expect(mockStore.maskCanvas.style.mixBlendMode).toBe('initial')
|
||||
expect(mockStore.canvasBackground.style.backgroundColor).toBe(
|
||||
'rgba(255,255,255,1)'
|
||||
)
|
||||
})
|
||||
|
||||
it('should update mask color for negative blend mode', async () => {
|
||||
const manager = useCanvasManager()
|
||||
|
||||
mockStore.maskBlendMode = MaskBlendMode.Negative
|
||||
mockStore.maskColor = { r: 255, g: 255, b: 255 }
|
||||
|
||||
await manager.updateMaskColor()
|
||||
|
||||
expect(mockStore.maskCanvas.style.mixBlendMode).toBe('difference')
|
||||
expect(mockStore.maskCanvas.style.opacity).toBe('1')
|
||||
expect(mockStore.canvasBackground.style.backgroundColor).toBe(
|
||||
'rgba(255,255,255,1)'
|
||||
)
|
||||
})
|
||||
|
||||
it('should update all pixels with mask color', async () => {
|
||||
const manager = useCanvasManager()
|
||||
|
||||
mockStore.maskColor = { r: 128, g: 64, b: 32 }
|
||||
mockStore.maskCanvas.width = 100
|
||||
mockStore.maskCanvas.height = 100
|
||||
|
||||
await manager.updateMaskColor()
|
||||
|
||||
for (let i = 0; i < mockImageData.data.length; i += 4) {
|
||||
expect(mockImageData.data[i]).toBe(128)
|
||||
expect(mockImageData.data[i + 1]).toBe(64)
|
||||
expect(mockImageData.data[i + 2]).toBe(32)
|
||||
}
|
||||
|
||||
expect(mockStore.maskCtx.putImageData).toHaveBeenCalledWith(
|
||||
mockImageData,
|
||||
0,
|
||||
0
|
||||
)
|
||||
})
|
||||
|
||||
it('should return early when canvas missing', async () => {
|
||||
const manager = useCanvasManager()
|
||||
|
||||
mockStore.maskCanvas = null
|
||||
|
||||
await manager.updateMaskColor()
|
||||
|
||||
expect(mockStore.maskCtx.getImageData).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should return early when context missing', async () => {
|
||||
const manager = useCanvasManager()
|
||||
|
||||
mockStore.maskCtx = null
|
||||
|
||||
await manager.updateMaskColor()
|
||||
|
||||
expect(mockStore.canvasBackground.style.backgroundColor).toBe('')
|
||||
})
|
||||
|
||||
it('should handle different opacity values', async () => {
|
||||
const manager = useCanvasManager()
|
||||
|
||||
mockStore.maskOpacity = 0.5
|
||||
|
||||
await manager.updateMaskColor()
|
||||
|
||||
expect(mockStore.maskCanvas.style.opacity).toBe('0.5')
|
||||
})
|
||||
})
|
||||
|
||||
describe('prepareMask', () => {
|
||||
it('should invert mask alpha', async () => {
|
||||
const manager = useCanvasManager()
|
||||
|
||||
for (let i = 0; i < mockImageData.data.length; i += 4) {
|
||||
mockImageData.data[i + 3] = 128
|
||||
}
|
||||
|
||||
const origImage = createMockImage(100, 100)
|
||||
const maskImage = createMockImage(100, 100)
|
||||
|
||||
await manager.invalidateCanvas(origImage, maskImage, null)
|
||||
|
||||
for (let i = 0; i < mockImageData.data.length; i += 4) {
|
||||
expect(mockImageData.data[i + 3]).toBe(127)
|
||||
}
|
||||
})
|
||||
|
||||
it('should apply mask color to all pixels', async () => {
|
||||
const manager = useCanvasManager()
|
||||
|
||||
mockStore.maskColor = { r: 100, g: 150, b: 200 }
|
||||
|
||||
const origImage = createMockImage(100, 100)
|
||||
const maskImage = createMockImage(100, 100)
|
||||
|
||||
await manager.invalidateCanvas(origImage, maskImage, null)
|
||||
|
||||
for (let i = 0; i < mockImageData.data.length; i += 4) {
|
||||
expect(mockImageData.data[i]).toBe(100)
|
||||
expect(mockImageData.data[i + 1]).toBe(150)
|
||||
expect(mockImageData.data[i + 2]).toBe(200)
|
||||
}
|
||||
})
|
||||
|
||||
it('should set composite operation', async () => {
|
||||
const manager = useCanvasManager()
|
||||
|
||||
const origImage = createMockImage(100, 100)
|
||||
const maskImage = createMockImage(100, 100)
|
||||
|
||||
await manager.invalidateCanvas(origImage, maskImage, null)
|
||||
|
||||
expect(mockStore.maskCtx.globalCompositeOperation).toBe('source-over')
|
||||
})
|
||||
})
|
||||
})
|
||||
480
tests-ui/tests/composables/maskeditor/useCanvasTools.test.ts
Normal file
480
tests-ui/tests/composables/maskeditor/useCanvasTools.test.ts
Normal file
@@ -0,0 +1,480 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { ColorComparisonMethod } from '@/extensions/core/maskeditor/types'
|
||||
|
||||
import { useCanvasTools } from '@/composables/maskeditor/useCanvasTools'
|
||||
|
||||
const mockCanvasHistory = {
|
||||
saveState: vi.fn()
|
||||
}
|
||||
|
||||
const mockStore = {
|
||||
maskCtx: null as any,
|
||||
imgCtx: null as any,
|
||||
maskCanvas: null as any,
|
||||
imgCanvas: null as any,
|
||||
rgbCtx: null as any,
|
||||
rgbCanvas: null as any,
|
||||
maskColor: { r: 255, g: 255, b: 255 },
|
||||
paintBucketTolerance: 10,
|
||||
fillOpacity: 100,
|
||||
colorSelectTolerance: 30,
|
||||
colorComparisonMethod: ColorComparisonMethod.Simple,
|
||||
selectionOpacity: 100,
|
||||
applyWholeImage: false,
|
||||
maskBoundary: false,
|
||||
maskTolerance: 10,
|
||||
canvasHistory: mockCanvasHistory
|
||||
}
|
||||
|
||||
vi.mock('@/stores/maskEditorStore', () => ({
|
||||
useMaskEditorStore: vi.fn(() => mockStore)
|
||||
}))
|
||||
|
||||
describe('useCanvasTools', () => {
|
||||
let mockMaskImageData: ImageData
|
||||
let mockImgImageData: ImageData
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
|
||||
mockMaskImageData = {
|
||||
data: new Uint8ClampedArray(100 * 100 * 4),
|
||||
width: 100,
|
||||
height: 100
|
||||
} as ImageData
|
||||
|
||||
mockImgImageData = {
|
||||
data: new Uint8ClampedArray(100 * 100 * 4),
|
||||
width: 100,
|
||||
height: 100
|
||||
} as ImageData
|
||||
|
||||
for (let i = 0; i < mockImgImageData.data.length; i += 4) {
|
||||
mockImgImageData.data[i] = 255
|
||||
mockImgImageData.data[i + 1] = 0
|
||||
mockImgImageData.data[i + 2] = 0
|
||||
mockImgImageData.data[i + 3] = 255
|
||||
}
|
||||
|
||||
mockStore.maskCtx = {
|
||||
getImageData: vi.fn(() => mockMaskImageData),
|
||||
putImageData: vi.fn(),
|
||||
clearRect: vi.fn()
|
||||
}
|
||||
|
||||
mockStore.imgCtx = {
|
||||
getImageData: vi.fn(() => mockImgImageData)
|
||||
}
|
||||
|
||||
mockStore.rgbCtx = {
|
||||
clearRect: vi.fn()
|
||||
}
|
||||
|
||||
mockStore.maskCanvas = {
|
||||
width: 100,
|
||||
height: 100
|
||||
}
|
||||
|
||||
mockStore.imgCanvas = {
|
||||
width: 100,
|
||||
height: 100
|
||||
}
|
||||
|
||||
mockStore.rgbCanvas = {
|
||||
width: 100,
|
||||
height: 100
|
||||
}
|
||||
|
||||
mockStore.maskColor = { r: 255, g: 255, b: 255 }
|
||||
mockStore.paintBucketTolerance = 10
|
||||
mockStore.fillOpacity = 100
|
||||
mockStore.colorSelectTolerance = 30
|
||||
mockStore.colorComparisonMethod = ColorComparisonMethod.Simple
|
||||
mockStore.selectionOpacity = 100
|
||||
mockStore.applyWholeImage = false
|
||||
mockStore.maskBoundary = false
|
||||
mockStore.maskTolerance = 10
|
||||
})
|
||||
|
||||
describe('paintBucketFill', () => {
|
||||
it('should fill empty area with mask color', () => {
|
||||
const tools = useCanvasTools()
|
||||
|
||||
tools.paintBucketFill({ x: 50, y: 50 })
|
||||
|
||||
expect(mockStore.maskCtx.getImageData).toHaveBeenCalledWith(
|
||||
0,
|
||||
0,
|
||||
100,
|
||||
100
|
||||
)
|
||||
expect(mockStore.maskCtx.putImageData).toHaveBeenCalledWith(
|
||||
mockMaskImageData,
|
||||
0,
|
||||
0
|
||||
)
|
||||
expect(mockCanvasHistory.saveState).toHaveBeenCalled()
|
||||
|
||||
const index = (50 * 100 + 50) * 4
|
||||
expect(mockMaskImageData.data[index + 3]).toBe(255)
|
||||
})
|
||||
|
||||
it('should erase filled area', () => {
|
||||
for (let i = 0; i < mockMaskImageData.data.length; i += 4) {
|
||||
mockMaskImageData.data[i + 3] = 255
|
||||
}
|
||||
|
||||
const tools = useCanvasTools()
|
||||
|
||||
tools.paintBucketFill({ x: 50, y: 50 })
|
||||
|
||||
const index = (50 * 100 + 50) * 4
|
||||
expect(mockMaskImageData.data[index + 3]).toBe(0)
|
||||
})
|
||||
|
||||
it('should respect tolerance', () => {
|
||||
mockStore.paintBucketTolerance = 0
|
||||
|
||||
for (let i = 0; i < mockMaskImageData.data.length; i += 4) {
|
||||
mockMaskImageData.data[i + 3] = 0
|
||||
}
|
||||
const centerIndex = (50 * 100 + 50) * 4
|
||||
mockMaskImageData.data[centerIndex + 3] = 10
|
||||
|
||||
const tools = useCanvasTools()
|
||||
|
||||
tools.paintBucketFill({ x: 51, y: 50 })
|
||||
|
||||
expect(mockMaskImageData.data[centerIndex + 3]).toBe(10)
|
||||
})
|
||||
|
||||
it('should return early when point out of bounds', () => {
|
||||
const tools = useCanvasTools()
|
||||
|
||||
tools.paintBucketFill({ x: -1, y: 50 })
|
||||
|
||||
expect(mockStore.maskCtx.putImageData).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should return early when canvas missing', () => {
|
||||
mockStore.maskCanvas = null
|
||||
|
||||
const tools = useCanvasTools()
|
||||
|
||||
tools.paintBucketFill({ x: 50, y: 50 })
|
||||
|
||||
expect(mockStore.maskCtx.getImageData).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should apply fill opacity', () => {
|
||||
mockStore.fillOpacity = 50
|
||||
|
||||
const tools = useCanvasTools()
|
||||
|
||||
tools.paintBucketFill({ x: 50, y: 50 })
|
||||
|
||||
const index = (50 * 100 + 50) * 4
|
||||
expect(mockMaskImageData.data[index + 3]).toBe(127)
|
||||
})
|
||||
|
||||
it('should apply mask color', () => {
|
||||
mockStore.maskColor = { r: 128, g: 64, b: 32 }
|
||||
|
||||
const tools = useCanvasTools()
|
||||
|
||||
tools.paintBucketFill({ x: 50, y: 50 })
|
||||
|
||||
const index = (50 * 100 + 50) * 4
|
||||
expect(mockMaskImageData.data[index]).toBe(128)
|
||||
expect(mockMaskImageData.data[index + 1]).toBe(64)
|
||||
expect(mockMaskImageData.data[index + 2]).toBe(32)
|
||||
})
|
||||
})
|
||||
|
||||
describe('colorSelectFill', () => {
|
||||
it('should select pixels by color with flood fill', async () => {
|
||||
const tools = useCanvasTools()
|
||||
|
||||
await tools.colorSelectFill({ x: 50, y: 50 })
|
||||
|
||||
expect(mockStore.maskCtx.getImageData).toHaveBeenCalledWith(
|
||||
0,
|
||||
0,
|
||||
100,
|
||||
100
|
||||
)
|
||||
expect(mockStore.imgCtx.getImageData).toHaveBeenCalledWith(0, 0, 100, 100)
|
||||
expect(mockStore.maskCtx.putImageData).toHaveBeenCalled()
|
||||
expect(mockCanvasHistory.saveState).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should select pixels in whole image when applyWholeImage is true', async () => {
|
||||
mockStore.applyWholeImage = true
|
||||
|
||||
const tools = useCanvasTools()
|
||||
|
||||
await tools.colorSelectFill({ x: 50, y: 50 })
|
||||
|
||||
expect(mockStore.maskCtx.putImageData).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should respect color tolerance', async () => {
|
||||
mockStore.colorSelectTolerance = 0
|
||||
|
||||
for (let i = 0; i < mockImgImageData.data.length; i += 4) {
|
||||
mockImgImageData.data[i] = 200
|
||||
}
|
||||
|
||||
const tools = useCanvasTools()
|
||||
|
||||
await tools.colorSelectFill({ x: 50, y: 50 })
|
||||
|
||||
const index = (50 * 100 + 50) * 4
|
||||
expect(mockMaskImageData.data[index + 3]).toBe(255)
|
||||
})
|
||||
|
||||
it('should return early when point out of bounds', async () => {
|
||||
const tools = useCanvasTools()
|
||||
|
||||
await tools.colorSelectFill({ x: -1, y: 50 })
|
||||
|
||||
expect(mockStore.maskCtx.putImageData).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should return early when canvas missing', async () => {
|
||||
mockStore.imgCanvas = null
|
||||
|
||||
const tools = useCanvasTools()
|
||||
|
||||
await tools.colorSelectFill({ x: 50, y: 50 })
|
||||
|
||||
expect(mockStore.maskCtx.getImageData).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should apply selection opacity', async () => {
|
||||
mockStore.selectionOpacity = 50
|
||||
|
||||
const tools = useCanvasTools()
|
||||
|
||||
await tools.colorSelectFill({ x: 50, y: 50 })
|
||||
|
||||
const index = (50 * 100 + 50) * 4
|
||||
expect(mockMaskImageData.data[index + 3]).toBe(127)
|
||||
})
|
||||
|
||||
it('should use HSL color comparison method', async () => {
|
||||
mockStore.colorComparisonMethod = ColorComparisonMethod.HSL
|
||||
|
||||
const tools = useCanvasTools()
|
||||
|
||||
await tools.colorSelectFill({ x: 50, y: 50 })
|
||||
|
||||
expect(mockStore.maskCtx.putImageData).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should use LAB color comparison method', async () => {
|
||||
mockStore.colorComparisonMethod = ColorComparisonMethod.LAB
|
||||
|
||||
const tools = useCanvasTools()
|
||||
|
||||
await tools.colorSelectFill({ x: 50, y: 50 })
|
||||
|
||||
expect(mockStore.maskCtx.putImageData).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should respect mask boundary', async () => {
|
||||
mockStore.maskBoundary = true
|
||||
mockStore.maskTolerance = 0
|
||||
|
||||
for (let i = 0; i < mockMaskImageData.data.length; i += 4) {
|
||||
mockMaskImageData.data[i + 3] = 255
|
||||
}
|
||||
|
||||
const tools = useCanvasTools()
|
||||
|
||||
await tools.colorSelectFill({ x: 50, y: 50 })
|
||||
|
||||
expect(mockStore.maskCtx.putImageData).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should update last color select point', async () => {
|
||||
const tools = useCanvasTools()
|
||||
|
||||
await tools.colorSelectFill({ x: 30, y: 40 })
|
||||
|
||||
expect(mockStore.maskCtx.putImageData).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('invertMask', () => {
|
||||
it('should invert mask alpha values', () => {
|
||||
for (let i = 0; i < mockMaskImageData.data.length; i += 4) {
|
||||
mockMaskImageData.data[i] = 255
|
||||
mockMaskImageData.data[i + 1] = 255
|
||||
mockMaskImageData.data[i + 2] = 255
|
||||
mockMaskImageData.data[i + 3] = 128
|
||||
}
|
||||
|
||||
const tools = useCanvasTools()
|
||||
|
||||
tools.invertMask()
|
||||
|
||||
expect(mockStore.maskCtx.getImageData).toHaveBeenCalledWith(
|
||||
0,
|
||||
0,
|
||||
100,
|
||||
100
|
||||
)
|
||||
expect(mockStore.maskCtx.putImageData).toHaveBeenCalledWith(
|
||||
mockMaskImageData,
|
||||
0,
|
||||
0
|
||||
)
|
||||
expect(mockCanvasHistory.saveState).toHaveBeenCalled()
|
||||
|
||||
for (let i = 0; i < mockMaskImageData.data.length; i += 4) {
|
||||
expect(mockMaskImageData.data[i + 3]).toBe(127)
|
||||
}
|
||||
})
|
||||
|
||||
it('should preserve mask color for empty pixels', () => {
|
||||
for (let i = 0; i < mockMaskImageData.data.length; i += 4) {
|
||||
mockMaskImageData.data[i + 3] = 0
|
||||
}
|
||||
|
||||
const firstPixelIndex = 100
|
||||
mockMaskImageData.data[firstPixelIndex * 4] = 128
|
||||
mockMaskImageData.data[firstPixelIndex * 4 + 1] = 64
|
||||
mockMaskImageData.data[firstPixelIndex * 4 + 2] = 32
|
||||
mockMaskImageData.data[firstPixelIndex * 4 + 3] = 255
|
||||
|
||||
const tools = useCanvasTools()
|
||||
|
||||
tools.invertMask()
|
||||
|
||||
for (let i = 0; i < mockMaskImageData.data.length; i += 4) {
|
||||
if (i !== firstPixelIndex * 4) {
|
||||
expect(mockMaskImageData.data[i]).toBe(128)
|
||||
expect(mockMaskImageData.data[i + 1]).toBe(64)
|
||||
expect(mockMaskImageData.data[i + 2]).toBe(32)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
it('should return early when canvas missing', () => {
|
||||
mockStore.maskCanvas = null
|
||||
|
||||
const tools = useCanvasTools()
|
||||
|
||||
tools.invertMask()
|
||||
|
||||
expect(mockStore.maskCtx.getImageData).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should return early when context missing', () => {
|
||||
mockStore.maskCtx = null
|
||||
|
||||
const tools = useCanvasTools()
|
||||
|
||||
tools.invertMask()
|
||||
|
||||
expect(mockCanvasHistory.saveState).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('clearMask', () => {
|
||||
it('should clear mask canvas', () => {
|
||||
const tools = useCanvasTools()
|
||||
|
||||
tools.clearMask()
|
||||
|
||||
expect(mockStore.maskCtx.clearRect).toHaveBeenCalledWith(0, 0, 100, 100)
|
||||
expect(mockStore.rgbCtx.clearRect).toHaveBeenCalledWith(0, 0, 100, 100)
|
||||
expect(mockCanvasHistory.saveState).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should handle missing mask canvas', () => {
|
||||
mockStore.maskCanvas = null
|
||||
|
||||
const tools = useCanvasTools()
|
||||
|
||||
tools.clearMask()
|
||||
|
||||
expect(mockStore.maskCtx.clearRect).not.toHaveBeenCalled()
|
||||
expect(mockCanvasHistory.saveState).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should handle missing rgb canvas', () => {
|
||||
mockStore.rgbCanvas = null
|
||||
|
||||
const tools = useCanvasTools()
|
||||
|
||||
tools.clearMask()
|
||||
|
||||
expect(mockStore.maskCtx.clearRect).toHaveBeenCalledWith(0, 0, 100, 100)
|
||||
expect(mockStore.rgbCtx.clearRect).not.toHaveBeenCalled()
|
||||
expect(mockCanvasHistory.saveState).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('clearLastColorSelectPoint', () => {
|
||||
it('should clear last color select point', async () => {
|
||||
const tools = useCanvasTools()
|
||||
|
||||
await tools.colorSelectFill({ x: 50, y: 50 })
|
||||
|
||||
tools.clearLastColorSelectPoint()
|
||||
|
||||
expect(mockStore.maskCtx.putImageData).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('edge cases', () => {
|
||||
it('should handle small canvas', () => {
|
||||
mockStore.maskCanvas.width = 1
|
||||
mockStore.maskCanvas.height = 1
|
||||
mockMaskImageData = {
|
||||
data: new Uint8ClampedArray(1 * 1 * 4),
|
||||
width: 1,
|
||||
height: 1
|
||||
} as ImageData
|
||||
mockStore.maskCtx.getImageData = vi.fn(() => mockMaskImageData)
|
||||
|
||||
const tools = useCanvasTools()
|
||||
|
||||
tools.paintBucketFill({ x: 0, y: 0 })
|
||||
|
||||
expect(mockStore.maskCtx.putImageData).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should handle fractional coordinates', () => {
|
||||
const tools = useCanvasTools()
|
||||
|
||||
tools.paintBucketFill({ x: 50.7, y: 50.3 })
|
||||
|
||||
expect(mockStore.maskCtx.putImageData).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should handle maximum tolerance', () => {
|
||||
mockStore.paintBucketTolerance = 255
|
||||
|
||||
const tools = useCanvasTools()
|
||||
|
||||
tools.paintBucketFill({ x: 50, y: 50 })
|
||||
|
||||
expect(mockStore.maskCtx.putImageData).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should handle zero opacity', () => {
|
||||
mockStore.fillOpacity = 0
|
||||
|
||||
const tools = useCanvasTools()
|
||||
|
||||
tools.paintBucketFill({ x: 50, y: 50 })
|
||||
|
||||
const index = (50 * 100 + 50) * 4
|
||||
expect(mockMaskImageData.data[index + 3]).toBe(0)
|
||||
})
|
||||
})
|
||||
})
|
||||
197
tests-ui/tests/composables/maskeditor/useImageLoader.test.ts
Normal file
197
tests-ui/tests/composables/maskeditor/useImageLoader.test.ts
Normal file
@@ -0,0 +1,197 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { useImageLoader } from '@/composables/maskeditor/useImageLoader'
|
||||
|
||||
const mockCanvasManager = {
|
||||
invalidateCanvas: vi.fn().mockResolvedValue(undefined),
|
||||
updateMaskColor: vi.fn().mockResolvedValue(undefined)
|
||||
}
|
||||
|
||||
const mockStore = {
|
||||
imgCanvas: null as any,
|
||||
maskCanvas: null as any,
|
||||
rgbCanvas: null as any,
|
||||
imgCtx: null as any,
|
||||
maskCtx: null as any,
|
||||
image: null as any
|
||||
}
|
||||
|
||||
const mockDataStore = {
|
||||
inputData: null as any
|
||||
}
|
||||
|
||||
vi.mock('@/stores/maskEditorStore', () => ({
|
||||
useMaskEditorStore: vi.fn(() => mockStore)
|
||||
}))
|
||||
|
||||
vi.mock('@/stores/maskEditorDataStore', () => ({
|
||||
useMaskEditorDataStore: vi.fn(() => mockDataStore)
|
||||
}))
|
||||
|
||||
vi.mock('@/composables/maskeditor/useCanvasManager', () => ({
|
||||
useCanvasManager: vi.fn(() => mockCanvasManager)
|
||||
}))
|
||||
|
||||
vi.mock('@vueuse/core', () => ({
|
||||
createSharedComposable: (fn: any) => fn
|
||||
}))
|
||||
|
||||
describe('useImageLoader', () => {
|
||||
let mockBaseImage: HTMLImageElement
|
||||
let mockMaskImage: HTMLImageElement
|
||||
let mockPaintImage: HTMLImageElement
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
|
||||
mockBaseImage = {
|
||||
width: 512,
|
||||
height: 512
|
||||
} as HTMLImageElement
|
||||
|
||||
mockMaskImage = {
|
||||
width: 512,
|
||||
height: 512
|
||||
} as HTMLImageElement
|
||||
|
||||
mockPaintImage = {
|
||||
width: 512,
|
||||
height: 512
|
||||
} as HTMLImageElement
|
||||
|
||||
mockStore.imgCtx = {
|
||||
clearRect: vi.fn()
|
||||
}
|
||||
|
||||
mockStore.maskCtx = {
|
||||
clearRect: vi.fn()
|
||||
}
|
||||
|
||||
mockStore.imgCanvas = {
|
||||
width: 0,
|
||||
height: 0
|
||||
}
|
||||
|
||||
mockStore.maskCanvas = {
|
||||
width: 0,
|
||||
height: 0
|
||||
}
|
||||
|
||||
mockStore.rgbCanvas = {
|
||||
width: 0,
|
||||
height: 0
|
||||
}
|
||||
|
||||
mockDataStore.inputData = {
|
||||
baseLayer: { image: mockBaseImage },
|
||||
maskLayer: { image: mockMaskImage },
|
||||
paintLayer: { image: mockPaintImage }
|
||||
}
|
||||
})
|
||||
|
||||
describe('loadImages', () => {
|
||||
it('should load images successfully', async () => {
|
||||
const loader = useImageLoader()
|
||||
|
||||
const result = await loader.loadImages()
|
||||
|
||||
expect(result).toBe(mockBaseImage)
|
||||
expect(mockStore.image).toBe(mockBaseImage)
|
||||
})
|
||||
|
||||
it('should set canvas dimensions', async () => {
|
||||
const loader = useImageLoader()
|
||||
|
||||
await loader.loadImages()
|
||||
|
||||
expect(mockStore.maskCanvas.width).toBe(512)
|
||||
expect(mockStore.maskCanvas.height).toBe(512)
|
||||
expect(mockStore.rgbCanvas.width).toBe(512)
|
||||
expect(mockStore.rgbCanvas.height).toBe(512)
|
||||
})
|
||||
|
||||
it('should clear canvas contexts', async () => {
|
||||
const loader = useImageLoader()
|
||||
|
||||
await loader.loadImages()
|
||||
|
||||
expect(mockStore.imgCtx.clearRect).toHaveBeenCalledWith(0, 0, 0, 0)
|
||||
expect(mockStore.maskCtx.clearRect).toHaveBeenCalledWith(0, 0, 0, 0)
|
||||
})
|
||||
|
||||
it('should call canvasManager methods', async () => {
|
||||
const loader = useImageLoader()
|
||||
|
||||
await loader.loadImages()
|
||||
|
||||
expect(mockCanvasManager.invalidateCanvas).toHaveBeenCalledWith(
|
||||
mockBaseImage,
|
||||
mockMaskImage,
|
||||
mockPaintImage
|
||||
)
|
||||
expect(mockCanvasManager.updateMaskColor).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should handle missing paintLayer', async () => {
|
||||
mockDataStore.inputData = {
|
||||
baseLayer: { image: mockBaseImage },
|
||||
maskLayer: { image: mockMaskImage },
|
||||
paintLayer: null
|
||||
}
|
||||
|
||||
const loader = useImageLoader()
|
||||
|
||||
await loader.loadImages()
|
||||
|
||||
expect(mockCanvasManager.invalidateCanvas).toHaveBeenCalledWith(
|
||||
mockBaseImage,
|
||||
mockMaskImage,
|
||||
null
|
||||
)
|
||||
})
|
||||
|
||||
it('should throw error when no input data', async () => {
|
||||
mockDataStore.inputData = null
|
||||
|
||||
const loader = useImageLoader()
|
||||
|
||||
await expect(loader.loadImages()).rejects.toThrow(
|
||||
'No input data available in dataStore'
|
||||
)
|
||||
})
|
||||
|
||||
it('should throw error when canvas elements missing', async () => {
|
||||
mockStore.imgCanvas = null
|
||||
|
||||
const loader = useImageLoader()
|
||||
|
||||
await expect(loader.loadImages()).rejects.toThrow(
|
||||
'Canvas elements or contexts not available'
|
||||
)
|
||||
})
|
||||
|
||||
it('should throw error when contexts missing', async () => {
|
||||
mockStore.imgCtx = null
|
||||
|
||||
const loader = useImageLoader()
|
||||
|
||||
await expect(loader.loadImages()).rejects.toThrow(
|
||||
'Canvas elements or contexts not available'
|
||||
)
|
||||
})
|
||||
|
||||
it('should handle different image dimensions', async () => {
|
||||
mockBaseImage.width = 1024
|
||||
mockBaseImage.height = 768
|
||||
|
||||
const loader = useImageLoader()
|
||||
|
||||
await loader.loadImages()
|
||||
|
||||
expect(mockStore.maskCanvas.width).toBe(1024)
|
||||
expect(mockStore.maskCanvas.height).toBe(768)
|
||||
expect(mockStore.rgbCanvas.width).toBe(1024)
|
||||
expect(mockStore.rgbCanvas.height).toBe(768)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,19 +0,0 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { imageLayerFilenamesIfApplicable } from '@/extensions/core/maskeditor/utils/maskEditorLayerFilenames'
|
||||
|
||||
describe('imageLayerFilenamesIfApplicable', () => {
|
||||
// In case the naming scheme changes, this test will ensure CI fails if developers forget to support the old naming scheme. (Causing MaskEditor to lose layer data for previously-saved images.)
|
||||
it('should support all past layer naming schemes to preserve backward compatibility', async () => {
|
||||
const dummyTimestamp = 1234567890
|
||||
const inputToSupport = `clipspace-painted-masked-${dummyTimestamp}.png`
|
||||
const expectedOutput = {
|
||||
maskedImage: `clipspace-mask-${dummyTimestamp}.png`,
|
||||
paint: `clipspace-paint-${dummyTimestamp}.png`,
|
||||
paintedImage: `clipspace-painted-${dummyTimestamp}.png`,
|
||||
paintedMaskedImage: inputToSupport
|
||||
}
|
||||
const actualOutput = imageLayerFilenamesIfApplicable(inputToSupport)
|
||||
expect(actualOutput).toEqual(expectedOutput)
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user