mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-05-21 21:09:00 +00:00
## Summary
Add unit tests for `maskEditorStore` Pinia store, raising coverage from
0% to 100% (statements / branches / functions / lines).
## Changes
- **What**: Add `src/stores/maskEditorStore.test.ts` (30 tests)
covering:
- Brush setters: `setBrushSize` (1–250), `setBrushOpacity` (0–1),
`setBrushHardness` (0–1), `setBrushStepSize` (1–100) — each tested at
lower bound, upper bound, and in-range.
- `resetBrushToDefault`: documents the exact default brush shape.
- Other clamped setters: `setPaintBucketTolerance` /
`setColorSelectTolerance` / `setMaskTolerance` (0–255), `setFillOpacity`
/ `setSelectionOpacity` (0–100), `setMaskOpacity` (0–1), `setZoomRatio`
(0.1–10).
- `setPanOffset` / `setCursorPoint`: copy-by-value semantics — mutating
the input after the call must not leak into store state.
- `resetZoom` / `triggerClear`: monotonic counter bumps.
- `maskColor` computed: `Black`, `White`, `Negative` blend modes plus
the `default:` fallback for unknown values.
- `canUndo` / `canRedo` proxy through to mocked `useCanvasHistory`.
- Canvas → ctx watchers: setting `maskCanvas` / `rgbCanvas` /
`imgCanvas` derives the corresponding `*Ctx` via `getContext('2d', {
willReadFrequently: true })`. Clearing the canvas leaves the previous
ctx in place (parametrized via `it.each` for all three).
- `resetState`: restores all non-DOM state to documented defaults;
explicitly verifies DOM refs (`maskCanvas` / `pointerZone` / `image`)
are NOT cleared so the editor can reuse mounted elements after a reset.
## Review Focus
- `useCanvasHistory` is mocked via `vi.hoisted` so each test gets the
same exposed `canUndo` / `canRedo` refs while the store's internal
`canvasHistory` reference is untouched. Without this, the store would
call into the real history with `null` canvas refs.
- `setPanOffset` / `setCursorPoint` tests mutate the input *after* the
call — that's the actual behavioral contract (defensive copy via
spread), not a default-value check.
- `resetState` test sets *every* field to a non-default before calling,
so the test fails if `resetState` ever forgets to reset a field. Final
assertions are positive (matches default), not weak negative checks.
- The "DOM refs preserved on reset" assertion is the
surprising-on-purpose part: a future refactor that adds
`maskCanvas.value = null` to `resetState` would break the editor's
ability to reuse mounted canvases after clearing internal state.
- `it.each` for the three canvas/ctx pairs covers the watcher's null
branch without three near-duplicate tests.
- `makeCanvas` overrides `canvas.getContext` directly rather than using
`vi.spyOn` because `HTMLCanvasElement.getContext` has overloads (2d /
webgl / webgpu / bitmaprenderer) and TypeScript picks the GPU overload
by default for spy return type inference.
- Style aligned with sibling `maskEditorDataStore.test.ts`:
`createTestingPinia({ stubActions: false })`, `should ...` naming,
`describe` grouped by exposed property/action, no default-only
change-detector tests.
┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11645-test-add-unit-tests-for-maskEditorStore-34e6d73d3650818e9855cd9f9f13e62a)
by [Unito](https://www.unito.io)
377 lines
11 KiB
TypeScript
377 lines
11 KiB
TypeScript
import { createTestingPinia } from '@pinia/testing'
|
|
import { setActivePinia } from 'pinia'
|
|
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
|
import { nextTick } from 'vue'
|
|
|
|
import {
|
|
BrushShape,
|
|
ColorComparisonMethod,
|
|
MaskBlendMode,
|
|
Tools
|
|
} from '@/extensions/core/maskeditor/types'
|
|
|
|
import { useMaskEditorStore } from '@/stores/maskEditorStore'
|
|
|
|
const mockHistory = vi.hoisted(() => ({
|
|
canUndo: { value: false },
|
|
canRedo: { value: false }
|
|
}))
|
|
|
|
vi.mock('@/composables/maskeditor/useCanvasHistory', () => ({
|
|
useCanvasHistory: vi.fn(() => mockHistory)
|
|
}))
|
|
|
|
const makeCanvas = (): HTMLCanvasElement => {
|
|
const canvas = document.createElement('canvas')
|
|
canvas.getContext = vi
|
|
.fn()
|
|
.mockReturnValue({ fake: true }) as HTMLCanvasElement['getContext']
|
|
return canvas
|
|
}
|
|
|
|
describe('maskEditorStore', () => {
|
|
beforeEach(() => {
|
|
setActivePinia(createTestingPinia({ stubActions: false }))
|
|
mockHistory.canUndo.value = false
|
|
mockHistory.canRedo.value = false
|
|
})
|
|
|
|
describe('brush setters', () => {
|
|
it('should clamp brush size between 1 and 250', () => {
|
|
const store = useMaskEditorStore()
|
|
|
|
store.setBrushSize(0)
|
|
expect(store.brushSettings.size).toBe(1)
|
|
|
|
store.setBrushSize(500)
|
|
expect(store.brushSettings.size).toBe(250)
|
|
|
|
store.setBrushSize(42)
|
|
expect(store.brushSettings.size).toBe(42)
|
|
})
|
|
|
|
it('should clamp brush opacity between 0 and 1', () => {
|
|
const store = useMaskEditorStore()
|
|
|
|
store.setBrushOpacity(-0.5)
|
|
expect(store.brushSettings.opacity).toBe(0)
|
|
|
|
store.setBrushOpacity(2)
|
|
expect(store.brushSettings.opacity).toBe(1)
|
|
|
|
store.setBrushOpacity(0.3)
|
|
expect(store.brushSettings.opacity).toBe(0.3)
|
|
})
|
|
|
|
it('should clamp brush hardness between 0 and 1', () => {
|
|
const store = useMaskEditorStore()
|
|
|
|
store.setBrushHardness(-1)
|
|
expect(store.brushSettings.hardness).toBe(0)
|
|
|
|
store.setBrushHardness(5)
|
|
expect(store.brushSettings.hardness).toBe(1)
|
|
})
|
|
|
|
it('should clamp brush step size between 1 and 100', () => {
|
|
const store = useMaskEditorStore()
|
|
|
|
store.setBrushStepSize(0)
|
|
expect(store.brushSettings.stepSize).toBe(1)
|
|
|
|
store.setBrushStepSize(500)
|
|
expect(store.brushSettings.stepSize).toBe(100)
|
|
})
|
|
})
|
|
|
|
describe('resetBrushToDefault', () => {
|
|
it('should restore the documented default brush', () => {
|
|
const store = useMaskEditorStore()
|
|
store.setBrushSize(123)
|
|
store.setBrushOpacity(0.1)
|
|
|
|
store.resetBrushToDefault()
|
|
|
|
expect(store.brushSettings).toEqual({
|
|
type: BrushShape.Arc,
|
|
size: 20,
|
|
opacity: 1,
|
|
hardness: 1,
|
|
stepSize: 5
|
|
})
|
|
})
|
|
})
|
|
|
|
describe('numeric setters with clamping', () => {
|
|
it('should clamp paintBucket tolerance between 0 and 255', () => {
|
|
const store = useMaskEditorStore()
|
|
store.setPaintBucketTolerance(-1)
|
|
expect(store.paintBucketTolerance).toBe(0)
|
|
store.setPaintBucketTolerance(999)
|
|
expect(store.paintBucketTolerance).toBe(255)
|
|
})
|
|
|
|
it('should clamp fill opacity between 0 and 100', () => {
|
|
const store = useMaskEditorStore()
|
|
store.setFillOpacity(-10)
|
|
expect(store.fillOpacity).toBe(0)
|
|
store.setFillOpacity(200)
|
|
expect(store.fillOpacity).toBe(100)
|
|
})
|
|
|
|
it('should clamp colorSelectTolerance between 0 and 255', () => {
|
|
const store = useMaskEditorStore()
|
|
store.setColorSelectTolerance(-5)
|
|
expect(store.colorSelectTolerance).toBe(0)
|
|
store.setColorSelectTolerance(999)
|
|
expect(store.colorSelectTolerance).toBe(255)
|
|
})
|
|
|
|
it('should clamp maskTolerance between 0 and 255', () => {
|
|
const store = useMaskEditorStore()
|
|
store.setMaskTolerance(-1)
|
|
expect(store.maskTolerance).toBe(0)
|
|
store.setMaskTolerance(500)
|
|
expect(store.maskTolerance).toBe(255)
|
|
})
|
|
|
|
it('should clamp selectionOpacity between 0 and 100', () => {
|
|
const store = useMaskEditorStore()
|
|
store.setSelectionOpacity(-1)
|
|
expect(store.selectionOpacity).toBe(0)
|
|
store.setSelectionOpacity(500)
|
|
expect(store.selectionOpacity).toBe(100)
|
|
})
|
|
|
|
it('should clamp maskOpacity between 0 and 1', () => {
|
|
const store = useMaskEditorStore()
|
|
store.setMaskOpacity(-0.5)
|
|
expect(store.maskOpacity).toBe(0)
|
|
store.setMaskOpacity(2)
|
|
expect(store.maskOpacity).toBe(1)
|
|
})
|
|
|
|
it('should clamp zoomRatio between 0.1 and 10', () => {
|
|
const store = useMaskEditorStore()
|
|
store.setZoomRatio(0.001)
|
|
expect(store.zoomRatio).toBe(0.1)
|
|
store.setZoomRatio(100)
|
|
expect(store.zoomRatio).toBe(10)
|
|
store.setZoomRatio(2.5)
|
|
expect(store.zoomRatio).toBe(2.5)
|
|
})
|
|
})
|
|
|
|
describe('setPanOffset / setCursorPoint', () => {
|
|
it('should copy pan offset by value, not by reference', () => {
|
|
const store = useMaskEditorStore()
|
|
const offset = { x: 10, y: 20 }
|
|
|
|
store.setPanOffset(offset)
|
|
offset.x = 999
|
|
|
|
expect(store.panOffset.x).toBe(10)
|
|
})
|
|
|
|
it('should copy cursor point by value, not by reference', () => {
|
|
const store = useMaskEditorStore()
|
|
const point = { x: 5, y: 7 }
|
|
|
|
store.setCursorPoint(point)
|
|
point.y = 999
|
|
|
|
expect(store.cursorPoint.y).toBe(7)
|
|
})
|
|
})
|
|
|
|
describe('triggers', () => {
|
|
it('should bump resetZoomTrigger each time resetZoom is called', () => {
|
|
const store = useMaskEditorStore()
|
|
const start = store.resetZoomTrigger
|
|
|
|
store.resetZoom()
|
|
store.resetZoom()
|
|
|
|
expect(store.resetZoomTrigger).toBe(start + 2)
|
|
})
|
|
|
|
it('should bump clearTrigger each time triggerClear is called', () => {
|
|
const store = useMaskEditorStore()
|
|
const start = store.clearTrigger
|
|
|
|
store.triggerClear()
|
|
store.triggerClear()
|
|
store.triggerClear()
|
|
|
|
expect(store.clearTrigger).toBe(start + 3)
|
|
})
|
|
})
|
|
|
|
describe('maskColor computed', () => {
|
|
it('should be black for MaskBlendMode.Black', () => {
|
|
const store = useMaskEditorStore()
|
|
store.maskBlendMode = MaskBlendMode.Black
|
|
|
|
expect(store.maskColor).toEqual({ r: 0, g: 0, b: 0 })
|
|
})
|
|
|
|
it('should be white for MaskBlendMode.White', () => {
|
|
const store = useMaskEditorStore()
|
|
store.maskBlendMode = MaskBlendMode.White
|
|
|
|
expect(store.maskColor).toEqual({ r: 255, g: 255, b: 255 })
|
|
})
|
|
|
|
it('should be white for MaskBlendMode.Negative', () => {
|
|
const store = useMaskEditorStore()
|
|
store.maskBlendMode = MaskBlendMode.Negative
|
|
|
|
expect(store.maskColor).toEqual({ r: 255, g: 255, b: 255 })
|
|
})
|
|
|
|
it('should fall back to black for an unknown blend mode', () => {
|
|
const store = useMaskEditorStore()
|
|
store.maskBlendMode = 'unrecognized' as MaskBlendMode
|
|
|
|
expect(store.maskColor).toEqual({ r: 0, g: 0, b: 0 })
|
|
})
|
|
})
|
|
|
|
describe('canUndo / canRedo proxies', () => {
|
|
it('should reflect canvasHistory.canUndo when it flips', () => {
|
|
const store = useMaskEditorStore()
|
|
|
|
mockHistory.canUndo.value = true
|
|
|
|
expect(store.canUndo).toBe(true)
|
|
})
|
|
|
|
it('should reflect canvasHistory.canRedo when it flips', () => {
|
|
const store = useMaskEditorStore()
|
|
|
|
mockHistory.canRedo.value = true
|
|
|
|
expect(store.canRedo).toBe(true)
|
|
})
|
|
})
|
|
|
|
describe('canvas → ctx watchers', () => {
|
|
it.each([
|
|
['maskCanvas', 'maskCtx'],
|
|
['rgbCanvas', 'rgbCtx'],
|
|
['imgCanvas', 'imgCtx']
|
|
] as const)(
|
|
'should derive %s using getContext with willReadFrequently',
|
|
async (canvasKey, ctxKey) => {
|
|
const store = useMaskEditorStore()
|
|
const canvas = makeCanvas()
|
|
|
|
store[canvasKey] = canvas
|
|
await nextTick()
|
|
|
|
expect(canvas.getContext).toHaveBeenCalledWith('2d', {
|
|
willReadFrequently: true
|
|
})
|
|
expect(store[ctxKey]).not.toBeNull()
|
|
}
|
|
)
|
|
|
|
it.each([
|
|
['maskCanvas', 'maskCtx'],
|
|
['rgbCanvas', 'rgbCtx'],
|
|
['imgCanvas', 'imgCtx']
|
|
] as const)(
|
|
'should leave existing %s ctx untouched when canvas is cleared',
|
|
async (canvasKey, ctxKey) => {
|
|
const store = useMaskEditorStore()
|
|
const canvas = makeCanvas()
|
|
store[canvasKey] = canvas
|
|
await nextTick()
|
|
const ctx = store[ctxKey]
|
|
|
|
store[canvasKey] = null
|
|
await nextTick()
|
|
|
|
expect(store[ctxKey]).toBe(ctx)
|
|
}
|
|
)
|
|
})
|
|
|
|
describe('resetState', () => {
|
|
it('should restore non-DOM state to documented defaults', () => {
|
|
const store = useMaskEditorStore()
|
|
store.setBrushSize(200)
|
|
store.maskBlendMode = MaskBlendMode.White
|
|
store.activeLayer = 'rgb'
|
|
store.rgbColor = '#00FF00'
|
|
store.currentTool = Tools.PaintPen
|
|
store.isAdjustingBrush = true
|
|
store.setPaintBucketTolerance(50)
|
|
store.setFillOpacity(20)
|
|
store.setColorSelectTolerance(80)
|
|
store.colorSelectLivePreview = true
|
|
store.colorComparisonMethod = ColorComparisonMethod.LAB
|
|
store.applyWholeImage = true
|
|
store.maskBoundary = true
|
|
store.setMaskTolerance(30)
|
|
store.setSelectionOpacity(50)
|
|
store.setZoomRatio(3)
|
|
store.setPanOffset({ x: 10, y: 20 })
|
|
store.setCursorPoint({ x: 5, y: 5 })
|
|
store.setMaskOpacity(0.2)
|
|
store.gpuTexturesNeedRecreation = true
|
|
store.gpuTextureWidth = 100
|
|
store.gpuTextureHeight = 200
|
|
|
|
store.resetState()
|
|
|
|
expect(store.brushSettings).toEqual({
|
|
type: BrushShape.Arc,
|
|
size: 10,
|
|
opacity: 0.7,
|
|
hardness: 1,
|
|
stepSize: 5
|
|
})
|
|
expect(store.maskBlendMode).toBe(MaskBlendMode.Black)
|
|
expect(store.activeLayer).toBe('mask')
|
|
expect(store.rgbColor).toBe('#FF0000')
|
|
expect(store.currentTool).toBe(Tools.MaskPen)
|
|
expect(store.isAdjustingBrush).toBe(false)
|
|
expect(store.paintBucketTolerance).toBe(5)
|
|
expect(store.fillOpacity).toBe(100)
|
|
expect(store.colorSelectTolerance).toBe(20)
|
|
expect(store.colorSelectLivePreview).toBe(false)
|
|
expect(store.colorComparisonMethod).toBe(ColorComparisonMethod.Simple)
|
|
expect(store.applyWholeImage).toBe(false)
|
|
expect(store.maskBoundary).toBe(false)
|
|
expect(store.maskTolerance).toBe(0)
|
|
expect(store.selectionOpacity).toBe(100)
|
|
expect(store.zoomRatio).toBe(1)
|
|
expect(store.panOffset).toEqual({ x: 0, y: 0 })
|
|
expect(store.cursorPoint).toEqual({ x: 0, y: 0 })
|
|
expect(store.maskOpacity).toBe(0.8)
|
|
expect(store.gpuTexturesNeedRecreation).toBe(false)
|
|
expect(store.gpuTextureWidth).toBe(0)
|
|
expect(store.gpuTextureHeight).toBe(0)
|
|
expect(store.pendingGPUMaskData).toBeNull()
|
|
expect(store.pendingGPURgbData).toBeNull()
|
|
})
|
|
|
|
it('should not clear DOM refs (canvases / pointerZone / image)', () => {
|
|
const store = useMaskEditorStore()
|
|
const canvas = document.createElement('canvas')
|
|
const zone = document.createElement('div')
|
|
const img = document.createElement('img')
|
|
store.maskCanvas = canvas
|
|
store.pointerZone = zone
|
|
store.image = img
|
|
|
|
store.resetState()
|
|
|
|
expect(store.maskCanvas).toBe(canvas)
|
|
expect(store.pointerZone).toBe(zone)
|
|
expect(store.image).toBe(img)
|
|
})
|
|
})
|
|
})
|