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:
Alexander Brown
2026-01-05 16:32:24 -08:00
committed by GitHub
parent 832588c7a9
commit 10feb1fd5b
272 changed files with 483 additions and 1239 deletions

View 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()
})
})
})

View 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
}
})
})

View 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 })
)
})
})

View 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
})
})
})

View 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
})
})
})
})

View 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')
})
})
})

View 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)
})
})

View 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')
})
})
})

View 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')
})
})