[refactor] Migrate minimap to domain-driven renderer architecture (#5069)

* move ref initialization to the component

* remove redundant init

* [refactor] Move minimap to domain-driven renderer structure

- Create new src/renderer/extensions/minimap/ structure following domain-driven design
- Add composables: useMinimapGraph, useMinimapViewport, useMinimapRenderer, useMinimapInteraction, useMinimapSettings
- Add minimapCanvasRenderer with efficient batched rendering
- Add comprehensive type definitions in types.ts
- Remove old src/composables/useMinimap.ts composable
- Implement proper separation of concerns with dedicated composables for each domain

The new structure provides cleaner APIs, better performance through batched rendering,
and improved maintainability through domain separation.

* [test] Fix minimap tests for new renderer structure

- Update all test imports to use new renderer paths
- Fix mock implementations to match new composable APIs
- Add proper RAF mocking for throttled functions
- Fix type assertions to handle strict TypeScript checks
- Update test expectations for new implementation behavior
- Fix viewport transform calculations in tests
- Handle async/throttled behavior correctly in tests

All 28 minimap tests now passing with new architecture.

* [fix] Remove unused init import in MiniMap component

* [refactor] Move useWorkflowThumbnail to renderer/thumbnail structure

- Moved useWorkflowThumbnail from src/composables to src/renderer/thumbnail/composables
- Updated all imports in components, stores and services
- Moved test file to match new structure
- This ensures all rendering-related composables live in the renderer directory

* [test] Fix minimap canvas renderer test for connections

- Fixed mock setup for graph links to match LiteGraph's hybrid Map/Object structure
- LiteGraph expects links to be accessible both as a Map and as an object
- Test now properly verifies connection rendering functionality
This commit is contained in:
Christian Byrne
2025-08-17 21:24:08 -07:00
committed by GitHub
parent ceac8f3741
commit 5a35562d3d
25 changed files with 3025 additions and 913 deletions

View File

@@ -0,0 +1,299 @@
import { useThrottleFn } from '@vueuse/core'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { ref } from 'vue'
import type { LGraph, LGraphNode } from '@/lib/litegraph/src/litegraph'
import { useMinimapGraph } from '@/renderer/extensions/minimap/composables/useMinimapGraph'
import { api } from '@/scripts/api'
vi.mock('@vueuse/core', () => ({
useThrottleFn: vi.fn((fn) => fn)
}))
vi.mock('@/scripts/api', () => ({
api: {
addEventListener: vi.fn(),
removeEventListener: vi.fn()
}
}))
describe('useMinimapGraph', () => {
let mockGraph: LGraph
let onGraphChangedMock: ReturnType<typeof vi.fn>
beforeEach(() => {
vi.clearAllMocks()
mockGraph = {
id: 'test-graph-123',
_nodes: [
{ id: '1', pos: [100, 100], size: [150, 80] },
{ id: '2', pos: [300, 200], size: [120, 60] }
],
links: { link1: { id: 'link1' } },
onNodeAdded: vi.fn(),
onNodeRemoved: vi.fn(),
onConnectionChange: vi.fn()
} as any
onGraphChangedMock = vi.fn()
})
it('should initialize with empty state', () => {
const graphRef = ref(mockGraph as any)
const graphManager = useMinimapGraph(graphRef, onGraphChangedMock)
expect(graphManager.updateFlags.value).toEqual({
bounds: false,
nodes: false,
connections: false,
viewport: false
})
})
it('should setup event listeners on init', () => {
const graphRef = ref(mockGraph as any)
const graphManager = useMinimapGraph(graphRef, onGraphChangedMock)
graphManager.init()
expect(api.addEventListener).toHaveBeenCalledWith(
'graphChanged',
expect.any(Function)
)
})
it('should wrap graph callbacks on setup', () => {
const originalOnNodeAdded = vi.fn()
const originalOnNodeRemoved = vi.fn()
const originalOnConnectionChange = vi.fn()
mockGraph.onNodeAdded = originalOnNodeAdded
mockGraph.onNodeRemoved = originalOnNodeRemoved
mockGraph.onConnectionChange = originalOnConnectionChange
const graphRef = ref(mockGraph as any)
const graphManager = useMinimapGraph(graphRef, onGraphChangedMock)
graphManager.setupEventListeners()
// Should wrap the callbacks
expect(mockGraph.onNodeAdded).not.toBe(originalOnNodeAdded)
expect(mockGraph.onNodeRemoved).not.toBe(originalOnNodeRemoved)
expect(mockGraph.onConnectionChange).not.toBe(originalOnConnectionChange)
// Test wrapped callbacks
const testNode = { id: '3' } as LGraphNode
mockGraph.onNodeAdded!(testNode)
expect(originalOnNodeAdded).toHaveBeenCalledWith(testNode)
expect(onGraphChangedMock).toHaveBeenCalled()
})
it('should prevent duplicate event listener setup', () => {
const graphRef = ref(mockGraph as any)
const graphManager = useMinimapGraph(graphRef, onGraphChangedMock)
// Store original callbacks for comparison
// const originalCallbacks = {
// onNodeAdded: mockGraph.onNodeAdded,
// onNodeRemoved: mockGraph.onNodeRemoved,
// onConnectionChange: mockGraph.onConnectionChange
// }
graphManager.setupEventListeners()
const wrappedCallbacks = {
onNodeAdded: mockGraph.onNodeAdded,
onNodeRemoved: mockGraph.onNodeRemoved,
onConnectionChange: mockGraph.onConnectionChange
}
// Setup again - should not re-wrap
graphManager.setupEventListeners()
expect(mockGraph.onNodeAdded).toBe(wrappedCallbacks.onNodeAdded)
expect(mockGraph.onNodeRemoved).toBe(wrappedCallbacks.onNodeRemoved)
expect(mockGraph.onConnectionChange).toBe(
wrappedCallbacks.onConnectionChange
)
})
it('should cleanup event listeners properly', () => {
const originalOnNodeAdded = vi.fn()
const originalOnNodeRemoved = vi.fn()
const originalOnConnectionChange = vi.fn()
mockGraph.onNodeAdded = originalOnNodeAdded
mockGraph.onNodeRemoved = originalOnNodeRemoved
mockGraph.onConnectionChange = originalOnConnectionChange
const graphRef = ref(mockGraph as any)
const graphManager = useMinimapGraph(graphRef, onGraphChangedMock)
graphManager.setupEventListeners()
graphManager.cleanupEventListeners()
// Should restore original callbacks
expect(mockGraph.onNodeAdded).toBe(originalOnNodeAdded)
expect(mockGraph.onNodeRemoved).toBe(originalOnNodeRemoved)
expect(mockGraph.onConnectionChange).toBe(originalOnConnectionChange)
})
it('should handle cleanup for never-setup graph', () => {
const consoleErrorSpy = vi
.spyOn(console, 'error')
.mockImplementation(() => {})
const graphRef = ref(mockGraph as any)
const graphManager = useMinimapGraph(graphRef, onGraphChangedMock)
graphManager.cleanupEventListeners()
expect(consoleErrorSpy).toHaveBeenCalledWith(
'Attempted to cleanup event listeners for graph that was never set up'
)
consoleErrorSpy.mockRestore()
})
it('should detect node position changes', () => {
const graphRef = ref(mockGraph as any)
const graphManager = useMinimapGraph(graphRef, onGraphChangedMock)
// First check - cache initial state
let hasChanges = graphManager.checkForChanges()
expect(hasChanges).toBe(true) // Initial cache population
// No changes
hasChanges = graphManager.checkForChanges()
expect(hasChanges).toBe(false)
// Change node position
mockGraph._nodes[0].pos = [200, 150]
hasChanges = graphManager.checkForChanges()
expect(hasChanges).toBe(true)
expect(graphManager.updateFlags.value.bounds).toBe(true)
expect(graphManager.updateFlags.value.nodes).toBe(true)
})
it('should detect node count changes', () => {
const graphRef = ref(mockGraph as any)
const graphManager = useMinimapGraph(graphRef, onGraphChangedMock)
// Cache initial state
graphManager.checkForChanges()
// Add a node
mockGraph._nodes.push({ id: '3', pos: [400, 300], size: [100, 50] } as any)
const hasChanges = graphManager.checkForChanges()
expect(hasChanges).toBe(true)
expect(graphManager.updateFlags.value.bounds).toBe(true)
expect(graphManager.updateFlags.value.nodes).toBe(true)
})
it('should detect connection changes', () => {
const graphRef = ref(mockGraph as any)
const graphManager = useMinimapGraph(graphRef, onGraphChangedMock)
// Cache initial state
graphManager.checkForChanges()
// Change connections
mockGraph.links = new Map([
[1, { id: 1 }],
[2, { id: 2 }]
]) as any
const hasChanges = graphManager.checkForChanges()
expect(hasChanges).toBe(true)
expect(graphManager.updateFlags.value.connections).toBe(true)
})
it('should handle node removal in callbacks', () => {
const originalOnNodeRemoved = vi.fn()
mockGraph.onNodeRemoved = originalOnNodeRemoved
const graphRef = ref(mockGraph as any)
const graphManager = useMinimapGraph(graphRef, onGraphChangedMock)
graphManager.setupEventListeners()
const removedNode = { id: '2' } as LGraphNode
mockGraph.onNodeRemoved!(removedNode)
expect(originalOnNodeRemoved).toHaveBeenCalledWith(removedNode)
expect(onGraphChangedMock).toHaveBeenCalled()
})
it('should destroy properly', () => {
const graphRef = ref(mockGraph as any)
const graphManager = useMinimapGraph(graphRef, onGraphChangedMock)
graphManager.init()
graphManager.setupEventListeners()
graphManager.destroy()
expect(api.removeEventListener).toHaveBeenCalledWith(
'graphChanged',
expect.any(Function)
)
})
it('should clear cache', () => {
const graphRef = ref(mockGraph as any)
const graphManager = useMinimapGraph(graphRef, onGraphChangedMock)
// Populate cache
graphManager.checkForChanges()
// Clear cache
graphManager.clearCache()
// Should detect changes again after clear
const hasChanges = graphManager.checkForChanges()
expect(hasChanges).toBe(true)
})
it('should handle null graph gracefully', () => {
const graphRef = ref(null as any)
const graphManager = useMinimapGraph(graphRef, onGraphChangedMock)
expect(() => graphManager.setupEventListeners()).not.toThrow()
expect(() => graphManager.cleanupEventListeners()).not.toThrow()
expect(graphManager.checkForChanges()).toBe(false)
})
it('should clean up removed nodes from cache', () => {
const graphRef = ref(mockGraph as any)
const graphManager = useMinimapGraph(graphRef, onGraphChangedMock)
// Cache initial state
graphManager.checkForChanges()
// Remove a node
mockGraph._nodes = mockGraph._nodes.filter((n) => n.id !== '2')
const hasChanges = graphManager.checkForChanges()
expect(hasChanges).toBe(true)
expect(graphManager.updateFlags.value.bounds).toBe(true)
})
it('should throttle graph changed callback', () => {
const throttledFn = vi.fn()
vi.mocked(useThrottleFn).mockReturnValue(throttledFn)
const graphRef = ref(mockGraph as any)
const graphManager = useMinimapGraph(graphRef, onGraphChangedMock)
graphManager.setupEventListeners()
// Trigger multiple changes rapidly
mockGraph.onNodeAdded!({ id: '3' } as LGraphNode)
mockGraph.onNodeAdded!({ id: '4' } as LGraphNode)
mockGraph.onNodeAdded!({ id: '5' } as LGraphNode)
// Should be throttled
expect(throttledFn).toHaveBeenCalledTimes(3)
})
})

View File

@@ -0,0 +1,328 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { ref } from 'vue'
import { useMinimapInteraction } from '@/renderer/extensions/minimap/composables/useMinimapInteraction'
import type { MinimapCanvas } from '@/renderer/extensions/minimap/types'
describe('useMinimapInteraction', () => {
let mockContainer: HTMLDivElement
let mockCanvas: MinimapCanvas
let centerViewOnMock: ReturnType<typeof vi.fn>
beforeEach(() => {
vi.clearAllMocks()
mockContainer = {
getBoundingClientRect: vi.fn().mockReturnValue({
left: 100,
top: 50,
width: 250,
height: 200
})
} as any
mockCanvas = {
ds: {
scale: 1,
offset: [0, 0]
},
setDirty: vi.fn()
} as any
centerViewOnMock = vi.fn()
})
it('should initialize with default values', () => {
const containerRef = ref(mockContainer)
const boundsRef = ref({ minX: 0, minY: 0, width: 500, height: 400 })
const scaleRef = ref(0.5)
const canvasRef = ref(mockCanvas as any)
const interaction = useMinimapInteraction(
containerRef,
boundsRef,
scaleRef,
250,
200,
centerViewOnMock,
canvasRef
)
expect(interaction.isDragging.value).toBe(false)
expect(interaction.containerRect.value).toEqual({
left: 0,
top: 0,
width: 250,
height: 200
})
})
it('should update container rect', () => {
const containerRef = ref(mockContainer)
const boundsRef = ref({ minX: 0, minY: 0, width: 500, height: 400 })
const scaleRef = ref(0.5)
const canvasRef = ref(mockCanvas as any)
const interaction = useMinimapInteraction(
containerRef,
boundsRef,
scaleRef,
250,
200,
centerViewOnMock,
canvasRef
)
interaction.updateContainerRect()
expect(mockContainer.getBoundingClientRect).toHaveBeenCalled()
expect(interaction.containerRect.value).toEqual({
left: 100,
top: 50,
width: 250,
height: 200
})
})
it('should handle pointer down and start dragging', () => {
const containerRef = ref(mockContainer)
const boundsRef = ref({ minX: 0, minY: 0, width: 500, height: 400 })
const scaleRef = ref(0.5)
const canvasRef = ref(mockCanvas as any)
const interaction = useMinimapInteraction(
containerRef,
boundsRef,
scaleRef,
250,
200,
centerViewOnMock,
canvasRef
)
const event = new PointerEvent('pointerdown', {
clientX: 150,
clientY: 100
})
interaction.handlePointerDown(event)
expect(interaction.isDragging.value).toBe(true)
expect(mockContainer.getBoundingClientRect).toHaveBeenCalled()
expect(centerViewOnMock).toHaveBeenCalled()
})
it('should handle pointer move when dragging', () => {
const containerRef = ref(mockContainer)
const boundsRef = ref({ minX: 0, minY: 0, width: 500, height: 400 })
const scaleRef = ref(0.5)
const canvasRef = ref(mockCanvas as any)
const interaction = useMinimapInteraction(
containerRef,
boundsRef,
scaleRef,
250,
200,
centerViewOnMock,
canvasRef
)
// Start dragging
interaction.handlePointerDown(
new PointerEvent('pointerdown', {
clientX: 150,
clientY: 100
})
)
// Move pointer
const moveEvent = new PointerEvent('pointermove', {
clientX: 200,
clientY: 150
})
interaction.handlePointerMove(moveEvent)
// Should calculate world coordinates and center view
expect(centerViewOnMock).toHaveBeenCalledTimes(2) // Once on down, once on move
// Calculate expected world coordinates
const x = 200 - 100 // clientX - containerLeft
const y = 150 - 50 // clientY - containerTop
const offsetX = (250 - 500 * 0.5) / 2 // (width - bounds.width * scale) / 2
const offsetY = (200 - 400 * 0.5) / 2 // (height - bounds.height * scale) / 2
const worldX = (x - offsetX) / 0.5 + 0 // (x - offsetX) / scale + bounds.minX
const worldY = (y - offsetY) / 0.5 + 0 // (y - offsetY) / scale + bounds.minY
expect(centerViewOnMock).toHaveBeenLastCalledWith(worldX, worldY)
})
it('should not move when not dragging', () => {
const containerRef = ref(mockContainer)
const boundsRef = ref({ minX: 0, minY: 0, width: 500, height: 400 })
const scaleRef = ref(0.5)
const canvasRef = ref(mockCanvas as any)
const interaction = useMinimapInteraction(
containerRef,
boundsRef,
scaleRef,
250,
200,
centerViewOnMock,
canvasRef
)
const moveEvent = new PointerEvent('pointermove', {
clientX: 200,
clientY: 150
})
interaction.handlePointerMove(moveEvent)
expect(centerViewOnMock).not.toHaveBeenCalled()
})
it('should handle pointer up to stop dragging', () => {
const containerRef = ref(mockContainer)
const boundsRef = ref({ minX: 0, minY: 0, width: 500, height: 400 })
const scaleRef = ref(0.5)
const canvasRef = ref(mockCanvas as any)
const interaction = useMinimapInteraction(
containerRef,
boundsRef,
scaleRef,
250,
200,
centerViewOnMock,
canvasRef
)
// Start dragging
interaction.handlePointerDown(
new PointerEvent('pointerdown', {
clientX: 150,
clientY: 100
})
)
expect(interaction.isDragging.value).toBe(true)
interaction.handlePointerUp()
expect(interaction.isDragging.value).toBe(false)
})
it('should handle wheel events for zooming', () => {
const containerRef = ref(mockContainer)
const boundsRef = ref({ minX: 0, minY: 0, width: 500, height: 400 })
const scaleRef = ref(0.5)
const canvasRef = ref(mockCanvas as any)
const interaction = useMinimapInteraction(
containerRef,
boundsRef,
scaleRef,
250,
200,
centerViewOnMock,
canvasRef
)
const wheelEvent = new WheelEvent('wheel', {
deltaY: -100,
clientX: 200,
clientY: 150
})
wheelEvent.preventDefault = vi.fn()
interaction.handleWheel(wheelEvent)
// Should update canvas scale (zoom in)
expect(mockCanvas.ds.scale).toBeCloseTo(1.1)
expect(centerViewOnMock).toHaveBeenCalled()
})
it('should respect zoom limits', () => {
const containerRef = ref(mockContainer)
const boundsRef = ref({ minX: 0, minY: 0, width: 500, height: 400 })
const scaleRef = ref(0.5)
const canvasRef = ref(mockCanvas as any)
const interaction = useMinimapInteraction(
containerRef,
boundsRef,
scaleRef,
250,
200,
centerViewOnMock,
canvasRef
)
// Set scale close to minimum
mockCanvas.ds.scale = 0.11
const wheelEvent = new WheelEvent('wheel', {
deltaY: 100, // Zoom out
clientX: 200,
clientY: 150
})
wheelEvent.preventDefault = vi.fn()
interaction.handleWheel(wheelEvent)
// Should not go below minimum scale
expect(mockCanvas.ds.scale).toBe(0.11)
expect(centerViewOnMock).not.toHaveBeenCalled()
})
it('should handle null container gracefully', () => {
const containerRef = ref<HTMLDivElement | undefined>(undefined)
const boundsRef = ref({ minX: 0, minY: 0, width: 500, height: 400 })
const scaleRef = ref(0.5)
const canvasRef = ref(mockCanvas as any)
const interaction = useMinimapInteraction(
containerRef,
boundsRef,
scaleRef,
250,
200,
centerViewOnMock,
canvasRef
)
// Should not throw
expect(() => interaction.updateContainerRect()).not.toThrow()
expect(() =>
interaction.handlePointerDown(new PointerEvent('pointerdown'))
).not.toThrow()
})
it('should handle null canvas gracefully', () => {
const containerRef = ref(mockContainer)
const boundsRef = ref({ minX: 0, minY: 0, width: 500, height: 400 })
const scaleRef = ref(0.5)
const canvasRef = ref(null as any)
const interaction = useMinimapInteraction(
containerRef,
boundsRef,
scaleRef,
250,
200,
centerViewOnMock,
canvasRef
)
// Should not throw
expect(() =>
interaction.handlePointerMove(new PointerEvent('pointermove'))
).not.toThrow()
expect(() => interaction.handleWheel(new WheelEvent('wheel'))).not.toThrow()
expect(centerViewOnMock).not.toHaveBeenCalled()
})
})

View File

@@ -0,0 +1,267 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { ref } from 'vue'
import type { LGraph } from '@/lib/litegraph/src/litegraph'
import { useMinimapRenderer } from '@/renderer/extensions/minimap/composables/useMinimapRenderer'
import { renderMinimapToCanvas } from '@/renderer/extensions/minimap/minimapCanvasRenderer'
import type { UpdateFlags } from '@/renderer/extensions/minimap/types'
vi.mock('@/renderer/extensions/minimap/minimapCanvasRenderer', () => ({
renderMinimapToCanvas: vi.fn()
}))
describe('useMinimapRenderer', () => {
let mockCanvas: HTMLCanvasElement
let mockContext: CanvasRenderingContext2D
let mockGraph: LGraph
beforeEach(() => {
vi.clearAllMocks()
mockContext = {
clearRect: vi.fn()
} as any
mockCanvas = {
getContext: vi.fn().mockReturnValue(mockContext)
} as any
mockGraph = {
_nodes: [{ id: '1', pos: [0, 0], size: [100, 100] }]
} as any
})
it('should initialize with full redraw needed', () => {
const canvasRef = ref(mockCanvas)
const graphRef = ref(mockGraph as any)
const boundsRef = ref({ minX: 0, minY: 0, width: 100, height: 100 })
const scaleRef = ref(1)
const updateFlagsRef = ref<UpdateFlags>({
bounds: false,
nodes: false,
connections: false,
viewport: false
})
const settings = {
nodeColors: ref(true),
showLinks: ref(true),
showGroups: ref(true),
renderBypass: ref(false),
renderError: ref(false)
}
const renderer = useMinimapRenderer(
canvasRef,
graphRef,
boundsRef,
scaleRef,
updateFlagsRef,
settings,
250,
200
)
expect(renderer.needsFullRedraw.value).toBe(true)
expect(renderer.needsBoundsUpdate.value).toBe(true)
})
it('should handle empty graph with fast path', () => {
const emptyGraph = { _nodes: [] } as any
const canvasRef = ref(mockCanvas)
const graphRef = ref(emptyGraph)
const boundsRef = ref({ minX: 0, minY: 0, width: 100, height: 100 })
const scaleRef = ref(1)
const updateFlagsRef = ref<UpdateFlags>({
bounds: false,
nodes: false,
connections: false,
viewport: false
})
const settings = {
nodeColors: ref(true),
showLinks: ref(true),
showGroups: ref(true),
renderBypass: ref(false),
renderError: ref(false)
}
const renderer = useMinimapRenderer(
canvasRef,
graphRef,
boundsRef,
scaleRef,
updateFlagsRef,
settings,
250,
200
)
renderer.renderMinimap()
expect(mockContext.clearRect).toHaveBeenCalledWith(0, 0, 250, 200)
expect(vi.mocked(renderMinimapToCanvas)).not.toHaveBeenCalled()
})
it('should only render when redraw is needed', async () => {
const { renderMinimapToCanvas } = await import(
'@/renderer/extensions/minimap/minimapCanvasRenderer'
)
const canvasRef = ref(mockCanvas)
const graphRef = ref(mockGraph as any)
const boundsRef = ref({ minX: 0, minY: 0, width: 100, height: 100 })
const scaleRef = ref(1)
const updateFlagsRef = ref<UpdateFlags>({
bounds: false,
nodes: false,
connections: false,
viewport: false
})
const settings = {
nodeColors: ref(true),
showLinks: ref(true),
showGroups: ref(true),
renderBypass: ref(false),
renderError: ref(false)
}
const renderer = useMinimapRenderer(
canvasRef,
graphRef,
boundsRef,
scaleRef,
updateFlagsRef,
settings,
250,
200
)
// First render (needsFullRedraw is true by default)
renderer.renderMinimap()
expect(vi.mocked(renderMinimapToCanvas)).toHaveBeenCalledTimes(1)
// Second render without changes (should not render)
renderer.renderMinimap()
expect(vi.mocked(renderMinimapToCanvas)).toHaveBeenCalledTimes(1)
// Set update flag and render again
updateFlagsRef.value.nodes = true
renderer.renderMinimap()
expect(vi.mocked(renderMinimapToCanvas)).toHaveBeenCalledTimes(2)
})
it('should update minimap with bounds and viewport callbacks', () => {
const updateBounds = vi.fn()
const updateViewport = vi.fn()
const canvasRef = ref(mockCanvas)
const graphRef = ref(mockGraph as any)
const boundsRef = ref({ minX: 0, minY: 0, width: 100, height: 100 })
const scaleRef = ref(1)
const updateFlagsRef = ref<UpdateFlags>({
bounds: true,
nodes: false,
connections: false,
viewport: false
})
const settings = {
nodeColors: ref(true),
showLinks: ref(true),
showGroups: ref(true),
renderBypass: ref(false),
renderError: ref(false)
}
const renderer = useMinimapRenderer(
canvasRef,
graphRef,
boundsRef,
scaleRef,
updateFlagsRef,
settings,
250,
200
)
renderer.updateMinimap(updateBounds, updateViewport)
expect(updateBounds).toHaveBeenCalled()
expect(updateViewport).toHaveBeenCalled()
expect(updateFlagsRef.value.bounds).toBe(false)
expect(renderer.needsFullRedraw.value).toBe(false) // After rendering, needsFullRedraw is reset to false
expect(updateFlagsRef.value.viewport).toBe(false) // After updating viewport, this is reset to false
})
it('should force full redraw when requested', () => {
const canvasRef = ref(mockCanvas)
const graphRef = ref(mockGraph as any)
const boundsRef = ref({ minX: 0, minY: 0, width: 100, height: 100 })
const scaleRef = ref(1)
const updateFlagsRef = ref<UpdateFlags>({
bounds: false,
nodes: false,
connections: false,
viewport: false
})
const settings = {
nodeColors: ref(true),
showLinks: ref(true),
showGroups: ref(true),
renderBypass: ref(false),
renderError: ref(false)
}
const renderer = useMinimapRenderer(
canvasRef,
graphRef,
boundsRef,
scaleRef,
updateFlagsRef,
settings,
250,
200
)
renderer.forceFullRedraw()
expect(renderer.needsFullRedraw.value).toBe(true)
expect(updateFlagsRef.value.bounds).toBe(true)
expect(updateFlagsRef.value.nodes).toBe(true)
expect(updateFlagsRef.value.connections).toBe(true)
expect(updateFlagsRef.value.viewport).toBe(true)
})
it('should handle null canvas gracefully', () => {
const canvasRef = ref<HTMLCanvasElement | undefined>(undefined)
const graphRef = ref(mockGraph as any)
const boundsRef = ref({ minX: 0, minY: 0, width: 100, height: 100 })
const scaleRef = ref(1)
const updateFlagsRef = ref<UpdateFlags>({
bounds: false,
nodes: false,
connections: false,
viewport: false
})
const settings = {
nodeColors: ref(true),
showLinks: ref(true),
showGroups: ref(true),
renderBypass: ref(false),
renderError: ref(false)
}
const renderer = useMinimapRenderer(
canvasRef,
graphRef,
boundsRef,
scaleRef,
updateFlagsRef,
settings,
250,
200
)
// Should not throw
expect(() => renderer.renderMinimap()).not.toThrow()
expect(mockCanvas.getContext).not.toHaveBeenCalled()
})
})

View File

@@ -0,0 +1,122 @@
import { createPinia, setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { useMinimapSettings } from '@/renderer/extensions/minimap/composables/useMinimapSettings'
import { useSettingStore } from '@/stores/settingStore'
import { useColorPaletteStore } from '@/stores/workspace/colorPaletteStore'
vi.mock('@/stores/settingStore')
vi.mock('@/stores/workspace/colorPaletteStore')
describe('useMinimapSettings', () => {
beforeEach(() => {
setActivePinia(createPinia())
vi.clearAllMocks()
})
it('should return all minimap settings as computed refs', () => {
const mockSettingStore = {
get: vi.fn((key: string) => {
const settings: Record<string, any> = {
'Comfy.Minimap.NodeColors': true,
'Comfy.Minimap.ShowLinks': false,
'Comfy.Minimap.ShowGroups': true,
'Comfy.Minimap.RenderBypassState': false,
'Comfy.Minimap.RenderErrorState': true
}
return settings[key]
})
}
vi.mocked(useSettingStore).mockReturnValue(mockSettingStore as any)
vi.mocked(useColorPaletteStore).mockReturnValue({
completedActivePalette: { light_theme: false }
} as any)
const settings = useMinimapSettings()
expect(settings.nodeColors.value).toBe(true)
expect(settings.showLinks.value).toBe(false)
expect(settings.showGroups.value).toBe(true)
expect(settings.renderBypass.value).toBe(false)
expect(settings.renderError.value).toBe(true)
})
it('should generate container styles based on theme', () => {
const mockColorPaletteStore = {
completedActivePalette: { light_theme: false }
}
vi.mocked(useSettingStore).mockReturnValue({ get: vi.fn() } as any)
vi.mocked(useColorPaletteStore).mockReturnValue(
mockColorPaletteStore as any
)
const settings = useMinimapSettings()
const styles = settings.containerStyles.value
expect(styles.width).toBe('250px')
expect(styles.height).toBe('200px')
expect(styles.backgroundColor).toBe('#15161C') // dark theme color
expect(styles.border).toBe('1px solid #333')
})
it('should generate light theme container styles', () => {
const mockColorPaletteStore = {
completedActivePalette: { light_theme: true }
}
vi.mocked(useSettingStore).mockReturnValue({ get: vi.fn() } as any)
vi.mocked(useColorPaletteStore).mockReturnValue(
mockColorPaletteStore as any
)
const settings = useMinimapSettings()
const styles = settings.containerStyles.value
expect(styles.backgroundColor).toBe('#FAF9F5') // light theme color
expect(styles.border).toBe('1px solid #ccc')
})
it('should generate panel styles based on theme', () => {
const mockColorPaletteStore = {
completedActivePalette: { light_theme: false }
}
vi.mocked(useSettingStore).mockReturnValue({ get: vi.fn() } as any)
vi.mocked(useColorPaletteStore).mockReturnValue(
mockColorPaletteStore as any
)
const settings = useMinimapSettings()
const styles = settings.panelStyles.value
expect(styles.backgroundColor).toBe('#15161C')
expect(styles.border).toBe('1px solid #333')
expect(styles.borderRadius).toBe('8px')
})
it('should create computed properties that call the store getter', () => {
const mockGet = vi.fn((key: string) => {
if (key === 'Comfy.Minimap.NodeColors') return true
if (key === 'Comfy.Minimap.ShowLinks') return false
return true
})
const mockSettingStore = { get: mockGet }
vi.mocked(useSettingStore).mockReturnValue(mockSettingStore as any)
vi.mocked(useColorPaletteStore).mockReturnValue({
completedActivePalette: { light_theme: false }
} as any)
const settings = useMinimapSettings()
// Access the computed properties
expect(settings.nodeColors.value).toBe(true)
expect(settings.showLinks.value).toBe(false)
// Verify the store getter was called with the correct keys
expect(mockGet).toHaveBeenCalledWith('Comfy.Minimap.NodeColors')
expect(mockGet).toHaveBeenCalledWith('Comfy.Minimap.ShowLinks')
})
})

View File

@@ -0,0 +1,289 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { ref } from 'vue'
import { useCanvasTransformSync } from '@/composables/canvas/useCanvasTransformSync'
import type { LGraph } from '@/lib/litegraph/src/litegraph'
import { useMinimapViewport } from '@/renderer/extensions/minimap/composables/useMinimapViewport'
import type { MinimapCanvas } from '@/renderer/extensions/minimap/types'
vi.mock('@/composables/canvas/useCanvasTransformSync')
vi.mock('@/renderer/core/spatial/boundsCalculator', () => ({
calculateNodeBounds: vi.fn(),
calculateMinimapScale: vi.fn(),
enforceMinimumBounds: vi.fn()
}))
describe('useMinimapViewport', () => {
let mockCanvas: MinimapCanvas
let mockGraph: LGraph
beforeEach(() => {
vi.clearAllMocks()
mockCanvas = {
canvas: {
clientWidth: 800,
clientHeight: 600,
width: 1600,
height: 1200
} as HTMLCanvasElement,
ds: {
scale: 1,
offset: [0, 0]
},
setDirty: vi.fn()
}
mockGraph = {
_nodes: [
{ pos: [100, 100], size: [150, 80] },
{ pos: [300, 200], size: [120, 60] }
]
} as any
vi.mocked(useCanvasTransformSync).mockReturnValue({
startSync: vi.fn(),
stopSync: vi.fn()
} as any)
})
it('should initialize with default bounds', () => {
const canvasRef = ref(mockCanvas as any)
const graphRef = ref(mockGraph as any)
const viewport = useMinimapViewport(canvasRef, graphRef, 250, 200)
expect(viewport.bounds.value).toEqual({
minX: 0,
minY: 0,
maxX: 0,
maxY: 0,
width: 0,
height: 0
})
expect(viewport.scale.value).toBe(1)
})
it('should calculate graph bounds from nodes', async () => {
const { calculateNodeBounds, enforceMinimumBounds } = await import(
'@/renderer/core/spatial/boundsCalculator'
)
vi.mocked(calculateNodeBounds).mockReturnValue({
minX: 100,
minY: 100,
maxX: 420,
maxY: 260,
width: 320,
height: 160
})
vi.mocked(enforceMinimumBounds).mockImplementation((bounds) => bounds)
const canvasRef = ref(mockCanvas as any)
const graphRef = ref(mockGraph as any)
const viewport = useMinimapViewport(canvasRef, graphRef, 250, 200)
viewport.updateBounds()
expect(calculateNodeBounds).toHaveBeenCalledWith(mockGraph._nodes)
expect(enforceMinimumBounds).toHaveBeenCalled()
})
it('should handle empty graph', async () => {
const { calculateNodeBounds } = await import(
'@/renderer/core/spatial/boundsCalculator'
)
vi.mocked(calculateNodeBounds).mockReturnValue(null)
const canvasRef = ref(mockCanvas as any)
const graphRef = ref({ _nodes: [] } as any)
const viewport = useMinimapViewport(canvasRef, graphRef, 250, 200)
viewport.updateBounds()
expect(viewport.bounds.value).toEqual({
minX: 0,
minY: 0,
maxX: 100,
maxY: 100,
width: 100,
height: 100
})
})
it('should update canvas dimensions', () => {
const canvasRef = ref(mockCanvas as any)
const graphRef = ref(mockGraph as any)
const viewport = useMinimapViewport(canvasRef, graphRef, 250, 200)
viewport.updateCanvasDimensions()
expect(viewport.canvasDimensions.value).toEqual({
width: 800,
height: 600
})
})
it('should calculate viewport transform', async () => {
const { calculateNodeBounds, enforceMinimumBounds, calculateMinimapScale } =
await import('@/renderer/core/spatial/boundsCalculator')
// Mock the bounds calculation
vi.mocked(calculateNodeBounds).mockReturnValue({
minX: 0,
minY: 0,
maxX: 500,
maxY: 400,
width: 500,
height: 400
})
vi.mocked(enforceMinimumBounds).mockImplementation((bounds) => bounds)
vi.mocked(calculateMinimapScale).mockReturnValue(0.5)
const canvasRef = ref(mockCanvas as any)
const graphRef = ref(mockGraph as any)
const viewport = useMinimapViewport(canvasRef, graphRef, 250, 200)
// Set canvas transform
mockCanvas.ds.scale = 2
mockCanvas.ds.offset = [-100, -50]
// Update bounds and viewport
viewport.updateBounds()
viewport.updateCanvasDimensions()
viewport.updateViewport()
const transform = viewport.viewportTransform.value
// World coordinates
const worldX = -(-100) // -offset[0] = 100
const worldY = -(-50) // -offset[1] = 50
// Viewport size in world coordinates
const viewportWidth = 800 / 2 // canvasWidth / scale = 400
const viewportHeight = 600 / 2 // canvasHeight / scale = 300
// Center offsets
const centerOffsetX = (250 - 500 * 0.5) / 2 // (250 - 250) / 2 = 0
const centerOffsetY = (200 - 400 * 0.5) / 2 // (200 - 200) / 2 = 0
// Expected values based on implementation: (worldX - bounds.minX) * scale + centerOffsetX
expect(transform.x).toBeCloseTo((worldX - 0) * 0.5 + centerOffsetX) // (100 - 0) * 0.5 + 0 = 50
expect(transform.y).toBeCloseTo((worldY - 0) * 0.5 + centerOffsetY) // (50 - 0) * 0.5 + 0 = 25
expect(transform.width).toBeCloseTo(viewportWidth * 0.5) // 400 * 0.5 = 200
expect(transform.height).toBeCloseTo(viewportHeight * 0.5) // 300 * 0.5 = 150
})
it('should center view on world coordinates', () => {
const canvasRef = ref(mockCanvas as any)
const graphRef = ref(mockGraph as any)
const viewport = useMinimapViewport(canvasRef, graphRef, 250, 200)
viewport.updateCanvasDimensions()
mockCanvas.ds.scale = 2
viewport.centerViewOn(300, 200)
// Should update canvas offset to center on the given world coordinates
const expectedOffsetX = -(300 - 800 / 2 / 2) // -(worldX - viewportWidth/2)
const expectedOffsetY = -(200 - 600 / 2 / 2) // -(worldY - viewportHeight/2)
expect(mockCanvas.ds.offset[0]).toBe(expectedOffsetX)
expect(mockCanvas.ds.offset[1]).toBe(expectedOffsetY)
expect(mockCanvas.setDirty).toHaveBeenCalledWith(true, true)
})
it('should start and stop viewport sync', () => {
const startSyncMock = vi.fn()
const stopSyncMock = vi.fn()
vi.mocked(useCanvasTransformSync).mockReturnValue({
startSync: startSyncMock,
stopSync: stopSyncMock
} as any)
const canvasRef = ref(mockCanvas as any)
const graphRef = ref(mockGraph as any)
const viewport = useMinimapViewport(canvasRef, graphRef, 250, 200)
viewport.startViewportSync()
expect(startSyncMock).toHaveBeenCalled()
viewport.stopViewportSync()
expect(stopSyncMock).toHaveBeenCalled()
})
it('should handle null canvas gracefully', () => {
const canvasRef = ref(null as any)
const graphRef = ref(mockGraph as any)
const viewport = useMinimapViewport(canvasRef, graphRef, 250, 200)
// Should not throw
expect(() => viewport.updateCanvasDimensions()).not.toThrow()
expect(() => viewport.updateViewport()).not.toThrow()
expect(() => viewport.centerViewOn(100, 100)).not.toThrow()
})
it('should calculate scale correctly', async () => {
const { calculateMinimapScale, calculateNodeBounds, enforceMinimumBounds } =
await import('@/renderer/core/spatial/boundsCalculator')
const testBounds = {
minX: 0,
minY: 0,
maxX: 500,
maxY: 400,
width: 500,
height: 400
}
vi.mocked(calculateNodeBounds).mockReturnValue(testBounds)
vi.mocked(enforceMinimumBounds).mockImplementation((bounds) => bounds)
vi.mocked(calculateMinimapScale).mockReturnValue(0.4)
const canvasRef = ref(mockCanvas as any)
const graphRef = ref(mockGraph as any)
const viewport = useMinimapViewport(canvasRef, graphRef, 250, 200)
viewport.updateBounds()
expect(calculateMinimapScale).toHaveBeenCalledWith(testBounds, 250, 200)
expect(viewport.scale.value).toBe(0.4)
})
it('should handle device pixel ratio', () => {
const originalDPR = window.devicePixelRatio
Object.defineProperty(window, 'devicePixelRatio', {
value: 2,
configurable: true
})
const canvasRef = ref(mockCanvas as any)
const graphRef = ref(mockGraph as any)
const viewport = useMinimapViewport(canvasRef, graphRef, 250, 200)
viewport.updateCanvasDimensions()
// Should use client dimensions or calculate from canvas dimensions / dpr
expect(viewport.canvasDimensions.value.width).toBe(800)
expect(viewport.canvasDimensions.value.height).toBe(600)
Object.defineProperty(window, 'devicePixelRatio', {
value: originalDPR,
configurable: true
})
})
})