mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-04-25 08:49:36 +00:00
chore: migrate tests from tests-ui/ to colocate with source files (#7811)
## Summary Migrates all unit tests from `tests-ui/` to colocate with their source files in `src/`, improving discoverability and maintainability. ## Changes - **What**: Relocated all unit tests to be adjacent to the code they test, following the `<source>.test.ts` naming convention - **Config**: Updated `vitest.config.ts` to remove `tests-ui` include pattern and `@tests-ui` alias - **Docs**: Moved testing documentation to `docs/testing/` with updated paths and patterns ## Review Focus - Migration patterns documented in `temp/plans/migrate-tests-ui-to-src.md` - Tests use `@/` path aliases instead of relative imports - Shared fixtures placed in `__fixtures__/` directories ┆Issue is synchronized with this [Notion page](https://www.notion.so/PR-7811-chore-migrate-tests-from-tests-ui-to-colocate-with-source-files-2da6d73d36508147a4cce85365dee614) by [Unito](https://www.unito.io) --------- Co-authored-by: Amp <amp@ampcode.com> Co-authored-by: GitHub Action <action@github.com>
This commit is contained in:
159
src/renderer/core/canvas/useCanvasInteractions.test.ts
Normal file
159
src/renderer/core/canvas/useCanvasInteractions.test.ts
Normal file
@@ -0,0 +1,159 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import type { LGraphCanvas } from '@/lib/litegraph/src/litegraph'
|
||||
import { useSettingStore } from '@/platform/settings/settingStore'
|
||||
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
|
||||
import { useCanvasInteractions } from '@/renderer/core/canvas/useCanvasInteractions'
|
||||
|
||||
// Mock stores
|
||||
vi.mock('@/renderer/core/canvas/canvasStore', () => {
|
||||
const getCanvas = vi.fn()
|
||||
const setCursorStyle = vi.fn()
|
||||
return {
|
||||
useCanvasStore: vi.fn(() => ({
|
||||
getCanvas,
|
||||
setCursorStyle
|
||||
}))
|
||||
}
|
||||
})
|
||||
vi.mock('@/platform/settings/settingStore', () => {
|
||||
const getFn = vi.fn()
|
||||
return { useSettingStore: vi.fn(() => ({ get: getFn })) }
|
||||
})
|
||||
vi.mock('@/scripts/app', () => ({
|
||||
app: {
|
||||
canvas: {
|
||||
canvas: {
|
||||
dispatchEvent: vi.fn()
|
||||
}
|
||||
}
|
||||
}
|
||||
}))
|
||||
|
||||
function createMockLGraphCanvas(read_only = true): LGraphCanvas {
|
||||
const mockCanvas: Partial<LGraphCanvas> = { read_only }
|
||||
return mockCanvas as LGraphCanvas
|
||||
}
|
||||
|
||||
function createMockPointerEvent(
|
||||
buttons: PointerEvent['buttons'] = 1
|
||||
): PointerEvent {
|
||||
const mockEvent: Partial<PointerEvent> = {
|
||||
buttons,
|
||||
preventDefault: vi.fn(),
|
||||
stopPropagation: vi.fn()
|
||||
}
|
||||
return mockEvent as PointerEvent
|
||||
}
|
||||
|
||||
function createMockWheelEvent(ctrlKey = false, metaKey = false): WheelEvent {
|
||||
const mockEvent: Partial<WheelEvent> = {
|
||||
ctrlKey,
|
||||
metaKey,
|
||||
preventDefault: vi.fn(),
|
||||
stopPropagation: vi.fn()
|
||||
}
|
||||
return mockEvent as WheelEvent
|
||||
}
|
||||
|
||||
describe('useCanvasInteractions', () => {
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks()
|
||||
})
|
||||
|
||||
describe('handlePointer', () => {
|
||||
it('should intercept left mouse events when canvas is read_only to enable space+drag navigation', () => {
|
||||
const { getCanvas } = useCanvasStore()
|
||||
const mockCanvas = createMockLGraphCanvas(true)
|
||||
vi.mocked(getCanvas).mockReturnValue(mockCanvas)
|
||||
|
||||
const { handlePointer } = useCanvasInteractions()
|
||||
|
||||
const mockEvent = createMockPointerEvent(1) // Left Mouse Button
|
||||
handlePointer(mockEvent)
|
||||
|
||||
expect(mockEvent.preventDefault).toHaveBeenCalled()
|
||||
expect(mockEvent.stopPropagation).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should forward middle mouse button events to canvas', () => {
|
||||
const { getCanvas } = useCanvasStore()
|
||||
const mockCanvas = createMockLGraphCanvas(false)
|
||||
vi.mocked(getCanvas).mockReturnValue(mockCanvas)
|
||||
const { handlePointer } = useCanvasInteractions()
|
||||
|
||||
const mockEvent = createMockPointerEvent(4) // Middle mouse button
|
||||
handlePointer(mockEvent)
|
||||
|
||||
expect(mockEvent.preventDefault).toHaveBeenCalled()
|
||||
expect(mockEvent.stopPropagation).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should not prevent default when canvas is not in read_only mode and not middle button', () => {
|
||||
const { getCanvas } = useCanvasStore()
|
||||
const mockCanvas = createMockLGraphCanvas(false)
|
||||
vi.mocked(getCanvas).mockReturnValue(mockCanvas)
|
||||
const { handlePointer } = useCanvasInteractions()
|
||||
|
||||
const mockEvent = createMockPointerEvent(1)
|
||||
handlePointer(mockEvent)
|
||||
|
||||
expect(mockEvent.preventDefault).not.toHaveBeenCalled()
|
||||
expect(mockEvent.stopPropagation).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should return early when canvas is null', () => {
|
||||
const { getCanvas } = useCanvasStore()
|
||||
vi.mocked(getCanvas).mockReturnValue(null as unknown as LGraphCanvas) // TODO: Fix misaligned types
|
||||
const { handlePointer } = useCanvasInteractions()
|
||||
|
||||
const mockEvent = createMockPointerEvent(1)
|
||||
handlePointer(mockEvent)
|
||||
|
||||
expect(getCanvas).toHaveBeenCalled()
|
||||
expect(mockEvent.preventDefault).not.toHaveBeenCalled()
|
||||
expect(mockEvent.stopPropagation).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('handleWheel', () => {
|
||||
it('should forward ctrl+wheel events to canvas in standard nav mode', () => {
|
||||
const { get } = useSettingStore()
|
||||
vi.mocked(get).mockReturnValue('standard')
|
||||
|
||||
const { handleWheel } = useCanvasInteractions()
|
||||
|
||||
// Ctrl key pressed
|
||||
const mockEvent = createMockWheelEvent(true)
|
||||
|
||||
handleWheel(mockEvent)
|
||||
|
||||
expect(mockEvent.preventDefault).toHaveBeenCalled()
|
||||
expect(mockEvent.stopPropagation).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should forward all wheel events to canvas in legacy nav mode', () => {
|
||||
const { get } = useSettingStore()
|
||||
vi.mocked(get).mockReturnValue('legacy')
|
||||
const { handleWheel } = useCanvasInteractions()
|
||||
|
||||
const mockEvent = createMockWheelEvent()
|
||||
handleWheel(mockEvent)
|
||||
|
||||
expect(mockEvent.preventDefault).toHaveBeenCalled()
|
||||
expect(mockEvent.stopPropagation).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should not prevent default for regular wheel events in standard nav mode', () => {
|
||||
const { get } = useSettingStore()
|
||||
vi.mocked(get).mockReturnValue('standard')
|
||||
const { handleWheel } = useCanvasInteractions()
|
||||
|
||||
const mockEvent = createMockWheelEvent()
|
||||
handleWheel(mockEvent)
|
||||
|
||||
expect(mockEvent.preventDefault).not.toHaveBeenCalled()
|
||||
expect(mockEvent.stopPropagation).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
})
|
||||
409
src/renderer/core/layout/store/layoutStore.test.ts
Normal file
409
src/renderer/core/layout/store/layoutStore.test.ts
Normal file
@@ -0,0 +1,409 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { LiteGraph } from '@/lib/litegraph/src/litegraph'
|
||||
import { layoutStore } from '@/renderer/core/layout/store/layoutStore'
|
||||
import { LayoutSource } from '@/renderer/core/layout/types'
|
||||
import type { LayoutChange, NodeLayout } from '@/renderer/core/layout/types'
|
||||
|
||||
describe('layoutStore CRDT operations', () => {
|
||||
beforeEach(() => {
|
||||
// Clear the store before each test
|
||||
layoutStore.initializeFromLiteGraph([])
|
||||
})
|
||||
// Helper to create test node data
|
||||
const createTestNode = (id: string): NodeLayout => ({
|
||||
id,
|
||||
position: { x: 100, y: 100 },
|
||||
size: { width: 200, height: 100 },
|
||||
zIndex: 0,
|
||||
visible: true,
|
||||
bounds: { x: 100, y: 100, width: 200, height: 100 }
|
||||
})
|
||||
|
||||
it('should create and retrieve nodes', () => {
|
||||
const nodeId = 'test-node-1'
|
||||
const layout = createTestNode(nodeId)
|
||||
|
||||
// Create node
|
||||
layoutStore.setSource(LayoutSource.External)
|
||||
layoutStore.applyOperation({
|
||||
type: 'createNode',
|
||||
entity: 'node',
|
||||
nodeId,
|
||||
layout,
|
||||
timestamp: Date.now(),
|
||||
source: LayoutSource.External,
|
||||
actor: 'test'
|
||||
})
|
||||
|
||||
// Retrieve node
|
||||
const nodeRef = layoutStore.getNodeLayoutRef(nodeId)
|
||||
expect(nodeRef.value).toEqual(layout)
|
||||
})
|
||||
|
||||
it('should move nodes', () => {
|
||||
const nodeId = 'test-node-2'
|
||||
const layout = createTestNode(nodeId)
|
||||
|
||||
// Create node first
|
||||
layoutStore.applyOperation({
|
||||
type: 'createNode',
|
||||
entity: 'node',
|
||||
nodeId,
|
||||
layout,
|
||||
timestamp: Date.now(),
|
||||
source: LayoutSource.External,
|
||||
actor: 'test'
|
||||
})
|
||||
|
||||
// Move node
|
||||
const newPosition = { x: 200, y: 300 }
|
||||
layoutStore.applyOperation({
|
||||
type: 'moveNode',
|
||||
entity: 'node',
|
||||
nodeId,
|
||||
position: newPosition,
|
||||
previousPosition: layout.position,
|
||||
timestamp: Date.now(),
|
||||
source: LayoutSource.Vue,
|
||||
actor: 'test'
|
||||
})
|
||||
|
||||
// Verify position updated
|
||||
const nodeRef = layoutStore.getNodeLayoutRef(nodeId)
|
||||
expect(nodeRef.value?.position).toEqual(newPosition)
|
||||
})
|
||||
|
||||
it('should resize nodes', () => {
|
||||
const nodeId = 'test-node-3'
|
||||
const layout = createTestNode(nodeId)
|
||||
|
||||
// Create node
|
||||
layoutStore.applyOperation({
|
||||
type: 'createNode',
|
||||
entity: 'node',
|
||||
nodeId,
|
||||
layout,
|
||||
timestamp: Date.now(),
|
||||
source: LayoutSource.External,
|
||||
actor: 'test'
|
||||
})
|
||||
|
||||
// Resize node
|
||||
const newSize = { width: 300, height: 150 }
|
||||
layoutStore.applyOperation({
|
||||
type: 'resizeNode',
|
||||
entity: 'node',
|
||||
nodeId,
|
||||
size: newSize,
|
||||
previousSize: layout.size,
|
||||
timestamp: Date.now(),
|
||||
source: LayoutSource.Canvas,
|
||||
actor: 'test'
|
||||
})
|
||||
|
||||
// Verify size updated
|
||||
const nodeRef = layoutStore.getNodeLayoutRef(nodeId)
|
||||
expect(nodeRef.value?.size).toEqual(newSize)
|
||||
})
|
||||
|
||||
it('should delete nodes', () => {
|
||||
const nodeId = 'test-node-4'
|
||||
const layout = createTestNode(nodeId)
|
||||
|
||||
// Create node
|
||||
layoutStore.applyOperation({
|
||||
type: 'createNode',
|
||||
entity: 'node',
|
||||
nodeId,
|
||||
layout,
|
||||
timestamp: Date.now(),
|
||||
source: LayoutSource.External,
|
||||
actor: 'test'
|
||||
})
|
||||
|
||||
// Delete node
|
||||
layoutStore.applyOperation({
|
||||
type: 'deleteNode',
|
||||
entity: 'node',
|
||||
nodeId,
|
||||
previousLayout: layout,
|
||||
timestamp: Date.now(),
|
||||
source: LayoutSource.External,
|
||||
actor: 'test'
|
||||
})
|
||||
|
||||
// Verify node deleted
|
||||
const nodeRef = layoutStore.getNodeLayoutRef(nodeId)
|
||||
expect(nodeRef.value).toBeNull()
|
||||
})
|
||||
|
||||
it('should handle source and actor tracking', async () => {
|
||||
const nodeId = 'test-node-5'
|
||||
const layout = createTestNode(nodeId)
|
||||
|
||||
// Set source and actor
|
||||
layoutStore.setSource(LayoutSource.Vue)
|
||||
layoutStore.setActor('user-123')
|
||||
|
||||
// Track change notifications AFTER setting source/actor
|
||||
const changes: LayoutChange[] = []
|
||||
const unsubscribe = layoutStore.onChange((change) => {
|
||||
changes.push(change)
|
||||
})
|
||||
|
||||
// Create node
|
||||
layoutStore.applyOperation({
|
||||
type: 'createNode',
|
||||
entity: 'node',
|
||||
nodeId,
|
||||
layout,
|
||||
timestamp: Date.now(),
|
||||
source: layoutStore.getCurrentSource(),
|
||||
actor: layoutStore.getCurrentActor()
|
||||
})
|
||||
|
||||
// Wait for onChange callback to be called (uses setTimeout internally)
|
||||
await vi.waitFor(() => {
|
||||
expect(changes.length).toBeGreaterThanOrEqual(1)
|
||||
})
|
||||
|
||||
const lastChange = changes[changes.length - 1]
|
||||
expect(lastChange.source).toBe('vue')
|
||||
expect(lastChange.operation.actor).toBe('user-123')
|
||||
|
||||
unsubscribe()
|
||||
})
|
||||
|
||||
it('should emit change when batch updating node bounds', async () => {
|
||||
const nodeId = 'test-node-6'
|
||||
const layout = createTestNode(nodeId)
|
||||
|
||||
layoutStore.applyOperation({
|
||||
type: 'createNode',
|
||||
entity: 'node',
|
||||
nodeId,
|
||||
layout,
|
||||
timestamp: Date.now(),
|
||||
source: LayoutSource.External,
|
||||
actor: 'test'
|
||||
})
|
||||
|
||||
const changes: LayoutChange[] = []
|
||||
const unsubscribe = layoutStore.onChange((change) => {
|
||||
changes.push(change)
|
||||
})
|
||||
|
||||
const newBounds = { x: 40, y: 60, width: 220, height: 120 }
|
||||
layoutStore.batchUpdateNodeBounds([{ nodeId, bounds: newBounds }])
|
||||
|
||||
// Wait for onChange callback to be called (uses setTimeout internally)
|
||||
await vi.waitFor(() => {
|
||||
expect(changes.length).toBeGreaterThan(0)
|
||||
const lastChange = changes[changes.length - 1]
|
||||
expect(lastChange.operation.type).toBe('batchUpdateBounds')
|
||||
})
|
||||
|
||||
const lastChange = changes[changes.length - 1]
|
||||
if (lastChange.operation.type === 'batchUpdateBounds') {
|
||||
expect(lastChange.nodeIds).toContain(nodeId)
|
||||
expect(lastChange.operation.bounds[nodeId]?.bounds).toEqual(newBounds)
|
||||
}
|
||||
|
||||
const nodeRef = layoutStore.getNodeLayoutRef(nodeId)
|
||||
expect(nodeRef.value?.position).toEqual({ x: 40, y: 60 })
|
||||
expect(nodeRef.value?.size).toEqual({ width: 220, height: 120 })
|
||||
|
||||
unsubscribe()
|
||||
})
|
||||
|
||||
it('should query nodes by spatial bounds', () => {
|
||||
const nodes = [
|
||||
{ id: 'node-a', position: { x: 0, y: 0 } },
|
||||
{ id: 'node-b', position: { x: 100, y: 100 } },
|
||||
{ id: 'node-c', position: { x: 250, y: 250 } }
|
||||
]
|
||||
|
||||
// Create nodes with proper bounds
|
||||
nodes.forEach(({ id, position }) => {
|
||||
const layout: NodeLayout = {
|
||||
...createTestNode(id),
|
||||
position,
|
||||
bounds: {
|
||||
x: position.x,
|
||||
y: position.y,
|
||||
width: 200,
|
||||
height: 100
|
||||
}
|
||||
}
|
||||
layoutStore.applyOperation({
|
||||
type: 'createNode',
|
||||
entity: 'node',
|
||||
nodeId: id,
|
||||
layout,
|
||||
timestamp: Date.now(),
|
||||
source: LayoutSource.External,
|
||||
actor: 'test'
|
||||
})
|
||||
})
|
||||
|
||||
// Query nodes in bounds
|
||||
const nodesInBounds = layoutStore.queryNodesInBounds({
|
||||
x: 50,
|
||||
y: 50,
|
||||
width: 200,
|
||||
height: 200
|
||||
})
|
||||
|
||||
// node-a: (0,0) to (200,100) - overlaps with query bounds (50,50) to (250,250)
|
||||
// node-b: (100,100) to (300,200) - overlaps with query bounds
|
||||
// node-c: (250,250) to (450,350) - touches corner of query bounds
|
||||
expect(nodesInBounds).toContain('node-a')
|
||||
expect(nodesInBounds).toContain('node-b')
|
||||
expect(nodesInBounds).toContain('node-c')
|
||||
})
|
||||
|
||||
it('should maintain operation history', () => {
|
||||
const nodeId = 'test-node-history'
|
||||
const layout = createTestNode(nodeId)
|
||||
const startTime = Date.now()
|
||||
|
||||
// Create node
|
||||
layoutStore.applyOperation({
|
||||
type: 'createNode',
|
||||
entity: 'node',
|
||||
nodeId,
|
||||
layout,
|
||||
timestamp: startTime,
|
||||
source: LayoutSource.External,
|
||||
actor: 'test-actor'
|
||||
})
|
||||
|
||||
// Move node
|
||||
layoutStore.applyOperation({
|
||||
type: 'moveNode',
|
||||
entity: 'node',
|
||||
nodeId,
|
||||
position: { x: 150, y: 150 },
|
||||
previousPosition: { x: 100, y: 100 },
|
||||
timestamp: startTime + 100,
|
||||
source: LayoutSource.Vue,
|
||||
actor: 'test-actor'
|
||||
})
|
||||
|
||||
// Get operations by actor
|
||||
const operations = layoutStore.getOperationsByActor('test-actor')
|
||||
expect(operations.length).toBeGreaterThanOrEqual(2)
|
||||
expect(operations[0].type).toBe('createNode')
|
||||
expect(operations[1].type).toBe('moveNode')
|
||||
|
||||
// Get operations since timestamp
|
||||
const recentOps = layoutStore.getOperationsSince(startTime + 50)
|
||||
expect(recentOps.length).toBeGreaterThanOrEqual(1)
|
||||
expect(recentOps[0].type).toBe('moveNode')
|
||||
})
|
||||
|
||||
it('normalizes DOM-sourced heights before storing', () => {
|
||||
const nodeId = 'dom-node'
|
||||
const layout = createTestNode(nodeId)
|
||||
|
||||
layoutStore.applyOperation({
|
||||
type: 'createNode',
|
||||
entity: 'node',
|
||||
nodeId,
|
||||
layout,
|
||||
timestamp: Date.now(),
|
||||
source: LayoutSource.External,
|
||||
actor: 'test'
|
||||
})
|
||||
|
||||
layoutStore.setSource(LayoutSource.DOM)
|
||||
layoutStore.batchUpdateNodeBounds([
|
||||
{
|
||||
nodeId,
|
||||
bounds: {
|
||||
x: layout.bounds.x,
|
||||
y: layout.bounds.y,
|
||||
width: layout.size.width,
|
||||
height: layout.size.height + LiteGraph.NODE_TITLE_HEIGHT
|
||||
}
|
||||
}
|
||||
])
|
||||
|
||||
const nodeRef = layoutStore.getNodeLayoutRef(nodeId)
|
||||
expect(nodeRef.value?.size.height).toBe(layout.size.height)
|
||||
expect(nodeRef.value?.size.width).toBe(layout.size.width)
|
||||
expect(nodeRef.value?.position).toEqual(layout.position)
|
||||
})
|
||||
|
||||
it('normalizes very small DOM-sourced heights safely', () => {
|
||||
const nodeId = 'small-dom-node'
|
||||
const layout = createTestNode(nodeId)
|
||||
layout.size.height = 10
|
||||
|
||||
layoutStore.applyOperation({
|
||||
type: 'createNode',
|
||||
entity: 'node',
|
||||
nodeId,
|
||||
layout,
|
||||
timestamp: Date.now(),
|
||||
source: LayoutSource.External,
|
||||
actor: 'test'
|
||||
})
|
||||
|
||||
layoutStore.setSource(LayoutSource.DOM)
|
||||
layoutStore.batchUpdateNodeBounds([
|
||||
{
|
||||
nodeId,
|
||||
bounds: {
|
||||
x: layout.bounds.x,
|
||||
y: layout.bounds.y,
|
||||
width: layout.size.width,
|
||||
height: layout.size.height + LiteGraph.NODE_TITLE_HEIGHT
|
||||
}
|
||||
}
|
||||
])
|
||||
|
||||
const nodeRef = layoutStore.getNodeLayoutRef(nodeId)
|
||||
expect(nodeRef.value?.size.height).toBeGreaterThanOrEqual(0)
|
||||
})
|
||||
|
||||
it('handles undefined NODE_TITLE_HEIGHT without NaN results', () => {
|
||||
const nodeId = 'undefined-title-height'
|
||||
const layout = createTestNode(nodeId)
|
||||
|
||||
layoutStore.applyOperation({
|
||||
type: 'createNode',
|
||||
entity: 'node',
|
||||
nodeId,
|
||||
layout,
|
||||
timestamp: Date.now(),
|
||||
source: LayoutSource.External,
|
||||
actor: 'test'
|
||||
})
|
||||
|
||||
const originalTitleHeight = LiteGraph.NODE_TITLE_HEIGHT
|
||||
// @ts-expect-error – intentionally simulate undefined runtime value
|
||||
LiteGraph.NODE_TITLE_HEIGHT = undefined
|
||||
|
||||
try {
|
||||
layoutStore.setSource(LayoutSource.DOM)
|
||||
layoutStore.batchUpdateNodeBounds([
|
||||
{
|
||||
nodeId,
|
||||
bounds: {
|
||||
x: layout.bounds.x,
|
||||
y: layout.bounds.y,
|
||||
width: layout.size.width,
|
||||
height: layout.size.height
|
||||
}
|
||||
}
|
||||
])
|
||||
|
||||
const nodeRef = layoutStore.getNodeLayoutRef(nodeId)
|
||||
expect(nodeRef.value?.size.height).toBe(layout.size.height)
|
||||
} finally {
|
||||
LiteGraph.NODE_TITLE_HEIGHT = originalTitleHeight
|
||||
}
|
||||
})
|
||||
})
|
||||
171
src/renderer/core/layout/transform/useTransformSettling.test.ts
Normal file
171
src/renderer/core/layout/transform/useTransformSettling.test.ts
Normal file
@@ -0,0 +1,171 @@
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { nextTick, ref } from 'vue'
|
||||
|
||||
import { useTransformSettling } from '@/renderer/core/layout/transform/useTransformSettling'
|
||||
|
||||
describe('useTransformSettling', () => {
|
||||
let element: HTMLDivElement
|
||||
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers()
|
||||
element = document.createElement('div')
|
||||
document.body.appendChild(element)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
document.body.removeChild(element)
|
||||
})
|
||||
|
||||
it('should track wheel events and settle after delay', async () => {
|
||||
const { isTransforming } = useTransformSettling(element, {
|
||||
settleDelay: 200
|
||||
})
|
||||
|
||||
// Initially not transforming
|
||||
expect(isTransforming.value).toBe(false)
|
||||
|
||||
// Dispatch wheel event
|
||||
element.dispatchEvent(new WheelEvent('wheel', { bubbles: true }))
|
||||
await nextTick()
|
||||
|
||||
// Should be transforming
|
||||
expect(isTransforming.value).toBe(true)
|
||||
|
||||
// Advance time but not past settle delay
|
||||
vi.advanceTimersByTime(100)
|
||||
expect(isTransforming.value).toBe(true)
|
||||
|
||||
// Advance past settle delay
|
||||
vi.advanceTimersByTime(150)
|
||||
expect(isTransforming.value).toBe(false)
|
||||
})
|
||||
|
||||
it('should reset settle timer on subsequent wheel events', async () => {
|
||||
const { isTransforming } = useTransformSettling(element, {
|
||||
settleDelay: 300
|
||||
})
|
||||
|
||||
// First wheel event
|
||||
element.dispatchEvent(new WheelEvent('wheel', { bubbles: true }))
|
||||
await nextTick()
|
||||
expect(isTransforming.value).toBe(true)
|
||||
|
||||
// Advance time partially
|
||||
vi.advanceTimersByTime(200)
|
||||
expect(isTransforming.value).toBe(true)
|
||||
|
||||
// Another wheel event should reset the timer
|
||||
element.dispatchEvent(new WheelEvent('wheel', { bubbles: true }))
|
||||
await nextTick()
|
||||
|
||||
// Advance 200ms more - should still be transforming
|
||||
vi.advanceTimersByTime(200)
|
||||
expect(isTransforming.value).toBe(true)
|
||||
|
||||
// Need another 100ms to settle (300ms total from last event)
|
||||
vi.advanceTimersByTime(100)
|
||||
expect(isTransforming.value).toBe(false)
|
||||
})
|
||||
|
||||
it('should not track pan events', async () => {
|
||||
const { isTransforming } = useTransformSettling(element)
|
||||
|
||||
// Pointer events should not trigger transform
|
||||
element.dispatchEvent(new PointerEvent('pointerdown', { bubbles: true }))
|
||||
element.dispatchEvent(new PointerEvent('pointermove', { bubbles: true }))
|
||||
await nextTick()
|
||||
|
||||
expect(isTransforming.value).toBe(false)
|
||||
})
|
||||
|
||||
it('should work with ref target', async () => {
|
||||
const targetRef = ref<HTMLElement | null>(null)
|
||||
const { isTransforming } = useTransformSettling(targetRef, {
|
||||
settleDelay: 200
|
||||
})
|
||||
|
||||
// No target yet
|
||||
expect(isTransforming.value).toBe(false)
|
||||
|
||||
// Set target
|
||||
targetRef.value = element
|
||||
await nextTick()
|
||||
|
||||
// Now events should work
|
||||
element.dispatchEvent(new WheelEvent('wheel', { bubbles: true }))
|
||||
await nextTick()
|
||||
expect(isTransforming.value).toBe(true)
|
||||
|
||||
vi.advanceTimersByTime(200)
|
||||
expect(isTransforming.value).toBe(false)
|
||||
})
|
||||
|
||||
it('should use capture phase for events', async () => {
|
||||
const captureHandler = vi.fn()
|
||||
const bubbleHandler = vi.fn()
|
||||
|
||||
// Add handlers to verify capture phase
|
||||
element.addEventListener('wheel', captureHandler, true)
|
||||
element.addEventListener('wheel', bubbleHandler, false)
|
||||
|
||||
const { isTransforming } = useTransformSettling(element)
|
||||
|
||||
// Create child element
|
||||
const child = document.createElement('div')
|
||||
element.appendChild(child)
|
||||
|
||||
// Dispatch event on child
|
||||
child.dispatchEvent(new WheelEvent('wheel', { bubbles: true }))
|
||||
await nextTick()
|
||||
|
||||
// Capture handler should be called before bubble handler
|
||||
expect(captureHandler).toHaveBeenCalled()
|
||||
expect(isTransforming.value).toBe(true)
|
||||
|
||||
element.removeEventListener('wheel', captureHandler, true)
|
||||
element.removeEventListener('wheel', bubbleHandler, false)
|
||||
})
|
||||
|
||||
it('should clean up event listeners when component unmounts', async () => {
|
||||
const removeEventListenerSpy = vi.spyOn(element, 'removeEventListener')
|
||||
|
||||
// Create a test component
|
||||
const TestComponent = {
|
||||
setup() {
|
||||
const { isTransforming } = useTransformSettling(element)
|
||||
return { isTransforming }
|
||||
},
|
||||
template: '<div>{{ isTransforming }}</div>'
|
||||
}
|
||||
|
||||
const wrapper = mount(TestComponent)
|
||||
await nextTick()
|
||||
|
||||
// Unmount component
|
||||
wrapper.unmount()
|
||||
|
||||
// Should have removed wheel event listener
|
||||
expect(removeEventListenerSpy).toHaveBeenCalledWith(
|
||||
'wheel',
|
||||
expect.any(Function),
|
||||
expect.objectContaining({ capture: true })
|
||||
)
|
||||
})
|
||||
|
||||
it('should use passive listeners when specified', async () => {
|
||||
const addEventListenerSpy = vi.spyOn(element, 'addEventListener')
|
||||
|
||||
useTransformSettling(element, {
|
||||
passive: true
|
||||
})
|
||||
|
||||
// Check that passive option was used for wheel event
|
||||
expect(addEventListenerSpy).toHaveBeenCalledWith(
|
||||
'wheel',
|
||||
expect.any(Function),
|
||||
expect.objectContaining({ passive: true, capture: true })
|
||||
)
|
||||
})
|
||||
})
|
||||
354
src/renderer/core/layout/transform/useTransformState.test.ts
Normal file
354
src/renderer/core/layout/transform/useTransformState.test.ts
Normal file
@@ -0,0 +1,354 @@
|
||||
import { beforeEach, describe, expect, it } from 'vitest'
|
||||
|
||||
import { useTransformState } from '@/renderer/core/layout/transform/useTransformState'
|
||||
import type { LGraphCanvas } from '@/lib/litegraph/src/LGraphCanvas'
|
||||
|
||||
// Create a mock canvas context for transform testing
|
||||
function createMockCanvasContext() {
|
||||
return {
|
||||
canvas: {
|
||||
width: 1280,
|
||||
height: 720,
|
||||
getBoundingClientRect: () => ({
|
||||
left: 0,
|
||||
top: 0,
|
||||
width: 1280,
|
||||
height: 720,
|
||||
right: 1280,
|
||||
bottom: 720,
|
||||
x: 0,
|
||||
y: 0
|
||||
})
|
||||
},
|
||||
ds: {
|
||||
offset: [0, 0],
|
||||
scale: 1
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
describe('useTransformState', () => {
|
||||
const transformState = useTransformState()
|
||||
|
||||
beforeEach(() => {
|
||||
transformState.syncWithCanvas({
|
||||
ds: { offset: [0, 0] }
|
||||
} as unknown as LGraphCanvas)
|
||||
})
|
||||
|
||||
describe('initial state', () => {
|
||||
it('should initialize with default camera values', () => {
|
||||
const { camera } = transformState
|
||||
expect(camera.x).toBe(0)
|
||||
expect(camera.y).toBe(0)
|
||||
expect(camera.z).toBe(1)
|
||||
})
|
||||
|
||||
it('should generate correct initial transform style', () => {
|
||||
const { transformStyle } = transformState
|
||||
expect(transformStyle.value).toEqual({
|
||||
transform: 'scale(1) translate(0px, 0px)',
|
||||
transformOrigin: '0 0'
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('syncWithCanvas', () => {
|
||||
it('should sync camera state with canvas transform', () => {
|
||||
const { syncWithCanvas, camera } = transformState
|
||||
const mockCanvas = createMockCanvasContext()
|
||||
|
||||
// Set mock canvas transform
|
||||
mockCanvas.ds.offset = [100, 50]
|
||||
mockCanvas.ds.scale = 2
|
||||
|
||||
syncWithCanvas(mockCanvas as any)
|
||||
|
||||
expect(camera.x).toBe(100)
|
||||
expect(camera.y).toBe(50)
|
||||
expect(camera.z).toBe(2)
|
||||
})
|
||||
|
||||
it('should handle null canvas gracefully', () => {
|
||||
const { syncWithCanvas, camera } = transformState
|
||||
|
||||
syncWithCanvas(null as any)
|
||||
|
||||
// Should remain at initial values
|
||||
expect(camera.x).toBe(0)
|
||||
expect(camera.y).toBe(0)
|
||||
expect(camera.z).toBe(1)
|
||||
})
|
||||
|
||||
it('should handle canvas without ds property', () => {
|
||||
const { syncWithCanvas, camera } = transformState
|
||||
const canvasWithoutDs = { canvas: {} }
|
||||
|
||||
syncWithCanvas(canvasWithoutDs as any)
|
||||
|
||||
// Should remain at initial values
|
||||
expect(camera.x).toBe(0)
|
||||
expect(camera.y).toBe(0)
|
||||
expect(camera.z).toBe(1)
|
||||
})
|
||||
|
||||
it('should update transform style after sync', () => {
|
||||
const { syncWithCanvas, transformStyle } = transformState
|
||||
const mockCanvas = createMockCanvasContext()
|
||||
|
||||
mockCanvas.ds.offset = [150, 75]
|
||||
mockCanvas.ds.scale = 0.5
|
||||
|
||||
syncWithCanvas(mockCanvas as any)
|
||||
|
||||
expect(transformStyle.value).toEqual({
|
||||
transform: 'scale(0.5) translate(150px, 75px)',
|
||||
transformOrigin: '0 0'
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('coordinate conversions', () => {
|
||||
beforeEach(() => {
|
||||
// Set up a known transform state
|
||||
const mockCanvas = createMockCanvasContext()
|
||||
mockCanvas.ds.offset = [100, 50]
|
||||
mockCanvas.ds.scale = 2
|
||||
transformState.syncWithCanvas(mockCanvas as any)
|
||||
})
|
||||
|
||||
describe('canvasToScreen', () => {
|
||||
it('should convert canvas coordinates to screen coordinates', () => {
|
||||
const { canvasToScreen } = transformState
|
||||
|
||||
const canvasPoint = { x: 10, y: 20 }
|
||||
const screenPoint = canvasToScreen(canvasPoint)
|
||||
|
||||
// screen = (canvas + offset) * scale
|
||||
// x: (10 + 100) * 2 = 220
|
||||
// y: (20 + 50) * 2 = 140
|
||||
expect(screenPoint).toEqual({ x: 220, y: 140 })
|
||||
})
|
||||
|
||||
it('should handle zero coordinates', () => {
|
||||
const { canvasToScreen } = transformState
|
||||
|
||||
const screenPoint = canvasToScreen({ x: 0, y: 0 })
|
||||
expect(screenPoint).toEqual({ x: 200, y: 100 })
|
||||
})
|
||||
|
||||
it('should handle negative coordinates', () => {
|
||||
const { canvasToScreen } = transformState
|
||||
|
||||
const screenPoint = canvasToScreen({ x: -10, y: -20 })
|
||||
expect(screenPoint).toEqual({ x: 180, y: 60 })
|
||||
})
|
||||
})
|
||||
|
||||
describe('screenToCanvas', () => {
|
||||
it('should convert screen coordinates to canvas coordinates', () => {
|
||||
const { screenToCanvas } = transformState
|
||||
|
||||
const screenPoint = { x: 220, y: 140 }
|
||||
const canvasPoint = screenToCanvas(screenPoint)
|
||||
|
||||
// canvas = screen / scale - offset
|
||||
// x: 220 / 2 - 100 = 10
|
||||
// y: 140 / 2 - 50 = 20
|
||||
expect(canvasPoint).toEqual({ x: 10, y: 20 })
|
||||
})
|
||||
|
||||
it('should be inverse of canvasToScreen', () => {
|
||||
const { canvasToScreen, screenToCanvas } = transformState
|
||||
|
||||
const originalPoint = { x: 25, y: 35 }
|
||||
const screenPoint = canvasToScreen(originalPoint)
|
||||
const backToCanvas = screenToCanvas(screenPoint)
|
||||
|
||||
expect(backToCanvas.x).toBeCloseTo(originalPoint.x)
|
||||
expect(backToCanvas.y).toBeCloseTo(originalPoint.y)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('getNodeScreenBounds', () => {
|
||||
beforeEach(() => {
|
||||
const mockCanvas = createMockCanvasContext()
|
||||
mockCanvas.ds.offset = [100, 50]
|
||||
mockCanvas.ds.scale = 2
|
||||
transformState.syncWithCanvas(mockCanvas as any)
|
||||
})
|
||||
|
||||
it('should calculate correct screen bounds for a node', () => {
|
||||
const { getNodeScreenBounds } = transformState
|
||||
|
||||
const nodePos: [number, number] = [10, 20]
|
||||
const nodeSize: [number, number] = [200, 100]
|
||||
const bounds = getNodeScreenBounds(nodePos, nodeSize)
|
||||
|
||||
// Top-left: canvasToScreen(10, 20) = (220, 140)
|
||||
// Width: 200 * 2 = 400
|
||||
// Height: 100 * 2 = 200
|
||||
expect(bounds.x).toBe(220)
|
||||
expect(bounds.y).toBe(140)
|
||||
expect(bounds.width).toBe(400)
|
||||
expect(bounds.height).toBe(200)
|
||||
})
|
||||
})
|
||||
|
||||
describe('isNodeInViewport', () => {
|
||||
beforeEach(() => {
|
||||
const mockCanvas = createMockCanvasContext()
|
||||
mockCanvas.ds.offset = [0, 0]
|
||||
mockCanvas.ds.scale = 1
|
||||
transformState.syncWithCanvas(mockCanvas as any)
|
||||
})
|
||||
|
||||
const viewport = { width: 1000, height: 600 }
|
||||
|
||||
it('should return true for nodes inside viewport', () => {
|
||||
const { isNodeInViewport } = transformState
|
||||
|
||||
const nodePos: [number, number] = [100, 100]
|
||||
const nodeSize: [number, number] = [200, 100]
|
||||
|
||||
expect(isNodeInViewport(nodePos, nodeSize, viewport)).toBe(true)
|
||||
})
|
||||
|
||||
it('should return false for nodes completely outside viewport', () => {
|
||||
const { isNodeInViewport } = transformState
|
||||
|
||||
// Node far to the right
|
||||
expect(isNodeInViewport([2000, 100], [200, 100], viewport)).toBe(false)
|
||||
|
||||
// Node far to the left
|
||||
expect(isNodeInViewport([-500, 100], [200, 100], viewport)).toBe(false)
|
||||
|
||||
// Node far below
|
||||
expect(isNodeInViewport([100, 1000], [200, 100], viewport)).toBe(false)
|
||||
|
||||
// Node far above
|
||||
expect(isNodeInViewport([100, -500], [200, 100], viewport)).toBe(false)
|
||||
})
|
||||
|
||||
it('should return true for nodes partially in viewport with margin', () => {
|
||||
const { isNodeInViewport } = transformState
|
||||
|
||||
// Node slightly outside but within margin
|
||||
const nodePos: [number, number] = [-50, -50]
|
||||
const nodeSize: [number, number] = [100, 100]
|
||||
|
||||
expect(isNodeInViewport(nodePos, nodeSize, viewport, 0.2)).toBe(true)
|
||||
})
|
||||
|
||||
it('should return false for tiny nodes (size culling)', () => {
|
||||
const { isNodeInViewport } = transformState
|
||||
|
||||
// Node is in viewport but too small
|
||||
const nodePos: [number, number] = [100, 100]
|
||||
const nodeSize: [number, number] = [3, 3] // Less than 4 pixels
|
||||
|
||||
expect(isNodeInViewport(nodePos, nodeSize, viewport)).toBe(false)
|
||||
})
|
||||
|
||||
it('should adjust margin based on zoom level', () => {
|
||||
const { isNodeInViewport, syncWithCanvas } = transformState
|
||||
const mockCanvas = createMockCanvasContext()
|
||||
|
||||
// Test with very low zoom
|
||||
mockCanvas.ds.scale = 0.05
|
||||
syncWithCanvas(mockCanvas as any)
|
||||
|
||||
// Node at edge should still be visible due to increased margin
|
||||
expect(isNodeInViewport([1100, 100], [200, 100], viewport)).toBe(true)
|
||||
|
||||
// Test with high zoom
|
||||
mockCanvas.ds.scale = 4
|
||||
syncWithCanvas(mockCanvas as any)
|
||||
|
||||
// Margin should be tighter
|
||||
expect(isNodeInViewport([1100, 100], [200, 100], viewport)).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('getViewportBounds', () => {
|
||||
beforeEach(() => {
|
||||
const mockCanvas = createMockCanvasContext()
|
||||
mockCanvas.ds.offset = [100, 50]
|
||||
mockCanvas.ds.scale = 2
|
||||
transformState.syncWithCanvas(mockCanvas as any)
|
||||
})
|
||||
|
||||
it('should calculate viewport bounds in canvas coordinates', () => {
|
||||
const { getViewportBounds } = transformState
|
||||
const viewport = { width: 1000, height: 600 }
|
||||
|
||||
const bounds = getViewportBounds(viewport, 0.2)
|
||||
|
||||
// With 20% margin:
|
||||
// marginX = 1000 * 0.2 = 200
|
||||
// marginY = 600 * 0.2 = 120
|
||||
// topLeft in screen: (-200, -120)
|
||||
// bottomRight in screen: (1200, 720)
|
||||
|
||||
// Convert to canvas coordinates (canvas = screen / scale - offset):
|
||||
// topLeft: (-200 / 2 - 100, -120 / 2 - 50) = (-200, -110)
|
||||
// bottomRight: (1200 / 2 - 100, 720 / 2 - 50) = (500, 310)
|
||||
|
||||
expect(bounds.x).toBe(-200)
|
||||
expect(bounds.y).toBe(-110)
|
||||
expect(bounds.width).toBe(700) // 500 - (-200)
|
||||
expect(bounds.height).toBe(420) // 310 - (-110)
|
||||
})
|
||||
|
||||
it('should handle zero margin', () => {
|
||||
const { getViewportBounds } = transformState
|
||||
const viewport = { width: 1000, height: 600 }
|
||||
|
||||
const bounds = getViewportBounds(viewport, 0)
|
||||
|
||||
// No margin, so viewport bounds are exact
|
||||
expect(bounds.x).toBe(-100) // 0 / 2 - 100
|
||||
expect(bounds.y).toBe(-50) // 0 / 2 - 50
|
||||
expect(bounds.width).toBe(500) // 1000 / 2
|
||||
expect(bounds.height).toBe(300) // 600 / 2
|
||||
})
|
||||
})
|
||||
|
||||
describe('edge cases', () => {
|
||||
it('should handle extreme zoom levels', () => {
|
||||
const { syncWithCanvas, canvasToScreen } = transformState
|
||||
const mockCanvas = createMockCanvasContext()
|
||||
|
||||
// Very small zoom
|
||||
mockCanvas.ds.scale = 0.001
|
||||
syncWithCanvas(mockCanvas as any)
|
||||
|
||||
const point1 = canvasToScreen({ x: 1000, y: 1000 })
|
||||
expect(point1.x).toBeCloseTo(1)
|
||||
expect(point1.y).toBeCloseTo(1)
|
||||
|
||||
// Very large zoom
|
||||
mockCanvas.ds.scale = 100
|
||||
syncWithCanvas(mockCanvas as any)
|
||||
|
||||
const point2 = canvasToScreen({ x: 1, y: 1 })
|
||||
expect(point2.x).toBe(100)
|
||||
expect(point2.y).toBe(100)
|
||||
})
|
||||
|
||||
it('should handle zero scale in screenToCanvas', () => {
|
||||
const { syncWithCanvas, screenToCanvas } = transformState
|
||||
const mockCanvas = createMockCanvasContext()
|
||||
|
||||
// Scale of 0 gets converted to 1 by || operator
|
||||
mockCanvas.ds.scale = 0
|
||||
syncWithCanvas(mockCanvas as any)
|
||||
|
||||
// Should use scale of 1 due to camera.z || 1 in implementation
|
||||
const result = screenToCanvas({ x: 100, y: 100 })
|
||||
expect(result.x).toBe(100) // (100 - 0) / 1
|
||||
expect(result.y).toBe(100) // (100 - 0) / 1
|
||||
})
|
||||
})
|
||||
})
|
||||
122
src/renderer/core/layout/utils/layoutMath.test.ts
Normal file
122
src/renderer/core/layout/utils/layoutMath.test.ts
Normal file
@@ -0,0 +1,122 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import type { NodeLayout } from '@/renderer/core/layout/types'
|
||||
import {
|
||||
REROUTE_RADIUS,
|
||||
boundsIntersect,
|
||||
calculateBounds,
|
||||
pointInBounds
|
||||
} from '@/renderer/core/layout/utils/layoutMath'
|
||||
|
||||
describe('layoutMath utils', () => {
|
||||
describe('pointInBounds', () => {
|
||||
it('detects inclusion correctly', () => {
|
||||
const bounds = { x: 10, y: 10, width: 100, height: 50 }
|
||||
expect(pointInBounds({ x: 10, y: 10 }, bounds)).toBe(true)
|
||||
expect(pointInBounds({ x: 110, y: 60 }, bounds)).toBe(true)
|
||||
expect(pointInBounds({ x: 9, y: 10 }, bounds)).toBe(false)
|
||||
expect(pointInBounds({ x: 111, y: 10 }, bounds)).toBe(false)
|
||||
expect(pointInBounds({ x: 10, y: 61 }, bounds)).toBe(false)
|
||||
})
|
||||
|
||||
it('works with zero-size bounds', () => {
|
||||
const zero = { x: 10, y: 20, width: 0, height: 0 }
|
||||
expect(pointInBounds({ x: 10, y: 20 }, zero)).toBe(true)
|
||||
expect(pointInBounds({ x: 10, y: 21 }, zero)).toBe(false)
|
||||
expect(pointInBounds({ x: 9, y: 20 }, zero)).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('boundsIntersect', () => {
|
||||
it('detects intersection correctly', () => {
|
||||
const a = { x: 0, y: 0, width: 10, height: 10 }
|
||||
const b = { x: 5, y: 5, width: 10, height: 10 }
|
||||
const c = { x: 11, y: 0, width: 5, height: 5 }
|
||||
expect(boundsIntersect(a, b)).toBe(true)
|
||||
expect(boundsIntersect(a, c)).toBe(false)
|
||||
})
|
||||
|
||||
it('treats touching edges as intersecting', () => {
|
||||
const a = { x: 0, y: 0, width: 10, height: 10 }
|
||||
const d = { x: 10, y: 0, width: 5, height: 5 } // touches at right edge
|
||||
expect(boundsIntersect(a, d)).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('REROUTE_RADIUS', () => {
|
||||
it('exports a sensible reroute radius', () => {
|
||||
expect(REROUTE_RADIUS).toBeGreaterThan(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('calculateBounds', () => {
|
||||
const createTestNode = (
|
||||
id: string,
|
||||
x: number,
|
||||
y: number,
|
||||
width: number,
|
||||
height: number
|
||||
): NodeLayout => ({
|
||||
id,
|
||||
position: { x, y },
|
||||
size: { width, height },
|
||||
zIndex: 0,
|
||||
visible: true,
|
||||
bounds: { x, y, width, height }
|
||||
})
|
||||
|
||||
it('calculates bounds for single node', () => {
|
||||
const nodes = [createTestNode('1', 10, 20, 100, 50)]
|
||||
const bounds = calculateBounds(nodes)
|
||||
|
||||
expect(bounds).toEqual({
|
||||
x: 10,
|
||||
y: 20,
|
||||
width: 100,
|
||||
height: 50
|
||||
})
|
||||
})
|
||||
|
||||
it('calculates combined bounds for multiple nodes', () => {
|
||||
const nodes = [
|
||||
createTestNode('1', 0, 0, 50, 50), // Top-left: (0,0) to (50,50)
|
||||
createTestNode('2', 100, 100, 30, 40), // Bottom-right: (100,100) to (130,140)
|
||||
createTestNode('3', 25, 75, 20, 10) // Middle: (25,75) to (45,85)
|
||||
]
|
||||
const bounds = calculateBounds(nodes)
|
||||
|
||||
expect(bounds).toEqual({
|
||||
x: 0, // leftmost
|
||||
y: 0, // topmost
|
||||
width: 130, // rightmost (130) - leftmost (0)
|
||||
height: 140 // bottommost (140) - topmost (0)
|
||||
})
|
||||
})
|
||||
|
||||
it('handles nodes with negative positions', () => {
|
||||
const nodes = [
|
||||
createTestNode('1', -50, -30, 40, 20), // (-50,-30) to (-10,-10)
|
||||
createTestNode('2', 10, 15, 25, 35) // (10,15) to (35,50)
|
||||
]
|
||||
const bounds = calculateBounds(nodes)
|
||||
|
||||
expect(bounds).toEqual({
|
||||
x: -50,
|
||||
y: -30,
|
||||
width: 85, // 35 - (-50)
|
||||
height: 80 // 50 - (-30)
|
||||
})
|
||||
})
|
||||
|
||||
it('handles empty array', () => {
|
||||
const bounds = calculateBounds([])
|
||||
|
||||
expect(bounds).toEqual({
|
||||
x: Infinity,
|
||||
y: Infinity,
|
||||
width: -Infinity,
|
||||
height: -Infinity
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
18
src/renderer/core/layout/utils/layoutUtils.test.ts
Normal file
18
src/renderer/core/layout/utils/layoutUtils.test.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { makeLinkSegmentKey } from '@/renderer/core/layout/utils/layoutUtils'
|
||||
|
||||
describe('layoutUtils', () => {
|
||||
describe('makeLinkSegmentKey', () => {
|
||||
it('creates stable keys for null reroute', () => {
|
||||
expect(makeLinkSegmentKey(10, null)).toBe('10:final')
|
||||
expect(makeLinkSegmentKey(42, null)).toBe('42:final')
|
||||
})
|
||||
|
||||
it('creates stable keys for numeric reroute ids', () => {
|
||||
expect(makeLinkSegmentKey(10, 3)).toBe('10:3')
|
||||
expect(makeLinkSegmentKey(42, 0)).toBe('42:0')
|
||||
expect(makeLinkSegmentKey(42, 7)).toBe('42:7')
|
||||
})
|
||||
})
|
||||
})
|
||||
47
src/renderer/core/layout/utils/mappers.test.ts
Normal file
47
src/renderer/core/layout/utils/mappers.test.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import * as Y from 'yjs'
|
||||
|
||||
import {
|
||||
NODE_LAYOUT_DEFAULTS,
|
||||
yNodeToLayout
|
||||
} from '@/renderer/core/layout/utils/mappers'
|
||||
import type { NodeLayoutMap } from '@/renderer/core/layout/utils/mappers'
|
||||
|
||||
describe('mappers', () => {
|
||||
it('yNodeToLayout reads from Yjs-attached map', () => {
|
||||
const layout = {
|
||||
id: 'node-1',
|
||||
position: { x: 12, y: 34 },
|
||||
size: { width: 111, height: 222 },
|
||||
zIndex: 5,
|
||||
visible: true,
|
||||
bounds: { x: 12, y: 34, width: 111, height: 222 }
|
||||
}
|
||||
|
||||
const doc = new Y.Doc()
|
||||
const ynode = doc.getMap('node') as NodeLayoutMap
|
||||
ynode.set('id', layout.id)
|
||||
ynode.set('position', layout.position)
|
||||
ynode.set('size', layout.size)
|
||||
ynode.set('zIndex', layout.zIndex)
|
||||
ynode.set('visible', layout.visible)
|
||||
ynode.set('bounds', layout.bounds)
|
||||
|
||||
const back = yNodeToLayout(ynode)
|
||||
expect(back).toEqual(layout)
|
||||
})
|
||||
|
||||
it('yNodeToLayout applies defaults for missing fields', () => {
|
||||
const doc = new Y.Doc()
|
||||
const ynode = doc.getMap('node') as NodeLayoutMap
|
||||
// Don't set any fields - they should all use defaults
|
||||
|
||||
const back = yNodeToLayout(ynode)
|
||||
expect(back.id).toBe(NODE_LAYOUT_DEFAULTS.id)
|
||||
expect(back.position).toEqual(NODE_LAYOUT_DEFAULTS.position)
|
||||
expect(back.size).toEqual(NODE_LAYOUT_DEFAULTS.size)
|
||||
expect(back.zIndex).toEqual(NODE_LAYOUT_DEFAULTS.zIndex)
|
||||
expect(back.visible).toEqual(NODE_LAYOUT_DEFAULTS.visible)
|
||||
expect(back.bounds).toEqual(NODE_LAYOUT_DEFAULTS.bounds)
|
||||
})
|
||||
})
|
||||
270
src/renderer/core/spatial/QuadTree.test.ts
Normal file
270
src/renderer/core/spatial/QuadTree.test.ts
Normal file
@@ -0,0 +1,270 @@
|
||||
import { beforeEach, describe, expect, it } from 'vitest'
|
||||
|
||||
import { QuadTree } from '@/renderer/core/spatial/QuadTree'
|
||||
import type { Bounds } from '@/renderer/core/spatial/QuadTree'
|
||||
|
||||
describe('QuadTree', () => {
|
||||
let quadTree: QuadTree<string>
|
||||
const worldBounds: Bounds = { x: 0, y: 0, width: 1000, height: 1000 }
|
||||
|
||||
beforeEach(() => {
|
||||
quadTree = new QuadTree<string>(worldBounds, {
|
||||
maxDepth: 4,
|
||||
maxItemsPerNode: 4
|
||||
})
|
||||
})
|
||||
|
||||
describe('insertion', () => {
|
||||
it('should insert items within bounds', () => {
|
||||
const success = quadTree.insert(
|
||||
'node1',
|
||||
{ x: 100, y: 100, width: 50, height: 50 },
|
||||
'node1'
|
||||
)
|
||||
expect(success).toBe(true)
|
||||
expect(quadTree.size).toBe(1)
|
||||
})
|
||||
|
||||
it('should reject items outside bounds', () => {
|
||||
const success = quadTree.insert(
|
||||
'node1',
|
||||
{ x: -100, y: -100, width: 50, height: 50 },
|
||||
'node1'
|
||||
)
|
||||
expect(success).toBe(false)
|
||||
expect(quadTree.size).toBe(0)
|
||||
})
|
||||
|
||||
it('should handle duplicate IDs by replacing', () => {
|
||||
quadTree.insert(
|
||||
'node1',
|
||||
{ x: 100, y: 100, width: 50, height: 50 },
|
||||
'data1'
|
||||
)
|
||||
quadTree.insert(
|
||||
'node1',
|
||||
{ x: 200, y: 200, width: 50, height: 50 },
|
||||
'data2'
|
||||
)
|
||||
|
||||
expect(quadTree.size).toBe(1)
|
||||
const results = quadTree.query({
|
||||
x: 150,
|
||||
y: 150,
|
||||
width: 100,
|
||||
height: 100
|
||||
})
|
||||
expect(results).toContain('data2')
|
||||
expect(results).not.toContain('data1')
|
||||
})
|
||||
})
|
||||
|
||||
describe('querying', () => {
|
||||
beforeEach(() => {
|
||||
// Insert test nodes in a grid pattern
|
||||
for (let x = 0; x < 10; x++) {
|
||||
for (let y = 0; y < 10; y++) {
|
||||
const id = `node_${x}_${y}`
|
||||
quadTree.insert(
|
||||
id,
|
||||
{
|
||||
x: x * 100,
|
||||
y: y * 100,
|
||||
width: 50,
|
||||
height: 50
|
||||
},
|
||||
id
|
||||
)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
it('should find nodes within query bounds', () => {
|
||||
const results = quadTree.query({ x: 0, y: 0, width: 250, height: 250 })
|
||||
expect(results.length).toBe(9) // 3x3 grid
|
||||
})
|
||||
|
||||
it('should return empty array for out-of-bounds query', () => {
|
||||
const results = quadTree.query({
|
||||
x: 2000,
|
||||
y: 2000,
|
||||
width: 100,
|
||||
height: 100
|
||||
})
|
||||
expect(results.length).toBe(0)
|
||||
})
|
||||
|
||||
it('should handle partial overlaps', () => {
|
||||
const results = quadTree.query({ x: 25, y: 25, width: 100, height: 100 })
|
||||
expect(results.length).toBe(4) // 2x2 grid due to overlap
|
||||
})
|
||||
|
||||
it('should handle large query areas efficiently', () => {
|
||||
const startTime = performance.now()
|
||||
const results = quadTree.query({ x: 0, y: 0, width: 1000, height: 1000 })
|
||||
const queryTime = performance.now() - startTime
|
||||
|
||||
expect(results.length).toBe(100) // All nodes
|
||||
expect(queryTime).toBeLessThan(5) // Should be fast
|
||||
})
|
||||
})
|
||||
|
||||
describe('removal', () => {
|
||||
it('should remove existing items', () => {
|
||||
quadTree.insert(
|
||||
'node1',
|
||||
{ x: 100, y: 100, width: 50, height: 50 },
|
||||
'node1'
|
||||
)
|
||||
expect(quadTree.size).toBe(1)
|
||||
|
||||
const success = quadTree.remove('node1')
|
||||
expect(success).toBe(true)
|
||||
expect(quadTree.size).toBe(0)
|
||||
})
|
||||
|
||||
it('should handle removal of non-existent items', () => {
|
||||
const success = quadTree.remove('nonexistent')
|
||||
expect(success).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('updating', () => {
|
||||
it('should update item position', () => {
|
||||
quadTree.insert(
|
||||
'node1',
|
||||
{ x: 100, y: 100, width: 50, height: 50 },
|
||||
'node1'
|
||||
)
|
||||
|
||||
const success = quadTree.update('node1', {
|
||||
x: 200,
|
||||
y: 200,
|
||||
width: 50,
|
||||
height: 50
|
||||
})
|
||||
expect(success).toBe(true)
|
||||
|
||||
// Should not find at old position
|
||||
const oldResults = quadTree.query({
|
||||
x: 75,
|
||||
y: 75,
|
||||
width: 100,
|
||||
height: 100
|
||||
})
|
||||
expect(oldResults).not.toContain('node1')
|
||||
|
||||
// Should find at new position
|
||||
const newResults = quadTree.query({
|
||||
x: 175,
|
||||
y: 175,
|
||||
width: 100,
|
||||
height: 100
|
||||
})
|
||||
expect(newResults).toContain('node1')
|
||||
})
|
||||
})
|
||||
|
||||
describe('subdivision', () => {
|
||||
it('should subdivide when exceeding max items', () => {
|
||||
// Insert 5 items (max is 4) to trigger subdivision
|
||||
for (let i = 0; i < 5; i++) {
|
||||
quadTree.insert(
|
||||
`node${i}`,
|
||||
{
|
||||
x: i * 10,
|
||||
y: i * 10,
|
||||
width: 5,
|
||||
height: 5
|
||||
},
|
||||
`node${i}`
|
||||
)
|
||||
}
|
||||
|
||||
expect(quadTree.size).toBe(5)
|
||||
|
||||
// Verify all items can still be found
|
||||
const allResults = quadTree.query(worldBounds)
|
||||
expect(allResults.length).toBe(5)
|
||||
})
|
||||
})
|
||||
|
||||
describe('performance', () => {
|
||||
it('should handle 1000 nodes efficiently', () => {
|
||||
const insertStart = performance.now()
|
||||
|
||||
// Insert 1000 nodes
|
||||
for (let i = 0; i < 1000; i++) {
|
||||
const x = Math.random() * 900
|
||||
const y = Math.random() * 900
|
||||
quadTree.insert(
|
||||
`node${i}`,
|
||||
{
|
||||
x,
|
||||
y,
|
||||
width: 50,
|
||||
height: 50
|
||||
},
|
||||
`node${i}`
|
||||
)
|
||||
}
|
||||
|
||||
const insertTime = performance.now() - insertStart
|
||||
expect(insertTime).toBeLessThan(50) // Should be fast
|
||||
|
||||
// Query performance
|
||||
const queryStart = performance.now()
|
||||
const results = quadTree.query({
|
||||
x: 400,
|
||||
y: 400,
|
||||
width: 200,
|
||||
height: 200
|
||||
})
|
||||
const queryTime = performance.now() - queryStart
|
||||
|
||||
expect(queryTime).toBeLessThan(2) // Queries should be very fast
|
||||
expect(results.length).toBeGreaterThan(0)
|
||||
expect(results.length).toBeLessThan(1000) // Should cull most nodes
|
||||
})
|
||||
})
|
||||
|
||||
describe('edge cases', () => {
|
||||
it('should handle zero-sized bounds', () => {
|
||||
const success = quadTree.insert(
|
||||
'point',
|
||||
{ x: 100, y: 100, width: 0, height: 0 },
|
||||
'point'
|
||||
)
|
||||
expect(success).toBe(true)
|
||||
|
||||
const results = quadTree.query({ x: 99, y: 99, width: 2, height: 2 })
|
||||
expect(results).toContain('point')
|
||||
})
|
||||
|
||||
it('should handle items spanning multiple quadrants', () => {
|
||||
const success = quadTree.insert(
|
||||
'large',
|
||||
{
|
||||
x: 400,
|
||||
y: 400,
|
||||
width: 200,
|
||||
height: 200
|
||||
},
|
||||
'large'
|
||||
)
|
||||
expect(success).toBe(true)
|
||||
|
||||
// Should be found when querying any overlapping quadrant
|
||||
const topLeft = quadTree.query({ x: 0, y: 0, width: 500, height: 500 })
|
||||
const bottomRight = quadTree.query({
|
||||
x: 500,
|
||||
y: 500,
|
||||
width: 500,
|
||||
height: 500
|
||||
})
|
||||
|
||||
expect(topLeft).toContain('large')
|
||||
expect(bottomRight).toContain('large')
|
||||
})
|
||||
})
|
||||
})
|
||||
273
src/renderer/core/thumbnail/useWorkflowThumbnail.test.ts
Normal file
273
src/renderer/core/thumbnail/useWorkflowThumbnail.test.ts
Normal file
@@ -0,0 +1,273 @@
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import type { ComfyWorkflow } from '@/platform/workflow/management/stores/workflowStore'
|
||||
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
|
||||
|
||||
vi.mock('@/renderer/core/thumbnail/graphThumbnailRenderer', () => ({
|
||||
createGraphThumbnail: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('@/scripts/api', () => ({
|
||||
api: {
|
||||
moveUserData: vi.fn(),
|
||||
listUserDataFullInfo: vi.fn(),
|
||||
addEventListener: vi.fn(),
|
||||
getUserData: vi.fn(),
|
||||
storeUserData: vi.fn(),
|
||||
apiURL: vi.fn((path: string) => `/api${path}`)
|
||||
}
|
||||
}))
|
||||
|
||||
const { useWorkflowThumbnail } =
|
||||
await import('@/renderer/core/thumbnail/useWorkflowThumbnail')
|
||||
const { createGraphThumbnail } =
|
||||
await import('@/renderer/core/thumbnail/graphThumbnailRenderer')
|
||||
const { api } = await import('@/scripts/api')
|
||||
|
||||
describe('useWorkflowThumbnail', () => {
|
||||
let workflowStore: ReturnType<typeof useWorkflowStore>
|
||||
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia())
|
||||
workflowStore = useWorkflowStore()
|
||||
|
||||
// Clear any existing thumbnails from previous tests BEFORE mocking
|
||||
const { clearAllThumbnails } = useWorkflowThumbnail()
|
||||
clearAllThumbnails()
|
||||
|
||||
// Now set up mocks
|
||||
vi.clearAllMocks()
|
||||
|
||||
global.URL.createObjectURL = vi.fn(() => 'data:image/png;base64,test')
|
||||
global.URL.revokeObjectURL = vi.fn()
|
||||
|
||||
// Mock API responses
|
||||
vi.mocked(api.moveUserData).mockResolvedValue({ status: 200 } as Response)
|
||||
|
||||
// Default createGraphThumbnail to return test value
|
||||
vi.mocked(createGraphThumbnail).mockReturnValue(
|
||||
'data:image/png;base64,test'
|
||||
)
|
||||
})
|
||||
|
||||
it('should capture minimap thumbnail', async () => {
|
||||
const { createMinimapPreview } = useWorkflowThumbnail()
|
||||
const thumbnail = await createMinimapPreview()
|
||||
|
||||
expect(createGraphThumbnail).toHaveBeenCalledOnce()
|
||||
expect(thumbnail).toBe('data:image/png;base64,test')
|
||||
})
|
||||
|
||||
it('should store and retrieve thumbnails', async () => {
|
||||
const { storeThumbnail, getThumbnail } = useWorkflowThumbnail()
|
||||
|
||||
const mockWorkflow = { key: 'test-workflow-key' } as ComfyWorkflow
|
||||
|
||||
await storeThumbnail(mockWorkflow)
|
||||
|
||||
const thumbnail = getThumbnail('test-workflow-key')
|
||||
expect(thumbnail).toBe('data:image/png;base64,test')
|
||||
})
|
||||
|
||||
it('should clear thumbnail', async () => {
|
||||
const { storeThumbnail, getThumbnail, clearThumbnail } =
|
||||
useWorkflowThumbnail()
|
||||
|
||||
const mockWorkflow = { key: 'test-workflow-key' } as ComfyWorkflow
|
||||
|
||||
await storeThumbnail(mockWorkflow)
|
||||
|
||||
expect(getThumbnail('test-workflow-key')).toBeDefined()
|
||||
|
||||
clearThumbnail('test-workflow-key')
|
||||
|
||||
expect(URL.revokeObjectURL).toHaveBeenCalledWith(
|
||||
'data:image/png;base64,test'
|
||||
)
|
||||
expect(getThumbnail('test-workflow-key')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('should clear all thumbnails', async () => {
|
||||
const { storeThumbnail, getThumbnail, clearAllThumbnails } =
|
||||
useWorkflowThumbnail()
|
||||
|
||||
const mockWorkflow1 = { key: 'workflow-1' } as ComfyWorkflow
|
||||
const mockWorkflow2 = { key: 'workflow-2' } as ComfyWorkflow
|
||||
|
||||
await storeThumbnail(mockWorkflow1)
|
||||
await storeThumbnail(mockWorkflow2)
|
||||
|
||||
expect(getThumbnail('workflow-1')).toBeDefined()
|
||||
expect(getThumbnail('workflow-2')).toBeDefined()
|
||||
|
||||
clearAllThumbnails()
|
||||
|
||||
expect(URL.revokeObjectURL).toHaveBeenCalledTimes(2)
|
||||
expect(getThumbnail('workflow-1')).toBeUndefined()
|
||||
expect(getThumbnail('workflow-2')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('should automatically handle thumbnail cleanup when workflow is renamed', async () => {
|
||||
const { storeThumbnail, getThumbnail, workflowThumbnails } =
|
||||
useWorkflowThumbnail()
|
||||
|
||||
// Create a temporary workflow
|
||||
const workflow = workflowStore.createTemporary('test-workflow.json')
|
||||
const originalKey = workflow.key
|
||||
|
||||
// Store thumbnail for the workflow
|
||||
await storeThumbnail(workflow)
|
||||
expect(getThumbnail(originalKey)).toBe('data:image/png;base64,test')
|
||||
expect(workflowThumbnails.value.size).toBe(1)
|
||||
|
||||
// Rename the workflow - this should automatically handle thumbnail cleanup
|
||||
const newPath = 'workflows/renamed-workflow.json'
|
||||
await workflowStore.renameWorkflow(workflow, newPath)
|
||||
|
||||
const newKey = workflow.key // The workflow's key should now be the new path
|
||||
|
||||
// The thumbnail should be moved from old key to new key
|
||||
expect(getThumbnail(originalKey)).toBeUndefined()
|
||||
expect(getThumbnail(newKey)).toBe('data:image/png;base64,test')
|
||||
expect(workflowThumbnails.value.size).toBe(1)
|
||||
|
||||
// No URL should be revoked since we're moving the thumbnail, not deleting it
|
||||
expect(URL.revokeObjectURL).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should properly revoke old URL when storing thumbnail over existing one', async () => {
|
||||
const { storeThumbnail, getThumbnail } = useWorkflowThumbnail()
|
||||
|
||||
const mockWorkflow = { key: 'test-workflow' } as ComfyWorkflow
|
||||
|
||||
// Store first thumbnail
|
||||
await storeThumbnail(mockWorkflow)
|
||||
const firstThumbnail = getThumbnail('test-workflow')
|
||||
expect(firstThumbnail).toBe('data:image/png;base64,test')
|
||||
|
||||
// Reset the mock to track new calls and create different URL
|
||||
vi.clearAllMocks()
|
||||
global.URL.createObjectURL = vi.fn(() => 'data:image/png;base64,test2')
|
||||
vi.mocked(createGraphThumbnail).mockReturnValue(
|
||||
'data:image/png;base64,test2'
|
||||
)
|
||||
|
||||
// Store second thumbnail for same workflow - should revoke the first URL
|
||||
await storeThumbnail(mockWorkflow)
|
||||
const secondThumbnail = getThumbnail('test-workflow')
|
||||
expect(secondThumbnail).toBe('data:image/png;base64,test2')
|
||||
|
||||
// URL.revokeObjectURL should have been called for the first thumbnail
|
||||
expect(URL.revokeObjectURL).toHaveBeenCalledWith(
|
||||
'data:image/png;base64,test'
|
||||
)
|
||||
expect(URL.revokeObjectURL).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('should clear thumbnail when workflow is deleted', async () => {
|
||||
const { storeThumbnail, getThumbnail, workflowThumbnails } =
|
||||
useWorkflowThumbnail()
|
||||
|
||||
// Create a workflow and store thumbnail
|
||||
const workflow = workflowStore.createTemporary('test-delete.json')
|
||||
await storeThumbnail(workflow)
|
||||
|
||||
expect(getThumbnail(workflow.key)).toBe('data:image/png;base64,test')
|
||||
expect(workflowThumbnails.value.size).toBe(1)
|
||||
|
||||
// Delete the workflow - this should clear the thumbnail
|
||||
await workflowStore.deleteWorkflow(workflow)
|
||||
|
||||
// Thumbnail should be cleared and URL revoked
|
||||
expect(getThumbnail(workflow.key)).toBeUndefined()
|
||||
expect(workflowThumbnails.value.size).toBe(0)
|
||||
expect(URL.revokeObjectURL).toHaveBeenCalledWith(
|
||||
'data:image/png;base64,test'
|
||||
)
|
||||
})
|
||||
|
||||
it('should clear thumbnail when temporary workflow is closed', async () => {
|
||||
const { storeThumbnail, getThumbnail, workflowThumbnails } =
|
||||
useWorkflowThumbnail()
|
||||
|
||||
// Create a temporary workflow and store thumbnail
|
||||
const workflow = workflowStore.createTemporary('temp-workflow.json')
|
||||
await storeThumbnail(workflow)
|
||||
|
||||
expect(getThumbnail(workflow.key)).toBe('data:image/png;base64,test')
|
||||
expect(workflowThumbnails.value.size).toBe(1)
|
||||
|
||||
// Close the workflow - this should clear the thumbnail for temporary workflows
|
||||
await workflowStore.closeWorkflow(workflow)
|
||||
|
||||
// Thumbnail should be cleared and URL revoked
|
||||
expect(getThumbnail(workflow.key)).toBeUndefined()
|
||||
expect(workflowThumbnails.value.size).toBe(0)
|
||||
expect(URL.revokeObjectURL).toHaveBeenCalledWith(
|
||||
'data:image/png;base64,test'
|
||||
)
|
||||
})
|
||||
|
||||
it('should handle multiple renames without leaking', async () => {
|
||||
const { storeThumbnail, getThumbnail, workflowThumbnails } =
|
||||
useWorkflowThumbnail()
|
||||
|
||||
// Create workflow and store thumbnail
|
||||
const workflow = workflowStore.createTemporary('original.json')
|
||||
await storeThumbnail(workflow)
|
||||
const originalKey = workflow.key
|
||||
|
||||
expect(getThumbnail(originalKey)).toBe('data:image/png;base64,test')
|
||||
expect(workflowThumbnails.value.size).toBe(1)
|
||||
|
||||
// Rename multiple times
|
||||
await workflowStore.renameWorkflow(workflow, 'workflows/renamed1.json')
|
||||
const firstRenameKey = workflow.key
|
||||
|
||||
expect(getThumbnail(originalKey)).toBeUndefined()
|
||||
expect(getThumbnail(firstRenameKey)).toBe('data:image/png;base64,test')
|
||||
expect(workflowThumbnails.value.size).toBe(1)
|
||||
|
||||
await workflowStore.renameWorkflow(workflow, 'workflows/renamed2.json')
|
||||
const secondRenameKey = workflow.key
|
||||
|
||||
expect(getThumbnail(originalKey)).toBeUndefined()
|
||||
expect(getThumbnail(firstRenameKey)).toBeUndefined()
|
||||
expect(getThumbnail(secondRenameKey)).toBe('data:image/png;base64,test')
|
||||
expect(workflowThumbnails.value.size).toBe(1)
|
||||
|
||||
// No URLs should be revoked since we're just moving thumbnails
|
||||
expect(URL.revokeObjectURL).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should handle edge cases like empty keys or invalid operations', async () => {
|
||||
const {
|
||||
getThumbnail,
|
||||
clearThumbnail,
|
||||
moveWorkflowThumbnail,
|
||||
workflowThumbnails
|
||||
} = useWorkflowThumbnail()
|
||||
|
||||
// Test getting non-existent thumbnail
|
||||
expect(getThumbnail('non-existent')).toBeUndefined()
|
||||
|
||||
// Test clearing non-existent thumbnail (should not throw)
|
||||
expect(() => clearThumbnail('non-existent')).not.toThrow()
|
||||
expect(URL.revokeObjectURL).not.toHaveBeenCalled()
|
||||
|
||||
// Test moving non-existent thumbnail (should not throw)
|
||||
expect(() => moveWorkflowThumbnail('non-existent', 'target')).not.toThrow()
|
||||
expect(workflowThumbnails.value.size).toBe(0)
|
||||
|
||||
// Test moving to same key (should not cause issues)
|
||||
const { storeThumbnail } = useWorkflowThumbnail()
|
||||
const mockWorkflow = { key: 'test-key' } as ComfyWorkflow
|
||||
await storeThumbnail(mockWorkflow)
|
||||
|
||||
expect(workflowThumbnails.value.size).toBe(1)
|
||||
moveWorkflowThumbnail('test-key', 'test-key')
|
||||
expect(workflowThumbnails.value.size).toBe(1)
|
||||
expect(getThumbnail('test-key')).toBe('data:image/png;base64,test')
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user